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)
|
- 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)
|
- 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)
|
- 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
|
## 2026.1.12-3
|
||||||
|
|
||||||
|
|||||||
@@ -10,10 +10,13 @@ import {
|
|||||||
isCloudCodeAssistFormatError,
|
isCloudCodeAssistFormatError,
|
||||||
isCompactionFailureError,
|
isCompactionFailureError,
|
||||||
isContextOverflowError,
|
isContextOverflowError,
|
||||||
|
isMessagingToolDuplicate,
|
||||||
isFailoverErrorMessage,
|
isFailoverErrorMessage,
|
||||||
|
normalizeTextForComparison,
|
||||||
sanitizeGoogleTurnOrdering,
|
sanitizeGoogleTurnOrdering,
|
||||||
sanitizeSessionMessagesImages,
|
sanitizeSessionMessagesImages,
|
||||||
sanitizeToolCallId,
|
sanitizeToolCallId,
|
||||||
|
stripThoughtSignatures,
|
||||||
} from "./pi-embedded-helpers.js";
|
} from "./pi-embedded-helpers.js";
|
||||||
import {
|
import {
|
||||||
DEFAULT_AGENTS_FILENAME,
|
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 };
|
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 MAX_BOOTSTRAP_CHARS = 4000;
|
||||||
const BOOTSTRAP_HEAD_CHARS = 2800;
|
const BOOTSTRAP_HEAD_CHARS = 2800;
|
||||||
const BOOTSTRAP_TAIL_CHARS = 800;
|
const BOOTSTRAP_TAIL_CHARS = 800;
|
||||||
@@ -138,7 +169,9 @@ export async function sanitizeSessionMessagesImages(
|
|||||||
}
|
}
|
||||||
const content = assistantMsg.content;
|
const content = assistantMsg.content;
|
||||||
if (Array.isArray(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;
|
if (!block || typeof block !== "object") return true;
|
||||||
const rec = block as { type?: unknown; text?: unknown };
|
const rec = block as { type?: unknown; text?: unknown };
|
||||||
if (rec.type !== "text" || typeof rec.text !== "string") return true;
|
if (rec.type !== "text" || typeof rec.text !== "string") return true;
|
||||||
|
|||||||
Reference in New Issue
Block a user