Skip to content

fix(client): capture and replay Anthropic redacted thinking blocks - #7019

Merged
Hmbown merged 3 commits into
codewhale-hq:mainfrom
asto18089:up/anthropic-redacted-thinking
Oct 18, 2026
Merged

Hmbown merged 3 commits into
codewhale-hq:mainfrom
asto18089:up/anthropic-redacted-thinking

Conversation

@asto18089

@asto18089 asto18089 commented Oct 18, 2026 •

Copy link
Copy Markdown
Contributor

What

Anthropic sometimes returns a redacted_thinking content block instead of a decodable thinking block (safety/policy on extended-thinking models). The stream decoder had no variant for it: the block was dropped with a warning, the turn survived — but the assistant tool_use then replayed without its thinking block, and Anthropic rejects that with an unretryable 400 on every retry of the turn. The non-stream decode path dropped it wholesale, and the block was never persisted or replayed.

This PR captures redacted_thinking byte-exact on both paths, persists it, replays it byte-exact on native Anthropic request building, and withholds the payload from non-native-Anthropic routes (a conservative call — a forwarding proxy might handle the block, but withholding is fail-safe). Redacted payloads never reach the visible transcript; display treats them like any elided thinking.

Alongside the capture plumbing, finished thinking blocks now persist in wire order (an ordered buffer instead of last-block-wins) — strictly necessary for correct redacted placement in multi-block turns, and a no-op for histories that only ever carried one block.

Scope

Deliberately excluded from the same-titled fork work: Responses pairing integrity, final-tool-turn strip rules, Gemini signature-gate scoping, encrypted-reasoning capture widening (upstream already captures on all Responses routes that send include), signature-only-block persistence, and token-estimator accounting for redacted bytes (disclosed here as a known approximation).

Tests

  • Stream decode (including the missing-key and "data": null gateway shapes), non-stream decode shim, native byte-exact replay, non-native strip, redacted-only assistant message drop, serde round-trip with field-absent session compat, and an engine-level pin that the block persists into the follow-up request in wire order.
  • cargo test -p codewhale-models --locked 44 passed; -p codewhale-tui --lib redacted 34 passed; targeted anthropic/thinking/flush filters green; clippy -D warnings clean (models, protocol, tui lib); check --workspace green.

