feat(discord): send an overflowing reply whole as a file #808

Merged
coilysiren merged 1 commit from aos/claude/aw85-outbound-spill into main 2026-08-15 16:18:18 +00:00
Member

Closes #791.

What changed

A reply that does not fit is sent whole. The message carries what fits plus one line naming the file and the byte count, and reply.txt carries the complete reply. The size is visible without opening it, which is the same promise the inbound and tool-result paths already make to the model, made to the member.

The file is the whole reply, not the tail. Attaching only the remainder would make a reader stitch two pieces together and would disagree with the tool-result path, which saves the whole result rather than the part that did not fit.

The transport declares the capability, asserted like reactor, replyBudget, and spanTagger already are. HTTP and the MCP turn implement nothing and are byte-for-byte unchanged.

Answering the five questions the issue raised

  1. Whole reply or only the remainder. Whole, per the tool-result precedent above.
  2. Where the bytes come from. The reply the turn just composed, in memory for the length of the send. No scratchpad is involved, which is how the third acceptance criterion is met rather than guarded: there is nothing to regress where none is mounted, and no quota is spent on an outbound message.
  3. Whether a file is a disclosure change. It is not. Every reply check runs on the untruncated text - runReplyChecks in turnstages.go:51 covers tool-call markup, grounding, self-attributed claims, identifiers, identity claims, and response style, and all of it runs before the send budget is consulted at agent.go:1136. The cut was never holding anything back, so sending the rest adds nothing that was not already approved.
  4. Size and rate. replyAttachmentBytes is maxScratchFileBytes, so the size we are willing to store is the size we are willing to attach, and it sits far under Discord's floor for an unboosted guild. Above it the reply falls back to today's truncation.
  5. Threads. Unchanged deliberately. The file rides on whatever message the existing thread routing resolved to rather than picking a target of its own, which keeps this change to the one hop the issue is about.

One correction to the issue

The job-notice path does the same at jobdiscord.go:75, :92, and :149.

The shape is the same and the defect is not. jobTerminalNotice returns one of three fixed phrases (joboutcomedelivery_test.go pins that), and the other two sites are progress lines. Those cuts can never fire, so there is nothing there to save. Recorded in the doc so the next reader does not re-check it.

Failing soft

Three cases send the cut message exactly as before and none reaches the member as an error: a transport that cannot carry a file, a reply over the attachment bound, and a send budget with no room to say the file exists. Same trade the tool-result spill makes.

Mutation

  • Dropping Files from the send: TestTheAttachmentReachesDiscordAsAFile fails on all three assertions, including the content type falling back to application/json.
  • Never overflowing: TestAnOverflowingReplyKeepsTheWholeText and TestTheMessageNamesTheFileAndTheTrueSize fail.
  • Attaching without saying so: TestTheMessageNamesTheFileAndTheTrueSize fails.

The two Discord tests drive ChannelMessageSendComplex through a captured round tripper, so the multipart body is asserted where Discord actually receives it rather than where the decision is made. TestAnOrdinaryReplySendsNoFile pins that an ordinary reply stays a plain JSON send.

ward exec gate passes on the rebased tree.

Not in this change

The ATTACH_FILES note in coilyco-bridge/deploy services/sirens-echo/README.md still points at nothing. That is a one-line edit in another repository and belongs to its own pull request.

