Repository navigation
fix(bedrock): capture and replay native Claude reasoning blocks - #4666
Conversation
Reasoning was flattened into one string and the last signature, and redacted bytes were stored in the signature string, where JSON persistence corrupted them. Record every content block by index and emit the ordered blocks as ProviderState on a complete message. Signed-off-by: Arnaud Héritier <aheritier@apache.org>
Replay the recorded Converse blocks when the message still matches them, so interleaved, multi-signature and redacted reasoning round-trips exactly. The flattened fallback now sends signed thinking with empty text as reasoning text instead of redacted content. Signed-off-by: Arnaud Héritier <aheritier@apache.org>
Signed-off-by: Arnaud Héritier <aheritier@apache.org>
Signed-off-by: Arnaud Héritier <aheritier@apache.org>
Bedrock messages now carry a ProviderState, and the Anthropic converters only replayed flattened thinking when the state was nil. After a fallback or a model switch from Bedrock, the thinking block was dropped and the tool loop was rejected. Only Anthropic's own state suppresses it. Signed-off-by: Arnaud Héritier <aheritier@apache.org>
Thinking cut off by max_tokens, or another vendor's reasoning on Bedrock, has no signature the API can verify. Skip it on replay, as the Anthropic path does, while keeping it in the message match. Signed-off-by: Arnaud Héritier <aheritier@apache.org>
There was a problem hiding this comment.
🟡 Changes recommended
Native text and reasoning accumulation introduces quadratic copying and allocation overhead on long streamed responses.
1 open issue
What changed in this PR
Adds native Bedrock reasoning capture and replay as part (a) of #4653, using existing provider-state persistence.
Changes:
- Preserves ordered reasoning, redacted bytes, text, and tool calls.
- Corrects signed-empty fallback and Anthropic handling of foreign provider state.
- Adds capture, replay, persistence tests, and documentation.
| File | Description |
|---|---|
| pkg/session/provider_state_persist_test.go | Tests opaque-byte persistence. |
| pkg/model/provider/bedrock/roundtrip_test.go | Tests stream-to-JSON replay. |
| pkg/model/provider/bedrock/native_blocks.go | Records and replays native blocks. |
| pkg/model/provider/bedrock/native_blocks_test.go | Tests replay and fallback behavior. |
| pkg/model/provider/bedrock/convert.go | Uses native replay and fixes signed-empty fallback. |
| pkg/model/provider/bedrock/client_test.go | Tests signed-empty reasoning. |
| pkg/model/provider/bedrock/adapter.go | Captures indexed stream blocks. |
| pkg/model/provider/bedrock/adapter_test.go | Tests capture and incomplete streams. |
| pkg/model/provider/anthropic/response_content.go | Identifies Anthropic-owned state. |
| pkg/model/provider/anthropic/features_integration_test.go | Tests foreign-state thinking fallback. |
| pkg/model/provider/anthropic/client.go | Preserves foreign-state flattened thinking. |
| pkg/model/provider/anthropic/beta_converter.go | Applies equivalent beta conversion behavior. |
| docs/providers/bedrock/index.md | Documents replay and compatibility. |
Review effort: Balanced
| switch d := d.(type) { | ||
| case *types.ContentBlockDeltaMemberText: | ||
| b := r.get(idx, "text") | ||
| b.block.Text += d.Value |
There was a problem hiding this comment.
Valid. #4666 merged before the fix landed, so it is in follow-up #4667 (3df6fb7): text and reasoning deltas now accumulate in a per-block strings.Builder on recordedBlock, and state() materializes nativeBlock.Text once. TestBlockRecorder_AccumulatesDeltasWithoutPerDeltaCopies records 1000 reasoning plus 1000 text deltas and asserts allocations stay below n/10 (it measured 2002, one per delta, before the change). Signatures (typically a single delta) and redacted bytes (append, already amortized) are unchanged.

Part (a) of #4653: Bedrock native reasoning blocks. Part of #4583 and epic #4580. Parts (b) and (c) follow as separate stacked PRs (see the plan in #4653).
What
Bedrock Converse now captures, persists and replays Claude's reasoning blocks exactly, instead of flattening them into one string and one signature.
ContentBlockIndex(reasoning text and signature, redacted bytes, text, tool use). When a message completes, it attaches the ordered blocks asProviderState{provider: "amazon-bedrock"}. Incomplete or failed streams record nothing.convertAssistantContentrebuilds the recorded blocks in order when the message still matches them: same text, reasoning and tool calls, compared with the runtime's own accumulation rules. Otherwise it falls back to the flattened fields.ProviderStatefor any provider. Nothing changes there.Bugs fixed (each with a test that failed first)
ThinkingSignature, a string; JSON replaced non-UTF-8 bytes on saveTwo regressions found in review and fixed before opening this PR:
ProviderStatewas present; now only Anthropic's own state suppresses it (280ee57).max_tokens, or another vendor's reasoning on Bedrock) is never replayed, matching the Anthropic replay path (913edd8).Compatibility
ThinkingSignature. The fallback now treats them as signed thinking, the common case on Claude 5. A legacy session that held genuinely redacted thinking would get a 400 on replay; those bytes were usually already corrupted by JSON.References
Vendor documentation behind each change (all links checked on 2026-10-20):
reasoningText(text + signature) vsredactedContent(bytes)contentBlockIndexin stream eventsValidation
task buildpasses.task testpasses. The 10 GeminiTestLoadExamplesfailures seen in earlier PRs didn't appear in this run.golangci-lintv2.14.0 reports 0 issues, andgo run ./lint .reports no offenses.go test -race -shuffle=onpasses onpkg/model/provider/bedrock,pkg/sessionandpkg/runtime.go test ./e2e -run '^TestWasmProviderDependencies$|^TestDependencies$'passes.Deferred minor findings:
ProviderState, including replies without reasoning.Not verified against live AWS: replay of native blocks on Converse.