From b4a0392464c14a3257c295767bb64c01f4fb9f2a Mon Sep 17 00:00:00 2001 From: Jeremy Date: Thu, 9 Apr 2026 08:27:23 -0500 Subject: [PATCH] fix(claude-code-cli): suppress internal tool call noise --- .../claude-code-cli/stream-adapter.ts | 66 +++++++++---------- .../tests/stream-adapter.test.ts | 34 +++++++++- 2 files changed, 65 insertions(+), 35 deletions(-) diff --git a/src/resources/extensions/claude-code-cli/stream-adapter.ts b/src/resources/extensions/claude-code-cli/stream-adapter.ts index b1eb8acca..03ace8763 100644 --- a/src/resources/extensions/claude-code-cli/stream-adapter.ts +++ b/src/resources/extensions/claude-code-cli/stream-adapter.ts @@ -152,6 +152,32 @@ export function makeStreamExhaustedErrorMessage(model: string, lastTextContent: return message; } +/** + * Claude Code executes its own internal tool loop inside the SDK call. The + * final assistant message should therefore contain only user-facing content + * (text/thinking), not replayable toolCall blocks that GSD would render again. + */ +export function buildFinalClaudeCodeContent( + blocks: AssistantMessage["content"], + lastThinkingContent: string, + lastTextContent: string, + resultText?: string, +): AssistantMessage["content"] { + const finalContent = blocks.filter((block) => block.type === "text" || block.type === "thinking"); + if (finalContent.length > 0) return finalContent; + + if (lastThinkingContent) { + finalContent.push({ type: "thinking", thinking: lastThinkingContent }); + } + if (lastTextContent) { + finalContent.push({ type: "text", text: lastTextContent }); + } + if (finalContent.length === 0 && resultText) { + finalContent.push({ type: "text", text: resultText }); + } + return finalContent; +} + // --------------------------------------------------------------------------- // SDK options builder // --------------------------------------------------------------------------- @@ -211,8 +237,6 @@ async function pumpSdkMessages( /** Track the last text content seen across all assistant turns for the final message. */ let lastTextContent = ""; let lastThinkingContent = ""; - /** Collect tool calls from intermediate SDK turns for tool_execution events. */ - const intermediateToolCalls: AssistantMessage["content"] = []; try { // Dynamic import — the SDK is an optional dependency. @@ -318,9 +342,6 @@ async function pumpSdkMessages( lastTextContent = block.text; } else if (block.type === "thinking" && block.thinking) { lastThinkingContent = block.thinking; - } else if (block.type === "toolCall") { - // Collect tool calls for externalToolExecution rendering - intermediateToolCalls.push(block); } } } @@ -331,35 +352,12 @@ async function pumpSdkMessages( // -- Result (terminal) -- case "result": { const result = msg as SDKResultMessage; - - // Build final message. Include intermediate tool calls so the - // agent loop's externalToolExecution path emits tool_execution - // events for proper TUI rendering, followed by the text response. - const finalContent: AssistantMessage["content"] = []; - - // Add tool calls from intermediate turns first (renders above text) - finalContent.push(...intermediateToolCalls); - - // Add text/thinking from the last turn - if (builder && builder.message.content.length > 0) { - for (const block of builder.message.content) { - if (block.type === "text" || block.type === "thinking") { - finalContent.push(block); - } - } - } else { - if (lastThinkingContent) { - finalContent.push({ type: "thinking", thinking: lastThinkingContent }); - } - if (lastTextContent) { - finalContent.push({ type: "text", text: lastTextContent }); - } - } - - // Fallback: use the SDK's result text if we have no content - if (finalContent.length === 0 && result.subtype === "success" && result.result) { - finalContent.push({ type: "text", text: result.result }); - } + const finalContent = buildFinalClaudeCodeContent( + builder?.message.content ?? [], + lastThinkingContent, + lastTextContent, + result.subtype === "success" ? result.result : undefined, + ); const finalMessage: AssistantMessage = { role: "assistant", diff --git a/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts b/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts index 40fd399dc..e8821aaff 100644 --- a/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts +++ b/src/resources/extensions/claude-code-cli/tests/stream-adapter.test.ts @@ -1,10 +1,11 @@ import { describe, test } from "node:test"; import assert from "node:assert/strict"; import { - makeStreamExhaustedErrorMessage, buildPromptFromContext, + buildFinalClaudeCodeContent, buildSdkOptions, getClaudeLookupCommand, + makeStreamExhaustedErrorMessage, parseClaudeLookupOutput, } from "../stream-adapter.ts"; import type { Context, Message } from "@gsd/pi-ai"; @@ -129,6 +130,37 @@ describe("stream-adapter — session persistence (#2859)", () => { }); }); +describe("stream-adapter — final content filtering (#3861)", () => { + test("buildFinalClaudeCodeContent strips intermediate tool calls from the final assistant message", () => { + const finalContent = buildFinalClaudeCodeContent( + [ + { type: "toolCall", id: "tc_1", name: "Read", arguments: {} }, + { type: "thinking", thinking: "Planning next step" }, + { type: "text", text: "Done." }, + ] as any, + "", + "", + ); + + assert.deepEqual(finalContent, [ + { type: "thinking", thinking: "Planning next step" }, + { type: "text", text: "Done." }, + ]); + }); + + test("buildFinalClaudeCodeContent falls back to cached text when the final turn only had tool calls", () => { + const finalContent = buildFinalClaudeCodeContent( + [ + { type: "toolCall", id: "tc_2", name: "Edit", arguments: { file_path: "app.ts" } }, + ] as any, + "", + "User-facing answer", + ); + + assert.deepEqual(finalContent, [{ type: "text", text: "User-facing answer" }]); + }); +}); + describe("stream-adapter — Windows Claude path lookup (#3770)", () => { test("getClaudeLookupCommand uses where on Windows", () => { assert.equal(getClaudeLookupCommand("win32"), "where claude");