diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index b7eeb7e7..fc1b6bca 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -5,6 +5,7 @@ ### Fixed - Fixed `pi -p` treating prompts that start with YAML frontmatter as extension flags instead of user messages ([#4163](https://github.com/badlogic/pi-mono/issues/4163)). +- Fixed pending tool results not updating in the live TUI after toggling thinking block visibility while the tool is running ([#4167](https://github.com/badlogic/pi-mono/issues/4167)). ## [0.73.0] - 2026-05-04 diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 05a01148..4eecad9f 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -2633,6 +2633,7 @@ export class InteractiveMode { switch (event.type) { case "agent_start": + this.pendingTools.clear(); if (this.settingsManager.getShowTerminalProgress()) { this.ui.terminal.setProgress(true); } @@ -3100,6 +3101,7 @@ export class InteractiveMode { options: { updateFooter?: boolean; populateHistory?: boolean } = {}, ): void { this.pendingTools.clear(); + const renderedPendingTools = new Map(); if (options.updateFooter) { this.footer.invalidate(); @@ -3141,16 +3143,16 @@ export class InteractiveMode { } component.updateResult({ content: [{ type: "text", text: errorMessage }], isError: true }); } else { - this.pendingTools.set(content.id, component); + renderedPendingTools.set(content.id, component); } } } } else if (message.role === "toolResult") { // Match tool results to pending tool components - const component = this.pendingTools.get(message.toolCallId); + const component = renderedPendingTools.get(message.toolCallId); if (component) { component.updateResult(message); - this.pendingTools.delete(message.toolCallId); + renderedPendingTools.delete(message.toolCallId); } } else { // All other messages use standard rendering @@ -3158,7 +3160,9 @@ export class InteractiveMode { } } - this.pendingTools.clear(); + for (const [toolCallId, component] of renderedPendingTools) { + this.pendingTools.set(toolCallId, component); + } this.ui.requestRender(); } diff --git a/packages/coding-agent/test/suite/regressions/4167-thinking-toggle-pending-tool-render.test.ts b/packages/coding-agent/test/suite/regressions/4167-thinking-toggle-pending-tool-render.test.ts new file mode 100644 index 00000000..21a0114e --- /dev/null +++ b/packages/coding-agent/test/suite/regressions/4167-thinking-toggle-pending-tool-render.test.ts @@ -0,0 +1,164 @@ +import type { AgentMessage } from "@mariozechner/pi-agent-core"; +import type { AssistantMessage, ToolResultMessage, Usage } from "@mariozechner/pi-ai"; +import { Container, Text, type TUI } from "@mariozechner/pi-tui"; +import stripAnsi from "strip-ansi"; +import { beforeAll, describe, expect, test, vi } from "vitest"; +import type { AgentSessionEvent } from "../../../src/core/agent-session.js"; +import type { SessionContext } from "../../../src/core/session-manager.js"; +import type { ToolExecutionComponent } from "../../../src/modes/interactive/components/tool-execution.js"; +import { InteractiveMode } from "../../../src/modes/interactive/interactive-mode.js"; +import { initTheme } from "../../../src/modes/interactive/theme/theme.js"; + +const TOOL_CALL_ID = "tool-4167"; +const TOOL_NAME = "slow_tool"; + +const EMPTY_USAGE: Usage = { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + totalTokens: 0, + cost: { + input: 0, + output: 0, + cacheRead: 0, + cacheWrite: 0, + total: 0, + }, +}; + +type RenderSessionContextThis = { + pendingTools: Map; + chatContainer: Container; + footer: { invalidate(): void }; + ui: TUI; + settingsManager: { + getShowImages(): boolean; + getImageWidthCells(): number; + }; + sessionManager: { getCwd(): string }; + session: { retryAttempt: number }; + toolOutputExpanded: boolean; + isInitialized: boolean; + updateEditorBorderColor(): void; + getRegisteredToolDefinition(toolName: string): undefined; + addMessageToChat(message: AgentMessage, options?: { populateHistory?: boolean }): void; +}; + +type RenderSessionContext = ( + this: RenderSessionContextThis, + sessionContext: SessionContext, + options?: { updateFooter?: boolean; populateHistory?: boolean }, +) => void; + +type HandleEvent = (this: RenderSessionContextThis, event: AgentSessionEvent) => Promise; + +function createFakeInteractiveModeThis(): RenderSessionContextThis { + const chatContainer = new Container(); + return { + pendingTools: new Map(), + chatContainer, + footer: { invalidate: vi.fn() }, + ui: { requestRender: vi.fn() } as unknown as TUI, + settingsManager: { + getShowImages: () => false, + getImageWidthCells: () => 60, + }, + sessionManager: { getCwd: () => process.cwd() }, + session: { retryAttempt: 0 }, + toolOutputExpanded: false, + isInitialized: true, + updateEditorBorderColor: vi.fn(), + getRegisteredToolDefinition: (_toolName: string) => undefined, + addMessageToChat(message: AgentMessage) { + chatContainer.addChild(new Text(message.role, 0, 0)); + }, + }; +} + +function createAssistantToolCallMessage(): AssistantMessage { + return { + role: "assistant", + content: [ + { + type: "toolCall", + id: TOOL_CALL_ID, + name: TOOL_NAME, + arguments: { delayMs: 10_000 }, + }, + ], + api: "test-api", + provider: "test-provider", + model: "test-model", + usage: EMPTY_USAGE, + stopReason: "toolUse", + timestamp: Date.now(), + }; +} + +function createToolResultMessage(text: string): ToolResultMessage { + return { + role: "toolResult", + toolCallId: TOOL_CALL_ID, + toolName: TOOL_NAME, + content: [{ type: "text", text }], + isError: false, + timestamp: Date.now(), + }; +} + +function createSessionContext(messages: AgentMessage[]): SessionContext { + return { + messages, + thinkingLevel: "off", + model: null, + }; +} + +function renderChat(container: Container): string { + return stripAnsi(container.render(120).join("\n")); +} + +describe("InteractiveMode.renderSessionContext", () => { + beforeAll(() => { + initTheme("dark"); + }); + + test("keeps unresolved rendered tool calls registered for live completion events", async () => { + const fakeThis = createFakeInteractiveModeThis(); + const renderSessionContext = ( + InteractiveMode.prototype as unknown as { renderSessionContext: RenderSessionContext } + ).renderSessionContext; + const handleEvent = (InteractiveMode.prototype as unknown as { handleEvent: HandleEvent }).handleEvent; + + renderSessionContext.call(fakeThis, createSessionContext([createAssistantToolCallMessage()])); + + expect(fakeThis.pendingTools.has(TOOL_CALL_ID)).toBe(true); + + await handleEvent.call(fakeThis, { + type: "tool_execution_end", + toolCallId: TOOL_CALL_ID, + toolName: TOOL_NAME, + result: { content: [{ type: "text", text: "FINAL_RESULT" }], details: undefined }, + isError: false, + }); + + expect(fakeThis.pendingTools.has(TOOL_CALL_ID)).toBe(false); + expect(renderChat(fakeThis.chatContainer)).toContain("FINAL_RESULT"); + }); + + test("does not keep completed historical tool calls registered as pending", () => { + const fakeThis = createFakeInteractiveModeThis(); + const renderSessionContext = ( + InteractiveMode.prototype as unknown as { renderSessionContext: RenderSessionContext } + ).renderSessionContext; + + renderSessionContext.call( + fakeThis, + createSessionContext([createAssistantToolCallMessage(), createToolResultMessage("HISTORICAL_RESULT")]), + ); + + expect(fakeThis.pendingTools.size).toBe(0); + expect(renderChat(fakeThis.chatContainer)).toContain("HISTORICAL_RESULT"); + }); +});