feat(ui): a Claude-Desktop-style home for every workspace
/workspace?lane=<id> opens on the workspace's home (WorkspaceHome): a prompt box whose Start opens a new session in the first free pane slot, Recents (a live session attaches its pane, any other opens its transcript, nothing resumes), and Instructions / Memory / Context / Folder. Instructions live in lanes.instructions, extra folders and uploaded files in the new lane_sources table (copies under <data dir>/workspace-context/<lane id>/); spawnRun reads them from the lane's rows and hands Claude --add-dir + an appended system prompt file, Codex --add-dir + developer_instructions. Memory is read-only. Every dashboard pane carries AGENTHUB_RUN_ID; hooks forward it and the run records which session the pane holds (a Claude pane follows /clear and /resume but not a nested claude -p), so the sidebar and notifications open the right pane (&run=). Settings loses its Projects group; Worktrees now decides which worktrees Add folder suggests.
This commit is contained in:
@@ -0,0 +1,360 @@
|
||||
/**
|
||||
* @file Tests for a workspace's own context (server/lib/lane-sources.js and
|
||||
* routes/lane-sources.js): folder validation (exists, a directory, not the
|
||||
* workspace's own folder, not the OS home, no duplicate), uploaded files stored
|
||||
* under AgentHub's data dir with a name it chooses, removal deleting only
|
||||
* AgentHub's own copies, instructions capped and cleared, the read-only
|
||||
* memory, what a pane is started with (`launchExtras` → claude/codex argv,
|
||||
* nothing for other tools), cleanup on delete, and same-origin writes only.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const http = require("node:http");
|
||||
|
||||
const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "lane-sources-")));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
|
||||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp } = require("../index");
|
||||
const lanesLib = require("../lib/lanes");
|
||||
const src = require("../lib/lane-sources");
|
||||
const tmux = require("../lib/tmux");
|
||||
const pty = require("../lib/pty-run");
|
||||
const { encodeCwd } = require("../lib/claude-home");
|
||||
|
||||
const ws = path.join(ROOT, "ws");
|
||||
const extra = path.join(ROOT, "extra");
|
||||
let lane;
|
||||
let codexLane;
|
||||
let otherLane;
|
||||
let server;
|
||||
let base;
|
||||
|
||||
function request(method, url, { body, headers = {} } = {}) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const data = Buffer.isBuffer(body) ? body : body ? Buffer.from(JSON.stringify(body)) : null;
|
||||
const req = http.request(
|
||||
`${base}${url}`,
|
||||
{
|
||||
method,
|
||||
headers: {
|
||||
...(data && !Buffer.isBuffer(body) ? { "content-type": "application/json" } : {}),
|
||||
...headers,
|
||||
},
|
||||
},
|
||||
(res) => {
|
||||
let text = "";
|
||||
res.on("data", (c) => (text += c));
|
||||
res.on("end", () =>
|
||||
resolve({ status: res.statusCode, body: text ? JSON.parse(text) : null })
|
||||
);
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
if (data) req.write(data);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
for (const d of [ws, extra, process.env.CLAUDE_HOME]) fs.mkdirSync(d, { recursive: true });
|
||||
lane = lanesLib.createLane({ cwd: ws, kind: "adopted", tool: "claude" });
|
||||
codexLane = lanesLib.createLane({ cwd: extra, kind: "adopted", tool: "codex" });
|
||||
otherLane = lanesLib.createLane({ cwd: path.join(ROOT), kind: "adopted", tool: "claude" });
|
||||
server = http.createServer(createApp());
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
base = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
server?.close();
|
||||
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("lane-sources folders", () => {
|
||||
it("adds an existing folder by its real path, once", () => {
|
||||
const row = src.addFolder(lane.id, extra + "/");
|
||||
assert.equal(row.path, extra);
|
||||
assert.equal(row.name, "extra");
|
||||
assert.throws(
|
||||
() => src.addFolder(lane.id, extra),
|
||||
(e) => e.code === "EDUP"
|
||||
);
|
||||
});
|
||||
|
||||
it("refuses the workspace's own folder, a missing one, a file, a relative path and the home", () => {
|
||||
assert.throws(
|
||||
() => src.addFolder(lane.id, ws),
|
||||
(e) => e.code === "EDUP"
|
||||
);
|
||||
assert.throws(
|
||||
() => src.addFolder(lane.id, path.join(ROOT, "nope")),
|
||||
(e) => e.code === "EBADPATH"
|
||||
);
|
||||
const f = path.join(ROOT, "a-file");
|
||||
fs.writeFileSync(f, "x");
|
||||
assert.throws(
|
||||
() => src.addFolder(lane.id, f),
|
||||
(e) => e.code === "EBADPATH"
|
||||
);
|
||||
assert.throws(
|
||||
() => src.addFolder(lane.id, "relative/dir"),
|
||||
(e) => e.code === "EBADPATH"
|
||||
);
|
||||
assert.throws(
|
||||
() => src.addFolder(lane.id, os.homedir()),
|
||||
(e) => e.code === "EBADHOME"
|
||||
);
|
||||
assert.throws(
|
||||
() => src.addFolder(lane.id, "/"),
|
||||
(e) => e.code === "EBADHOME"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lane-sources files", () => {
|
||||
it("stores an upload under the data dir with a safe name it chooses", () => {
|
||||
const row = src.addFile(lane.id, "../../etc/pass wd.md", Buffer.from("# spec"));
|
||||
assert.equal(row.name, "pass wd.md");
|
||||
assert.equal(path.dirname(row.path), src.contextDir(lane.id));
|
||||
assert.ok(row.path.startsWith(path.join(process.env.DASHBOARD_DATA_DIR, "workspace-context")));
|
||||
assert.equal(fs.readFileSync(row.path, "utf8"), "# spec");
|
||||
const again = src.addFile(lane.id, "pass wd.md", Buffer.from("two"));
|
||||
assert.equal(again.name, "pass wd (2).md");
|
||||
});
|
||||
|
||||
it("refuses an empty or oversized file", () => {
|
||||
assert.throws(
|
||||
() => src.addFile(lane.id, "e.txt", Buffer.alloc(0)),
|
||||
(e) => e.code === "EEMPTY"
|
||||
);
|
||||
assert.throws(
|
||||
() => src.addFile(lane.id, "big.bin", Buffer.alloc(src.MAX_FILE_BYTES + 1)),
|
||||
(e) => e.code === "ETOOBIG"
|
||||
);
|
||||
});
|
||||
|
||||
it("removing a file deletes AgentHub's copy; removing a folder leaves the folder", () => {
|
||||
const file = src.addFile(otherLane.id, "x.txt", Buffer.from("x"));
|
||||
const folder = src.addFolder(otherLane.id, extra);
|
||||
src.removeSource(otherLane.id, file.id);
|
||||
src.removeSource(otherLane.id, folder.id);
|
||||
assert.equal(fs.existsSync(file.path), false);
|
||||
assert.equal(fs.existsSync(extra), true);
|
||||
assert.deepEqual(src.listSources(otherLane.id), []);
|
||||
assert.throws(
|
||||
() => src.removeSource(otherLane.id, file.id),
|
||||
(e) => e.code === "ENOSOURCE"
|
||||
);
|
||||
});
|
||||
|
||||
it("will not remove another workspace's source", () => {
|
||||
const [own] = src.listSources(lane.id);
|
||||
assert.throws(
|
||||
() => src.removeSource(otherLane.id, own.id),
|
||||
(e) => e.code === "ENOSOURCE"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lane-sources instructions and launch", () => {
|
||||
it("caps instructions and clears them on blank", () => {
|
||||
assert.throws(
|
||||
() => src.setInstructions(lane.id, "x".repeat(src.MAX_INSTRUCTIONS + 1)),
|
||||
(e) => e.code === "ETOOBIG"
|
||||
);
|
||||
assert.throws(
|
||||
() => src.setInstructions(lane.id, 5),
|
||||
(e) => e.code === "EBADFIELD"
|
||||
);
|
||||
src.setInstructions(lane.id, " ");
|
||||
assert.equal(lanesLib.getLane(lane.id).instructions, null);
|
||||
src.setInstructions(lane.id, "- always answer in Vietnamese");
|
||||
assert.equal(lanesLib.getLane(lane.id).instructions, "- always answer in Vietnamese");
|
||||
});
|
||||
|
||||
it("starts a claude pane with --add-dir first and the instructions from a file", () => {
|
||||
let argv = null;
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
|
||||
if (args[0] === "new-session") argv = args.slice(args.indexOf("--") + 1);
|
||||
return "";
|
||||
});
|
||||
pty.spawnRun({ laneId: lane.id, cwd: ws, initialPrompt: "-c fix it" });
|
||||
tmux.__reset();
|
||||
assert.equal(argv[0], "claude");
|
||||
assert.equal(argv[1], "--add-dir");
|
||||
assert.deepEqual(argv.slice(2, 4), [extra, src.contextDir(lane.id)]);
|
||||
assert.equal(argv[4], "--permission-mode");
|
||||
const flag = argv.find((a) => a.startsWith("--append-system-prompt-file="));
|
||||
const file = flag.slice("--append-system-prompt-file=".length);
|
||||
assert.ok(file.startsWith(process.env.DASHBOARD_DATA_DIR));
|
||||
const text = fs.readFileSync(file, "utf8");
|
||||
assert.match(text, /^- always answer in Vietnamese/);
|
||||
assert.match(text, /pass wd\.md/);
|
||||
// A prompt that looks like flags stays one positional, never `claude -c`.
|
||||
assert.equal(argv.at(-1), " -c fix it");
|
||||
assert.ok(!argv.includes("-c"));
|
||||
});
|
||||
|
||||
it("starts a codex pane with repeated --add-dir and developer_instructions", () => {
|
||||
src.setInstructions(codexLane.id, 'say "hi"\nthen stop');
|
||||
src.addFolder(codexLane.id, ws);
|
||||
const { addDirs, systemPrompt } = src.launchExtras(codexLane.id, "codex");
|
||||
assert.deepEqual(addDirs, [ws]);
|
||||
let argv = null;
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
|
||||
if (args[0] === "new-session") argv = args.slice(args.indexOf("--") + 1);
|
||||
return "";
|
||||
});
|
||||
pty.spawnRun({ laneId: codexLane.id, cwd: extra, tool: "codex", initialPrompt: "go" });
|
||||
tmux.__reset();
|
||||
assert.deepEqual(argv.slice(argv.indexOf("--add-dir"), argv.indexOf("--add-dir") + 2), [
|
||||
"--add-dir",
|
||||
ws,
|
||||
]);
|
||||
assert.ok(argv.includes(`developer_instructions=${JSON.stringify(systemPrompt)}`));
|
||||
assert.equal(argv.at(-1), "go");
|
||||
});
|
||||
|
||||
it("hands Codex a pointer to the file once the instructions are too long for tmux", () => {
|
||||
src.setInstructions(codexLane.id, "dài ".repeat(3000));
|
||||
const { systemPrompt, systemPromptFile } = src.launchExtras(codexLane.id, "codex");
|
||||
assert.ok(Buffer.byteLength(systemPrompt) > src.INLINE_PROMPT_BYTES);
|
||||
const inline = src.codexInstructions(systemPrompt, systemPromptFile);
|
||||
assert.ok(inline.includes(systemPromptFile));
|
||||
assert.ok(Buffer.byteLength(inline) < 1024);
|
||||
assert.equal(fs.readFileSync(systemPromptFile, "utf8"), systemPrompt);
|
||||
src.setInstructions(codexLane.id, null);
|
||||
});
|
||||
|
||||
it("gives an unsupported tool nothing, and skips a vanished folder", () => {
|
||||
assert.deepEqual(src.launchExtras(lane.id, "gemini"), {
|
||||
addDirs: [],
|
||||
systemPrompt: null,
|
||||
systemPromptFile: null,
|
||||
});
|
||||
const gone = path.join(ROOT, "gone");
|
||||
fs.mkdirSync(gone);
|
||||
src.addFolder(otherLane.id, gone);
|
||||
fs.rmdirSync(gone);
|
||||
assert.deepEqual(src.launchExtras(otherLane.id, "claude").addDirs, []);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lane-sources review fixes", () => {
|
||||
it("leaves no file on disk when an upload is refused for the source limit", () => {
|
||||
const d = path.join(ROOT, "limit-ws");
|
||||
fs.mkdirSync(d);
|
||||
const l = lanesLib.createLane({ cwd: d, kind: "adopted", tool: "claude" });
|
||||
for (let i = 0; i < 50; i++) src.addFile(l.id, `f${i}.txt`, Buffer.from("x"));
|
||||
assert.throws(
|
||||
() => src.addFile(l.id, "one-too-many.txt", Buffer.from("x")),
|
||||
(e) => e.code === "ETOOMANY"
|
||||
);
|
||||
assert.equal(fs.readdirSync(src.contextDir(l.id)).length, 50);
|
||||
lanesLib.deleteLane(l.id);
|
||||
});
|
||||
|
||||
it("measures Codex's inline budget on the escaped TOML string, and escapes DEL", () => {
|
||||
const quotes = '"'.repeat(src.INLINE_PROMPT_BYTES - 100);
|
||||
assert.ok(src.codexInstructions(quotes, "/p.md").includes("/p.md"));
|
||||
assert.equal(src.tomlString('a\x7fb"'), '"a\\u007fb\\""');
|
||||
});
|
||||
|
||||
it("does not serve instructions in the lane list payload", () => {
|
||||
src.setInstructions(lane.id, "secret-ish text");
|
||||
assert.equal("instructions" in lanesLib.lanePayload(lanesLib.getLane(lane.id)), false);
|
||||
src.setInstructions(lane.id, "- always answer in Vietnamese");
|
||||
});
|
||||
|
||||
it("still starts a pane when the prompt file cannot be written", () => {
|
||||
const real = fs.writeFileSync;
|
||||
fs.writeFileSync = () => {
|
||||
throw Object.assign(new Error("read-only"), { code: "EROFS" });
|
||||
};
|
||||
try {
|
||||
const got = src.launchExtras(lane.id, "claude");
|
||||
assert.equal(got.systemPromptFile, null);
|
||||
// Codex can still take a short text inline; Claude needs the file.
|
||||
assert.equal(src.codexInstructions(got.systemPrompt, null), got.systemPrompt);
|
||||
assert.equal(src.codexInstructions("x".repeat(src.INLINE_PROMPT_BYTES + 1), null), null);
|
||||
} finally {
|
||||
fs.writeFileSync = real;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("lane-sources memory and cleanup", () => {
|
||||
it("reads the folder's CLAUDE.md and Claude's auto memory, read-only", () => {
|
||||
fs.writeFileSync(path.join(ws, "CLAUDE.md"), "# rules");
|
||||
const mem = path.join(process.env.CLAUDE_HOME, "projects", encodeCwd(ws), "memory");
|
||||
fs.mkdirSync(mem, { recursive: true });
|
||||
fs.writeFileSync(path.join(mem, "MEMORY.md"), "- a fact");
|
||||
const files = src.memoryFiles(lane.id);
|
||||
assert.deepEqual(
|
||||
files.map((f) => f.label),
|
||||
["CLAUDE.md", "memory/MEMORY.md"]
|
||||
);
|
||||
assert.equal(files[1].content, "- a fact");
|
||||
});
|
||||
|
||||
it("deleting the workspace drops its rows and AgentHub's copies, not its folders", () => {
|
||||
const tmp = lanesLib.createLane({ cwd: path.join(ROOT, "claude"), kind: "adopted" });
|
||||
const f = src.addFile(tmp.id, "a.txt", Buffer.from("a"));
|
||||
src.addFolder(tmp.id, extra);
|
||||
lanesLib.deleteLane(tmp.id);
|
||||
assert.equal(fs.existsSync(path.dirname(f.path)), false);
|
||||
assert.equal(fs.existsSync(extra), true);
|
||||
assert.throws(
|
||||
() => src.listSources(tmp.id),
|
||||
(e) => e.code === "ENOLANE"
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("lane-sources routes", () => {
|
||||
it("lists, uploads, and refuses a cross-origin write", async () => {
|
||||
const list = await request("GET", `/api/lanes/${lane.id}/sources`);
|
||||
assert.equal(list.status, 200);
|
||||
assert.equal(list.body.supported, true);
|
||||
assert.equal(list.body.instructions, "- always answer in Vietnamese");
|
||||
|
||||
const up = await request("POST", `/api/lanes/${lane.id}/sources/file`, {
|
||||
body: Buffer.from("notes"),
|
||||
headers: {
|
||||
"content-type": "application/octet-stream",
|
||||
"x-file-name": encodeURIComponent("ghi chú.txt"),
|
||||
},
|
||||
});
|
||||
assert.equal(up.status, 201);
|
||||
assert.ok(up.body.sources.some((s) => s.name === "ghi chú.txt"));
|
||||
|
||||
const evil = await request("PUT", `/api/lanes/${lane.id}/instructions`, {
|
||||
body: { instructions: "exfiltrate" },
|
||||
headers: { origin: "https://evil.example" },
|
||||
});
|
||||
assert.equal(evil.status, 403);
|
||||
assert.equal(lanesLib.getLane(lane.id).instructions, "- always answer in Vietnamese");
|
||||
|
||||
const missing = await request("GET", `/api/lanes/99999/sources`);
|
||||
assert.equal(missing.status, 404);
|
||||
const bad = await request("POST", `/api/lanes/${lane.id}/sources/folder`, {
|
||||
body: { path: "/definitely/not/here" },
|
||||
});
|
||||
assert.equal(bad.status, 400);
|
||||
assert.equal(bad.body.error.code, "EBADPATH");
|
||||
const noKey = await request("PUT", `/api/lanes/${lane.id}/instructions`, { body: {} });
|
||||
assert.equal(noKey.status, 400);
|
||||
assert.equal(lanesLib.getLane(lane.id).instructions, "- always answer in Vietnamese");
|
||||
});
|
||||
});
|
||||
@@ -477,20 +477,21 @@ describe("pty-run", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("builds each prompt mode, with fixed args first", () => {
|
||||
it("builds each prompt mode, with fixed args first, a dash-led prompt never a flag", () => {
|
||||
const argvFor = (tool, extra = {}) =>
|
||||
paneArgv(
|
||||
captureNewSession(() =>
|
||||
pty.spawnRun({ laneId: 15, cwd: "/tmp/repo", tool, initialPrompt: "-x y", ...extra })
|
||||
)
|
||||
);
|
||||
assert.deepEqual(argvFor("gemini"), [at("gemini"), "--prompt-interactive", "-x y"]);
|
||||
assert.deepEqual(argvFor("gemini"), [at("gemini"), "--prompt-interactive", " -x y"]);
|
||||
// Behind a `--` separator the prompt is already a positional, kept as typed.
|
||||
assert.deepEqual(argvFor("grok"), [at("grok"), "--", "-x y"]);
|
||||
assert.deepEqual(argvFor("command-code"), [at("command-code"), "--trust", "-x y"]);
|
||||
assert.deepEqual(argvFor("command-code"), [at("command-code"), "--trust", " -x y"]);
|
||||
assert.deepEqual(argvFor("gemini", { bypass: false }), [
|
||||
at("gemini"),
|
||||
"--prompt-interactive",
|
||||
"-x y",
|
||||
" -x y",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,143 @@
|
||||
/**
|
||||
* @file Tests that a dashboard pane learns which session it holds: every pane
|
||||
* is started with `AGENTHUB_RUN_ID` in its tmux env, and a hook carrying that
|
||||
* `run_id` binds the hook's session to the run (`dashboard_runs.session_id`),
|
||||
* following `/clear` to a new session; a `run_id` that is not a dashboard pane
|
||||
* name, or names no run, changes nothing.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, before, after } = require("node:test");
|
||||
const assert = require("node:assert/strict");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const http = require("node:http");
|
||||
|
||||
const ROOT = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "run-bind-")));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(ROOT, "t.db");
|
||||
process.env.CLAUDE_HOME = path.join(ROOT, "claude");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(ROOT, "data");
|
||||
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp } = require("../index");
|
||||
const dashboardRuns = require("../lib/dashboard-runs");
|
||||
const tmux = require("../lib/tmux");
|
||||
const pty = require("../lib/pty-run");
|
||||
|
||||
let server;
|
||||
let base;
|
||||
|
||||
function postHook(body) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const data = Buffer.from(JSON.stringify(body));
|
||||
const req = http.request(
|
||||
`${base}/api/hooks/event`,
|
||||
{ method: "POST", headers: { "content-type": "application/json" } },
|
||||
(res) => {
|
||||
res.resume();
|
||||
res.on("end", () => resolve(res.statusCode));
|
||||
}
|
||||
);
|
||||
req.on("error", reject);
|
||||
req.end(data);
|
||||
});
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
fs.mkdirSync(process.env.CLAUDE_HOME, { recursive: true });
|
||||
server = http.createServer(createApp());
|
||||
await new Promise((r) => server.listen(0, "127.0.0.1", r));
|
||||
base = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
server?.close();
|
||||
fs.rmSync(ROOT, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("run ↔ session binding", () => {
|
||||
it("starts every pane with AGENTHUB_RUN_ID naming that pane", () => {
|
||||
let argv = null;
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "has-session") throw Object.assign(new Error("gone"), { status: 1 });
|
||||
if (args[0] === "new-session") argv = args;
|
||||
return "";
|
||||
});
|
||||
pty.spawnRun({ laneId: 77, slot: 2, cwd: ROOT });
|
||||
tmux.__reset();
|
||||
const env = argv.filter((a, i) => argv[i - 1] === "-e");
|
||||
assert.ok(env.includes("AGENTHUB_RUN_ID=agenthub-lane-77-2"), env.join(" "));
|
||||
});
|
||||
|
||||
it("binds a hook's session to the run it names, and follows a new session", async () => {
|
||||
dashboardRuns.recordRun({
|
||||
id: "agenthub-lane-5-1",
|
||||
mode: "interactive",
|
||||
cwd: ROOT,
|
||||
status: "running",
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
const hook = (session_id, run_id) =>
|
||||
postHook({ hook_type: "SessionStart", data: { session_id, cwd: ROOT }, run_id });
|
||||
assert.equal(await hook("sess-aaaa-1111", "agenthub-lane-5-1"), 200);
|
||||
assert.equal(dashboardRuns.getRun("agenthub-lane-5-1").session_id, "sess-aaaa-1111");
|
||||
// `/clear` in the pane: a new session that is the pane's now.
|
||||
await postHook({
|
||||
hook_type: "SessionStart",
|
||||
data: { session_id: "sess-bbbb-2222", cwd: ROOT, source: "clear" },
|
||||
run_id: "agenthub-lane-5-1",
|
||||
});
|
||||
assert.equal(dashboardRuns.getRun("agenthub-lane-5-1").session_id, "sess-bbbb-2222");
|
||||
});
|
||||
|
||||
it("keeps the pane's session when a nested CLI inside it starts its own", async () => {
|
||||
// A `claude -p` the agent runs through Bash inherits AGENTHUB_RUN_ID.
|
||||
for (const hook_type of ["SessionStart", "UserPromptSubmit", "Stop"]) {
|
||||
await postHook({
|
||||
hook_type,
|
||||
data: { session_id: "sess-nest-9999", cwd: ROOT, source: "startup" },
|
||||
run_id: "agenthub-lane-5-1",
|
||||
});
|
||||
}
|
||||
assert.equal(dashboardRuns.getRun("agenthub-lane-5-1").session_id, "sess-bbbb-2222");
|
||||
});
|
||||
|
||||
it("follows any new session on a non-claude pane, whose hooks carry no reliable source", async () => {
|
||||
dashboardRuns.recordRun({
|
||||
id: "agenthub-lane-6-1",
|
||||
mode: "interactive",
|
||||
cwd: ROOT,
|
||||
status: "running",
|
||||
startedAt: Date.now(),
|
||||
});
|
||||
const hook = (session_id) =>
|
||||
postHook({
|
||||
hook_type: "UserPromptSubmit",
|
||||
tool: "codex",
|
||||
data: { session_id, cwd: ROOT, prompt: "hi" },
|
||||
run_id: "agenthub-lane-6-1",
|
||||
});
|
||||
await hook("codex-thread-aaaa");
|
||||
assert.equal(dashboardRuns.getRun("agenthub-lane-6-1").session_id, "codex-thread-aaaa");
|
||||
await hook("codex-thread-bbbb");
|
||||
assert.equal(dashboardRuns.getRun("agenthub-lane-6-1").session_id, "codex-thread-bbbb");
|
||||
});
|
||||
|
||||
it("ignores a run_id that is not a dashboard pane, or names no run", async () => {
|
||||
await postHook({
|
||||
hook_type: "SessionStart",
|
||||
data: { session_id: "sess-cccc-3333", cwd: ROOT },
|
||||
run_id: "main",
|
||||
});
|
||||
await postHook({
|
||||
hook_type: "SessionStart",
|
||||
data: { session_id: "sess-cccc-3333", cwd: ROOT },
|
||||
run_id: "agenthub-lane-999",
|
||||
});
|
||||
assert.equal(dashboardRuns.getRun("main"), null);
|
||||
assert.equal(dashboardRuns.getRun("agenthub-lane-999"), null);
|
||||
assert.equal(dashboardRuns.getRun("agenthub-lane-5-1").session_id, "sess-bbbb-2222");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user