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
157 lines
6.4 KiB
TypeScript
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" });
|
|
});
|
|
});
|