Merge pull request #805 from marcmarg/fix/strip-thought-signatures
fix: strip thought_signature fields for cross-provider compatibility
This commit is contained in:
@@ -29,6 +29,7 @@
|
||||
- Auto-reply: re-evaluate reasoning tag enforcement on fallback providers to prevent leaked reasoning. (#810 — thanks @mcinteerj)
|
||||
- Tools/Gemini: drop null-only union variants while cleaning tool schemas to avoid Cloud Code Assist schema errors. (#782 — thanks @AbhisekBasu1)
|
||||
- Connections UI: polish multi-account account cards in the Connections view. (#816 — thanks @steipete)
|
||||
- Gemini: strip Claude `msg_*` thought_signature fields from session history to avoid base64 decode errors. (#805 — thanks @marcmarg)
|
||||
|
||||
## 2026.1.12-3
|
||||
|
||||
|
||||
@@ -10,10 +10,13 @@ import {
|
||||
isCloudCodeAssistFormatError,
|
||||
isCompactionFailureError,
|
||||
isContextOverflowError,
|
||||
isMessagingToolDuplicate,
|
||||
isFailoverErrorMessage,
|
||||
normalizeTextForComparison,
|
||||
sanitizeGoogleTurnOrdering,
|
||||
sanitizeSessionMessagesImages,
|
||||
sanitizeToolCallId,
|
||||
stripThoughtSignatures,
|
||||
} from "./pi-embedded-helpers.js";
|
||||
import {
|
||||
DEFAULT_AGENTS_FILENAME,
|
||||
@@ -500,3 +503,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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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<T>(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;
|
||||
|
||||
Reference in New Issue
Block a user