Compare commits
5 Commits
fix/issue-
...
fix/repl-a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98f38d8bfc | ||
|
|
279cd1a7e1 | ||
|
|
5c13223aa4 | ||
|
|
2c8842f87c | ||
|
|
858f06d964 |
@@ -195,10 +195,12 @@ function convertContentBlocks(
|
|||||||
// handled separately
|
// handled separately
|
||||||
break
|
break
|
||||||
case 'thinking':
|
case 'thinking':
|
||||||
// Append thinking as text with a marker for models that support reasoning
|
case 'redacted_thinking':
|
||||||
if (block.thinking) {
|
// Strip thinking blocks for OpenAI-compatible providers.
|
||||||
parts.push({ type: 'text', text: `<thinking>${block.thinking}</thinking>` })
|
// These are Anthropic-specific content types that 3P providers
|
||||||
}
|
// don't understand. Serializing them as <thinking> text corrupts
|
||||||
|
// multi-turn context: the model sees the tags as part of its
|
||||||
|
// previous reply and may mimic or misattribute them.
|
||||||
break
|
break
|
||||||
default:
|
default:
|
||||||
if (block.text) {
|
if (block.text) {
|
||||||
|
|||||||
@@ -72,16 +72,23 @@ export function getContextWindowForModel(
|
|||||||
return 1_000_000
|
return 1_000_000
|
||||||
}
|
}
|
||||||
|
|
||||||
// OpenAI-compatible provider — use known context windows for the model
|
// OpenAI-compatible provider — use known context windows for the model.
|
||||||
if (
|
// Unknown models get a conservative 8k default so auto-compact triggers
|
||||||
|
// before hitting a hard context_window_exceeded error (issue #248 finding 3).
|
||||||
|
const isOpenAIProvider =
|
||||||
isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) ||
|
isEnvTruthy(process.env.CLAUDE_CODE_USE_OPENAI) ||
|
||||||
isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI) ||
|
isEnvTruthy(process.env.CLAUDE_CODE_USE_GEMINI) ||
|
||||||
isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
|
isEnvTruthy(process.env.CLAUDE_CODE_USE_GITHUB)
|
||||||
) {
|
if (isOpenAIProvider) {
|
||||||
const openaiWindow = getOpenAIContextWindow(model)
|
const openaiWindow = getOpenAIContextWindow(model)
|
||||||
if (openaiWindow !== undefined) {
|
if (openaiWindow !== undefined) {
|
||||||
return openaiWindow
|
return openaiWindow
|
||||||
}
|
}
|
||||||
|
console.error(
|
||||||
|
`[context] Warning: model "${model}" not in context window table — using conservative 8k default. ` +
|
||||||
|
'Add it to src/utils/model/openaiContextWindows.ts for accurate compaction.',
|
||||||
|
)
|
||||||
|
return 8_000
|
||||||
}
|
}
|
||||||
|
|
||||||
const cap = getModelCapability(model)
|
const cap = getModelCapability(model)
|
||||||
|
|||||||
@@ -69,3 +69,93 @@ test('loadConversationForResume rejects oversized transcripts before resume hook
|
|||||||
)
|
)
|
||||||
expect(hookSpy).not.toHaveBeenCalled()
|
expect(hookSpy).not.toHaveBeenCalled()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
test('deserializeMessagesWithInterruptDetection strips thinking blocks only for OpenAI-compatible providers', async () => {
|
||||||
|
const serializedMessages = [
|
||||||
|
user(id(10), 'hello'),
|
||||||
|
{
|
||||||
|
type: 'assistant',
|
||||||
|
uuid: id(11),
|
||||||
|
parentUuid: id(10),
|
||||||
|
timestamp: ts,
|
||||||
|
cwd: '/tmp',
|
||||||
|
sessionId,
|
||||||
|
version: 'test',
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: [
|
||||||
|
{ type: 'thinking', thinking: 'secret reasoning' },
|
||||||
|
{ type: 'text', text: 'visible reply' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: 'assistant',
|
||||||
|
uuid: id(12),
|
||||||
|
parentUuid: id(11),
|
||||||
|
timestamp: ts,
|
||||||
|
cwd: '/tmp',
|
||||||
|
sessionId,
|
||||||
|
version: 'test',
|
||||||
|
message: {
|
||||||
|
role: 'assistant',
|
||||||
|
content: [{ type: 'thinking', thinking: 'only hidden reasoning' }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
user(id(13), 'follow up'),
|
||||||
|
]
|
||||||
|
|
||||||
|
mock.module('./model/providers.js', () => ({
|
||||||
|
getAPIProvider: () => 'openai',
|
||||||
|
isOpenAICompatibleProvider: (provider: string) =>
|
||||||
|
provider === 'openai' ||
|
||||||
|
provider === 'gemini' ||
|
||||||
|
provider === 'github' ||
|
||||||
|
provider === 'codex',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const openaiModule = await import(`./conversationRecovery.ts?provider=openai-${Date.now()}`)
|
||||||
|
const thirdParty = openaiModule.deserializeMessagesWithInterruptDetection(serializedMessages as never[])
|
||||||
|
const thirdPartyAssistantMessages = thirdParty.messages.filter(
|
||||||
|
message => message.type === 'assistant',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(thirdPartyAssistantMessages).toHaveLength(2)
|
||||||
|
expect(thirdPartyAssistantMessages[0]?.message?.content).toEqual([
|
||||||
|
{ type: 'text', text: 'visible reply' },
|
||||||
|
])
|
||||||
|
expect(
|
||||||
|
JSON.stringify(thirdPartyAssistantMessages.map(message => message.message?.content)),
|
||||||
|
).not.toContain('secret reasoning')
|
||||||
|
expect(
|
||||||
|
JSON.stringify(thirdPartyAssistantMessages.map(message => message.message?.content)),
|
||||||
|
).not.toContain('only hidden reasoning')
|
||||||
|
|
||||||
|
mock.restore()
|
||||||
|
mock.module('./model/providers.js', () => ({
|
||||||
|
getAPIProvider: () => 'bedrock',
|
||||||
|
isOpenAICompatibleProvider: (provider: string) =>
|
||||||
|
provider === 'openai' ||
|
||||||
|
provider === 'gemini' ||
|
||||||
|
provider === 'github' ||
|
||||||
|
provider === 'codex',
|
||||||
|
}))
|
||||||
|
|
||||||
|
const bedrockModule = await import(`./conversationRecovery.ts?provider=bedrock-${Date.now()}`)
|
||||||
|
const anthropicCompatible = bedrockModule.deserializeMessagesWithInterruptDetection(serializedMessages as never[])
|
||||||
|
const anthropicAssistantMessages = anthropicCompatible.messages.filter(
|
||||||
|
message => message.type === 'assistant',
|
||||||
|
)
|
||||||
|
|
||||||
|
expect(anthropicAssistantMessages).toHaveLength(2)
|
||||||
|
expect(anthropicAssistantMessages[0]?.message?.content).toEqual([
|
||||||
|
{ type: 'thinking', thinking: 'secret reasoning' },
|
||||||
|
{ type: 'text', text: 'visible reply' },
|
||||||
|
])
|
||||||
|
expect(
|
||||||
|
JSON.stringify(anthropicAssistantMessages.map(message => message.message?.content)),
|
||||||
|
).toContain('secret reasoning')
|
||||||
|
expect(
|
||||||
|
JSON.stringify(anthropicAssistantMessages.map(message => message.message?.content)),
|
||||||
|
).not.toContain('only hidden reasoning')
|
||||||
|
})
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ const originalSimple = process.env.CLAUDE_CODE_SIMPLE
|
|||||||
const sessionId = '00000000-0000-4000-8000-000000001999'
|
const sessionId = '00000000-0000-4000-8000-000000001999'
|
||||||
const ts = '2026-04-02T00:00:00.000Z'
|
const ts = '2026-04-02T00:00:00.000Z'
|
||||||
|
|
||||||
|
|
||||||
function id(n: number): string {
|
function id(n: number): string {
|
||||||
return `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`
|
return `00000000-0000-4000-8000-${String(n).padStart(12, '0')}`
|
||||||
}
|
}
|
||||||
@@ -76,4 +77,3 @@ test('loadConversationForResume rejects oversized reconstructed transcripts', as
|
|||||||
'Reconstructed transcript is too large to resume safely',
|
'Reconstructed transcript is too large to resume safely',
|
||||||
)
|
)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import {
|
|||||||
type FileHistorySnapshot,
|
type FileHistorySnapshot,
|
||||||
} from './fileHistory.js'
|
} from './fileHistory.js'
|
||||||
import { logError } from './log.js'
|
import { logError } from './log.js'
|
||||||
|
import { getAPIProvider } from './model/providers.js'
|
||||||
import {
|
import {
|
||||||
createAssistantMessage,
|
createAssistantMessage,
|
||||||
createUserMessage,
|
createUserMessage,
|
||||||
@@ -177,6 +178,25 @@ export type DeserializeResult = {
|
|||||||
turnInterruptionState: TurnInterruptionState
|
turnInterruptionState: TurnInterruptionState
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Remove thinking/redacted_thinking content blocks from assistant messages.
|
||||||
|
* Messages that become empty after stripping are removed entirely.
|
||||||
|
*/
|
||||||
|
function stripThinkingBlocks(messages: NormalizedMessage[]): NormalizedMessage[] {
|
||||||
|
return messages.reduce<NormalizedMessage[]>((acc, msg) => {
|
||||||
|
if (msg.type !== 'assistant' || !Array.isArray(msg.message?.content)) {
|
||||||
|
acc.push(msg)
|
||||||
|
return acc
|
||||||
|
}
|
||||||
|
const filtered = msg.message.content.filter(
|
||||||
|
(block: { type?: string }) => block.type !== 'thinking' && block.type !== 'redacted_thinking',
|
||||||
|
)
|
||||||
|
if (filtered.length === 0) return acc
|
||||||
|
acc.push({ ...msg, message: { ...msg.message, content: filtered } })
|
||||||
|
return acc
|
||||||
|
}, [])
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Deserializes messages from a log file into the format expected by the REPL.
|
* Deserializes messages from a log file into the format expected by the REPL.
|
||||||
* Filters unresolved tool uses, orphaned thinking messages, and appends a
|
* Filters unresolved tool uses, orphaned thinking messages, and appends a
|
||||||
@@ -227,10 +247,19 @@ export function deserializeMessagesWithInterruptDetection(
|
|||||||
filteredToolUses,
|
filteredToolUses,
|
||||||
) as NormalizedMessage[]
|
) as NormalizedMessage[]
|
||||||
|
|
||||||
|
// Strip thinking/redacted_thinking content blocks from assistant messages
|
||||||
|
// when resuming against a 3P provider. These Anthropic-specific blocks cause
|
||||||
|
// 400 errors or context corruption on OpenAI-compatible providers (issue #248 finding 5).
|
||||||
|
const provider = getAPIProvider()
|
||||||
|
const isThirdPartyProvider = provider !== 'firstParty' && provider !== 'bedrock' && provider !== 'vertex' && provider !== 'foundry'
|
||||||
|
const thinkingStripped = isThirdPartyProvider
|
||||||
|
? stripThinkingBlocks(filteredThinking)
|
||||||
|
: filteredThinking
|
||||||
|
|
||||||
// Filter out assistant messages with only whitespace text content.
|
// Filter out assistant messages with only whitespace text content.
|
||||||
// This can happen when model outputs "\n\n" before thinking, user cancels mid-stream.
|
// This can happen when model outputs "\n\n" before thinking, user cancels mid-stream.
|
||||||
const filteredMessages = filterWhitespaceOnlyAssistantMessages(
|
const filteredMessages = filterWhitespaceOnlyAssistantMessages(
|
||||||
filteredThinking,
|
thinkingStripped,
|
||||||
) as NormalizedMessage[]
|
) as NormalizedMessage[]
|
||||||
|
|
||||||
const internalState = detectTurnInterruption(filteredMessages)
|
const internalState = detectTurnInterruption(filteredMessages)
|
||||||
|
|||||||
Reference in New Issue
Block a user