Files
dev/third_party/pi/packages/coding-agent/test/program-status-reporter.test.ts
alexion 6081e3e325 chore(vendor): import pi 1.3.0
Imported as a linear vendor commit so forge rebases do not replay the upstream root tree.

git-subtree-dir: third_party/pi

git-subtree-split: 71023aec1546d6d677a65669c5665c0927ce0dd0
2026-10-15 17:58:48 -04:00

157 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" });
});
});