pi-mono/packages/coding-agent/test/interactive-mode-compaction.test.ts
Mario Zechner 503c605528 feat(tui,coding-agent): report program status via OSC 7501
Report idle, working, blocked, done, and error to terminals that support
the Program Status Protocol. ProcessTerminal sends the support query with
the keyboard query and reports only after the terminal answers before DA1;
PI_PROGRAM_STATUS=1|0 overrides detection. Interactive mode reports runs,
compaction, extension dialogs, and logins. agent_settled now carries
aborted so cancelled runs report idle.

closes #10607
2026-10-07 19:05:41 +00:00

276 lines
9.1 KiB
TypeScript

import type { Usage } from "@earendil-works/pi-ai";
import { Container } from "@earendil-works/pi-tui";
import { describe, expect, test, vi } from "vitest";
import type { SessionEntry } from "../src/core/session-manager.ts";
import { InteractiveMode } from "../src/modes/interactive/interactive-mode.ts";
import { initTheme } from "../src/modes/interactive/theme/theme.ts";
import { stripAnsi } from "../src/utils/ansi.ts";
describe("InteractiveMode compaction events", () => {
test("uses the cache miss notice setting for compaction and branch summary costs", () => {
const usage: Usage = {
input: 10,
output: 20,
cacheRead: 30,
cacheWrite: 40,
totalTokens: 100,
cost: { input: 0.01, output: 0.02, cacheRead: 0.03, cacheWrite: 0.065, total: 0.125 },
};
const addCompactionCostNotice = Reflect.get(InteractiveMode.prototype, "addCompactionCostNotice") as (
this: { chatContainer: Container; settingsManager: { getShowCacheMissNotices(): boolean } },
notice: {
type: "compaction_cost";
kind: "compaction" | "branch_summary";
usage: Usage;
},
) => void;
initTheme("dark");
const enabled = {
chatContainer: new Container(),
settingsManager: { getShowCacheMissNotices: () => true },
};
addCompactionCostNotice.call(enabled, { type: "compaction_cost", kind: "compaction", usage });
addCompactionCostNotice.call(enabled, {
type: "compaction_cost",
kind: "branch_summary",
usage,
});
const output = stripAnsi(enabled.chatContainer.render(120).join("\n"));
expect(output).toContain("Compaction: 100 tokens billed (~$0.13)");
expect(output).toContain("Branch summary: 100 tokens billed (~$0.13)");
const disabled = {
chatContainer: new Container(),
settingsManager: { getShowCacheMissNotices: () => false },
};
addCompactionCostNotice.call(disabled, { type: "compaction_cost", kind: "compaction", usage });
expect(disabled.chatContainer.children).toHaveLength(0);
});
test("renders each compaction cost after its summary", () => {
const currentUsage: Usage = {
input: 10,
output: 20,
cacheRead: 30,
cacheWrite: 40,
totalTokens: 100,
cost: { input: 0.01, output: 0.02, cacheRead: 0.03, cacheWrite: 0.04, total: 0.1 },
};
const previousUsage: Usage = {
input: 1,
output: 2,
cacheRead: 3,
cacheWrite: 4,
totalTokens: 10,
cost: { input: 0.001, output: 0.002, cacheRead: 0.003, cacheWrite: 0.004, total: 0.01 },
};
const entries: SessionEntry[] = [
{
type: "compaction",
id: "current",
parentId: "previous",
timestamp: "2025-01-02T00:00:00Z",
summary: "current summary",
firstKeptEntryId: "kept",
tokensBefore: 200,
usage: currentUsage,
},
{
type: "compaction",
id: "previous",
parentId: null,
timestamp: "2025-01-01T00:00:00Z",
summary: "previous summary",
firstKeptEntryId: "kept",
tokensBefore: 100,
usage: previousUsage,
},
];
const fakeThis = { renderSessionItems: vi.fn() };
const renderSessionEntries = Reflect.get(InteractiveMode.prototype, "renderSessionEntries") as (
this: typeof fakeThis,
entries: SessionEntry[],
) => void;
renderSessionEntries.call(fakeThis, entries);
expect(fakeThis.renderSessionItems).toHaveBeenCalledWith(
[
expect.objectContaining({ role: "compactionSummary", summary: "current summary" }),
{ type: "compaction_cost", kind: "compaction", usage: currentUsage },
expect.objectContaining({ role: "compactionSummary", summary: "previous summary" }),
{ type: "compaction_cost", kind: "compaction", usage: previousUsage },
],
{},
);
});
test("renders retained entries and appends the latest summary cost at the bottom", async () => {
const usage: Usage = {
input: 10,
output: 20,
cacheRead: 30,
cacheWrite: 40,
totalTokens: 100,
cost: { input: 0.01, output: 0.02, cacheRead: 0.03, cacheWrite: 0.065, total: 0.125 },
};
const latestCompaction: SessionEntry = {
type: "compaction",
id: "latest",
parentId: "previous",
timestamp: "2025-01-02T00:00:00Z",
summary: "summary",
firstKeptEntryId: "kept",
tokensBefore: 123,
usage,
};
const previousCompaction: SessionEntry = {
type: "compaction",
id: "previous",
parentId: null,
timestamp: "2025-01-01T00:00:00Z",
summary: "previous summary",
firstKeptEntryId: "kept",
tokensBefore: 100,
usage,
};
const fakeThis = {
isInitialized: true,
footer: { invalidate: vi.fn() },
programStatus: { handleEvent: vi.fn() },
autoCompactionEscapeHandler: undefined as (() => void) | undefined,
autoCompactionLoader: undefined,
defaultEditor: {},
statusContainer: { clear: vi.fn() },
chatContainer: { clear: vi.fn() },
sessionManager: { buildContextEntries: vi.fn().mockReturnValue([latestCompaction, previousCompaction]) },
renderSessionEntries: vi.fn(),
addMessageToChat: vi.fn(),
addCompactionCostNotice: vi.fn(),
showError: vi.fn(),
showStatus: vi.fn(),
clearStatusIndicator: vi.fn(),
flushCompactionQueue: vi.fn().mockResolvedValue(undefined),
settingsManager: { getShowTerminalProgress: () => false },
ui: { requestRender: vi.fn(), terminal: { setProgress: vi.fn() } },
};
const handleEvent = Reflect.get(InteractiveMode.prototype, "handleEvent") as (
this: typeof fakeThis,
event: {
type: "compaction_end";
reason: "manual" | "threshold" | "overflow";
result: { tokensBefore: number; summary: string; usage?: Usage } | undefined;
aborted: boolean;
willRetry: boolean;
errorMessage?: string;
},
) => Promise<void>;
await handleEvent.call(fakeThis, {
type: "compaction_end",
reason: "manual",
result: {
tokensBefore: 123,
summary: "summary",
usage,
},
aborted: false,
willRetry: false,
});
expect(fakeThis.chatContainer.clear).toHaveBeenCalledTimes(1);
expect(fakeThis.renderSessionEntries).toHaveBeenCalledWith([previousCompaction]);
expect(fakeThis.addMessageToChat).toHaveBeenCalledTimes(1);
expect(fakeThis.addMessageToChat).toHaveBeenCalledWith(
expect.objectContaining({
role: "compactionSummary",
tokensBefore: 123,
summary: "summary",
}),
);
expect(fakeThis.addCompactionCostNotice).toHaveBeenCalledWith({
type: "compaction_cost",
kind: "compaction",
usage,
});
expect(fakeThis.flushCompactionQueue).toHaveBeenCalledWith({ willRetry: false });
});
test("updates the working state when the same agent run resumes after compaction", async () => {
const fakeThis = {
isInitialized: true,
footer: { invalidate: vi.fn() },
programStatus: { handleEvent: vi.fn() },
activeStatusIndicator: undefined,
workingVisible: true,
showWorkingStatusIndicator: vi.fn(),
clearStatusIndicator: vi.fn(),
settingsManager: { getShowTerminalProgress: () => true },
ui: { requestRender: vi.fn(), terminal: { setProgress: vi.fn() } },
};
const handleEvent = Reflect.get(InteractiveMode.prototype, "handleEvent") as (
this: typeof fakeThis,
event: { type: "turn_start" },
) => Promise<void>;
await handleEvent.call(fakeThis, { type: "turn_start" });
expect(fakeThis.ui.terminal.setProgress).toHaveBeenCalledWith(true);
expect(fakeThis.showWorkingStatusIndicator).toHaveBeenCalledTimes(1);
expect(fakeThis.clearStatusIndicator).not.toHaveBeenCalled();
expect(fakeThis.ui.requestRender).toHaveBeenCalledTimes(1);
fakeThis.workingVisible = false;
await handleEvent.call(fakeThis, { type: "turn_start" });
expect(fakeThis.showWorkingStatusIndicator).toHaveBeenCalledTimes(1);
expect(fakeThis.clearStatusIndicator).toHaveBeenCalledTimes(1);
expect(fakeThis.ui.requestRender).toHaveBeenCalledTimes(2);
});
// Regression test for #9340.
test("routes interactive response aborts through AgentSession", () => {
const abort = vi.fn(async () => {});
const ui = {
clearAllQueues: () => ({ steering: [], followUp: [] }),
updatePendingMessagesDisplay: vi.fn(),
session: { abort },
};
const restoreQueuedMessagesToEditor = Reflect.get(InteractiveMode.prototype, "restoreQueuedMessagesToEditor") as (
this: typeof ui,
options?: { abort?: boolean },
) => number;
restoreQueuedMessagesToEditor.call(ui, { abort: true });
expect(abort).toHaveBeenCalledOnce();
});
test("preserves steering behavior when flushing into an active agent run", async () => {
const fakeThis = {
compactionQueuedMessages: [{ text: "change direction", mode: "steer" as const }],
session: {
clearQueue: vi.fn(),
prompt: vi.fn().mockResolvedValue(undefined),
steer: vi.fn().mockResolvedValue(undefined),
followUp: vi.fn().mockResolvedValue(undefined),
},
isExtensionCommand: vi.fn().mockReturnValue(false),
updatePendingMessagesDisplay: vi.fn(),
showError: vi.fn(),
};
const flushCompactionQueue = Reflect.get(InteractiveMode.prototype, "flushCompactionQueue") as (
this: typeof fakeThis,
options?: { willRetry?: boolean },
) => Promise<void>;
await flushCompactionQueue.call(fakeThis, { willRetry: false });
expect(fakeThis.session.prompt).toHaveBeenCalledWith("change direction", { streamingBehavior: "steer" });
expect(fakeThis.compactionQueuedMessages).toEqual([]);
expect(fakeThis.showError).not.toHaveBeenCalled();
});
});