Files
AgentHub/server/__tests__/pty-run.test.js
T
nntrivi2001 8b8582f231 fix(runs): persist tmux-backed runs so a resumed session shows its own info
`dashboard_runs.mode` is NOT NULL and `recordRun` swallows insert errors by
design, so pty-run.js passing `mode: null` meant NO tmux-backed run was ever
persisted — the newest row in a live database dated from before pty-run.js
replaced run-spawner.js. `publicRun` reads cwd, model, prompt preview,
started_at and session_id off that row and the handle itself is recomputed
from tmux, so the runs modal rendered a live row with no cwd, no model, no
prompt and a start date of Jan 1 1970. The same missing row is why a resumed
session kept its separate `external` badge: the client dedupes an external
session against the session ids carried by run rows, and no run row ever
carried one.

- pty-run.js records `mode: "interactive"` — every run here is one pane.
- dashboard-runs.js still never fails a live run on a db error, but warns
  instead of swallowing silently; that silence hid this for six weeks.
- RunHistory renders `startedAt: 0` as "-" rather than dating a row with no
  known start time to the epoch.

Also closes the other half of the same lie: adopting a pane that is already
running an agent now throws ERUNLIVE (409 from both the lane start action and
POST /api/run) when the request carried a prompt or a resumeSessionId. That
argv cannot be typed over a live agent, so answering 200 reported a resume
that never spawned. A start asking for nothing but a pane still adopts.
2026-09-10 17:07:23 +07:00

426 lines
15 KiB
JavaScript

