Merge pull request #3872 from jeremymcs/fix/claude-code-stream-noise-followup

fix: suppress streamed Claude Code internal tool noise
This commit is contained in:
Jeremy McSpadden 2026-04-09 10:10:49 -05:00 committed by GitHub
commit 335535b506
2 changed files with 121 additions and 14 deletions

View file

@ -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;
}

View file

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