Closes #791. ## What changed **A reply that does not fit is sent whole.** The message carries what fits plus one line naming the file and the byte count, and `reply.txt` carries the complete reply. The size is visible without opening it, which is the same promise the inbound and tool-result paths already make to the model, made to the member. **The file is the whole reply, not the tail.** Attaching only the remainder would make a reader stitch two pieces together and would disagree with the tool-result path, which saves the whole result rather than the part that did not fit. **The transport declares the capability**, asserted like `reactor`, `replyBudget`, and `spanTagger` already are. HTTP and the MCP turn implement nothing and are byte-for-byte unchanged. ## Answering the five questions the issue raised 1. **Whole reply or only the remainder.** Whole, per the tool-result precedent above. 2. **Where the bytes come from.** The reply the turn just composed, in memory for the length of the send. **No scratchpad is involved**, which is how the third acceptance criterion is met rather than guarded: there is nothing to regress where none is mounted, and no quota is spent on an outbound message. 3. **Whether a file is a disclosure change.** It is not. Every reply check runs on the untruncated text - `runReplyChecks` in `turnstages.go:51` covers tool-call markup, grounding, self-attributed claims, identifiers, identity claims, and response style, and all of it runs before the send budget is consulted at `agent.go:1136`. The cut was never holding anything back, so sending the rest adds nothing that was not already approved. 4. **Size and rate.** `replyAttachmentBytes` is `maxScratchFileBytes`, so the size we are willing to store is the size we are willing to attach, and it sits far under Discord's floor for an unboosted guild. Above it the reply falls back to today's truncation. 5. **Threads.** Unchanged deliberately. The file rides on whatever message the existing thread routing resolved to rather than picking a target of its own, which keeps this change to the one hop the issue is about. ## One correction to the issue > The job-notice path does the same at `jobdiscord.go:75`, `:92`, and `:149`. The shape is the same and the defect is not. `jobTerminalNotice` returns one of three fixed phrases (`joboutcomedelivery_test.go` pins that), and the other two sites are progress lines. Those cuts can never fire, so there is nothing there to save. Recorded in the doc so the next reader does not re-check it. ## Failing soft Three cases send the cut message exactly as before and none reaches the member as an error: a transport that cannot carry a file, a reply over the attachment bound, and a send budget with no room to say the file exists. Same trade the tool-result spill makes. ## Mutation - Dropping `Files` from the send: `TestTheAttachmentReachesDiscordAsAFile` fails on all three assertions, including the content type falling back to `application/json`. - Never overflowing: `TestAnOverflowingReplyKeepsTheWholeText` and `TestTheMessageNamesTheFileAndTheTrueSize` fail. - Attaching without saying so: `TestTheMessageNamesTheFileAndTheTrueSize` fails. The two Discord tests drive `ChannelMessageSendComplex` through a captured round tripper, so the multipart body is asserted where Discord actually receives it rather than where the decision is made. `TestAnOrdinaryReplySendsNoFile` pins that an ordinary reply stays a plain JSON send. `ward exec gate` passes on the rebased tree. ## Not in this change The `ATTACH_FILES` note in `coilyco-bridge/deploy` `services/sirens-echo/README.md` still points at nothing. That is a one-line edit in another repository and belongs to its own pull request.
feat(discord): send an overflowing reply whole as a file
All checks were successful
ci / image-build (pull_request) Successful in 26s
ci / test (pull_request) Successful in 50s
ci / publish-echo-image (pull_request) Has been skipped
ci / publish-observed (pull_request) Has been skipped
1568d7f077
Large content has three paths through the harness and two of them already
refuse to destroy the remainder. An upload lands in the scratchpad and the turn
is told the path and the true size. A tool result over its cap is saved whole
and the model is told where it went. Outbound was the exception: the reply was
cut at the send budget and everything past the cut was gone.

A reply that does not fit is now sent as a message plus the whole reply as
`reply.txt`, with one line naming the file and the byte count so the size is
visible without opening it. The file is the complete reply rather than the
tail, matching the tool-result path, so a reader never stitches two pieces
together.

The bytes are the reply the turn just composed, held in memory for the length
of the send. No scratchpad is involved, so nothing regresses where none is
mounted and no quota is spent on an outbound message.

Three cases send the cut message exactly as before: a transport that cannot
carry a file, a reply over the attachment bound, and a send budget with no room
to say the file exists.

Co-authored-by: Kai Siren <coilysiren@gmail.com>
Co-authored-by: Claude <noreply@anthropic.com>
Sign in to join this conversation.
No reviewers
No milestone
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!808
No description provided.