pi-mono/packages/coding-agent/test/program-status-reporter.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

156 lines
6.4 KiB
TypeScript

import type { AssistantMessage } from "@earendil-works/pi-ai";
import type { ProgramStatus, Terminal } from "@earendil-works/pi-tui";
import { describe, expect, it } from "vitest";
import type { AgentSessionEvent } from "../src/core/agent-session.ts";
import { ProgramStatusReporter } from "../src/modes/interactive/program-status-reporter.ts";
function setup(sessionName?: string) {
const reports: ProgramStatus[] = [];
const terminal = { setProgramStatus: (status: ProgramStatus) => reports.push(status) } as unknown as Terminal;
const session = { name: sessionName };
const reporter = new ProgramStatusReporter(
() => terminal,
() => session.name,
);
const send = (...events: AgentSessionEvent[]) => {
for (const event of events) reporter.handleEvent(event);
};
const last = () => {
const { app: _app, ...status } = reports.at(-1)!;
return Object.fromEntries(Object.entries(status).filter(([, value]) => value !== undefined));
};
return { reporter, reports, session, send, last };
}
function assistantEnd(stopReason: AssistantMessage["stopReason"], errorMessage?: string): AgentSessionEvent {
const message = {
role: "assistant",
content: [{ type: "text", text: "secret assistant output" }],
stopReason,
errorMessage,
} as AssistantMessage;
return { type: "message_end", message };
}
const settled: AgentSessionEvent = { type: "agent_settled", aborted: false };
const compactionEnd = (
reason: "manual" | "threshold" | "overflow",
options: { aborted?: boolean; errorMessage?: string } = {},
): AgentSessionEvent => ({
type: "compaction_end",
reason,
result: undefined,
aborted: options.aborted ?? false,
willRetry: false,
errorMessage: options.errorMessage,
});
// #10607
describe("ProgramStatusReporter", () => {
it("reports idle, working during a run, and done once it settles", () => {
const { reporter, reports, send, last } = setup("Fix login");
reporter.report();
expect(reports.at(-1)).toEqual({ state: "idle", app: "pi" });
send({ type: "agent_start" });
expect(last()).toEqual({ state: "working", message: "Fix login" });
send(assistantEnd("toolUse"), assistantEnd("stop"));
expect(last()).toEqual({ state: "working", message: "Fix login" });
send(settled);
expect(last()).toEqual({ state: "done", message: "Fix login" });
expect(JSON.stringify(reports)).not.toContain("secret assistant output");
});
it("reports only the outcome of the run: retried errors, final errors, and aborts", () => {
const { send, last } = setup();
send({ type: "agent_start" }, assistantEnd("error", "overloaded"), assistantEnd("stop"), settled);
expect(last()).toEqual({ state: "done" });
send({ type: "agent_start" }, assistantEnd("error", "Invalid API key\n{details}"), settled);
expect(last()).toEqual({ state: "error", message: "Invalid API key" });
send({ type: "agent_start" }, assistantEnd("aborted"), { type: "agent_settled", aborted: true });
expect(last()).toEqual({ state: "idle" });
// Aborted after a successful response, for example in an agent_before_settle hook or a retry delay.
send({ type: "agent_start" }, assistantEnd("stop"), { type: "agent_settled", aborted: true });
expect(last()).toEqual({ state: "idle" });
});
it("reports a failed recovery compaction as the run's error unless a later response succeeds", () => {
const { send, last } = setup();
send(
{ type: "agent_start" },
assistantEnd("length"),
{ type: "compaction_start", reason: "overflow" },
compactionEnd("overflow", { errorMessage: "Compaction failed\nstack" }),
settled,
);
expect(last()).toEqual({ state: "error", message: "Compaction failed" });
send(
{ type: "agent_start" },
{ type: "compaction_start", reason: "threshold" },
compactionEnd("threshold", { errorMessage: "Compaction failed" }),
assistantEnd("stop"),
settled,
);
expect(last()).toEqual({ state: "done" });
});
it("reports compaction inside a run and the result of a manual compaction", () => {
const { send, last } = setup("Session");
send({ type: "agent_start" }, { type: "compaction_start", reason: "threshold" });
expect(last()).toEqual({ state: "working", message: "Compacting context" });
send(compactionEnd("threshold"));
expect(last()).toEqual({ state: "working", message: "Session" });
send(assistantEnd("stop"), settled);
send({ type: "compaction_start", reason: "manual" }, compactionEnd("manual"));
expect(last()).toEqual({ state: "done", message: "Session" });
send({ type: "compaction_start", reason: "manual" }, compactionEnd("manual", { errorMessage: "No model" }));
expect(last()).toEqual({ state: "error", message: "No model" });
send({ type: "compaction_start", reason: "manual" }, compactionEnd("manual", { aborted: true }));
expect(last()).toEqual({ state: "idle" });
});
it("reports the most recent open dialog and the underlying state once all close", () => {
const { reporter, send, last } = setup();
send({ type: "agent_start" });
reporter.setBlocked("extension-selector", { kind: "permission", message: "Allow bash?" });
reporter.setBlocked("login", { kind: "auth", message: "Log in to Anthropic" });
expect(last()).toEqual({ state: "blocked", kind: "auth", message: "Log in to Anthropic" });
// The run settles while the selector is still open.
reporter.setBlocked("login", undefined);
send(assistantEnd("stop"), settled);
expect(last()).toEqual({ state: "blocked", kind: "permission", message: "Allow bash?" });
// Reopening a source replaces its dialog instead of stacking a second one.
reporter.setBlocked("extension-selector", { kind: "question", message: "Pick one" });
expect(last()).toEqual({ state: "blocked", kind: "question", message: "Pick one" });
reporter.setBlocked("extension-selector", undefined);
expect(last()).toEqual({ state: "done" });
});
it("sends each status once and follows session name changes", () => {
const { reporter, reports, session, send, last } = setup("Old");
send({ type: "agent_start" }, { type: "turn_start" } as AgentSessionEvent, assistantEnd("toolUse"));
reporter.report();
expect(reports).toHaveLength(1);
session.name = "New";
send({ type: "session_info_changed", name: "New" });
expect(last()).toEqual({ state: "working", message: "New" });
});
it("returns to idle when the session is replaced", () => {
const { reporter, send, last } = setup();
send({ type: "agent_start" }, assistantEnd("stop"), settled);
reporter.reset();
expect(last()).toEqual({ state: "idle" });
});
});