rebuildCountChanged decides which messages to re-emit verbatim by byte-matching each output
message against its pre-pipeline normalized form (apply/apply.go:1344: "Each output message that
byte-matches a pre-pipeline normalized message (a survivor) is emitted as its ORIGINAL body raw
bytes"). A message that does not match is marshaled fresh.
For a synthetic role=tool message that is unsafe. Anthropic has no tool role — the synthetic
message is this proxy's internal representation of a tool_result content block. So if any
component rewrites a tool message's text (format compacting indented JSON is the ordinary case,
and it rewrites hundreds per run) and the message count changes (summarize), the rewritten
message no longer byte-matches, falls through to a fresh marshal, and the internal role reaches
the wire:
400 messages: Unexpected role "tool"
The equal-count path already avoids this by writing tool-text rewrites into the body's
tool_result blocks with sjson (apply/apply.go:151: "a change rewrites only that block's
content"). The count-change path does not.
Status: plausible, NOT yet reproduced on main
Being explicit because it matters for triage: this is inferred from reading the mechanism, not
observed. It was a real, measured defect on #80 — 12 of 39 live runs failed with the error above
once an earlier fix stopped tool results being deleted, which had been masking it — and caf32d7
fixed it there by (a) writing tool-text rewrites into the body's tool_result blocks before the
rebuild, so the rebuild only decides which messages to keep and never how to serialize one, and
(b) matching synthetic tool messages by tool_call_id rather than by bytes, since their text may
legitimately differ from the pre-pipeline form.
main's apply.go has since been reworked substantially and caf32d7 does not cherry-pick — it
conflicts in three places. Hand-resolving conflicts inside the byte-losslessness machinery is
exactly what #80's own commit warned against ("a careless change risks the byte-losslessness
guarantee this package exists to provide"), so it was not attempted in #110.
Suggested order of work
- Reproduce first. A test with an Anthropic body whose
tool_result carries indented JSON
(so format actually rewrites it — see below), a pipeline of [format, summarize] so the count
changes, asserting no role: "tool" appears anywhere in the emitted body. Confirm it fails on
main before changing anything.
- Then port
caf32d7's two changes against the current apply.go rather than resolving its diff.
The test trap, recorded because it cost three attempts on #80
caf32d7's regression test passed with the fix removed, twice:
- first version: the fixture's tool content was plain prose, which
format leaves alone;
- second: the content was already-compact JSON, hitting
format's already_compact gate.
It only became real with indented JSON, plus an assertion of the precondition — that some case
actually carried a rewritten tool_result through a count change — so it fails loudly instead of
passing vacuously. Any new reproduction should assert that precondition too.
rebuildCountChangeddecides which messages to re-emit verbatim by byte-matching each outputmessage against its pre-pipeline normalized form (
apply/apply.go:1344: "Each output message thatbyte-matches a pre-pipeline normalized message (a survivor) is emitted as its ORIGINAL body raw
bytes"). A message that does not match is marshaled fresh.
For a synthetic role=tool message that is unsafe. Anthropic has no tool role — the synthetic
message is this proxy's internal representation of a
tool_resultcontent block. So if anycomponent rewrites a tool message's text (
formatcompacting indented JSON is the ordinary case,and it rewrites hundreds per run) and the message count changes (
summarize), the rewrittenmessage no longer byte-matches, falls through to a fresh marshal, and the internal role reaches
the wire:
The equal-count path already avoids this by writing tool-text rewrites into the body's
tool_resultblocks with sjson (apply/apply.go:151: "a change rewrites only that block'scontent"). The count-change path does not.Status: plausible, NOT yet reproduced on
mainBeing explicit because it matters for triage: this is inferred from reading the mechanism, not
observed. It was a real, measured defect on #80 — 12 of 39 live runs failed with the error above
once an earlier fix stopped tool results being deleted, which had been masking it — and
caf32d7fixed it there by (a) writing tool-text rewrites into the body's
tool_resultblocks before therebuild, so the rebuild only decides which messages to keep and never how to serialize one, and
(b) matching synthetic tool messages by
tool_call_idrather than by bytes, since their text maylegitimately differ from the pre-pipeline form.
main'sapply.gohas since been reworked substantially andcaf32d7does not cherry-pick — itconflicts in three places. Hand-resolving conflicts inside the byte-losslessness machinery is
exactly what #80's own commit warned against ("a careless change risks the byte-losslessness
guarantee this package exists to provide"), so it was not attempted in #110.
Suggested order of work
tool_resultcarries indented JSON(so
formatactually rewrites it — see below), a pipeline of[format, summarize]so the countchanges, asserting no
role: "tool"appears anywhere in the emitted body. Confirm it fails onmainbefore changing anything.caf32d7's two changes against the currentapply.gorather than resolving its diff.The test trap, recorded because it cost three attempts on #80
caf32d7's regression test passed with the fix removed, twice:formatleaves alone;format'salready_compactgate.It only became real with indented JSON, plus an assertion of the precondition — that some case
actually carried a rewritten
tool_resultthrough a count change — so it fails loudly instead ofpassing vacuously. Any new reproduction should assert that precondition too.