From 7208a6af36a6eca3c9bdeecbe4b5245f5ff71e31 Mon Sep 17 00:00:00 2001 From: Jeremy Date: Tue, 14 Apr 2026 21:41:29 -0500 Subject: [PATCH] fix(chat): prune claude MCP provisional text above tool output --- .../src/core/chat-controller-ordering.test.ts | 86 +++++++++++++++++++ .../controllers/chat-controller.ts | 46 ++++++++++ 2 files changed, 132 insertions(+) diff --git a/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts b/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts index bacdb2da4..41a1fd8f9 100644 --- a/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts +++ b/packages/pi-coding-agent/src/core/chat-controller-ordering.test.ts @@ -234,6 +234,92 @@ test("chat-controller renders serverToolUse before trailing text matching conten assert.equal(host.chatContainer.children[1]?.constructor?.name, "AssistantMessageComponent"); }); +test("chat-controller drops provisional pre-tool text for claude-code MCP turns", async () => { + (globalThis as any)[Symbol.for("@gsd/pi-coding-agent:theme")] = { + fg: (_key: string, text: string) => text, + bg: (_key: string, text: string) => text, + bold: (text: string) => text, + italic: (text: string) => text, + truncate: (text: string) => text, + }; + + const host = createHost(); + host.getMarkdownThemeWithSettings = () => ({}); + + const mcpTool = { + type: "toolCall", + id: "mcp-tool-1", + name: "read", + mcpServer: "filesystem", + arguments: { filePath: "/tmp/demo.txt" }, + }; + + await handleAgentEvent(host, { type: "message_start", message: makeAssistant([]) } as any); + + // Provisional assistant text arrives first. + await handleAgentEvent( + host, + { + type: "message_update", + message: makeAssistant([{ type: "text", text: "Let me inspect the workspace first." }]), + assistantMessageEvent: { + type: "text_delta", + contentIndex: 0, + delta: "Let me inspect the workspace first.", + partial: makeAssistant([{ type: "text", text: "Let me inspect the workspace first." }]), + }, + } as any, + ); + assert.equal(host.chatContainer.children.length, 1); + assert.equal(host.chatContainer.children[0]?.constructor?.name, "AssistantMessageComponent"); + + // MCP tool appears; provisional text should be removed from the chat stack. + await handleAgentEvent( + host, + { + type: "message_update", + message: makeAssistant([{ type: "text", text: "Let me inspect the workspace first." }, mcpTool]), + assistantMessageEvent: { + type: "toolcall_end", + contentIndex: 1, + toolCall: { + ...mcpTool, + externalResult: { + content: [{ type: "text", text: "file preview" }], + details: {}, + isError: false, + }, + }, + partial: makeAssistant([{ type: "text", text: "Let me inspect the workspace first." }, mcpTool]), + }, + } as any, + ); + assert.equal(host.chatContainer.children.length, 1, "provisional pre-tool text should be pruned"); + assert.equal(host.chatContainer.children[0]?.constructor?.name, "ToolExecutionComponent"); + + // Final assistant output should render below the tool. + const finalContent = [mcpTool, { type: "text", text: "Which missing feature matters most to you?" }]; + await handleAgentEvent( + host, + { + type: "message_update", + message: makeAssistant(finalContent), + assistantMessageEvent: { + type: "text_delta", + contentIndex: 1, + delta: "Which missing feature matters most to you?", + partial: makeAssistant(finalContent), + }, + } as any, + ); + assert.equal(host.chatContainer.children.length, 2); + assert.equal(host.chatContainer.children[0]?.constructor?.name, "ToolExecutionComponent"); + assert.equal(host.chatContainer.children[1]?.constructor?.name, "AssistantMessageComponent"); + + // Finalize to tear down any pinned spinner state. + await handleAgentEvent(host, { type: "message_end", message: makeAssistant(finalContent) } as any); +}); + test("chat-controller pins latest assistant text above editor when tool calls are present", async () => { (globalThis as any)[Symbol.for("@gsd/pi-coding-agent:theme")] = { fg: (_key: string, text: string) => text, diff --git a/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts b/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts index c2f6bc1eb..556c87c54 100644 --- a/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts +++ b/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.ts @@ -302,6 +302,18 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & { // Build desired segment plan from content[]. { const blocks = host.streamingMessage.content; + const isClaudeCodeProvider = host.streamingMessage.provider === "claude-code"; + const hasMcpToolBlock = blocks.some((b: any) => { + if (b?.type === "toolCall") { + return typeof b?.mcpServer === "string" || String(b?.name ?? "").startsWith("mcp__"); + } + if (b?.type === "serverToolUse") { + return typeof b?.mcpServer === "string" || String(b?.name ?? "").startsWith("mcp__"); + } + return false; + }); + const shouldDropPreToolText = isClaudeCodeProvider && hasMcpToolBlock; + const firstToolIdx = blocks.findIndex((b: any) => b.type === "toolCall" || b.type === "serverToolUse"); type DesiredSegment = | { kind: "text-run"; startIndex: number; endIndex: number } | { kind: "tool"; contentIndex: number; toolId: string }; @@ -312,6 +324,9 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & { const isText = b.type === "text" || b.type === "thinking"; const isTool = b.type === "toolCall" || b.type === "serverToolUse"; if (isText) { + if (shouldDropPreToolText && firstToolIdx >= 0 && i < firstToolIdx) { + continue; + } if (runStart === -1) runStart = i; } else { if (runStart !== -1) { @@ -327,6 +342,37 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & { desired.push({ kind: "text-run", startIndex: runStart, endIndex: blocks.length - 1 }); } + // Claude Code MCP can emit provisional pre-tool prose that gets + // superseded by post-tool output. Prune stale text-run segments so + // the final assistant output remains below tool output. + if (shouldDropPreToolText && firstToolIdx >= 0) { + const desiredTextStarts = new Set( + desired + .filter((seg): seg is Extract => seg.kind === "text-run") + .map((seg) => seg.startIndex), + ); + const desiredToolIndices = new Set( + desired + .filter((seg): seg is Extract => seg.kind === "tool") + .map((seg) => seg.contentIndex), + ); + const nextRendered: RenderedSegment[] = []; + for (const seg of renderedSegments) { + if (seg.kind === "text-run" && !desiredTextStarts.has(seg.startIndex)) { + host.chatContainer.removeChild(seg.component); + if (host.streamingComponent === seg.component) { + host.streamingComponent = undefined; + } + continue; + } + if (seg.kind === "tool" && !desiredToolIndices.has(seg.contentIndex)) { + continue; + } + nextRendered.push(seg); + } + renderedSegments = nextRendered; + } + // Append any newly needed segments (never reorder existing ones). for (const seg of desired) { if (seg.kind === "tool") {