feat(threads): a thread can carry one living answer instead of a run #1138

Merged
coilyco-ops merged 1 commit from aos/claude/edit-reply-951 into main 2026-08-23 02:08:29 +00:00
Member

Kai chose this shape on the issue: a turn ends by editing this service's own earlier reply in the thread, rather than posting another.

The literal title is not buildable, and that is worth recording

threadForReply opens every thread this service creates with MessageThreadStartComplex(message.ChannelID, message.ID, ...), from the member's message. So a thread's starter belongs to the member, and Discord's edit endpoint only edits your own content. "Editing the message at the top of a thread" is an API constraint rather than a harness gap.

What this edits is the first message this service posted there, which is the top of the conversation it owns.

The mechanism already existed

jobdiscord.go has edited its own messages for job progress at three call sites since long before this. What was missing was a path from a turn's outcome to that call, not the ability to edit.

Safety, which is most of the change

  • It routes through sendReply. The response checks, the identifier guard, the blank-reply stop and the notice shape all still apply. An edit that bypassed them would be the unreviewed second write surface docs/sirens-echo-tool-markup.md exists to prevent.
  • Off by default behind SIRENS_ECHO_THREAD_SUMMARY, so the flag is the rollback. That is how the coalescing lane landed, and nothing changes for a deployment that does not ask.
  • The first reply is remembered, never the newest, because a living answer is one message rather than a moving target.
  • A failed edit falls back to an ordinary reply and forgets the thread, so a deleted message costs the turn nothing and is not retried on every later turn.
  • The cache is bounded like every other per-channel one.

Falsified, not just asserted

break what the test says
remove the flag gate edited "reply-1" with the flag off
remove the fallback sendReply: unknown message, a member losing an answer

Eight tests, just gate green, package clean under -race.

Found while building this

go test -race surfaced a data race in three tests I wrote earlier tonight, unrelated to this change. Fixed separately in #1137 and filed as #1136, rather than folded in here.

closes #951

Kai chose this shape on the issue: **a turn ends by editing this service's own earlier reply in the thread**, rather than posting another. ## The literal title is not buildable, and that is worth recording `threadForReply` opens every thread this service creates with `MessageThreadStartComplex(message.ChannelID, message.ID, ...)`, **from the member's message**. So a thread's starter belongs to the member, and Discord's edit endpoint only edits your own content. "Editing the message at the top of a thread" is an API constraint rather than a harness gap. What this edits is the first message **this service** posted there, which is the top of the conversation it owns. ## The mechanism already existed `jobdiscord.go` has edited its own messages for job progress at three call sites since long before this. What was missing was a path from a turn's outcome to that call, not the ability to edit. ## Safety, which is most of the change * **It routes through `sendReply`.** The response checks, the identifier guard, the blank-reply stop and the notice shape all still apply. An edit that bypassed them would be the unreviewed second write surface `docs/sirens-echo-tool-markup.md` exists to prevent. * **Off by default** behind `SIRENS_ECHO_THREAD_SUMMARY`, so the flag is the rollback. That is how the coalescing lane landed, and nothing changes for a deployment that does not ask. * **The first reply is remembered, never the newest**, because a living answer is one message rather than a moving target. * **A failed edit falls back to an ordinary reply and forgets the thread**, so a deleted message costs the turn nothing and is not retried on every later turn. * The cache is bounded like every other per-channel one. ## Falsified, not just asserted | break | what the test says | | --- | --- | | remove the flag gate | `edited "reply-1" with the flag off` | | remove the fallback | `sendReply: unknown message`, a member losing an answer | Eight tests, `just gate` green, package clean under `-race`. ## Found while building this `go test -race` surfaced a data race in three tests I wrote earlier tonight, unrelated to this change. Fixed separately in #1137 and filed as #1136, rather than folded in here. closes #951
feat(threads): a thread can carry one living answer instead of a run
All checks were successful
ci / image-build (pull_request) Successful in 41s
ci / test (pull_request) Successful in 1m7s
ci / publish-echo-image (pull_request) Has been skipped
ci / publish-observed (pull_request) Has been skipped
d2f51519a2
Kai chose this shape for #951: a turn ends by editing this service's own
earlier reply in the thread rather than posting another.

