Ctrl K
ComunidadGuías…

test/codex-idle-recovery.test.js

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

Archivo original ↗Preguntar a KiBot
var test = require("node:test");
var assert = require("node:assert/strict");
var createAdapter = require("../lib/yoke/adapters/codex").createCodexAdapter;

function tick() {
  return new Promise(function(resolve) { setImmediate(resolve); });
}

function deferred() {
  var resolve;
  var promise = new Promise(function(done) { resolve = done; });
  return { promise: promise, resolve: resolve };
}

function fixture(t, overrides) {
  var servers = [];
  var nextThread = 0;
  var nextTurn = 0;
  function createServer() {
    var server = { started: false, stopped: 0, calls: [], handlers: [] };
    server.start = function() { server.started = true; return Promise.resolve(); };
    server.stop = function() { server.stopped++; server.started = false; };
    server.addHandler = function(fn) {
      var entry = { threadId: null, fn: fn };
      server.handlers.push(entry);
      return entry;
    };
    server.removeHandler = function(entry) {
      server.handlers = server.handlers.filter(function(current) { return current !== entry; });
    };
    server.notify = function() {};
    server.respond = function() {};
    server.send = function(method, params) {
      server.calls.push({ method: method, params: params });
      if (overrides && overrides.send) {
        var overridden = overrides.send(server, method, params);
        if (overridden !== undefined) return overridden;
      }
      if (method === "thread/start") return Promise.resolve({ thread: { id: "thread-" + (++nextThread) } });
      if (method === "thread/resume") return Promise.resolve({ thread: { id: params.threadId } });
      if (method === "turn/start") return Promise.resolve({ turn: { id: "turn-" + (++nextTurn) } });
      if (method === "thread/loaded/list") return Promise.resolve({ data: server.recoveryTargetUnknown ? ["saved-thread"] : [], nextCursor: null });
      return Promise.resolve({ data: [], nextCursor: null });
    };
    server.complete = function(threadId, turnId) {
      server.handlers.slice().forEach(function(entry) {
        if (entry.threadId === threadId) entry.fn({ method: "turn/completed", params: {
          threadId: threadId, turn: { id: turnId, status: "completed" },
        } });
      });
    };
    servers.push(server);
    return server;
  }
  var adapter = createAdapter({ cwd: process.cwd(), createAppServer: createServer });
  t.after(function() { return adapter.shutdown(); });
  return {
    adapter: adapter,
    servers: servers,
    fresh: function() { return adapter.createQuery({ cwd: process.cwd(), model: "gpt-test" }); },
    recover: function(controller, threadId, extra) {
      return adapter.createQuery(Object.assign({ cwd: process.cwd(), resumeSessionId: threadId || "saved-thread",
        abortController: controller,
        sessionMcpServer: { name: "clay-session-tools", command: process.execPath, args: ["unused-test-bridge.js"] },
      }, extra || {}));
    },
  };
}

async function completeFirstTurn(kit) {
  var handle = await kit.fresh();
  assert.equal(handle.pushMessage("First message"), true);
  await tick();
  kit.servers[0].complete("thread-1", "turn-1");
  await tick();
  return handle;
}

async function assertWaiting(kit) {
  kit.servers[0].recoveryTargetUnknown = true;
  var controller = new AbortController();
  var settled = false;
  var pending = kit.recover(controller).finally(function() { settled = true; });
  var cancelled = assert.rejects(pending, { name: 'AbortError' });
  await tick();
  assert.equal(settled, false, 'unsafe recovery retains admission rather than rejecting the message');
  controller.abort();
  await cancelled;
  kit.servers[0].recoveryTargetUnknown = false;
}

test("completed idle handles no longer block recovery and reject later pushes", async function(t) {
  var kit = fixture(t);
  var idle = await completeFirstTurn(kit);
  var iterator = idle[Symbol.asyncIterator]();
  var resultSeen = false;
  for (var i = 0; i < 4; i++) {
    var event = await iterator.next();
    if (event.value && event.value.yokeType === "result") { resultSeen = true; break; }
  }
  assert.equal(resultSeen, true);
  var recovery = await kit.recover(null, "thread-1");
  assert.equal(kit.servers[0].stopped, 1);
  assert.equal(kit.servers[0].handlers.length, 0, "retired input loop detached before process release");
  assert.equal(idle.pushMessage("Must be resumed through a new handle"), false);
  assert.equal(kit.servers[0].calls.some(function(call) { return call.method === "turn/interrupt"; }), false);
  recovery.pushMessage("Continue saved conversation");
  await tick();
  assert.equal(kit.servers[1].calls.filter(function(call) { return call.method === "thread/resume"; })[0].params.threadId, "thread-1");
  assert.equal(kit.servers[1].calls.filter(function(call) { return call.method === "turn/start"; }).length, 1);
});

