Files
my-pi/pi-subagents/test/conversation-viewer.test.ts

528 lines
20 KiB
TypeScript

import { beforeEach, describe, expect, it, vi } from "vitest";
import type { AgentRecord } from "../src/types.js";
// ── Mock wrapTextWithAnsi ──────────────────────────────────────────────
// We need to control what wrapTextWithAnsi returns to simulate the
// upstream bug (returning lines wider than requested width).
// vi.mock is hoisted and intercepts before conversation-viewer.ts binds
// its import.
let wrapOverride: ((text: string, width: number) => string[]) | null = null;
vi.mock("@earendil-works/pi-tui", async (importOriginal) => {
const original = await importOriginal<typeof import("@earendil-works/pi-tui")>();
return {
...original,
wrapTextWithAnsi: (...args: [string, number]) => {
if (wrapOverride) return wrapOverride(...args);
return original.wrapTextWithAnsi(...args);
},
};
});
// Must import AFTER vi.mock declaration (vitest hoists vi.mock but the
// dynamic import of the test subject must happen after)
const { visibleWidth } = await import("@earendil-works/pi-tui");
const { ConversationViewer } = await import("../src/ui/conversation-viewer.js");
// ── Helpers ────────────────────────────────────────────────────────────
function mockTui(rows = 40, columns = 80) {
return {
terminal: { rows, columns },
requestRender: vi.fn(),
} as any;
}
function mockSession(messages: any[] = []) {
return {
messages,
subscribe: vi.fn(() => vi.fn()),
dispose: vi.fn(),
getSessionStats: () => ({ tokens: { input: 0, output: 0, cacheWrite: 0 } }),
} as any;
}
function mockRecord(overrides: Partial<AgentRecord> = {}): AgentRecord {
return {
id: "test-1",
type: "general-purpose",
description: "test agent",
status: "running",
toolUses: 0,
startedAt: Date.now(),
...overrides,
} as AgentRecord;
}
function ansiTheme() {
return {
fg: (_color: string, text: string) => `\x1b[38;5;240m${text}\x1b[0m`,
bold: (text: string) => `\x1b[1m${text}\x1b[22m`,
} as any;
}
function assertAllLinesFit(lines: string[], width: number) {
for (let i = 0; i < lines.length; i++) {
const vw = visibleWidth(lines[i]);
expect(vw, `line ${i} exceeds width (${vw} > ${width}): ${JSON.stringify(lines[i])}`).toBeLessThanOrEqual(width);
}
}
// ── Tests ──────────────────────────────────────────────────────────────
beforeEach(() => {
wrapOverride = null;
});
describe("ConversationViewer cost display", () => {
/** The header line, with a cost of `cost` on the record and showCost `on`. */
function header(on: boolean, cost: number): string {
const record = mockRecord({
lifetimeUsage: { input: 1000, output: 200, cacheWrite: 0, cost },
} as Partial<AgentRecord>);
const viewer = new ConversationViewer(
mockTui(30, 200), mockSession([]), record, undefined,
{ fg: (_c: string, t: string) => t, bold: (t: string) => t } as any,
vi.fn(), undefined, undefined, undefined, on,
);
return viewer.render(200).join("\n");
}
it("shows the cost beside the token count when enabled", () => {
// The viewer opens on finished agents, whose live activity entry is gone —
// so this reads the record, and would show nothing if it did not.
const out = header(true, 0.0042);
expect(out).toContain("1.2k token");
expect(out).toContain("~$0.0042");
});
it("shows no cost when disabled", () => {
const out = header(false, 0.0042);
expect(out).toContain("1.2k token");
expect(out).not.toContain("$");
});
it("shows no cost for a model with no pricing data", () => {
expect(header(true, 0)).not.toContain("$");
});
});
describe("ConversationViewer", () => {
describe("render width safety", () => {
const widths = [40, 80, 120, 216];
it("no line exceeds width with empty messages", () => {
for (const w of widths) {
const viewer = new ConversationViewer(
mockTui(30, w), mockSession([]), mockRecord(), undefined, ansiTheme(), vi.fn(),
);
assertAllLinesFit(viewer.render(w), w);
}
});
it("no line exceeds width with plain text messages", () => {
const messages = [
{ role: "user", content: "Hello, how are you?" },
{ role: "assistant", content: [{ type: "text", text: "I am fine, thank you for asking." }] },
];
for (const w of widths) {
const viewer = new ConversationViewer(
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
);
assertAllLinesFit(viewer.render(w), w);
}
});
it("keeps bordered rows exact-width at a double-width truncation boundary", () => {
const width = 40;
for (let prefixLength = 0; prefixLength < width; prefixLength++) {
const viewer = new ConversationViewer(
mockTui(30, width),
mockSession([]),
mockRecord({ description: `${"a".repeat(prefixLength)}界more` }),
undefined,
ansiTheme(),
vi.fn(),
);
for (const line of viewer.render(width)) {
expect(
visibleWidth(line),
`prefix ${prefixLength} produced an under-width bordered row: ${JSON.stringify(line)}`,
).toBe(width);
}
}
});
it("no line exceeds width when text is longer than viewport", () => {
const longLine = "A".repeat(500);
const messages = [
{ role: "user", content: longLine },
{ role: "assistant", content: [{ type: "text", text: longLine }] },
{ role: "toolResult", toolUseId: "t1", content: [{ type: "text", text: longLine }] },
];
for (const w of widths) {
const viewer = new ConversationViewer(
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
);
assertAllLinesFit(viewer.render(w), w);
}
});
it("no line exceeds width with embedded ANSI escape codes in content", () => {
const ansiText = `\x1b[1mBold heading\x1b[22m and \x1b[31mred text\x1b[0m ${"X".repeat(300)}`;
const messages = [
{ role: "toolResult", toolUseId: "t1", content: [{ type: "text", text: ansiText }] },
];
for (const w of widths) {
const viewer = new ConversationViewer(
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
);
assertAllLinesFit(viewer.render(w), w);
}
});
it("no line exceeds width with long URLs", () => {
const url = "https://example.com/" + "a/b/c/d/e/".repeat(30) + "?q=" + "x".repeat(100);
const messages = [
{ role: "assistant", content: [{ type: "text", text: `Check this link: ${url}` }] },
];
for (const w of widths) {
const viewer = new ConversationViewer(
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
);
assertAllLinesFit(viewer.render(w), w);
}
});
it("no line exceeds width with wide table-like content", () => {
const header = "| " + Array.from({ length: 20 }, (_, i) => `Column${i}`).join(" | ") + " |";
const dataRow = "| " + Array.from({ length: 20 }, () => "value123").join(" | ") + " |";
const table = [header, dataRow, dataRow, dataRow].join("\n");
const messages = [
{ role: "toolResult", toolUseId: "t1", content: [{ type: "text", text: table }] },
];
for (const w of widths) {
const viewer = new ConversationViewer(
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
);
assertAllLinesFit(viewer.render(w), w);
}
});
it("no line exceeds width with bashExecution messages", () => {
const messages = [
{
role: "bashExecution", command: "cat " + "/very/long/path/".repeat(20) + "file.txt",
output: "O".repeat(600),
exitCode: 0, cancelled: false, truncated: false, timestamp: Date.now(),
},
];
for (const w of widths) {
const viewer = new ConversationViewer(
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
);
assertAllLinesFit(viewer.render(w), w);
}
});
it("no line exceeds width with running activity indicator", () => {
const activity = {
activeTools: new Map([["read", "file.ts"], ["grep", "pattern"]]),
toolUses: 5, tokens: "10k", responseText: "R".repeat(400),
session: { getSessionStats: () => ({ tokens: { total: 50000 } }) },
};
const messages = [
{ role: "user", content: "do the thing" },
{ role: "assistant", content: [{ type: "text", text: "working on it" }] },
];
for (const w of widths) {
const viewer = new ConversationViewer(
mockTui(30, w), mockSession(messages), mockRecord({ status: "running" }), activity as any, ansiTheme(), vi.fn(),
);
assertAllLinesFit(viewer.render(w), w);
}
});
it("no line exceeds width with tool calls", () => {
const messages = [
{
role: "assistant",
content: [
{ type: "text", text: "Let me check that." },
{ type: "toolCall", toolUseId: "t1", name: "very_long_tool_name_" + "x".repeat(200), input: {} },
],
},
];
for (const w of widths) {
const viewer = new ConversationViewer(
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
);
assertAllLinesFit(viewer.render(w), w);
}
});
it("no line exceeds width at narrow terminal", () => {
const messages = [
{ role: "user", content: "Hello world, this is a normal sentence." },
{ role: "assistant", content: [{ type: "text", text: "Sure, here's the answer." }] },
];
for (const w of [8, 10, 15, 20]) {
const viewer = new ConversationViewer(
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
);
assertAllLinesFit(viewer.render(w), w);
}
});
it("no line exceeds width with mixed ANSI + unicode content", () => {
const text = `\x1b[32m✓\x1b[0m Test passed — 日本語テスト ${"あ".repeat(50)} \x1b[33m⚠\x1b[0m`;
const messages = [
{ role: "toolResult", toolUseId: "t1", content: [{ type: "text", text }] },
];
for (const w of widths) {
const viewer = new ConversationViewer(
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
);
assertAllLinesFit(viewer.render(w), w);
}
});
});
describe("safety net against upstream wrapTextWithAnsi bugs", () => {
// These tests call buildContentLines() directly (via the private method)
// because render() has its own truncation via row(). The safety net in
// buildContentLines is what prevents the TUI crash — it must clamp
// independently of render().
/** Call the private buildContentLines method directly. */
function callBuildContentLines(viewer: InstanceType<typeof ConversationViewer>, width: number): string[] {
return (viewer as any).buildContentLines(width);
}
it("mock is intercepting wrapTextWithAnsi", async () => {
const { wrapTextWithAnsi } = await import("@earendil-works/pi-tui");
wrapOverride = () => ["MOCK_SENTINEL"];
expect(wrapTextWithAnsi("anything", 10)).toEqual(["MOCK_SENTINEL"]);
wrapOverride = null;
});
it("clamps overwidth lines from toolResult content", () => {
const w = 80;
wrapOverride = () => ["X".repeat(w + 50)];
const messages = [
{ role: "toolResult", toolUseId: "t1", content: [{ type: "text", text: "output" }] },
];
const viewer = new ConversationViewer(
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
);
assertAllLinesFit(callBuildContentLines(viewer, w), w);
});
it("clamps overwidth lines from user message content", () => {
const w = 80;
wrapOverride = () => ["Y".repeat(w + 100)];
const messages = [{ role: "user", content: "hello" }];
const viewer = new ConversationViewer(
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
);
assertAllLinesFit(callBuildContentLines(viewer, w), w);
});
it("clamps overwidth lines from assistant message content", () => {
const w = 80;
wrapOverride = () => ["Z".repeat(w + 100)];
const messages = [
{ role: "assistant", content: [{ type: "text", text: "response" }] },
];
const viewer = new ConversationViewer(
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
);
assertAllLinesFit(callBuildContentLines(viewer, w), w);
});
it("clamps overwidth lines from bashExecution output", () => {
const w = 80;
wrapOverride = () => ["B".repeat(w + 100)];
const messages = [
{
role: "bashExecution", command: "ls", output: "out",
exitCode: 0, cancelled: false, truncated: false, timestamp: Date.now(),
},
];
const viewer = new ConversationViewer(
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
);
assertAllLinesFit(callBuildContentLines(viewer, w), w);
});
it("clamps overwidth lines that also contain ANSI codes", () => {
const w = 80;
wrapOverride = () => [`\x1b[1m\x1b[31m${"W".repeat(w + 30)}\x1b[0m`];
const messages = [
{ role: "toolResult", toolUseId: "t1", content: [{ type: "text", text: "output" }] },
];
const viewer = new ConversationViewer(
mockTui(30, w), mockSession(messages), mockRecord(), undefined, ansiTheme(), vi.fn(),
);
assertAllLinesFit(callBuildContentLines(viewer, w), w);
});
});
describe("stop key", () => {
const W = 80;
it("two-press x stops a running agent (first arms, second aborts)", () => {
const onStop = vi.fn();
const tui = mockTui(30, W);
const viewer = new ConversationViewer(
tui, mockSession(), mockRecord({ status: "running" }), undefined, ansiTheme(), vi.fn(), onStop,
);
// Idle footer offers the stop affordance.
expect(viewer.render(W).join("\n")).toContain("x stop");
// First press arms (no abort yet) and re-renders.
viewer.handleInput("x");
expect(onStop).not.toHaveBeenCalled();
expect(tui.requestRender).toHaveBeenCalled();
expect(viewer.render(W).join("\n")).toContain("x again to STOP");
// Second press aborts.
viewer.handleInput("x");
expect(onStop).toHaveBeenCalledTimes(1);
});
it("any other key disarms the confirm", () => {
const onStop = vi.fn();
const viewer = new ConversationViewer(
mockTui(30, W), mockSession(), mockRecord({ status: "running" }), undefined, ansiTheme(), vi.fn(), onStop,
);
viewer.handleInput("x"); // arm
viewer.handleInput("j"); // scroll → disarm
expect(viewer.render(W).join("\n")).toContain("x stop");
expect(viewer.render(W).join("\n")).not.toContain("x again to STOP");
viewer.handleInput("x"); // arms again, does NOT stop
expect(onStop).not.toHaveBeenCalled();
});
it("does not offer or perform stop once the agent is no longer running", () => {
const onStop = vi.fn();
const viewer = new ConversationViewer(
mockTui(30, W), mockSession(), mockRecord({ status: "completed" }), undefined, ansiTheme(), vi.fn(), onStop,
);
expect(viewer.render(W).join("\n")).not.toContain("x stop");
viewer.handleInput("x");
viewer.handleInput("x");
expect(onStop).not.toHaveBeenCalled();
});
it("no stop affordance when no onStop handler is provided (read-only history)", () => {
const viewer = new ConversationViewer(
mockTui(30, W), mockSession(), mockRecord({ status: "running" }), undefined, ansiTheme(), vi.fn(),
);
expect(viewer.render(W).join("\n")).not.toContain("x stop");
expect(() => { viewer.handleInput("x"); viewer.handleInput("x"); }).not.toThrow();
});
});
describe("steer composer", () => {
const W = 80;
function makeViewer(opts: { status?: AgentRecord["status"]; onSteer?: (m: string) => void } = {}) {
const onSteer = opts.onSteer ?? vi.fn();
const tui = mockTui(30, W);
const viewer = new ConversationViewer(
tui, mockSession(), mockRecord({ status: opts.status ?? "running" }),
undefined, ansiTheme(), vi.fn(), undefined, undefined, onSteer,
);
return { viewer, tui, onSteer };
}
it("offers the steer affordance for a running agent and opens on Enter", () => {
const { viewer } = makeViewer();
expect(viewer.render(W).join("\n")).toContain("Enter steer");
viewer.handleInput("\r"); // Enter
// Composer is shown (its prompt + send/cancel hint), idle footer is gone.
const out = viewer.render(W).join("\n");
expect(out).toContain("Enter send · Esc cancel");
expect(out).not.toContain("Enter steer");
});
it("typing then Enter sends the trimmed message and closes the composer", () => {
const { viewer, onSteer } = makeViewer();
viewer.handleInput("\r"); // open composer
for (const ch of " hello ") viewer.handleInput(ch);
viewer.handleInput("\r"); // send
expect(onSteer).toHaveBeenCalledWith("hello");
expect(viewer.render(W).join("\n")).not.toContain("Enter send"); // composer closed
});
it("Esc cancels the composer without sending", () => {
const { viewer, onSteer } = makeViewer();
viewer.handleInput("\r"); // open composer
for (const ch of "draft") viewer.handleInput(ch);
viewer.handleInput("\x1b"); // Esc
expect(onSteer).not.toHaveBeenCalled();
expect(viewer.render(W).join("\n")).not.toContain("Enter send");
});
it("an empty submit just returns (like Esc), without calling onSteer", () => {
const { viewer, onSteer } = makeViewer();
viewer.handleInput("\r"); // open composer
viewer.handleInput("\r"); // empty submit
expect(onSteer).not.toHaveBeenCalled();
expect(viewer.render(W).join("\n")).not.toContain("Enter send"); // composer closed
});
it("scroll keys are inert while composing (input owns them)", () => {
const { viewer } = makeViewer();
viewer.handleInput("\r"); // open composer
// 'j' would normally scroll, but here it types into the composer.
viewer.handleInput("j");
expect(viewer.render(W).join("\n")).toContain("Enter send · Esc cancel");
});
it("no steer affordance once the agent is no longer running", () => {
const { viewer, onSteer } = makeViewer({ status: "completed" });
expect(viewer.render(W).join("\n")).not.toContain("Enter steer");
viewer.handleInput("\r");
expect(viewer.render(W).join("\n")).not.toContain("Enter send");
expect(onSteer).not.toHaveBeenCalled();
});
it("no steer affordance when no onSteer handler is provided", () => {
const viewer = new ConversationViewer(
mockTui(30, W), mockSession(), mockRecord({ status: "running" }), undefined, ansiTheme(), vi.fn(),
);
expect(viewer.render(W).join("\n")).not.toContain("Enter steer");
expect(() => viewer.handleInput("\r")).not.toThrow();
});
it("composer rows never exceed width", () => {
for (const w of [40, 80, 120]) {
const tui = mockTui(30, w);
const viewer = new ConversationViewer(
tui, mockSession(), mockRecord({ status: "running" }),
undefined, ansiTheme(), vi.fn(), undefined, undefined, vi.fn(),
);
viewer.handleInput("\r"); // open composer
for (const ch of "x".repeat(200)) viewer.handleInput(ch);
assertAllLinesFit(viewer.render(w), w);
}
});
});
});