import { afterEach, beforeAll, beforeEach, describe, expect, it, vi } from "bun:test"; import type { AssistantMessage, ToolCall, Usage } from "@oh-my-pi/pi-ai"; import { resetSettingsForTest, Settings, settings } from "@oh-my-pi/pi-coding-agent/config/settings"; import { TranscriptContainer } from "@oh-my-pi/pi-coding-agent/modes/components/transcript-container"; import { EventController } from "@oh-my-pi/pi-coding-agent/modes/controllers/event-controller"; import { initTheme } from "@oh-my-pi/pi-coding-agent/modes/theme/theme"; import type { InteractiveModeContext } from "@oh-my-pi/pi-coding-agent/modes/types"; import type { AgentSessionEvent } from "@oh-my-pi/pi-coding-agent/session/agent-session"; import type { TUI } from "@oh-my-pi/pi-tui"; const TOOL_CALL_A_ID = "toolu_mixed_text_order_a"; const TOOL_CALL_B_ID = "toolu_mixed_text_order_b"; const INTRO_MARKER = "INTRO TEXT BEFORE FIRST TOOL"; const TOOL_RESULT_A_MARKER = "TOOL RESULT FROM FIRST TOOL"; const MIDDLE_MARKER = "MIDDLE TEXT BETWEEN TOOL CALLS"; const TOOL_RESULT_B_MARKER = "TOOL RESULT FROM SECOND TOOL"; const FINAL_MARKER = "FINAL ANSWER AFTER SECOND TOOL"; function zeroUsage(): Usage { return { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, totalTokens: 0, cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 }, }; } function assistantMessage(content: AssistantMessage["content"]): AssistantMessage { return { role: "assistant", content, api: "cursor", provider: "cursor", model: "cursor-model", stopReason: "stop", usage: zeroUsage(), timestamp: 1, }; } function lineContaining(lines: string[], marker: string): number { const index = lines.findIndex(line => line.includes(marker)); if (index === -1) { throw new Error(`Rendered transcript did not contain ${marker}:\n${lines.join("\n")}`); } return index; } function createFixture() { const chatContainer = new TranscriptContainer(); const pendingTools = new Map(); const ui = { requestRender: vi.fn(), requestComponentRender: vi.fn(), imageBudget: undefined, } as unknown as TUI; const viewSession = { getToolByName: () => undefined, extensionRunner: undefined, isTtsrAbortPending: false, retryAttempt: 0, }; let hasDisplayableThinkingContent = false; const ctx = { isInitialized: true, init: vi.fn(async () => {}), ui, settings, chatContainer, pendingTools, toolOutputExpanded: false, effectiveHideThinkingBlock: false, proseOnlyThinking: true, statusLine: { invalidate: vi.fn() }, updateEditorTopBorder: vi.fn(), noteDisplayableThinkingContent: vi.fn((message: AssistantMessage) => { const hasThinking = message.content.some( content => content.type === "thinking" && content.thinking.trim() !== "", ); if (!hasThinking || hasDisplayableThinkingContent) return false; hasDisplayableThinkingContent = true; return true; }), session: viewSession, viewSession, sessionManager: { getCwd: () => process.cwd() }, showWarning: vi.fn(), showPinnedError: vi.fn(), clearTransientSessionUi: vi.fn(), lastAssistantUsage: zeroUsage(), } as unknown as InteractiveModeContext; return { controller: new EventController(ctx), chatContainer }; } describe("EventController mixed assistant text/tool rendering", () => { beforeAll(async () => { await initTheme(false); }); beforeEach(async () => { resetSettingsForTest(); await Settings.init({ inMemory: true, overrides: { "display.smoothStreaming": false } }); }); afterEach(() => { vi.restoreAllMocks(); resetSettingsForTest(); }); it("renders assistant text segments in order around two tool results from one mixed message", async () => { const { controller, chatContainer } = createFixture(); const toolCallA: ToolCall = { type: "toolCall", id: TOOL_CALL_A_ID, name: "contract_probe_a", arguments: { value: "a" }, }; const toolCallB: ToolCall = { type: "toolCall", id: TOOL_CALL_B_ID, name: "contract_probe_b", arguments: { value: "b" }, }; const started = assistantMessage([]); const withFirstToolCall = assistantMessage([{ type: "text", text: INTRO_MARKER }, toolCallA]); const withSecondToolCall = assistantMessage([ { type: "text", text: INTRO_MARKER }, toolCallA, { type: "text", text: MIDDLE_MARKER }, toolCallB, ]); const completed = assistantMessage([ { type: "text", text: INTRO_MARKER }, toolCallA, { type: "text", text: MIDDLE_MARKER }, toolCallB, { type: "text", text: FINAL_MARKER }, ]); await controller.handleEvent({ type: "message_start", message: started } as Extract< AgentSessionEvent, { type: "message_start" } >); await controller.handleEvent({ type: "message_update", message: withFirstToolCall, assistantMessageEvent: { type: "toolcall_end", contentIndex: 1, toolCall: toolCallA, partial: withFirstToolCall, }, } as Extract); await controller.handleEvent({ type: "message_update", message: withSecondToolCall, assistantMessageEvent: { type: "toolcall_end", contentIndex: 3, toolCall: toolCallB, partial: withSecondToolCall, }, } as Extract); const liveLines = chatContainer.render(120).map(line => Bun.stripANSI(line)); expect(lineContaining(liveLines, INTRO_MARKER)).toBeLessThan(lineContaining(liveLines, MIDDLE_MARKER)); await controller.handleEvent({ type: "tool_execution_start", toolCallId: TOOL_CALL_A_ID, toolName: "contract_probe_a", args: { value: "a" }, } as Extract); await controller.handleEvent({ type: "tool_execution_end", toolCallId: TOOL_CALL_A_ID, toolName: "contract_probe_a", result: { content: [{ type: "text", text: TOOL_RESULT_A_MARKER }] }, isError: false, } as Extract); await controller.handleEvent({ type: "tool_execution_start", toolCallId: TOOL_CALL_B_ID, toolName: "contract_probe_b", args: { value: "b" }, } as Extract); await controller.handleEvent({ type: "tool_execution_end", toolCallId: TOOL_CALL_B_ID, toolName: "contract_probe_b", result: { content: [{ type: "text", text: TOOL_RESULT_B_MARKER }] }, isError: false, } as Extract); await controller.handleEvent({ type: "message_end", message: completed } as Extract< AgentSessionEvent, { type: "message_end" } >); const lines = chatContainer.render(120).map(line => Bun.stripANSI(line)); const introLine = lineContaining(lines, INTRO_MARKER); const toolResultALine = lineContaining(lines, TOOL_RESULT_A_MARKER); const middleLine = lineContaining(lines, MIDDLE_MARKER); const toolResultBLine = lineContaining(lines, TOOL_RESULT_B_MARKER); const finalLine = lineContaining(lines, FINAL_MARKER); expect(introLine).toBeLessThan(toolResultALine); expect(toolResultALine).toBeLessThan(middleLine); expect(lines.filter(line => line.includes(MIDDLE_MARKER))).toHaveLength(1); expect(middleLine).toBeLessThan(toolResultBLine); expect(toolResultBLine).toBeLessThan(finalLine); }); });