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")); });