Skip to content

Commit 5b6069e

Browse files
committed
🐛 fix(opencode): preserve thinking block signatures + configurable strategy UI
Root cause fix (from PR anomalyco#14393): - Always pass providerMetadata for reasoning parts (removed differentModel guard) - Always pass callProviderMetadata for tool parts - Fix asymmetric compaction buffer (use maxOutputTokens consistently) Configurable thinking strategy (none/strip/compact): - Settings > General: Thinking Strategy dropdown - Context tab: Always-visible strategy selector - Error card: Retry buttons for thinking block errors - Processor: Auto-compact on thinking error with compact strategy Default 'none' preserves original behavior.
1 parent 18b9492 commit 5b6069e

File tree

11 files changed

+352
-118
lines changed

11 files changed

+352
-118
lines changed
Lines changed: 33 additions & 92 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
1-
# Design: Fix Thinking Block Error (Option D)
1+
# Design: Fix Thinking Block Error
22

33
**Date:** 2026-02-25
4-
**Status:** Approved — Ready to implement
4+
**Status:** Implemented
55

66
## Problem
77
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:
@@ -11,93 +11,34 @@ messages.3.content.1: `thinking` or `redacted_thinking` blocks in the latest ass
1111

1212
Session becomes stuck — even compaction triggers the same error.
1313

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
14+
## Root Cause (verified via PR #14393)
15+
1. **Bug 1:** `toModelMessages()` strips `providerMetadata` (including Bedrock thinking signatures) when `differentModel` is true — which always happens during compaction due to model ID format mismatch.
16+
2. **Bug 2:** Asymmetric compaction buffer (20K vs 32K) causes compaction to trigger too late for some models.
17+
18+
## Solution: Root Fix + Configurable Strategy
19+
20+
### Root Fix (from PR #14393)
21+
- Always pass `providerMetadata` for reasoning parts and `callProviderMetadata` for tool parts (removed `differentModel` guard)
22+
- Symmetric compaction buffer using `maxOutputTokens()` consistently
23+
24+
### Configurable Thinking Strategy
25+
Three options available in Settings and Context tab:
26+
- **"none" (default):** Original behavior — send thinking blocks as-is. With the root fix, signatures are now preserved correctly.
27+
- **"strip":** Proactively remove thinking from last assistant message before sending. Prevents errors but loses thinking context.
28+
- **"compact":** Preserve thinking but auto-compact on error. First message may fail, then auto-recovers.
29+
30+
### Error Recovery UI
31+
- Chat error card shows "Retry (strip thinking)" and "Retry (compact session)" buttons
32+
- Context tab shows error alert with recovery buttons when thinking error detected
33+
34+
## Files Modified
35+
1. `message-v2.ts` — Root fix: always pass providerMetadata/callProviderMetadata + conditional strip logic
36+
2. `compaction.ts` — Root fix: symmetric buffer calculation
37+
3. `config.ts``thinking_strategy: "none" | "strip" | "compact"` config option
38+
4. `prompt.ts` — Reads config, passes stripLastReasoning flag
39+
5. `processor.ts` — Detects thinking errors, auto-compacts with "compact" strategy
40+
6. `session-turn.tsx` — Error card with retry buttons
41+
7. `session-turn.css` — Error button styles
42+
8. `message-timeline.tsx` — Retry handler wiring
43+
9. `settings-general.tsx` — Thinking Strategy dropdown
44+
10. `session-context-tab.tsx` — Always-visible strategy selector + error recovery

packages/app/src/components/session/session-context-tab.tsx

Lines changed: 118 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import { findLast } from "@opencode-ai/util/array"
99
import { same } from "@/utils/same"
1010
import { Icon } from "@opencode-ai/ui/icon"
1111
import { Button } from "@opencode-ai/ui/button"
12+
import { Select } from "@opencode-ai/ui/select"
13+
import { showToast } from "@opencode-ai/ui/toast"
1214
import { Accordion } from "@opencode-ai/ui/accordion"
1315
import { StickyAccordionHeader } from "@opencode-ai/ui/sticky-accordion-header"
1416
import { Code } from "@opencode-ai/ui/code"
@@ -285,9 +287,55 @@ export function SessionContextTab() {
285287
{(c) => {
286288
const sdk = useSDK()
287289
const [compacting, setCompacting] = createSignal(false)
290+
const [stripping, setStripping] = createSignal(false)
291+
const [strategy, setStrategy] = createSignal<"none" | "strip" | "compact">("none")
288292
const usage = () => c().usage ?? 0
289293
const color = () => usage() > 80 ? "var(--syntax-error)" : usage() > 60 ? "var(--syntax-warning)" : "var(--syntax-success)"
290294

295+
// Load current strategy from backend
296+
sdk.client.config.get().then((res) => {
297+
const val = (res.data as any)?.compaction?.thinking_strategy
298+
if (val === "none" || val === "strip" || val === "compact") setStrategy(val)
299+
}).catch(() => {})
300+
301+
const strategyOptions = [
302+
{ value: "none" as const, label: "None (default)" },
303+
{ value: "strip" as const, label: "Strip thinking" },
304+
{ value: "compact" as const, label: "Compact on error" },
305+
]
306+
307+
const updateStrategy = (value: "none" | "strip" | "compact") => {
308+
setStrategy(value)
309+
sdk.client.config
310+
.update({ config: { compaction: { thinking_strategy: value } } as any })
311+
.then(() => showToast({
312+
variant: "success",
313+
title: "Strategy updated",
314+
description: value === "strip"
315+
? "Thinking blocks stripped before sending"
316+
: "Thinking preserved, auto-compact on error",
317+
}))
318+
.catch((err) => {
319+
setStrategy(strategy() === "strip" ? "compact" : "strip")
320+
showToast({ title: "Failed", description: err instanceof Error ? err.message : String(err) })
321+
})
322+
}
323+
324+
// Detect if the last assistant message has a thinking block error
325+
const thinkingError = createMemo(() => {
326+
const all = messages()
327+
for (let i = all.length - 1; i >= 0; i--) {
328+
const msg = all[i]
329+
if (msg.role !== "assistant") continue
330+
const err = (msg as any).error
331+
if (!err || err.name === "MessageAbortedError") continue
332+
const text = (err.data?.message ?? "").toLowerCase()
333+
if (text.includes("thinking") && text.includes("cannot be modified")) return true
334+
break
335+
}
336+
return false
337+
})
338+
291339
const compact = async () => {
292340
if (!params.id || compacting()) return
293341
setCompacting(true)
@@ -304,6 +352,23 @@ export function SessionContextTab() {
304352
}
305353
}
306354

355+
const stripAndCompact = async () => {
356+
if (!params.id || stripping()) return
357+
setStripping(true)
358+
try {
359+
await sdk.client.config.update({ config: { compaction: { thinking_strategy: "strip" } } as any })
360+
const last = visibleUserMessages().at(-1)
361+
await sdk.client.session.summarize({
362+
sessionID: params.id,
363+
directory: sdk.directory,
364+
providerID: last?.model?.providerID,
365+
modelID: last?.model?.modelID,
366+
})
367+
} finally {
368+
setStripping(false)
369+
}
370+
}
371+
307372
return (
308373
<div class="flex flex-col gap-3 p-4 rounded-lg bg-surface-raised-base border border-border-weak-base">
309374
<div class="flex items-center justify-between">
@@ -318,14 +383,16 @@ export function SessionContextTab() {
318383
</span>
319384
</div>
320385
</div>
321-
<Button
322-
size="small"
323-
variant={usage() > 70 ? "primary" : "secondary"}
324-
disabled={compacting() || visibleUserMessages().length === 0}
325-
onClick={compact}
326-
>
327-
{compacting() ? language.t("command.session.compact") + "..." : language.t("command.session.compact")}
328-
</Button>
386+
<div class="flex items-center gap-2">
387+
<Button
388+
size="small"
389+
variant={usage() > 70 ? "primary" : "secondary"}
390+
disabled={compacting() || stripping() || visibleUserMessages().length === 0}
391+
onClick={compact}
392+
>
393+
{compacting() ? language.t("command.session.compact") + "..." : language.t("command.session.compact")}
394+
</Button>
395+
</div>
329396
</div>
330397
<div class="h-2 w-full rounded-full overflow-hidden" style={{ "background-color": "var(--surface-base)" }}>
331398
<div
@@ -336,6 +403,49 @@ export function SessionContextTab() {
336403
}}
337404
/>
338405
</div>
406+
<div class="flex items-center justify-between pt-1">
407+
<div class="flex flex-col">
408+
<span class="text-12-medium text-text-strong">Thinking strategy</span>
409+
<span class="text-11-regular text-text-weak">How thinking blocks are handled</span>
410+
</div>
411+
<Select
412+
options={strategyOptions}
413+
current={strategyOptions.find((o) => o.value === strategy())}
414+
value={(o) => o.value}
415+
label={(o) => o.label}
416+
onSelect={(option) => option && updateStrategy(option.value)}
417+
variant="secondary"
418+
size="small"
419+
/>
420+
</div>
421+
<Show when={thinkingError()}>
422+
<div class="flex flex-col gap-2 p-3 rounded-md border border-border-weak-base" style={{ "background-color": "color-mix(in srgb, var(--syntax-error) 8%, transparent)" }}>
423+
<span class="text-12-medium" style={{ color: "var(--syntax-error)" }}>
424+
Thinking block error detected
425+
</span>
426+
<span class="text-12-regular text-text-weak">
427+
The API rejected modified thinking blocks. Choose a recovery strategy:
428+
</span>
429+
<div class="flex gap-2 flex-wrap">
430+
<Button
431+
size="small"
432+
variant="secondary"
433+
disabled={stripping() || compacting()}
434+
onClick={stripAndCompact}
435+
>
436+
{stripping() ? "Stripping..." : "Strip thinking & compact"}
437+
</Button>
438+
<Button
439+
size="small"
440+
variant="secondary"
441+
disabled={compacting() || stripping()}
442+
onClick={compact}
443+
>
444+
{compacting() ? "Compacting..." : "Compact only"}
445+
</Button>
446+
</div>
447+
</div>
448+
</Show>
339449
</div>
340450
)
341451
}}

packages/app/src/components/settings-general.tsx

Lines changed: 55 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Component, Show, createMemo, createResource, type JSX } from "solid-js"
1+
import { Component, Show, createMemo, createResource, createSignal, type JSX } from "solid-js"
22
import { createStore } from "solid-js/store"
33
import { Button } from "@opencode-ai/ui/button"
44
import { Icon } from "@opencode-ai/ui/icon"
@@ -10,6 +10,7 @@ import { showToast } from "@opencode-ai/ui/toast"
1010
import { useLanguage } from "@/context/language"
1111
import { usePlatform } from "@/context/platform"
1212
import { useSettings, monoFontFamily } from "@/context/settings"
13+
import { useSDK } from "@/context/sdk"
1314
import { playSound, SOUND_OPTIONS } from "@/utils/sound"
1415
import { Link } from "./link"
1516

@@ -313,6 +314,21 @@ export const SettingsGeneral: Component = () => {
313314
</div>
314315
)
315316

317+
const sdk = useSDK()
318+
const [thinkingStrategy, setThinkingStrategy] = createSignal<"none" | "strip" | "compact">("none")
319+
320+
// Load current thinking strategy from backend config
321+
sdk.client.config.get().then((res) => {
322+
const strategy = (res.data as any)?.compaction?.thinking_strategy
323+
if (strategy === "none" || strategy === "strip" || strategy === "compact") setThinkingStrategy(strategy)
324+
}).catch(() => {})
325+
326+
const thinkingOptions = [
327+
{ value: "none" as const, label: "None (default)" },
328+
{ value: "strip" as const, label: "Strip thinking" },
329+
{ value: "compact" as const, label: "Compact on error" },
330+
]
331+
316332
const FeedSection = () => (
317333
<div class="flex flex-col gap-1">
318334
<h3 class="text-14-medium text-text-strong pb-2">{language.t("settings.general.section.feed")}</h3>
@@ -330,6 +346,44 @@ export const SettingsGeneral: Component = () => {
330346
</div>
331347
</SettingsRow>
332348

349+
<SettingsRow
350+
title="Thinking Strategy"
351+
description="How to handle thinking blocks that cause API errors in long sessions"
352+
>
353+
<Select
354+
data-action="settings-thinking-strategy"
355+
options={thinkingOptions}
356+
current={thinkingOptions.find((o) => o.value === thinkingStrategy())}
357+
value={(o) => o.value}
358+
label={(o) => o.label}
359+
onSelect={(option) => {
360+
if (!option) return
361+
setThinkingStrategy(option.value)
362+
sdk.client.config
363+
.update({ config: { compaction: { thinking_strategy: option.value } } as any })
364+
.then(() =>
365+
showToast({
366+
variant: "success",
367+
title: "Thinking strategy updated",
368+
description: option.value === "strip"
369+
? "Thinking blocks will be stripped before sending to API"
370+
: "Thinking blocks preserved; session auto-compacts on error",
371+
}),
372+
)
373+
.catch((err) => {
374+
setThinkingStrategy(thinkingStrategy() === "strip" ? "compact" : "strip")
375+
showToast({
376+
title: "Failed to update",
377+
description: err instanceof Error ? err.message : String(err),
378+
})
379+
})
380+
}}
381+
variant="secondary"
382+
size="small"
383+
triggerVariant="settings"
384+
/>
385+
</SettingsRow>
386+
333387
<SettingsRow
334388
title={language.t("settings.general.row.shellToolPartsExpanded.title")}
335389
description={language.t("settings.general.row.shellToolPartsExpanded.description")}

0 commit comments

Comments
 (0)