diff --git a/src/adapters/google-antigravity-replay.ts b/src/adapters/google-antigravity-replay.ts index d3ffa480c8..c03c55b662 100644 --- a/src/adapters/google-antigravity-replay.ts +++ b/src/adapters/google-antigravity-replay.ts @@ -446,17 +446,37 @@ function canonicalJsonBounded(value: unknown, maxBytes: number): string | null { */ function functionCallKey(name: unknown, args: unknown): string | undefined { if (typeof name !== "string" || name.length === 0) return undefined; + const hash = createHash("sha256"); + updateHashWithString(hash, name); let canonical: string | null; try { canonical = canonicalJsonBounded(args ?? {}, REPLAY_MAX_CANONICAL_ARGS_BYTES); } catch { - canonical = ""; + canonical = null; + } + if (canonical !== null) { + updateHashWithString(hash, canonical); + return hash.digest("hex"); + } + const buf = Buffer.allocUnsafe(8192); + let offset = 0; + const sink = (chunk: string) => { + for (let index = 0; index < chunk.length; index += 1) { + buf.writeUInt16LE(chunk.charCodeAt(index), offset); + offset += 2; + if (offset === buf.length) { + hash.update(buf); + offset = 0; + } + } + }; + try { + writeCanonicalJson(args ?? {}, sink); + if (offset > 0) hash.update(buf.subarray(0, offset)); + return hash.digest("hex"); + } catch { + return undefined; } - if (canonical === null) return undefined; - const hash = createHash("sha256"); - updateHashWithString(hash, name); - updateHashWithString(hash, canonical); - return hash.digest("hex"); } /** Test-only key-derivation seam: the fixed-key regression cannot go red diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 9a074c0506..27c69fcc18 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -16,7 +16,7 @@ import { isAllowedToolChoice, namespacedToolName, resolveToolChoiceWireName, too import { contentPartsToText, parseDataUrl } from "./image"; import { getVertexAccessToken } from "../lib/gcp-adc"; import { fetchAntigravityWithRetry, fetchVertexWithRetry } from "./google-http"; -import { safeAntigravityHttpErrorMessage, safeVertexHttpErrorMessage } from "./google-errors"; +import { safeAntigravityHttpErrorMessage, safeGoogleHttpErrorMessage, safeVertexHttpErrorMessage } from "./google-errors"; import { isVertexTruncatedTurn, vertexTruncationErrorMessage } from "./google-truncation"; import { ANTIGRAVITY_REQUEST_UA, antigravitySessionId, isLikelyRealThoughtSignature, sanitizeAntigravityClaudeSignatures } from "./google-antigravity-wire"; import { compileGoogleWireBody } from "./google-wire-compiler"; @@ -323,9 +323,15 @@ function artifactMarkdownUrl(filePath: string): string { return artifactHttpUrl(filePath).replace(/([()])/g, "\\$1"); } +/** Short stable fingerprint for replay-cache namespaces (never the raw secret). */ +function shortReplayFingerprint(value: string): string { + return createHash("sha256").update(value).digest("hex").slice(0, 12); +} + interface GoogleResponsePart { text?: string; thought?: boolean; + thoughtSignature?: string; functionCall?: { name: string; args: unknown }; } @@ -351,6 +357,9 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte // another merely because the public model id and first prompt happen to match. let vertexReplayModel: string | undefined; let vertexReplaySession: string | undefined; + // AI-Studio direct mode shares the same stateless signature replay, namespaced below. + let directReplayModel: string | undefined; + let directReplaySession: string | undefined; let restoreGoogleToolName = (name: string): string => name; return { name: "google", @@ -361,10 +370,18 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte ? { fetchResponse: (request: AdapterRequest, ctx?: AdapterFetchContext): Promise => (provider.googleMode === "cloud-code-assist" ? fetchAntigravityWithRetry : fetchVertexWithRetry)(request, ctx), - formatErrorBody: (status: number, _headers: Headers, payloadText: string): string => - (provider.googleMode === "cloud-code-assist" ? safeAntigravityHttpErrorMessage : safeVertexHttpErrorMessage)(status, payloadText), } : {}), + // AI-Studio direct mode keeps the default server fetch path but still formats upstream error + // bodies (the web-search loop and server error path read formatErrorBody when present). + formatErrorBody: (status: number, _headers: Headers, payloadText: string): string => { + const label = provider.googleMode === "cloud-code-assist" + ? "Antigravity" + : provider.googleMode === "vertex" + ? "Vertex AI" + : "Gemini"; + return safeGoogleHttpErrorMessage(label, status, payloadText); + }, async buildRequest(parsed: OcxParsedRequest) { const routedModelId = provider.googleMode === "cloud-code-assist" @@ -526,6 +543,15 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte headers["x-goog-api-key"] = apiKey; const compiled = compileGoogleWireBody(body); + // Direct (AI Studio) Gemini shares the stateless thought-signature replay cache with + // CCA/Vertex: signatures observed on the response stream are re-injected into replayed + // functionCall parts the client cannot round-trip, scoped per credential fingerprint + + // wire model so opaque tokens cannot cross keys or routes. + directReplayModel = `direct:${shortReplayFingerprint(provider.baseUrl ?? "")}:${shortReplayFingerprint(apiKey)}:${routedModelId}`; + directReplaySession = vertexReplaySessionId(parsed); + if (Array.isArray((compiled.body as { contents?: unknown[] }).contents)) { + applyAntigravityReplay(directReplayModel, directReplaySession, (compiled.body as { contents: unknown[] }).contents); + } restoreGoogleToolName = compiled.restoreToolName; return { url, method: "POST", headers, body: JSON.stringify(compiled.body) }; }, @@ -584,9 +610,13 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte const err = chunk.error as { message?: string } | undefined; // Clear-on-invalid: a signature rejection means our replayed thoughtSignatures are stale. // Drop the cache entry so the next turn starts clean instead of re-injecting a bad sig. - const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel; - const replaySession = provider.googleMode === "cloud-code-assist" ? antigravitySession : vertexReplaySession; - if ((provider.googleMode === "cloud-code-assist" || provider.googleMode === "vertex") + const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel + : provider.googleMode === "vertex" ? vertexReplayModel + : directReplayModel; + const replaySession = provider.googleMode === "cloud-code-assist" ? antigravitySession + : provider.googleMode === "vertex" ? vertexReplaySession + : directReplaySession; + if ((provider.googleMode === "cloud-code-assist" || provider.googleMode === "vertex" || provider.googleMode === "ai-studio" || provider.googleMode == null) && replayModel && replaySession && /signature|invalid_argument|invalid argument/i.test(err?.message ?? "")) { clearAntigravityReplay(replayModel, replaySession); @@ -642,9 +672,13 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte const parts = candidate.content?.parts as GoogleResponsePart[] | undefined; // Record Gemini thought signatures for the next stateless tool-result turn. Vertex and // Antigravity use separate model namespaces so opaque provider state cannot cross routes. - const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel; - const replaySession = provider.googleMode === "cloud-code-assist" ? antigravitySession : vertexReplaySession; - if ((provider.googleMode === "cloud-code-assist" || provider.googleMode === "vertex") + const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel + : provider.googleMode === "vertex" ? vertexReplayModel + : directReplayModel; + const replaySession = provider.googleMode === "cloud-code-assist" ? antigravitySession + : provider.googleMode === "vertex" ? vertexReplaySession + : directReplaySession; + if ((provider.googleMode === "cloud-code-assist" || provider.googleMode === "vertex" || provider.googleMode === "ai-studio" || provider.googleMode == null) && parts && replayModel && replaySession) { observeAntigravityReplay(replayModel, replaySession, parts as unknown[]); } @@ -674,7 +708,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte const id = `call_${crypto.randomUUID().slice(0, 8)}`; toolCallsStarted++; emittedContentEvent = true; - yield { type: "tool_call_start", id, name: restoreGoogleToolName(part.functionCall.name) }; + yield { type: "tool_call_start", id, name: restoreGoogleToolName(part.functionCall.name), thoughtSignature: part.thoughtSignature }; yield { type: "tool_call_delta", arguments: JSON.stringify(part.functionCall.args ?? {}) }; yield { type: "tool_call_end" }; } @@ -864,9 +898,13 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte if (candidates?.[0]?.content?.parts) { // Non-streaming Google-family response: observe thought signatures for the next turn, // using the same transport-scoped namespace as the streaming path. - const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel : vertexReplayModel; - const replaySession = provider.googleMode === "cloud-code-assist" ? antigravitySession : vertexReplaySession; - if ((provider.googleMode === "cloud-code-assist" || provider.googleMode === "vertex") + const replayModel = provider.googleMode === "cloud-code-assist" ? antigravityModel + : provider.googleMode === "vertex" ? vertexReplayModel + : directReplayModel; + const replaySession = provider.googleMode === "cloud-code-assist" ? antigravitySession + : provider.googleMode === "vertex" ? vertexReplaySession + : directReplaySession; + if ((provider.googleMode === "cloud-code-assist" || provider.googleMode === "vertex" || provider.googleMode === "ai-studio" || provider.googleMode == null) && replayModel && replaySession) { observeAntigravityReplay(replayModel, replaySession, candidates[0].content.parts as unknown[]); } @@ -890,7 +928,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte if (part.functionCall) { const id = `call_${crypto.randomUUID().slice(0, 8)}`; toolCallsStarted++; - events.push({ type: "tool_call_start", id, name: restoreGoogleToolName(part.functionCall.name) }); + events.push({ type: "tool_call_start", id, name: restoreGoogleToolName(part.functionCall.name), thoughtSignature: part.thoughtSignature }); events.push({ type: "tool_call_delta", arguments: JSON.stringify(part.functionCall.args ?? {}) }); events.push({ type: "tool_call_end" }); } diff --git a/src/types.ts b/src/types.ts index aaf4fe18d2..af3f6aada0 100644 --- a/src/types.ts +++ b/src/types.ts @@ -328,7 +328,7 @@ export type AdapterEvent = // Never rendered — it only rides the reasoning item's envelope so the next request can replay it. | { type: "kiro_redacted_reasoning"; data: string } | { type: "reasoning_raw_delta"; text: string } - | { type: "tool_call_start"; id: string; name: string } + | { type: "tool_call_start"; id: string; name: string; thoughtSignature?: string } | { type: "tool_call_delta"; arguments: string } | { type: "tool_call_end" } /** Internal boundary between a guarded first pass and its one-shot continuation. */ diff --git a/src/web-search/executor.ts b/src/web-search/executor.ts index e5cd585b61..92bd9b0869 100644 --- a/src/web-search/executor.ts +++ b/src/web-search/executor.ts @@ -58,7 +58,9 @@ export async function runWebSearch( model: settings.model, instructions: settings.describeImages ? BASE_INSTRUCTION + IMAGE_INSTRUCTION : BASE_INSTRUCTION, input: [{ type: "message", role: "user", content: [{ type: "input_text", text: query }] }], - tools: [hostedTool], + // The ChatGPT (codex) backend rejects extra web_search parameters (observed: "Unknown parameter: + // 'tools[0].max_results'"), so replay only the bare hosted tool shape. + tools: [{ type: "web_search" }], tool_choice: "auto", reasoning: { effort: settings.reasoning }, // NOTE: the ChatGPT (codex) backend rejects `max_output_tokens` ("Unsupported parameter") and diff --git a/src/web-search/loop.ts b/src/web-search/loop.ts index 4be7bdbbc6..d769e59870 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -32,6 +32,8 @@ interface WebSearchCall { // empty array means the model called the tool with neither `query` nor `queries` (handled as an // empty-query placeholder). queries: string[]; + // Gemini/Antigravity thought signature from the original tool call, replayed on iteration #2 so the upstream functionCall part stays valid. + thoughtSignature?: string; } /** @@ -69,7 +71,7 @@ export function scanEventsForWebSearch(events: AdapterEvent[]): { const passthrough: AdapterEvent[] = []; let hasRealToolCall = false; let hasMalformedToolCall = false; - let pending: { name: string; id: string; argsBuf: string; closed: boolean; events: AdapterEvent[] } | null = null; + let pending: { name: string; id: string; argsBuf: string; closed: boolean; signature?: string; events: AdapterEvent[] } | null = null; const isBlank = (value: string): boolean => value.trim().length === 0; const flushPending = (): void => { // A pending call that never saw tool_call_end is structurally malformed. @@ -84,7 +86,7 @@ export function scanEventsForWebSearch(events: AdapterEvent[]): { if (e.type === "tool_call_start") { flushPending(); if (isBlank(e.id) || isBlank(e.name)) hasMalformedToolCall = true; - pending = { name: e.name, id: e.id, argsBuf: "", closed: false, events: [e] }; + pending = { name: e.name, id: e.id, argsBuf: "", closed: false, signature: e.thoughtSignature, events: [e] }; } else if (e.type === "tool_call_delta") { // Orphan delta (no open call) is malformed. if (!pending) hasMalformedToolCall = true; @@ -100,7 +102,7 @@ export function scanEventsForWebSearch(events: AdapterEvent[]): { pending.events.push(e); pending.closed = true; if (pending.name === WEB_SEARCH_TOOL_NAME) { - calls.push({ id: pending.id, queries: parseQueries(pending.argsBuf) }); + calls.push({ id: pending.id, queries: parseQueries(pending.argsBuf), ...(pending.signature ? { thoughtSignature: pending.signature } : {}) }); } else { passthrough.push(...pending.events); if (!isBlank(pending.id) && !isBlank(pending.name)) hasRealToolCall = true; @@ -678,7 +680,7 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise { expect(antigravityReplayKeyForTests(MODEL, SESSION)).toBe(antigravityReplayKeyForTests(MODEL, SESSION)); }); - test("canonicalization overflow skips the call without an unbounded intermediate", () => { - // Args whose canonical form exceeds 64 KiB: rejected DURING the walk. + test("large arguments stream into SHA-256 and preserve thought signatures across replay", () => { + // Large args (>64 KiB) derive a deterministic fixed-size key via incremental streaming + // without unbounded string allocation or dropping the thought signature (#1772). const bigArgs = { blob: "x".repeat(256 * 1024) }; - expect(antigravityFunctionCallKeyForTests("f", bigArgs)).toBeUndefined(); + const key = antigravityFunctionCallKeyForTests("f", bigArgs); + expect(typeof key).toBe("string"); + expect(key).toMatch(/^[0-9a-f]{64}$/); observeAntigravityReplay(MODEL, SESSION, [fcPart("f", bigArgs, "sig-1234567890abcdef")]); const metrics = antigravityReplayMetrics(); - expect(metrics.calls).toBe(0); - expect(metrics.sessions).toBe(0); - expect(metrics.totalBytes).toBe(0); - // A conforming call right after still caches normally. - observeAntigravityReplay(MODEL, SESSION, [fcPart("g", { a: 1 }, "sig-1234567890abcdef")]); - expect(antigravityReplayMetrics().calls).toBe(1); + expect(metrics.calls).toBe(1); + expect(metrics.sessions).toBe(1); + const contents = [{ role: "model", parts: [fcPart("f", bigArgs)] }]; + applyAntigravityReplay(MODEL, SESSION, contents); + expect((contents[0].parts[0] as { thoughtSignature?: string }).thoughtSignature).toBe("sig-1234567890abcdef"); }); test("canonical equality is preserved for nested structures", () => { diff --git a/tests/google-direct-thought-signature.test.ts b/tests/google-direct-thought-signature.test.ts new file mode 100644 index 0000000000..f672f4aadf --- /dev/null +++ b/tests/google-direct-thought-signature.test.ts @@ -0,0 +1,122 @@ +import { beforeEach, describe, expect, test } from "bun:test"; +import { createGoogleAdapter as createGoogleAdapterProduction } from "../src/adapters/google"; +import { __resetAntigravityReplayCache } from "../src/adapters/google-antigravity-replay"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../src/types"; +import { withTestTranslatorBudget } from "./helpers/translator-budget"; + +const createGoogleAdapter = (...args: Parameters) => + withTestTranslatorBudget(createGoogleAdapterProduction(...args)); + +const SIGNATURE = "CiQAx-direct-thought-signature-0123456789abcdef"; +const MODEL = "gemini-3.7-flash"; + +const provider = { + adapter: "google", + googleMode: "ai-studio", + baseUrl: "https://generativelanguage.googleapis.com", + apiKey: "direct-test-key", +} as OcxProviderConfig; + +function request(messages: OcxParsedRequest["context"]["messages"], stream: boolean): OcxParsedRequest { + return { + modelId: MODEL, + stream, + context: { + messages, + systemPrompt: [], + tools: [{ name: "shell_command", description: "run a command", parameters: { type: "object" } }], + }, + options: {}, + } as unknown as OcxParsedRequest; +} + +const firstTurn = (stream: boolean) => request([{ role: "user", content: "run pwd" }], stream); + +const continuation = () => request([ + { role: "user", content: "run pwd" }, + { + role: "assistant", + content: [{ + type: "toolCall", + id: "call_shell_1", + name: "shell_command", + arguments: { command: "pwd" }, + }], + }, + { + role: "toolResult", + toolCallId: "call_shell_1", + toolName: "shell_command", + content: "/workspace", + }, +], false); + +function scoped(parsed: OcxParsedRequest, threadId: string): OcxParsedRequest { + parsed._clientThreadId = threadId; + return parsed; +} + +function responseBody(): Record { + return { + candidates: [{ + content: { + role: "model", + parts: [{ + functionCall: { name: "shell_command", args: { command: "pwd" } }, + thoughtSignature: SIGNATURE, + }], + }, + finishReason: "STOP", + }], + usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 2 }, + }; +} + +function replayedFunctionCall(body: string): Record { + const parsed = JSON.parse(body) as { contents: Array<{ role?: string; parts?: Record[] }> }; + const model = parsed.contents.find(content => content.role === "model"); + const part = model?.parts?.find(candidate => "functionCall" in candidate); + if (!part) throw new Error("compiled direct request omitted the replayed functionCall"); + return part; +} + +describe("AI Studio direct thought-signature continuation", () => { + beforeEach(() => __resetAntigravityReplayCache()); + + test("streaming functionCall signature is replayed on the next tool-result turn", async () => { + const firstAdapter = createGoogleAdapter(provider); + await firstAdapter.buildRequest(firstTurn(true)); + const response = new Response(`data: ${JSON.stringify(responseBody())}\n\n`, { + headers: { "content-type": "text/event-stream" }, + }); + const events: AdapterEvent[] = []; + for await (const event of firstAdapter.parseStream(response)) events.push(event); + expect(events.some(event => event.type === "tool_call_start")).toBe(true); + expect(events.at(-1)?.type).toBe("done"); + + const followup = await createGoogleAdapter(provider).buildRequest(continuation()); + expect(replayedFunctionCall(followup.body as string).thoughtSignature).toBe(SIGNATURE); + }); + + test("non-streaming functionCall signature is replayed unchanged", async () => { + const firstAdapter = createGoogleAdapter(provider); + await firstAdapter.buildRequest(firstTurn(false)); + const events = await firstAdapter.parseResponse!(new Response(JSON.stringify(responseBody()))); + expect(events.some(event => event.type === "tool_call_start")).toBe(true); + + const followup = await createGoogleAdapter(provider).buildRequest(continuation()); + expect(replayedFunctionCall(followup.body as string).thoughtSignature).toBe(SIGNATURE); + }); + + test("signatures do not cross client-thread namespaces", async () => { + const firstAdapter = createGoogleAdapter(provider); + await firstAdapter.buildRequest(scoped(firstTurn(false), "thread-a")); + await firstAdapter.parseResponse!(new Response(JSON.stringify(responseBody()))); + + const otherThread = await createGoogleAdapter(provider).buildRequest(scoped(continuation(), "thread-b")); + expect(replayedFunctionCall(otherThread.body as string).thoughtSignature).toBeUndefined(); + + const originalThread = await createGoogleAdapter(provider).buildRequest(scoped(continuation(), "thread-a")); + expect(replayedFunctionCall(originalThread.body as string).thoughtSignature).toBe(SIGNATURE); + }); +}); diff --git a/tests/google-vertex-http.test.ts b/tests/google-vertex-http.test.ts index e640f6fabe..2c46233335 100644 --- a/tests/google-vertex-http.test.ts +++ b/tests/google-vertex-http.test.ts @@ -256,7 +256,7 @@ describe("adapter fetchResponse wiring", () => { expect(typeof vertex.fetchResponse).toBe("function"); expect(typeof vertex.formatErrorBody).toBe("function"); expect(aistudio.fetchResponse).toBeUndefined(); - expect(aistudio.formatErrorBody).toBeUndefined(); + expect(typeof aistudio.formatErrorBody).toBe("function"); }); test("Vertex and Antigravity formatter hooks are provider-classified and leak-negative", async () => { @@ -264,7 +264,8 @@ describe("adapter fetchResponse wiring", () => { const payload = vertexError(400, "INVALID_ARGUMENT", "Bearer secret-token at /Users/example/key.json"); const vertex = createGoogleAdapter({ adapter: "google", googleMode: "vertex" } as never); const antigravity = createGoogleAdapter({ adapter: "google", googleMode: "cloud-code-assist" } as never); - for (const [adapter, label] of [[vertex, "Vertex AI"], [antigravity, "Antigravity"]] as const) { + const aistudio = createGoogleAdapter({ adapter: "google", apiKey: "k" } as never); + for (const [adapter, label] of [[vertex, "Vertex AI"], [antigravity, "Antigravity"], [aistudio, "Gemini"]] as const) { const text = adapter.formatErrorBody!(400, new Headers({ authorization: "Bearer header-secret" }), payload); expect(text).toContain(`${label} invalid request`); expect(text).not.toContain("secret-token"); diff --git a/tests/web-search-thought-signature.test.ts b/tests/web-search-thought-signature.test.ts new file mode 100644 index 0000000000..631b0d9875 --- /dev/null +++ b/tests/web-search-thought-signature.test.ts @@ -0,0 +1,68 @@ +import { describe, expect, test } from "bun:test"; +import { createGoogleAdapter } from "../src/adapters/google"; +import type { OcxParsedRequest } from "../src/types"; +import { scanEventsForWebSearch } from "../src/web-search/loop"; + +const provider = { adapter: "google", baseUrl: "https://generativelanguage.googleapis.com", apiKey: "key" }; + +function parsedWith(messages: unknown[]): OcxParsedRequest { + return { modelId: "gemini-3.7-flash", stream: false, options: {}, context: { messages, tools: [] } } as unknown as OcxParsedRequest; +} + +describe("web-search loop — thought signature capture", () => { + test("scanEventsForWebSearch preserves the tool_call_start thoughtSignature", () => { + const { calls } = scanEventsForWebSearch([ + { type: "tool_call_start", id: "call_abc", name: "web_search", thoughtSignature: "EpMBCpABARFNMg9hsKzS" }, + { type: "tool_call_delta", arguments: "{\"query\":\"hello\"}" }, + { type: "tool_call_end" }, + ]); + expect(calls).toHaveLength(1); + expect(calls[0]!.id).toBe("call_abc"); + expect(calls[0]!.thoughtSignature).toBe("EpMBCpABARFNMg9hsKzS"); + }); + + test("scanEventsForWebSearch leaves thoughtSignature undefined when absent", () => { + const { calls } = scanEventsForWebSearch([ + { type: "tool_call_start", id: "call_abc", name: "web_search" }, + { type: "tool_call_delta", arguments: "{}" }, + { type: "tool_call_end" }, + ]); + expect(calls).toHaveLength(1); + expect(calls[0]!.thoughtSignature).toBeUndefined(); + }); +}); + +describe("google adapter — thought signature forwarding", () => { + test("assistant toolCall with a real signature is forwarded as functionCall.thoughtSignature", async () => { + const parsed = parsedWith([ + { role: "user", content: "hi", timestamp: 0 }, + { + role: "assistant", + content: [ + { type: "toolCall", id: "call_1", name: "web_search", arguments: { query: "x" }, thoughtSignature: "EpMBCpABARFNMg9hsKzSDcdjcfnegypDQKWEcIl0" }, + ], + timestamp: 0, + }, + ]); + const { body } = await createGoogleAdapter(provider).buildRequest(parsed); + const wire = JSON.parse(body) as { contents: { role: string; parts: Record[] }[] }; + const modelPart = wire.contents.find(c => c.role === "model")!.parts[0]!; + expect((modelPart.functionCall as { name: string }).name).toBe("web_search"); + expect(modelPart.thoughtSignature).toBe("EpMBCpABARFNMg9hsKzSDcdjcfnegypDQKWEcIl0"); + }); + + test("synthetic tool-call ids are never forwarded as thoughtSignature", async () => { + const parsed = parsedWith([ + { role: "user", content: "hi", timestamp: 0 }, + { + role: "assistant", + content: [{ type: "toolCall", id: "call_1", name: "web_search", arguments: { query: "x" }, thoughtSignature: "fc_12345" }], + timestamp: 0, + }, + ]); + const { body } = await createGoogleAdapter(provider).buildRequest(parsed); + const wire = JSON.parse(body) as { contents: { role: string; parts: Record[] }[] }; + const modelPart = wire.contents.find(c => c.role === "model")!.parts[0]!; + expect(modelPart.thoughtSignature).toBeUndefined(); + }); +});