fix(chat): prune claude MCP provisional text above tool output
This commit is contained in:
parent
c63f801412
commit
7208a6af36
2 changed files with 132 additions and 0 deletions
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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") {
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue