diff --git a/src/agents/pi-embedded-utils.test.ts b/src/agents/pi-embedded-utils.test.ts index e753bbab9..5fdd74623 100644 --- a/src/agents/pi-embedded-utils.test.ts +++ b/src/agents/pi-embedded-utils.test.ts @@ -257,4 +257,105 @@ describe("extractAssistantText", () => { const result = extractAssistantText(msg); expect(result).toBe("First block.\nThird block."); }); + + it("strips downgraded Gemini tool call text representations", () => { + const msg: AssistantMessage = { + role: "assistant", + content: [ + { + type: "text", + text: `[Tool Call: exec (ID: toolu_vrtx_014w1P6B6w4V92v4VzG7Qk12)] +Arguments: { "command": "git status", "timeout": 120000 }`, + }, + ], + timestamp: Date.now(), + }; + + const result = extractAssistantText(msg); + expect(result).toBe(""); + }); + + it("strips multiple downgraded tool calls", () => { + const msg: AssistantMessage = { + role: "assistant", + content: [ + { + type: "text", + text: `[Tool Call: read (ID: toolu_1)] +Arguments: { "path": "/some/file.txt" } +[Tool Call: exec (ID: toolu_2)] +Arguments: { "command": "ls -la" }`, + }, + ], + timestamp: Date.now(), + }; + + const result = extractAssistantText(msg); + expect(result).toBe(""); + }); + + it("strips tool results for downgraded calls", () => { + const msg: AssistantMessage = { + role: "assistant", + content: [ + { + type: "text", + text: `[Tool Result for ID toolu_123] +{"status": "ok", "data": "some result"}`, + }, + ], + timestamp: Date.now(), + }; + + const result = extractAssistantText(msg); + expect(result).toBe(""); + }); + + it("preserves text around downgraded tool calls", () => { + const msg: AssistantMessage = { + role: "assistant", + content: [ + { + type: "text", + text: `Let me check that for you. +[Tool Call: browser (ID: toolu_abc)] +Arguments: { "action": "act", "request": "click button" }`, + }, + ], + timestamp: Date.now(), + }; + + const result = extractAssistantText(msg); + expect(result).toBe("Let me check that for you."); + }); + + it("handles multiple text blocks with tool calls and results", () => { + const msg: AssistantMessage = { + role: "assistant", + content: [ + { + type: "text", + text: "Here's what I found:", + }, + { + type: "text", + text: `[Tool Call: read (ID: toolu_1)] +Arguments: { "path": "/test.txt" }`, + }, + { + type: "text", + text: `[Tool Result for ID toolu_1] +File contents here`, + }, + { + type: "text", + text: "Done checking.", + }, + ], + timestamp: Date.now(), + }; + + const result = extractAssistantText(msg); + expect(result).toBe("Here's what I found:\nDone checking."); + }); }); diff --git a/src/agents/pi-embedded-utils.ts b/src/agents/pi-embedded-utils.ts index 12594eaa1..f973b0ab1 100644 --- a/src/agents/pi-embedded-utils.ts +++ b/src/agents/pi-embedded-utils.ts @@ -21,6 +21,32 @@ function stripMinimaxToolCallXml(text: string): string { return cleaned; } +/** + * Strip downgraded tool call text representations that leak into text content. + * When replaying history to Gemini, tool calls without `thought_signature` are + * downgraded to text blocks like `[Tool Call: name (ID: ...)]`. These should + * not be shown to users. + */ +function stripDowngradedToolCallText(text: string): string { + if (!text) return text; + if (!/\[Tool (?:Call|Result)/i.test(text)) return text; + + // Remove [Tool Call: name (ID: ...)] blocks and their Arguments. + // Match until the next [Tool marker or end of string. + let cleaned = text.replace( + /\[Tool Call:[^\]]*\]\n?(?:Arguments:[\s\S]*?)?(?=\n*\[Tool |\n*$)/gi, + "", + ); + + // Remove [Tool Result for ID ...] blocks and their content. + cleaned = cleaned.replace( + /\[Tool Result for ID[^\]]*\]\n?[\s\S]*?(?=\n*\[Tool |\n*$)/gi, + "", + ); + + return cleaned.trim(); +} + export function extractAssistantText(msg: AssistantMessage): string { const isTextBlock = (block: unknown): block is { type: "text"; text: string } => { if (!block || typeof block !== "object") return false; @@ -31,7 +57,9 @@ export function extractAssistantText(msg: AssistantMessage): string { const blocks = Array.isArray(msg.content) ? msg.content .filter(isTextBlock) - .map((c) => stripMinimaxToolCallXml(c.text).trim()) + .map((c) => + stripDowngradedToolCallText(stripMinimaxToolCallXml(c.text)).trim(), + ) .filter(Boolean) : []; return blocks.join("\n").trim();