Files
oh-my-pi/packages/coding-agent/test/event-controller-mixed-assistant-render.test.ts
T
can1357 58a2acd330 test(coding-agent): align ctx fixtures with settled-component cache and todo commit-on-execute
- Added transcriptMessageComponents to every InteractiveMode ctx test literal; #6033's reuse cache made the field required and addMessageToChat populates it unconditionally.
- Wired setTodoPhases into the eager-todo ToolSession fixture to mirror sdk.ts; the test relied on the stale message_end todo replay #6148 removed.
2026-07-22 21:32:12 +02:00

215 lines
7.2 KiB
TypeScript

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,
transcriptMessageComponents: new WeakMap(),
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<AgentSessionEvent, { type: "message_update" }>);
await controller.handleEvent({
type: "message_update",
message: withSecondToolCall,
assistantMessageEvent: {
type: "toolcall_end",
contentIndex: 3,
toolCall: toolCallB,
partial: withSecondToolCall,
},
} as Extract<AgentSessionEvent, { type: "message_update" }>);
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<AgentSessionEvent, { type: "tool_execution_start" }>);
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<AgentSessionEvent, { type: "tool_execution_end" }>);
await controller.handleEvent({
type: "tool_execution_start",
toolCallId: TOOL_CALL_B_ID,
toolName: "contract_probe_b",
args: { value: "b" },
} as Extract<AgentSessionEvent, { type: "tool_execution_start" }>);
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<AgentSessionEvent, { type: "tool_execution_end" }>);
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);
});
});