Ctrl K
ComunidadGuías…

server/acp-recovery.e2e.test.ts

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

Archivo original ↗Preguntar a KiBot
import { spawn, type ChildProcess } from "node:child_process";
import { closeSync, existsSync, openSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { expect, it } from "vitest";
import { launchVerificationServer, runControlOmb, verificationServerEnvironment } from "../scripts/control-omb.ts";
import { AUTO_RETRY_LEAD } from "./incidents.ts";
import { waitForExit } from "./testing/cleanup.ts";

it.each([false, true])("recovers the same conversation after an ACP internal error without replay (output: %s)", async (afterOutput) => {
  const fixture = await launchVerificationServer();
  const { dataDir, url, logPath } = fixture.info;
  let server: ChildProcess | undefined;
  const failureFile = join(dataDir, "rpc-failure.json");
  const rpcFile = join(dataDir, "rpc.jsonl");
  const dumpFile = join(dataDir, "acp-dump.json");
  const evidence: unknown[] = [{ url, dataDir, afterOutput }];
  const api = async (method: string, path: string, body?: unknown, status = 200) => {
    const response = await fetch(url + path, {
      method, headers: { "content-type": "application/json", origin: url },
      ...(body === undefined ? {} : { body: JSON.stringify(body) }), signal: AbortSignal.timeout(30_000),
    });
    const value = await response.json() as any;
    expect(response.status, `${method} ${path}: ${JSON.stringify(value)}`).toBe(status);
    return value;
  };
  const control = async (...args: string[]) => {
    const result = await runControlOmb([...args, "--url", url]) as any;
    evidence.push({ command: args, result });
    return result;
  };
  const calls = () => existsSync(rpcFile)
    ? readFileSync(rpcFile, "utf8").trim().split("\n").filter(Boolean).map((line) => JSON.parse(line) as { pid: number; method: string }) : [];
  const prompts = () => calls().filter((call) => call.method === "session/prompt");
  try {
    // Add only the repository-owned synthetic provider to the launcher's
    // temporary config while stopped. Never discover the person's CLI/home.
    await waitForExit(fixture.child, { signal: "SIGTERM" });
    const configPath = join(dataDir, "config.json");
    const config = JSON.parse(readFileSync(configPath, "utf8"));
    config.instances.opencodeGo = {
      driver: "opencodeGo", displayName: "Recovery fixture",
      config: { cli: fileURLToPath(new URL("./testing/fake-acp-cli.ts", import.meta.url)) },
      environment: {
        FAKE_ACP_MODELS: "fixture/recovery", FAKE_ACP_DUMP: dumpFile, FAKE_ACP_DUMP_PROMPT: "1",
        FAKE_ACP_RPC_APPEND_FILE: rpcFile, FAKE_ACP_RPC_FAILURE_FILE: failureFile,
        ...(afterOutput ? { FAKE_ACP_RPC_FAILURE_AFTER_OUTPUT: "1" } : {}),
      },
    };
    writeFileSync(configPath, JSON.stringify(config));
    const log = openSync(logPath, "a", 0o600);
    server = spawn(process.execPath, ["--experimental-strip-types", fileURLToPath(new URL("./index.ts", import.meta.url))], {
      cwd: fileURLToPath(new URL("..", import.meta.url)),
      env: verificationServerEnvironment({}, dataDir, Number(new URL(url).port)), stdio: ["ignore", log, log],
    });
    closeSync(log);
    await expect.poll(async () => {
      try { return (await fetch(url + "/api/health", { signal: AbortSignal.timeout(1_000) })).ok; } catch { return false; }
    }, { timeout: 20_000 }).toBe(true);

    const { bot } = await control("new-bot", "--name", "Recovery bot");
    const threadId = bot.activeTaskId;
    await api("PATCH", `/api/bots/${bot.id}/tasks/${threadId}`, { modelSelection: { instanceId: "opencodeGo", model: "fixture/recovery" } });
    const messages = async () => (await api("GET", `/api/threads/${threadId}/messages?limit=100`)).messages as any[];
    const task = async () => (await api("GET", "/api/bots")).bots.find((entry: any) => entry.id === bot.id)
      .tasks.find((entry: any) => entry.threadId === threadId);
    const send = (text: string) => control("send", "--bot", bot.id, "--task", threadId, "--text", text);
    const wait = () => control("wait", "--bot", bot.id, "--task", threadId, "--timeout", "30");
    const firstText = "REMEMBER_RECOVERY_7Q";
    const failedText = "FAILED_WORK_8R";
    const retryText = "EXPLICIT_RETRY_9S";

    await send(firstText);
    expect((await wait()).status).toBe("settled");
    expect(prompts()).toHaveLength(1);
    const firstProcess = prompts()[0].pid;

    writeFileSync(failureFile, JSON.stringify({ code: -32603, message: "Internal error", data: { details: "OpenCode service failure" } }));
    await send(failedText);
    // OpenMausBot retries the failed run once by itself, on a replacement process that loads the same session; the fake
    // still refuses, so that retry fails too.
    const failures = async () => (await messages()).filter((message) => message.tool?.ok === false && /OpenCode service failure/.test(message.tool.name));
    await expect.poll(async () => (await failures()).length, { timeout: 20_000 }).toBe(2);
    await expect.poll(async () => (await task()).busy, { timeout: 20_000 }).toBe(false);
    expect((await messages()).filter((message) => message.role === "user" && message.text?.startsWith(AUTO_RETRY_LEAD))).toHaveLength(1);
    expect(prompts()).toHaveLength(3);
    expect(prompts()[1].pid).toBe(firstProcess);
    const retryProcess = prompts()[2].pid;
    expect(retryProcess).not.toBe(firstProcess);
    expect(calls().filter((call) => call.pid === retryProcess).map((call) => call.method)).toContain("session/load");
    expect(calls().filter((call) => call.method === "session/prompt.error")).toHaveLength(2);
    evidence.push({ failedTask: await task(), promptCallsBeforeRetry: prompts() });

    // The fake keeps the error cached in its process even after removal.
    // Only a replacement process can answer this explicit next user message.
    rmSync(failureFile);
    await send(retryText);
    expect((await wait()).status).toBe("settled");
    expect(prompts()).toHaveLength(4);
    expect([firstProcess, retryProcess]).not.toContain(prompts()[3].pid);
    const prompt = JSON.stringify(JSON.parse(readFileSync(`${dumpFile}.prompt.json`, "utf8")));
    expect(prompt.split(retryText).length - 1).toBe(1);
    expect(prompt).not.toContain(firstText);
    expect(prompt).not.toContain(failedText);
    const finalMessages = await messages();
    for (const text of [firstText, failedText, retryText]) {
      expect(finalMessages.filter((message) => message.role === "user" && message.text === text)).toHaveLength(1);
    }
    expect(finalMessages.findLast((message) => message.role === "bot" && message.kind === "text")?.turnSucceeded).toBe(true);
    expect((await task()).busy).toBe(false);
    evidence.push({ task: await task(), calls: calls(), messages: finalMessages, prompt });
  } finally {
    const evidencePath = `${logPath}.acp-recovery.json`;
    writeFileSync(evidencePath, JSON.stringify(evidence, null, 2));
    await waitForExit(server, { signal: "SIGTERM" });
    await fixture.close();
    console.info(JSON.stringify({ logPath, evidencePath, fixtureRemoved: !existsSync(dataDir) }));
  }
}, 90_000);