Ported from the Pinvou/CodeWhale fork (#91), redacted-thinking slice only.

No-Issue: Anthropic redacted_thinking blocks were dropped and never replayed, so tool loops hit an unretryable 400

Anthropic sometimes replaces a whole thinking block with an encrypted
`redacted_thinking` content block. Main had no representation for it: the
SSE `content_block_start` failed to decode and the block was dropped, the
non-stream response decode failed wholesale, and nothing was ever replayed.
A tool loop that replays the bare `tool_use` without its thinking block
then fails with "Expected `thinking` or `redacted_thinking`, but found
`tool_use`" - an unretryable 400 on every retry of that turn.

Fix, ported from fork PR codewhale-hq#91 (redacted-thinking slice only):

- models: add a tolerant `ContentBlockStart::RedactedThinking` stream
  variant (missing-key and explicit-null `data` decode to an empty
  payload instead of failing the event) and a serde-default
  `redacted_data` field on the neutral `ContentBlock::Thinking`, so
  persisted sessions round-trip and older sessions load unchanged
- stream capture: park redacted blocks byte-exact the moment they arrive
  (and finished thinking blocks in wire order), persist them, keep them
  in the mid-stream retry prefix, and treat a stream that captured one as
  received content instead of silently re-issuing the request
- non-stream capture: rewrite `redacted_thinking` blocks into the neutral
  shape before decoding so the response (and its tool calls) survives
- replay: send the payload back byte-exact on native `*.anthropic.com`
  routes; withhold it from every non-native Messages host, dropping
  redacted-only assistant messages instead of emitting `content: []`
- other wires (Chat Completions, Responses) pick explicit fields only,
  so the payload never leaves the Anthropic dialect, and the transcript
  renders redacted blocks like any textless thinking block - header only,
  payload bytes never displayed
- engine: redacted starts count as actionable content and their payload
  counts toward the stream content cap

Out of scope, still withheld from the fork commit: multi-turn thinking
strip rules, Responses pairing integrity, Gemini signature-gate scoping,
encrypted-reasoning capture widening, OpenRouter reasoning details, and
the compaction-estimator byte accounting.

Co-authored-by: zhuowp <9426526+zhuowp@users.noreply.github.com>
Signed-off-by: asto <asto18089@126.com>
@asto18089
asto18089 requested a review from Hmbown as a code owner October 18, 2026 02:06

@claude claude Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Claude Code Review

This pull request is from a fork — automated review is disabled. A repository maintainer can comment @claude review to run a one-time review.

@github-actions github-actions Bot added the contribution-gate Author not yet in .github/APPROVED_CONTRIBUTORS; a maintainer grants access with /lgtm label Oct 18, 2026
Hmbown pushed a commit that referenced this pull request Oct 18, 2026
#7016, #7018 and #7019 from @asto18089 carry a Co-authored-by trailer for
@zhuowp (the work was ported from a fork they contributed to).
scripts/check-contributor-credit.py, run as the per-PR "Version drift"
job, fails those PRs because @zhuowp is absent from
web/lib/release-credits.ts. docs/CONTRIBUTORS.md and CHANGELOG.md already
credit them. This adds the handle to UNRELEASED_CONTRIBUTORS, which the
release cut moves into the release block.

Refs #7016
Refs #7018
Refs #7019

Verified: python3 scripts/check-contributor-credit.py ends "Every
contributor in the window is credited on all three surfaces";
web check-facts OK; npx tsc --noEmit OK.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBKbhx19BRbZtvWK8rVKjz
Hmbown pushed a commit that referenced this pull request Oct 18, 2026
Review follow-up on #7019 (@asto18089). The PR made any thinking block
flushed into completed_thinking count as received content in
stream_died_with_nothing. flush_thinking_block keeps blocks whose text is
whitespace or that carry only opaque state. On Responses/Codex routes a
reasoning item with encrypted content but an empty summary is common, so
such a block blocked the silent NoContentStreamDeath retry where main
retried. A whitespace-only chat reasoning chunk did the same.

The guard now counts a completed block only when it is redacted or has
non-blank thinking: the rule current_thinking already follows. Redacted
blocks still count, which is what the PR needed. The PR's comment above
the guard is updated to say so.

Refs #7019

Verified on the merged tree (merge b6373c2 plus this change):
scripts/dev-test.sh tui redacted thinking anthropic stream_: 442 run, 442
passed. scripts/dev-test.sh protocol redacted: 3 passed. cargo test -p
codewhale-models --lib redacted: 2 passed. cargo fmt --check clean.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBKbhx19BRbZtvWK8rVKjz
@Hmbown
Hmbown merged commit b6373c2 into codewhale-hq:main Oct 18, 2026
27 checks passed
@Hmbown

Hmbown commented Oct 18, 2026

Copy link
Copy Markdown
Collaborator

🤖 This reply is from Claude Opus 5.5, an AI agent working on behalf of the Codewhale maintainers. It is posted through @Hmbown's account, but Hunter did not write it.

Merged in b6373c2. Thank you @asto18089. Before this, a redacted_thinking block was dropped on both stream and non-stream decode, so a [redacted_thinking, tool_use] turn replayed as a bare tool_use and got the same 400 on every retry. The tolerant stream variant, the non-stream normalizer, byte-exact native replay and the non-native withhold together close that.

I merged it locally because it conflicted with #7014 in one hunk of build_anthropic_body. The resolution keeps your withhold block, followed by #7014's updated sampling comment. Your commits are intact.

One follow-up on main (a175d92). With completed_thinking in the stream-death guard, a flushed block that holds only opaque state blocked the silent NoContentStreamDeath retry. That's common on Responses/Codex routes: encrypted content with an empty summary. Whitespace-only text did the same, so the guard now counts a completed block only when it is redacted or has non-blank text. Redacted blocks still count, as you intended.

Not in a release yet.

— Written by Claude Opus 5.5 (AI agent) on behalf of the maintainers.

Hmbown pushed a commit that referenced this pull request Oct 18, 2026
Adds [Unreleased] entries for #7014, #7015, #7017, #7018, #7019 and #7020,
crediting @asto18089 (and @zhuowp for the fork ports). Each merged without
a changelog line. The entry for #7020 names its behavior change: the
app-server's legacy [providers.custom] table now refuses a non-Chat wire
with provider_wire_format_unsupported instead of forwarding a Chat body.

Refs #7014
Refs #7015
Refs #7017
Refs #7018
Refs #7019
Refs #7020
Refs #6997

Verified: sync-changelog --check up to date; web check-facts OK;
check-contributor-credit "Every contributor in the window is credited on
all three surfaces".

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01NBKbhx19BRbZtvWK8rVKjz
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

contribution-gate Author not yet in .github/APPROVED_CONTRIBUTORS; a maintainer grants access with /lgtm

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants