diff --git a/modules/messenger/cmd/messenger/answers_test.go b/modules/messenger/cmd/messenger/answers_test.go index cdffa473..808d22b2 100644 --- a/modules/messenger/cmd/messenger/answers_test.go +++ b/modules/messenger/cmd/messenger/answers_test.go @@ -161,7 +161,11 @@ func (m *memAsks) PutLinksAsked(key string, times []time.Time) error { var rootFreeOf = map[*Holder]map[string]string{} // told is what the router published to askers, by subject. -type told struct{ by map[string][]asks.Warrant } +type told struct { + by map[string][]asks.Warrant + // asked are the asks the router said it took, by subject (sayAsked). + asked map[string][]map[string]any +} func (w *told) of(asker string) []asks.Warrant { return w.by[asks.DecidedSubject(asker)] } @@ -171,7 +175,7 @@ func newRouter(t *testing.T) (*Holder, *fakeChannel, *fakeChannel, *clock, *memA t.Helper() h, tg, dt, c, _ := newHolder(t) store := newMemAsks() - w := &told{by: map[string][]asks.Warrant{}} + w := &told{by: map[string][]asks.Warrant{}, asked: map[string][]map[string]any{}} bench := NewBench(c.now) bench.SetKinds([]KindInfo{ {Kind: "telegram", Module: "telegram", Node: "anchor", Capabilities: []string{"deliver", "reaches-away", "choice", "edit", @@ -204,6 +208,9 @@ func newRouter(t *testing.T) (*Holder, *fakeChannel, *fakeChannel, *clock, *memA if wt, ok := body.(asks.Warrant); ok { w.by[subject] = append(w.by[subject], wt) } + if m, ok := body.(map[string]any); ok && strings.HasPrefix(subject, "mesh.seat."+asks.Seat+".event."+AskedEvent+".") { + w.asked[subject] = append(w.asked[subject], m) + } return nil } return h, tg, dt, c, store, w @@ -325,6 +332,10 @@ func TestWithoutALinkedAccountOnlyTheMeshMCPServerApproves(t *testing.T) { !strings.Contains(rec.HeldWhy, "no Telegram account is linked") { t.Fatalf("not held: %+v %+v", rec, w.of("mesh-delivery")) } + // Held or shown, the ask is said taken on the bus for its asker, so a session hears it open (2026-10-30). + if said := w.asked[askedSubject("mesh-delivery")]; len(said) != 1 || said[0]["ask"] != "d-1" || said[0]["state"] != StateHeld { + t.Fatalf("the ask taken is not said on %s: %+v", askedSubject("mesh-delivery"), said) + } if len(dt.sends) != 0 { t.Errorf("the desk was offered an ask with an approval: %+v", dt.sends) } diff --git a/modules/messenger/cmd/messenger/asks.go b/modules/messenger/cmd/messenger/asks.go index 08a53a95..79031de1 100644 --- a/modules/messenger/cmd/messenger/asks.go +++ b/modules/messenger/cmd/messenger/asks.go @@ -560,6 +560,7 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error { } else { h.keepAsk(key, rec, 0) } + h.sayAsked(asker, a, rec) if rec.State == StateHeld { h.Logf("[messenger] %s asks %q (%s): held, not shown: %s", asker, a.Headline, a.ID, rec.HeldWhy) return nil @@ -568,6 +569,31 @@ func (h *Holder) TakeAsk(verb, asker string, body json.RawMessage) error { return nil } +// AskedEvent is the seat's event that an ask was taken, said for its asker as decided is (ADR 0259 ยง3): a session +// waits on `operator-channel.asked.` to hear an ask open, and on `operator-channel.decided.` to hear +// its answer (the operator, 2026-10-30: "you should be able to hook into approval requests so you also get a +// notification"). The SDK names decided's subject; asked's is named here until the SDK's asks contract carries it. +const AskedEvent = "asked" + +// askedSubject is where the router says an ask of an asker was taken. +func askedSubject(asker string) string { + return "mesh.seat." + asks.Seat + ".event." + AskedEvent + "." + asker +} + +// sayAsked says on the bus that an ask was taken, open or held: its id, asker, what it is about, its headline (the +// words the operator's phone shows, already judged to carry nothing that may not leave the mesh) and when it ends. +// Never its options' bindings or its whole words. A failure is said in the log and holds nothing: the ask stands. +func (h *Holder) sayAsked(asker string, a asks.Ask, rec AskRecord) { + if h.Publish == nil { + return + } + body := map[string]any{"ask": a.ID, "asker": asker, "about": a.About, "headline": a.Headline, "state": rec.State, + "opened": rec.Opened.UTC(), "expires": a.Expires} + if err := h.Publish(askedSubject(asker), body, "asked."+ticket()); err != nil { + h.Logf("[messenger] the ask %s of %s is taken and could not be said on the bus: %v", a.ID, asker, err) + } +} + // ticketsFor is every option's ticket on every kind that can carry the ask's highest level now: an ask with an // approval is answered only where it could be approved (answerLevel). None when no ready channel can. func (h *Holder) ticketsFor(a asks.Ask) map[string]Ticket { diff --git a/modules/messenger/cmd/messenger/manifest_test.go b/modules/messenger/cmd/messenger/manifest_test.go index 2281501b..6e926a9a 100644 --- a/modules/messenger/cmd/messenger/manifest_test.go +++ b/modules/messenger/cmd/messenger/manifest_test.go @@ -64,8 +64,8 @@ func TestItDeclaresTheSeatsOfAnAskAndHoldsTheOperatorChannel(t *testing.T) { // m, _ := readManifest(t) verbs := []string{"open", "history", "notify", "tell", "asks"} want := []seat{ - {Name: asks.Seat, Scope: "mesh", Serves: verbs, Accepts: []string{"ask", "cancel", "condition"}, Emits: []string{"decided"}, - ByCaller: []string{"ask", "cancel", "condition", "decided"}, Records: []string{"asks"}}, + {Name: asks.Seat, Scope: "mesh", Serves: verbs, Accepts: []string{"ask", "cancel", "condition"}, Emits: []string{"decided", "asked"}, + ByCaller: []string{"ask", "cancel", "condition", "decided", "asked"}, Records: []string{"asks"}}, {Name: asks.ChannelSeat, Scope: "mesh", Kinded: true, Accepts: []string{asks.Show, asks.Edit, asks.Send}}, {Name: asks.IntakeSeat, Scope: "mesh", Kinded: true, Emits: []string{asks.Chosen, asks.Link, asks.FailedWhat, asks.StandingWhat}, Proofs: []string{asks.CodeProof}}, diff --git a/modules/messenger/module.json b/modules/messenger/module.json index 4aceaebe..85c91b26 100644 --- a/modules/messenger/module.json +++ b/modules/messenger/module.json @@ -20,13 +20,15 @@ "condition" ], "emits": [ - "decided" + "decided", + "asked" ], "by-caller": [ "ask", "cancel", "condition", - "decided" + "decided", + "asked" ], "records": [ "asks"