Skip to content

Commit 4975cfc

Browse files
auritiClaude Opus 4.6
andauthored
fix: strip Anthropic params from 3P resume paths (#479)
* fix: strip Anthropic-specific params from 3P provider paths Three silent failure modes affecting all third-party provider users: 1. Thinking blocks serialized as <thinking> text corrupt multi-turn context — strip them instead of converting to raw text tags. 2. Unknown models fall through to 200k context window default, so auto-compact never triggers — use conservative 8k for unknown 3P models with a warning log. 3. Session resume with thinking blocks causes 400 or context corruption on 3P providers — strip thinking/redacted_thinking content blocks from deserialized messages when resuming against a non-Anthropic provider. Addresses findings 2, 3, and 5 from #248. * test: align resume stripping expectation with orphan-thinking filter * test: isolate provider env in conversation recovery tests * test: move provider-sensitive resume coverage behind module mocks * test: trim extra blank lines in conversation recovery test Keep the focused provider-resume test diff clean so the regression branch stays easy to review. Co-Authored-By: Claude Opus 4.6 <noreply@openclaude.dev> --------- Co-authored-by: Claude Opus 4.6 <noreply@openclaude.dev>
1 parent 600c01f commit 4975cfc

5 files changed

Lines changed: 137 additions & 9 deletions

File tree

src/services/api/openaiShim.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -195,10 +195,12 @@ function convertContentBlocks(
195195
// handled separately
196196
break
197197
case 'thinking':
198-
// Append thinking as text with a marker for models that support reasoning
199-
if (block.thinking) {
200-
parts.push({ type: 'text', text: `<thinking>${block.thinking}</thinking>` })
201-
}
198+
case 'redacted_thinking':
199+
// Strip thinking blocks for OpenAI-compatible providers.
200+
// These are Anthropic-specific content types that 3P providers
201+
// don't understand. Serializing them as <thinking> text corrupts
202+
// multi-turn context: the model sees the tags as part of its
203+
// previous reply and may mimic or misattribute them.
202204
break
203205
default:
204206
if (block.text) {

src/utils/context.ts

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -72,16 +72,23 @@ export function getContextWindowForModel(
7272
return 1_000_000
7373
}
7474

75-
// OpenAI-compatible provider — use known context windows for the model
76-
if (
75+
// OpenAI-compatible provider — use known context windows for the model.
76+
// Unknown models get a conservative 8k default so auto-compact triggers
77+
// before hitting a hard context_window_exceeded error (issue #248 finding 3).
78+
const isOpenAIProvider =
7779
isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) ||
7880
isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI) ||
7981
isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
80-
) {
82+
if (isOpenAIProvider) {
8183
const openaiWindow = getOpenAIContextWindow(model)
8284
if (openaiWindow !== undefined) {
8385
return openaiWindow
8486
}
87+
console.error(
88+
`[context] Warning: model "${model}" not in context window table — using conservative 8k default. ` +
89+
'Add it to src/utils/model/openaiContextWindows.ts for accurate compaction.',
90+
)
91+
return 8_000
8592
}
8693

8794
const cap = getModelCapability(model)

src/utils/conversationRecovery.hooks.test.ts

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -69,3 +69,93 @@ test('loadConversationForResume rejects oversized transcripts before resume hook
6969
)
7070
expect(hookSpy).not.toHaveBeenCalled()
7171
})
72+
73+
test('deserializeMessagesWithInterruptDetection strips thinking blocks only for OpenAI-compatible providers', async () => {
74+
const serializedMessages = [
75+
user(id(10), 'hello'),
76+
{
77+
type: 'assistant',
78+
uuid: id(11),
79+
parentUuid: id(10),
80+
timestamp: ts,
81+
cwd: '/tmp',
82+
sessionId,
83+
version: 'test',
84+
message: {
85+
role: 'assistant',
86+
content: [
87+
{ type: 'thinking', thinking: 'secret reasoning' },
88+
{ type: 'text', text: 'visible reply' },
89+
],
90+
},
91+
},
92+
{
93+
type: 'assistant',
94+
uuid: id(12),
95+
parentUuid: id(11),
96+
timestamp: ts,
97+
cwd: '/tmp',
98+
sessionId,
99+
version: 'test',
100+
message: {
101+
role: 'assistant',
102+
content: [{ type: 'thinking', thinking: 'only hidden reasoning' }],
103+
},
104+
},
105+
user(id(13), 'follow up'),
106+
]
107+
108+
mock.module('./model/providers.js', () => ({
109+
getAPIProvider: () => 'openai',
110+
isOpenAICompatibleProvider: (provider: string) =>
111+
provider === 'openai' ||
112+
provider === 'gemini' ||
113+
provider === 'github' ||
114+
provider === 'codex',
115+
}))
116+
117+
const openaiModule = await import(`./conversationRecovery.ts?provider=openai-${Date.now()}`)
118+
const thirdParty = openaiModule.deserializeMessagesWithInterruptDetection(serializedMessages as never[])
119+
const thirdPartyAssistantMessages = thirdParty.messages.filter(
120+
message => message.type === 'assistant',
121+
)
122+
123+
expect(thirdPartyAssistantMessages).toHaveLength(2)
124+
expect(thirdPartyAssistantMessages[0]?.message?.content).toEqual([
125+
{ type: 'text', text: 'visible reply' },
126+
])
127+
expect(
128+
JSON.stringify(thirdPartyAssistantMessages.map(message => message.message?.content)),
129+
).not.toContain('secret reasoning')
130+
expect(
131+
JSON.stringify(thirdPartyAssistantMessages.map(message => message.message?.content)),
132+
).not.toContain('only hidden reasoning')
133+
134+
mock.restore()
135+
mock.module('./model/providers.js', () => ({
136+
getAPIProvider: () => 'bedrock',
137+
isOpenAICompatibleProvider: (provider: string) =>
138+
provider === 'openai' ||
139+
provider === 'gemini' ||
140+
provider === 'github' ||
141+
provider === 'codex',
142+
}))
143+
144+
const bedrockModule = await import(`./conversationRecovery.ts?provider=bedrock-${Date.now()}`)
145+
const anthropicCompatible = bedrockModule.deserializeMessagesWithInterruptDetection(serializedMessages as never[])
146+
const anthropicAssistantMessages = anthropicCompatible.messages.filter(
147+
message => message.type === 'assistant',
148+
)
149+
150+
expect(anthropicAssistantMessages).toHaveLength(2)
151+
expect(anthropicAssistantMessages[0]?.message?.content).toEqual([
152+
{ type: 'thinking', thinking: 'secret reasoning' },
153+
{ type: 'text', text: 'visible reply' },
154+
])
155+
expect(
156+
JSON.stringify(anthropicAssistantMessages.map(message => message.message?.content)),
157+
).toContain('secret reasoning')
158+
expect(
159+
JSON.stringify(anthropicAssistantMessages.map(message => message.message?.content)),
160+
).not.toContain('only hidden reasoning')
161+
})

