diff --git a/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.test.ts b/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.test.ts new file mode 100644 index 000000000..d667af20d --- /dev/null +++ b/packages/pi-coding-agent/src/modes/interactive/controllers/chat-controller.test.ts @@ -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), ""); +}); 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 2c79fbd58..1fe373f20 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 @@ -22,6 +22,28 @@ function hasAssistantToolBlocks(message: { content: Array }): 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): 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,15 +308,7 @@ export async function handleAgentEvent(host: InteractiveModeStateHost & { if (hasTools) hasToolsInTurn = true; if (hasToolsInTurn) { - // Collect the latest text block(s) from the assistant message - let latestText = ""; - for (let i = contentBlocks.length - 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;