test("a running sibling prevents retirement of every idle handle", async function(t) {
  var kit = fixture(t);
  var idle = await completeFirstTurn(kit);
  var active = await kit.fresh();
  active.pushMessage("Still running");
  await tick();
  await assertWaiting(kit);
  assert.equal(kit.servers[0].stopped, 0);
  assert.equal(kit.servers[0].handlers.length, 2);
  assert.equal(idle.pushMessage("Still usable"), true, "failed recovery cannot partially retire idle inputs");
});

test("an allocated handle awaiting its first message remains protected", async function(t) {
  var kit = fixture(t);
  var fresh = await kit.fresh();
  await assertWaiting(kit);
  assert.equal(fresh.pushMessage("Initial delivery"), true);
  assert.equal(kit.servers[0].stopped, 0);
});

test("queued messages stay protected between turns", async function(t) {
  var kit = fixture(t);
  var handle = await kit.fresh();
  handle.pushMessage("First");
  await tick();
  handle.pushMessage("Second");
  kit.servers[0].complete("thread-1", "turn-1");
  await assertWaiting(kit);
  await tick();
  var starts = kit.servers[0].calls.filter(function(call) { return call.method === "turn/start"; });
  assert.equal(starts.length, 2);
  assert.equal(starts[1].params.input[0].text, "Second");
  assert.equal(kit.servers[0].stopped, 0);
  kit.servers[0].complete("thread-1", "turn-2");
  await tick();
  await kit.recover(null, "thread-1");
  assert.equal(kit.servers[0].stopped, 1);
});

test("recovery never releases a runtime while Stop is awaiting interruption", async function(t) {
  var interrupt = deferred();
  var kit = fixture(t, { send: function(server, method) {
    if (method === "turn/interrupt") return interrupt.promise;
  } });
  var handle = await kit.fresh();
  handle.pushMessage("Running");
  await tick();
  var stopped = handle.abort();
  await assertWaiting(kit);
  assert.equal(kit.servers[0].stopped, 0);
  interrupt.resolve({});
  await stopped;
  await kit.recover();
  assert.equal(kit.servers[0].stopped, 1);
});

test("messages arriving during idle verification invalidate retirement even if they finish", async function(t) {
  var probe = deferred();
  var probing = false;
  var kit = fixture(t, { send: function(server, method) {
    if (probing && method === "thread/backgroundTerminals/list") return probe.promise;
  } });
  var handle = await completeFirstTurn(kit);
  probing = true;
  var controller = new AbortController();
  kit.servers[0].recoveryTargetUnknown = true;
  var rejected = assert.rejects(kit.recover(controller), { name: 'AbortError' });
  await tick();
  assert.equal(handle.pushMessage("Arrived during verification"), true);
  await tick();
  kit.servers[0].complete("thread-1", "turn-2");
  await tick();
  probe.resolve({ data: [], nextCursor: null });
  await tick();
  controller.abort();
  await rejected;
  assert.equal(kit.servers[0].stopped, 0);
  assert.equal(handle.pushMessage("Still usable"), true);
});

test("ordinary starts wait for recovery admission and attach to a replacement shared runtime", async function(t) {
  var probe = deferred();
  var probing = false;
  var kit = fixture(t, { send: function(server, method) {
    if (probing && method === "thread/backgroundTerminals/list") return probe.promise;
  } });
  var idle = await completeFirstTurn(kit);
  probing = true;
  var recovery = kit.recover(null, "thread-1");
  await tick();
  var admitted = false;
  var ordinary = kit.fresh().then(function(handle) { admitted = true; return handle; });
  await tick();
  assert.equal(admitted, false);
  probe.resolve({ data: [], nextCursor: null });
  await recovery;
  var next = await ordinary;
  assert.equal(idle.pushMessage("Cannot write to retired server"), false);
  assert.equal(next.pushMessage("New shared query"), true);
  await tick();
  assert.equal(kit.servers.length, 3);
  assert.equal(kit.servers[0].stopped, 1);
  assert.equal(kit.servers[2].calls.filter(function(call) { return call.method === "turn/start"; }).length, 1);
});

