diff --git a/src/main/services/agent-goal-tools.ts b/src/main/services/agent-goal-tools.ts index 208029e4..80d7a69d 100644 --- a/src/main/services/agent-goal-tools.ts +++ b/src/main/services/agent-goal-tools.ts @@ -144,7 +144,7 @@ async function auditGoalCompletion( tools: auditTools, })); if (turn.toolCalls.length === 0) return parseVerdict(turn.text); - messages.push({ role: "assistant", text: turn.text, toolCalls: turn.toolCalls }); + messages.push({ role: "assistant", text: turn.text, toolCalls: turn.toolCalls, ...(turn.replay ? { replay: turn.replay } : {}) }); const results: Array<{ toolCallId: string; toolName: string; output: string }> = []; for (const [index, toolCall] of turn.toolCalls.entries()) { throwIfRunStopped(); diff --git a/src/main/services/agent-run-loop.ts b/src/main/services/agent-run-loop.ts index c332312f..ebf0e749 100644 --- a/src/main/services/agent-run-loop.ts +++ b/src/main/services/agent-run-loop.ts @@ -95,14 +95,14 @@ export async function runAgentLoop(deps: AgentRunLoopDeps, input: AgentRunLoopIn } const lateMessages = input.pollNewMessages?.(); if (lateMessages) { - messages.push({ role: "assistant", text: turn.text, toolCalls: [] }, { role: "user", text: lateMessages }); + messages.push({ role: "assistant", text: turn.text, toolCalls: [], ...(turn.replay ? { replay: turn.replay } : {}) }, { role: "user", text: lateMessages }); continue; } return { finalText: turn.text, iterations: iteration, toolCallCount, lastRequestId, hitStepLimit: false }; } lastText = turn.text.trim() || lastText; - messages.push({ role: "assistant", text: turn.text, toolCalls: turn.toolCalls }); + messages.push({ role: "assistant", text: turn.text, toolCalls: turn.toolCalls, ...(turn.replay ? { replay: turn.replay } : {}) }); const results: Array<{ toolCallId: string; toolName: string; output: string }> = []; for (const [index, call] of turn.toolCalls.entries()) { throwIfRunStopped(); diff --git a/src/main/services/provider-tool-turn.ts b/src/main/services/provider-tool-turn.ts index 34e80f46..c597397d 100644 --- a/src/main/services/provider-tool-turn.ts +++ b/src/main/services/provider-tool-turn.ts @@ -22,14 +22,29 @@ export interface ToolCall { arguments: JsonRecord; } +/** The provider's own assistant output, sent back unchanged so reasoning, encrypted reasoning and signatures survive tool turns. */ +export type AssistantReplay = + | { format: "responses" | "anthropic" | "bedrock" | "gemini"; content: unknown[] } + | { format: "chat"; content: JsonRecord }; + +export type AssistantTurnMessage = { role: "assistant"; text: string; toolCalls: ToolCall[]; replay?: AssistantReplay }; + export type AgentTurnMessage = | { role: "user"; text: string } - | { role: "assistant"; text: string; toolCalls: ToolCall[] } + | AssistantTurnMessage | { role: "tool"; results: Array<{ toolCallId: string; toolName: string; output: string }>; followUp?: string }; +/** The message's replay when it came from this wire format; a replay from another format is a bug, not something to drop. */ +export function replayIn(message: AssistantTurnMessage, format: AssistantReplay["format"]): AssistantReplay | null { + if (!message.replay) return null; + if (message.replay.format !== format) throw new Error(`An assistant turn in ${message.replay.format} format cannot be sent to a ${format} provider.`); + return message.replay; +} + export interface AgentTurnResult { text: string; toolCalls: ToolCall[]; + replay?: AssistantReplay; rawResponseJson: string; /** Rate-limit headers of the response, when the provider sent any. */ rateLimitState?: ProviderObservedRateLimitState | null; diff --git a/src/main/services/provider-tool-turns/anthropic-format.ts b/src/main/services/provider-tool-turns/anthropic-format.ts index fdcf3b2f..0e28f3f3 100644 --- a/src/main/services/provider-tool-turns/anthropic-format.ts +++ b/src/main/services/provider-tool-turns/anthropic-format.ts @@ -1,6 +1,6 @@ /** Anthropic Messages API encoding/decoding, shared by anthropic + claude-pro. */ import { isRecord, type JsonRecord } from "../provider-http"; -import { type AgentTurnMessage, type ToolCall, type ToolDefinition } from "../provider-tool-turn"; +import { replayIn, type AgentTurnMessage, type AssistantReplay, type ToolCall, type ToolDefinition } from "../provider-tool-turn"; export function anthropicToolDefs(tools: ToolDefinition[]): unknown[] { return tools.map((tool) => ({ name: tool.name, description: tool.description, input_schema: tool.parameters })); @@ -12,10 +12,11 @@ export function toAnthropicMessages(messages: AgentTurnMessage[]): unknown[] { if (message.role === "user") { out.push({ role: "user", content: [{ type: "text", text: message.text }] }); } else if (message.role === "assistant") { + const replay = replayIn(message, "anthropic"); const content: unknown[] = []; if (message.text) content.push({ type: "text", text: message.text }); for (const call of message.toolCalls) content.push({ type: "tool_use", id: call.id, name: call.name, input: call.arguments }); - out.push({ role: "assistant", content }); + out.push({ role: "assistant", content: replay ? replay.content : content }); } else { const content: unknown[] = message.results.map((result) => ({ type: "tool_result", tool_use_id: result.toolCallId, content: result.output })); if (message.followUp) content.push({ type: "text", text: message.followUp }); @@ -25,7 +26,7 @@ export function toAnthropicMessages(messages: AgentTurnMessage[]): unknown[] { return out; } -export function parseAnthropicContent(data: JsonRecord): { text: string; toolCalls: ToolCall[] } { +export function parseAnthropicContent(data: JsonRecord): { text: string; toolCalls: ToolCall[]; replay: AssistantReplay } { const content = Array.isArray(data.content) ? data.content : []; const textParts: string[] = []; const toolCalls: ToolCall[] = []; @@ -36,5 +37,5 @@ export function parseAnthropicContent(data: JsonRecord): { text: string; toolCal toolCalls.push({ id: block.id, name: block.name, arguments: isRecord(block.input) ? block.input : {} }); } } - return { text: textParts.join("\n").trim(), toolCalls }; + return { text: textParts.join("\n").trim(), toolCalls, replay: { format: "anthropic", content } }; } diff --git a/src/main/services/provider-tool-turns/bedrock-format.ts b/src/main/services/provider-tool-turns/bedrock-format.ts index 3be38e99..e3bead5d 100644 --- a/src/main/services/provider-tool-turns/bedrock-format.ts +++ b/src/main/services/provider-tool-turns/bedrock-format.ts @@ -1,6 +1,6 @@ /** AWS Bedrock Converse API encoding/decoding (ported from Pi's bedrock handler). */ import { isRecord, type JsonRecord } from "../provider-http"; -import type { AgentTurnMessage, ToolCall, ToolDefinition } from "../provider-tool-turn"; +import { replayIn, type AgentTurnMessage, type AssistantReplay, type ToolCall, type ToolDefinition } from "../provider-tool-turn"; export function bedrockToolConfig(tools: ToolDefinition[]): JsonRecord { return { @@ -15,10 +15,11 @@ export function toBedrockMessages(messages: AgentTurnMessage[]): JsonRecord[] { if (message.role === "user") { out.push({ role: "user", content: [{ text: message.text }] }); } else if (message.role === "assistant") { + const replay = replayIn(message, "bedrock"); const content: unknown[] = []; if (message.text) content.push({ text: message.text }); for (const call of message.toolCalls) content.push({ toolUse: { toolUseId: call.id, name: call.name, input: call.arguments } }); - out.push({ role: "assistant", content }); + out.push({ role: "assistant", content: replay ? replay.content : content }); } else { const content: unknown[] = message.results.map((result) => ({ toolResult: { toolUseId: result.toolCallId, content: [{ text: result.output }] } })); if (message.followUp) content.push({ text: message.followUp }); @@ -28,7 +29,7 @@ export function toBedrockMessages(messages: AgentTurnMessage[]): JsonRecord[] { return out; } -export function parseBedrockOutput(data: JsonRecord): { text: string; toolCalls: ToolCall[] } { +export function parseBedrockOutput(data: JsonRecord): { text: string; toolCalls: ToolCall[]; replay: AssistantReplay } { const output = isRecord(data.output) ? data.output : null; const message = output && isRecord(output.message) ? output.message : null; const content = message && Array.isArray(message.content) ? message.content : []; @@ -44,5 +45,5 @@ export function parseBedrockOutput(data: JsonRecord): { text: string; toolCalls: if (id && name) toolCalls.push({ id, name, arguments: isRecord(toolUse.input) ? toolUse.input : {} }); } } - return { text: textParts.join("\n").trim(), toolCalls }; + return { text: textParts.join("\n").trim(), toolCalls, replay: { format: "bedrock", content } }; } diff --git a/src/main/services/provider-tool-turns/chat-format.ts b/src/main/services/provider-tool-turns/chat-format.ts index b46dba4c..22578079 100644 --- a/src/main/services/provider-tool-turns/chat-format.ts +++ b/src/main/services/provider-tool-turns/chat-format.ts @@ -1,6 +1,9 @@ /** OpenAI Chat Completions encoding/decoding (xAI, Groq, Mistral, Cerebras, OpenRouter, Kimi family). */ import { isRecord, ProviderHttpError, type JsonRecord, type ProviderEventStreamMessage } from "../provider-http"; -import { parseArguments, type AgentTurnMessage, type AgentTurnResult, type ToolCall, type ToolDefinition } from "../provider-tool-turn"; +import { parseArguments, replayIn, type AgentTurnMessage, type AgentTurnResult, type AssistantReplay, type ToolCall, type ToolDefinition } from "../provider-tool-turn"; + +/** Fields providers use for reasoning text; the first non-empty one is the provider's, as in Pi. */ +const REASONING_FIELDS = ["reasoning_content", "reasoning", "reasoning_text"] as const; export function chatToolDefs(tools: ToolDefinition[]): unknown[] { return tools.map((tool) => ({ type: "function", function: { name: tool.name, description: tool.description, parameters: tool.parameters } })); @@ -13,7 +16,8 @@ export function toChatMessages(instructions: string | null, messages: AgentTurnM if (message.role === "user") { out.push({ role: "user", content: message.text }); } else if (message.role === "assistant") { - const entry: JsonRecord = { role: "assistant", content: message.text || null }; + const replay = replayIn(message, "chat"); + const entry: JsonRecord = { role: "assistant", content: message.text || null, ...(replay ? replay.content as JsonRecord : {}) }; if (message.toolCalls.length > 0) { entry.tool_calls = message.toolCalls.map((call) => ({ id: call.id, type: "function", function: { name: call.name, arguments: JSON.stringify(call.arguments) } })); } @@ -28,7 +32,7 @@ export function toChatMessages(instructions: string | null, messages: AgentTurnM return out; } -export function parseChatChoice(data: JsonRecord): { text: string; toolCalls: ToolCall[] } { +export function parseChatChoice(data: JsonRecord): { text: string; toolCalls: ToolCall[]; replay?: AssistantReplay } { const choices = Array.isArray(data.choices) ? data.choices : []; const first = choices.length > 0 && isRecord(choices[0]) ? choices[0] : null; const message = first && isRecord(first.message) ? first.message : null; @@ -43,24 +47,37 @@ export function parseChatChoice(data: JsonRecord): { text: string; toolCalls: To const name = fn && typeof fn.name === "string" ? fn.name : ""; if (id && name) toolCalls.push({ id, name, arguments: parseArguments(fn?.arguments) }); } - return { text: text.trim(), toolCalls }; + const field = REASONING_FIELDS.find((name) => typeof message[name] === "string" && message[name]); + const details = Array.isArray(message.reasoning_details) ? message.reasoning_details.filter(isRecord) : []; + return { text: text.trim(), toolCalls, ...chatReplay(field ? { field, text: message[field] as string } : null, details) }; } -/** - * Accumulate a Chat Completions SSE stream into the final turn result. - * - * Streaming here is load-bearing, not cosmetic: reasoning models (the Kimi - * family) can think well past the 60s header deadline before a buffered - * response would start, so the turn must consume deltas as they arrive and - * let the stream idle deadline govern instead. Tool calls arrive as indexed - * fragments — the opening chunk of a call carries id and name, later chunks - * append argument text. Usage-only chunks have no choices; reasoning_content - * deltas keep the stream alive but are not part of the result. - */ +/** Reasoning to send back on the assistant message: the text under its own field, or OpenRouter's structured details. */ +function chatReplay(reasoning: { field: string; text: string } | null, details: JsonRecord[]): { replay?: AssistantReplay } { + if (details.length > 0) return { replay: { format: "chat", content: { reasoning_details: details } } }; + if (reasoning) return { replay: { format: "chat", content: { [reasoning.field]: reasoning.text } } }; + return {}; +} + +/** OpenRouter streams reasoning details as deltas; consecutive text or summary deltas form one entry, encrypted entries stay whole. */ +function appendReasoningDetail(details: JsonRecord[], detail: JsonRecord): void { + const last = details.at(-1); + const key = detail.type === "reasoning.text" ? "text" : detail.type === "reasoning.summary" ? "summary" : null; + if (!key || !last || last.type !== detail.type) { + details.push({ ...detail }); + return; + } + last[key] = String(last[key] ?? "") + String(detail[key] ?? ""); + for (const [name, value] of Object.entries(detail)) if (last[name] === undefined || last[name] === null) last[name] = value; +} + +/** Streamed so reasoning models outlive the header deadline; tool calls arrive as indexed fragments, reasoning deltas become the replay. */ export async function accumulateChatStream(messages: AsyncIterable): Promise { const raw: string[] = []; const textParts: string[] = []; const fragments = new Map(); + const reasoning = { field: "", text: "" }; + const details: JsonRecord[] = []; for await (const message of messages) { raw.push(message.rawData); if (message.data.error !== undefined) throw new ProviderHttpError(streamErrorText(message.data.error), null, streamErrorStatus(message.data.error)); @@ -69,6 +86,9 @@ export async function accumulateChatStream(messages: AsyncIterable typeof delta[name] === "string" && delta[name]); + if (field) Object.assign(reasoning, { field: reasoning.field || field, text: reasoning.text + (delta[field] as string) }); + if (Array.isArray(delta.reasoning_details)) for (const detail of delta.reasoning_details.filter(isRecord)) appendReasoningDetail(details, detail); const calls = Array.isArray(delta.tool_calls) ? delta.tool_calls : []; for (const call of calls) { if (!isRecord(call)) continue; @@ -86,7 +106,7 @@ export async function accumulateChatStream(messages: AsyncIterable a - b)) { if (fragment.id && fragment.name) toolCalls.push({ id: fragment.id, name: fragment.name, arguments: parseArguments(fragment.args) }); } - return { text: textParts.join("").trim(), toolCalls, rawResponseJson: JSON.stringify(raw) }; + return { text: textParts.join("").trim(), toolCalls, rawResponseJson: JSON.stringify(raw), ...chatReplay(reasoning.field ? reasoning : null, details) }; } /** An in-stream error object's HTTP-style code (OpenRouter sends e.g. { code: 500 }), or null. */ diff --git a/src/main/services/provider-tool-turns/gemini-format.ts b/src/main/services/provider-tool-turns/gemini-format.ts index e9224cb3..7edfd819 100644 --- a/src/main/services/provider-tool-turns/gemini-format.ts +++ b/src/main/services/provider-tool-turns/gemini-format.ts @@ -4,7 +4,7 @@ * Antigravity request wrapper in google-gemini-cli. */ import { isRecord, ProviderHttpError, type JsonRecord, type ProviderEventStreamMessage } from "../provider-http"; -import { type AgentTurnMessage, type AgentTurnResult, type ToolCall, type ToolDefinition } from "../provider-tool-turn"; +import { replayIn, type AgentTurnMessage, type AgentTurnResult, type AssistantReplay, type ToolCall, type ToolDefinition } from "../provider-tool-turn"; const ANTIGRAVITY_SYSTEM_INSTRUCTION = "You are Antigravity, a powerful agentic AI coding assistant designed by the Google Deepmind team working on Advanced Agentic Coding." + @@ -36,6 +36,11 @@ function toGeminiContents(model: string, messages: AgentTurnMessage[]): JsonReco if (message.role === "user") { contents.push({ role: "user", parts: [{ text: message.text }] }); } else if (message.role === "assistant") { + const replay = replayIn(message, "gemini"); + if (replay) { + contents.push({ role: "model", parts: replay.content }); + continue; + } const parts: unknown[] = []; if (message.text) parts.push({ text: message.text }); for (const call of message.toolCalls) { @@ -120,18 +125,19 @@ function partsToResult(parts: unknown[]): { text: string[]; toolCalls: ToolCall[ } /** Parse a non-streaming Gemini generateContent response (Vertex). */ -export function parseGeminiResponse(data: JsonRecord): { text: string; toolCalls: ToolCall[] } { +export function parseGeminiResponse(data: JsonRecord): { text: string; toolCalls: ToolCall[]; replay: AssistantReplay } { const candidates = Array.isArray(data.candidates) ? data.candidates : []; const content = candidates.length > 0 && isRecord(candidates[0]) && isRecord(candidates[0].content) ? candidates[0].content : null; const parts = content && Array.isArray(content.parts) ? content.parts : []; const result = partsToResult(parts); - return { text: result.text.join("").trim(), toolCalls: result.toolCalls }; + return { text: result.text.join("").trim(), toolCalls: result.toolCalls, replay: { format: "gemini", content: parts } }; } export async function accumulateGeminiStream(messages: AsyncIterable): Promise { const raw: string[] = []; const textParts: string[] = []; const toolCalls: ToolCall[] = []; + const replayParts: unknown[] = []; let counter = 0; for await (const message of messages) { raw.push(message.rawData); @@ -146,6 +152,7 @@ export async function accumulateGeminiStream(messages: AsyncIterable 0 ? { replay: { format: "gemini", content: replayParts } } : {}) }; } diff --git a/src/main/services/provider-tool-turns/responses-format.ts b/src/main/services/provider-tool-turns/responses-format.ts index 5834a18a..9c812a75 100644 --- a/src/main/services/provider-tool-turns/responses-format.ts +++ b/src/main/services/provider-tool-turns/responses-format.ts @@ -4,7 +4,7 @@ * `convertResponsesTools` and the Responses streaming event shapes. */ import { isRecord, ProviderHttpError, type JsonRecord, type ProviderEventStreamMessage } from "../provider-http"; -import { parseArguments, type AgentTurnMessage, type AgentTurnResult, type ToolCall, type ToolDefinition } from "../provider-tool-turn"; +import { parseArguments, replayIn, type AgentTurnMessage, type AgentTurnResult, type AssistantReplay, type ToolCall, type ToolDefinition } from "../provider-tool-turn"; export function responsesToolDefs(tools: ToolDefinition[], strict: boolean | null = false): unknown[] { // `strict` must be a boolean or omitted. Passing `strict: null` (the Codex @@ -25,6 +25,11 @@ export function toResponsesInput(messages: AgentTurnMessage[]): unknown[] { if (message.role === "user") { items.push({ role: "user", content: [{ type: "input_text", text: message.text }] }); } else if (message.role === "assistant") { + const replay = replayIn(message, "responses"); + if (replay) { + items.push(...(replay.content as unknown[])); + continue; + } if (message.text) { items.push({ type: "message", role: "assistant", content: [{ type: "output_text", text: message.text, annotations: [] }] }); } @@ -42,7 +47,7 @@ export function toResponsesInput(messages: AgentTurnMessage[]): unknown[] { } /** Parse a non-streaming Responses payload (`data.output[]`). */ -export function parseResponsesOutput(data: JsonRecord): { text: string; toolCalls: ToolCall[] } { +export function parseResponsesOutput(data: JsonRecord): { text: string; toolCalls: ToolCall[]; replay: AssistantReplay } { const output = Array.isArray(data.output) ? data.output : []; const textParts: string[] = []; const toolCalls: ToolCall[] = []; @@ -58,7 +63,14 @@ export function parseResponsesOutput(data: JsonRecord): { text: string; toolCall } } } - return { text: textParts.join("\n").trim(), toolCalls }; + return { text: textParts.join("\n").trim(), toolCalls, replay: { format: "responses", content: responsesReplay(output) } }; +} + +/** Output items as next-turn input, the Codex way under store:false: no item ids, and reasoning only when it carries its encrypted content. */ +function responsesReplay(output: unknown[]): unknown[] { + return output.filter(isRecord) + .filter((item) => item.type !== "reasoning" || typeof item.encrypted_content === "string") + .map(({ id: _id, ...item }) => item); } function eventType(message: ProviderEventStreamMessage): string | null { @@ -113,13 +125,11 @@ export async function accumulateResponsesStream(messages: AsyncIterable 0 ? completionOutput : streamed; - const parsed = parseResponsesOutput({ output }); - return { text: parsed.text, toolCalls: parsed.toolCalls, rawResponseJson: JSON.stringify(raw) }; + return { ...parseResponsesOutput({ output }), rawResponseJson: JSON.stringify(raw) }; } } if (streamed.length > 0) { - const parsed = parseResponsesOutput({ output: streamed }); - return { text: parsed.text, toolCalls: parsed.toolCalls, rawResponseJson: JSON.stringify(raw) }; + return { ...parseResponsesOutput({ output: streamed }), rawResponseJson: JSON.stringify(raw) }; } throw new Error("Responses stream ended before a completion event."); } diff --git a/src/main/services/run-context-compaction.ts b/src/main/services/run-context-compaction.ts index ba521c97..2ab18a3c 100644 --- a/src/main/services/run-context-compaction.ts +++ b/src/main/services/run-context-compaction.ts @@ -31,5 +31,12 @@ export function compactToolHistory(messages: AgentTurnMessage[], keepRecent = KE }); return { ...message, results }; }); - return { messages: compacted, elided }; + const firstRewritten = compacted.findIndex((message, index) => message.role === "tool" && message.results.some((result, at) => result !== (messages[index] as typeof message).results[at])); + if (firstRewritten < 0) return { messages: compacted, elided }; + // Bedrock's reasoning signature hashes every earlier message, so its turns after a rewrite go back unsigned; Gemini requires its signatures kept. + const unsigned = compacted.map((message, index) => { + if (index < firstRewritten || message.role !== "assistant" || message.replay?.format !== "bedrock") return message; + return { role: message.role, text: message.text, toolCalls: message.toolCalls }; + }); + return { messages: unsigned, elided }; } diff --git a/tests/unit/agent-goal-tools.test.ts b/tests/unit/agent-goal-tools.test.ts index 07d238c5..3d07cc9a 100644 --- a/tests/unit/agent-goal-tools.test.ts +++ b/tests/unit/agent-goal-tools.test.ts @@ -84,15 +84,17 @@ describe("complete_goal with independent audit", () => { test("an approving auditor closes the goal; the auditor gets read-only workspace access", async () => { const h = createHarness(); const seenTools: string[][] = []; + const replay = { format: "anthropic" as const, content: [{ type: "thinking", thinking: "check", signature: "SIG" }] }; let turns = 0; const auditor = async (input: AuditorInput): Promise => { seenTools.push(input.tools.map((tool) => tool.name)); turns += 1; if (turns === 1) { - return turnOf("Inspecting.", [{ id: "c1", name: "read_file", arguments: { path: "src/index.ts" } }]); + return { ...turnOf("Inspecting.", [{ id: "c1", name: "read_file", arguments: { path: "src/index.ts" } }]), replay }; } const readResult = input.messages.at(-1); expect(readResult?.role).toBe("tool"); + expect(input.messages.at(-2)).toMatchObject({ role: "assistant", replay }); return turnOf("The implementation exists and runs. "); }; diff --git a/tests/unit/provider-tool-turns.test.ts b/tests/unit/provider-tool-turns.test.ts index 313386cb..16f1bd2f 100644 --- a/tests/unit/provider-tool-turns.test.ts +++ b/tests/unit/provider-tool-turns.test.ts @@ -51,7 +51,7 @@ describe("openai responses encoding", () => { { type: "message", content: [{ type: "output_text", text: "done" }] }, { type: "function_call", call_id: "c2", name: "create_task", arguments: '{"title":"x"}' }, ] }; - expect(parseResponsesOutput(data)).toEqual({ text: "done", toolCalls: [{ id: "c2", name: "create_task", arguments: { title: "x" } }] }); + expect(parseResponsesOutput(data)).toMatchObject({ text: "done", toolCalls: [{ id: "c2", name: "create_task", arguments: { title: "x" } }] }); }); test("accumulates the SSE completion event", async () => { @@ -131,7 +131,7 @@ describe("anthropic encoding", () => { test("parses text and tool_use blocks", () => { const data = { content: [{ type: "text", text: "ok" }, { type: "tool_use", id: "tu", name: "f", input: { x: 1 } }] }; - expect(parseAnthropicContent(data)).toEqual({ text: "ok", toolCalls: [{ id: "tu", name: "f", arguments: { x: 1 } }] }); + expect(parseAnthropicContent(data)).toMatchObject({ text: "ok", toolCalls: [{ id: "tu", name: "f", arguments: { x: 1 } }] }); }); }); @@ -184,7 +184,7 @@ describe("bedrock converse encoding", () => { test("parses the converse output", () => { const data = { output: { message: { content: [{ text: "ok" }, { toolUse: { toolUseId: "t1", name: "f", input: { x: 1 } } }] } } }; - expect(parseBedrockOutput(data)).toEqual({ text: "ok", toolCalls: [{ id: "t1", name: "f", arguments: { x: 1 } }] }); + expect(parseBedrockOutput(data)).toMatchObject({ text: "ok", toolCalls: [{ id: "t1", name: "f", arguments: { x: 1 } }] }); }); }); @@ -194,7 +194,7 @@ describe("vertex (gemini) encoding", () => { expect((body.systemInstruction as JsonRecord).parts).toEqual([{ text: "sys" }]); expect(body.tools).toEqual([{ functionDeclarations: [{ name: "f", description: "d", parametersJsonSchema: { type: "object" } }] }]); const parsed = parseGeminiResponse({ candidates: [{ content: { parts: [{ text: "hi" }, { functionCall: { name: "f", args: { x: 1 }, id: "v1" } }] } }] }); - expect(parsed).toEqual({ text: "hi", toolCalls: [{ id: "v1", name: "f", arguments: { x: 1 } }] }); + expect(parsed).toMatchObject({ text: "hi", toolCalls: [{ id: "v1", name: "f", arguments: { x: 1 } }] }); }); }); diff --git a/tests/unit/reasoning-replay.test.ts b/tests/unit/reasoning-replay.test.ts new file mode 100644 index 00000000..f6b1d463 --- /dev/null +++ b/tests/unit/reasoning-replay.test.ts @@ -0,0 +1,84 @@ +import { describe, expect, test } from "vitest"; +import type { JsonRecord, ProviderEventStreamMessage } from "../../src/main/services/provider-http"; +import type { AgentTurnMessage, AgentTurnResult } from "../../src/main/services/provider-tool-turn"; +import { accumulateResponsesStream, parseResponsesOutput, toResponsesInput } from "../../src/main/services/provider-tool-turns/responses-format"; +import { accumulateChatStream, parseChatChoice, toChatMessages } from "../../src/main/services/provider-tool-turns/chat-format"; +import { parseAnthropicContent, toAnthropicMessages } from "../../src/main/services/provider-tool-turns/anthropic-format"; +import { parseBedrockOutput, toBedrockMessages } from "../../src/main/services/provider-tool-turns/bedrock-format"; +import { accumulateGeminiStream, parseGeminiResponse, vertexRequestBody } from "../../src/main/services/provider-tool-turns/gemini-format"; + +async function* stream(items: JsonRecord[]): AsyncIterable { + for (const data of items) yield { eventName: null, data, rawData: JSON.stringify(data) }; +} + +/** The conversation the run loop builds after a turn that called one tool. */ +function after(turn: Pick): AgentTurnMessage[] { + return [ + { role: "user", text: "go" }, + { role: "assistant", text: turn.text, toolCalls: turn.toolCalls, ...(turn.replay ? { replay: turn.replay } : {}) }, + { role: "tool", results: turn.toolCalls.map((call) => ({ toolCallId: call.id, toolName: call.name, output: "ok" })) }, + ]; +} + +describe("reasoning survives tool turns", () => { + test("Responses: encrypted reasoning goes back before the call, without item ids; unencrypted reasoning cannot be replayed under store:false", async () => { + const turn = await accumulateResponsesStream(stream([ + { type: "response.output_item.done", item: { type: "reasoning", id: "rs_1", summary: [], encrypted_content: "ENC" } }, + { type: "response.output_item.done", item: { type: "function_call", id: "fc_1", call_id: "c1", name: "f", arguments: "{}" } }, + { type: "response.completed", response: { output: [] } }, + ])); + expect(toResponsesInput(after(turn)).slice(1, 3)).toEqual([ + { type: "reasoning", summary: [], encrypted_content: "ENC" }, + { type: "function_call", call_id: "c1", name: "f", arguments: "{}" }, + ]); + const plain = parseResponsesOutput({ output: [{ type: "reasoning", id: "rs_2", summary: [] }, { type: "message", id: "msg_1", role: "assistant", content: [{ type: "output_text", text: "hi" }] }] }); + expect(plain.replay?.content).toEqual([{ type: "message", role: "assistant", content: [{ type: "output_text", text: "hi" }] }]); + }); + + test("Anthropic: thinking and redacted thinking blocks go back unmodified with their signatures", () => { + const content = [ + { type: "thinking", thinking: "plan", signature: "SIG" }, + { type: "redacted_thinking", data: "OPAQUE" }, + { type: "tool_use", id: "t1", name: "f", input: { a: 1 } }, + ]; + const sent = toAnthropicMessages(after(parseAnthropicContent({ content }))) as JsonRecord[]; + expect(sent[1]).toEqual({ role: "assistant", content }); + }); + + test("Bedrock: reasoningContent with its signature goes back unmodified", () => { + const content = [{ reasoningContent: { reasoningText: { text: "plan", signature: "SIG" } } }, { toolUse: { toolUseId: "t1", name: "f", input: {} } }]; + const sent = toBedrockMessages(after(parseBedrockOutput({ output: { message: { role: "assistant", content } } }))); + expect(sent[1]).toEqual({ role: "assistant", content }); + }); + + test("Gemini: thought signatures on parts go back, streamed or not", async () => { + const parts = [{ text: "plan", thought: true }, { functionCall: { name: "f", args: {} }, thoughtSignature: "SIG" }]; + const vertex = vertexRequestBody({ model: "gemini-3-pro", instructions: null, messages: after(parseGeminiResponse({ candidates: [{ content: { parts } }] })), tools: [] }); + expect((vertex.contents as JsonRecord[])[1]).toEqual({ role: "model", parts }); + const streamed = await accumulateGeminiStream(stream(parts.map((part) => ({ response: { candidates: [{ content: { parts: [part] } }] } })))); + expect(streamed.replay).toEqual({ format: "gemini", content: parts }); + }); + + test("Chat: streamed reasoning goes back under the field it came in, and OpenRouter details are merged into whole entries", async () => { + const kimi = await accumulateChatStream(stream([ + { choices: [{ delta: { reasoning_content: "think " } }] }, + { choices: [{ delta: { reasoning_content: "hard" } }] }, + { choices: [{ delta: { tool_calls: [{ index: 0, id: "c1", function: { name: "f", arguments: "{}" } }] } }] }, + ])); + expect((toChatMessages(null, after(kimi)) as JsonRecord[])[1]).toMatchObject({ role: "assistant", reasoning_content: "think hard" }); + const openrouter = await accumulateChatStream(stream([ + { choices: [{ delta: { reasoning: "a", reasoning_details: [{ type: "reasoning.text", text: "a", index: 0 }] } }] }, + { choices: [{ delta: { reasoning: "b", reasoning_details: [{ type: "reasoning.text", text: "b", signature: "SIG", index: 0 }] } }] }, + { choices: [{ delta: { reasoning_details: [{ type: "reasoning.encrypted", data: "ENC", index: 1 }] } }] }, + { choices: [{ delta: { content: "done" } }] }, + ])); + expect(openrouter.replay).toEqual({ format: "chat", content: { reasoning_details: [{ type: "reasoning.text", text: "ab", signature: "SIG", index: 0 }, { type: "reasoning.encrypted", data: "ENC", index: 1 }] } }); + const azure = parseChatChoice({ choices: [{ message: { content: "x", reasoning: "why" } }] }); + expect(azure.replay).toEqual({ format: "chat", content: { reasoning: "why" } }); + }); + + test("a turn from one wire format is never silently replayed in another", () => { + const messages = after(parseAnthropicContent({ content: [{ type: "text", text: "hi" }] })); + expect(() => toChatMessages(null, messages)).toThrow("anthropic format cannot be sent to a chat provider"); + }); +}); diff --git a/tests/unit/run-context-compaction.test.ts b/tests/unit/run-context-compaction.test.ts index 590b99e7..39b676f3 100644 --- a/tests/unit/run-context-compaction.test.ts +++ b/tests/unit/run-context-compaction.test.ts @@ -8,7 +8,7 @@ import { DatabaseConnection } from "../../src/main/database/connection"; import { OrganizationService } from "../../src/main/services/organization-service"; import { ChatService } from "../../src/main/services/chat-service"; import { IntegrationKeyStore } from "../../src/main/services/integration-key-store"; -import type { AgentTurnMessage, AgentTurnResult } from "../../src/main/services/provider-tool-turn"; +import type { AgentTurnMessage, AgentTurnResult, AssistantReplay } from "../../src/main/services/provider-tool-turn"; function toolTurn(name: string, output: string): AgentTurnMessage[] { const id = `c_${randomUUID()}`; @@ -40,6 +40,24 @@ describe("tool history compaction", () => { expect(compactToolHistory(compacted).elided).toBe(0); }); + test("Bedrock turns after a rewritten result lose their history-bound signature; earlier ones and Gemini signatures stay", () => { + const replay = { format: "bedrock" as const, content: [{ reasoningContent: { reasoningText: { text: "plan", signature: "SIG" } } }] }; + const gemini = { format: "gemini" as const, content: [{ functionCall: { name: "read_file", args: {} }, thoughtSignature: "SIG" }] }; + const signed = (turn: AgentTurnMessage[], with_: AssistantReplay = replay) => turn.map((message) => (message.role === "assistant" ? { ...message, replay: with_ } : message)); + const messages: AgentTurnMessage[] = [ + { role: "user", text: "task" }, + ...signed(toolTurn("read_file", "x".repeat(2000))), + ...signed(toolTurn("read_file", "short")), + ...signed(toolTurn("read_file", "short")), + ...signed(toolTurn("read_file", "short")), + ]; + const replays = compactToolHistory(messages).messages.flatMap((message) => (message.role === "assistant" ? [message.replay ?? null] : [])); + expect(replays).toEqual([replay, null, null, null]); + expect(compactToolHistory(signed(toolTurn("read_file", "short"))).messages[0]).toMatchObject({ replay }); + const geminiTurns = [...signed(toolTurn("read_file", "x".repeat(2000)), gemini), ...signed(toolTurn("read_file", "a"), gemini), ...signed(toolTurn("read_file", "b"), gemini), ...signed(toolTurn("read_file", "c"), gemini)]; + expect(compactToolHistory(geminiTurns).messages.flatMap((message) => (message.role === "assistant" ? [message.replay] : []))).toEqual([gemini, gemini, gemini, gemini]); + }); + test("the run loop sends compacted history to the provider", async () => { const connection = new DatabaseConnection(join(tmpdir(), `ac-compact-${randomUUID()}.db`)); const organization = new OrganizationService(connection.db);