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
This commit is contained in:
@@ -164,6 +164,21 @@ describe("ai-routers — update", () => {
|
||||
it("reports a name that does not exist", () => {
|
||||
assert.throws(() => routers.updateRouter("ghost", { label: "x" }), { code: "ENOTFOUND" });
|
||||
});
|
||||
|
||||
it("sets, keeps and clears behavesAs, and refuses an id that is not a Claude model's", () => {
|
||||
const created = routers.createRouter(nineRouter({ behavesAs: " claude-opus-5-5 " }));
|
||||
assert.equal(created.behavesAs, "claude-opus-5-5");
|
||||
assert.equal(
|
||||
routers.updateRouter(created.name, { label: "renamed" }).behavesAs,
|
||||
"claude-opus-5-5"
|
||||
);
|
||||
assert.equal(routers.updateRouter(created.name, { behavesAs: "" }).behavesAs, "");
|
||||
for (const bad of ["agent", "claude opus", "-claude-x", 5]) {
|
||||
assert.throws(() => routers.updateRouter(created.name, { behavesAs: bad }), {
|
||||
code: "EBADMODEL",
|
||||
});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("ai-routers — remove", () => {
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
/**
|
||||
* @file Tests for server/lib/commit-message.js against a real git repo and a
|
||||
* fake `claude` script: only staged, in-lane, non-secret changes reach the
|
||||
* model; its argv keeps tools, hooks and persistence off; its answer is
|
||||
* cleaned; and every failure is a coded error.
|
||||
* @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 { execFileSync } = require("node:child_process");
|
||||
|
||||
const { suggestCommitMessage, buildInput } = require("../lib/commit-message");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-commitmsg-"));
|
||||
const REPO = path.join(ROOT, "repo");
|
||||
const SUB = path.join(REPO, "sub");
|
||||
const ARGS = path.join(ROOT, "args.txt");
|
||||
const STDIN = path.join(ROOT, "stdin.txt");
|
||||
|
||||
const g = (...args) => {
|
||||
const env = { ...process.env };
|
||||
delete env.GIT_DIR;
|
||||
delete env.GIT_INDEX_FILE;
|
||||
execFileSync("git", ["-c", "user.email=t@h", "-c", "user.name=t", ...args], { cwd: REPO, env });
|
||||
};
|
||||
const write = (file, body) => {
|
||||
fs.mkdirSync(path.dirname(path.join(REPO, file)), { recursive: true });
|
||||
fs.writeFileSync(path.join(REPO, file), body);
|
||||
};
|
||||
/** A fake claude: records argv and stdin, prints `reply`, exits `code`. */
|
||||
function fakeClaude(reply, code = 0) {
|
||||
const bin = path.join(ROOT, `claude-${Math.random().toString(36).slice(2)}`);
|
||||
fs.writeFileSync(
|
||||
bin,
|
||||
`#!/bin/sh\nprintf '%s\\n' "$@" > '${ARGS}'\ncat > '${STDIN}'\nprintf '${reply}'\nexit ${code}\n`,
|
||||
{ mode: 0o755 }
|
||||
);
|
||||
return bin;
|
||||
}
|
||||
|
||||
before(() => {
|
||||
fs.mkdirSync(REPO);
|
||||
g("init", "-q", "-b", "main");
|
||||
write("a.txt", "one\n");
|
||||
g("add", "-A");
|
||||
g("commit", "-q", "-m", "feat: seed");
|
||||
});
|
||||
after(() => fs.rmSync(ROOT, { recursive: true, force: true }));
|
||||
|
||||
describe("commit-message", () => {
|
||||
it("refuses with ENOSTAGED when nothing is staged", async () => {
|
||||
write("a.txt", "one\nunstaged\n");
|
||||
await assert.rejects(buildInput(REPO), { code: "ENOSTAGED" });
|
||||
});
|
||||
|
||||
it("sends only staged, non-secret changes and keeps tools/hooks/persistence off", async () => {
|
||||
write("b.txt", "staged line\n");
|
||||
write(".claude/settings.local.json", '{"env":{"KEY":"sk-secret"}}\n');
|
||||
g("add", "-f", "b.txt", ".claude/settings.local.json");
|
||||
const msg = await suggestCommitMessage(REPO, { bin: fakeClaude("```\\nfeat: add b\\n```") });
|
||||
assert.equal(msg, "feat: add b");
|
||||
const stdin = fs.readFileSync(STDIN, "utf8");
|
||||
assert.match(stdin, /feat: seed/);
|
||||
assert.match(stdin, /staged line/);
|
||||
assert.doesNotMatch(stdin, /unstaged|sk-secret|settings\.local/);
|
||||
const argv = fs.readFileSync(ARGS, "utf8").split("\n");
|
||||
for (const flag of ["-p", "--safe-mode", "--no-session-persistence", "--tools"]) {
|
||||
assert.ok(argv.includes(flag), flag);
|
||||
}
|
||||
assert.equal(argv[argv.indexOf("--tools") + 1], "");
|
||||
});
|
||||
|
||||
it("scopes a subdirectory lane to its own staged files", async () => {
|
||||
write("sub/c.txt", "inside\n");
|
||||
g("add", "sub/c.txt");
|
||||
const input = await buildInput(SUB);
|
||||
assert.match(input, /inside/);
|
||||
assert.doesNotMatch(input, /staged line/);
|
||||
});
|
||||
|
||||
it("reports a failing or silent CLI as EAI", async () => {
|
||||
await assert.rejects(suggestCommitMessage(REPO, { bin: fakeClaude("boom", 1) }), {
|
||||
code: "EAI",
|
||||
});
|
||||
await assert.rejects(suggestCommitMessage(REPO, { bin: fakeClaude("") }), { code: "EAI" });
|
||||
});
|
||||
});
|
||||
@@ -52,6 +52,15 @@ describe("dashboard-runs", () => {
|
||||
assert.equal(runs.listRuns({ laneId: 8 }).length, 2);
|
||||
});
|
||||
|
||||
it("forgets a closed Terminal tab and nothing of another tool", () => {
|
||||
start("agenthub-lane-9", { laneId: 9, tool: "claude" });
|
||||
start("agenthub-lane-9-1", { laneId: 9, tool: "shell" });
|
||||
assert.equal(runs.forgetShellRun("agenthub-lane-9"), false);
|
||||
assert.ok(runs.getRun("agenthub-lane-9"));
|
||||
assert.equal(runs.forgetShellRun("agenthub-lane-9-1"), true);
|
||||
assert.equal(runs.getRun("agenthub-lane-9-1"), null);
|
||||
});
|
||||
|
||||
it("flags as abandoned only the running rows whose pane is gone", () => {
|
||||
start("agenthub-lane-5", { laneId: 5 });
|
||||
start("agenthub-lane-6", { laneId: 6 });
|
||||
|
||||
@@ -185,7 +185,8 @@ describe("lane-sources instructions and launch", () => {
|
||||
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);
|
||||
// The CLI rides behind `sh -c <wait for a client> sh` (pty-run.js).
|
||||
if (args[0] === "new-session") argv = args.slice(args.indexOf("--") + 5);
|
||||
return "";
|
||||
});
|
||||
pty.spawnRun({ laneId: lane.id, cwd: ws, initialPrompt: "-c fix it" });
|
||||
|
||||
@@ -1105,6 +1105,11 @@ describe("the lane git surface: changes, branches, actions (issue #34)", () => {
|
||||
const bogus = await request("POST", `/api/lanes/${laneId}/git/rebase`, {});
|
||||
assert.equal(bogus.status, 400);
|
||||
assert.equal(bogus.body.error.code, "EBADACTION");
|
||||
|
||||
// Its own route, not an action: never reaches claude with nothing staged.
|
||||
const suggest = await request("POST", `/api/lanes/${laneId}/git/commit-message`, {});
|
||||
assert.equal(suggest.status, 400);
|
||||
assert.equal(suggest.body.error.code, "ENOSTAGED");
|
||||
});
|
||||
|
||||
it("400s a publish to a url that is not https, ssh or scp-like", async () => {
|
||||
|
||||
@@ -75,11 +75,9 @@ describe("pty-run", () => {
|
||||
const { spawn } = require("node:child_process");
|
||||
const bin = fs.mkdtempSync(path.join(os.tmpdir(), "fake-tmux-"));
|
||||
const flag = path.join(bin, "attached");
|
||||
fs.writeFileSync(
|
||||
path.join(bin, "tmux"),
|
||||
`#!/bin/sh\n[ -e '${flag}' ] && echo 1 || echo 0\n`,
|
||||
{ mode: 0o755 }
|
||||
);
|
||||
fs.writeFileSync(path.join(bin, "tmux"), `#!/bin/sh\n[ -e '${flag}' ] && echo 1 || echo 0\n`, {
|
||||
mode: 0o755,
|
||||
});
|
||||
try {
|
||||
const t0 = Date.now();
|
||||
const child = spawn("sh", ["-c", argv[2]], {
|
||||
@@ -492,11 +490,43 @@ describe("pty-run", () => {
|
||||
return calls.find((c) => c[0] === "new-session");
|
||||
}
|
||||
|
||||
/** The pane's own command line — everything tmux passes after the `--`. */
|
||||
/** The CLI's own command line: what tmux passes after the `--`, less the
|
||||
* `sh -c '<wait for a client>; exec "$@"' sh` wrapper spawnRun puts first. */
|
||||
function paneArgv(newSessionCall) {
|
||||
return newSessionCall.slice(newSessionCall.indexOf("--") + 1);
|
||||
const argv = newSessionCall.slice(newSessionCall.indexOf("--") + 1);
|
||||
assert.deepEqual([argv[0], argv[1], argv[3]], ["sh", "-c", "sh"]);
|
||||
assert.match(argv[2], /session_attached.*exec "\$@"$/);
|
||||
return argv.slice(4);
|
||||
}
|
||||
|
||||
it("spawnRun starts the CLI only once the pane has its real size, argv untouched", () => {
|
||||
// A claude drawn at tmux's birth size duplicated its banner on the first
|
||||
// resize; the argv rides as positional parameters, never script text.
|
||||
const call = captureNewSession(() =>
|
||||
pty.spawnRun({ laneId: 14, cwd: "/tmp/repo", initialPrompt: "a'; rm -rf / #" })
|
||||
);
|
||||
const argv = paneArgv(call);
|
||||
assert.ok(argv.includes("a'; rm -rf / #"));
|
||||
const { execFileSync } = require("node:child_process");
|
||||
const out = execFileSync(
|
||||
"sh",
|
||||
[
|
||||
...call.slice(call.indexOf("--") + 2, call.indexOf("--") + 4),
|
||||
"sh",
|
||||
"printf",
|
||||
"%s|",
|
||||
"x y",
|
||||
"$HOME",
|
||||
],
|
||||
{
|
||||
env: { ...process.env, PATH: "/nonexistent-tmux-dir:" + process.env.PATH, TMUX_PANE: "" },
|
||||
encoding: "utf8",
|
||||
timeout: 5000,
|
||||
}
|
||||
);
|
||||
assert.equal(out, "x y|$HOME|");
|
||||
});
|
||||
|
||||
it("spawnRun builds a codex argv for a codex lane", () => {
|
||||
const call = captureNewSession(() =>
|
||||
pty.spawnRun({
|
||||
@@ -583,15 +613,11 @@ describe("pty-run", () => {
|
||||
assert.ok(!envFlags(claude).some((a) => a.startsWith("CODEX_HOME=")));
|
||||
});
|
||||
|
||||
it("keeps a claude pane off the alternate screen, so tmux scrolls (and draws a scrollbar for) it", () => {
|
||||
it("leaves a claude pane's screen mode to the user's own tui setting", () => {
|
||||
// Forcing it inline duplicated the transcript on every pane narrowing.
|
||||
const envFlags = (call) => call.filter((a, n) => call[n - 1] === "-e");
|
||||
const claude = captureNewSession(() => pty.spawnRun({ laneId: 42, cwd: "/tmp/repo" }));
|
||||
assert.ok(envFlags(claude).includes("CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=1"));
|
||||
tmux.__reset();
|
||||
const codex = captureNewSession(() =>
|
||||
pty.spawnRun({ laneId: 43, cwd: "/tmp/repo", tool: "codex" })
|
||||
);
|
||||
assert.ok(!envFlags(codex).some((a) => a.startsWith("CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=")));
|
||||
assert.ok(!envFlags(claude).some((a) => a.startsWith("CLAUDE_CODE_DISABLE_ALTERNATE_SCREEN=")));
|
||||
});
|
||||
|
||||
describe("catalog tools", () => {
|
||||
@@ -719,7 +745,11 @@ describe("pty-run", () => {
|
||||
assert.equal(envOf(ns, "GOOSE_MODE"), "auto");
|
||||
assert.ok(!calls.some((c) => c[0] === "set-buffer"));
|
||||
live = true;
|
||||
t.mock.timers.tick(3000);
|
||||
// delayMs (3000) counts from when the CLI starts: after up to 2 s of
|
||||
// waiting for a client to attach.
|
||||
t.mock.timers.tick(4999);
|
||||
assert.ok(!calls.some((c) => c[0] === "set-buffer"));
|
||||
t.mock.timers.tick(1);
|
||||
assert.deepEqual(
|
||||
calls.find((c) => c[0] === "set-buffer"),
|
||||
["set-buffer", "-b", "agenthub-paste-agenthub-lane-16", "--", "fix it"]
|
||||
|
||||
@@ -119,6 +119,33 @@ describe("routerForSpawn — Claude", () => {
|
||||
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", () => {
|
||||
|
||||
@@ -59,7 +59,7 @@ describe("tmux wrapper", () => {
|
||||
"-t",
|
||||
"=agenthub-lane-1:",
|
||||
"pane-scrollbars-style",
|
||||
"fg=brightwhite,bg=black,width=2,pad=0",
|
||||
"fg=brightwhite,bg=black,width=1,pad=0",
|
||||
]);
|
||||
});
|
||||
|
||||
|
||||
Reference in New Issue
Block a user