mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
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
156 lines
6.4 KiB
TypeScript
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" });
|
|
});
|
|
});
|