Q79: reasoning, encrypted reasoning and signatures survive tool turns

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-08 13:24:59 -04:00
1 parent 5d5bc802e9
commit d96967cfb9
13 files changed
+211 -46

No files matched your search

+1 -1
View File
@@ -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();
+2 -2
View File
@@ -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();
+16 -1
View File
@@ -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;
@@ -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 } };
}
@@ -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 } };
}
@@ -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<ProviderEventStreamMessage>): Promise<AgentTurnResult> {
const raw: string[] = [];
const textParts: string[] = [];
const fragments = new Map<number, { id: string; name: string; args: string }>();
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<ProviderEvent
const delta = first && isRecord(first.delta) ? first.delta : null;
if (!delta) continue;
if (typeof delta.content === "string") textParts.push(delta.content);
const field = REASONING_FIELDS.find((name) => 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<ProviderEvent
for (const [, fragment] of [...fragments.entries()].sort(([a], [b]) => 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. */
@@ -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<ProviderEventStreamMessage>): Promise<AgentTurnResult> {
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<ProviderEve
const parts = content && Array.isArray(content.parts) ? content.parts : [];
for (const part of parts) {
if (!isRecord(part)) continue;
replayParts.push(part);
if (typeof part.text === "string" && part.thought !== true) {
textParts.push(part.text);
} else if (isRecord(part.functionCall)) {
@@ -157,5 +164,5 @@ export async function accumulateGeminiStream(messages: AsyncIterable<ProviderEve
}
}
}
return { text: textParts.join("").trim(), toolCalls, rawResponseJson: JSON.stringify(raw) };
return { text: textParts.join("").trim(), toolCalls, rawResponseJson: JSON.stringify(raw), ...(replayParts.length > 0 ? { replay: { format: "gemini", content: replayParts } } : {}) };
}
@@ -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<Provider
const response = isRecord(message.data.response) ? message.data.response : null;
const completionOutput = response && Array.isArray(response.output) ? response.output : [];
const output = completionOutput.length > 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.");
}
+8 -1
View File
@@ -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 };
}
+3 -1
View File
@@ -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<AuditorTurn> => {
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. <approved/>");
};
+4 -4
View File
@@ -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 } }] });
});
});
+84
View File
@@ -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<ProviderEventStreamMessage> {
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<AgentTurnResult, "text" | "toolCalls" | "replay">): 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");
});
});
+19 -1
View File
@@ -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);