|
| 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 |
0 commit comments