box: stop a tool card splitting one assistant message into two text blocks - #174
box: stop a tool card splitting one assistant message into two text blocks#174pythonlearner1025 wants to merge 2 commits into
Conversation
…locks
A lody assistant message renders split mid-sentence with a tool card wedged
between the halves. In stored history the second half is a separate text item
that literally starts with a space:
[21] text "Three"
[22] tool_call toolu_0166kpDv... (grep ...)
[23] text " characterization agents are running in parallel, plus ..."
Measured on this box 2026-09-02: 23 of 375 stored assistant text blocks (~6%)
begin that way. It reads to the user as a garbled or lost message.
The daemon already knows which message a delta belongs to and throws it away.
`claude-acp.js` computes `messageIdForGrouping` -- the Anthropic assistant
message id -- and `applyMessageId` stamps it on every agent_message_chunk;
`zContentChunk.messageId` parses it. The history applier maps the chunk to
`{type, text}` without the id and merges a delta only into
`items[items.length - 1]`, so anything appended between two deltas of ONE
message ends that block permanently.
Verified the ordering is faithful before blaming the applier: enqueueACPUpdate
and the whole dispatchEvent -> emit path are synchronous, flush grouping is
always a single group across ~4,600 flushes, and flush_updates_now NESTS
flush_updates_batch rather than racing it. The interleaving is real in the ACP
stream -- parallel and background tools report themselves between deltas -- so
the applier is the right layer.
Six hunks, guarded on the installed package version plus each anchor at exactly
one occurrence. The discriminator is the id and nothing else: no text heuristic,
since "starts with a space" corrupts legitimate content and a genuine
text -> tool -> text across two messages must still render as two blocks.
Adjacency still wins first, so the patch can only join what today splits, never
split what today joins. With no id the emitted object is byte-identical to
today's, so every other adapter is untouched.
The two emitted helpers are a behavioural transcription of the upstream fix
(blitzdotdev/Lody#22), verified equal over a 69-case matrix covering both the
merge target and the id the merged item keeps. Delete this patch when that
merges and the daemon pin moves.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
`PATCH_SCRIPTS` says it is "every patch the image build applies to the published bundle, IN THE ORDER the Dockerfile applies them", and it listed three. Without this the harness boots a daemon that differs from the one a box ships, so the message-split patch would never be exercised against a real daemon — and the next patch to land would inherit the same gap. Verified with it registered: test/lody-session-workdir.test.ts 26/26 and test/lody-worktree-session.test.ts 8/8 against a real daemon carrying all four patches. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Follow-up: harness registration + full gate resultsTwo corrections to the gate section above, both now resolved in
The full-suite failures were load flakes, not regressions. All three pass on re-run:
The two Gates: |
Symptom
A lody assistant message renders split mid-sentence with a tool card wedged between the halves. In stored history the second half is a separate text item that literally starts with a space:
Measured on a canary box 2026-09-02: 23 of 375 stored assistant text blocks (~6%) begin that way. It reads to the user as a garbled or lost message.
Root cause
The daemon already knows which message a delta belongs to, and throws it away.
claude-acp.jscomputesmessageIdForGrouping(message)— the Anthropic assistant messageid— andapplyMessageIdstamps it on everyagent_message_chunk/agent_thought_chunk. It is emitted explicitly for grouping.zContentChunk.messageId.{type, text}without the id, and merges a delta only intoitems[items.length - 1]. So anything appended between two deltas of one message — a tool call, a subagent task — ends that text block permanently.Tool calls are reported asynchronously, so parallel and background tools land between deltas routinely.
What was ruled out first
The applier is the right layer only because the transport is faithful, and that was measured, not assumed:
enqueueACPUpdateand the wholedispatchEvent → emitpath are synchronous — no reordering on the way into the buffer.acp.flush_updates_nownestsacp.flush_updates_batchrather than racing it (an apparent span overlap is nesting, not concurrency).toolCallIds; Loro replay shows only monotonic appends.The patch
Six hunks against the published bundle, guarded on the installed package version plus each anchor at exactly one occurrence (a whole-file sha256 can only ever pin the first patch in a chain).
The discriminator is the id and nothing else. No text heuristic — "starts with a space" and "starts with a lowercase letter" both corrupt legitimate content, and a genuine
text → tool → textacross two API messages carries two different ids and must still render as two blocks.Adjacency still wins first, so the patch can only ever join what today splits — it can never split what today joins. With no id the emitted object is byte-identical to today's, so Codex, Grok and DeepSeek are untouched.
The two emitted helpers are a behavioural transcription of the upstream fix, blitzdotdev/Lody#22, verified equal over a 69-case matrix covering both the merge target and the id the merged item keeps. Delete this patch when that merges and the daemon pin moves.
Verification
node --check."Three"case and every negative control.Gates
npm run typecheck— passnpm run lint:gate— pass (74 anti-slop, baseline; 0 blitz-house)npm test— 723 tests, 1 failure:webapp-websocket-origin.test.tstimed out at 5000ms under parallel load and passes 4/4 in isolation. Unrelated to this diff, which touches onlypackages/box/patches/, the Dockerfile and docs.🤖 Generated with Claude Code