Files
AgentHub/server/__tests__/router-apply.test.js
T
nntrivi2001 f0500dc592 feat: AI commit messages, change tree, router behavesAs, sidebar times (v2.2.0)
- Changes panel: Files and Changes merged into one panel; the overview's
  groups drawn as a folder tree (compacted chains, file counts, fold,
  stage/unstage a whole folder)
- AI-suggested commit message for what is staged
  (POST /api/lanes/:id/git/commit-message, claude -p --safe-mode, haiku)
- AI Router: optional "Behaves as" Claude model id, written as
  modelPicker options into the router's --settings file (issue #56)
- Sidebar: session rows show last activity, live rows their start time;
  a closed Terminal row can be removed (DELETE /api/run/history/:id)
- Every pane's CLI starts only once a client is attached; claude panes
  no longer forced inline; tmux scrollbar one column wide
- Update notifier: "Check for updates" reopens a closed notice
2026-10-06 12:36:06 +07:00

258 lines
9.4 KiB
JavaScript

/**
* @file Tests for starting one session on an AI Router profile — Claude Code's
* `--settings=<file>` env file and the Codex `<name>.config.toml` layer, an
* edit or delete reaching those files, and the one-time cleanup of the old
* per-workspace router out of a workspace's `settings.local.json`.
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
*/
const os = require("node:os");
const path = require("node:path");
const fs = require("node:fs");
const SUITE_ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-router-apply-"));
process.env.CLAUDE_HOME = path.join(SUITE_ROOT, ".claude");
process.env.CODEX_HOME = path.join(SUITE_ROOT, ".codex");
process.env.DASHBOARD_DATA_DIR = path.join(SUITE_ROOT, "data");
process.env.DASHBOARD_DB_PATH = path.join(SUITE_ROOT, "dashboard.db");
const { describe, it, beforeEach, after } = require("node:test");
const assert = require("node:assert/strict");
const routers = require("../lib/ai-routers");
const lanesLib = require("../lib/lanes");
const { db } = require("../db");
const {
resolveRouter,
resumeRouter,
routerForSpawn,
refreshProfileFiles,
removeProfileFiles,
clearLaneRouters,
} = require("../lib/router-apply");
const settingsFile = (name) =>
path.join(process.env.DASHBOARD_DATA_DIR, "router-settings", `${name}.json`);
const layerFile = (name) => path.join(process.env.CODEX_HOME, `${name}.config.toml`);
after(() => fs.rmSync(SUITE_ROOT, { recursive: true, force: true }));
beforeEach(() => {
for (const dir of [
path.join(process.env.CLAUDE_HOME, "agenthub-routers"),
process.env.CODEX_HOME,
process.env.DASHBOARD_DATA_DIR,
]) {
fs.rmSync(dir, { recursive: true, force: true });
}
});
function saveNineRouter() {
return routers.createRouter({
label: "9Router",
baseUrl: "http://localhost:20128/v1",
apiKey: "sk-secret-value",
model: "Highest",
});
}
const spawnFor = (tool, name) => routerForSpawn(tool, resolveRouter(tool, name));
describe("resolveRouter", () => {
it("reads null, empty and native as the vendor's own API", () => {
for (const name of [null, undefined, "", "native"])
assert.equal(resolveRouter("claude", name), null);
});
it("refuses a profile the store does not have", () => {
assert.throws(
() => resolveRouter("claude", "nope"),
(e) => e.code === "EBADROUTER"
);
});
it("refuses a router on a tool with no endpoint to point at one", () => {
const { name } = saveNineRouter();
assert.throws(
() => resolveRouter("antigravity", name),
(e) => e.code === "EUNSUPPORTED"
);
assert.equal(resolveRouter("antigravity", "native"), null);
});
});
describe("routerForSpawn — Claude", () => {
it("passes a 0600 settings file holding the eight env keys, never the key in argv", () => {
const { name } = saveNineRouter();
const { argv, env } = spawnFor("claude", name);
assert.deepEqual(argv, [`--settings=${settingsFile(name)}`]);
assert.deepEqual(env, {});
assert.ok(!argv.join(" ").includes("sk-secret-value"));
const written = JSON.parse(fs.readFileSync(settingsFile(name), "utf8")).env;
assert.equal(written.ANTHROPIC_BASE_URL, "http://localhost:20128/v1");
assert.equal(written.ANTHROPIC_AUTH_TOKEN, "sk-secret-value");
for (const k of [
"ANTHROPIC_MODEL",
"ANTHROPIC_DEFAULT_OPUS_MODEL",
"ANTHROPIC_DEFAULT_SONNET_MODEL",
"ANTHROPIC_DEFAULT_HAIKU_MODEL",
"ANTHROPIC_SMALL_FAST_MODEL",
"CLAUDE_CODE_SUBAGENT_MODEL",
]) {
assert.equal(written[k], "Highest", k);
}
assert.equal(fs.statSync(settingsFile(name)).mode & 0o777, 0o600);
});
it("leaves a blank key out instead of blanking the user's own setting", () => {
const { name } = routers.createRouter({
label: "Local",
baseUrl: "http://127.0.0.1:20128/v1",
apiKey: "",
model: "Highest",
});
spawnFor("claude", name);
const written = JSON.parse(fs.readFileSync(settingsFile(name), "utf8")).env;
assert.equal("ANTHROPIC_AUTH_TOKEN" in written, false);
});
it("adds nothing for native", () => {
assert.deepEqual(spawnFor("claude", "native"), { argv: [], env: {} });
});
it("writes no modelPicker without behavesAs, so the user's own one still applies", () => {
const { name } = saveNineRouter();
spawnFor("claude", name);
const written = JSON.parse(fs.readFileSync(settingsFile(name), "utf8"));
assert.equal("modelPicker" in written, false);
});
it("maps every distinct router model name with behavesAs (issue #56)", () => {
const { name } = routers.createRouter({
label: "9Router combo",
baseUrl: "http://localhost:20128/v1",
model: "agent",
models: { haiku: "agent-fast", subagent: "claude-haiku-4-5-20251001" },
behavesAs: "claude-opus-5-5",
});
spawnFor("claude", name);
const written = JSON.parse(fs.readFileSync(settingsFile(name), "utf8"));
// A bare claude-* id is one Claude Code knows; mapping it would override
// its real handling.
assert.deepEqual(written.modelPicker, {
options: [
{ model: "agent", behavesAs: "claude-opus-5-5" },
{ model: "agent-fast", behavesAs: "claude-opus-5-5" },
],
});
});
});
describe("routerForSpawn — Codex", () => {
it("writes a profile layer, never the base config, and hands the key over in env", () => {
const base = path.join(process.env.CODEX_HOME, "config.toml");
fs.mkdirSync(process.env.CODEX_HOME, { recursive: true });
fs.writeFileSync(base, 'approval_policy = "never"\n');
const { name } = saveNineRouter();
const { argv, env } = spawnFor("codex", name);
assert.deepEqual(argv, ["-p", name]);
assert.deepEqual(env, { AGENTHUB_ROUTER_KEY: "sk-secret-value" });
assert.equal(fs.readFileSync(base, "utf8"), 'approval_policy = "never"\n');
const layer = fs.readFileSync(layerFile(name), "utf8");
assert.match(layer, /model_provider = "9router"/);
assert.match(layer, /base_url = "http:\/\/localhost:20128\/v1"/);
assert.match(layer, /env_key = "AGENTHUB_ROUTER_KEY"/);
// Not "chat": codex-cli 0.149 refuses to load it at all.
assert.match(layer, /wire_api = "responses"/);
assert.ok(!layer.includes("sk-secret-value"));
});
it("names no env_key when the profile has no key", () => {
const { name } = routers.createRouter({
label: "Local gateway",
baseUrl: "http://127.0.0.1:20128/v1",
apiKey: "",
model: "Highest",
});
const { env } = spawnFor("codex", name);
assert.deepEqual(env, {});
assert.ok(!fs.readFileSync(layerFile(name), "utf8").includes("env_key"));
});
});
describe("an edit or a delete reaches the session files", () => {
it("an edit rewrites the files that exist and creates none", () => {
const { name } = saveNineRouter();
spawnFor("claude", name);
routers.updateRouter(name, { apiKey: "sk-rotated" });
refreshProfileFiles(name);
const env = JSON.parse(fs.readFileSync(settingsFile(name), "utf8")).env;
assert.equal(env.ANTHROPIC_AUTH_TOKEN, "sk-rotated");
assert.equal(fs.existsSync(layerFile(name)), false);
});
it("a delete removes both files", () => {
const { name } = saveNineRouter();
spawnFor("claude", name);
spawnFor("codex", name);
removeProfileFiles(name);
assert.equal(fs.existsSync(settingsFile(name)), false);
assert.equal(fs.existsSync(layerFile(name)), false);
});
});
describe("resumeRouter", () => {
it("goes back to the router the session last ran on, while it exists", () => {
const { name } = saveNineRouter();
const runs = require("../lib/dashboard-runs");
runs.recordRun({
id: "agenthub-lane-91",
sessionId: "sess-router-1",
mode: "interactive",
router: name,
});
assert.equal(resumeRouter("sess-router-1"), name);
routers.removeRouter(name);
assert.equal(resumeRouter("sess-router-1"), null);
assert.equal(resumeRouter("sess-unknown"), null);
});
});
describe("clearLaneRouters", () => {
it("takes the old router's keys out of a workspace's settings and clears the column", () => {
const cwd = path.join(SUITE_ROOT, "old-lane");
const file = path.join(cwd, ".claude", "settings.local.json");
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(
file,
JSON.stringify({
permissions: { allow: ["Bash(ls)"] },
env: { ANTHROPIC_BASE_URL: "http://x", ANTHROPIC_AUTH_TOKEN: "sk", MY_VAR: "1" },
})
);
const lane = lanesLib.createLane({ title: "old", cwd, router_profile: "9router" });
clearLaneRouters();
const data = JSON.parse(fs.readFileSync(file, "utf8"));
assert.deepEqual(data.env, { MY_VAR: "1" });
assert.deepEqual(data.permissions, { allow: ["Bash(ls)"] });
assert.equal(
db.prepare("SELECT router_profile FROM lanes WHERE id = ?").get(lane.id).router_profile,
null
);
});
it("keeps the column of a workspace whose file it could not clean, to retry later", () => {
const cwd = path.join(SUITE_ROOT, "broken-lane");
const file = path.join(cwd, ".claude", "settings.local.json");
fs.mkdirSync(path.dirname(file), { recursive: true });
fs.writeFileSync(file, "{ not json");
const lane = lanesLib.createLane({ title: "broken", cwd, router_profile: "9router" });
clearLaneRouters();
assert.equal(fs.readFileSync(file, "utf8"), "{ not json");
assert.equal(
db.prepare("SELECT router_profile FROM lanes WHERE id = ?").get(lane.id).router_profile,
"9router"
);
});
});