From c4e8b60d2caa2bb040e83d46f2e0bc14ec8f9af6 Mon Sep 17 00:00:00 2001 From: Marc Date: Mon, 12 Jan 2026 23:02:54 +0100 Subject: [PATCH] fix: strip thought_signature fields for cross-provider compatibility MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Claude's extended thinking feature generates thought_signature fields (message IDs like "msg_abc123...") in content blocks. When these are sent to Google's Gemini API, it expects Base64-encoded bytes and rejects Claude's format with a 400 error. This commit adds stripThoughtSignatures() to remove these fields from assistant message content blocks during sanitization, enabling session histories to be shared across different providers (e.g., Claude → Gemini). Fixes cross-provider session bug where switching from Claude-thinking to Gemini (or vice versa) would fail with: "Invalid value at 'thought_signature' (TYPE_BYTES), Base64 decoding failed" Co-Authored-By: Claude Opus 4.5 --- src/agents/pi-embedded-helpers.test.ts | 175 +++++++++++++++++++++++++ src/agents/pi-embedded-helpers.ts | 35 ++++- 2 files changed, 209 insertions(+), 1 deletion(-) diff --git a/src/agents/pi-embedded-helpers.test.ts b/src/agents/pi-embedded-helpers.test.ts index 4d9872ebf..14f77c294 100644 --- a/src/agents/pi-embedded-helpers.test.ts +++ b/src/agents/pi-embedded-helpers.test.ts @@ -13,6 +13,8 @@ import { sanitizeGoogleTurnOrdering, sanitizeSessionMessagesImages, sanitizeToolCallId, + stripThoughtSignatures, + validateGeminiTurns, } from "./pi-embedded-helpers.js"; import { DEFAULT_AGENTS_FILENAME, @@ -480,3 +482,176 @@ describe("sanitizeSessionMessagesImages", () => { ]); }); }); + +describe("normalizeTextForComparison", () => { + it("lowercases text", () => { + expect(normalizeTextForComparison("Hello World")).toBe("hello world"); + }); + + it("trims whitespace", () => { + expect(normalizeTextForComparison(" hello ")).toBe("hello"); + }); + + it("collapses multiple spaces", () => { + expect(normalizeTextForComparison("hello world")).toBe("hello world"); + }); + + it("strips emoji", () => { + expect(normalizeTextForComparison("Hello 👋 World 🌍")).toBe("hello world"); + }); + + it("handles mixed normalization", () => { + expect(normalizeTextForComparison(" Hello 👋 WORLD 🌍 ")).toBe( + "hello world", + ); + }); +}); + +describe("stripThoughtSignatures", () => { + it("returns non-array content unchanged", () => { + expect(stripThoughtSignatures("hello")).toBe("hello"); + expect(stripThoughtSignatures(null)).toBe(null); + expect(stripThoughtSignatures(undefined)).toBe(undefined); + expect(stripThoughtSignatures(123)).toBe(123); + }); + + it("removes msg_-prefixed thought_signature from content blocks", () => { + const input = [ + { type: "text", text: "hello", thought_signature: "msg_abc123" }, + { type: "thinking", thinking: "test", thought_signature: "AQID" }, + ]; + const result = stripThoughtSignatures(input); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ type: "text", text: "hello" }); + expect(result[1]).toEqual({ + type: "thinking", + thinking: "test", + thought_signature: "AQID", + }); + expect("thought_signature" in result[0]).toBe(false); + expect("thought_signature" in result[1]).toBe(true); + }); + + it("preserves blocks without thought_signature", () => { + const input = [ + { type: "text", text: "hello" }, + { type: "toolCall", id: "call_1", name: "read", arguments: {} }, + ]; + const result = stripThoughtSignatures(input); + + expect(result).toEqual(input); + }); + + it("handles mixed blocks with and without thought_signature", () => { + const input = [ + { type: "text", text: "hello", thought_signature: "msg_abc" }, + { type: "toolCall", id: "call_1", name: "read", arguments: {} }, + { type: "thinking", thinking: "hmm", thought_signature: "msg_xyz" }, + ]; + const result = stripThoughtSignatures(input); + + expect(result).toEqual([ + { type: "text", text: "hello" }, + { type: "toolCall", id: "call_1", name: "read", arguments: {} }, + { type: "thinking", thinking: "hmm" }, + ]); + }); + + it("handles empty array", () => { + expect(stripThoughtSignatures([])).toEqual([]); + }); + + it("handles null/undefined blocks in array", () => { + const input = [null, undefined, { type: "text", text: "hello" }]; + const result = stripThoughtSignatures(input); + expect(result).toEqual([null, undefined, { type: "text", text: "hello" }]); + }); +}); + +describe("sanitizeSessionMessagesImages - thought_signature stripping", () => { + it("strips msg_-prefixed thought_signature from assistant message content blocks", async () => { + const input = [ + { + role: "assistant", + content: [ + { type: "text", text: "hello", thought_signature: "msg_abc123" }, + { + type: "thinking", + thinking: "reasoning", + thought_signature: "AQID", + }, + ], + }, + ] satisfies AgentMessage[]; + + const out = await sanitizeSessionMessagesImages(input, "test"); + + expect(out).toHaveLength(1); + const content = (out[0] as { content?: unknown[] }).content; + expect(content).toHaveLength(2); + expect("thought_signature" in ((content?.[0] ?? {}) as object)).toBe(false); + expect((content?.[1] as { thought_signature?: unknown })?.thought_signature).toBe( + "AQID", + ); + }); +}); + +describe("isMessagingToolDuplicate", () => { + it("returns false for empty sentTexts", () => { + expect(isMessagingToolDuplicate("hello world", [])).toBe(false); + }); + + it("returns false for short texts", () => { + expect(isMessagingToolDuplicate("short", ["short"])).toBe(false); + }); + + it("detects exact duplicates", () => { + expect( + isMessagingToolDuplicate("Hello, this is a test message!", [ + "Hello, this is a test message!", + ]), + ).toBe(true); + }); + + it("detects duplicates with different casing", () => { + expect( + isMessagingToolDuplicate("HELLO, THIS IS A TEST MESSAGE!", [ + "hello, this is a test message!", + ]), + ).toBe(true); + }); + + it("detects duplicates with emoji variations", () => { + expect( + isMessagingToolDuplicate("Hello! 👋 This is a test message!", [ + "Hello! This is a test message!", + ]), + ).toBe(true); + }); + + it("detects substring duplicates (LLM elaboration)", () => { + expect( + isMessagingToolDuplicate( + 'I sent the message: "Hello, this is a test message!"', + ["Hello, this is a test message!"], + ), + ).toBe(true); + }); + + it("detects when sent text contains block reply (reverse substring)", () => { + expect( + isMessagingToolDuplicate("Hello, this is a test message!", [ + 'I sent the message: "Hello, this is a test message!"', + ]), + ).toBe(true); + }); + + it("returns false for non-matching texts", () => { + expect( + isMessagingToolDuplicate("This is completely different content.", [ + "Hello, this is a test message!", + ]), + ).toBe(false); + }); +}); diff --git a/src/agents/pi-embedded-helpers.ts b/src/agents/pi-embedded-helpers.ts index b3b797230..88e1b1447 100644 --- a/src/agents/pi-embedded-helpers.ts +++ b/src/agents/pi-embedded-helpers.ts @@ -22,6 +22,37 @@ import type { WorkspaceBootstrapFile } from "./workspace.js"; export type EmbeddedContextFile = { path: string; content: string }; +// ── Cross-provider thought_signature sanitization ────────────────────────────── +// Claude's extended thinking feature generates thought_signature fields (message IDs +// like "msg_abc123...") in content blocks. When these are sent to Google's Gemini API, +// it expects Base64-encoded bytes and rejects Claude's format with a 400 error. +// This function strips thought_signature fields to enable cross-provider session sharing. + +type ContentBlockWithSignature = { + thought_signature?: unknown; + [key: string]: unknown; +}; + +/** + * Strips Claude-style thought_signature fields from content blocks. + * + * Gemini expects thought signatures as base64-encoded bytes, but Claude stores message ids + * like "msg_abc123...". We only strip "msg_*" to preserve any provider-valid signatures. + */ +export function stripThoughtSignatures(content: T): T { + if (!Array.isArray(content)) return content; + return content.map((block) => { + if (!block || typeof block !== "object") return block; + const rec = block as ContentBlockWithSignature; + const signature = rec.thought_signature; + if (typeof signature !== "string" || !signature.startsWith("msg_")) { + return block; + } + const { thought_signature: _signature, ...rest } = rec; + return rest; + }) as T; +} + const MAX_BOOTSTRAP_CHARS = 4000; const BOOTSTRAP_HEAD_CHARS = 2800; const BOOTSTRAP_TAIL_CHARS = 800; @@ -138,7 +169,9 @@ export async function sanitizeSessionMessagesImages( } const content = assistantMsg.content; if (Array.isArray(content)) { - const filteredContent = content.filter((block) => { + // Strip thought_signature fields to enable cross-provider session sharing + const strippedContent = stripThoughtSignatures(content); + const filteredContent = strippedContent.filter((block) => { if (!block || typeof block !== "object") return true; const rec = block as { type?: unknown; text?: unknown }; if (rec.type !== "text" || typeof rec.text !== "string") return true;