Merge pull request #3867 from jeremymcs/fix/claude-code-cli-noise

fix: suppress Claude Code internal tool noise in TUI
This commit is contained in:
Jeremy McSpadden 2026-04-09 09:09:44 -05:00 committed by GitHub
commit 72b7b6be7f
2 changed files with 65 additions and 35 deletions

View file

@ -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",

View file

@ -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");