diff --git a/packages/ai/CHANGELOG.md b/packages/ai/CHANGELOG.md index b7b7844e..5f8ab525 100644 --- a/packages/ai/CHANGELOG.md +++ b/packages/ai/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Google provider thinking detection: `isThinkingPart()` now only checks `thought === true`, not `thoughtSignature`. Per Google docs, `thoughtSignature` is for context replay and can appear on any part type. Also removed `id` field from `functionCall`/`functionResponse` (rejected by Vertex AI and Cloud Code Assist), and added `textSignature` round-trip for multi-turn reasoning context. ([#631](https://github.com/badlogic/pi-mono/pull/631) by [@theBucky](https://github.com/theBucky)) + ## [0.42.5] - 2026-01-11 ## [0.42.4] - 2026-01-10 diff --git a/packages/ai/src/providers/google-gemini-cli.ts b/packages/ai/src/providers/google-gemini-cli.ts index 73aeea08..02b11367 100644 --- a/packages/ai/src/providers/google-gemini-cli.ts +++ b/packages/ai/src/providers/google-gemini-cli.ts @@ -511,6 +511,10 @@ export const streamGoogleGeminiCli: StreamFunction<"google-gemini-cli"> = ( }); } else { currentBlock.text += part.text; + currentBlock.textSignature = retainThoughtSignature( + currentBlock.textSignature, + part.thoughtSignature, + ); stream.push({ type: "text_delta", contentIndex: blockIndex(), diff --git a/packages/ai/src/providers/google-shared.ts b/packages/ai/src/providers/google-shared.ts index 52a83b9f..60d8b058 100644 --- a/packages/ai/src/providers/google-shared.ts +++ b/packages/ai/src/providers/google-shared.ts @@ -13,18 +13,19 @@ type GoogleApiType = "google-generative-ai" | "google-gemini-cli" | "google-vert * 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). + * - `thought: true` is the definitive marker for thinking content (thought summaries). + * - `thoughtSignature` is an encrypted representation of the model's internal thought process + * used to preserve reasoning context across multi-turn interactions. + * - `thoughtSignature` can appear on ANY part type (text, functionCall, etc.) - it does NOT + * indicate the part itself is thinking content. + * - For non-functionCall responses, the signature appears on the last part for context replay. * - 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. + * See: https://ai.google.dev/gemini-api/docs/thought-signatures */ export function isThinkingPart(part: Pick): boolean { - return part.thought === true || (typeof part.thoughtSignature === "string" && part.thoughtSignature.length > 0); + return part.thought === true; } /** @@ -84,7 +85,10 @@ export function convertMessages(model: Model, contex if (block.type === "text") { // Skip empty text blocks - they can cause issues with some models (e.g. Claude via Antigravity) if (!block.text || block.text.trim() === "") continue; - parts.push({ text: sanitizeSurrogates(block.text) }); + parts.push({ + text: sanitizeSurrogates(block.text), + ...(block.textSignature && { thoughtSignature: block.textSignature }), + }); } else if (block.type === "thinking") { // Skip empty thinking blocks if (!block.thinking || block.thinking.trim() === "") continue; @@ -104,14 +108,10 @@ export function convertMessages(model: Model, contex } else if (block.type === "toolCall") { const part: Part = { functionCall: { - id: block.id, name: block.name, args: block.arguments, }, }; - if (model.provider === "google-vertex" && part?.functionCall?.id) { - delete part.functionCall.id; // Vertex AI does not support 'id' in functionCall - } if (block.thoughtSignature) { part.thoughtSignature = block.thoughtSignature; } @@ -152,7 +152,6 @@ export function convertMessages(model: Model, contex const functionResponsePart: Part = { functionResponse: { - id: msg.toolCallId, name: msg.toolName, response: msg.isError ? { error: responseValue } : { output: responseValue }, // Nest images inside functionResponse.parts for Gemini 3 @@ -160,10 +159,6 @@ export function convertMessages(model: Model, contex }, }; - if (model.provider === "google-vertex" && functionResponsePart.functionResponse?.id) { - delete functionResponsePart.functionResponse.id; // Vertex AI does not support 'id' in functionResponse - } - // Cloud Code Assist API requires all function responses to be in a single user turn. // Check if the last content is already a user turn with function responses and merge. const lastContent = contents[contents.length - 1]; diff --git a/packages/ai/src/providers/google-vertex.ts b/packages/ai/src/providers/google-vertex.ts index 823ce5e3..384e4ca3 100644 --- a/packages/ai/src/providers/google-vertex.ts +++ b/packages/ai/src/providers/google-vertex.ts @@ -142,6 +142,10 @@ export const streamGoogleVertex: StreamFunction<"google-vertex"> = ( }); } else { currentBlock.text += part.text; + currentBlock.textSignature = retainThoughtSignature( + currentBlock.textSignature, + part.thoughtSignature, + ); stream.push({ type: "text_delta", contentIndex: blockIndex(), diff --git a/packages/ai/src/providers/google.ts b/packages/ai/src/providers/google.ts index cd2cf341..128dde7c 100644 --- a/packages/ai/src/providers/google.ts +++ b/packages/ai/src/providers/google.ts @@ -129,6 +129,10 @@ export const streamGoogle: StreamFunction<"google-generative-ai"> = ( }); } else { currentBlock.text += part.text; + currentBlock.textSignature = retainThoughtSignature( + currentBlock.textSignature, + part.thoughtSignature, + ); stream.push({ type: "text_delta", contentIndex: blockIndex(), diff --git a/packages/ai/test/google-thinking-signature.test.ts b/packages/ai/test/google-thinking-signature.test.ts index 02ae63f2..c90f7caa 100644 --- a/packages/ai/test/google-thinking-signature.test.ts +++ b/packages/ai/test/google-thinking-signature.test.ts @@ -4,12 +4,15 @@ import { isThinkingPart, retainThoughtSignature } from "../src/providers/google- describe("Google thinking detection (thoughtSignature)", () => { it("treats part.thought === true as thinking", () => { expect(isThinkingPart({ thought: true, thoughtSignature: undefined })).toBe(true); + expect(isThinkingPart({ thought: true, thoughtSignature: "opaque-signature" })).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 thoughtSignature alone as thinking", () => { + // Per Google docs, thoughtSignature is for context replay and can appear on any part type. + // Only thought === true indicates thinking content. + // See: https://ai.google.dev/gemini-api/docs/thought-signatures + expect(isThinkingPart({ thought: undefined, thoughtSignature: "opaque-signature" })).toBe(false); + expect(isThinkingPart({ thought: false, thoughtSignature: "opaque-signature" })).toBe(false); }); it("does not treat empty/missing signatures as thinking if thought is not set", () => { @@ -32,6 +35,4 @@ describe("Google thinking detection (thoughtSignature)", () => { 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. });