From b7d9590fdc95d5ed000325bcc2a88133f5df448c Mon Sep 17 00:00:00 2001 From: jochen Date: Wed, 14 Oct 2026 20:06:24 +0200 Subject: [PATCH] An answered ask keeps its question whole, its answer below it (issue 714) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Issue 372's fix with issue 639's one line edited an answered ask down to 'You chose X at HH:MM · headline', so the operator could not read back what they approved. The headline stays the title, the summary, points, link and Details stay the body, and how it ended and who acts on it become the note. Telegram cuts the body from its end past 4096 characters, never the title or the note. --- modules/messenger/README.md | 10 ++-- .../messenger/cmd/messenger/answers_test.go | 14 ++--- .../messenger/cmd/messenger/feedback_test.go | 4 +- .../messenger/cmd/messenger/glance_test.go | 12 +++-- modules/messenger/cmd/messenger/holder.go | 53 +++++++++++++++---- modules/messenger/cmd/messenger/preview.go | 2 +- .../messenger/cmd/messenger/replaced_test.go | 4 +- modules/messenger/cmd/messenger/whole_test.go | 7 ++- modules/telegram/cmd/telegram/render.go | 5 +- modules/telegram/cmd/telegram/render_test.go | 17 ++++++ 10 files changed, 92 insertions(+), 36 deletions(-) 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()) + } +}