diff --git a/src/responses/parser.ts b/src/responses/parser.ts index 2f1dd73dd4..9a503df53c 100644 --- a/src/responses/parser.ts +++ b/src/responses/parser.ts @@ -754,6 +754,23 @@ export function parseRequest( }; } +/** + * Restore provider-opaque signatures after routing has supplied the complete replay scope. + * The initial request parse necessarily happens before provider and credential selection, so + * production callers use this once the route-bound scope has been populated. + */ +export function hydrateReplayThoughtSignatures(parsed: OcxParsedRequest): void { + const scope = parsed._reasoningReplayScope; + for (const message of parsed.context.messages) { + if (message.role !== "assistant" || !Array.isArray(message.content)) continue; + for (const part of message.content) { + if (part.type !== "toolCall" || part.providerMetadata || !part.id) continue; + const remembered = replayThoughtSignatureMetadata(part.id, scope); + if (remembered) part.providerMetadata = remembered; + } + } +} + /** * The Responses `text.format` object when it requests structured output (json_schema or * json_object), undefined otherwise. Acceptance is identical to the boolean detector this diff --git a/src/server/responses/core.ts b/src/server/responses/core.ts index 8df2fc672d..294c634c6d 100644 --- a/src/server/responses/core.ts +++ b/src/server/responses/core.ts @@ -8,7 +8,7 @@ import { multiAgentGuidanceEnabled, resolveEnvValue, } from "../../config"; -import { parseRequest } from "../../responses/parser"; +import { hydrateReplayThoughtSignatures, parseRequest } from "../../responses/parser"; import { bindReasoningReplayScope, reasoningReplayCodexCredentialIdentity, @@ -2148,6 +2148,7 @@ async function handleResponsesInner( codexAuthContext: authCtx, forwardHeaders: selectedForwardHeaders, }); + hydrateReplayThoughtSignatures(parsed); logCtx.providerAdapter = adapter.name; // Ordinary requests receive one durable attempt only after their final initial // adapter is resolved. Combo children own their attempt and retries keep it. diff --git a/tests/google-signature-history-roundtrip.test.ts b/tests/google-signature-history-roundtrip.test.ts index 049ea50f36..5441a826fc 100644 --- a/tests/google-signature-history-roundtrip.test.ts +++ b/tests/google-signature-history-roundtrip.test.ts @@ -8,7 +8,7 @@ import { tmpdir } from "node:os"; import { join } from "node:path"; import { createGoogleAdapter as createGoogleAdapterProduction } from "../src/adapters/google"; import { __resetAntigravityReplayCache } from "../src/adapters/google-antigravity-replay"; -import { parseRequest } from "../src/responses/parser"; +import { hydrateReplayThoughtSignatures, parseRequest } from "../src/responses/parser"; import { flushThoughtSignatureReplayForTests, lookupReplayThoughtSignature, @@ -179,6 +179,25 @@ describe("#1735 thought signature survives history replay", () => { expect(part?.thoughtSignature).toBe(SIGNATURE); }); + test("the server can restore a signature after its route scope is bound", async () => { + const scope = scopeFor(); + rememberThoughtSignatureForReplay("call_shell_late", SIGNATURE, scope); + const parsed = parseRequest({ + model: MODEL, + input: [ + { type: "function_call", call_id: "call_shell_late", name: "shell_command", arguments: "{}" }, + { type: "function_call_output", call_id: "call_shell_late", output: "/workspace" }, + ], + }); + + parsed._reasoningReplayScope = scope; + hydrateReplayThoughtSignatures(parsed); + + 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("a custom_tool_call replay is re-signed from the proxy-side store", async () => { rememberThoughtSignatureForReplay("call_custom_1", SIGNATURE_B, scopeFor()); const parsed = parseRequestScoped({