Skip to content

Thinking state is parsed but dropped when assistant tool-call turns are replayed #805

Description

@Fanzzzd

OpenCodeReview version

  • v1.8.10: 4bcc95acecd629e64ea984c962cfd08dd892a38e
  • Reproduced on current main: f44821d9aa0993e8e3cf90ae3fe12e733e045b15
  • Installation: built from source
  • OS used for the deterministic test: macOS (Apple Silicon)
  • Affected protocols: OpenAI-compatible chat; the same flattening also affects Anthropic thinking and Responses reasoning items

Bug description

OpenCodeReview parses provider reasoning/thinking from a model response, but drops its native representation before the next tool-call turn. The orchestration loop reconstructs assistant history from only visible content and normalized tool_calls.

This has two failure modes:

  1. Strict thinking providers can reject the next request because the assistant tool-call message is missing required replay state such as reasoning_content.
  2. Tolerant gateways may return HTTP 200 but silently lose reasoning continuity, so a successful request does not prove that the model received the previous assistant turn as produced.

When visible content is empty, ChatResponse.Content() currently moves the reasoning text into ordinary assistant content. That avoids an empty string but does not preserve the original field or its protocol semantics.

Source trace

The data-loss path is explicit:

  1. The response type has ResponseMessage.ReasoningContent.
  2. Both streaming and non-streaming OpenAI-compatible paths populate it.
  3. RunPerFile reduces the response to resp.Content() and resp.ToolCalls(), then passes only those values to addNextMessage.
  4. Request-side Message has no reasoning/replay field.
  5. NewToolCallMessage and addNextMessage therefore append only content and tool_calls.
  6. ChatResponse.Content() either drops reasoning when visible content exists or returns it as ordinary content when visible content is empty.

The provider-specific clients show the wider form of the problem:

Deterministic reproduction

This source-level regression requires no live model or API endpoint:

func TestAssistantToolCallHistoryPreservesReasoningContent(t *testing.T) {
	emptyContent := ""
	response := &ChatResponse{Choices: []Choice{{Message: ResponseMessage{
		Content:          &emptyContent,
		ReasoningContent: "private reasoning that the provider requires on replay",
		ToolCalls:        []ToolCall{{ID: "call_1"}},
	}}}}

	// Same response-to-history conversion used by llmloop.Runner.
	historyMessage := NewToolCallMessage(response.Content(), response.ToolCalls())
	payload, err := json.Marshal(historyMessage)
	if err != nil {
		t.Fatal(err)
	}
	if !bytes.Contains(payload, []byte(`"reasoning_content":"private reasoning that the provider requires on replay"`)) {
		t.Fatalf("assistant tool-call history dropped reasoning_content: %s", payload)
	}
}

Run:

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

Actual result on both revisions:

--- FAIL: TestAssistantToolCallHistoryPreservesReasoningContent
assistant tool-call history dropped reasoning_content:
{"role":"assistant","content":"private reasoning that the provider requires on replay","tool_calls":[...]}

Expected behavior

After an assistant returns tool calls, the next request should contain the complete provider-valid assistant turn that preceded the tool results. The review loop should consume a normalized view for tool execution, but it should not reconstruct provider history from that lossy view.

Proposed solution: adapter-owned assistant turns

The most durable fix is to introduce a deep module at the provider adapter seam:

type AssistantTurn struct {
	Parts  []Part       // normalized text, reasoning summary, and tool calls
	Replay ReplayState  // opaque provider-native continuation state
}

type ReplayState struct {
	Provider string
	Model    string
	Raw      json.RawMessage
}

The exact names are unimportant; the invariants are:

  1. The adapter returns one finalized AssistantTurn; the loop appends that turn as a whole before appending tool results.
  2. Parts is the normalized application view used by OCR tools, comments, metrics, and redacted logs.
  3. Replay is adapter-owned and is the only source used to serialize the next provider request. The loop must not parse, edit, reorder, or regenerate it.
  4. Streaming assembly belongs to the adapter and finalization must preserve typed indexes/signatures before a turn becomes replayable.
  5. Compression may summarize completed history, but it must treat the latest assistant/tool segment atomically and retain its replay envelope.
  6. Persistence/logging should store a redacted projection separately from restricted replay state; hidden reasoning should not be exposed merely to make continuation work.

This shape aligns with current agent protocols:

  • Kimi K3 requires replaying the complete assistant message, including reasoning_content and tool calls.
  • Anthropic requires complete, unmodified thinking/redacted-thinking blocks and signatures around tool use.
  • OpenAI Agents/Responses preserves typed reasoning items/encrypted content or uses server-managed continuation rather than flattening them to text.
  • Gemini uses opaque thought signatures that must accompany later tool turns.
  • OpenCode's provider transform retains reasoning parts and provider options, then applies provider-specific continuation rules.

PR-Agent is not an implementation reference for this particular problem: its own configuration says the review flow is a single-shot model call with no tool-use loop.

Incremental rollout

  1. Replace the loop's separate Content()/ToolCalls() history append with a complete assistant turn.
  2. Implement OpenAI-compatible replay first: preserve and serialize reasoning_content in both streaming and non-streaming paths.
  3. Add adapter-native Anthropic blocks/signatures and Responses items/encrypted content without forcing them through one universal string field.
  4. Update active-history copying, compression boundaries, and any persistence format that claims to preserve model requests.

Regression coverage

  • A strict two-turn fake server: first response has visible content, reasoning_content, and a tool call; second request must replay all three fields.
  • Repeat with empty visible content to ensure reasoning is not moved into ordinary content.
  • Streaming and non-streaming OpenAI-compatible responses.
  • Multiple parallel tool calls and matched tool results.
  • Anthropic signed/redacted thinking blocks.
  • Responses typed reasoning items/encrypted content.
  • Compression must not split or strip the active assistant/tool segment.
  • Redacted logs must not become the replay source.

Existing issues and PRs

I could not find an existing issue that tracks complete provider-native assistant-turn replay across tool calls.

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