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:
- Strict thinking providers can reject the next request because the assistant tool-call message is missing required replay state such as
reasoning_content.
- 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:
- The response type has
ResponseMessage.ReasoningContent.
- Both streaming and non-streaming OpenAI-compatible paths populate it.
RunPerFile reduces the response to resp.Content() and resp.ToolCalls(), then passes only those values to addNextMessage.
- Request-side
Message has no reasoning/replay field.
NewToolCallMessage and addNextMessage therefore append only content and tool_calls.
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:
- The adapter returns one finalized
AssistantTurn; the loop appends that turn as a whole before appending tool results.
Parts is the normalized application view used by OCR tools, comments, metrics, and redacted logs.
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.
- Streaming assembly belongs to the adapter and finalization must preserve typed indexes/signatures before a turn becomes replayable.
- Compression may summarize completed history, but it must treat the latest assistant/tool segment atomically and retain its replay envelope.
- 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
- Replace the loop's separate
Content()/ToolCalls() history append with a complete assistant turn.
- Implement OpenAI-compatible replay first: preserve and serialize
reasoning_content in both streaming and non-streaming paths.
- Add adapter-native Anthropic blocks/signatures and Responses items/encrypted content without forcing them through one universal string field.
- 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.
OpenCodeReview version
4bcc95acecd629e64ea984c962cfd08dd892a38emain:f44821d9aa0993e8e3cf90ae3fe12e733e045b15Bug 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
contentand normalizedtool_calls.This has two failure modes:
reasoning_content.When visible content is empty,
ChatResponse.Content()currently moves the reasoning text into ordinary assistantcontent. That avoids an empty string but does not preserve the original field or its protocol semantics.Source trace
The data-loss path is explicit:
ResponseMessage.ReasoningContent.RunPerFilereduces the response toresp.Content()andresp.ToolCalls(), then passes only those values toaddNextMessage.Messagehas no reasoning/replay field.NewToolCallMessageandaddNextMessagetherefore append onlycontentandtool_calls.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:
Run:
Actual result on both revisions:
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:
The exact names are unimportant; the invariants are:
AssistantTurn; the loop appends that turn as a whole before appending tool results.Partsis the normalized application view used by OCR tools, comments, metrics, and redacted logs.Replayis adapter-owned and is the only source used to serialize the next provider request. The loop must not parse, edit, reorder, or regenerate it.This shape aligns with current agent protocols:
reasoning_contentand tool calls.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
Content()/ToolCalls()history append with a complete assistant turn.reasoning_contentin both streaming and non-streaming paths.Regression coverage
reasoning_content, and a tool call; second request must replay all three fields.content.Existing issues and PRs
llm.extra_bodyso users could disable thinking. That is a useful workaround, but it does not preserve reasoning-enabled tool turns.thinkingfield from the current response. It does not add reasoning to assistant history or replay it on the next request.I could not find an existing issue that tracks complete provider-native assistant-turn replay across tool calls.