Files
AgentHub/server/__tests__/lanes-api.test.js
T
nntrivi2001 5ba9e07d78 feat(cc-config,lanes): per-profile quota, and three fixes that made a write look lost
Closes #19, #20, #21, #27.

#19 — a saved auth profile now reports how much of its account's quota is
already spent, which is the only number that decides which one is worth
switching to. Nothing under CLAUDE_HOME records it, so it comes from the same
endpoint the CLI's own /usage reads, answered for whichever account's OAuth
token is presented. It is a POST and a button, never a poll: every profile that
is not the active one is normally expired, so answering means refreshing it
first, and a refresh ROTATES the stored refresh token. The rotated pair is
written straight back — dropping it would leave the profile holding a
credential Anthropic has already retired, i.e. an account needing a fresh
login. The token is read, put in an Authorization header, and never returned,
logged or echoed; what leaves the server is two percentages and two reset
times.

#20 — a settings patch that changes nothing wrote nothing but still took a
timestamped backup, so opening the config panel quietly grew a backup
directory inside every lane. patchSettings now compares the rendered output
with what is already on disk and returns early, fixed once where all callers
route through. A lane's git exclude also picks up .claude/.cc-config-backups/
beside settings.local.json.

#21 — the router apply route was the only mutating lane route that never
broadcast its lane. The write reached disk and the column, but the client's
cached row kept the old router_profile forever: the picker looked right until
the user switched lanes and back, at which point the effect re-read the stale
row and flipped the selection to the previous profile. One broadcastLane, plus
a lane-switch guard so an apply that resolves after the user has moved on no
longer paints its result onto whichever lane is now on screen.

#27 — Add-lane in worktree mode left the dialog up after a clean setup with a
dead confirm button and no obvious way out. It closes itself when nothing
failed, and when something did, the summary stays and the confirm becomes a
real Close.
2026-09-17 22:20:32 +07:00

1383 lines
50 KiB
JavaScript

