diff --git a/src/core/task-persistence/apiMessages.ts b/src/core/task-persistence/apiMessages.ts index 5beda00ddc4..1fa1e6df5b8 100644 --- a/src/core/task-persistence/apiMessages.ts +++ b/src/core/task-persistence/apiMessages.ts @@ -17,6 +17,7 @@ export type ApiMessage = Anthropic.MessageParam & { type?: "reasoning" summary?: any[] encrypted_content?: string + text?: string } export async function readApiMessages({ diff --git a/src/core/task/Task.ts b/src/core/task/Task.ts index 7c0355e4982..bfbdd843dff 100644 --- a/src/core/task/Task.ts +++ b/src/core/task/Task.ts @@ -646,19 +646,21 @@ export class Task extends EventEmitter implements TaskLike { return readApiMessages({ taskId: this.taskId, globalStoragePath: this.globalStoragePath }) } - private async addToApiConversationHistory(message: Anthropic.MessageParam) { + private async addToApiConversationHistory(message: Anthropic.MessageParam, reasoning?: string) { // Capture the encrypted_content / thought signatures from the provider (e.g., OpenAI Responses API, Google GenAI) if present. // We only persist data reported by the current response body. const handler = this.api as ApiHandler & { getResponseId?: () => string | undefined getEncryptedContent?: () => { encrypted_content: string; id?: string } | undefined getThoughtSignature?: () => string | undefined + getSummary?: () => any[] | undefined } if (message.role === "assistant") { const responseId = handler.getResponseId?.() const reasoningData = handler.getEncryptedContent?.() const thoughtSignature = handler.getThoughtSignature?.() + const reasoningSummary = handler.getSummary?.() // Start from the original assistant message const messageWithTs: any = { @@ -667,10 +669,26 @@ export class Task extends EventEmitter implements TaskLike { 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) { + // Store reasoning: plain text (most providers) or encrypted (OpenAI Native) + if (reasoning) { + const reasoningBlock = { + type: "reasoning", + text: reasoning, + summary: reasoningSummary ?? ([] as any[]), + } + + 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] + } + } else if (reasoningData?.encrypted_content) { + // OpenAI Native encrypted reasoning const reasoningBlock = { type: "reasoning", summary: [] as any[], @@ -2661,10 +2679,13 @@ export class Task extends EventEmitter implements TaskLike { } } - await this.addToApiConversationHistory({ - role: "assistant", - content: assistantContent, - }) + await this.addToApiConversationHistory( + { + role: "assistant", + content: assistantContent, + }, + reasoningMessage || undefined, + ) TelemetryService.instance.captureConversationMessage(this.taskId, "assistant") @@ -3339,14 +3360,16 @@ export class Task extends EventEmitter implements TaskLike { 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 } : {}), - }) + // Standalone reasoning: send encrypted, skip plain text + if (msg.type === "reasoning") { + if (msg.encrypted_content) { + cleanConversationHistory.push({ + type: "reasoning", + summary: msg.summary, + encrypted_content: msg.encrypted_content!, + ...(msg.id ? { id: msg.id } : {}), + }) + } continue } @@ -3364,13 +3387,16 @@ export class Task extends EventEmitter implements TaskLike { const [first, ...rest] = contentArray - const hasEmbeddedReasoning = + // Embedded reasoning: encrypted (send) or plain text (skip) + const hasEncryptedReasoning = first && (first as any).type === "reasoning" && typeof (first as any).encrypted_content === "string" + const hasPlainTextReasoning = + first && (first as any).type === "reasoning" && typeof (first as any).text === "string" - if (hasEmbeddedReasoning) { + if (hasEncryptedReasoning) { const reasoningBlock = first as any - // Emit a separate reasoning item for the provider + // Send as separate reasoning item (OpenAI Native) cleanConversationHistory.push({ type: "reasoning", summary: reasoningBlock.summary ?? [], @@ -3378,7 +3404,25 @@ export class Task extends EventEmitter implements TaskLike { ...(reasoningBlock.id ? { id: reasoningBlock.id } : {}), }) - // Build assistant message without the embedded reasoning block + // Send assistant message without reasoning + 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 + } else if (hasPlainTextReasoning) { + // Strip plain text reasoning, send assistant message only let assistantContent: Anthropic.Messages.MessageParam["content"] if (rest.length === 0) { diff --git a/src/core/task/__tests__/reasoning-preservation.test.ts b/src/core/task/__tests__/reasoning-preservation.test.ts index d486339fc83..7a73d2b1d07 100644 --- a/src/core/task/__tests__/reasoning-preservation.test.ts +++ b/src/core/task/__tests__/reasoning-preservation.test.ts @@ -371,4 +371,62 @@ describe("Task reasoning preservation", () => { text: "Here is my response.", }) }) + + it("should store plain text reasoning from streaming for all providers", 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 without getEncryptedContent (like Anthropic, Gemini, etc.) + task.api = { + getModel: vi.fn().mockReturnValue({ + id: "test-model", + info: { + contextWindow: 16000, + supportsPromptCache: true, + }, + }), + } as any + + // Simulate the new path: passing reasoning as a parameter + const reasoningText = "Let me analyze this carefully. First, I'll consider the requirements..." + const assistantText = "Here is my response." + + await (task as any).addToApiConversationHistory( + { + role: "assistant", + content: [{ type: "text", text: assistantText }], + }, + reasoningText, + ) + + expect(task.apiConversationHistory).toHaveLength(1) + const stored = task.apiConversationHistory[0] as any + + expect(stored.role).toBe("assistant") + expect(Array.isArray(stored.content)).toBe(true) + + const [reasoningBlock, textBlock] = stored.content + + // Verify reasoning is stored with plain text, not encrypted + expect(reasoningBlock).toMatchObject({ + type: "reasoning", + text: reasoningText, + summary: [], + }) + + // Verify there's no encrypted_content field (that's only for OpenAI Native) + expect(reasoningBlock.encrypted_content).toBeUndefined() + + expect(textBlock).toMatchObject({ + type: "text", + text: assistantText, + }) + }) })