The literal title is not buildable and that is worth recording. Threads
open from the member's message via threadForReply, so a thread's starter
belongs to the member and Discord refuses a bot editing another author's
content. What this edits is the first message this service posted there.

The mechanism already existed. jobdiscord.go edits its own messages for
job progress, so this is a path from a turn's outcome to that call rather
than a new capability.

It routes through sendReply, so the response checks, the identifier guard,
the blank-reply stop and the notice shape all still apply. An edit that
bypassed them would be the unreviewed second write surface the tool-markup
doc exists to prevent.

Off by default behind SIRENS_ECHO_THREAD_SUMMARY, so the flag is the
rollback, which is how the coalescing lane landed. Nothing changes for a
deployment that does not ask.

The first reply is the one remembered, never the newest, because a living
answer is one message rather than a moving target. The cache is bounded
like every other per-channel one. A failed edit falls back to an ordinary
reply and forgets the thread, so a deleted message costs the turn nothing
and is not retried on every later turn.

Both halves were checked against their failure. Removing the flag gate
reports `edited "reply-1" with the flag off`, and removing the fallback
reports `sendReply: unknown message`, which is a member losing an answer.

closes #951

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Agent-Role: engineer
Author
Member

Reviewed the branch at d2f5151 before this opened, so the substance is on #951. Short version: the checks decision is satisfied structurally, because the branch point sits inside sendReply after the blank-content gate, so the identifier guard, response checks, notice shape and tool-call receipt all run ahead of both endings. An edit cannot carry content a post could not, and it needed no sendReply refactor, which is better than the cost I warned might be honest.

Two things from that review are worth answering in this body rather than leaving to a reader:

1. The edit truncates where the post path overflows. EditReply calls truncateRunes(content, discordReplyLimit) and reviseSummary never receives whole, so a long answer is complete when posted and cut when revised. That may well be right for a living summary, since an attachment per revision is its own mess. It is a real behavioural difference and this body does not mention it. One line either way.

2. ThreadID reads cached state and returns empty on a miss, so a cold cache silently degrades the feature to ordinary replies. Good failure direction, same as LocationLabel in #1121, and the third cached-state-miss site tonight.

Splitting the race out to #1137 rather than folding it in was right, and its open question is now #1139.

Nothing here blocks the merge.

Reviewed the branch at `d2f5151` before this opened, so the substance is on https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/951. Short version: **the checks decision is satisfied structurally**, because the branch point sits inside `sendReply` after the blank-content gate, so the identifier guard, response checks, notice shape and tool-call receipt all run ahead of both endings. An edit cannot carry content a post could not, and it needed no `sendReply` refactor, which is better than the cost I warned might be honest. Two things from that review are worth answering in this body rather than leaving to a reader: **1. The edit truncates where the post path overflows.** `EditReply` calls `truncateRunes(content, discordReplyLimit)` and `reviseSummary` never receives `whole`, so a long answer is complete when posted and cut when revised. That may well be right for a living summary, since an attachment per revision is its own mess. **It is a real behavioural difference and this body does not mention it.** One line either way. **2. `ThreadID` reads cached state and returns empty on a miss**, so a cold cache silently degrades the feature to ordinary replies. Good failure direction, same as `LocationLabel` in https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/pulls/1121, and the third cached-state-miss site tonight. Splitting the race out to #1137 rather than folding it in was right, and its open question is now https://forgejo.coilysiren.me/coilyco-gaming/sirens-echo/issues/1139. Nothing here blocks the merge.
coilyco-ops deleted branch aos/claude/edit-reply-951 2026-08-23 02:08:29 +00:00
Sign in to join this conversation.
No reviewers
No project
No assignees
1 participant
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set

Reference
coilyco-gaming/sirens-echo!1138
No description provided.