Ctrl K
ComunidadGuías…

server/auto-vm-claims.test.ts

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
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<void>, 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<void>((_, 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<void>>().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.");
  });
});