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