Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
147 changes: 111 additions & 36 deletions src/core/task/Task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
// 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) {
this.apiConversationHistory.push({
const reasoningBlock = {
type: "reasoning",
summary: [],
summary: [] as any[],
encrypted_content: reasoningData.encrypted_content,
...(reasoningData.id ? { id: reasoningData.id } : {}),
ts: Date.now(),
} as any)
}
}

const messageWithTs = {
...message,
...(responseId ? { id: responseId } : {}),
ts: Date.now(),
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() }
Expand Down Expand Up @@ -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(
Expand Down Expand Up @@ -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)
}
Expand Down
46 changes: 46 additions & 0 deletions src/core/task/__tests__/reasoning-preservation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.",
})
})
})
Loading