diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 07dd38e476..746f9490a4 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -413,6 +413,7 @@ interface GoogleResponsePart { thought?: boolean; thoughtSignature?: string; thought_signature?: string; + extra_content?: { google?: { thought_signature?: unknown } }; functionCall?: unknown; } @@ -421,6 +422,18 @@ interface GoogleFunctionCall { args?: unknown; } +/** + * Read a Gemini/Antigravity thought signature from a response part. Antigravity can place it + * either directly on the part (`thoughtSignature` / `thought_signature`) or inside the same + * nested `extra_content.google.thought_signature` shape used on the Responses wire. + */ +function googlePartThoughtSignature(part: GoogleResponsePart): string | undefined { + const direct = part.thoughtSignature ?? part.thought_signature; + if (typeof direct === "string" && direct.length > 0) return direct; + const nested = part.extra_content?.google?.thought_signature; + return typeof nested === "string" && nested.length > 0 ? nested : undefined; +} + /** * 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 @@ -430,7 +443,7 @@ function googleToolCallMetadataFromPart( part: GoogleResponsePart, fallbackSignature?: string, ): { providerMetadata: OcxProviderOpaqueToolCallMetadata } | undefined { - const signature = part.thoughtSignature ?? part.thought_signature ?? fallbackSignature; + const signature = googlePartThoughtSignature(part) ?? fallbackSignature; if (!isLikelyRealThoughtSignature(signature)) return undefined; return { providerMetadata: { google: { thoughtSignature: signature } } }; } @@ -960,7 +973,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte } if (parts) { for (const part of parts) { - const sig = part.thoughtSignature ?? part.thought_signature; + const sig = googlePartThoughtSignature(part); if (part.thought === true && sig && isLikelyRealThoughtSignature(sig)) { pendingStreamThoughtSig = sig; } @@ -1224,7 +1237,7 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte } let pendingThoughtSig: string | undefined; for (const part of parts) { - const sig = part.thoughtSignature ?? part.thought_signature; + const sig = googlePartThoughtSignature(part); if (part.thought === true && sig && isLikelyRealThoughtSignature(sig)) { pendingThoughtSig = sig; } diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 22bf3c18c3..dbe3a7dad8 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -2177,6 +2177,18 @@ async function handleResponsesInner( if (inboundClientThreadId) { parsed._clientThreadId = inboundClientThreadId; parsed._reasoningReplayScope = { clientThreadId: inboundClientThreadId }; + } else if ( + options.inboundWire === "anthropic" + && options.promptCacheKeyIsSharedCohort !== true + && typeof parsed.options.promptCacheKey === "string" + && parsed.options.promptCacheKey.trim().length > 0 + ) { + // Claude Code has no Codex parent-thread header, but its metadata.user_id is + // translated into a stable per-session prompt_cache_key. Use it as the replay + // thread identity so Gemini thought signatures are remembered by call_id for + // Anthropic Messages clients too (#1735/#1926). Keep `_clientThreadId` unset so + // existing provider session-id derivation (first-user-text fallback) is unchanged. + parsed._reasoningReplayScope = { clientThreadId: parsed.options.promptCacheKey }; } } catch (err) { if (isTranslatorBudgetExceededError(err)) { diff --git a/tests/claude-code-thought-signature-scope.test.ts b/tests/claude-code-thought-signature-scope.test.ts new file mode 100644 index 0000000000..1d214eda41 --- /dev/null +++ b/tests/claude-code-thought-signature-scope.test.ts @@ -0,0 +1,111 @@ +/** + * Regression coverage for the Claude Code thought-signature replay scope: + * + * Claude Code speaks Anthropic Messages and does not send Codex's + * `x-codex-parent-thread-id`. The server must still create a reasoning-replay + * scope for a real per-session `prompt_cache_key` (derived from + * `metadata.user_id`) so Gemini/Antigravity thought signatures can be remembered + * by call_id. The shared Desktop `prompt_cache_key` cohort must NOT get a scope. + */ +import { afterEach, describe, expect, mock, test } from "bun:test"; + +import type { ProviderAdapter } from "../src/adapters/base"; +import type { AdapterEvent, OcxConfig, OcxParsedRequest, OcxProviderConfig } from "../src/types"; + +const actualResolver = await import("../src/server/adapter-resolve"); + +let adapterFactory: ((provider: OcxProviderConfig) => ProviderAdapter) | undefined; + +mock.module("../src/server/adapter-resolve", () => ({ + ...actualResolver, + resolveAdapter(provider: OcxProviderConfig, cacheRetention?: "none" | "short" | "long") { + return adapterFactory?.(provider) ?? actualResolver.resolveAdapter(provider, cacheRetention); + }, +})); + +const { handleResponses } = await import("../src/server/responses"); + +afterEach(() => { + adapterFactory = undefined; +}); + +function captureAdapter(captured: OcxParsedRequest[]): ProviderAdapter { + return { + name: "capture-replay-scope", + buildRequest: () => ({ url: "https://capture.test", method: "POST", headers: {}, body: "{}" }), + async *parseStream(): AsyncGenerator { + yield { type: "done" }; + }, + async runTurn(parsed: OcxParsedRequest, _incoming, emit) { + captured.push(parsed); + emit({ type: "done" }); + }, + }; +} + +function testConfig(): OcxConfig { + return { + port: 0, + defaultProvider: "a", + providers: { + a: { + adapter: "openai-chat", + baseUrl: "https://capture.test", + authMode: "key", + apiKey: "capture-key", + models: ["m1"], + }, + }, + } as OcxConfig; +} + +async function drive(options: { + promptCacheKey?: string; + promptCacheKeyIsSharedCohort?: boolean; +}): Promise { + const captured: OcxParsedRequest[] = []; + adapterFactory = () => captureAdapter(captured); + const body: Record = { + model: "m1", + stream: true, + input: [{ type: "message", role: "user", content: [{ type: "input_text", text: "hello" }] }], + }; + if (options.promptCacheKey !== undefined) body.prompt_cache_key = options.promptCacheKey; + + const response = await handleResponses( + new Request("http://localhost/v1/responses", { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify(body), + }), + testConfig(), + { model: "", provider: "" }, + { + inboundWire: "anthropic", + ...(options.promptCacheKeyIsSharedCohort === undefined + ? {} + : { promptCacheKeyIsSharedCohort: options.promptCacheKeyIsSharedCohort }), + }, + ); + await response.text(); + expect(captured.length).toBe(1); + return captured[0]!; +} + +describe("Claude Code Anthropic inbound reasoning-replay scope", () => { + test("a real per-session prompt_cache_key creates a call_id replay scope", async () => { + const parsed = await drive({ promptCacheKey: "session-key-123", promptCacheKeyIsSharedCohort: false }); + expect(parsed._clientThreadId).toBeUndefined(); + expect(parsed._reasoningReplayScope?.clientThreadId).toBe("session-key-123"); + }); + + test("the shared Desktop prompt_cache_key cohort does not create a scope", async () => { + const parsed = await drive({ promptCacheKey: "shared-cohort-key", promptCacheKeyIsSharedCohort: true }); + expect(parsed._reasoningReplayScope).toBeUndefined(); + }); + + test("an Anthropic replay without prompt_cache_key does not create a scope", async () => { + const parsed = await drive({}); + expect(parsed._reasoningReplayScope).toBeUndefined(); + }); +}); diff --git a/tests/google-signature-history-roundtrip.test.ts b/tests/google-signature-history-roundtrip.test.ts index c914825bee..571ce27c1a 100644 --- a/tests/google-signature-history-roundtrip.test.ts +++ b/tests/google-signature-history-roundtrip.test.ts @@ -118,6 +118,20 @@ describe("#1735 thought signature survives history replay", () => { .toBe(SIGNATURE); }); + test("a functionCall part with nested extra_content.google.thought_signature is read", 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" } }, + extra_content: { google: { thought_signature: 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());