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:
Jeremy 2026-04-13 08:20:24 -05:00
parent dc84694c65
commit 9ffde91020
2 changed files with 94 additions and 20 deletions

View file

@ -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), "");
});

View file

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