686b80bd99
Closes #26, #25. `runs` is the denominator every pass rate on the Workflows page is read against, and any backwards move opened a new one. Stage detection bounces by design — one feature reads `implement → tests → implement` as the session edits, runs the suite and edits again — so a single feature was recorded as five or six runs, and a lane in constant use reported a handful of passes spread across dozens of phantom runs. A backwards move now opens a run only when it is DECLARED; a backwards detection continues the run in flight. Reaching the final node still closes the run for either, since work after `done` is new work however it was observed. Replayed over this repo's own lane the rule takes 26 recorded runs down to the 5 features that actually shipped through it. The panel itself is unchanged: passes are still declarations only. `/reload-plugins` and `/reload-skills` were typed back to back. Claude Code opens its slash-command menu the moment `/` is typed and the Enter that follows lands on that menu, so the second command was appended to the line still in the composer and the pane submitted `/reload-plugins/reload-skills` — which Claude Code runs as `/reload-plugins` with an argument, never reloading skills, and leaving a stray prompt turn in the scrollback. Reproduced and fixed against a live pane: SETTLE_MS (1s) between the two sends, which makes `reloadOpenSessions` async rather than block the event loop, and the same pause in the detached update-all worker.
186 lines
7.1 KiB
JavaScript
186 lines
7.1 KiB
JavaScript
/**
|
|
* @file Unit tests for typing `/reload-plugins` / `/reload-skills` into the
|
|
* live Claude Code lane panes. Uses tmux.js's exec seam, so no real tmux binary
|
|
* is invoked and no real session is ever typed into.
|
|
*
|
|
* The behaviours worth locking down are the ones that would silently do the
|
|
* wrong thing: sending to a pane sitting at a shell prompt (the slash command
|
|
* would run as a shell command instead), forwarding a caller-supplied string
|
|
* into a REPL, and letting one dead pane fail the whole batch. The `tool ===
|
|
* "claude"` filter is not covered here — it reads a dashboard-runs DB row,
|
|
* which these stubs do not populate.
|
|
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
|
*/
|
|
|
|
const { describe, it, beforeEach, after } = require("node:test");
|
|
const assert = require("node:assert/strict");
|
|
const path = require("node:path");
|
|
const fs = require("node:fs");
|
|
const os = require("node:os");
|
|
|
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "cc-reload-test-"));
|
|
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
|
|
|
const tmux = require("../lib/tmux");
|
|
const { reloadOpenSessions, reloadableLanes } = require("../lib/cc-reload");
|
|
|
|
after(() => {
|
|
try {
|
|
fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
|
|
} catch {
|
|
/* best effort */
|
|
}
|
|
});
|
|
|
|
/**
|
|
* Stub tmux so `listRuns()` sees `sessions` and each pane reports the command
|
|
* given in `panes` (defaulting to a live `claude`).
|
|
* @returns {any[][]} the recorded argv arrays
|
|
*/
|
|
function stubTmux(sessions, panes = {}) {
|
|
const calls = [];
|
|
tmux.__setExecImpl((args) => {
|
|
calls.push(args);
|
|
if (args[0] === "list-sessions") return sessions.join("\n");
|
|
if (args[0] === "has-session") {
|
|
if (sessions.includes(args[2])) return "";
|
|
const e = new Error("no such session");
|
|
e.status = 1;
|
|
throw e;
|
|
}
|
|
if (args[0] === "display-message") {
|
|
const name = args[3];
|
|
const cmd = panes[name] ?? "claude";
|
|
if (cmd === null) {
|
|
const e = new Error("pane gone");
|
|
e.status = 1;
|
|
throw e;
|
|
}
|
|
return `${cmd}\n`;
|
|
}
|
|
if (args[0] === "send-keys") {
|
|
if (panes[args[2]] === "__send-fails__") throw new Error("pane died mid-send");
|
|
return "";
|
|
}
|
|
return "";
|
|
});
|
|
return calls;
|
|
}
|
|
|
|
describe("reloadableLanes", () => {
|
|
beforeEach(() => tmux.__reset());
|
|
|
|
it("lists a lane whose pane is running claude", () => {
|
|
stubTmux(["agenthub-lane-1"]);
|
|
const lanes = reloadableLanes();
|
|
assert.equal(lanes.length, 1);
|
|
assert.equal(lanes[0].id, "agenthub-lane-1");
|
|
assert.equal(lanes[0].laneId, 1);
|
|
});
|
|
|
|
it("skips a pane sitting at a shell prompt", () => {
|
|
// The claude process there has exited, so `/reload-plugins` would be typed
|
|
// at a bash prompt and run as a shell command instead.
|
|
stubTmux(["agenthub-lane-1"], { "agenthub-lane-1": "bash" });
|
|
assert.deepEqual(reloadableLanes(), []);
|
|
});
|
|
|
|
it("skips every shell name, not just bash", () => {
|
|
stubTmux(["agenthub-lane-1", "agenthub-lane-2", "agenthub-lane-3"], {
|
|
"agenthub-lane-1": "zsh",
|
|
"agenthub-lane-2": "fish",
|
|
"agenthub-lane-3": "dash",
|
|
});
|
|
assert.deepEqual(reloadableLanes(), []);
|
|
});
|
|
|
|
it("returns nothing when no lane sessions exist at all", () => {
|
|
stubTmux([]);
|
|
assert.deepEqual(reloadableLanes(), []);
|
|
});
|
|
});
|
|
|
|
describe("reloadOpenSessions", () => {
|
|
beforeEach(() => tmux.__reset());
|
|
|
|
it("types both reload commands, plugins before skills", async () => {
|
|
// Order matters: a skill shipped BY a plugin only exists on disk once the
|
|
// plugin itself has been reloaded.
|
|
const calls = stubTmux(["agenthub-lane-7"]);
|
|
const out = await reloadOpenSessions({ settleMs: 0 });
|
|
|
|
assert.deepEqual(out.commands, ["/reload-plugins", "/reload-skills"]);
|
|
assert.equal(out.lanes_total, 1);
|
|
assert.deepEqual(out.lanes[0].sent, ["/reload-plugins", "/reload-skills"]);
|
|
|
|
const typed = calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").map((c) => c[4]);
|
|
assert.deepEqual(typed, ["/reload-plugins", "/reload-skills"]);
|
|
// Each command must be submitted, or it just sits in the input box.
|
|
assert.equal(calls.filter((c) => c[0] === "send-keys" && c[3] === "Enter").length, 2);
|
|
});
|
|
|
|
it("waits between the two commands so they cannot share one composer line", async () => {
|
|
// Without the pause the first Enter lands on Claude Code's slash-command
|
|
// menu, the second command is appended to the line still in the composer,
|
|
// and the pane submits `/reload-plugins/reload-skills` as a prompt — the
|
|
// reported bug. Timing is the behaviour, so timing is what is asserted.
|
|
const calls = stubTmux(["agenthub-lane-1"]);
|
|
const at = [];
|
|
const typed = () => calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").length;
|
|
const before = typed();
|
|
const started = Date.now();
|
|
const done = reloadOpenSessions({ settleMs: 40 });
|
|
at.push(typed() - before);
|
|
await done;
|
|
|
|
assert.equal(at[0], 1, "only the first command is typed before the wait");
|
|
assert.ok(Date.now() - started >= 35, "the second command waited for the first to submit");
|
|
});
|
|
|
|
it("honours a narrowed kinds list", async () => {
|
|
const calls = stubTmux(["agenthub-lane-1"]);
|
|
const out = await reloadOpenSessions({ kinds: ["skills"], settleMs: 0 });
|
|
assert.deepEqual(out.commands, ["/reload-skills"]);
|
|
const typed = calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").map((c) => c[4]);
|
|
assert.deepEqual(typed, ["/reload-skills"]);
|
|
});
|
|
|
|
it("ignores an unknown kind rather than typing it into a REPL", async () => {
|
|
// The command set is fixed in the module; a pane's stdin is not a place to
|
|
// forward caller-supplied strings.
|
|
const calls = stubTmux(["agenthub-lane-1"]);
|
|
const out = await reloadOpenSessions({ kinds: ["plugins", "rm -rf /"], settleMs: 0 });
|
|
assert.deepEqual(out.commands, ["/reload-plugins"]);
|
|
const typed = calls.filter((c) => c[0] === "send-keys" && c[3] === "-l").map((c) => c[4]);
|
|
assert.deepEqual(typed, ["/reload-plugins"]);
|
|
});
|
|
|
|
it("types into several lanes in one call", async () => {
|
|
stubTmux(["agenthub-lane-1", "agenthub-lane-2"]);
|
|
const out = await reloadOpenSessions({ settleMs: 0 });
|
|
assert.equal(out.lanes_total, 2);
|
|
assert.deepEqual(
|
|
out.lanes.map((l) => l.laneId).sort((a, b) => a - b),
|
|
[1, 2]
|
|
);
|
|
});
|
|
|
|
it("records a pane that dies mid-send without failing the others", async () => {
|
|
stubTmux(["agenthub-lane-1", "agenthub-lane-2"], { "agenthub-lane-1": "__send-fails__" });
|
|
const out = await reloadOpenSessions({ settleMs: 0 });
|
|
const failed = out.lanes.find((l) => l.id === "agenthub-lane-1");
|
|
const ok = out.lanes.find((l) => l.id === "agenthub-lane-2");
|
|
assert.match(failed.error, /died mid-send/);
|
|
assert.deepEqual(failed.sent, [], "nothing was delivered to the dead pane");
|
|
assert.deepEqual(ok.sent, ["/reload-plugins", "/reload-skills"]);
|
|
assert.equal(ok.error, undefined);
|
|
});
|
|
|
|
it("reports an empty result instead of throwing when nothing is open", async () => {
|
|
stubTmux([]);
|
|
const out = await reloadOpenSessions({ settleMs: 0 });
|
|
assert.equal(out.lanes_total, 0);
|
|
assert.deepEqual(out.lanes, []);
|
|
});
|
|
});
|