436 lines
16 KiB
TypeScript
436 lines
16 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import test from "node:test";
|
|
import { milestoneNotification } from "./status.ts";
|
|
import { Supervisor } from "./supervisor.ts";
|
|
import type { ChildHandle, ChildRunner, RunnerEvents, SpawnRequest } from "./types.ts";
|
|
import { widget } from "./ui.ts";
|
|
|
|
class FakeHandle implements ChildHandle {
|
|
cancelCalls = 0;
|
|
|
|
async cancel(): Promise<void> {
|
|
this.cancelCalls += 1;
|
|
}
|
|
}
|
|
|
|
class FakeRunner implements ChildRunner {
|
|
starts: Array<{ id: string; request: SpawnRequest; events: RunnerEvents; handle: FakeHandle }> = [];
|
|
autoAccept = true;
|
|
|
|
async start(id: string, request: SpawnRequest, _cwd: string, events: RunnerEvents): Promise<ChildHandle> {
|
|
const handle = new FakeHandle();
|
|
this.starts.push({ id, request, events, handle });
|
|
if (this.autoAccept) events.accepted(`session-${id}`);
|
|
return handle;
|
|
}
|
|
}
|
|
|
|
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
|
|
async function spawnStarted(supervisor: Supervisor, prompt = "work") {
|
|
const accepted = supervisor.spawn({ prompt });
|
|
await sleep(0);
|
|
return accepted;
|
|
}
|
|
|
|
test("cancel is idempotent and reaches cancelled", async () => {
|
|
const runner = new FakeRunner();
|
|
const supervisor = new Supervisor(runner, "/tmp");
|
|
const accepted = await spawnStarted(supervisor);
|
|
|
|
const first = await supervisor.cancel(accepted.id);
|
|
const second = await supervisor.cancel(accepted.id);
|
|
|
|
assert.equal(first.state, "cancelled");
|
|
assert.equal(second.state, "cancelled");
|
|
assert.equal(runner.starts[0].handle.cancelCalls, 1);
|
|
});
|
|
|
|
test("startup timeout reaches timed_out", async () => {
|
|
const runner = new FakeRunner();
|
|
runner.autoAccept = false;
|
|
const supervisor = new Supervisor(runner, "/tmp", { timeouts: { startMs: 5 } });
|
|
const accepted = await spawnStarted(supervisor);
|
|
|
|
await sleep(20);
|
|
|
|
const status = supervisor.status(accepted.id);
|
|
assert.equal(status.state, "timed_out");
|
|
assert.equal(status.stopReason, "start_timeout");
|
|
assert.equal(runner.starts[0].handle.cancelCalls, 1);
|
|
});
|
|
|
|
test("runtime timeout reaches timed_out", async () => {
|
|
const runner = new FakeRunner();
|
|
const supervisor = new Supervisor(runner, "/tmp", { timeouts: { runMs: 5 } });
|
|
const accepted = await spawnStarted(supervisor);
|
|
|
|
await sleep(20);
|
|
|
|
const status = supervisor.status(accepted.id);
|
|
assert.equal(status.state, "timed_out");
|
|
assert.equal(status.stopReason, "run_timeout");
|
|
assert.equal(runner.starts[0].handle.cancelCalls, 1);
|
|
});
|
|
|
|
test("activity exposes ordered transcript events while status and list keep only summaries", async () => {
|
|
const runner = new FakeRunner();
|
|
const supervisor = new Supervisor(runner, "/tmp");
|
|
const accepted = await spawnStarted(supervisor);
|
|
|
|
runner.starts[0].events.running({ type: "message_started", role: "assistant" });
|
|
runner.starts[0].events.running({
|
|
type: "message_delta",
|
|
role: "assistant",
|
|
assistantMessageEvent: { type: "content_delta", delta: "private transcript body" },
|
|
});
|
|
runner.starts[0].events.running({ type: "tool_started", tool: "read", input: { path: "secret-notes.md" } });
|
|
runner.starts[0].events.running({ type: "tool_completed", tool: "read", output: "secret file contents" });
|
|
|
|
type ActivityStatus = ReturnType<Supervisor["status"]> & {
|
|
activityHistory: Array<{ type: string; summary: string }>;
|
|
currentActivity: { summary: string };
|
|
};
|
|
const activity = supervisor.activity(accepted.id);
|
|
const status = supervisor.status(accepted.id) as ActivityStatus;
|
|
const listed = supervisor.list().find((item) => item.id === accepted.id) as ActivityStatus | undefined;
|
|
|
|
assert.deepEqual(
|
|
activity.map((event) => event.type),
|
|
["queued", "starting", "prompt accepted", "message_started", "message_delta", "tool_started", "tool_completed"],
|
|
);
|
|
assert.deepEqual(activity[4], {
|
|
type: "message_delta",
|
|
summary: "assistant message content_delta",
|
|
at: activity[4].at,
|
|
role: "assistant",
|
|
tool: undefined,
|
|
phase: "content_delta",
|
|
text: "private transcript body",
|
|
input: undefined,
|
|
output: undefined,
|
|
error: undefined,
|
|
payload: {
|
|
type: "message_delta",
|
|
role: "assistant",
|
|
assistantMessageEvent: { type: "content_delta", delta: "private transcript body" },
|
|
},
|
|
});
|
|
assert.deepEqual(activity[5], {
|
|
type: "tool_started",
|
|
summary: "read secret-notes.md",
|
|
at: activity[5].at,
|
|
role: undefined,
|
|
tool: "read",
|
|
phase: "started",
|
|
text: undefined,
|
|
input: { path: "secret-notes.md" },
|
|
output: undefined,
|
|
error: undefined,
|
|
payload: { type: "tool_started", tool: "read", input: { path: "secret-notes.md" } },
|
|
});
|
|
assert.equal(activity[6].output, "secret file contents");
|
|
|
|
assert.ok(Array.isArray(status.activityHistory), "status should expose structured activityHistory");
|
|
assert.deepEqual(status.activityHistory.map((event) => event.type), activity.map((event) => event.type));
|
|
assert.deepEqual(status.activityHistory.map((event) => event.summary), activity.map((event) => event.summary));
|
|
assert.equal(status.currentActivity.summary, "read");
|
|
assert.equal(listed?.currentActivity.summary, "read");
|
|
assert.doesNotMatch(JSON.stringify(status), /private transcript body|secret file contents/u);
|
|
assert.doesNotMatch(JSON.stringify(listed), /private transcript body|secret file contents/u);
|
|
});
|
|
|
|
test("process failure reaches failed with diagnostics", async () => {
|
|
const runner = new FakeRunner();
|
|
const supervisor = new Supervisor(runner, "/tmp");
|
|
const accepted = await spawnStarted(supervisor);
|
|
|
|
runner.starts[0].events.failed("process closed with code 1");
|
|
|
|
const status = supervisor.status(accepted.id);
|
|
assert.equal(status.state, "failed");
|
|
assert.equal(status.error, "process closed with code 1");
|
|
});
|
|
|
|
test("shutdown cancels running children", async () => {
|
|
const runner = new FakeRunner();
|
|
const supervisor = new Supervisor(runner, "/tmp");
|
|
const accepted = await spawnStarted(supervisor);
|
|
|
|
await supervisor.shutdown();
|
|
|
|
const status = supervisor.status(accepted.id);
|
|
assert.equal(status.state, "cancelled");
|
|
assert.equal(status.stopReason, "shutdown");
|
|
assert.equal(runner.starts[0].handle.cancelCalls, 1);
|
|
});
|
|
|
|
test("completed children ignore later cancel", async () => {
|
|
const runner = new FakeRunner();
|
|
const supervisor = new Supervisor(runner, "/tmp");
|
|
const accepted = await spawnStarted(supervisor);
|
|
|
|
runner.starts[0].events.completed("done", "agent_settled");
|
|
await supervisor.cancel(accepted.id);
|
|
|
|
const result = supervisor.result(accepted.id);
|
|
assert.equal(result.state, "completed");
|
|
assert.equal(result.result, "done");
|
|
assert.equal(runner.starts[0].handle.cancelCalls, 0);
|
|
});
|
|
|
|
test("explicit labels are reused across accepted status list and result surfaces", async () => {
|
|
const runner = new FakeRunner();
|
|
const supervisor = new Supervisor(runner, "/tmp");
|
|
const label = "Review risky migration";
|
|
|
|
const accepted = supervisor.spawn({ prompt: "inspect the migration plan", label } as SpawnRequest & { label: string });
|
|
await sleep(0);
|
|
runner.starts[0].events.completed("done", "agent_settled");
|
|
|
|
assert.deepEqual(
|
|
{
|
|
accepted: accepted.label,
|
|
status: supervisor.status(accepted.id).label,
|
|
list: supervisor.list().find((status) => status.id === accepted.id)?.label,
|
|
result: (supervisor.result(accepted.id) as { label?: string }).label,
|
|
},
|
|
{
|
|
accepted: label,
|
|
status: label,
|
|
list: label,
|
|
result: label,
|
|
},
|
|
);
|
|
});
|
|
|
|
test("ad hoc fallback labels are prompt-derived and reused by widget and result surfaces", async () => {
|
|
const runner = new FakeRunner();
|
|
const supervisor = new Supervisor(runner, "/tmp");
|
|
const prompt = " Audit\n\tguest enablement plan ";
|
|
const label = "Audit guest enablement plan";
|
|
|
|
const accepted = supervisor.spawn({ prompt });
|
|
await sleep(0);
|
|
runner.starts[0].events.completed("done", "agent_settled");
|
|
const statuses = supervisor.list();
|
|
const inspectorLines = widget(statuses, true)().render(240);
|
|
|
|
assert.deepEqual(
|
|
{
|
|
accepted: accepted.label,
|
|
childRequest: runner.starts[0].request.label,
|
|
status: supervisor.status(accepted.id).label,
|
|
list: statuses.find((status) => status.id === accepted.id)?.label,
|
|
result: supervisor.result(accepted.id).label,
|
|
},
|
|
{
|
|
accepted: label,
|
|
childRequest: label,
|
|
status: label,
|
|
list: label,
|
|
result: label,
|
|
},
|
|
);
|
|
assert.ok(inspectorLines.some((line) => line.includes(`completed 0s ${label} result: available`)), inspectorLines.join("\n"));
|
|
assert.doesNotMatch(accepted.label, /^ad-hoc sg-/u);
|
|
});
|
|
|
|
test("milestone notifications use the stored label", async () => {
|
|
const runner = new FakeRunner();
|
|
const supervisor = new Supervisor(runner, "/tmp");
|
|
const accepted = supervisor.spawn({ prompt: "work", label: "Review migration" });
|
|
await sleep(0);
|
|
runner.starts[0].events.completed("done", "agent_settled");
|
|
|
|
assert.deepEqual(milestoneNotification(supervisor.status(accepted.id), "completed"), {
|
|
message: "Subagent Review migration completed",
|
|
level: "info",
|
|
});
|
|
assert.equal(milestoneNotification(supervisor.status(accepted.id), "running"), undefined);
|
|
});
|
|
|
|
test("shutdown clears recent terminal expiry timer", async () => {
|
|
const runner = new FakeRunner();
|
|
let changes = 0;
|
|
const supervisor = new Supervisor(runner, "/tmp", {
|
|
recentTerminalTtlMs: 5,
|
|
onChange: () => {
|
|
changes += 1;
|
|
},
|
|
});
|
|
await spawnStarted(supervisor);
|
|
|
|
await supervisor.shutdown();
|
|
const afterShutdown = changes;
|
|
await sleep(15);
|
|
|
|
assert.equal(changes, afterShutdown);
|
|
});
|
|
|
|
test("batch spawn returns explicit labels on accepted child requests and statuses while preserving failures", async () => {
|
|
const runner = new FakeRunner();
|
|
const supervisor = new Supervisor(runner, "/tmp");
|
|
|
|
const result = supervisor.spawnBatch([
|
|
{ prompt: "one", label: "Review docs" },
|
|
{ prompt: "" },
|
|
{ prompt: "two", label: "Check tests" },
|
|
]);
|
|
await sleep(0);
|
|
|
|
assert.deepEqual(result.accepted.map((accepted) => accepted.label), ["Review docs", "Check tests"]);
|
|
assert.equal(result.failed.length, 1);
|
|
assert.equal(result.failed[0].index, 1);
|
|
assert.deepEqual(runner.starts.map((start) => start.request.label), ["Review docs", "Check tests"]);
|
|
assert.deepEqual(result.accepted.map((accepted) => supervisor.status(accepted.id).label), ["Review docs", "Check tests"]);
|
|
});
|
|
|
|
test("maxConcurrent preserves queued records", async () => {
|
|
const runner = new FakeRunner();
|
|
const supervisor = new Supervisor(runner, "/tmp", { maxConcurrent: 1 });
|
|
|
|
const result = supervisor.spawnBatch([{ prompt: "one" }, { prompt: "two" }]);
|
|
await sleep(0);
|
|
|
|
assert.equal(result.accepted.length, 2);
|
|
assert.equal(runner.starts.length, 1);
|
|
assert.equal(supervisor.status(result.accepted[1].id).state, "queued");
|
|
|
|
runner.starts[0].events.completed("done", "agent_settled");
|
|
await sleep(0);
|
|
|
|
assert.equal(runner.starts.length, 2);
|
|
});
|
|
|
|
test("terminal records stay listed past ttl and remain retrievable until cleared", async () => {
|
|
const runner = new FakeRunner();
|
|
const supervisor = new Supervisor(runner, "/tmp", { recentTerminalTtlMs: 5 });
|
|
const completed = await spawnStarted(supervisor, "one");
|
|
const failed = await spawnStarted(supervisor, "two");
|
|
|
|
runner.starts[0].events.completed("one done", "agent_settled");
|
|
runner.starts[1].events.failed("two failed");
|
|
await sleep(10);
|
|
|
|
const listedIds = supervisor.list().map((status) => status.id);
|
|
assert.ok(listedIds.includes(completed.id));
|
|
assert.ok(listedIds.includes(failed.id));
|
|
assert.equal(supervisor.result(completed.id).result, "one done");
|
|
assert.equal(supervisor.result(failed.id).error, "two failed");
|
|
|
|
(supervisor as Supervisor & { clearTerminal(): void }).clearTerminal();
|
|
|
|
const afterClearIds = supervisor.list().map((status) => status.id);
|
|
assert.equal(afterClearIds.includes(completed.id), false);
|
|
assert.equal(afterClearIds.includes(failed.id), false);
|
|
assert.throws(() => supervisor.status(completed.id), /unknown subagent id/);
|
|
assert.throws(() => supervisor.result(failed.id), /unknown subagent id/);
|
|
});
|
|
|
|
test("zero recent terminal ttl does not hide terminal statuses", async () => {
|
|
const runner = new FakeRunner();
|
|
const supervisor = new Supervisor(runner, "/tmp", { recentTerminalTtlMs: 0 });
|
|
const accepted = await spawnStarted(supervisor);
|
|
|
|
runner.starts[0].events.completed("done", "agent_settled");
|
|
|
|
assert.equal(supervisor.list().some((status) => status.id === accepted.id), true);
|
|
assert.equal(supervisor.result(accepted.id).result, "done");
|
|
});
|
|
|
|
test("wait blocks until multiple subagents are terminal", async () => {
|
|
const runner = new FakeRunner();
|
|
const supervisor = new Supervisor(runner, "/tmp");
|
|
const first = await spawnStarted(supervisor, "one");
|
|
const second = await spawnStarted(supervisor, "two");
|
|
|
|
const waiting = supervisor.wait([first.id, second.id], { timeoutMs: 100 });
|
|
runner.starts[0].events.completed("one done", "agent_settled");
|
|
await sleep(0);
|
|
|
|
assert.equal(await Promise.race([waiting.then(() => "done"), sleep(10).then(() => "pending")]), "pending");
|
|
|
|
runner.starts[1].events.failed("two failed");
|
|
const result = await waiting;
|
|
|
|
assert.equal(result.timedOut, false);
|
|
assert.equal(result.ready, true);
|
|
assert.deepEqual(result.ids, [first.id, second.id]);
|
|
assert.equal(result.pending.length, 0);
|
|
assert.deepEqual(result.results.map((item) => item.state), ["completed", "failed"]);
|
|
assert.equal(result.results[0].result, "one done");
|
|
assert.equal(result.results[1].error, "two failed");
|
|
});
|
|
|
|
test("wait returns pending statuses on timeout", async () => {
|
|
const runner = new FakeRunner();
|
|
const supervisor = new Supervisor(runner, "/tmp");
|
|
const first = await spawnStarted(supervisor, "one");
|
|
const second = await spawnStarted(supervisor, "two");
|
|
|
|
runner.starts[0].events.completed("one done", "agent_settled");
|
|
const result = await supervisor.wait([first.id, second.id], { timeoutMs: 5 });
|
|
|
|
assert.equal(result.timedOut, true);
|
|
assert.equal(result.ready, false);
|
|
assert.deepEqual(result.results.map((item) => item.state), ["completed", "running"]);
|
|
assert.deepEqual(result.pending.map((item) => item.id), [second.id]);
|
|
});
|
|
|
|
test("wait any returns after the first terminal subagent", async () => {
|
|
const runner = new FakeRunner();
|
|
const supervisor = new Supervisor(runner, "/tmp");
|
|
const first = await spawnStarted(supervisor, "one");
|
|
const second = await spawnStarted(supervisor, "two");
|
|
|
|
const waiting = supervisor.wait([first.id, second.id], { mode: "any", timeoutMs: 100 });
|
|
runner.starts[1].events.completed("two done", "agent_settled");
|
|
const result = await waiting;
|
|
|
|
assert.equal(result.timedOut, false);
|
|
assert.equal(result.ready, true);
|
|
assert.deepEqual(result.results.map((item) => item.state), ["running", "completed"]);
|
|
assert.deepEqual(result.pending.map((item) => item.id), [first.id]);
|
|
});
|
|
|
|
test("wait rejects unknown and empty id sets", async () => {
|
|
const runner = new FakeRunner();
|
|
const supervisor = new Supervisor(runner, "/tmp");
|
|
|
|
await assert.rejects(() => supervisor.wait([]), /at least one subagent id is required/);
|
|
await assert.rejects(() => supervisor.wait(["missing"]), /unknown subagent id: missing/);
|
|
});
|
|
|
|
test("wait abort rejects without cancelling child", async () => {
|
|
const runner = new FakeRunner();
|
|
const supervisor = new Supervisor(runner, "/tmp");
|
|
const accepted = await spawnStarted(supervisor, "one");
|
|
const controller = new AbortController();
|
|
|
|
const waiting = supervisor.wait([accepted.id], { signal: controller.signal });
|
|
controller.abort();
|
|
|
|
await assert.rejects(waiting, /subagent wait aborted/);
|
|
assert.equal(runner.starts[0].handle.cancelCalls, 0);
|
|
});
|
|
|
|
test("wait follows queued subagents through queue start and completion", async () => {
|
|
const runner = new FakeRunner();
|
|
const supervisor = new Supervisor(runner, "/tmp", { maxConcurrent: 1 });
|
|
const batch = supervisor.spawnBatch([{ prompt: "one" }, { prompt: "two" }]);
|
|
await sleep(0);
|
|
|
|
const waiting = supervisor.wait([batch.accepted[1].id], { timeoutMs: 100 });
|
|
assert.equal(await Promise.race([waiting.then(() => "done"), sleep(10).then(() => "pending")]), "pending");
|
|
|
|
runner.starts[0].events.completed("one done", "agent_settled");
|
|
await sleep(0);
|
|
runner.starts[1].events.completed("two done", "agent_settled");
|
|
const result = await waiting;
|
|
|
|
assert.equal(result.timedOut, false);
|
|
assert.equal(result.ready, true);
|
|
assert.deepEqual(result.results.map((item) => item.result), ["two done"]);
|
|
});
|