Files
oh-my-pi/packages/coding-agent/test/modes/controllers/btw-controller.test.ts
T
2026-05-12 04:09:33 +02:00

162 lines
5.5 KiB
TypeScript

import { beforeAll, describe, expect, it, vi } from "bun:test";
import type { AssistantMessage, Usage } from "@oh-my-pi/pi-ai";
import { BtwController } from "@oh-my-pi/pi-coding-agent/modes/controllers/btw-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 { Container, type TUI } from "@oh-my-pi/pi-tui";
const usage: Usage = {
input: 0,
output: 0,
cacheRead: 0,
cacheWrite: 0,
totalTokens: 0,
cost: { input: 0, output: 0, cacheRead: 0, cacheWrite: 0, total: 0 },
};
function createAssistantMessage(text: string): AssistantMessage {
return {
role: "assistant",
content: [{ type: "text", text }],
api: "anthropic-messages",
provider: "anthropic",
model: "claude-sonnet-4-5",
usage,
stopReason: "stop",
timestamp: Date.now(),
};
}
interface RunEphemeralTurnArgs {
promptText: string;
onTextDelta?: (delta: string) => void;
signal?: AbortSignal;
}
interface RunEphemeralTurnResult {
replyText: string;
assistantMessage: AssistantMessage;
}
function makeFakeSession(
runEphemeralTurn: (args: RunEphemeralTurnArgs) => Promise<RunEphemeralTurnResult>,
): InteractiveModeContext["session"] {
return {
model: { provider: "anthropic", id: "claude-sonnet-4-5" },
runEphemeralTurn,
} as unknown as InteractiveModeContext["session"];
}
function makeCtx(session: InteractiveModeContext["session"], btwContainer = new Container()): InteractiveModeContext {
return {
ui: { requestRender: vi.fn() } as unknown as TUI,
btwContainer,
session,
showStatus: vi.fn(),
showError: vi.fn(),
} as unknown as InteractiveModeContext;
}
beforeAll(async () => {
await initTheme();
});
describe("BtwController", () => {
it("dispatches the question to runEphemeralTurn with the btw prompt wrapper and a fresh signal", async () => {
const runEphemeralTurn = vi.fn(async (_args: RunEphemeralTurnArgs) => ({
replyText: "Answer",
assistantMessage: createAssistantMessage("Answer"),
}));
const ctx = makeCtx(makeFakeSession(runEphemeralTurn));
const controller = new BtwController(ctx);
await controller.start("What changed?");
// Drain microtasks so the inner promise can resolve.
await Promise.resolve();
await Promise.resolve();
expect(runEphemeralTurn).toHaveBeenCalledTimes(1);
const callArg = runEphemeralTurn.mock.calls[0]?.[0];
expect(callArg).toBeDefined();
expect(callArg?.promptText).toContain("<btw>");
expect(callArg?.promptText).toContain("What changed?");
expect(callArg?.signal).toBeInstanceOf(AbortSignal);
expect(typeof callArg?.onTextDelta).toBe("function");
expect(controller.hasActiveRequest()).toBe(true);
});
it("replaces a previous request by aborting it before issuing the next runEphemeralTurn", async () => {
const signals: AbortSignal[] = [];
let firstRelease!: () => void;
const firstPromise = new Promise<RunEphemeralTurnResult>(resolve => {
firstRelease = () => resolve({ replyText: "first", assistantMessage: createAssistantMessage("first") });
});
const runEphemeralTurn = vi
.fn<(args: RunEphemeralTurnArgs) => Promise<RunEphemeralTurnResult>>()
.mockImplementationOnce(async args => {
signals.push(args.signal as AbortSignal);
return firstPromise;
})
.mockImplementationOnce(async args => {
signals.push(args.signal as AbortSignal);
return { replyText: "second", assistantMessage: createAssistantMessage("second") };
});
const btwContainer = new Container();
const ctx = makeCtx(makeFakeSession(runEphemeralTurn), btwContainer);
const controller = new BtwController(ctx);
await controller.start("First?");
await controller.start("Second?");
// Allow the second call to settle.
await Promise.resolve();
await Promise.resolve();
expect(runEphemeralTurn).toHaveBeenCalledTimes(2);
expect(signals[0]?.aborted).toBe(true);
expect(signals[1]?.aborted).toBe(false);
expect(btwContainer.children).toHaveLength(1);
// Allow the orphaned first request to finish to keep the test clean.
firstRelease();
});
it("clears the panel when the active request is dismissed via Escape", async () => {
const runEphemeralTurn = vi.fn(async () => new Promise<RunEphemeralTurnResult>(() => {}));
const btwContainer = new Container();
const ctx = makeCtx(makeFakeSession(runEphemeralTurn), btwContainer);
const controller = new BtwController(ctx);
await controller.start("Question?");
expect(btwContainer.children).toHaveLength(1);
expect(controller.handleEscape()).toBe(true);
expect(btwContainer.children).toHaveLength(0);
expect(controller.hasActiveRequest()).toBe(false);
});
it("rejects empty questions before issuing the side-channel call", async () => {
const runEphemeralTurn = vi.fn(async () => ({
replyText: "n/a",
assistantMessage: createAssistantMessage("n/a"),
}));
const ctx = makeCtx(makeFakeSession(runEphemeralTurn));
const controller = new BtwController(ctx);
await controller.start(" ");
expect(runEphemeralTurn).not.toHaveBeenCalled();
expect(controller.hasActiveRequest()).toBe(false);
});
it("shows an error message when no model is configured", async () => {
const runEphemeralTurn = vi.fn(async () => ({
replyText: "n/a",
assistantMessage: createAssistantMessage("n/a"),
}));
const session = { model: undefined, runEphemeralTurn } as unknown as InteractiveModeContext["session"];
const ctx = makeCtx(session);
const controller = new BtwController(ctx);
await controller.start("Anything?");
expect(runEphemeralTurn).not.toHaveBeenCalled();
expect(ctx.showError).toHaveBeenCalled();
});
});