feat: AI Router per session; Settings at the sidebar's foot; shipped tool icons
AI Router is a session's choice, not a workspace's: - an AI Router select beside the model picker in the workspace home and a pane's run form (RouterPicker.tsx; hidden off Claude/Codex or with only Native; last pick remembered in agenthub.session.router) - `router` on POST /api/lanes/:id/start and POST /api/run; Claude gets --settings=<data dir>/router-settings/<name>.json (0600, outranks project and user settings, key never in argv), Codex -p <name> + key via tmux -e - dashboard_runs.router; a resume naming none goes back to it - profile PATCH rewrites the session files, DELETE removes them - gone: POST /api/lanes/:id/router/apply, LaneRouterRow, the Add-workspace router radio, the claude-/codex-home `routers` answers; clearLaneRouters takes the old keys out of each workspace's settings.local.json at start Sidebar: Settings pinned to the foot, the collapse toggle beside it (stacked on the rail) instead of in the brand's corner. Icons: shipped SVG marks for claude, codex, openclaw and hermes (a black/white pair swapped by theme) in client/public/ai-tools/, used by AiToolIcon before the server's favicon.
This commit is contained in:
@@ -267,6 +267,59 @@ describe("pty-run", () => {
|
||||
}
|
||||
});
|
||||
|
||||
it("spawnRun starts a session on ITS router: Claude by --settings, Codex by -p", () => {
|
||||
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 "";
|
||||
});
|
||||
// The router store and the files a spawn writes live under these homes.
|
||||
const saved = ["CLAUDE_HOME", "CODEX_HOME", "DASHBOARD_DATA_DIR"].map((k) => [
|
||||
k,
|
||||
process.env[k],
|
||||
]);
|
||||
process.env.CLAUDE_HOME = path.join(TMP, "claude");
|
||||
process.env.CODEX_HOME = path.join(TMP, "codex");
|
||||
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
||||
try {
|
||||
const { name } = require("../lib/ai-routers").createRouter({
|
||||
label: "Router",
|
||||
baseUrl: "http://localhost:20128/v1",
|
||||
apiKey: "sk-secret-value",
|
||||
model: "M",
|
||||
});
|
||||
pty.spawnRun({ laneId: 51, cwd: "/tmp/repo", router: name, initialPrompt: "hi" });
|
||||
const flag = newSessionArgv.find((a) => a.startsWith("--settings="));
|
||||
assert.equal(flag, `--settings=${path.join(TMP, "data", "router-settings", `${name}.json`)}`);
|
||||
assert.ok(!newSessionArgv.join(" ").includes("sk-secret-value"));
|
||||
assert.equal(require("../lib/dashboard-runs").getRun("agenthub-lane-51").router, name);
|
||||
|
||||
pty.spawnRun({ laneId: 52, cwd: "/tmp/repo", tool: "codex", router: name });
|
||||
const at = newSessionArgv.indexOf("-p");
|
||||
assert.deepEqual(newSessionArgv.slice(at, at + 2), ["-p", name]);
|
||||
// The key rides tmux's -e, never the command line.
|
||||
assert.ok(newSessionArgv.includes("AGENTHUB_ROUTER_KEY=sk-secret-value"));
|
||||
|
||||
pty.spawnRun({ laneId: 53, cwd: "/tmp/repo" });
|
||||
assert.ok(!newSessionArgv.some((a) => a.startsWith("--settings=")));
|
||||
assert.equal(require("../lib/dashboard-runs").getRun("agenthub-lane-53").router, null);
|
||||
assert.throws(
|
||||
() => pty.spawnRun({ laneId: 54, cwd: "/tmp/repo", router: "nope" }),
|
||||
(e) => e.code === "EBADROUTER"
|
||||
);
|
||||
} finally {
|
||||
for (const [k, v] of saved) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it("spawnRun offers bypass in the pane's shift+tab cycle without starting in it", () => {
|
||||
let newSessionArgv = null;
|
||||
tmux.__setExecImpl((args) => {
|
||||
|
||||
Reference in New Issue
Block a user