test("cancelled recovery does not retire idle inputs or start an isolated process", async function(t) {
  var probe = deferred();
  var probing = false;
  var kit = fixture(t, { send: function(server, method) {
    if (probing && method === "thread/backgroundTerminals/list") return probe.promise;
  } });
  var handle = await completeFirstTurn(kit);
  probing = true;
  var controller = new AbortController();
  var cancelled = assert.rejects(kit.recover(controller, "thread-1"), { name: "AbortError" });
  await tick();
  controller.abort();
  probe.resolve({ data: [], nextCursor: null });
  await cancelled;
  assert.equal(kit.servers.length, 1);
  assert.equal(kit.servers[0].stopped, 0);
  assert.equal(handle.pushMessage("Still usable"), true);
});

test("background work and unverified background state prevent retirement", async function(t) {
  var results = [
    { data: [{ processId: "background-1" }], nextCursor: null },
    { terminals: [{ id: "background-1", status: "running" }] },
    { data: [], nextCursor: "more" },
    {},
    new Error("Background inspection unavailable"),
  ];
  for (var i = 0; i < results.length; i++) {
    await t.test("background case " + i, async function(st) {
      var result = results[i];
      var inspecting = false;
      var kit = fixture(st, { send: function(server, method) {
        if (inspecting && method === "thread/backgroundTerminals/list") {
          return result instanceof Error ? Promise.reject(result) : Promise.resolve(result);
        }
      } });
      var idle = await completeFirstTurn(kit);
      inspecting = true;
      await assertWaiting(kit);
      assert.equal(kit.servers[0].stopped, 0);
      assert.equal(idle.pushMessage("Keep the conversation available"), true);
    });
  }
});

test("a live background terminal on the target thread is terminated so recovery proceeds", async function(t) {
  var terminalsLive = false;
  var kit = fixture(t, { send: function(server, method, params) {
    if (method === "thread/read" && params.threadId === "thread-1") {
      return Promise.resolve({ thread: { id: "thread-1", status: { type: "idle" } } });
    }
    if (method === "thread/backgroundTerminals/list" && params.threadId === "thread-1") {
      return Promise.resolve(terminalsLive
        ? { data: [{ itemId: "call-9", processId: "7", command: "npm run dev", cwd: "/tmp" }], nextCursor: null }
        : { data: [], nextCursor: null });
    }
    if (method === "thread/backgroundTerminals/terminate") {
      terminalsLive = false;
      return Promise.resolve({ terminated: true });
    }
  } });
  var idle = await completeFirstTurn(kit);
  terminalsLive = true;
  var repaired = [];
  var recovery = await kit.recover(null, "thread-1", { onRecoveryRepair: function(info) { repaired.push(info); } });
  var terminations = kit.servers[0].calls.filter(function(call) { return call.method === "thread/backgroundTerminals/terminate"; });
  assert.equal(terminations.length, 1);
  assert.deepEqual(terminations[0].params, { threadId: "thread-1", processId: "7" });
  assert.deepEqual(repaired, [{ terminatedBackgroundTerminals: 1, commands: ["npm run dev"] }]);
  assert.equal(idle.pushMessage("Must be resumed through a new handle"), false);
  recovery.pushMessage("Continue saved conversation");
  await tick();
  assert.equal(kit.servers[1].calls.filter(function(call) { return call.method === "thread/resume"; })[0].params.threadId, "thread-1");
});

test("background terminals of other conversations are never terminated", async function(t) {
  var kit = fixture(t, { send: function(server, method, params) {
    if (method === "thread/backgroundTerminals/list" && params.threadId === "thread-1") {
      return Promise.resolve({ data: [{ itemId: "call-9", processId: "7", command: "npm run dev", cwd: "/tmp" }], nextCursor: null });
    }
  } });
  var idle = await completeFirstTurn(kit);
  await assertWaiting(kit);
  assert.equal(kit.servers[0].calls.some(function(call) { return call.method === "thread/backgroundTerminals/terminate"; }), false,
    "a sibling conversation's background terminal must survive another conversation's recovery");
  assert.equal(kit.servers[0].stopped, 0);
  assert.equal(idle.pushMessage("Still usable"), true);
});

