From 9b20b28a2546849c0b29a15e548a0b952bb37d2d Mon Sep 17 00:00:00 2001 From: Jeremy Date: Thu, 9 Apr 2026 09:51:28 -0500 Subject: [PATCH] fix(claude-code-cli): suppress streamed internal tool noise --- .../claude-code-cli/stream-adapter.ts | 77 +++++++++++++++---- .../tests/stream-adapter.test.ts | 58 +++++++++++++- 2 files changed, 121 insertions(+), 14 deletions(-) diff --git a/src/resources/extensions/claude-code-cli/stream-adapter.ts b/src/resources/extensions/claude-code-cli/stream-adapter.ts index 03ace8763..7995cdcdc 100644 --- a/src/resources/extensions/claude-code-cli/stream-adapter.ts +++ b/src/resources/extensions/claude-code-cli/stream-adapter.ts @@ -154,16 +154,73 @@ export function makeStreamExhaustedErrorMessage(model: string, lastTextContent: /** * 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. + * streamed and final assistant messages should therefore contain only + * user-facing content (text/thinking), not replayable tool blocks that GSD + * would render again. */ +function isUserFacingClaudeCodeBlock(block: AssistantMessage["content"][number]): boolean { + return block.type === "text" || block.type === "thinking"; +} + +function filterUserFacingClaudeCodeContent( + blocks: AssistantMessage["content"], +): AssistantMessage["content"] { + return blocks.filter(isUserFacingClaudeCodeBlock); +} + +function remapClaudeCodeContentIndex( + blocks: AssistantMessage["content"], + contentIndex: number, +): number { + let visibleCount = 0; + for (let i = 0; i <= contentIndex && i < blocks.length; i++) { + if (isUserFacingClaudeCodeBlock(blocks[i]!)) visibleCount++; + } + return Math.max(0, visibleCount - 1); +} + +function sanitizeClaudeCodePartial( + partial: AssistantMessage, +): AssistantMessage { + return { + ...partial, + content: filterUserFacingClaudeCodeContent(partial.content), + }; +} + +export function sanitizeClaudeCodeStreamingEvent( + event: AssistantMessageEvent, +): AssistantMessageEvent | null { + switch (event.type) { + case "toolcall_start": + case "toolcall_delta": + case "toolcall_end": + case "server_tool_use": + case "web_search_result": + return null; + case "text_start": + case "text_delta": + case "text_end": + case "thinking_start": + case "thinking_delta": + case "thinking_end": + return { + ...event, + contentIndex: remapClaudeCodeContentIndex(event.partial.content, event.contentIndex), + partial: sanitizeClaudeCodePartial(event.partial), + }; + default: + return event; + } +} + 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"); + const finalContent = filterUserFacingClaudeCodeContent(blocks); if (finalContent.length > 0) return finalContent; if (lastThinkingContent) { @@ -305,16 +362,10 @@ async function pumpSdkMessages( if (!builder) break; const assistantEvent = builder.handleEvent(event); - if (assistantEvent) { - // Skip toolcall events — the agent loop's externalToolExecution - // path emits tool_execution_start/end events after streamSimple - // returns. Streaming toolcall events would render tool calls - // out of order in the TUI's accumulated message content. - const t = assistantEvent.type; - if (t !== "toolcall_start" && t !== "toolcall_delta" && t !== "toolcall_end") { - stream.push(assistantEvent); - } - } + const sanitizedEvent = assistantEvent + ? sanitizeClaudeCodeStreamingEvent(assistantEvent) + : null; + if (sanitizedEvent) stream.push(sanitizedEvent); break; } 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 e8821aaff..118832d1a 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 @@ -7,8 +7,9 @@ import { getClaudeLookupCommand, makeStreamExhaustedErrorMessage, parseClaudeLookupOutput, + sanitizeClaudeCodeStreamingEvent, } from "../stream-adapter.ts"; -import type { Context, Message } from "@gsd/pi-ai"; +import type { AssistantMessage, Context, Message } from "@gsd/pi-ai"; // --------------------------------------------------------------------------- // Existing tests — exhausted stream fallback (#2575) @@ -161,6 +162,61 @@ describe("stream-adapter — final content filtering (#3861)", () => { }); }); +describe("stream-adapter — streaming content filtering follow-up (#3867)", () => { + function makePartial(content: AssistantMessage["content"]): AssistantMessage { + return { + role: "assistant", + content, + api: "anthropic-messages", + provider: "claude-code", + model: "claude-sonnet-4-20250514", + usage: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, + }, + stopReason: "stop", + timestamp: Date.now(), + }; + } + + test("sanitizeClaudeCodeStreamingEvent strips tool calls from streamed partials and remaps contentIndex", () => { + const event = sanitizeClaudeCodeStreamingEvent({ + type: "text_delta", + contentIndex: 2, + delta: "Done.", + partial: makePartial([ + { type: "toolCall", id: "tc_1", name: "ToolSearch", arguments: {} }, + { type: "thinking", thinking: "Planning next step" }, + { type: "text", text: "Done." }, + ] as any), + }); + + assert.ok(event, "text events should still be forwarded"); + assert.equal(event!.type, "text_delta"); + assert.equal((event! as any).contentIndex, 1); + assert.deepEqual((event! as any).partial.content, [ + { type: "thinking", thinking: "Planning next step" }, + { type: "text", text: "Done." }, + ]); + }); + + test("sanitizeClaudeCodeStreamingEvent suppresses internal tool streaming events entirely", () => { + const event = sanitizeClaudeCodeStreamingEvent({ + type: "toolcall_start", + contentIndex: 0, + partial: makePartial([ + { type: "toolCall", id: "tc_1", name: "Bash", arguments: {} }, + ] as any), + }); + + assert.equal(event, null); + }); +}); + describe("stream-adapter — Windows Claude path lookup (#3770)", () => { test("getClaudeLookupCommand uses where on Windows", () => { assert.equal(getClaudeLookupCommand("win32"), "where claude");