fix(chat): prune claude MCP provisional text above tool output

This commit is contained in:
Jeremy 2026-04-14 21:41:29 -05:00
parent c63f801412
commit 7208a6af36
2 changed files with 132 additions and 0 deletions

View file

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

View file

@ -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<DesiredSegment, { kind: "text-run" }> => seg.kind === "text-run")
.map((seg) => seg.startIndex),
);
const desiredToolIndices = new Set(
desired
.filter((seg): seg is Extract<DesiredSegment, { kind: "tool" }> => 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") {