Files
pi-goals/test/supervisor-session.test.ts
T

127 lines
5.8 KiB
TypeScript

import { execFileSync } from "node:child_process";
import { existsSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { afterEach, describe, expect, it, vi } from "vitest";
import { approvalPath } from "../src/approval.js";
import { registerVisibleSupervisor } from "../src/supervisor-session.js";
function setup(cwd: string, planPath: string) {
vi.stubEnv("PI_GOALS_WORKER_ID", "worker-session");
vi.stubEnv("PI_GOALS_WORKER_INTERCOM_ID", "worker-intercom");
vi.stubEnv("PI_GOALS_OWNER_SESSION_ID", "worker-session");
vi.stubEnv("PI_GOALS_PLAN_PATH", planPath);
vi.stubEnv("PI_GOALS_APPROVAL_ID", "approval-1");
const hooks = new Map<string, any>();
const tools = new Map<string, any>();
const entries: any[] = [];
const paired: Array<{ workerIntercomId: string; goal: string }> = [];
const announced: Array<{ workerIntercomId: string; approvalId: string }> = [];
let branch: any[] = [];
const ctx = {
cwd,
getSystemPrompt: () => "base",
getContextUsage: () => ({ tokens: 10 }),
compact: vi.fn((options: any) => options.onComplete()),
sessionManager: {
getEntries: () => entries,
getBranch: () => branch,
getSessionId: () => "supervisor-session",
},
ui: { notify: vi.fn() },
};
const pi = {
events: {
on() {},
emit(name: string, request: any) {
if (name !== "pi-supervise:pair:v1") return;
paired.push({ workerIntercomId: request.workerIntercomId, goal: request.goal });
request.resolve();
},
},
on: (name: string, handler: any) => hooks.set(name, handler),
registerTool: (tool: any) => tools.set(tool.name, tool),
appendEntry: (customType: string, data: unknown) => entries.push({ type: "custom", customType, data }),
};
registerVisibleSupervisor(pi as unknown as ExtensionAPI, {
workerIntercomId: async () => "worker-intercom",
waitForSupervisorReady: async () => {},
announceSupervisorReady: async (workerIntercomId, approvalId) => { announced.push({ workerIntercomId, approvalId }); },
});
return { announced, branch: (value: any[]) => { branch = value; }, ctx, entries, hooks, paired, tools };
}
afterEach(() => vi.unstubAllEnvs());
describe("visible supervisor session", () => {
it("compacts the fork before pairing it with the worker", async () => {
const cwd = mkdtempSync(join(tmpdir(), "pi-goals-supervisor-"));
try {
const runtime = setup(cwd, join(cwd, ".pi/plan/worker-v1.md"));
await runtime.hooks.get("session_start")({}, runtime.ctx);
expect(runtime.ctx.compact).toHaveBeenCalledOnce();
expect(runtime.entries.at(-1)).toMatchObject({ customType: "pi-goals-visible-supervisor-v1" });
expect(runtime.paired).toEqual([{ workerIntercomId: "worker-intercom", goal: join(cwd, ".pi/plan/worker-v1.md") }]);
expect(runtime.announced).toEqual([{ workerIntercomId: "worker-intercom", approvalId: "approval-1" }]);
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
it("records approval only from a stopped view with evidence and no active work", async () => {
const cwd = mkdtempSync(join(tmpdir(), "pi-goals-supervisor-"));
try {
writeFileSync(join(cwd, ".gitignore"), ".pi/\n");
execFileSync("git", ["init", "-q"], { cwd });
execFileSync("git", ["add", ".gitignore"], { cwd });
execFileSync("git", ["-c", "user.name=test", "-c", "user.email=test@example.com", "commit", "-qm", "initial"], { cwd });
const planPath = join(cwd, ".pi/plan/worker-v1.md");
execFileSync("mkdir", ["-p", join(cwd, ".pi/plan")]);
writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [ ] goal: make the file\n - discriminator: output exists\n - evidence:\n - `result.txt`: contains ok\n\n## Log\n");
const runtime = setup(cwd, planPath);
runtime.branch([{
type: "message",
message: { role: "user", content: [{ type: "text", text: "The worker stopped.\n\ntool calls with no result: none\nchild pi processes still running: none" }] },
}]);
const approved = await runtime.tools.get("ApproveGoal").execute("id", {
goal: "make the file",
inspectedPlan: true,
inspectedRepository: true,
inspectedEvidence: true,
inspectedVerifyOutput: true,
}, undefined, undefined, runtime.ctx);
expect(approved.isError).toBe(false);
expect(existsSync(approvalPath(cwd, "worker-session", "make the file"))).toBe(true);
writeFileSync(planPath, "# Plan\n\n## Goals\n\n1. [ ] goal: make the file\n - evidence:\n - \n - tasks:\n - write result.txt\n");
const missingEvidence = await runtime.tools.get("ApproveGoal").execute("id", {
goal: "make the file", inspectedPlan: true, inspectedRepository: true, inspectedEvidence: true, inspectedVerifyOutput: true,
}, undefined, undefined, runtime.ctx);
expect(missingEvidence.isError).toBe(true);
expect(missingEvidence.content[0].text).toContain("nonblank evidence entry");
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
it("rejects approval while the worker view has an unfinished tool call", async () => {
const cwd = mkdtempSync(join(tmpdir(), "pi-goals-supervisor-"));
try {
const planPath = join(cwd, "plan.md");
writeFileSync(planPath, "1. [ ] goal: wait\n - evidence:\n - result\n");
const runtime = setup(cwd, planPath);
runtime.branch([{
type: "message",
message: { role: "user", content: [{ type: "text", text: "The worker stopped.\n\ntool calls with no result: bash\nchild pi processes still running: none" }] },
}]);
const rejected = await runtime.tools.get("ApproveGoal").execute("id", {
goal: "wait", inspectedPlan: true, inspectedRepository: true, inspectedEvidence: true, inspectedVerifyOutput: true,
}, undefined, undefined, runtime.ctx);
expect(rejected.isError).toBe(true);
expect(rejected.content[0].text).toContain("bash");
} finally {
rmSync(cwd, { recursive: true, force: true });
}
});
});