From d8b01b53b2cbfa3160c44d2349b18ef46914c3df Mon Sep 17 00:00:00 2001 From: bitkyc08-arch Date: Sat, 15 Aug 2026 22:44:45 +0900 Subject: [PATCH] fix(google): carry Gemini thought signatures through the Responses round trip Gemini validates a thought signature against the exact part that carried the function call. The signature was observed on the response and then dropped: the adapter event carried only id and name, the Responses function_call item had nowhere to put it, and the parser rebuilt the tool call without it. A same-process replay cache hid this for streaming turns, which is why the loss looked intermittent - history replay and previous_response_id had no cache to fall back on. Carry it with the individual tool call at every hop instead. One shared seam owns the wire shape, the Google adapter attaches metadata from the originating part and prefers it on the way back out, and both synthetic loops copy it per call so parallel calls can never share or swap a signature. Signatures are opaque here: never parsed, merged, re-encoded, or synthesized, and refused above the same 64 KiB ceiling the replay cache already enforces. Closes #1735 Co-authored-by: chilung-cgu --- .../050_gemini_thought_signature.md | 49 ++++++++ src/adapters/google.ts | 34 ++++- src/bridge.ts | 17 ++- src/images/loop.ts | 20 ++- src/responses/parser.ts | 8 +- src/responses/provider-opaque-metadata.ts | 73 +++++++++++ src/responses/schema.ts | 6 + src/types.ts | 19 ++- src/web-search/loop.ts | 26 +++- ...google-provider-metadata-roundtrip.test.ts | 59 +++++++++ ...google-signature-history-roundtrip.test.ts | 116 ++++++++++++++++++ 11 files changed, 410 insertions(+), 17 deletions(-) create mode 100644 devlog/_plan/260815_roadmap_closeout/050_gemini_thought_signature.md create mode 100644 src/responses/provider-opaque-metadata.ts create mode 100644 tests/google-provider-metadata-roundtrip.test.ts create mode 100644 tests/google-signature-history-roundtrip.test.ts diff --git a/devlog/_plan/260815_roadmap_closeout/050_gemini_thought_signature.md b/devlog/_plan/260815_roadmap_closeout/050_gemini_thought_signature.md new file mode 100644 index 0000000000..73cad4d60f --- /dev/null +++ b/devlog/_plan/260815_roadmap_closeout/050_gemini_thought_signature.md @@ -0,0 +1,49 @@ +# 050 — Gemini thought signature survives the Responses round trip (#1735) + +## Problem + +Gemini issues a `thoughtSignature` on the exact part that carries a function call, and the next +request is only valid when that signature returns on the part rebuilt from that same call. + +Today the signature is observed on the response and then lost: the adapter event carries only +`id`/`name`, the Responses `function_call` item has nowhere to put it, and the parser rebuilds an +`OcxToolCall` without it. A same-process replay cache hides this for streaming turns, which is why +the bug reads as intermittent — history replay and `previous_response_id` have no cache to fall +back on. + +PR #1772 repaired one web-search reconstruction. The roadmap's own instruction is that a +web-search-only patch is not acceptable, because every other tool loop rebuilds calls the same way. + +## Approach + +Carry the metadata with the individual tool call, at every hop it already travels: + +1. `OcxProviderOpaqueToolCallMetadata` on `OcxToolCall` and on `tool_call_start`. +2. `src/responses/provider-opaque-metadata.ts` — the single seam that reads and writes the wire + shape `extra_content.google.thought_signature`. +3. Google adapter attaches metadata from the originating part (streaming and buffered), and on + the outbound side prefers it over the legacy field. +4. Responses schema models the bounded nested shape; parser reads it back. +5. Bridge emits it on the `function_call` item so a client can round-trip it. + +Values are opaque: never parsed, merged, re-encoded, or synthesized. One upstream part maps to one +event, one Responses item, one internal call, one rebuilt part. + +## Deliberately out of scope for this cycle + +The audit also found two adjacent defects that are **not** #1735 and must not ride along: + +- Google-issued `functionCall.id` is discarded and replaced with a synthetic `call_*`. The + generated call/response ids still match each other, so pairing works; preserving Google's exact + id is a separate contract change with its own blast radius. +- `src/web-search/loop.ts` interleaves parallel calls as `FC1, FR1, FC2, FR2` where Google requires + `FC1, FC2, FR1, FR2`. That is a message-ordering fix in the sidecar loop, independent of whether + a signature is carried. + +Both are recorded here so the next cycle can pick them up deliberately rather than by accident. + +## Acceptance + +A signature observed on a Google function-call part is present on the Responses `function_call` +item, survives parsing back into the tool call, and is re-attached to the rebuilt part — with no +dependency on the replay cache. Legacy callers that still set `thoughtSignature` keep working. diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 9a074c0506..764e4ce031 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -8,6 +8,7 @@ import type { OcxContentPart, OcxParsedRequest, OcxProviderConfig, + OcxProviderOpaqueToolCallMetadata, OcxTextContent, OcxToolCall, OcxUsage, @@ -209,7 +210,10 @@ function messagesToGeminiFormat( // conversion 400s. Gemini accepts the optional id and pairs call/response by it. if (callId !== undefined) functionCall.id = callId; const part: Record = { functionCall }; - if (isLikelyRealThoughtSignature(tc.thoughtSignature)) part.thoughtSignature = tc.thoughtSignature; + // Prefer the metadata that travelled with this exact call; fall back to the legacy + // field for callers that have not been migrated. Never merge or synthesize. + const signature = tc.providerMetadata?.google?.thoughtSignature ?? tc.thoughtSignature; + if (isLikelyRealThoughtSignature(signature)) part.thoughtSignature = signature; parts.push(part); } } @@ -326,9 +330,23 @@ function artifactMarkdownUrl(filePath: string): string { interface GoogleResponsePart { text?: string; thought?: boolean; + thoughtSignature?: string; functionCall?: { name: string; args: unknown }; } +/** + * Carry a Gemini thought signature with the exact function-call part that produced it. Google + * validates the signature against that specific part, so it must ride the individual tool call + * rather than be re-matched by name/arguments later (issue #1735). + */ +function googleToolCallMetadataFromPart( + part: GoogleResponsePart, +): { providerMetadata: OcxProviderOpaqueToolCallMetadata } | undefined { + const signature = part.thoughtSignature; + if (!isLikelyRealThoughtSignature(signature)) return undefined; + return { providerMetadata: { google: { thoughtSignature: signature } } }; +} + /** * Google marks model-internal reasoning as a normal text-bearing part plus `thought: true`. * Keep that provider visibility bit authoritative here so the streaming and buffered parsers @@ -674,7 +692,12 @@ 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), + ...googleToolCallMetadataFromPart(part), + }; yield { type: "tool_call_delta", arguments: JSON.stringify(part.functionCall.args ?? {}) }; yield { type: "tool_call_end" }; } @@ -890,7 +913,12 @@ 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), + ...googleToolCallMetadataFromPart(part), + }); events.push({ type: "tool_call_delta", arguments: JSON.stringify(part.functionCall.args ?? {}) }); events.push({ type: "tool_call_end" }); } diff --git a/src/bridge.ts b/src/bridge.ts index f29764bf39..85802118f6 100644 --- a/src/bridge.ts +++ b/src/bridge.ts @@ -2,6 +2,7 @@ import type { AdapterEvent, OcxMessagePhase, OcxProviderContinuationState, + OcxProviderOpaqueToolCallMetadata, OcxReasoningReplayScopeRef, OcxUsage, } from "./types"; @@ -10,6 +11,7 @@ import { adapterFailureFromMessage, classifyError, CYBER_POLICY_ERROR_CODE, isCy import { encodeCompactionSummary } from "./responses/compaction"; import { encodeReasoningEnvelope, type ReasoningEnvelope } from "./responses/reasoning-envelope"; import { rememberReasoningForCall } from "./responses/reasoning-replay-cache"; +import { responsesExtraContentFromProviderMetadata } from "./responses/provider-opaque-metadata"; import { resolveStallTimeoutSec } from "./stall-timeout"; import { usageDisplayTotalTokens } from "./usage/totals"; import { @@ -496,7 +498,7 @@ export function bridgeToResponsesSSE( // synthetic compaction item's payload on done. let compactionText = ""; let compactionTextBytes = 0; - let currentToolCall: { itemId: string; outputIndex: number; callId: string; name: string; args: string; argsBytes: number; namespace?: string; freeform?: boolean; toolSearch?: boolean; inputEmitted?: string } | null = null; + let currentToolCall: { itemId: string; outputIndex: number; callId: string; name: string; args: string; argsBytes: number; namespace?: string; freeform?: boolean; toolSearch?: boolean; inputEmitted?: string; providerMetadata?: OcxProviderOpaqueToolCallMetadata } | null = null; // Open native web-search cell (between begin and end). Holds the output index allocated on // begin so the matching done reuses it; closed as `failed` if the stream terminates early. let currentWebSearch: { itemId: string; eventId: string; outputIndex: number } | null = null; @@ -621,6 +623,9 @@ export function bridgeToResponsesSSE( call_id: currentToolCall.callId, name: currentToolCall.name, arguments: argsStr, status: "completed", ...(currentToolCall.namespace ? { namespace: currentToolCall.namespace } : {}), + // Provider-opaque metadata (issue #1735) rides the item so a client that replays + // this history can hand the signature back on the part it belongs to. + ...(responsesExtraContentFromProviderMetadata(currentToolCall.providerMetadata) ?? {}), }; emit("response.output_item.done", { output_index: currentToolCall.outputIndex, item }); retainFinishedItem(item as OutputItem); @@ -655,6 +660,10 @@ export function bridgeToResponsesSSE( call_id: currentToolCall.callId, name: currentToolCall.name, arguments: argsStr, status: "incomplete", ...(currentToolCall.namespace ? { namespace: currentToolCall.namespace } : {}), + // An incomplete call can still be persisted and replayed (max_output_tokens), so it + // carries the same metadata as the completed item — otherwise SSE and buffered JSON + // would disagree about whether the signature survives. + ...(responsesExtraContentFromProviderMetadata(currentToolCall.providerMetadata) ?? {}), }; emit("response.output_item.done", { output_index: currentToolCall.outputIndex, item }); retainFinishedItem(item as OutputItem); @@ -1013,7 +1022,7 @@ export function bridgeToResponsesSSE( ? { type: "custom_tool_call", id: itemId, call_id: event.id, name: realName, input: "", status: "in_progress" } : { type: "function_call", id: itemId, call_id: event.id, name: realName, arguments: "", status: "in_progress", ...(ns ? { namespace: ns } : {}) }; emit("response.output_item.added", { output_index: outputIndex, item }); - currentToolCall = { itemId, outputIndex, callId: event.id, name: realName, args: "", argsBytes: 0, namespace: ns, freeform, toolSearch }; + currentToolCall = { itemId, outputIndex, callId: event.id, name: realName, args: "", argsBytes: 0, namespace: ns, freeform, toolSearch, providerMetadata: event.providerMetadata }; budget?.openCall(event.id); break; } @@ -1476,6 +1485,7 @@ function buildResponseJSONWithBudget( let currentToolCallId = ""; let currentToolCallName = ""; let currentToolCallArgs = ""; + let currentToolCallProviderMetadata: OcxProviderOpaqueToolCallMetadata | undefined; let currentToolCallArgsBytes = 0; // Web-search citations awaiting the next assistant message (attached as url_citation annotations). let pendingWebSources: { url: string; title?: string }[] = []; @@ -1584,11 +1594,13 @@ function buildResponseJSONWithBudget( call_id: currentToolCallId, name: realName, arguments: coercedArgs || "{}", status, ...(ns ? { namespace: ns } : {}), + ...(responsesExtraContentFromProviderMetadata(currentToolCallProviderMetadata) ?? {}), }); } budget?.closeCall(currentToolCallId); currentToolCallId = ""; currentToolCallName = ""; + currentToolCallProviderMetadata = undefined; currentToolCallArgs = ""; currentToolCallArgsBytes = 0; }; @@ -1703,6 +1715,7 @@ function buildResponseJSONWithBudget( currentToolCallName = e.name; currentToolCallArgs = ""; currentToolCallArgsBytes = 0; + currentToolCallProviderMetadata = e.providerMetadata; break; case "tool_call_delta": { diff --git a/src/images/loop.ts b/src/images/loop.ts index 74fdfd4076..65a71276c6 100644 --- a/src/images/loop.ts +++ b/src/images/loop.ts @@ -14,8 +14,9 @@ import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "../adapters/ import { existsSync } from "node:fs"; import { pathToFileURL } from "node:url"; import { createAdapterEventQueue } from "../adapters/run-turn-queue"; -import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderContinuationState, OcxRequestOptions, OcxThinkingContent, OcxUsage, RateLimitRetryPolicy } from "../types"; +import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderContinuationState, OcxProviderOpaqueToolCallMetadata, OcxRequestOptions, OcxThinkingContent, OcxUsage, RateLimitRetryPolicy } from "../types"; import { namespacedToolName, toolChoiceToolPredicate } from "../types"; +import { cloneProviderOpaqueToolCallMetadata } from "../responses/provider-opaque-metadata"; import type { AttemptRecoveryKind } from "../usage/log"; import { bridgeToResponsesSSE } from "../bridge"; import { clearableDeadline, idleDeadline } from "../lib/abort"; @@ -93,6 +94,11 @@ interface ImageCall { id: string; name: string; args: string; + /** + * Provider-opaque metadata from the originating part (issue #1735). Stored PER CALL: a + * signature belongs to one specific part, so parallel calls must not share one value. + */ + providerMetadata?: OcxProviderOpaqueToolCallMetadata; } /** @@ -109,13 +115,13 @@ function scanEventsForImageCall(events: AdapterEvent[], toolNames: Set): const calls: ImageCall[] = []; const passthrough: AdapterEvent[] = []; let hasRealToolCall = false; - let pending: { name: string; id: string; argsBuf: string; events: AdapterEvent[] } | null = null; + let pending: { name: string; id: string; argsBuf: string; events: AdapterEvent[]; providerMetadata?: OcxProviderOpaqueToolCallMetadata } | null = null; const flushPending = (): void => { if (!pending) return; if (toolNames.has(pending.name)) { // Unterminated image call still carries buffered args — fulfill so malformed JSON // becomes a normal tool_result error instead of silently vanishing. - calls.push({ id: pending.id, name: pending.name, args: pending.argsBuf }); + calls.push({ id: pending.id, name: pending.name, args: pending.argsBuf, providerMetadata: pending.providerMetadata }); } else { passthrough.push(...pending.events); hasRealToolCall = true; @@ -125,14 +131,14 @@ function scanEventsForImageCall(events: AdapterEvent[], toolNames: Set): for (const e of events) { if (e.type === "tool_call_start") { flushPending(); - pending = { name: e.name, id: e.id, argsBuf: "", events: [e] }; + pending = { name: e.name, id: e.id, argsBuf: "", events: [e], providerMetadata: e.providerMetadata }; } else if (e.type === "tool_call_delta" && pending) { pending.argsBuf += e.arguments; pending.events.push(e); } else if (e.type === "tool_call_end" && pending) { pending.events.push(e); if (toolNames.has(pending.name)) { - calls.push({ id: pending.id, name: pending.name, args: pending.argsBuf }); + calls.push({ id: pending.id, name: pending.name, args: pending.argsBuf, providerMetadata: pending.providerMetadata }); } else { passthrough.push(...pending.events); hasRealToolCall = true; @@ -871,6 +877,10 @@ export async function runWithImageBridge(deps: ImageBridgeDeps): Promise = {}; @@ -519,6 +520,11 @@ export function parseRequest(body: unknown): OcxParsedRequest { type: "toolCall", id: call.call_id, name: call.name, arguments: args, ...(call.namespace ? { namespace: call.namespace } : {}), }; + // Provider-opaque metadata (e.g. a Gemini thought signature) travels with the call so a + // history-replayed or previous_response_id turn rebuilds the same signed part instead of + // depending on the same-process replay cache (issue #1735). + const providerMetadata = providerMetadataFromResponsesFunctionCall(call); + if (providerMetadata) toolCall.providerMetadata = providerMetadata; assistantHolderWithReasoning().content.push(toolCall); continue; } diff --git a/src/responses/provider-opaque-metadata.ts b/src/responses/provider-opaque-metadata.ts new file mode 100644 index 0000000000..c52e30951f --- /dev/null +++ b/src/responses/provider-opaque-metadata.ts @@ -0,0 +1,73 @@ +/** + * Provider-opaque tool-call metadata across the Responses boundary (issue #1735). + * + * Gemini issues a `thoughtSignature` on the exact part that carries a function call, and the + * next request is only valid if that signature comes back on the part rebuilt from that same + * call. Every synthetic loop in this proxy (web search, images, continuation replay) tears a + * tool call down into id/name/arguments and builds a fresh one, which silently dropped the + * signature and left only the same-process replay cache to paper over it. History replay and + * `previous_response_id` had no cache to fall back on. + * + * This module is the single seam where that metadata crosses into and out of the Responses + * wire, so a loop that rebuilds a call only has to carry one field instead of knowing about + * any provider. Values are treated as opaque: never parsed, merged, re-encoded, or synthesized. + */ +import type { OcxProviderOpaqueToolCallMetadata } from "../types"; + +/** Wire shape: `extra_content.google.thought_signature` on a Responses function_call item. */ +interface ResponsesExtraContent { + google?: { thought_signature?: unknown }; +} + +function isObj(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +/** + * Same ceiling the Antigravity replay cache already enforces on a stored signature. An opaque + * token this large is not a real signature, and accepting it would let a caller push unbounded + * state through history replay. + */ +const MAX_SIGNATURE_BYTES = 64 * 1024; + +function isCarryableSignature(value: unknown): value is string { + if (typeof value !== "string" || value.length === 0) return false; + // Cheap length pre-check: UTF-8 is at most 3 bytes per UTF-16 code unit for the BMP, so this + // skips the encode for the overwhelmingly common short case. + if (value.length <= MAX_SIGNATURE_BYTES / 3) return true; + return Buffer.byteLength(value, "utf8") <= MAX_SIGNATURE_BYTES; +} + +/** Read provider metadata off an inbound Responses function_call item. */ +export function providerMetadataFromResponsesFunctionCall( + item: { extra_content?: unknown } | undefined, +): OcxProviderOpaqueToolCallMetadata | undefined { + const extra = item?.extra_content; + if (!isObj(extra)) return undefined; + const google = (extra as ResponsesExtraContent).google; + if (!isObj(google)) return undefined; + const signature = google.thought_signature; + if (!isCarryableSignature(signature)) return undefined; + return { google: { thoughtSignature: signature } }; +} + +/** Serialize provider metadata onto an outbound Responses function_call item. */ +export function responsesExtraContentFromProviderMetadata( + metadata: OcxProviderOpaqueToolCallMetadata | undefined, +): { extra_content: { google: { thought_signature: string } } } | undefined { + const signature = metadata?.google?.thoughtSignature; + if (!isCarryableSignature(signature)) return undefined; + return { extra_content: { google: { thought_signature: signature } } }; +} + +/** + * Copy metadata for a rebuilt tool call. A signature belongs to one specific part, so a loop + * that fans one model response into several calls must copy per call and never share or merge. + */ +export function cloneProviderOpaqueToolCallMetadata( + metadata: OcxProviderOpaqueToolCallMetadata | undefined, +): OcxProviderOpaqueToolCallMetadata | undefined { + const signature = metadata?.google?.thoughtSignature; + if (!isCarryableSignature(signature)) return undefined; + return { google: { thoughtSignature: signature } }; +} diff --git a/src/responses/schema.ts b/src/responses/schema.ts index 551d9d7619..5e1f876236 100644 --- a/src/responses/schema.ts +++ b/src/responses/schema.ts @@ -64,6 +64,12 @@ const functionCallItemSchema = z.object({ name: z.string().min(1), namespace: z.string().optional(), arguments: z.string().optional(), + // Provider-opaque metadata that must survive the round trip verbatim (issue #1735). The shape + // is bounded on purpose: only the one nested key we round-trip is modeled, so an unexpected + // payload cannot ride through as arbitrary passthrough state. + extra_content: z.object({ + google: z.object({ thought_signature: z.string().optional() }).optional(), + }).optional(), }); const functionCallOutputItemSchema = z.object({ type: z.literal("function_call_output"), diff --git a/src/types.ts b/src/types.ts index aaf4fe18d2..a2771de0e5 100644 --- a/src/types.ts +++ b/src/types.ts @@ -180,10 +180,27 @@ export interface OcxToolCall { arguments: Record; customWireName?: string; thoughtSignature?: string; + /** + * Provider-issued opaque metadata that must survive the whole round trip unchanged + * (issue #1735). A signed Gemini part is only valid when its signature comes back on the + * SAME part it was issued for, so this travels with the individual tool call rather than + * being matched by name/arguments after the fact. + */ + providerMetadata?: OcxProviderOpaqueToolCallMetadata; /** MCP namespace (e.g. "mcp__context7") when this call targets a namespaced tool. */ namespace?: string; } +/** + * Opaque, provider-scoped tool-call metadata. Values are never parsed, merged, re-encoded, or + * synthesized — they are carried verbatim or not at all. + */ +export interface OcxProviderOpaqueToolCallMetadata { + google?: { + thoughtSignature?: string; + }; +} + export type OcxAssistantContentPart = OcxTextContent | OcxThinkingContent | OcxToolCall; export interface OcxTool { @@ -328,7 +345,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; providerMetadata?: OcxProviderOpaqueToolCallMetadata } | { 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/loop.ts b/src/web-search/loop.ts index 4be7bdbbc6..e6129bfc2c 100644 --- a/src/web-search/loop.ts +++ b/src/web-search/loop.ts @@ -1,6 +1,7 @@ import type { AdapterRequest, IncomingMeta, ProviderAdapter } from "../adapters/base"; -import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxThinkingContent, OcxUsage, RateLimitRetryPolicy } from "../types"; +import type { AdapterEvent, OcxMessage, OcxParsedRequest, OcxProviderConfig, OcxProviderOpaqueToolCallMetadata, OcxThinkingContent, OcxUsage, RateLimitRetryPolicy } from "../types"; import { namespacedToolName, toolChoiceToolPredicate } from "../types"; +import { cloneProviderOpaqueToolCallMetadata } from "../responses/provider-opaque-metadata"; import type { AttemptRecoveryKind } from "../usage/log"; import { bridgeToResponsesSSE } from "../bridge"; import { runWebSearch, type SidecarOutcome, type SidecarOutcomeRecorder, type SidecarSettings } from "./executor"; @@ -32,6 +33,11 @@ interface WebSearchCall { // empty array means the model called the tool with neither `query` nor `queries` (handled as an // empty-query placeholder). queries: string[]; + /** + * Provider-opaque metadata from the originating part (issue #1735). Stored PER CALL so a + * signature can never migrate to a different call when the model batches several. + */ + providerMetadata?: OcxProviderOpaqueToolCallMetadata; } /** @@ -69,7 +75,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; events: AdapterEvent[]; providerMetadata?: OcxProviderOpaqueToolCallMetadata } | 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 +90,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, events: [e], providerMetadata: e.providerMetadata }; } else if (e.type === "tool_call_delta") { // Orphan delta (no open call) is malformed. if (!pending) hasMalformedToolCall = true; @@ -100,7 +106,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), providerMetadata: pending.providerMetadata }); } else { passthrough.push(...pending.events); if (!isBlank(pending.id) && !isBlank(pending.name)) hasRealToolCall = true; @@ -678,7 +684,17 @@ export async function runWithWebSearch(deps: WebSearchLoopDeps): Promise { + test("round-trips a signature through the Responses wire shape unchanged", () => { + const metadata: OcxProviderOpaqueToolCallMetadata = { google: { thoughtSignature: SIG_A } }; + const wire = responsesExtraContentFromProviderMetadata(metadata); + expect(wire).toEqual({ extra_content: { google: { thought_signature: SIG_A } } }); + // The value is opaque: what comes back out must be byte-identical to what went in. + expect(providerMetadataFromResponsesFunctionCall(wire)).toEqual(metadata); + }); + + test("parallel calls keep distinct signatures and never share one", () => { + const a = cloneProviderOpaqueToolCallMetadata({ google: { thoughtSignature: SIG_A } }); + const b = cloneProviderOpaqueToolCallMetadata({ google: { thoughtSignature: SIG_B } }); + expect(a?.google?.thoughtSignature).toBe(SIG_A); + expect(b?.google?.thoughtSignature).toBe(SIG_B); + // Clones are independent objects, so mutating one rebuilt call cannot reach another. + expect(a).not.toBe(b); + a!.google!.thoughtSignature = "mutated"; + expect(b?.google?.thoughtSignature).toBe(SIG_B); + }); + + test("absent, malformed, and unknown payloads carry nothing", () => { + expect(providerMetadataFromResponsesFunctionCall(undefined)).toBeUndefined(); + expect(providerMetadataFromResponsesFunctionCall({})).toBeUndefined(); + expect(providerMetadataFromResponsesFunctionCall({ extra_content: "nope" })).toBeUndefined(); + expect(providerMetadataFromResponsesFunctionCall({ extra_content: { google: "nope" } })).toBeUndefined(); + expect(providerMetadataFromResponsesFunctionCall({ extra_content: { google: { thought_signature: 42 } } })).toBeUndefined(); + expect(providerMetadataFromResponsesFunctionCall({ extra_content: { google: { thought_signature: "" } } })).toBeUndefined(); + // A sibling vendor key is not modeled, so it cannot ride through as passthrough state. + expect(providerMetadataFromResponsesFunctionCall({ extra_content: { other: { secret: "x" } } })).toBeUndefined(); + expect(responsesExtraContentFromProviderMetadata(undefined)).toBeUndefined(); + expect(responsesExtraContentFromProviderMetadata({})).toBeUndefined(); + }); + + test("an oversized signature is refused at every boundary", () => { + // Matches the ceiling the Antigravity replay cache already enforces: a token this large is + // not a real signature, and carrying it would push unbounded state through history replay. + const oversized = "x".repeat(64 * 1024 + 1); + const metadata = { google: { thoughtSignature: oversized } }; + expect(providerMetadataFromResponsesFunctionCall({ extra_content: { google: { thought_signature: oversized } } })).toBeUndefined(); + expect(responsesExtraContentFromProviderMetadata(metadata)).toBeUndefined(); + expect(cloneProviderOpaqueToolCallMetadata(metadata)).toBeUndefined(); + + // The boundary itself still passes. + const atLimit = "y".repeat(64 * 1024); + expect(cloneProviderOpaqueToolCallMetadata({ google: { thoughtSignature: atLimit } })?.google?.thoughtSignature) + .toHaveLength(64 * 1024); + }); +}); diff --git a/tests/google-signature-history-roundtrip.test.ts b/tests/google-signature-history-roundtrip.test.ts new file mode 100644 index 0000000000..4557613717 --- /dev/null +++ b/tests/google-signature-history-roundtrip.test.ts @@ -0,0 +1,116 @@ +/** + * #1735: a Gemini thought signature must survive a HISTORY-driven turn, where the same-process + * replay cache is not available — the exact case the cache was masking. + */ +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 { parseRequest } from "../src/responses/parser"; +import type { AdapterEvent, OcxParsedRequest, OcxProviderConfig } from "../src/types"; +import { withTestTranslatorBudget } from "./helpers/translator-budget"; + +const createGoogleAdapter = (...args: Parameters) => + withTestTranslatorBudget(createGoogleAdapterProduction(...args)); + +const SIGNATURE = "CiQAx-history-thought-signature-0123456789abcdef"; +const SIGNATURE_B = "CiQAx-history-thought-signature-second-call-99"; +const MODEL = "gemini-3.6-flash"; + +const provider = { + adapter: "google", + googleMode: "vertex", + baseUrl: "https://aiplatform.googleapis.com", + apiKey: "vertex-test-key", +} as OcxProviderConfig; + +function firstTurn(): OcxParsedRequest { + return { + modelId: MODEL, + stream: false, + context: { + messages: [{ role: "user", content: "run pwd" }], + systemPrompt: [], + tools: [{ name: "shell_command", description: "run a command", parameters: { type: "object" } }], + }, + options: {}, + } as unknown as OcxParsedRequest; +} + +function googleBody(parts: Record[]): Record { + return { + candidates: [{ content: { role: "model", parts }, finishReason: "STOP" }], + usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 2 }, + }; +} + +function modelParts(body: string): Record[] { + const parsed = JSON.parse(body) as { contents: Array<{ role?: string; parts?: Record[] }> }; + return parsed.contents.find(content => content.role === "model")?.parts ?? []; +} + +describe("#1735 thought signature survives history replay", () => { + beforeEach(() => __resetAntigravityReplayCache()); + + test("the adapter attaches the signature to the tool call that produced it", async () => { + const adapter = createGoogleAdapter(provider); + await adapter.buildRequest(firstTurn()); + const events = await adapter.parseResponse!(new Response(JSON.stringify(googleBody([ + { functionCall: { name: "shell_command", args: { command: "pwd" } }, thoughtSignature: SIGNATURE }, + ])))); + const start = events.find((e: AdapterEvent) => e.type === "tool_call_start"); + expect(start && "providerMetadata" in start ? start.providerMetadata?.google?.thoughtSignature : undefined) + .toBe(SIGNATURE); + }); + + test("parallel calls each keep their own signature", async () => { + const adapter = createGoogleAdapter(provider); + await adapter.buildRequest(firstTurn()); + const events = await adapter.parseResponse!(new Response(JSON.stringify(googleBody([ + { functionCall: { name: "shell_command", args: { command: "pwd" } }, thoughtSignature: SIGNATURE }, + { functionCall: { name: "shell_command", args: { command: "ls" } }, thoughtSignature: SIGNATURE_B }, + ])))); + const signatures = events + .filter((e: AdapterEvent) => e.type === "tool_call_start") + .map((e: AdapterEvent) => ("providerMetadata" in e ? e.providerMetadata?.google?.thoughtSignature : undefined)); + // Neither signature may migrate onto the other call. + expect(signatures).toEqual([SIGNATURE, SIGNATURE_B]); + }); + + test("a signature replayed through Responses history reaches the rebuilt Google part", async () => { + // No cache is warmed here: this is a cold process replaying client-supplied history. + const parsed = parseRequest({ + model: MODEL, + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "run pwd" }] }, + { + type: "function_call", + call_id: "call_shell_1", + name: "shell_command", + arguments: JSON.stringify({ command: "pwd" }), + extra_content: { google: { thought_signature: SIGNATURE } }, + }, + { type: "function_call_output", call_id: "call_shell_1", output: "/workspace" }, + ], + tools: [{ type: "function", name: "shell_command", description: "run", parameters: { type: "object" } }], + }); + + const request = await createGoogleAdapter(provider).buildRequest(parsed); + const part = modelParts(request.body as string).find(candidate => "functionCall" in candidate); + expect(part?.thoughtSignature).toBe(SIGNATURE); + }); + + test("history without a signature stays unsigned rather than borrowing one", async () => { + const parsed = parseRequest({ + model: MODEL, + input: [ + { type: "message", role: "user", content: [{ type: "input_text", text: "run pwd" }] }, + { type: "function_call", call_id: "call_shell_1", name: "shell_command", arguments: JSON.stringify({ command: "pwd" }) }, + { type: "function_call_output", call_id: "call_shell_1", output: "/workspace" }, + ], + tools: [{ type: "function", name: "shell_command", description: "run", parameters: { type: "object" } }], + }); + const request = await createGoogleAdapter(provider).buildRequest(parsed); + const part = modelParts(request.body as string).find(candidate => "functionCall" in candidate); + expect(part?.thoughtSignature).toBeUndefined(); + }); +});