[FIX] Fix OpenAI Native handling of encrypted reasoning blocks to prevent error when condensing (#9263)

This commit is contained in:
Hannes Rudolph 2025-11-14 10:54:15 -07:00 committed by GitHub
parent da461182ae
commit 4a9ff8f4ef
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
2 changed files with 159 additions and 38 deletions

View file

@ -613,22 +613,36 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
const responseId = handler.getResponseId?.()
const reasoningData = handler.getEncryptedContent?.()
// If we have encrypted_content, add it as a reasoning item before the assistant message
if (reasoningData?.encrypted_content) {
this.apiConversationHistory.push({
type: "reasoning",
summary: [],
encrypted_content: reasoningData.encrypted_content,
...(reasoningData.id ? { id: reasoningData.id } : {}),
ts: Date.now(),
} as any)
}
const messageWithTs = {
// Start from the original assistant message
const messageWithTs: any = {
...message,
...(responseId ? { id: responseId } : {}),
ts: Date.now(),
}
// If we have encrypted_content, embed it as the first content block on the assistant message.
// This keeps reasoning + assistant atomic for context management while still allowing providers
// to receive a separate reasoning item when we build the request.
if (reasoningData?.encrypted_content) {
const reasoningBlock = {
type: "reasoning",
summary: [] as any[],
encrypted_content: reasoningData.encrypted_content,
...(reasoningData.id ? { id: reasoningData.id } : {}),
}
if (typeof messageWithTs.content === "string") {
messageWithTs.content = [
reasoningBlock,
{ type: "text", text: messageWithTs.content } satisfies Anthropic.Messages.TextBlockParam,
]
} else if (Array.isArray(messageWithTs.content)) {
messageWithTs.content = [reasoningBlock, ...messageWithTs.content]
} else if (!messageWithTs.content) {
messageWithTs.content = [reasoningBlock]
}
}
this.apiConversationHistory.push(messageWithTs)
} else {
const messageWithTs = { ...message, ts: Date.now() }
@ -2908,33 +2922,9 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}
}
// Properly type cleaned conversation history to include either standard Anthropic messages
// or provider-specific reasoning items (for encrypted continuity).
type ReasoningItemForRequest = {
type: "reasoning"
encrypted_content: string
id?: string
summary?: any[]
}
type CleanConversationMessage = Anthropic.Messages.MessageParam | ReasoningItemForRequest
const messagesSinceLastSummary = getMessagesSinceLastSummary(this.apiConversationHistory)
const cleanConversationHistory: CleanConversationMessage[] = maybeRemoveImageBlocks(
messagesSinceLastSummary,
this.api,
).map((msg: ApiMessage): CleanConversationMessage => {
// Pass through reasoning items as-is (including id if present)
if (msg.type === "reasoning") {
return {
type: "reasoning",
summary: msg.summary,
encrypted_content: msg.encrypted_content!,
...(msg.id ? { id: msg.id } : {}),
}
}
// For regular messages, just return role and content
return { role: msg.role!, content: msg.content as Anthropic.Messages.ContentBlockParam[] | string }
})
const messagesWithoutImages = maybeRemoveImageBlocks(messagesSinceLastSummary, this.api)
const cleanConversationHistory = this.buildCleanConversationHistory(messagesWithoutImages as ApiMessage[])
// Check auto-approval limits
const approvalResult = await this.autoApprovalHandler.checkAutoApprovalLimits(
@ -3166,6 +3156,91 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
return checkpointSave(this, force, suppressMessage)
}
private buildCleanConversationHistory(
messages: ApiMessage[],
): Array<
Anthropic.Messages.MessageParam | { type: "reasoning"; encrypted_content: string; id?: string; summary?: any[] }
> {
type ReasoningItemForRequest = {
type: "reasoning"
encrypted_content: string
id?: string
summary?: any[]
}
const cleanConversationHistory: (Anthropic.Messages.MessageParam | ReasoningItemForRequest)[] = []
for (const msg of messages) {
// Legacy path: standalone reasoning items stored as separate messages
if (msg.type === "reasoning" && msg.encrypted_content) {
cleanConversationHistory.push({
type: "reasoning",
summary: msg.summary,
encrypted_content: msg.encrypted_content!,
...(msg.id ? { id: msg.id } : {}),
})
continue
}
// Preferred path: assistant message with embedded reasoning as first content block
if (msg.role === "assistant") {
const rawContent = msg.content
const contentArray: Anthropic.Messages.ContentBlockParam[] = Array.isArray(rawContent)
? (rawContent as Anthropic.Messages.ContentBlockParam[])
: rawContent !== undefined
? ([
{ type: "text", text: rawContent } satisfies Anthropic.Messages.TextBlockParam,
] as Anthropic.Messages.ContentBlockParam[])
: []
const [first, ...rest] = contentArray
const hasEmbeddedReasoning =
first && (first as any).type === "reasoning" && typeof (first as any).encrypted_content === "string"
if (hasEmbeddedReasoning) {
const reasoningBlock = first as any
// Emit a separate reasoning item for the provider
cleanConversationHistory.push({
type: "reasoning",
summary: reasoningBlock.summary ?? [],
encrypted_content: reasoningBlock.encrypted_content,
...(reasoningBlock.id ? { id: reasoningBlock.id } : {}),
})
// Build assistant message without the embedded reasoning block
let assistantContent: Anthropic.Messages.MessageParam["content"]
if (rest.length === 0) {
assistantContent = ""
} else if (rest.length === 1 && rest[0].type === "text") {
assistantContent = (rest[0] as Anthropic.Messages.TextBlockParam).text
} else {
assistantContent = rest
}
cleanConversationHistory.push({
role: "assistant",
content: assistantContent,
} satisfies Anthropic.Messages.MessageParam)
continue
}
}
// Default path for regular messages (no embedded reasoning)
if (msg.role) {
cleanConversationHistory.push({
role: msg.role,
content: msg.content as Anthropic.Messages.ContentBlockParam[] | string,
})
}
}
return cleanConversationHistory
}
public async checkpointRestore(options: CheckpointRestoreOptions) {
return checkpointRestore(this, options)
}

View file

@ -325,4 +325,50 @@ describe("Task reasoning preservation", () => {
expect(task.apiConversationHistory[0].content[0].text).toBe("Here is my response.")
expect(task.apiConversationHistory[0].content[0].text).not.toContain("<think>")
})
it("should embed encrypted reasoning as first assistant content block", async () => {
const task = new Task({
provider: mockProvider as ClineProvider,
apiConfiguration: mockApiConfiguration,
task: "Test task",
startTask: false,
})
// Avoid disk writes in this test
;(task as any).saveApiConversationHistory = vi.fn().mockResolvedValue(undefined)
// Mock API handler to provide encrypted reasoning data and response id
task.api = {
getEncryptedContent: vi.fn().mockReturnValue({
encrypted_content: "encrypted_payload",
id: "rs_test",
}),
getResponseId: vi.fn().mockReturnValue("resp_test"),
} as any
await (task as any).addToApiConversationHistory({
role: "assistant",
content: [{ type: "text", text: "Here is my response." }],
})
expect(task.apiConversationHistory).toHaveLength(1)
const stored = task.apiConversationHistory[0] as any
expect(stored.role).toBe("assistant")
expect(Array.isArray(stored.content)).toBe(true)
expect(stored.id).toBe("resp_test")
const [reasoningBlock, textBlock] = stored.content
expect(reasoningBlock).toMatchObject({
type: "reasoning",
encrypted_content: "encrypted_payload",
id: "rs_test",
})
expect(textBlock).toMatchObject({
type: "text",
text: "Here is my response.",
})
})
})