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:
commit
335535b506
2 changed files with 121 additions and 14 deletions
|
|
@ -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;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue