test(tui): regression test for pinned latest-output duplication
Extract the post-tool text-block selection logic into a small pure helper (`findLatestPinnableText`) so the regression scenario can be covered without standing up the full interactive controller harness. The new test pins the bug from #4120: when content blocks are `[text1, tool1, text2_streaming]`, the helper must return `text1` (not `text2`), because `text2` is still streaming live into the chat container and mirroring it would render the same tokens twice. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
dc84694c65
commit
9ffde91020
2 changed files with 94 additions and 20 deletions
|
|
@ -0,0 +1,71 @@
|
|||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { findLatestPinnableText } from "./chat-controller.js";
|
||||
|
||||
test("findLatestPinnableText: empty content returns empty string", () => {
|
||||
assert.equal(findLatestPinnableText([]), "");
|
||||
});
|
||||
|
||||
test("findLatestPinnableText: no tool calls returns empty string", () => {
|
||||
const blocks = [
|
||||
{ type: "text", text: "hello" },
|
||||
{ type: "text", text: "world" },
|
||||
];
|
||||
assert.equal(findLatestPinnableText(blocks), "");
|
||||
});
|
||||
|
||||
test("findLatestPinnableText: returns text preceding a tool call", () => {
|
||||
const blocks = [
|
||||
{ type: "text", text: "doing the thing" },
|
||||
{ type: "toolCall", id: "1", name: "Read" },
|
||||
];
|
||||
assert.equal(findLatestPinnableText(blocks), "doing the thing");
|
||||
});
|
||||
|
||||
test("findLatestPinnableText: ignores trailing streaming text after the last tool call (regression: pinned mirror duplicated chat-container tokens)", () => {
|
||||
const blocks = [
|
||||
{ type: "text", text: "first prose" },
|
||||
{ type: "toolCall", id: "1", name: "Read" },
|
||||
{ type: "text", text: "second prose still streaming" },
|
||||
];
|
||||
assert.equal(findLatestPinnableText(blocks), "first prose");
|
||||
});
|
||||
|
||||
test("findLatestPinnableText: with multiple tools, picks text before the most recent tool call", () => {
|
||||
const blocks = [
|
||||
{ type: "text", text: "first" },
|
||||
{ type: "toolCall", id: "1", name: "Read" },
|
||||
{ type: "text", text: "second" },
|
||||
{ type: "toolCall", id: "2", name: "Grep" },
|
||||
{ type: "text", text: "third streaming" },
|
||||
];
|
||||
assert.equal(findLatestPinnableText(blocks), "second");
|
||||
});
|
||||
|
||||
test("findLatestPinnableText: treats serverToolUse the same as toolCall", () => {
|
||||
const blocks = [
|
||||
{ type: "text", text: "before web search" },
|
||||
{ type: "serverToolUse", id: "ws1", name: "web_search" },
|
||||
{ type: "text", text: "answer streaming" },
|
||||
];
|
||||
assert.equal(findLatestPinnableText(blocks), "before web search");
|
||||
});
|
||||
|
||||
test("findLatestPinnableText: skips empty/whitespace-only text blocks", () => {
|
||||
const blocks = [
|
||||
{ type: "text", text: "real prose" },
|
||||
{ type: "text", text: " " },
|
||||
{ type: "text", text: "" },
|
||||
{ type: "toolCall", id: "1", name: "Read" },
|
||||
];
|
||||
assert.equal(findLatestPinnableText(blocks), "real prose");
|
||||
});
|
||||
|
||||
test("findLatestPinnableText: thinking blocks are not pinnable", () => {
|
||||
const blocks = [
|
||||
{ type: "thinking", thinking: "internal" },
|
||||
{ type: "toolCall", id: "1", name: "Read" },
|
||||
];
|
||||
assert.equal(findLatestPinnableText(blocks), "");
|
||||
});
|
||||
|
|
@ -22,6 +22,28 @@ function hasAssistantToolBlocks(message: { content: Array<any> }): boolean {
|
|||
return message.content.some((c) => c.type === "toolCall" || c.type === "serverToolUse");
|
||||
}
|
||||
|
||||
// Pick the latest non-empty text block that appears strictly before the most
|
||||
// recent tool call. Text blocks that come after the last tool call are still
|
||||
// streaming live into the chat container, so mirroring them into the pinned
|
||||
// "Latest Output" zone would render the same tokens twice.
|
||||
export function findLatestPinnableText(contentBlocks: Array<any>): string {
|
||||
let lastToolIdx = -1;
|
||||
for (let i = contentBlocks.length - 1; i >= 0; i--) {
|
||||
const c = contentBlocks[i];
|
||||
if (c?.type === "toolCall" || c?.type === "serverToolUse") {
|
||||
lastToolIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
for (let i = lastToolIdx - 1; i >= 0; i--) {
|
||||
const c = contentBlocks[i];
|
||||
if (c?.type === "text" && typeof c.text === "string" && c.text.trim()) {
|
||||
return c.text.trim();
|
||||
}
|
||||
}
|
||||
return "";
|
||||
}
|
||||
|
||||
// Tracks the latest assistant text for the pinned message zone
|
||||
let lastPinnedText = "";
|
||||
// Whether any tool execution has been added in this assistant turn (triggers pinned display)
|
||||
|
|
@ -286,26 +308,7 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & {
|
|||
if (hasTools) hasToolsInTurn = true;
|
||||
|
||||
if (hasToolsInTurn) {
|
||||
// Mirror the latest text block that precedes the most recent tool
|
||||
// call. Text blocks that come *after* the last tool call are still
|
||||
// streaming live into the chat container, so mirroring them would
|
||||
// duplicate the same tokens in two places at once.
|
||||
let lastToolIdx = -1;
|
||||
for (let i = contentBlocks.length - 1; i >= 0; i--) {
|
||||
const c = contentBlocks[i] as any;
|
||||
if (c.type === "toolCall" || c.type === "serverToolUse") {
|
||||
lastToolIdx = i;
|
||||
break;
|
||||
}
|
||||
}
|
||||
let latestText = "";
|
||||
for (let i = lastToolIdx - 1; i >= 0; i--) {
|
||||
const c = contentBlocks[i] as any;
|
||||
if (c.type === "text" && c.text?.trim()) {
|
||||
latestText = c.text.trim();
|
||||
break;
|
||||
}
|
||||
}
|
||||
const latestText = findLatestPinnableText(contentBlocks);
|
||||
|
||||
if (latestText && latestText !== lastPinnedText) {
|
||||
lastPinnedText = latestText;
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue