diff --git a/modules/messenger/README.md b/modules/messenger/README.md index 7a70a347..10b86afd 100644 --- a/modules/messenger/README.md +++ b/modules/messenger/README.md @@ -49,8 +49,9 @@ channel's client and no channel's secret. warrant published to the asker alone. A warrant the bus did not take is published again every minute. - **What the operator sees when they answer** (issue 372): first, in reply to the answer where it was given, that it was recorded and whether its asker was told ("You chose Approve: recorded, and claude-code was - told.") — said only once the ask's end is recorded — or, refused, why; then the ask's message edited to how - it ended, as its title ("You chose Approve at 13:30 on Telegram · "), with no answers left on it. + told.") — said only once the ask's end is recorded — or, refused, why; then the ask's message edited with no + answers left on it: its question whole (headline, summary, points, link, Details), and below it how it ended + ("You chose Approve at 13:30 on Telegram.") and who acts on it (issue 714). What the asker then did is not said: the asks protocol carries no result back to the router, so the message says only that the asker was told and acts on it itself. - **An ask never defaults**: at its expiry it ends `expired` and its asker does what it said it would. @@ -138,8 +139,9 @@ and said in the log). Everything else is the message's note (`asks.Message.Note` smaller: who asked and when, with what happens if nobody answers; what each answer does; where else it is answered; and the asker's Details (who proposed it, a fingerprint, how to read it whole) — on a kind that proves who answers and was given the ask's answers, its Details whole (`asks.Ask.WholeDetails`): every value an approval sets, held to -the secret rule alone, as the whole words are. Answered, the message is one line — -"You chose Approve at 16:06 on Telegram · " — and the router's word on the asker is its note. +the secret rule alone, as the whole words are. Answered, the message keeps its headline, body and Details, so the +operator can read back what they answered (issue 714); its note becomes "You chose Approve at 16:06 on Telegram." +with the router's word on the asker. **An ask's values are shown whole where the sender is proven** (hq issue 383). An ask may carry its explanation twice: as it is held to the whole content rule, and whole (`asks.Ask.Whole`), its paths and addresses as they diff --git a/modules/messenger/cmd/messenger/answers_test.go b/modules/messenger/cmd/messenger/answers_test.go index 828629cc..3410abb8 100644 --- a/modules/messenger/cmd/messenger/answers_test.go +++ b/modules/messenger/cmd/messenger/answers_test.go @@ -365,10 +365,12 @@ func TestAnApproveFromTheLinkedAccountIsAWarrantForTheAskerAlone(t *testing.T) { for _, m := range ch.edits { edited = m } - // The question's title is how it ended, read at a glance; then what the router knows of the asker (issue 372). - if len(edited.Actions) != 0 || edited.Title != "You chose Release at 12:00 on Telegram · Delivery of hq held for 36 hours" || - edited.Body != "" || edited.Note != "Told to mesh-delivery, which acts on it itself." { - t.Errorf("%s still offers %q, or does not say how it ended in one line (issue 639): %q / %q / %q", ch.name, labelsOf(edited), edited.Title, edited.Body, edited.Note) + // The question stays whole (issue 714); below it, how it ended and what the router knows of the asker + // (issue 372). + if len(edited.Actions) != 0 || edited.Title != "Delivery of hq held for 36 hours" || + edited.Body != heldAsk(c, "d-1").Explanation || + edited.Note != "You chose Release at 12:00 on Telegram.\nTold to mesh-delivery, which acts on it itself." { + t.Errorf("%s still offers %q, or does not keep the question and say how it ended (issue 714): %q / %q / %q", ch.name, labelsOf(edited), edited.Title, edited.Body, edited.Note) } } // The tap is confirmed where it was made, in reply to it, once the warrant stood — and before the question @@ -624,8 +626,8 @@ func TestAnAskExpiresAndNeverDefaults(t *testing.T) { for _, m := range tg.edits { edited = m } - if len(edited.Actions) != 0 || !strings.HasPrefix(edited.Title, "No longer answerable: nobody answered in time · Delivery of hq") || - edited.Body != "" || edited.Note != "The delivery stays held." { + if len(edited.Actions) != 0 || !strings.HasPrefix(edited.Title, "Delivery of hq") || + edited.Body == "" || edited.Note != "No longer answerable: nobody answered in time.\nThe delivery stays held." { t.Errorf("the copy still offers answers, or does not say how it ended: %+v", edited) } } diff --git a/modules/messenger/cmd/messenger/feedback_test.go b/modules/messenger/cmd/messenger/feedback_test.go index fc7c8859..e3657c77 100644 --- a/modules/messenger/cmd/messenger/feedback_test.go +++ b/modules/messenger/cmd/messenger/feedback_test.go @@ -51,7 +51,7 @@ func TestAnApprovalIsConfirmedWhereItWasGivenAndThenTheQuestionSaysHowItEnded(t } want := []string{ "send You chose Release: recorded, and mesh-delivery was told.", - "edit You chose Release at 12:00 on Telegram · Delivery of hq held for 36 hours", + "edit Delivery of hq held for 36 hours", } if strings.Join(log, "\n") != strings.Join(want, "\n") { t.Fatalf("the operator saw, in order:\n%s\nwant:\n%s", strings.Join(log, "\n"), strings.Join(want, "\n")) @@ -122,7 +122,7 @@ func TestTheConfirmationNeverClaimsWhatDidNotHappen(t *testing.T) { for _, m := range tg.edits { edited = m } - if !strings.HasPrefix(edited.Note, "Told to mesh-delivery as soon as the bus takes it") { + if !strings.Contains(edited.Note, "\nTold to mesh-delivery as soon as the bus takes it") { t.Errorf("the question claims its asker was told: %q", edited.Note) } }) diff --git a/modules/messenger/cmd/messenger/glance_test.go b/modules/messenger/cmd/messenger/glance_test.go index 669f38a3..223caeb9 100644 --- a/modules/messenger/cmd/messenger/glance_test.go +++ b/modules/messenger/cmd/messenger/glance_test.go @@ -9,8 +9,8 @@ import ( ) // novox/hq issue 639: an ask's message reads at a glance on the phone. Its body is the asker's summary and points -// alone; who asked and when, what each answer does, the expiry and the asker's Details are its note; once answered -// it is one line with the headline. +// alone; who asked and when, what each answer does, the expiry and the asker's Details are its note. Once answered +// it keeps the question whole, so the operator can read back what they answered (issue 714). // decisionAsk is the ask for ADR 0343 as the knowledge base composes it now (its glance form). func decisionAsk(c *clock) asks.Ask { @@ -59,7 +59,8 @@ func TestAnAsksMessageIsItsSummaryWithTheRestInItsNote(t *testing.T) { if n := len(strings.Split(m.Text(), "\n")); n > 9 || len(m.Actions) != 2 { t.Errorf("the message is %d lines offering %s:\n%s", n, labelsOf(m), m.Text()) } - // Answered, it is one line with the headline; the body is not said again. + // Answered, it keeps its headline, summary, points and Details; below them, how it ended and who acts on it; + // its buttons are gone (issue 714: the one line issue 639 made left nothing to read back). if out, err := h.Chosen("telegram", choiceOf(ticketFor(m, "Approve"), "42", c.now())); err != nil || out != "chosen" { t.Fatalf("Approve: %q %v", out, err) } @@ -67,8 +68,9 @@ func TestAnAsksMessageIsItsSummaryWithTheRestInItsNote(t *testing.T) { for _, e := range tg.edits { edited = e } - if edited.Title != "You chose Approve at "+asked+" on Telegram · "+a.Headline || edited.Body != "" || - strings.Contains(edited.Note, "What an agent learns") || len(edited.Actions) != 0 { + if edited.Title != a.Headline || edited.Body != a.Explanation+"\n"+want[2]+"\n"+want[3] || + edited.Note != "You chose Approve at "+asked+" on Telegram.\nTold to mesh-knowledge, which acts on it itself." || + len(edited.Actions) != 0 { t.Errorf("the answered copy: %q\n%q\n%q", edited.Title, edited.Body, edited.Note) } } diff --git a/modules/messenger/cmd/messenger/holder.go b/modules/messenger/cmd/messenger/holder.go index 67310e9a..2c237bb4 100644 --- a/modules/messenger/cmd/messenger/holder.go +++ b/modules/messenger/cmd/messenger/holder.go @@ -856,7 +856,7 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message { } } if r.Answered != "" { - m = h.answeredWords(r, m) + m = h.answeredWords(r, m, kind) } if kind == "" { return m @@ -910,18 +910,24 @@ func (h *Holder) sayOn(r *Record, what, kind string) Message { return m } -// answeredWords is a message that says what came of its question. An ask's own message is the question -// itself, so it becomes how it ended and what it was about — "You chose Approve at 13:30 on Telegram · -// " — one line, read at a glance on the phone, where the edit rings nothing (issue 372); the body -// is not said again (issue 639), and what the router knows of the asker after it is the note. A condition's -// message keeps its own title, and says it first. -func (h *Holder) answeredWords(r *Record, m Message) Message { +// answeredWords is a message that says what came of its question. An ask's own message keeps the question +// whole — its headline as the title, the asker's summary, points and link as the body, its Details after them — +// so the operator can read back what they answered (issue 714, which undid issue 639's one line). How it ended +// ("You chose Approve at 13:30 on Telegram.") and what the router knows of the asker after it are the note, +// below the question, and its answers are gone (issue 372). A channel that must shorten it cuts the body from +// its end, the Details first; never the headline or the note. A condition's message keeps its own title, and +// says how it ended first. +func (h *Holder) answeredWords(r *Record, m Message, kind string) Message { if r.Origin == "ask" { - first, rest, _ := strings.Cut(r.Answered, "\n") headline, _ := wordsOf(r) - out := Message{Title: strings.TrimSuffix(first, ".") + " · " + headline, Note: strings.TrimSpace(rest), + body := []string{} + if b := strings.TrimSpace(m.Body); b != "" { + body = append(body, b) + } + body = append(body, h.answeredDetails(r, kind)...) + out := Message{Title: headline, Body: strings.Join(body, "\n"), Note: strings.TrimSpace(r.Answered), Urgent: m.Urgent, Quiet: m.Quiet} - if _, ok := Check(out.Text()); ok { + if _, ok := Check(out.Title + "\n" + out.Note); ok { return out } // Its headline refused (it is the asker's): the outcome is still said, before what may leave. @@ -930,6 +936,33 @@ func (h *Holder) answeredWords(r *Record, m Message) Message { return m } +// answeredDetails are an answered ask's Details, line by line, as its open message noted them (issue 714): whole +// on a kind that proves who answers, a line that may not leave withheld and said so. +func (h *Holder) answeredDetails(r *Record, kind string) []string { + if r.Ask == "" { + return nil + } + e := h.askOf(r.Ask) + if e == nil { + return nil + } + details, passes := e.rec.Ask.Details, Check + if wd := h.wholeDetailsOf(r, kind); wd != "" { + details, passes = wd, CheckSecret + } + var lines []string + for _, line := range strings.Split(strings.TrimSpace(details), "\n") { + if line = strings.TrimSpace(line); line == "" { + continue + } + if refusal, ok := passes(line); !ok { + line = withheldLine(line, refusal, passes) + } + lines = append(lines, line) + } + return lines +} + // wholeWords is a record's message with the ask's whole words in place of its explanation, when they carry no // secret's shape once composed; else false, and the message under the whole content rule stands. func (h *Holder) wholeWords(r *Record, what, whole string) (Message, bool) { diff --git a/modules/messenger/cmd/messenger/preview.go b/modules/messenger/cmd/messenger/preview.go index caa40e94..dc73f910 100644 --- a/modules/messenger/cmd/messenger/preview.go +++ b/modules/messenger/cmd/messenger/preview.go @@ -91,7 +91,7 @@ func (h *Holder) Preview(key, what string) (map[string]any, error) { asking := h.openAsk(&r) != nil // askOf takes the same lock, so this is asked outside it m, line, refusal, ownWords := h.allowedWords(&r, what, now) if r.Answered != "" { - m = h.answeredWords(&r, m) + m = h.answeredWords(&r, m, "") } out := map[string]any{ "key": key, diff --git a/modules/messenger/cmd/messenger/replaced_test.go b/modules/messenger/cmd/messenger/replaced_test.go index 100e0b80..467a9c0c 100644 --- a/modules/messenger/cmd/messenger/replaced_test.go +++ b/modules/messenger/cmd/messenger/replaced_test.go @@ -50,7 +50,7 @@ func TestAReplacedAsksMessageEndsWithIt(t *testing.T) { m, edited = r.m, true } } - if !edited || len(m.Actions) != 0 || !strings.Contains(m.Title, "Replaced by a newer question") { + if !edited || len(m.Actions) != 0 || !strings.HasPrefix(m.Note, "Replaced by a newer question") { t.Errorf("%s: the replaced ask's message still stands: edited %v, %q %v", ch.name, edited, m.Text(), labelsOf(m)) } } @@ -86,7 +86,7 @@ func TestAnAsksMessageLeftOpenAfterItsAskEndedIsEndedOnTheNextTick(t *testing.T) if got := openAskRecords(h); len(got) != 0 { t.Fatalf("the message of an ended ask is still open: %v", got) } - if m, ok := tg.edits["1"]; !ok || len(m.Actions) != 0 || !strings.Contains(m.Title, "Replaced by a newer question") { + if m, ok := tg.edits["1"]; !ok || len(m.Actions) != 0 || !strings.HasPrefix(m.Note, "Replaced by a newer question") { t.Errorf("the leftover's copy was not ended: %v %q %v", ok, m.Text(), labelsOf(m)) } } diff --git a/modules/messenger/cmd/messenger/whole_test.go b/modules/messenger/cmd/messenger/whole_test.go index 93b3e4d3..e696f832 100644 --- a/modules/messenger/cmd/messenger/whole_test.go +++ b/modules/messenger/cmd/messenger/whole_test.go @@ -61,8 +61,7 @@ func TestAnAsksWholeWordsAreShownWhereTheSenderIsProvenAndMaskedElsewhere(t *tes if got := w.of("mesh-controller"); len(got) != 0 || store.asks["mesh-controller.s-1"].State != StateOpen { t.Errorf("the ask was answered: %+v / %s", got, store.asks["mesh-controller.s-1"].State) } - // Approved, the message is edited to one line that says so, with the headline; the body is not said again - // (issue 639). + // Approved, the message keeps its headline and words, and says below them how it ended (issue 714). if out, err := h.Chosen("telegram", choiceOf(ticketFor(phone, "Release"), "42", c.now())); err != nil || out != "chosen" { t.Fatalf("Release: %q %v", out, err) } @@ -70,8 +69,8 @@ func TestAnAsksWholeWordsAreShownWhereTheSenderIsProvenAndMaskedElsewhere(t *tes if !ok { t.Fatalf("the phone's message was not edited: %+v", tg.edits) } - if !strings.HasPrefix(edited.Title, "You chose Release at ") || !strings.HasSuffix(edited.Title, " · Set mounts on shanks?") || - edited.Body != "" || len(edited.Actions) != 0 { + if edited.Title != "Set mounts on shanks?" || edited.Body == "" || + !strings.HasPrefix(edited.Note, "You chose Release at ") || len(edited.Actions) != 0 { t.Errorf("the edited message: %q\n%s\nactions %+v", edited.Title, edited.Body, edited.Actions) } } diff --git a/modules/telegram/cmd/telegram/render.go b/modules/telegram/cmd/telegram/render.go index 9bdd257f..81c22782 100644 --- a/modules/telegram/cmd/telegram/render.go +++ b/modules/telegram/cmd/telegram/render.go @@ -4,10 +4,11 @@ package main // formatting", "too much text, no formatting, no summary"). The router's words are shown as given — // `exact-render` — and formatted, never reworded: // -// - the title in bold: the headline, or how an answered ask ended; +// - the title in bold: the headline, an answered ask's too (issue 714); // - the body as it is, a line that is a key with its value (as a settings proposal lists them, "+ key: value") // in monospace; -// - the note — who asked, what each answer does, the expiry, the asker's details — apart, after a blank line, +// - the note — who asked, what each answer does, the expiry, the asker's details; once answered, how it ended +// and who acts on it — apart, after a blank line, // in a blockquote Telegram shows collapsed to its first lines, expanded on a tap. // // Every character Telegram's HTML would read as markup is escaped first, so a message carrying '<' neither diff --git a/modules/telegram/cmd/telegram/render_test.go b/modules/telegram/cmd/telegram/render_test.go index a2658313..963136d8 100644 --- a/modules/telegram/cmd/telegram/render_test.go +++ b/modules/telegram/cmd/telegram/render_test.go @@ -84,3 +84,20 @@ func TestTheNoteIsEscaped(t *testing.T) { t.Errorf("%q", got) } } + +// novox/hq issue 714: an answered ask keeps its question whole, with how it ended below it. Over Telegram's bound, +// the end of the body (the asker's Details) is cut; the headline and the answer are never. +func TestAnAnsweredAskOverTheBoundKeepsItsHeadlineAndItsAnswer(t *testing.T) { + answer := "You chose Approve at 20:03 on Telegram.\nTold to mesh-knowledge, which acts on it itself." + m := asks.Message{Handle: "h-714", To: "42", Title: "Fixes reach every machine at once, unchecked", + Body: "A fix goes to every machine in one step.\n• No machine goes first\n" + strings.Repeat("Details line. ", 400), + Note: answer} + s := partsOf(m) + if n := len([]rune(s.plain())); n > MaxLength { + t.Fatalf("the answered ask is %d characters, over %d", n, MaxLength) + } + if s.title != m.Title || s.note != answer || !strings.HasPrefix(s.body, "A fix goes to every machine in one step.\n• No machine goes first") || + !strings.HasSuffix(s.body, cutNote) { + t.Errorf("the headline, the summary or the answer was cut:\n%s", s.plain()) + } +}