import { describe, expect, it, vi } from "vitest"; import { keepTransient, lazyClaimFailedText, lazyClaimRetryText, MAX_CLAIM_ATTEMPTS, startAutoVmClaim, transientClaimFailure, type AutoVmClaimSlot, type AutoVmClaimTable } from "./auto-vm-claims.ts"; const slot = (claim: () => Promise, generation = "gen-1"): AutoVmClaimSlot => ({ owner: { threadId: "t1", generation }, claim, }); describe("startAutoVmClaim", () => { it("fires a thread's claim exactly once, even across repeated gate polls", async () => { const table: AutoVmClaimTable = new Map(); const claim = vi.fn(async () => undefined); table.set("t1", slot(claim)); startAutoVmClaim(table, "t1", "gen-1"); startAutoVmClaim(table, "t1", "gen-1"); startAutoVmClaim(table, "t1", "gen-1"); await table.get("t1")!.begin; expect(claim).toHaveBeenCalledExactlyOnceWith(undefined); }); it("hands the claim what the first screen call would open, so the person's question can name it", async () => { const table: AutoVmClaimTable = new Map(); const claim = vi.fn(async () => undefined); table.set("t1", slot(claim)); startAutoVmClaim(table, "t1", "gen-1", { app: "Chrome", site: "calendar.google.com" }); await table.get("t1")!.begin; expect(claim).toHaveBeenCalledExactlyOnceWith({ app: "Chrome", site: "calendar.google.com" }); }); it("takes a person's No as an answer: the slot refuses later calls, and no failed turn is reported", async () => { const table: AutoVmClaimTable = new Map(); const onRejected = vi.fn(); const declined = Object.assign(new Error("the person said no to using their own computer"), { declined: true }); table.set("t1", { ...slot(async () => { throw declined; }), onRejected }); startAutoVmClaim(table, "t1", "gen-1"); await table.get("t1")!.begin; expect(table.get("t1")).toMatchObject({ failed: true, failure: "the person said no to using their own computer" }); expect(onRejected).not.toHaveBeenCalled(); }); it("refuses to fire for a different dispatch generation", () => { const table: AutoVmClaimTable = new Map(); const claim = vi.fn(async () => undefined); table.set("t1", slot(claim, "gen-1")); startAutoVmClaim(table, "t1", "gen-2"); expect(claim).not.toHaveBeenCalled(); expect(table.get("t1")!.begin).toBeUndefined(); }); it("fails closed: a rejected claim keeps the slot so later gate polls stay held (F1)", async () => { const table: AutoVmClaimTable = new Map(); const claim = vi.fn(async () => { throw new Error("Computer is still busy. Stop the turn using it, then retry."); }); table.set("t1", slot(claim)); startAutoVmClaim(table, "t1", "gen-1"); await table.get("t1")!.begin; expect(table.has("t1")).toBe(true); expect(table.get("t1")!.failed).toBe(true); expect(table.get("t1")!.owner.generation).toBe("gen-1"); startAutoVmClaim(table, "t1", "gen-1"); startAutoVmClaim(table, "t1", "gen-1"); expect(claim).toHaveBeenCalledExactlyOnceWith(undefined); table.delete("t1"); const next = vi.fn(async () => undefined); table.set("t1", slot(next, "gen-2")); startAutoVmClaim(table, "t1", "gen-2"); await table.get("t1")!.begin; expect(next).toHaveBeenCalledExactlyOnceWith(undefined); expect(table.get("t1")!.failed).toBeUndefined(); }); it("keeps the slot after success so later polls stay no-ops", async () => { const table: AutoVmClaimTable = new Map(); const claim = vi.fn(async () => undefined); table.set("t1", slot(claim)); startAutoVmClaim(table, "t1", "gen-1"); await table.get("t1")!.begin; startAutoVmClaim(table, "t1", "gen-1"); await Promise.resolve(); expect(claim).toHaveBeenCalledExactlyOnceWith(undefined); expect(table.has("t1")).toBe(true); }); it("records a landed claim so the gate can answer honestly instead of held", async () => { const table: AutoVmClaimTable = new Map(); table.set("t1", slot(vi.fn(async () => undefined))); startAutoVmClaim(table, "t1", "gen-1"); expect(table.get("t1")!.claimed).toBeUndefined(); await table.get("t1")!.begin; expect(table.get("t1")!.claimed).toBe(true); expect(table.get("t1")!.failed).toBeUndefined(); }); it("keeps the rejection's message so the refusal can say why", async () => { const table: AutoVmClaimTable = new Map(); table.set("t1", slot(async () => { throw new Error("the Local VM is not ready (App Settings → Computers)"); })); startAutoVmClaim(table, "t1", "gen-1"); await table.get("t1")!.begin; expect(table.get("t1")!.failure).toBe("the Local VM is not ready (App Settings → Computers)"); expect(table.get("t1")!.claimed).toBeUndefined(); }); it("a stale rejection cannot delete a newer generation's slot", async () => { const table: AutoVmClaimTable = new Map(); let rejectFirst: (error: Error) => void = () => {}; const first = slot(() => new Promise((_, reject) => { rejectFirst = reject; })); table.set("t1", first); startAutoVmClaim(table, "t1", "gen-1"); const second = slot(vi.fn(async () => undefined), "gen-2"); table.set("t1", second); rejectFirst(new Error("stale holder")); await first.begin; expect(table.get("t1")).toBe(second); expect(second.begin).toBeUndefined(); }); it("fires the turn's rejection hook once when its claim rejects (issue #1369)", async () => { const table: AutoVmClaimTable = new Map(); const onRejected = vi.fn(); table.set("t1", { owner: { threadId: "t1", generation: "gen-1" }, claim: async () => { throw new Error("the Local VM died"); }, onRejected, }); startAutoVmClaim(table, "t1", "gen-1"); await table.get("t1")!.begin; expect(onRejected).toHaveBeenCalledExactlyOnceWith("the Local VM died", expect.objectContaining({ message: "the Local VM died" })); startAutoVmClaim(table, "t1", "gen-1"); expect(onRejected).toHaveBeenCalledExactlyOnceWith("the Local VM died", expect.objectContaining({ message: "the Local VM died" })); }); it("lets a later screen call claim again after a network failure, without failing the turn", async () => { const table: AutoVmClaimTable = new Map(); const onRejected = vi.fn(); const unreachable = Object.assign(new Error("Could not reach MausBot Cloud's computer service to list cloud computers"), { transient: true }); const claim = vi.fn<() => Promise>().mockRejectedValueOnce(unreachable).mockResolvedValueOnce(undefined); table.set("t1", { ...slot(claim), label: "the cloud computer", onRejected }); startAutoVmClaim(table, "t1", "gen-1"); await table.get("t1")!.begin; const after = table.get("t1")!; expect(after).toMatchObject({ retryReason: unreachable.message, begin: undefined }); expect(after.failed).toBeUndefined(); expect(onRejected).not.toHaveBeenCalled(); expect(lazyClaimRetryText(after)).toBe("This turn could not reach the cloud computer just now (Could not reach MausBot Cloud's computer service to list cloud computers). This call was not performed; try it again in a moment."); startAutoVmClaim(table, "t1", "gen-1"); await after.begin; expect(after).toMatchObject({ claimed: true, retryReason: undefined, attempts: 2 }); expect(claim).toHaveBeenCalledTimes(2); }); it("fails closed once network failures reach the attempt limit, and at once on any other failure", async () => { const table: AutoVmClaimTable = new Map(); const onRejected = vi.fn(); const reset = Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNRESET" } }); const claim = vi.fn(async () => { throw reset; }); table.set("t1", { ...slot(claim), onRejected }); for (let attempt = 0; attempt < MAX_CLAIM_ATTEMPTS + 2; attempt++) { startAutoVmClaim(table, "t1", "gen-1"); await table.get("t1")!.begin; } expect(claim).toHaveBeenCalledTimes(MAX_CLAIM_ATTEMPTS); expect(table.get("t1")).toMatchObject({ failed: true, failure: "fetch failed" }); expect(onRejected).toHaveBeenCalledOnce(); const refused = vi.fn(async () => { throw new Error("the cloud computer didn't start in time"); }); table.set("t2", { owner: { threadId: "t2", generation: "gen-1" }, claim: refused }); startAutoVmClaim(table, "t2", "gen-1"); await table.get("t2")!.begin; startAutoVmClaim(table, "t2", "gen-1"); expect(refused).toHaveBeenCalledOnce(); expect(table.get("t2")!.failed).toBe(true); }); it("knows a network failure through the wrapper that rewords it", () => { expect(transientClaimFailure(new DOMException("The operation was aborted due to timeout", "TimeoutError"))).toBe(true); expect(transientClaimFailure(Object.assign(new TypeError("fetch failed"), { cause: { code: "ECONNREFUSED" } }))).toBe(true); expect(transientClaimFailure(Object.assign(new Error("boat.dev rejected your Boat API key"), { status: 401 }))).toBe(false); expect(transientClaimFailure(new Error("the cloud computer didn't start in time"))).toBe(false); const shown = keepTransient(Object.assign(new Error("unreachable"), { transient: true }), new Error("Pepper's cloud computer isn't available right now.")); expect(transientClaimFailure(shown)).toBe(true); expect(transientClaimFailure(keepTransient(new Error("refused"), new Error("reworded")))).toBe(false); }); it("never fires the rejection hook for a claim that lands", async () => { const table: AutoVmClaimTable = new Map(); const onRejected = vi.fn(); table.set("t1", { owner: { threadId: "t1", generation: "gen-1" }, claim: async () => undefined, onRejected, }); startAutoVmClaim(table, "t1", "gen-1"); await table.get("t1")!.begin; expect(table.get("t1")!.claimed).toBe(true); expect(onRejected).not.toHaveBeenCalled(); }); }); describe("lazyClaimFailedText", () => { const boat = { label: "the cloud computer", startsComputer: true as const, failure: "Miso's cloud desktop didn't start. This conversation is back on Auto. Send your message again." }; it("gives the bot the computer's own reason, and says the conversation is back on Auto only when it is", () => { expect(lazyClaimFailedText({ ...boat, cause: "the cloud computer could not be created or reached", backOnAuto: true })).toBe( "The cloud computer could not be started: the cloud computer could not be created or reached. This call was not performed. " + "This conversation is back on Auto. Do not retry computer work in this turn; tell the person what you could not do."); expect(lazyClaimFailedText({ ...boat, cause: "the cloud computer didn't start in time." })).toBe( "The cloud computer could not be started: the cloud computer didn't start in time. This call was not performed. " + "Do not retry computer work in this turn; tell the person what you could not do."); }); it("keeps the claim wording for a computer the turn does not start", () => { expect(lazyClaimFailedText({ label: "the Local VM", failure: "the Local VM is not ready" })).toBe( "This turn could not claim the Local VM (the Local VM is not ready). This call was not performed. Do not retry computer work in this turn; tell the person what you could not do."); }); });