diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts index 7bc8ef9a..60625aaf 100644 --- a/packages/ai/src/providers/google-gemini-cli.ts +++ b/packages/ai/src/providers/google-gemini-cli.ts @@ -19,7 +19,14 @@ import type { } from "../types.js"; import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; -import { convertMessages, convertTools, mapStopReasonString, mapToolChoice } from "./google-shared.js"; +import { + convertMessages, + convertTools, + isThinkingPart, + mapStopReasonString, + mapToolChoice, + retainThoughtSignature, +} from "./google-shared.js"; /** * Thinking level for Gemini 3 models. @@ -360,7 +367,7 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( if (candidate?.content?.parts) { for (const part of candidate.content.parts) { if (part.text !== undefined) { - const isThinking = part.thought === true; + const isThinking = isThinkingPart(part); if ( !currentBlock || (isThinking && currentBlock.type !== "thinking") || @@ -395,7 +402,10 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( } if (currentBlock.type === "thinking") { currentBlock.thinking += part.text; - currentBlock.thinkingSignature = part.thoughtSignature; + currentBlock.thinkingSignature = retainThoughtSignature( + currentBlock.thinkingSignature, + part.thoughtSignature, + ); stream.push({ type: "thinking_delta", contentIndex: blockIndex(), diff --git a/packages/ai/src/providers/google-shared.ts b/packages/ai/src/providers/google-shared.ts index f0b2c3e4..50610a9c 100644 --- a/packages/ai/src/providers/google-shared.ts +++ b/packages/ai/src/providers/google-shared.ts @@ -9,6 +9,38 @@ import { transformMessages } from "./transorm-messages.js"; type GoogleApiType = "google-generative-ai" | "google-gemini-cli" | "google-vertex"; +/** + * Determines whether a streamed Gemini `Part` should be treated as "thinking". + * + * Protocol note (Gemini / Vertex AI thought signatures): + * - `thoughtSignature` may appear without `thought: true` (including in empty-text parts at the end of streaming). + * - When persisting/replaying model outputs, signature-bearing parts must be preserved as-is; + * do not merge/move signatures across parts. + * - Our streaming representation uses content blocks, so we classify any non-empty `thoughtSignature` + * as thinking to avoid leaking thought content into normal assistant text. + * + * Some Google backends send thought content with `thoughtSignature` but omit `thought: true` + * on subsequent deltas. We treat any non-empty `thoughtSignature` as thinking to avoid + * leaking thought text into the normal assistant text stream. + */ +export function isThinkingPart(part: Pick): boolean { + return part.thought === true || (typeof part.thoughtSignature === "string" && part.thoughtSignature.length > 0); +} + +/** + * Retain thought signatures during streaming. + * + * Some backends only send `thoughtSignature` on the first delta for a given part/block; later deltas may omit it. + * This helper preserves the last non-empty signature for the current block. + * + * Note: this does NOT merge or move signatures across distinct response parts. It only prevents + * a signature from being overwritten with `undefined` within the same streamed block. + */ +export function retainThoughtSignature(existing: string | undefined, incoming: string | undefined): string | undefined { + if (typeof incoming === "string" && incoming.length > 0) return incoming; + return existing; +} + /** * Convert internal messages to Gemini Content[] format. */ diff --git a/packages/ai/src/providers/google-vertex.ts b/packages/ai/src/providers/google-vertex.ts index 3aebec36..823ce5e3 100644 --- a/packages/ai/src/providers/google-vertex.ts +++ b/packages/ai/src/providers/google-vertex.ts @@ -20,7 +20,14 @@ import type { import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; import type { GoogleThinkingLevel } from "./google-gemini-cli.js"; -import { convertMessages, convertTools, mapStopReason, mapToolChoice } from "./google-shared.js"; +import { + convertMessages, + convertTools, + isThinkingPart, + mapStopReason, + mapToolChoice, + retainThoughtSignature, +} from "./google-shared.js"; export interface GoogleVertexOptions extends StreamOptions { toolChoice?: "auto" | "none" | "any"; @@ -88,7 +95,7 @@ export const streamGoogleVertex: StreamFunction<"google-vertex"> = ( if (candidate?.content?.parts) { for (const part of candidate.content.parts) { if (part.text !== undefined) { - const isThinking = part.thought === true; + const isThinking = isThinkingPart(part); if ( !currentBlock || (isThinking && currentBlock.type !== "thinking") || @@ -123,7 +130,10 @@ export const streamGoogleVertex: StreamFunction<"google-vertex"> = ( } if (currentBlock.type === "thinking") { currentBlock.thinking += part.text; - currentBlock.thinkingSignature = part.thoughtSignature; + currentBlock.thinkingSignature = retainThoughtSignature( + currentBlock.thinkingSignature, + part.thoughtSignature, + ); stream.push({ type: "thinking_delta", contentIndex: blockIndex(), diff --git a/packages/ai/src/providers/google.ts b/packages/ai/src/providers/google.ts index 67893eef..cd2cf341 100644 --- a/packages/ai/src/providers/google.ts +++ b/packages/ai/src/providers/google.ts @@ -20,7 +20,14 @@ import type { import { AssistantMessageEventStream } from "../utils/event-stream.js"; import { sanitizeSurrogates } from "../utils/sanitize-unicode.js"; import type { GoogleThinkingLevel } from "./google-gemini-cli.js"; -import { convertMessages, convertTools, mapStopReason, mapToolChoice } from "./google-shared.js"; +import { + convertMessages, + convertTools, + isThinkingPart, + mapStopReason, + mapToolChoice, + retainThoughtSignature, +} from "./google-shared.js"; export interface GoogleOptions extends StreamOptions { toolChoice?: "auto" | "none" | "any"; @@ -75,7 +82,7 @@ export const streamGoogle: StreamFunction<"google-generative-ai"> = ( if (candidate?.content?.parts) { for (const part of candidate.content.parts) { if (part.text !== undefined) { - const isThinking = part.thought === true; + const isThinking = isThinkingPart(part); if ( !currentBlock || (isThinking && currentBlock.type !== "thinking") || @@ -110,7 +117,10 @@ export const streamGoogle: StreamFunction<"google-generative-ai"> = ( } if (currentBlock.type === "thinking") { currentBlock.thinking += part.text; - currentBlock.thinkingSignature = part.thoughtSignature; + currentBlock.thinkingSignature = retainThoughtSignature( + currentBlock.thinkingSignature, + part.thoughtSignature, + ); stream.push({ type: "thinking_delta", contentIndex: blockIndex(), diff --git a/packages/ai/test/google-thinking-signature.test.ts b/packages/ai/test/google-thinking-signature.test.ts new file mode 100644 index 00000000..02ae63f2 --- /dev/null +++ b/packages/ai/test/google-thinking-signature.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { isThinkingPart, retainThoughtSignature } from "../src/providers/google-shared.js"; + +describe("Google thinking detection (thoughtSignature)", () => { + it("treats part.thought === true as thinking", () => { + expect(isThinkingPart({ thought: true, thoughtSignature: undefined })).toBe(true); + }); + + it("treats a non-empty thoughtSignature as thinking even if thought is missing", () => { + // This is the bug: some backends omit `thought: true` but still include `thoughtSignature` + expect(isThinkingPart({ thought: undefined, thoughtSignature: "opaque-signature" })).toBe(true); + expect(isThinkingPart({ thought: false, thoughtSignature: "opaque-signature" })).toBe(true); + }); + + it("does not treat empty/missing signatures as thinking if thought is not set", () => { + expect(isThinkingPart({ thought: undefined, thoughtSignature: undefined })).toBe(false); + expect(isThinkingPart({ thought: false, thoughtSignature: "" })).toBe(false); + }); + + it("preserves the existing signature when subsequent deltas omit thoughtSignature", () => { + const first = retainThoughtSignature(undefined, "sig-1"); + expect(first).toBe("sig-1"); + + const second = retainThoughtSignature(first, undefined); + expect(second).toBe("sig-1"); + + const third = retainThoughtSignature(second, ""); + expect(third).toBe("sig-1"); + }); + + it("updates the signature when a new non-empty signature arrives", () => { + const updated = retainThoughtSignature("sig-1", "sig-2"); + expect(updated).toBe("sig-2"); + }); + + // Note: signature-only parts (empty text + thoughtSignature) are handled by isThinkingPart via thoughtSignature presence. +});