/**
* @file HTTP tests for /api/lanes: CRUD, stage reporting, the aggregate
* counters the header badges read, and the last-event age that drives liveness.
* @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 path = require("path");
const os = require("os");
const http = require("http");
const fs = require("fs");
const { WebSocket } = require("ws");
const TEST_DB = path.join(os.tmpdir(), `dashboard-lanes-api-${Date.now()}-${process.pid}.db`);
process.env.DASHBOARD_DB_PATH = TEST_DB;
process.env.DASHBOARD_REMOTE_SYNC_MS = "0";
process.env.DASHBOARD_LIVENESS_PROBE = "0";
const { createApp, startServer } = require("../index");
let server;
let BASE;
function request(method, urlPath, body, headers = {}) {
return new Promise((resolve, reject) => {
const url = new URL(urlPath, BASE);
const payload = body ? JSON.stringify(body) : null;
const req = http.request(
{
hostname: url.hostname,
port: url.port,
path: url.pathname + url.search,
method,
headers: {
...headers,
...(payload
? { "Content-Type": "application/json", "Content-Length": Buffer.byteLength(payload) }
: {}),
},
},
(res) => {
let data = "";
res.on("data", (c) => (data += c));
res.on("end", () => {
let parsed = null;
try {
parsed = JSON.parse(data);
} catch {
/* non-JSON body */
}
resolve({ status: res.statusCode, body: parsed });
});
}
);
req.on("error", reject);
if (payload) req.write(payload);
req.end();
});
}
before(async () => {
const app = createApp();
server = await startServer(app, 0);
BASE = `http://127.0.0.1:${server.address().port}`;
});
after(() => server && server.close());
describe("/api/lanes", () => {
let laneId;
it("creates a lane", async () => {
const r = await request("POST", "/api/lanes", { title: "Lane A", cwd: "/tmp/lane-api-a" });
assert.equal(r.status, 201);
assert.equal(r.body.lane.title, "Lane A");
assert.equal(r.body.lane.stage, "idle");
laneId = r.body.lane.id;
});
it("rejects a relative cwd", async () => {
const r = await request("POST", "/api/lanes", { cwd: "relative/path" });
assert.equal(r.status, 400);
});
it("rejects a duplicate cwd", async () => {
const r = await request("POST", "/api/lanes", { cwd: "/tmp/lane-api-a" });
assert.equal(r.status, 409);
});
it("rejects a stage that names no node of the lane's pipeline", async () => {
// Silently storing it is what made "CI gates" read never-reached on a lane
// where the agent had been declaring `test` (the default pipeline's name)
// all along.
const r = await request("POST", `/api/lanes/${laneId}/stage`, { stage: "implemnt" });
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EBADSTAGE");
assert.ok(r.body.error.stages.includes("implement"));
});
it("accepts a stage declared by one of the node's aliases", async () => {
const r = await request("POST", `/api/lanes/${laneId}/stage`, { stage: "coding" });
assert.equal(r.status, 200);
});
it("reports a stage and returns node states", async () => {
const r = await request("POST", `/api/lanes/${laneId}/stage`, {
stage: "review",
status: "running",
evidence: null,
});
assert.equal(r.status, 200);
const review = r.body.lane.pipeline_nodes.find((n) => n.id === "review");
assert.equal(review.state, "current");
assert.ok(r.body.lane.progress > 0);
});
it("404s on an unknown lane", async () => {
const r = await request("POST", "/api/lanes/99999/stage", { stage: "plan" });
assert.equal(r.status, 404);
});
it("lists lanes with counters", async () => {
const r = await request("GET", "/api/lanes");
assert.equal(r.status, 200);
assert.ok(r.body.lanes.length >= 1);
assert.equal(r.body.counts.total, r.body.lanes.length);
assert.equal(typeof r.body.counts.running, "number");
assert.equal(typeof r.body.counts.needs_you, "number");
});
it("exposes pipeline templates", async () => {
const r = await request("GET", "/api/lanes/pipelines");
assert.equal(r.status, 200);
assert.ok(r.body.pipelines.some((p) => p.id === "default"));
});
it("patches and deletes", async () => {
const p = await request("PATCH", `/api/lanes/${laneId}`, { ci_status: "green" });
assert.equal(p.body.lane.ci_status, "green");
const d = await request("DELETE", `/api/lanes/${laneId}`);
assert.equal(d.status, 200);
assert.equal((await request("GET", `/api/lanes/${laneId}`)).status, 404);
});
// The Workspace's inline rename is a text field PATCHing this route, so a
// title is the one lane column a user types directly. Bad input on any
// patchable column is a 400: reaching better-sqlite3 with a non-scalar
// raises a datatype error Express can only report as a 500.
it("renames a lane, and answers 400 rather than 500 for unbindable input", async () => {
const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-rename-route" });
const id = created.body.lane.id;
const named = await request("PATCH", `/api/lanes/${id}`, { title: " checkout flow " });
assert.equal(named.status, 200);
assert.equal(named.body.lane.title, "checkout flow");
const cleared = await request("PATCH", `/api/lanes/${id}`, { title: "" });
assert.equal(cleared.body.lane.title, "");
for (const patch of [{ title: { evil: true } }, { notes: { a: 1 } }, { ci_status: true }]) {
const bad = await request("PATCH", `/api/lanes/${id}`, patch);
assert.equal(bad.status, 400, `expected 400 for ${JSON.stringify(patch)}`);
assert.equal(bad.body.error.code, "EBADFIELD");
}
// A NOT NULL column patched to null is the caller's mistake too.
const notNull = await request("PATCH", `/api/lanes/${id}`, { status: null });
assert.equal(notNull.status, 400);
assert.equal((await request("DELETE", `/api/lanes/${id}`)).status, 200);
});
it("rejects cross-origin lane deletion", async () => {
const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-cross-origin-delete" });
const id = created.body.lane.id;
const rejected = await request("DELETE", `/api/lanes/${id}`, undefined, {
Origin: "https://attacker.example",
});
assert.equal(rejected.status, 403);
assert.equal(rejected.body.error.code, "EBADORIGIN");
assert.equal((await request("GET", `/api/lanes/${id}`)).status, 200);
assert.equal((await request("DELETE", `/api/lanes/${id}`)).status, 200);
});
it("rejects cross-origin lane patching", async () => {
const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-cross-origin-patch" });
const id = created.body.lane.id;
const rejected = await request(
"PATCH",
`/api/lanes/${id}`,
{ run_id: "attacker-run" },
{ Origin: "https://attacker.example" }
);
assert.equal(rejected.status, 403);
assert.equal(rejected.body.error.code, "EBADORIGIN");
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.run_id, null);
assert.equal((await request("DELETE", `/api/lanes/${id}`)).status, 200);
});
it("rejects cross-origin lane creation", async () => {
const rejected = await request(
"POST",
"/api/lanes",
{ cwd: "/tmp/lane-cross-origin-create" },
{ Origin: "https://attacker.example" }
);
assert.equal(rejected.status, 403);
assert.equal(rejected.body.error.code, "EBADORIGIN");
});
it("rejects cross-origin stage reporting", async () => {
const created = await request("POST", "/api/lanes", { cwd: "/tmp/lane-cross-origin-stage" });
const id = created.body.lane.id;
const rejected = await request(
"POST",
`/api/lanes/${id}/stage`,
{ stage: "implement" },
{ Origin: "https://attacker.example" }
);
assert.equal(rejected.status, 403);
assert.equal(rejected.body.error.code, "EBADORIGIN");
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.stage, "idle");
assert.equal((await request("DELETE", `/api/lanes/${id}`)).status, 200);
});
it("broadcasts lane_update on create", async () => {
const wsUrl = `ws://127.0.0.1:${server.address().port}/ws`;
const ws = new WebSocket(wsUrl);
const messages = [];
const timeout = new Promise((_, reject) => {
setTimeout(() => reject(new Error("ws test timeout")), 5000);
});
await new Promise((resolve, reject) => {
ws.on("open", resolve);
ws.on("error", reject);
Promise.race([timeout]).catch(reject);
});
ws.on("message", (msg) => {
try {
messages.push(JSON.parse(msg));
} catch {
/* parse error */
}
});
const createResp = await request("POST", "/api/lanes", {
title: "WS Test Lane",
cwd: "/tmp/ws-test-lane",
});
assert.equal(createResp.status, 201);
const newLaneId = createResp.body.lane.id;
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
const laneUpdateMsg = messages.find(
(m) => m.type === "lane_update" && m.data.lane?.id === newLaneId
);
assert.ok(laneUpdateMsg, "create should broadcast lane_update");
assert.equal(laneUpdateMsg.data.lane.id, newLaneId);
assert.equal(laneUpdateMsg.data.lane.title, "WS Test Lane");
// Test delete broadcast
messages.length = 0;
const deleteResp = await request("DELETE", `/api/lanes/${newLaneId}`);
assert.equal(deleteResp.status, 200);
await new Promise((resolve) => {
setTimeout(resolve, 100);
});
const deleteMsg = messages.find(
(m) => m.type === "lane_update" && m.data.removed === newLaneId
);
assert.ok(deleteMsg, "delete should broadcast lane_update with removed");
assert.equal(deleteMsg.data.removed, newLaneId);
ws.close();
});
it("broadcasts lane_update on router apply", async () => {
// Without this the client's cached row keeps the OLD router_profile: the
// picker looks right until the user switches lanes and back, at which
// point the effect re-reads the stale row and flips the selection.
const cwd = fs.mkdtempSync(path.join(os.tmpdir(), "lane-router-bcast-"));
const created = await request("POST", "/api/lanes", { cwd, title: "Router Lane" });
const id = created.body.lane.id;
// Start somewhere other than native, so the broadcast has to carry a
// CHANGED value rather than the one the row already held.
require("../lib/lanes").setRouterProfile(id, "some-router");
const ws = new WebSocket(`ws://127.0.0.1:${server.address().port}/ws`);
const messages = [];
await new Promise((resolve, reject) => {
ws.on("open", resolve);
ws.on("error", reject);
});
ws.on("message", (msg) => {
try {
messages.push(JSON.parse(msg));
} catch {
/* parse error */
}
});
try {
const applied = await request("POST", `/api/lanes/${id}/router/apply`, { profile: "native" });
assert.equal(applied.status, 200);
await new Promise((resolve) => setTimeout(resolve, 100));
const msg = messages.find((m) => m.type === "lane_update" && m.data.lane?.id === id);
assert.ok(msg, "router apply should broadcast lane_update");
// `native` is stored as null — what matters is that the pushed row is the
// post-apply one, not the "some-router" the client still has cached.
assert.equal(msg.data.lane.router_profile, null);
} finally {
// A socket left open by a failed assertion keeps the whole run alive.
ws.close();
}
await request("DELETE", `/api/lanes/${id}`);
fs.rmSync(cwd, { recursive: true, force: true });
});
});
describe("hook → lane binding", () => {
it("binds a session to the lane owning its cwd and flags/clears needs_action", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-hook-a",
title: "Hooked",
});
const id = created.body.lane.id;
await request("POST", "/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "sess-lane-1", cwd: "/tmp/lane-hook-a/sub/dir" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.session_id, "sess-lane-1");
await request("POST", "/api/hooks/event", {
hook_type: "Notification",
data: { session_id: "sess-lane-1", cwd: "/tmp/lane-hook-a", message: "needs permission" },
});
assert.equal(
(await request("GET", `/api/lanes/${id}`)).body.lane.needs_action,
"needs permission"
);
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: { session_id: "sess-lane-1", cwd: "/tmp/lane-hook-a", tool_name: "Read" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, null);
await request("DELETE", `/api/lanes/${id}`);
});
it("ignores a hook whose cwd is under no lane", async () => {
const before = (await request("GET", "/api/lanes")).body.lanes.length;
await request("POST", "/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "sess-lane-orphan", cwd: "/tmp/not-a-lane" },
});
assert.equal((await request("GET", "/api/lanes")).body.lanes.length, before);
});
it("clears needs_action only from the session that raised it, not from cross-session rebinds", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-cross-session",
title: "Cross-Session",
});
const id = created.body.lane.id;
// Session A sets needs_action
await request("POST", "/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "sess-a", cwd: "/tmp/lane-cross-session" },
});
await request("POST", "/api/hooks/event", {
hook_type: "Notification",
data: { session_id: "sess-a", cwd: "/tmp/lane-cross-session", message: "blocked" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, "blocked");
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.session_id, "sess-a");
// Session B PostToolUse: rebinds lane to B, but does NOT clear A's flag
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: { session_id: "sess-b", cwd: "/tmp/lane-cross-session", tool_name: "Read" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.session_id, "sess-b");
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, "blocked");
// Session B's second PostToolUse clears the flag (B is now bound)
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: { session_id: "sess-b", cwd: "/tmp/lane-cross-session", tool_name: "Read" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, null);
await request("DELETE", `/api/lanes/${id}`);
});
it("raises needs_action with default 'needs you' when Notification has no message", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-default-msg",
title: "Default Msg",
});
const id = created.body.lane.id;
await request("POST", "/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "sess-c", cwd: "/tmp/lane-default-msg" },
});
await request("POST", "/api/hooks/event", {
hook_type: "Notification",
data: { session_id: "sess-c", cwd: "/tmp/lane-default-msg" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.needs_action, "needs you");
await request("DELETE", `/api/lanes/${id}`);
});
it("the bare idle nudge never raises needs_action and clears a stale one", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-idle-nudge",
title: "Idle Nudge",
});
const id = created.body.lane.id;
await request("POST", "/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "sess-nudge", cwd: "/tmp/lane-idle-nudge" },
});
await request("POST", "/api/hooks/event", {
hook_type: "Notification",
data: {
session_id: "sess-nudge",
cwd: "/tmp/lane-idle-nudge",
message: "Claude needs your permission to use Bash",
},
});
assert.equal(
(await request("GET", `/api/lanes/${id}`)).body.lane.needs_action,
"Claude needs your permission to use Bash"
);
// The 60s idle nudge fires after Stop: it proves the CLI is parked at an
// idle prompt, so it must clear rather than pin the banner.
await request("POST", "/api/hooks/event", {
hook_type: "Notification",
data: {
session_id: "sess-nudge",
cwd: "/tmp/lane-idle-nudge",
message: "Claude is waiting for your input",
},
});
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.needs_action, null);
assert.equal(lane.status, "idle");
await request("DELETE", `/api/lanes/${id}`);
});
it("mirrors CLI turn state onto lane.status for a lane the dashboard did not launch", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-turn-status",
title: "Turn Status",
});
const id = created.body.lane.id;
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.status, "idle");
await request("POST", "/api/hooks/event", {
hook_type: "UserPromptSubmit",
data: { session_id: "sess-turn", cwd: "/tmp/lane-turn-status" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.status, "running");
// A subagent finishing is not the end of the turn.
await request("POST", "/api/hooks/event", {
hook_type: "SubagentStop",
data: { session_id: "sess-turn", cwd: "/tmp/lane-turn-status" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.status, "running");
await request("POST", "/api/hooks/event", {
hook_type: "Stop",
data: { session_id: "sess-turn", cwd: "/tmp/lane-turn-status" },
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.status, "idle");
await request("DELETE", `/api/lanes/${id}`);
});
});
describe("hook → stage detection", () => {
it("a Bash test-run hook sets detected_stage to tests, a following Read leaves it unchanged", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-detect-a",
title: "Detect",
});
const id = created.body.lane.id;
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-1",
cwd: "/tmp/lane-detect-a",
tool_name: "Bash",
tool_input: { command: "npm run test:server" },
},
});
let lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.detected_stage, "tests");
assert.ok(lane.detected_signal);
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-1",
cwd: "/tmp/lane-detect-a",
tool_name: "Read",
tool_input: { file_path: "/tmp/lane-detect-a/foo.js" },
},
});
lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.detected_stage, "tests");
await request("DELETE", `/api/lanes/${id}`);
});
it("a hook whose cwd is under no lane changes nothing", async () => {
const beforeLanes = (await request("GET", "/api/lanes")).body.lanes.length;
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-orphan",
cwd: "/tmp/not-a-lane-detect",
tool_name: "Bash",
tool_input: { command: "npm run test:server" },
},
});
assert.equal((await request("GET", "/api/lanes")).body.lanes.length, beforeLanes);
});
it("a lane already declared at ship ignores an implement detection", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-detect-declared",
title: "Declared",
});
const id = created.body.lane.id;
await request("POST", `/api/lanes/${id}/stage`, { stage: "ship", status: "running" });
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-2",
cwd: "/tmp/lane-detect-declared",
tool_name: "Edit",
tool_input: { file_path: "/tmp/lane-detect-declared/foo.js" },
},
});
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.detected_stage, null);
await request("DELETE", `/api/lanes/${id}`);
});
it("a malformed hook payload still returns 200 and leaves the lane untouched", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-detect-malformed",
title: "Malformed",
});
const id = created.body.lane.id;
const r1 = await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-3",
cwd: "/tmp/lane-detect-malformed",
tool_input: "just a string, no tool_name",
},
});
assert.equal(r1.status, 200);
const r2 = await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: { session_id: "sess-detect-4", cwd: "/tmp/lane-detect-malformed" },
});
assert.equal(r2.status, 200);
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.detected_stage, null);
await request("DELETE", `/api/lanes/${id}`);
});
it("detection is visible in GET /api/lanes/:id with a detected node, and no node is 'done'", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-detect-visible",
title: "Visible",
});
const id = created.body.lane.id;
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-5",
cwd: "/tmp/lane-detect-visible",
tool_name: "Bash",
tool_input: { command: "npm run test:server" },
},
});
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.detected_stage, "tests");
assert.ok(lane.detected_signal);
const testsNode = lane.pipeline_nodes.find((n) => n.id === "tests");
assert.equal(testsNode.detected, true);
assert.ok(!lane.pipeline_nodes.some((n) => n.state === "done"));
await request("DELETE", `/api/lanes/${id}`);
});
it("a throwing recordDetection does not cost the lane bookkeeping in the same hook", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-detect-throws",
title: "Throws",
});
const id = created.body.lane.id;
// Bind the session and raise needs_action, exactly as a permission prompt does.
await request("POST", "/api/hooks/event", {
hook_type: "SessionStart",
data: { session_id: "sess-detect-throw", cwd: "/tmp/lane-detect-throws" },
});
await request("POST", "/api/hooks/event", {
hook_type: "Notification",
data: {
session_id: "sess-detect-throw",
cwd: "/tmp/lane-detect-throws",
message: "needs permission",
},
});
assert.equal(
(await request("GET", `/api/lanes/${id}`)).body.lane.needs_action,
"needs permission"
);
// Stand in for the real failures recordDetection can raise mid-hook: ENOLANE
// when the lane is deleted between resolveLaneByCwd and its lookup, or
// SQLITE_BUSY from another process on the same database.
const lanesLib = require("../lib/lanes");
const realRecordDetection = lanesLib.recordDetection;
lanesLib.recordDetection = () => {
throw Object.assign(new Error("no lane 999"), { code: "ENOLANE" });
};
try {
const r = await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-detect-throw",
cwd: "/tmp/lane-detect-throws",
tool_name: "Edit",
tool_input: { file_path: "/tmp/lane-detect-throws/foo.js" },
},
});
assert.equal(r.status, 200);
} finally {
lanesLib.recordDetection = realRecordDetection;
}
const lane = (await request("GET", `/api/lanes/${id}`)).body.lane;
assert.equal(lane.needs_action, null, "bookkeeping must survive a failed detection");
assert.equal(lane.detected_stage, null);
await request("DELETE", `/api/lanes/${id}`);
});
});
describe("lane actions", () => {
it("requires confirmation, then removes an adopted lane row without its directory", async () => {
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-a" });
const id = c.body.lane.id;
assert.equal((await request("POST", `/api/lanes/${id}/remove`, {})).status, 400);
const preflight = await request("GET", `/api/lanes/${id}/preflight?action=remove`);
const removed = await request("POST", `/api/lanes/${id}/remove`, {
confirm: true,
expect: {
head: preflight.body.head,
dirty: preflight.body.dirty,
untracked: preflight.body.untracked,
unpushed: preflight.body.unpushed,
},
});
assert.equal(removed.status, 200);
assert.equal((await request("GET", `/api/lanes/${id}`)).status, 404);
});
it("rejects an unknown action", async () => {
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-b" });
const id = c.body.lane.id;
const before = await request("GET", `/api/lanes/${id}`);
const r = await request("POST", `/api/lanes/${id}/frobnicate`, {});
assert.equal(r.status, 400);
const after = await request("GET", `/api/lanes/${id}`);
assert.equal(after.body.lane.stage, before.body.lane.stage);
assert.equal(after.body.lane.status, before.body.lane.status);
await request("DELETE", `/api/lanes/${id}`);
});
it("clear resets stage state but keeps the lane", async () => {
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-c", title: "Keep me" });
const id = c.body.lane.id;
await request("POST", `/api/lanes/${id}/stage`, { stage: "review", status: "running" });
const r = await request("POST", `/api/lanes/${id}/clear`, {});
assert.equal(r.status, 200);
assert.equal(r.body.lane.stage, "idle");
assert.equal(r.body.lane.title, "Keep me");
assert.deepEqual(r.body.lane.stages, {});
await request("DELETE", `/api/lanes/${id}`);
});
it("stop on a lane with no run is a no-op, not a 500", async () => {
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-d" });
const r = await request("POST", `/api/lanes/${c.body.lane.id}/stop`, {});
assert.equal(r.status, 200);
assert.equal(r.body.lane.status, "idle");
await request("DELETE", `/api/lanes/${c.body.lane.id}`);
});
it("message on a lane is no longer supported via REST", async () => {
const c = await request("POST", "/api/lanes", { cwd: "/tmp/lane-action-e" });
const id = c.body.lane.id;
const r = await request("POST", `/api/lanes/${id}/message`, { text: "hello" });
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EUNSUPPORTED");
await request("DELETE", `/api/lanes/${id}`);
});
});
describe("GET /api/lanes/:id/git", () => {
const fs = require("node:fs");
const { execFileSync } = require("node:child_process");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-api-git-"));
const g = (cwd, ...args) => {
const env = { ...process.env };
for (const k of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"]) delete env[k];
env.GIT_TERMINAL_PROMPT = "0";
return execFileSync("git", args, { cwd, encoding: "utf8", env });
};
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
it("reports the working-copy facts for a lane pointing at a real repo", async () => {
const dir = path.join(ROOT, "repo");
fs.mkdirSync(dir, { recursive: true });
g(dir, "init", "-b", "feat/api-facts");
g(dir, "config", "user.email", "t@example.com");
g(dir, "config", "user.name", "Test");
fs.writeFileSync(path.join(dir, "a.txt"), "one\n");
g(dir, "add", "-A");
g(dir, "commit", "-m", "api facts fixture");
fs.writeFileSync(path.join(dir, "a.txt"), "two\n");
fs.writeFileSync(path.join(dir, "untracked.txt"), "u\n");
const created = await request("POST", "/api/lanes", { cwd: dir, title: "git facts" });
const id = created.body.lane.id;
const r = await request("GET", `/api/lanes/${id}/git`);
assert.equal(r.status, 200);
assert.equal(r.body.available, true);
assert.equal(r.body.branch, "feat/api-facts");
assert.equal(r.body.subject, "api facts fixture");
assert.equal(r.body.head, g(dir, "rev-parse", "--short", "HEAD").trim());
assert.equal(r.body.dirty, 1);
assert.equal(r.body.untracked, 1);
await request("DELETE", `/api/lanes/${id}`);
});
it("reports available:false for a plain directory, not an error", async () => {
const dir = path.join(ROOT, "plain");
fs.mkdirSync(dir, { recursive: true });
const created = await request("POST", "/api/lanes", { cwd: dir, title: "plain dir" });
const id = created.body.lane.id;
const r = await request("GET", `/api/lanes/${id}/git`);
assert.equal(r.status, 200);
assert.deepEqual(r.body, { available: false });
await request("DELETE", `/api/lanes/${id}`);
});
it("reports available:false when the lane's directory is gone", async () => {
const dir = path.join(ROOT, "vanishes");
fs.mkdirSync(dir, { recursive: true });
const created = await request("POST", "/api/lanes", { cwd: dir, title: "vanishing" });
const id = created.body.lane.id;
fs.rmSync(dir, { recursive: true, force: true });
const r = await request("GET", `/api/lanes/${id}/git`);
assert.equal(r.status, 200);
assert.deepEqual(r.body, { available: false });
await request("DELETE", `/api/lanes/${id}`);
});
it("404s for a lane that does not exist", async () => {
const r = await request("GET", "/api/lanes/999999/git");
assert.equal(r.status, 404);
assert.equal(r.body.error.code, "ENOLANE");
});
it("resolves to the facts route, not an action error", async () => {
// The `/:id/:action` catch-all is a POST and cannot shadow this GET, so
// this pins the response SHAPE rather than any registration order.
const dir = path.join(ROOT, "ordering");
fs.mkdirSync(dir, { recursive: true });
const created = await request("POST", "/api/lanes", { cwd: dir, title: "ordering" });
const id = created.body.lane.id;
const r = await request("GET", `/api/lanes/${id}/git`);
assert.equal(r.status, 200);
assert.ok("available" in r.body, JSON.stringify(r.body));
assert.equal(r.body.error, undefined);
await request("DELETE", `/api/lanes/${id}`);
});
});
describe("GET /api/lanes/branches", () => {
const fs = require("node:fs");
const { execFileSync } = require("node:child_process");
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-api-branches-"));
const g = (cwd, ...args) => {
const env = { ...process.env };
for (const k of ["GIT_DIR", "GIT_WORK_TREE", "GIT_INDEX_FILE", "GIT_COMMON_DIR"]) delete env[k];
env.GIT_TERMINAL_PROMPT = "0";
return execFileSync("git", args, { cwd, encoding: "utf8", env });
};
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
it("lists the repo's local branches and the current one", async () => {
const dir = path.join(ROOT, "repo");
fs.mkdirSync(dir, { recursive: true });
g(dir, "init", "-b", "main");
g(dir, "config", "user.email", "t@example.com");
g(dir, "config", "user.name", "Test");
fs.writeFileSync(path.join(dir, "a.txt"), "a\n");
g(dir, "add", "-A");
g(dir, "commit", "-m", "init");
g(dir, "branch", "feat/x");
const r = await request("GET", `/api/lanes/branches?repo=${encodeURIComponent(dir)}`);
assert.equal(r.status, 200);
assert.deepEqual([...r.body.branches].sort(), ["feat/x", "main"]);
assert.equal(r.body.current, "main");
});
it("400s for a relative path", async () => {
const r = await request("GET", "/api/lanes/branches?repo=relative/path");
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EBADSOURCEREPO");
});
it("400s for a path that does not exist", async () => {
const r = await request(
"GET",
`/api/lanes/branches?repo=${encodeURIComponent(path.join(ROOT, "nope"))}`
);
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EBADSOURCEREPO");
});
it("400s for a directory that is not a git repository", async () => {
const dir = path.join(ROOT, "plain");
fs.mkdirSync(dir, { recursive: true });
const r = await request("GET", `/api/lanes/branches?repo=${encodeURIComponent(dir)}`);
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EBADSOURCEREPO");
});
it("is not swallowed by the /:id catch-all", async () => {
// "/branches" would otherwise be read as a lane id and 404 with ENOLANE.
const r = await request("GET", "/api/lanes/branches?repo=%2Fdoes%2Fnot%2Fmatter");
assert.notEqual(r.body?.error?.code, "ENOLANE");
});
});
describe("detection attributes work to the lane the work touched", () => {
// Measured on a real session: 325 of 400 hook events carried the SESSION's
// cwd, while the actual edits and test runs happened inside a different
// repo reached with `cd <other> && ...`. The lane doing the work detected
// nothing; the lane the terminal happened to start in absorbed all of it.
it("credits a Bash command that cd's into another lane to THAT lane", async () => {
const session = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-attr-session",
title: "where the shell started",
});
const worked = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-attr-worked",
title: "where the work happened",
});
const sessionId = session.body.lane.id;
const workedId = worked.body.lane.id;
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-attr-1",
cwd: "/tmp/lane-attr-session",
tool_name: "Bash",
tool_input: { command: "cd /tmp/lane-attr-worked && npm run test:server" },
},
});
assert.equal(
(await request("GET", `/api/lanes/${workedId}`)).body.lane.detected_stage,
"tests"
);
assert.equal(
(await request("GET", `/api/lanes/${sessionId}`)).body.lane.detected_stage,
null,
"the session's own lane did no work and must not be credited"
);
await request("DELETE", `/api/lanes/${workedId}`);
await request("DELETE", `/api/lanes/${sessionId}`);
});
it("credits an Edit to the lane owning the edited file", async () => {
const session = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-edit-session",
title: "session",
});
const worked = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-edit-worked",
title: "worked",
});
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-attr-2",
cwd: "/tmp/lane-edit-session",
tool_name: "Edit",
tool_input: { file_path: "/tmp/lane-edit-worked/server/lib/x.js" },
},
});
assert.equal(
(await request("GET", `/api/lanes/${worked.body.lane.id}`)).body.lane.detected_stage,
"implement"
);
assert.equal(
(await request("GET", `/api/lanes/${session.body.lane.id}`)).body.lane.detected_stage,
null
);
await request("DELETE", `/api/lanes/${worked.body.lane.id}`);
await request("DELETE", `/api/lanes/${session.body.lane.id}`);
});
it("falls back to the session's lane when the tool names no other lane's path", async () => {
const created = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-attr-fallback",
title: "fallback",
});
const id = created.body.lane.id;
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-attr-3",
cwd: "/tmp/lane-attr-fallback",
tool_name: "Bash",
tool_input: { command: "npm run test:server" },
},
});
assert.equal((await request("GET", `/api/lanes/${id}`)).body.lane.detected_stage, "tests");
await request("DELETE", `/api/lanes/${id}`);
});
it("keeps session bookkeeping on the session's lane, not the worked-in lane", async () => {
// session_id / needs_action ARE session-scoped facts: the session really is
// bound to the directory it started in. Only the stage inference follows
// the work.
const session = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-book-session",
title: "session",
});
const worked = await request("POST", "/api/lanes", {
cwd: "/tmp/lane-book-worked",
title: "worked",
});
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-attr-4",
cwd: "/tmp/lane-book-session",
tool_name: "Bash",
tool_input: { command: "cd /tmp/lane-book-worked && npm test" },
},
});
assert.equal(
(await request("GET", `/api/lanes/${session.body.lane.id}`)).body.lane.session_id,
"sess-attr-4"
);
assert.equal(
(await request("GET", `/api/lanes/${worked.body.lane.id}`)).body.lane.session_id,
null
);
await request("DELETE", `/api/lanes/${worked.body.lane.id}`);
await request("DELETE", `/api/lanes/${session.body.lane.id}`);
});
it("a hook event at a shared cwd only touches the claude lane, never the codex lane", async () => {
const claudeLane = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/lane-dual-tool",
tool: "claude",
});
const codexLane = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/lane-dual-tool",
tool: "codex",
});
assert.notEqual(claudeLane.body.lane.id, codexLane.body.lane.id);
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-dual-tool",
cwd: "/tmp/lane-dual-tool",
tool_name: "Bash",
tool_input: { command: "npm run test:server" },
},
});
assert.equal(
(await request("GET", `/api/lanes/${claudeLane.body.lane.id}`)).body.lane.detected_stage,
"tests"
);
assert.equal(
(await request("GET", `/api/lanes/${claudeLane.body.lane.id}`)).body.lane.session_id,
"sess-dual-tool"
);
assert.equal(
(await request("GET", `/api/lanes/${codexLane.body.lane.id}`)).body.lane.detected_stage,
null,
"the codex lane sharing this cwd must not absorb a claude hook event"
);
assert.equal(
(await request("GET", `/api/lanes/${codexLane.body.lane.id}`)).body.lane.session_id,
null
);
await request("DELETE", `/api/lanes/${claudeLane.body.lane.id}`);
await request("DELETE", `/api/lanes/${codexLane.body.lane.id}`);
});
});
describe("GET /:id/ci/status", () => {
let laneId;
let laneCwd;
let restorePath;
let bin;
before(async () => {
laneCwd = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-ci-status-route-"));
const r = await request("POST", "/api/lanes", { title: "CI status lane", cwd: laneCwd });
laneId = r.body.lane.id;
bin = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-ci-status-bin-"));
fs.writeFileSync(
path.join(bin, "gh"),
`#!/usr/bin/env bash
if [ "$1" = "run" ] && [ "$2" = "list" ]; then
echo '[{"databaseId":1,"status":"completed","conclusion":"success"}]'
exit 0
fi
if [ "$1" = "run" ] && [ "$2" = "view" ]; then
echo '{"jobs":[{"name":"tests","status":"completed","conclusion":"success","url":"https://x/1"}]}'
exit 0
fi
exit 1
`
);
fs.chmodSync(path.join(bin, "gh"), 0o755);
const originalPath = process.env.PATH;
process.env.PATH = `${bin}${path.delimiter}${originalPath}`;
restorePath = () => {
process.env.PATH = originalPath;
};
});
after(() => {
if (restorePath) restorePath();
fs.rmSync(laneCwd, { recursive: true, force: true });
fs.rmSync(bin, { recursive: true, force: true });
});
it("404s for an unknown lane", async () => {
const r = await request("GET", "/api/lanes/999999/ci/status?ref=development");
assert.equal(r.status, 404);
});
it("400s when ref is missing", async () => {
const r = await request("GET", `/api/lanes/${laneId}/ci/status`);
assert.equal(r.status, 400);
assert.equal(r.body.error.code, "EBADREF");
});
it("returns the derived state for a real ref", async () => {
const r = await request("GET", `/api/lanes/${laneId}/ci/status?ref=development`);
assert.equal(r.status, 200);
assert.equal(r.body.state, "success");
assert.equal(r.body.checks[0].name, "tests");
});
});
describe("/api/lanes tool tests", () => {
it("POST /api/lanes/ensure stores the requested tool", async () => {
const res = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/codex-lane",
tool: "codex",
});
assert.equal(res.status, 201);
assert.equal(res.body.lane.tool, "codex");
});
it("POST /api/lanes/ensure rejects an unknown tool", async () => {
const res = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/bad-tool",
tool: "cursor",
});
assert.equal(res.status, 400);
assert.equal(res.body.error.code, "EBADTOOL");
});
it("POST /api/lanes/ensure creates a second lane for the same cwd on a different tool", async () => {
const first = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/claude-lane",
tool: "claude",
});
assert.equal(first.status, 201);
const second = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/claude-lane",
tool: "codex",
});
assert.equal(second.status, 201);
assert.equal(second.body.created, true);
assert.notEqual(second.body.lane.id, first.body.lane.id);
assert.equal(second.body.lane.cwd, first.body.lane.cwd);
assert.equal(second.body.lane.tool, "codex");
assert.equal(first.body.lane.tool, "claude");
});
it("POST /api/lanes/ensure re-ensuring the same (cwd, tool) pair is a no-op", async () => {
const first = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/claude-lane-2",
tool: "claude",
});
assert.equal(first.status, 201);
const second = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/claude-lane-2",
tool: "claude",
});
assert.equal(second.status, 200);
assert.equal(second.body.created, false);
assert.equal(second.body.lane.id, first.body.lane.id);
});
it("POST /api/lanes/ensure re-ensuring with the same tool is a no-op", async () => {
const first = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/same-tool-lane",
tool: "codex",
});
assert.equal(first.status, 201);
const second = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/same-tool-lane",
tool: "codex",
});
assert.equal(second.status, 200);
assert.equal(second.body.created, false);
assert.equal(second.body.lane.tool, "codex");
});
it("POST /api/lanes/ensure defaults to 'claude' tool when tool is not specified", async () => {
const res = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/default-tool-lane",
});
assert.equal(res.status, 201);
assert.equal(res.body.lane.tool, "claude");
});
it("GET /api/lanes returns both lanes for a shared cwd with distinct IDs and tools", async () => {
const claudeLane = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/dual-list-cwd",
tool: "claude",
});
const codexLane = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/dual-list-cwd",
tool: "codex",
});
const claudeId = claudeLane.body.lane.id;
const codexId = codexLane.body.lane.id;
const list = await request("GET", "/api/lanes");
assert.equal(list.status, 200);
const dualCwdLanes = list.body.lanes.filter((l) => l.cwd === "/tmp/dual-list-cwd");
assert.equal(dualCwdLanes.length, 2, "expected both lanes to appear in the list");
const found = {
claude: dualCwdLanes.find((l) => l.tool === "claude"),
codex: dualCwdLanes.find((l) => l.tool === "codex"),
};
assert.ok(found.claude, "claude lane must be in the list");
assert.ok(found.codex, "codex lane must be in the list");
assert.equal(found.claude.id, claudeId);
assert.equal(found.codex.id, codexId);
assert.notEqual(found.claude.id, found.codex.id);
await request("DELETE", `/api/lanes/${claudeId}`);
await request("DELETE", `/api/lanes/${codexId}`);
});
it("DELETE one lane of a (cwd, tool) pair leaves the sibling lane untouched", async () => {
const claudeLane = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/dual-delete-cwd",
tool: "claude",
});
const codexLane = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/dual-delete-cwd",
tool: "codex",
});
const claudeId = claudeLane.body.lane.id;
const codexId = codexLane.body.lane.id;
// Delete the codex lane
const deleteRes = await request("DELETE", `/api/lanes/${codexId}`);
assert.equal(deleteRes.status, 200);
// Verify the codex lane is gone
assert.equal((await request("GET", `/api/lanes/${codexId}`)).status, 404);
// Verify the claude lane still exists and is untouched
const claudeAfterDelete = await request("GET", `/api/lanes/${claudeId}`);
assert.equal(claudeAfterDelete.status, 200);
assert.equal(claudeAfterDelete.body.lane.tool, "claude");
assert.equal(claudeAfterDelete.body.lane.cwd, "/tmp/dual-delete-cwd");
await request("DELETE", `/api/lanes/${claudeId}`);
});
it("POST /api/lanes/ensure rejects an invalid tool even when two valid lanes already exist at that cwd", async () => {
const claudeLane = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/dual-invalid-tool-cwd",
tool: "claude",
});
await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/dual-invalid-tool-cwd",
tool: "codex",
});
const badRes = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/dual-invalid-tool-cwd",
tool: "cursor", // invalid
});
assert.equal(badRes.status, 400);
assert.equal(badRes.body.error.code, "EBADTOOL");
await request("DELETE", `/api/lanes/${claudeLane.body.lane.id}`);
});
it("POST /api/lanes/worktree with tool parameter creates a lane with the specified tool", async () => {
const sourceRepo = require("path").join(
require("os").tmpdir(),
`agenthub-worktree-src-${Date.now()}`
);
const fs = require("fs");
fs.mkdirSync(sourceRepo, { recursive: true });
require("child_process").execSync("git init", { cwd: sourceRepo });
const res = await request("POST", "/api/lanes/worktree", {
sourceRepo,
slug: "test-codex-worktree",
tool: "codex",
});
assert.equal(res.status, 202, "worktree create should return 202 Accepted");
assert.equal(res.body.lane.tool, "codex");
// Cleanup
fs.rmSync(sourceRepo, { recursive: true, force: true });
// Note: the actual provisioning happens asynchronously, so we don't verify it here.
// Just verify the lane was created with the right tool.
if (res.body.lane.id) {
await request("DELETE", `/api/lanes/${res.body.lane.id}`);
}
});
it("POST /api/lanes/worktree defaults to 'claude' tool when tool is not specified", async () => {
const sourceRepo = require("path").join(
require("os").tmpdir(),
`agenthub-worktree-default-${Date.now()}`
);
const fs = require("fs");
fs.mkdirSync(sourceRepo, { recursive: true });
require("child_process").execSync("git init", { cwd: sourceRepo });
const res = await request("POST", "/api/lanes/worktree", {
sourceRepo,
slug: "test-default-worktree",
});
assert.equal(res.status, 202);
assert.equal(res.body.lane.tool, "claude");
// Cleanup
fs.rmSync(sourceRepo, { recursive: true, force: true });
if (res.body.lane.id) {
await request("DELETE", `/api/lanes/${res.body.lane.id}`);
}
});
it("POST /api/lanes/ensure with tool parameter preserves title on re-ensure", async () => {
const first = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/ensure-title-cwd",
tool: "claude",
title: "My Claude Lane",
});
assert.equal(first.body.lane.title, "My Claude Lane");
// Re-ensure without title should preserve the original
const second = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/ensure-title-cwd",
tool: "claude",
});
assert.equal(second.status, 200);
assert.equal(second.body.created, false);
assert.equal(second.body.lane.title, "My Claude Lane");
await request("DELETE", `/api/lanes/${first.body.lane.id}`);
});
it("two lanes at the same cwd with different tools have independent session_id tracking", async () => {
const claudeLane = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/dual-session-cwd",
tool: "claude",
});
const codexLane = await request("POST", "/api/lanes/ensure", {
cwd: "/tmp/dual-session-cwd",
tool: "codex",
});
const claudeId = claudeLane.body.lane.id;
const codexId = codexLane.body.lane.id;
// Simulate a hook event that should only update the claude lane
await request("POST", "/api/hooks/event", {
hook_type: "PostToolUse",
data: {
session_id: "sess-dual-session-test",
cwd: "/tmp/dual-session-cwd",
tool_name: "Bash",
tool_input: { command: "npm run test:server" },
},
});
const claudeAfter = await request("GET", `/api/lanes/${claudeId}`);
const codexAfter = await request("GET", `/api/lanes/${codexId}`);
assert.equal(claudeAfter.body.lane.session_id, "sess-dual-session-test");
assert.equal(codexAfter.body.lane.session_id, null);
await request("DELETE", `/api/lanes/${claudeId}`);
await request("DELETE", `/api/lanes/${codexId}`);
});
});