/**
* @file pty-run.test.js
* @description Unit tests for the tmux-backed run lifecycle. Injects a fake
* tmux exec implementation (via tmux.js's test seam) so no real tmux binary
* is invoked.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const { describe, it, beforeEach, before, 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(), "pty-run-test-"));
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
const tmux = require("../lib/tmux");
const pty = require("../lib/pty-run");
describe("pty-run", () => {
after(() => {
try {
fs.rmSync(TMP, { recursive: true, force: true, maxRetries: 5, retryDelay: 100 });
} catch {
/* best-effort */
}
});
beforeEach(() => {
tmux.__reset();
});
it("spawnRun creates a new tmux session named agenthub-lane-<id> when none exists", () => {
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
if (args[0] === "has-session") {
const e = new Error("no such session");
e.status = 1;
throw e;
}
return "";
});
const handle = pty.spawnRun({ laneId: 42, cwd: "/tmp/repo", model: "opus" });
assert.equal(handle.id, "agenthub-lane-42");
const newSessionCall = calls.find((c) => c[0] === "new-session");
assert.ok(newSessionCall, "expected a new-session call");
assert.deepEqual(newSessionCall.slice(0, 6), [
"new-session",
"-d",
"-s",
"agenthub-lane-42",
"-c",
"/tmp/repo",
]);
assert.ok(newSessionCall.includes("claude"));
assert.ok(newSessionCall.includes("--model"));
assert.ok(newSessionCall.includes("opus"));
});
it("spawnRun is a no-op (adopts) when the existing session's pane runs claude", () => {
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
if (args[0] === "display-message") return "claude\n";
return ""; // has-session succeeds → already running
});
const handle = pty.spawnRun({ laneId: 7, cwd: "/tmp/repo" });
assert.equal(handle.id, "agenthub-lane-7");
assert.ok(!calls.some((c) => c[0] === "new-session"), "must not create a duplicate session");
assert.ok(!calls.some((c) => c[0] === "send-keys"), "must not type over a live agent");
});
it("spawnRun types the argv into an existing session idling at a shell prompt", () => {
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
if (args[0] === "display-message") return "bash\n";
return ""; // has-session succeeds → session exists, pane is a shell
});
pty.spawnRun({ laneId: 8, cwd: "/tmp/repo", resumeSessionId: "abc12345" });
assert.ok(!calls.some((c) => c[0] === "new-session"), "must not create a duplicate session");
const literal = calls.find((c) => c[0] === "send-keys" && c[3] === "-l");
assert.ok(literal, "expected a literal send-keys with the command line");
assert.match(literal[4], /^AGENTHUB_LANE_ID='8' 'claude' .*'--resume' 'abc12345'$/);
assert.ok(
calls.some((c) => c[0] === "send-keys" && c[3] === "Enter"),
"expected the command to be submitted"
);
});
it("spawnRun single-quotes an initial prompt typed into an existing shell pane", () => {
let literal = null;
tmux.__setExecImpl((args) => {
if (args[0] === "display-message") return "zsh\n";
if (args[0] === "send-keys" && args[3] === "-l") literal = args[4];
return "";
});
pty.spawnRun({ laneId: 9, cwd: "/tmp/repo", initialPrompt: "don't; rm -rf /" });
assert.ok(literal.endsWith(`'don'\\''t; rm -rf /'`), literal);
});
it("spawnRun puts AGENTHUB_LANE_ID in the new session's environment", () => {
let newSessionArgv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
const e = new Error("gone");
e.status = 1;
throw e;
}
if (args[0] === "new-session") newSessionArgv = args;
return "";
});
pty.spawnRun({ laneId: 33, cwd: "/tmp/repo", tool: "codex" });
const at = newSessionArgv.indexOf("-e");
assert.ok(at > -1, "expected a tmux -e environment flag");
assert.equal(newSessionArgv[at + 1], "AGENTHUB_LANE_ID=33");
// Must stay ahead of the `--` that starts the pane's own argv.
assert.ok(at < newSessionArgv.indexOf("--"));
});
it("spawnRun prefixes AGENTHUB_LANE_ID when typing into an existing shell pane", () => {
let literal = null;
tmux.__setExecImpl((args) => {
if (args[0] === "display-message") return "bash\n";
if (args[0] === "send-keys" && args[3] === "-l") literal = args[4];
return "";
});
pty.spawnRun({ laneId: 12, cwd: "/tmp/repo", tool: "codex" });
// An assignment prefix on the same line: tmux's -e only applies at session
// creation, and a live shell takes its env from the command line.
assert.match(literal, /^AGENTHUB_LANE_ID='12' 'codex'/);
});
it("spawnRun with resumeSessionId passes --resume in argv", () => {
let newSessionArgv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
const e = new Error("gone");
e.status = 1;
throw e;
}
if (args[0] === "new-session") newSessionArgv = args;
return "";
});
pty.spawnRun({ laneId: 1, cwd: "/tmp/repo", resumeSessionId: "abc12345" });
assert.ok(newSessionArgv.includes("--resume"));
assert.ok(newSessionArgv.includes("abc12345"));
});
it("spawnRun offers bypass in the pane's shift+tab cycle without starting in it", () => {
let newSessionArgv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
const e = new Error("gone");
e.status = 1;
throw e;
}
if (args[0] === "new-session") newSessionArgv = args;
return "";
});
pty.spawnRun({ laneId: 4, cwd: "/tmp/repo" });
assert.ok(newSessionArgv.includes("--allow-dangerously-skip-permissions"));
// ...as an OPTION only: the starting mode stays the requested one, and
// `--dangerously-skip-permissions` (which would enable it) is never passed.
assert.ok(!newSessionArgv.includes("--dangerously-skip-permissions"));
const modeAt = newSessionArgv.indexOf("--permission-mode");
assert.equal(newSessionArgv[modeAt + 1], "acceptEdits");
});
it("spawnRun appends a positional initial prompt after argv flags", () => {
let newSessionArgv = null;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
const e = new Error("gone");
e.status = 1;
throw e;
}
if (args[0] === "new-session") newSessionArgv = args;
return "";
});
pty.spawnRun({ laneId: 3, cwd: "/tmp/repo", initialPrompt: "fix the bug" });
assert.equal(newSessionArgv[newSessionArgv.length - 1], "fix the bug");
});
it("killRun calls tmux kill-session with the run id", () => {
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
return "";
});
assert.equal(pty.killRun("agenthub-lane-5"), true);
assert.ok(calls.some((c) => c[0] === "kill-session" && c[2] === "agenthub-lane-5"));
});
it("listRuns reflects live tmux-session state, not cached memory", () => {
tmux.__setExecImpl((args) => {
if (args[0] === "list-sessions") return "agenthub-lane-1\nagenthub-lane-2\n";
return "";
});
const first = pty.listRuns();
assert.deepEqual(first.map((r) => r.id).sort(), ["agenthub-lane-1", "agenthub-lane-2"]);
// Session killed out-of-band (not through killRun) — next list() call
// must self-correct, proving state is computed, not stored.
tmux.__setExecImpl((args) => {
if (args[0] === "list-sessions") return "agenthub-lane-1\n";
return "";
});
const second = pty.listRuns();
assert.deepEqual(
second.map((r) => r.id),
["agenthub-lane-1"]
);
});
it("laneIdFromRunId parses the numeric lane id back out", () => {
assert.equal(pty.laneIdFromRunId("agenthub-lane-42"), 42);
assert.equal(pty.laneIdFromRunId("not-a-run-id"), null);
});
it("getRun returns the recorded prompt for a live run", () => {
let sessionExists = false;
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
if (sessionExists) {
return ""; // session exists
}
// Session doesn't exist yet
const e = new Error("no such session");
e.status = 1;
throw e;
}
if (args[0] === "new-session") {
sessionExists = true; // Mark session as created
}
return "";
});
const handle = pty.spawnRun({
laneId: 99,
cwd: "/tmp/test",
initialPrompt: "the live prompt",
});
const retrieved = pty.getRun(handle.id);
assert.equal(retrieved.id, "agenthub-lane-99");
assert.equal(retrieved.promptPreview, "the live prompt");
assert.equal(retrieved.status, "running");
});
function captureNewSession(fn) {
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
if (args[0] === "has-session") {
const e = new Error("no such session");
e.status = 1;
throw e;
}
return "";
});
fn();
return calls.find((c) => c[0] === "new-session");
}
/** The pane's own command line — everything tmux passes after the `--`. */
function paneArgv(newSessionCall) {
return newSessionCall.slice(newSessionCall.indexOf("--") + 1);
}
it("spawnRun builds a codex argv for a codex lane", () => {
const call = captureNewSession(() =>
pty.spawnRun({
laneId: 7,
cwd: "/tmp/repo",
tool: "codex",
model: "gpt-5.1-codex",
sandbox: "workspace-write",
approval: "on-request",
effort: "high",
initialPrompt: "hello",
})
);
const argv = paneArgv(call);
assert.deepEqual(argv, [
"codex",
"-m",
"gpt-5.1-codex",
"-s",
"workspace-write",
"-a",
"on-request",
"-c",
"model_reasoning_effort=high",
"hello",
]);
});
it("spawnRun puts codex resume first, as a subcommand", () => {
const withId = captureNewSession(() =>
pty.spawnRun({
laneId: 8,
cwd: "/tmp/repo",
tool: "codex",
resumeSessionId: "0199aaaa-bbbb-cccc-dddd-eeeeffff0000",
})
);
assert.deepEqual(paneArgv(withId).slice(0, 3), [
"codex",
"resume",
"0199aaaa-bbbb-cccc-dddd-eeeeffff0000",
]);
tmux.__reset();
const last = captureNewSession(() =>
pty.spawnRun({ laneId: 9, cwd: "/tmp/repo", tool: "codex", resumeLast: true })
);
assert.deepEqual(paneArgv(last).slice(0, 3), ["codex", "resume", "--last"]);
});
it("spawnRun rejects a codex sandbox/approval outside the allowlist", () => {
tmux.__setExecImpl(() => "");
assert.throws(
() => pty.spawnRun({ laneId: 10, cwd: "/tmp/repo", tool: "codex", sandbox: "rm -rf" }),
(err) => err.code === "EBADSANDBOX"
);
assert.throws(
() => pty.spawnRun({ laneId: 10, cwd: "/tmp/repo", tool: "codex", approval: "always" }),
(err) => err.code === "EBADAPPROVAL"
);
});
it("spawnRun never emits codex bypass flags", () => {
const call = captureNewSession(() =>
pty.spawnRun({ laneId: 11, cwd: "/tmp/repo", tool: "codex", sandbox: "danger-full-access" })
);
assert.ok(!call.some((a) => String(a).startsWith("--dangerously-bypass")));
});
// `dashboard_runs.mode` is NOT NULL and recordRun swallows insert errors, so a
// null there persisted nothing at all while every call still looked fine: the
// runs modal rendered a row with no cwd, model, prompt or start time, and a
// resumed session kept its separate `external` row because no run row ever
// carried its session id. These assert the row actually lands.
it("refuses to swallow a resume or a prompt when the pane already runs an agent", () => {
// Adopting silently here answered 200 while spawning no `--resume` at all:
// the dashboard reported a resumed session that never happened.
tmux.__setExecImpl((args) => {
if (args[0] === "display-message") return "claude\n";
return ""; // has-session succeeds → pane is busy with a live agent
});
assert.throws(
() => pty.spawnRun({ laneId: 51, cwd: "/tmp/repo", resumeSessionId: "abc12345" }),
(err) => err.code === "ERUNLIVE"
);
assert.throws(
() => pty.spawnRun({ laneId: 51, cwd: "/tmp/repo", initialPrompt: "do the thing" }),
(err) => err.code === "ERUNLIVE"
);
});
it("still adopts a busy pane when the request asked for nothing but a pane", () => {
const calls = [];
tmux.__setExecImpl((args) => {
calls.push(args);
if (args[0] === "display-message") return "claude\n";
return "";
});
const handle = pty.spawnRun({ laneId: 52, cwd: "/tmp/repo" });
assert.equal(handle.id, "agenthub-lane-52");
assert.ok(!calls.some((c) => c[0] === "new-session"));
assert.ok(!calls.some((c) => c[0] === "send-keys"));
});
describe("run persistence", () => {
const dashboardRuns = require("../lib/dashboard-runs");
it("spawnRun persists the run so the runs list has cwd, model and prompt", () => {
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
const e = new Error("gone");
e.status = 1;
throw e;
}
return "";
});
pty.spawnRun({
laneId: 71,
cwd: "/tmp/persisted",
model: "claude-opus-5",
initialPrompt: "ship it",
});
const row = dashboardRuns.getRun("agenthub-lane-71");
assert.ok(row, "expected a dashboard_runs row for the spawned run");
assert.equal(row.cwd, "/tmp/persisted");
assert.equal(row.model, "claude-opus-5");
assert.equal(row.prompt_preview, "ship it");
assert.equal(row.lane_id, 71);
assert.ok(row.started_at, "started_at must be set, or the UI dates the row to the epoch");
assert.ok(row.mode, "mode is NOT NULL — a null here silently drops the whole insert");
});
it("a resumed run carries the session id, which is what dedupes the external row", () => {
tmux.__setExecImpl((args) => {
if (args[0] === "has-session") {
const e = new Error("gone");
e.status = 1;
throw e;
}
return "";
});
const sessionId = "c2055094-09cf-4735-9712-09e5cdf7023f";
const handle = pty.spawnRun({
laneId: 72,
cwd: "/tmp/resumed",
resumeSessionId: sessionId,
});
// The UI reads sessionId off the run list; an external session whose id is
// already on a run row is not repeated as its own `external` row.
assert.equal(handle.sessionId, sessionId);
assert.equal(dashboardRuns.getRun("agenthub-lane-72").resume_session_id, sessionId);
});
});
});