var fs = require("fs"); var os = require("os"); var path = require("path"); // Isolate every config read before any Clay module loads. var fixtureHome = fs.mkdtempSync(path.join(os.tmpdir(), "clay-replay-reconnect-home-")); process.env.CLAY_HOME = fixtureHome; var test = require("node:test"); var assert = require("node:assert/strict"); var childProcess = require("child_process"); var captureReconnect = require("./fixtures/history-replay-reconnect-sequence").captureReconnect; test.after(function () { fs.rmSync(fixtureHome, { recursive: true, force: true }); }); function types(messages) { return messages.map(function (msg) { return msg.type; }); } test("reconnect sends authoritative processing state before replay, then live state after history_done", function () { [["running", true], ["tool", true], ["idle", false]].forEach(function (pair) { var sent = captureReconnect({ shape: pair[0], size: 5 }); var order = types(sent); var switched = sent[order.indexOf("session_switched")]; assert.equal(switched.isProcessing, pair[1], pair[0] + " session_switched carries isProcessing"); assert.ok(order.indexOf("session_switched") < order.indexOf("history_meta")); assert.ok(order.indexOf("history_meta") < order.indexOf("history_done")); if (pair[1]) assert.ok(order.indexOf("history_done") < order.indexOf("status"), "status arrives only after replay"); else assert.equal(order.indexOf("status"), -1); if (pair[0] === "tool") assert.ok(order.indexOf("history_done") < order.indexOf("permission_request_pending")); }); }); test("initial replay and reconnect target 300 entries while retaining complete turns and 100-entry pages", function () { var createSessionManager = require("../lib/sessions").createSessionManager; [300, 500, 650].forEach(function (total) { var history = Array.from({ length: total }, function (_, index) { return index % 5 === 0 ? { type: "user_message", text: "Turn " + index } : { type: "delta", text: "Saved answer " + index }; }); var dir = fs.mkdtempSync(path.join(os.tmpdir(), "clay-history-window-")); try { var sent = []; var sm = createSessionManager({ cwd: path.join(dir, "project"), sessionsBase: path.join(dir, "sessions"), cliSessionsDir: path.join(dir, "cli"), send: function (message) { sent.push(message); } }); assert.equal(sm.INITIAL_HISTORY_SIZE, 300); assert.equal(sm.HISTORY_PAGE_SIZE, 100); sent.length = 0; sm.replayHistory({ history: history, vendor: "claude", localId: 101 }); assert.equal(sent.find(function (message) { return message.type === "history_meta"; }).from, Math.max(0, total - 300)); assert.equal(sent.filter(function (message) { return typeof message._historyIndex === "number"; }).length, 300); var reconnect = captureReconnect({ history: history, processing: false }); assert.equal(reconnect.find(function (message) { return message.type === "history_meta"; }).from, Math.max(0, total - 300)); assert.equal(sm.findTurnBoundary(history, Math.min(total - 1, 352)), Math.min(350, Math.floor((total - 1) / 5) * 5)); history = [{ type: "user_message", text: "One complete long turn" }].concat(history.map(function () { return { type: "delta", text: "Long answer" }; })); sent.length = 0; sm.replayHistory({ history: history, vendor: "claude", localId: 101 }); assert.equal(sent[0].from, 0, "a long turn retains the original unbounded boundary behaviour"); } finally { fs.rmSync(dir, { recursive: true, force: true }); } }); }); function loadPlaywright() { try { return require("playwright"); } catch (e) { return null; } } function startFixtureServer() { var server = childProcess.spawn(process.execPath, [path.join(__dirname, "fixtures/history-replay-browser-server.js")]); return new Promise(function (resolve, reject) { server.once("error", reject); server.stdout.once("data", function (data) { resolve({ url: String(data).trim(), server: server }); }); }); } test("Chromium restores real reconnect sequences atomically and continues live output", { timeout: 180000 }, async function (t) { var playwright = loadPlaywright(); if (!playwright) { t.skip("playwright is not installed"); return; } var browser; try { browser = await playwright.chromium.launch(); } catch (e) { t.skip("Chromium is not available: " + e.message); return; } var fixture = await startFixtureServer(); async function run(capture, extra) { var page = await browser.newPage(); try { await page.goto(fixture.url); await page.waitForFunction("window.fixtureReady === true"); return await page.evaluate(async function (args) { var result = await window.runReconnectFixture(args.messages); if (args.extra === "stale") result.staleSpinning = !!document.querySelector('[data-tool-id="stale-tool"]:not(.done)'); if (args.extra === "switch") result.switchPath = await window.runHistoryFixture(args.vendor); return result; }, { messages: captureReconnect(capture), extra: extra || null, vendor: capture.vendor }); } finally { await page.close(); } } try { for (var vendor of ["claude", "codex"]) { var running = await run({ shape: "running", size: 3000, vendor: vendor }, "switch"); assert.equal(running.partialPaints, 0, vendor + ": no history painted before history_done"); assert.equal(running.savedToolText, true); assert.equal(running.thinkingItems, 1); assert.equal(running.openAnswer, true, vendor + ": the active answer stays open after reconnect"); assert.equal(running.liveSameBubble, true, vendor + ": live text continues the restored answer"); assert.equal(running.assistantBubblesAfterLive, 1); assert.equal(running.replayingHistory, false); assert.deepEqual(running.errors, []); assert.equal(running.switchPath.partialPaints, 0, vendor + ": switch_session path still restores atomically"); var tool = await run({ shape: "tool", size: 3000, vendor: vendor }); assert.equal(tool.partialPaints, 0); assert.equal(tool.toolsDone["active-tool"], false, vendor + ": the running tool is not marked done"); assert.equal(tool.toolsDone["old-tool"], true); assert.equal(tool.permissionActionable, true, vendor + ": the pending permission stays actionable"); assert.deepEqual(tool.errors, []); var idle = await run({ shape: "idle", size: 3000, vendor: vendor }); assert.equal(idle.partialPaints, 0); assert.equal(idle.sessionIsProcessing, false); assert.equal(idle.openAnswer, false, vendor + ": a finished turn is finalized"); assert.equal(idle.liveSameBubble, false); assert.deepEqual(idle.errors, []); } // An earlier turn interrupted before its tool finished (no result or // done recorded) must not leave a spinner in a restored running session. var ts = 1700000000000; var stale = await run({ processing: true, history: [ { type: "user_message", text: "first", _ts: ts }, { type: "tool_start", id: "stale-tool", name: "Bash", _ts: ts }, { type: "tool_executing", id: "stale-tool", name: "Bash", input: { command: "sleep 1" }, _ts: ts }, { type: "user_message", text: "second", _ts: ts }, { type: "delta", text: "Working on it", _ts: ts }, ] }, "stale"); assert.equal(stale.toolsDone["stale-tool"], true); assert.equal(stale.staleSpinning, false); assert.equal(stale.openAnswer, true, "the current answer still continues"); // Question answers, plan feedback and Driver delegations are recorded mid-turn; tools // started before them may still be running and must stay active. var midTurn = [ ["askUserAnswer", { type: "user_message", text: "Yes", askUserAnswer: true, _ts: ts }], ["planFeedback", { type: "user_message", text: "Use smaller steps", planFeedback: true, _ts: ts }], ["delegated", { type: "user_message", text: "Run the task", delegated: true, delegatedBy: 7, delegatedByTitle: "Driver", _ts: ts }], ]; for (var entry of midTurn) { var injected = await run({ processing: true, history: [ { type: "user_message", text: "start", _ts: ts }, { type: "tool_start", id: "running-tool", name: "Bash", _ts: ts }, { type: "tool_executing", id: "running-tool", name: "Bash", input: { command: "npm test" }, _ts: ts }, entry[1], ] }); assert.equal(injected.toolsDone["running-tool"], false, entry[0] + " keeps the running tool active"); assert.deepEqual(injected.errors, []); } } finally { await browser.close(); fixture.server.kill(); } });