diff --git a/src/adapters/google.ts b/src/adapters/google.ts index 746f9490a4..d004b9bf87 100644 --- a/src/adapters/google.ts +++ b/src/adapters/google.ts @@ -964,7 +964,9 @@ export function createGoogleAdapter(provider: OcxProviderConfig): ProviderAdapte const replaySession = provider.googleMode === "cloud-code-assist" ? antigravitySession : vertexReplaySession; if ((provider.googleMode === "cloud-code-assist" || provider.googleMode === "vertex") && parts && replayModel && replaySession) { - pendingStreamThoughtSig = observeAntigravityReplay( + // Observation may scan the whole frame, so use it only for replay-cache side effects. + // The source-order loop below exclusively owns stream carry and cannot pair backwards. + observeAntigravityReplay( replayModel, replaySession, parts as unknown[], diff --git a/tests/google-signature-history-roundtrip.test.ts b/tests/google-signature-history-roundtrip.test.ts index 571ce27c1a..40f23154e8 100644 --- a/tests/google-signature-history-roundtrip.test.ts +++ b/tests/google-signature-history-roundtrip.test.ts @@ -33,6 +33,13 @@ const provider = { apiKey: "vertex-test-key", } as OcxProviderConfig; +const aiStudioProvider = { + adapter: "google", + googleMode: "ai-studio", + baseUrl: "https://generativelanguage.googleapis.com", + apiKey: "ai-studio-test-key", +} as OcxProviderConfig; + /** * A replay scope is now REQUIRED for the store to remember or return anything: a @@ -89,6 +96,18 @@ function modelParts(body: string): Record[] { return parsed.contents.find(content => content.role === "model")?.parts ?? []; } +/** Build a streaming response whose SSE frames remain distinct transport chunks. */ +function sseResponse(frames: string[]): Response { + const stream = new ReadableStream({ + start(controller) { + const encoder = new TextEncoder(); + for (const frame of frames) controller.enqueue(encoder.encode(frame)); + controller.close(); + }, + }); + return new Response(stream); +} + describe("#1735 thought signature survives history replay", () => { let previousHome: string | undefined; let testDir: string; @@ -175,16 +194,8 @@ describe("#1735 thought signature survives history replay", () => { `data: ${JSON.stringify({ usageMetadata: { promptTokenCount: 5, candidatesTokenCount: 2 } })}\n\n`, ]; - const stream = new ReadableStream({ - start(controller) { - const encoder = new TextEncoder(); - for (const frame of frames) controller.enqueue(encoder.encode(frame)); - controller.close(); - }, - }); - const events: AdapterEvent[] = []; - for await (const event of adapter.parseStream(new Response(stream))) { + for await (const event of adapter.parseStream(sseResponse(frames))) { events.push(event); } @@ -199,6 +210,52 @@ describe("#1735 thought signature survives history replay", () => { .toBe(SIGNATURE); }); + test("streaming signatures only attach to function calls that follow them in the same frame", async () => { + const adapter = createGoogleAdapter(provider); + await adapter.buildRequest(firstTurn()); + const frames = [ + `data: ${JSON.stringify(googleBody([ + { functionCall: { name: "shell_command", args: { command: "pwd" } } }, + { text: "thinking...", thought: true, thought_signature: SIGNATURE }, + { functionCall: { name: "shell_command", args: { command: "ls" } } }, + ]))}\n\n`, + ]; + + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(sseResponse(frames))) events.push(event); + + const signatures = events + .filter((event): event is Extract => + event.type === "tool_call_start") + .map(event => event.providerMetadata?.google?.thoughtSignature); + expect(signatures).toEqual([undefined, SIGNATURE]); + }); + + test("AI Studio keeps source-order thought signature carry across stream frames", async () => { + const adapter = createGoogleAdapter(aiStudioProvider); + await adapter.buildRequest(firstTurn()); + const frames = [ + `data: ${JSON.stringify({ + candidates: [{ + content: { + role: "model", + parts: [{ text: "thinking...", thought: true, thought_signature: SIGNATURE }], + }, + }], + })}\n\n`, + `data: ${JSON.stringify(googleBody([ + { functionCall: { name: "shell_command", args: { command: "pwd" } } }, + ]))}\n\n`, + ]; + + const events: AdapterEvent[] = []; + for await (const event of adapter.parseStream(sseResponse(frames))) events.push(event); + + const start = events.find((event): event is Extract => + event.type === "tool_call_start"); + expect(start?.providerMetadata?.google?.thoughtSignature).toBe(SIGNATURE); + }); + 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 = parseRequestScoped({