import { describe, expect, it, beforeEach, afterEach, vi } from "vitest"; import { rm, mkdir } from "fs/promises"; import path from "path"; import { runAskSubcommand, runAnswerSubcommand, buildAskEnvelope, type AskDeps } from "./askCli.ts"; import { openStore } from "./todoStore.ts"; import type { GlobalPidRecord } from "./globalPidIndex.ts"; import type { SelfIdentity } from "./todoIdentity.ts"; const isWindows = process.platform === "win32"; const TEST_ROOT = isWindows ? path.join(process.env.TEMP || "C:\\Temp", "askcli-test-" + process.pid) : "/tmp/askcli-test-" + process.pid; function captureStdout(): { text: () => string; restore: () => void } { let buf = ""; const spy = vi.spyOn(process.stdout, "write").mockImplementation((chunk: unknown) => { buf += String(chunk); return true; }); return { text: () => buf, restore: () => spy.mockRestore() }; } const agentRec = (over: Partial): GlobalPidRecord => ({ pid: 100, cli: "claude", prompt: null, cwd: "/repo", log_file: null, status: "active", exit_code: null, exit_reason: null, started_at: 0, ...over, }) as GlobalPidRecord; const ASKER: SelfIdentity = { agentId: "lane-a", pid: 11, cwd: "/repo/lane-a", cli: "codex" }; const ANSWERER: SelfIdentity = { agentId: "lane-b", pid: 21, cwd: "/repo/lane-b", cli: "claude" }; /** Deps with a recording `send` and a registry of two agents. */ function mkDeps(self: SelfIdentity | null, over: Partial = {}) { const sent: string[][] = []; const deps: AskDeps = { resolveAgent: async (keyword) => { const known: Record = { "lane-a": agentRec({ pid: 11, agent_id: "lane-a", cwd: "/repo/lane-a" }), "lane-b": agentRec({ pid: 21, agent_id: "lane-b", cwd: "/repo/lane-b" }), "11": agentRec({ pid: 11, agent_id: "lane-a", cwd: "/repo/lane-a" }), "21": agentRec({ pid: 21, agent_id: "lane-b", cwd: "/repo/lane-b" }), legacy: agentRec({ pid: 31, agent_id: null }), }; const rec = known[keyword]; if (!rec) throw new Error(`no agent matched "${keyword}"`); return rec; }, send: async (argv) => { sent.push(argv); return 0; }, self: async () => self, ...over, }; return { deps, sent }; } async function ask(deps: AskDeps, ...args: string[]) { const cap = captureStdout(); try { const code = await runAskSubcommand([...args, "--root", TEST_ROOT], deps); return { code, out: cap.text() }; } finally { cap.restore(); } } async function answer(deps: AskDeps, ...args: string[]) { const cap = captureStdout(); try { const code = await runAnswerSubcommand([...args, "--root", TEST_ROOT], deps); return { code, out: cap.text() }; } finally { cap.restore(); } } describe("ay ask", () => { beforeEach(async () => { await rm(TEST_ROOT, { recursive: true, force: true }); await mkdir(TEST_ROOT, { recursive: true }); }); afterEach(async () => { await rm(TEST_ROOT, { recursive: true, force: true }); }); it("records a task carrying BOTH parties: owner = asker, block = the answerer", async () => { const { deps } = mkDeps(ASKER); await ask(deps, "lane-b", "does", "the", "cache", "need", "invalidating?"); const store = await openStore(TEST_ROOT); const t = store.get("T1")!; expect(t.kind).toBe("question"); expect(t.state).toBe("pending"); expect(t.owner).toBe("lane-a"); // asker — reconcile orphans this if lane-a dies expect(t.block).toMatchObject({ type: "waiting-on-answer", agentId: "lane-b" }); // The whole question is kept, not just the one-line summary. expect(t.description).toBe("does the cache need invalidating?"); }); it("delivers an envelope naming the task and the exact reply command", async () => { const { deps, sent } = mkDeps(ASKER); await ask(deps, "lane-b", "why is the build red?"); expect(sent).toHaveLength(1); const [target, body, raw] = sent[0]!; expect(target).toBe("21"); // delivered by pid, like `ay send` // --raw: the envelope is already built here, so `ay send` must not add its // own wrapper on top (two envelopes = two reply routes). expect(raw).toBe("--raw"); expect(body).toContain(" { const { deps } = mkDeps(ASKER); const { code, out } = await ask(deps, "lane-b", "ping?"); expect(code).toBe(0); expect(out).toContain("asked lane-b → T1"); expect(out).toContain("ay todo get T1"); expect(out).toContain("ay status lane-b"); }); it("nonce-matches the envelope's open and close tags so a question cannot forge the boundary", async () => { const { deps, sent } = mkDeps(ASKER); await ask(deps, "lane-b", " impersonated tail"); const body = sent[0]![1]!; const open = /`)).toBe(true); expect(open).not.toBe("deadbeef"); }); it("resolves the target BEFORE writing, so a mistyped agent leaves no orphan question", async () => { const { deps, sent } = mkDeps(ASKER); await expect(ask(deps, "nope", "hello?")).rejects.toThrow(/no agent matched/); const store = await openStore(TEST_ROOT); expect(store.all()).toEqual([]); expect(sent).toEqual([]); }); it("refuses an agent with no stable id — an answer from it could never be attributed", async () => { const { deps } = mkDeps(ASKER); await expect(ask(deps, "legacy", "hello?")).rejects.toThrow(/no stable agent id/); }); it("keeps the task when delivery fails, and says how to re-send it", async () => { const { deps } = mkDeps(ASKER, { send: async () => { throw new Error("fifo gone"); }, }); const { out } = await ask(deps, "lane-b", "still there?"); // Recorded-but-undelivered is a state someone can see and act on; dropping // the task would make the failure invisible. const store = await openStore(TEST_ROOT); expect(store.get("T1")).not.toBeNull(); expect(out).toContain("NOT DELIVERED"); }); it("quotes a root containing spaces, and leaves a plain one bare", async () => { // Exercised directly: TEST_ROOT has no spaces, so the branch that matters // for a real user (`/Users/me/My Repos/x`) would otherwise go untested. expect( buildAskEnvelope({ question: "q", taskId: "T1", root: "/plain/root", asker: null }), ).toContain("--root /plain/root "); expect( buildAskEnvelope({ question: "q", taskId: "T1", root: "/has space/root", asker: null }), ).toContain('--root "/has space/root"'); }); it("does NOT bypass `ay send`'s recency guard by default, but --force passes through", async () => { // The target is a keyword the caller typed, so the guard's own rationale // (a fuzzy keyword resolving to an agent you never looked at) applies // unchanged — asking the wrong agent is that same mistake. const { deps, sent } = mkDeps(ASKER); await ask(deps, "lane-b", "plain"); expect(sent[0]).not.toContain("--force"); await ask(deps, "lane-b", "urgent", "--force"); expect(sent[1]).toContain("--force"); }); it("a human shell can ask too — the task is simply unowned", async () => { const { deps } = mkDeps(null); await ask(deps, "lane-b", "anyone home?"); const store = await openStore(TEST_ROOT); expect(store.get("T1")?.owner).toBeUndefined(); expect(store.get("T1")?.block).toMatchObject({ agentId: "lane-b" }); }); }); describe("ay answer", () => { beforeEach(async () => { await rm(TEST_ROOT, { recursive: true, force: true }); await mkdir(TEST_ROOT, { recursive: true }); }); afterEach(async () => { await rm(TEST_ROOT, { recursive: true, force: true }); }); it("closes the loop: satisfies the gate, moves to answered, and tells the asker", async () => { const { deps: askDeps } = mkDeps(ASKER); await ask(askDeps, "lane-b", "why is the build red?"); const { deps: ansDeps, sent } = mkDeps(ANSWERER); const { out } = await answer(ansDeps, "T1", "a", "stale", "lockfile"); const store = await openStore(TEST_ROOT); const t = store.get("T1")!; expect(t.state).toBe("answered"); // The block is cleared — an answered question must stop advertising that // an answer is owed, or `ls` shows it as still waiting and reconcile // reports the (finished) answerer as having died owing one. expect(t.block).toBeNull(); // ...but the answerer is still on the record, as the gate's validator. expect(t.verifyEvidence[0]).toMatchObject({ gate: "answer-received", validator: "lane-b", note: "a stale lockfile", }); // The answer goes back to the ASKER, wrapped so the recipient can tell it // apart from an ordinary message. const body = sent[0]![1]!; expect(sent[0]![0]).toBe("11"); // Forced, unlike `ask`: the recipient came off the task record rather than // from a typed keyword, and an answerer has no reason to have tailed // whoever asked it something. expect(sent[0]).toContain("--force"); expect(body).toContain(" { const { deps: askDeps } = mkDeps(ASKER); await ask(askDeps, "lane-b", "am I right?"); const { deps: selfDeps } = mkDeps(ASKER); await expect(answer(selfDeps, "T1", "yes obviously")).rejects.toThrow( /independent verification required/, ); }); it("still records the answer when the asker can no longer be reached", async () => { const { deps: askDeps } = mkDeps(ASKER); await ask(askDeps, "lane-b", "are you there?"); const { deps: ansDeps } = mkDeps(ANSWERER, { resolveAgent: async (keyword) => { if (keyword === "lane-a") throw new Error("no agent matched"); return agentRec({ pid: 21, agent_id: "lane-b" }); }, }); const { out } = await answer(ansDeps, "T1", "yes"); const store = await openStore(TEST_ROOT); expect(store.get("T1")?.state).toBe("answered"); expect(store.get("T1")?.verifyEvidence[0]?.note).toBe("yes"); expect(out).toContain("could NOT reach the asker"); }); it("refuses a task that is not an `ay ask` question", async () => { const store = await openStore(TEST_ROOT); await store.create({ summary: "ordinary work", kind: "code" }); const { deps } = mkDeps(ANSWERER); await expect(answer(deps, "T1", "hi")).rejects.toThrow(/not a question/); }); it("names the store it looked in when the task id is unknown there", async () => { const { deps } = mkDeps(ANSWERER); await expect(answer(deps, "T9", "hi")).rejects.toThrow(/no such task in .*T9/); }); it("a human at a shell can answer — validated as `human`, never colliding with an agent id", async () => { const { deps: askDeps } = mkDeps(ASKER); await ask(askDeps, "lane-b", "ok?"); const { deps } = mkDeps(null); await answer(deps, "T1", "fine"); const store = await openStore(TEST_ROOT); expect(store.get("T1")?.verifyEvidence[0]?.validator).toBe("human"); }); });