f0500dc592
- 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
258 lines
9.4 KiB
JavaScript
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"
|
|
);
|
|
});
|
|
});
|