test("a refused termination keeps the conversation retained", async function(t) {
  var kit = fixture(t, { send: function(server, method, params) {
    if (method === "thread/read" && params.threadId === "thread-1") {
      return Promise.resolve({ thread: { id: "thread-1", status: { type: "idle" } } });
    }
    if (method === "thread/backgroundTerminals/list" && params.threadId === "thread-1") {
      return Promise.resolve({ data: [{ itemId: "call-9", processId: "7", command: "npm run dev", cwd: "/tmp" }], nextCursor: null });
    }
    if (method === "thread/backgroundTerminals/terminate") {
      return Promise.resolve({ terminated: false });
    }
  } });
  var idle = await completeFirstTurn(kit);
  var controller = new AbortController();
  var settled = false;
  var repaired = [];
  var pending = kit.recover(controller, "thread-1", { onRecoveryRepair: function(info) { repaired.push(info); } })
    .finally(function() { settled = true; });
  var cancelled = assert.rejects(pending, { name: "AbortError" });
  await tick();
  assert.equal(settled, false, "an unconfirmed termination retains admission rather than proceeding");
  controller.abort();
  await cancelled;
  assert.ok(kit.servers[0].calls.some(function(call) { return call.method === "thread/backgroundTerminals/terminate"; }));
  assert.deepEqual(repaired, [], "no repair is reported when nothing was terminated");
  assert.equal(kit.servers[0].stopped, 0);
  assert.equal(idle.pushMessage("Still usable"), true);
});

test("shutdown invalidates recovery and ordinary starts waiting on its handoff", async function(t) {
  var probe = deferred();
  var probing = false;
  var kit = fixture(t, { send: function(server, method) {
    if (probing && method === "thread/backgroundTerminals/list") return probe.promise;
  } });
  await completeFirstTurn(kit);
  probing = true;
  var recovery = assert.rejects(kit.recover(null, "thread-1"), /shut/i);
  await tick();
  var ordinary = assert.rejects(kit.fresh(), /shut/i);
  var warmup = assert.rejects(kit.adapter.init({}), /shut/i);
  await kit.adapter.shutdown();
  probe.resolve({ data: [], nextCursor: null });
  await Promise.all([recovery, ordinary, warmup]);
  assert.equal(kit.servers.length, 1, "pending work cannot resurrect a shut down runtime");
});

test("cancelled ordinary admission cannot create a handle after recovery finishes", async function(t) {
  var probe = deferred();
  var probing = false;
  var kit = fixture(t, { send: function(server, method) {
    if (probing && method === "thread/backgroundTerminals/list") return probe.promise;
  } });
  await completeFirstTurn(kit);
  probing = true;
  var recovery = kit.recover(null, "thread-1");
  await tick();
  var controller = new AbortController();
  var cancelled = assert.rejects(kit.adapter.createQuery({ abortController: controller }), { name: "AbortError" });
  controller.abort();
  probe.resolve({ data: [], nextCursor: null });
  await recovery;
  await cancelled;
  assert.equal(kit.servers.length, 2, "only the recovery runtime was created");
});

test("SDK stream cleanup retires an idle session and its next message resumes once", async function(t) {
  var kit = fixture(t);
  var createSDKBridge = require("../lib/sdk-bridge").createSDKBridge;
  var session = { localId: 700, vendor: "codex", isProcessing: true,
    pendingAskUser: {}, pendingPermissions: {}, pendingElicitations: {}, history: [], messageUUIDs: [],
  };
  var recorded = [];
  var bridge = createSDKBridge({
    cwd: process.cwd(), adapter: kit.adapter, adapters: { codex: kit.adapter }, send: function() {},
    sessionManager: {
      sessions: new Map([[700, session]]), availableModels: [],
      saveSessionFile: function() {}, broadcastSessionList: function() {}, sendToSession: function() {},
      sendAndRecord: function(target, message) { recorded.push(message); },
    },
  });
  assert.equal(await bridge.startQuery(session, "First message"), true);
  await tick();
  kit.servers[0].complete("thread-1", "turn-1");
  await tick();
  assert.equal(session.isProcessing, false);
  assert.ok(session.queryInstance, "normal turn completion retains its input");
  var originalThread = session.cliSessionId;
  var recovery = await kit.recover(null, originalThread);
  recovery.close();
  await tick();
  assert.equal(session.queryInstance, null, "SDK finally clears the retired handle");
  assert.equal(session.abortController, null);
  assert.equal(session.cliSessionId, originalThread, "history identity is preserved");
  assert.equal(recorded.some(function(message) { return message.type === "error"; }), false);
  session.isProcessing = true;
  assert.equal(bridge.pushMessage(session, "Next message"), false, "caller must resume after retirement");
  assert.equal(await bridge.startQuery(session, "Next message"), true);
  await tick();
  var starts = kit.servers[2].calls.filter(function(call) { return call.method === "turn/start"; });
  assert.equal(starts.length, 1);
  assert.equal(starts[0].params.threadId, originalThread);
  assert.ok(starts[0].params.input[0].text.endsWith("Next message"));
});