src/utils/conversationRecovery.test.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const originalSimple = process.env.CLAUDE_CODE_SIMPLE
1313
const sessionId = '00000000-0000-4000-8000-000000001999'
1414
const ts = '2026-04-02T00:00:00.000Z'
1515

16+
1617
function id(n: number): string {
1718
return `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`
1819
}
@@ -76,4 +77,3 @@ test('loadConversationForResume rejects oversized reconstructed transcripts', as
7677
'Reconstructed transcript is too large to resume safely',
7778
)
7879
})
79-

src/utils/conversationRecovery.ts

Lines changed: 30 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import {
2424
type FileHistorySnapshot,
2525
} from './fileHistory.js'
2626
import { logError } from './log.js'
27+
import { getAPIProvider } from './model/providers.js'
2728
import {
2829
createAssistantMessage,
2930
createUserMessage,
@@ -177,6 +178,25 @@ export type DeserializeResult = {
177178
turnInterruptionState: TurnInterruptionState
178179
}
179180

181+
/**
182+
* Remove thinking/redacted_thinking content blocks from assistant messages.
183+
* Messages that become empty after stripping are removed entirely.
184+
*/
185+
function stripThinkingBlocks(messages: NormalizedMessage[]): NormalizedMessage[] {
186+
return messages.reduce<NormalizedMessage[]>((acc, msg) => {
187+
if (msg.type !== 'assistant' || !Array.isArray(msg.message?.content)) {
188+
acc.push(msg)
189+
return acc
190+
}
191+
const filtered = msg.message.content.filter(
192+
(block: { type?: string }) => block.type !== 'thinking' && block.type !== 'redacted_thinking',
193+
)
194+
if (filtered.length === 0) return acc
195+
acc.push({ ...msg, message: { ...msg.message, content: filtered } })
196+
return acc
197+
}, [])
198+
}
199+
180200
/**
181201
* Deserializes messages from a log file into the format expected by the REPL.
182202
* Filters unresolved tool uses, orphaned thinking messages, and appends a
@@ -227,10 +247,19 @@ export function deserializeMessagesWithInterruptDetection(
227247
filteredToolUses,
228248
) as NormalizedMessage[]
229249

250+
// Strip thinking/redacted_thinking content blocks from assistant messages
251+
// when resuming against a 3P provider. These Anthropic-specific blocks cause
252+
// 400 errors or context corruption on OpenAI-compatible providers (issue #248 finding 5).
253+
const provider = getAPIProvider()
254+
const isThirdPartyProvider = provider !== 'firstParty' && provider !== 'bedrock' && provider !== 'vertex' && provider !== 'foundry'
255+
const thinkingStripped = isThirdPartyProvider
256+
? stripThinkingBlocks(filteredThinking)
257+
: filteredThinking
258+
230259
// Filter out assistant messages with only whitespace text content.
231260
// This can happen when model outputs "\n\n" before thinking, user cancels mid-stream.
232261
const filteredMessages = filterWhitespaceOnlyAssistantMessages(
233-
filteredThinking,
262+
thinkingStripped,
234263
) as NormalizedMessage[]
235264

236265
const internalState = detectTurnInterruption(filteredMessages)

0 commit comments

Comments
 (0)