Skip to content

Anthropic signed thinking blocks are lost (replayed as plain text, signature dropped) across tool-use turns #811

Description

@Fanzzzd

OpenCodeReview version

Bug description

The Anthropic adapter parses thinking blocks into the normalized ResponseMessage.ReasoningContent, but assistant history for the next tool-result request is rebuilt from only visible text and tool_use blocks. Two things go wrong for extended-thinking models:

  1. The signed thinking block (and any redacted_thinking block) is not replayed. Anthropic requires thinking blocks around tool use to be passed back unmodified and in their original order; a strict endpoint can reject the continuation, and a tolerant one returns HTTP 200 while silently losing reasoning continuity.
  2. It is worse than a plain drop: a thinking + tool_use turn has no visible text, so ChatResponse.Content() falls back to the reasoning text, and the private thinking is replayed as an ordinary visible text block. Hidden reasoning leaks into visible assistant content, and its signature disappears entirely.

Source trace

All references are main @ 71d2981:

  1. mapAnthropicResponse flattens thinking blocks into a joined ReasoningContent string — the block signature is already gone at this point.
  2. RunPerFile rebuilds history via resp.Content() + resp.ToolCalls() (the lossy projection described in Thinking state is parsed but dropped when assistant tool-call turns are replayed #805), and Content() substitutes reasoning text when visible content is empty.
  3. buildAnthropicParams emits only text and tool_use blocks for assistant history; no code path emits a thinking block param.

Deterministic reproduction

No live endpoint or API key needed — this exercises the adapter's own response→history→request chain:

func TestAnthropicToolUseReplayPreservesSignedThinking(t *testing.T) {
	raw := `{
		"id": "msg_01",
		"type": "message",
		"role": "assistant",
		"model": "claude-sonnet-4-5",
		"content": [
			{"type": "thinking", "thinking": "private plan the API requires on replay", "signature": "sig_abc123"},
			{"type": "tool_use", "id": "toolu_01", "name": "file_read", "input": {"path": "main.go"}}
		],
		"stop_reason": "tool_use"
	}`
	var sdkMsg anthropic.Message
	if err := json.Unmarshal([]byte(raw), &sdkMsg); err != nil {
		t.Fatal(err)
	}

	client := NewAnthropicClient(ClientConfig{URL: "https://api.anthropic.com"})
	resp := client.mapAnthropicResponse(&sdkMsg)

	// Same response-to-history conversion used by llmloop.Runner.
	history := NewToolCallMessage(resp.Content(), resp.ToolCalls())

	params, err := client.buildAnthropicParams("claude-sonnet-4-5", ChatRequest{
		Messages: []Message{
			{Role: "user", Content: "review this file"},
			history,
			NewToolResultMessage("toolu_01", "package main"),
		},
	})
	if err != nil {
		t.Fatal(err)
	}
	payload, err := json.Marshal(params.Messages)
	if err != nil {
		t.Fatal(err)
	}
	if !bytes.Contains(payload, []byte(`"type":"thinking"`)) || !bytes.Contains(payload, []byte("sig_abc123")) {
		t.Fatalf("assistant replay dropped the signed thinking block: %s", payload)
	}
}

Run:

go test ./internal/llm -run '^TestAnthropicToolUseReplayPreservesSignedThinking$' -count=1

Actual result on main @ 71d2981:

--- FAIL: TestAnthropicToolUseReplayPreservesSignedThinking (0.00s)
    assistant replay dropped the signed thinking block: [{"content":[{"text":"review this file","type":"text"}],"role":"user"},{"content":[{"text":"private plan the API requires on replay","type":"text"},{"id":"toolu_01","input":{"path":"main.go"},"name":"file_read","type":"tool_use"}],"role":"assistant"},{"content":[{"tool_use_id":"toolu_01","is_error":false,"content":[{"text":"package main","type":"text"}],"type":"tool_result"}],"role":"user"}]

Note the replayed assistant turn: the signed thinking block came back as {"text":"private plan the API requires on replay","type":"text"} — private reasoning converted to visible text, sig_abc123 nowhere in the request.

Expected behavior

The request following a thinking + tool_use turn should carry the original ordered assistant content blocks — signed thinking / redacted_thinking preserved unmodified before tool_use — and hidden thinking should never be converted into visible text.

Anthropic reference: https://platform.claude.com/docs/en/about-claude/models/extended-thinking-models#thinking-encryption

Proposed direction

An adapter-owned anthropicReplay envelope behind the same private replayEnvelope seam discussed in #805/#806, rather than reusing the OpenAI Chat envelope (different protocol, different invariants):

  • retain the complete ordered assistant content blocks for the next Messages request — thinking with its signature, redacted_thinking, text, and tool_use — without flattening or reordering;
  • keep signatures and hidden/redacted thinking out of normalized JSON, persisted logs, and the generic ChatResponse fallback;
  • participate in message cloning, token accounting, and compression through the existing seam;
  • define an explicit model-transition policy, since signed thinking state may not be valid after switching models;
  • if Anthropic streaming is enabled, assemble thinking_delta/signature_delta/block-stop events before a turn becomes replayable.

Key regression coverage for any fix: a strict two-request fake Anthropic endpoint verifying that every block is replayed exactly and in order before the matched tool_result blocks; signed and redacted thinking each covered; multiple tool calls keep original order and IDs; persistence/log tests prove signatures and hidden thinking are not exposed.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions