Store reasoning in conversation history for all providers (#9451)

This commit is contained in:
Daniel 2025-11-20 19:59:53 -05:00 committed by GitHub
parent d389771d75
commit 7715158d51
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 124 additions and 21 deletions

View file

@ -17,6 +17,7 @@ export type ApiMessage = Anthropic.MessageParam & {
type?: "reasoning"
summary?: any[]
encrypted_content?: string
text?: string
}
export async function readApiMessages({

View file

@ -646,19 +646,21 @@ export class Task extends EventEmitter<TaskEvents> 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<TaskEvents> 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[],
@ -2678,10 +2696,13 @@ export class Task extends EventEmitter<TaskEvents> implements TaskLike {
}
}
await this.addToApiConversationHistory({
role: "assistant",
content: assistantContent,
})
await this.addToApiConversationHistory(
{
role: "assistant",
content: assistantContent,
},
reasoningMessage || undefined,
)
TelemetryService.instance.captureConversationMessage(this.taskId, "assistant")
@ -3356,14 +3377,16 @@ export class Task extends EventEmitter<TaskEvents> 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
}
@ -3381,13 +3404,16 @@ export class Task extends EventEmitter<TaskEvents> 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 ?? [],
@ -3395,7 +3421,25 @@ export class Task extends EventEmitter<TaskEvents> 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) {

View file

@ -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,
})
})
})