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

+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);