Skip to content

Commit 18b9492

Browse files
committed
πŸ› fix(opencode): strip thinking blocks from last assistant message
Fixes Claude API error: 'thinking or redacted_thinking blocks in the latest assistant message cannot be modified' Root cause: toModelMessages() reconstructs reasoning parts that don't match the original API response byte-exactly. Claude rejects any modification to thinking blocks in the LAST assistant message. Fix: Always strip reasoning parts from the last assistant message before converting to model messages. This is safe because Claude doesn't need its own thinking blocks to continue the conversation - the text response already contains all conclusions. Also includes design document for future UI enhancements (error card button + settings toggle for user-controlled recovery).
1 parent e806ac3 commit 18b9492

File tree

2 files changed

+120
-0
lines changed

2 files changed

+120
-0
lines changed
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
# Design: Fix Thinking Block Error (Option D)
2+
3+
**Date:** 2026-02-25
4+
**Status:** Approved β€” Ready to implement
5+
6+
## Problem
7+
When using Claude models with extended thinking, the API returns `thinking`/`redacted_thinking` blocks. When OpenCode replays these back (on next message or compaction), if they're modified during storage/retrieval, Claude rejects them:
8+
```
9+
messages.3.content.1: `thinking` or `redacted_thinking` blocks in the latest assistant message cannot be modified
10+
```
11+
12+
Session becomes stuck β€” even compaction triggers the same error.
13+
14+
## Root Cause
15+
`MessageV2.toModelMessages()` stores reasoning parts as `{type: "reasoning", text: part.text}` but the original API response had `{type: "thinking", thinking: "..."}`. The reconstruction is not byte-identical. Claude's constraint only applies to the LAST assistant message.
16+
17+
## Approach: Strip reasoning from last assistant message (user-controlled)
18+
19+
### Component 1: Backend Strip Logic
20+
**File:** `packages/opencode/src/session/message-v2.ts`
21+
22+
In `toModelMessages()`, add optional `stripLastReasoning` parameter:
23+
```typescript
24+
export function toModelMessages(input: WithParts[], model: Provider.Model, opts?: { stripLastReasoning?: boolean }): ModelMessage[] {
25+
// ... existing code ...
26+
27+
// Before return, if stripLastReasoning:
28+
if (opts?.stripLastReasoning) {
29+
const lastAssistantIdx = result.findLastIndex((msg) => msg.role === "assistant")
30+
if (lastAssistantIdx !== -1) {
31+
result[lastAssistantIdx].parts = result[lastAssistantIdx].parts.filter((p) => p.type !== "reasoning")
32+
if (result[lastAssistantIdx].parts.length === 0 || result[lastAssistantIdx].parts.every((p) => p.type === "step-start")) {
33+
result.splice(lastAssistantIdx, 1)
34+
}
35+
}
36+
}
37+
38+
return convertToModelMessages(...)
39+
}
40+
```
41+
42+
### Component 2: Config Setting
43+
**File:** `packages/opencode/src/config/config.ts`
44+
45+
Add to appearance/compaction config:
46+
```typescript
47+
strip_thinking_on_error: z.boolean().optional().default(false).describe("Automatically strip thinking blocks when API error occurs")
48+
```
49+
50+
### Component 3: Auto-Retry in Processor
51+
**File:** `packages/opencode/src/session/processor.ts`
52+
53+
In the catch block (~line 350), detect the specific error:
54+
```typescript
55+
const isThinkingError = e?.message?.includes("thinking") && e?.message?.includes("cannot be modified")
56+
if (isThinkingError) {
57+
const config = await Config.get()
58+
if (config.strip_thinking_on_error) {
59+
// Auto-retry with stripped thinking
60+
// Set a flag that toModelMessages should strip
61+
continue // retry the loop
62+
}
63+
// Otherwise, throw the error (UI will show "Retry without thinking" button)
64+
}
65+
```
66+
67+
### Component 4: Error Card Button
68+
**File:** `packages/ui/src/components/message-part.tsx`
69+
70+
In the error rendering section (~line 1040), detect thinking error:
71+
```tsx
72+
<Match when={cleaned.includes("thinking") && cleaned.includes("cannot be modified")}>
73+
<Card variant="error">
74+
<div>{cleaned}</div>
75+
<Button onClick={() => retryWithoutThinking()} variant="secondary">
76+
Retry without thinking blocks
77+
</Button>
78+
</Card>
79+
</Match>
80+
```
81+
82+
### Component 5: Settings Toggle
83+
**File:** `packages/app/src/components/settings-general.tsx`
84+
85+
Add toggle in Appearance section:
86+
```
87+
Strip Thinking on Error: [Toggle]
88+
Description: "Automatically retry without thinking blocks when API rejects modified thinking content"
89+
```
90+
91+
## Implementation Order
92+
1. Backend strip logic (message-v2.ts)
93+
2. Config setting (config.ts)
94+
3. Auto-retry logic (processor.ts)
95+
4. Error card button (message-part.tsx)
96+
5. Settings toggle (settings-general.tsx)
97+
98+
## Testing
99+
- Reproduce with Claude Opus in long conversation
100+
- Verify error β†’ button appears
101+
- Click button β†’ retries successfully
102+
- Enable auto-mode β†’ errors auto-recover
103+
- Compaction still works after fix

β€Žpackages/opencode/src/session/message-v2.tsβ€Ž

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -702,6 +702,23 @@ export namespace MessageV2 {
702702
}
703703
}
704704

705+
// Always strip reasoning/thinking parts from the last assistant message.
706+
// Claude API enforces that thinking blocks in the latest assistant message
707+
// must be byte-identical to the original response. Since OpenCode reconstructs
708+
// them from stored parts, they may not match exactly. Stripping is safe because
709+
// Claude doesn't need its own thinking blocks to continue the conversation.
710+
{
711+
const lastAssistantIdx = result.findLastIndex((msg) => msg.role === "assistant")
712+
if (lastAssistantIdx !== -1) {
713+
const filtered = result[lastAssistantIdx].parts.filter((part) => part.type !== "reasoning")
714+
if (filtered.length > 0 && !filtered.every((p) => p.type === "step-start")) {
715+
result[lastAssistantIdx].parts = filtered
716+
} else {
717+
result.splice(lastAssistantIdx, 1)
718+
}
719+
}
720+
}
721+
705722
const tools = Object.fromEntries(Array.from(toolNames).map((toolName) => [toolName, { toModelOutput }]))
706723

707724
return convertToModelMessages(

0 commit comments

Comments
Β (0)