feat: v2.5.12 — plan and quota for every AI tool, Grok Build and Kimi Code, Quit AgentHub (#65)
Usage panel (status bar): - every account shows its plan (Claude subscriptionType, Codex chatgpt_plan_type, Cursor membershipType, Grok subscriptionTier) - new quota rows: Cursor CLI (cursor.com usage-summary), Antigravity (`agy --print /usage`, two model pools; AGENTHUB_AGY_USAGE=0 never runs agy), Grok Build, Kimi Code, OpenCode Go — each login read from the tool's own file, sent only to its own host while valid, never refreshed - in-flight quota reads are shared between tabs AI Tools catalog: Grok Build (`grok`) and Kimi Code (`kimi`) added; Gemini CLI, Hermes and OpenClaw removed. Also: Quit AgentHub (#65, `agenthub stop`), status-area icon, Add to Chat on an editor selection, Settings in three groups, JetBrains-style Changes panel (ticked files/hunks, ignore, blame, file history), terminal font. --no-verify: :3000 is taken by another dev server (lane-detect tests); both suites run by hand — server 2636/2639 (the 3 are :3000), client 1131/1131.
This commit is contained in:
@@ -59,7 +59,7 @@ describe("/api/ai-tools", () => {
|
||||
assert.equal(agy.path, path.join(BIN, "agy"));
|
||||
assert.equal(agy.resume, true);
|
||||
assert.equal(agy.bypass, true);
|
||||
assert.equal(body.tools.find((t) => t.id === "gemini").installed, false);
|
||||
assert.equal(body.tools.find((t) => t.id === "opencode").installed, false);
|
||||
assert.equal("args" in agy, false);
|
||||
});
|
||||
|
||||
@@ -76,7 +76,7 @@ describe("/api/ai-tools", () => {
|
||||
|
||||
it("answers ENOTOOL / EBADDEFAULT / EBADFIELD", async () => {
|
||||
assert.equal((await request("PUT", "/api/ai-tools/nope", { enabled: false })).status, 404);
|
||||
const bad = await request("PUT", "/api/ai-tools/gemini", { default: true });
|
||||
const bad = await request("PUT", "/api/ai-tools/opencode", { default: true });
|
||||
assert.equal(bad.status, 400);
|
||||
assert.equal(bad.body.error.code, "EBADDEFAULT");
|
||||
assert.equal((await request("PUT", "/api/ai-tools/claude", { enabled: "no" })).status, 400);
|
||||
@@ -112,10 +112,10 @@ describe("/api/ai-tools", () => {
|
||||
});
|
||||
|
||||
it("refresh re-detects", async () => {
|
||||
fs.writeFileSync(path.join(BIN, "gemini"), "#!/bin/sh\n");
|
||||
fs.chmodSync(path.join(BIN, "gemini"), 0o755);
|
||||
fs.writeFileSync(path.join(BIN, "opencode"), "#!/bin/sh\n");
|
||||
fs.chmodSync(path.join(BIN, "opencode"), 0o755);
|
||||
const r = await request("POST", "/api/ai-tools/refresh");
|
||||
assert.equal(r.body.tools.find((t) => t.id === "gemini").installed, true);
|
||||
assert.equal(r.body.tools.find((t) => t.id === "opencode").installed, true);
|
||||
});
|
||||
|
||||
it("/api/run/binary resolves through the catalog", async () => {
|
||||
@@ -132,9 +132,9 @@ describe("lanes on catalog tools", () => {
|
||||
fs.mkdirSync(d, { recursive: true });
|
||||
return d;
|
||||
};
|
||||
let r = await request("POST", "/api/lanes", { cwd: dir("a"), title: "a", tool: "gemini" });
|
||||
let r = await request("POST", "/api/lanes", { cwd: dir("a"), title: "a", tool: "opencode" });
|
||||
assert.equal(r.status, 201);
|
||||
assert.equal(r.body.lane.tool, "gemini");
|
||||
assert.equal(r.body.lane.tool, "opencode");
|
||||
r = await request("POST", "/api/lanes", { cwd: dir("b"), title: "b", tool: "omp" });
|
||||
assert.equal(r.status, 201, "a tool that is not installed may still get a lane");
|
||||
r = await request("POST", "/api/lanes", { cwd: dir("c"), title: "c", tool: "nope" });
|
||||
@@ -212,8 +212,8 @@ describe("install / update / icons", () => {
|
||||
const notInstalled = await request("POST", "/api/ai-tools/codex/update");
|
||||
assert.equal(notInstalled.status, 409);
|
||||
assert.equal(notInstalled.body.error.code, "ENOTINSTALLED");
|
||||
// gemini was put on PATH by the refresh test, outside any npm prefix.
|
||||
const notNpm = await request("POST", "/api/ai-tools/gemini/update");
|
||||
// opencode was put on PATH by the refresh test, outside any npm prefix.
|
||||
const notNpm = await request("POST", "/api/ai-tools/opencode/update");
|
||||
assert.equal(notNpm.status, 409);
|
||||
assert.equal(notNpm.body.error.code, "ENOTNPM");
|
||||
const cross = await request("POST", "/api/ai-tools/copilot/install", undefined, {
|
||||
@@ -241,7 +241,7 @@ describe("install / update / icons", () => {
|
||||
assert.deepEqual(spawned.at(-1), [path.join(BIN, "claude"), "update"]);
|
||||
const v = await request("GET", "/api/ai-tools/versions");
|
||||
assert.equal(v.status, 200);
|
||||
assert.equal(v.body.gemini.npmManaged, false);
|
||||
assert.equal(v.body.opencode.npmManaged, false);
|
||||
});
|
||||
|
||||
it("update-all answers ENOTHING when nothing has an update", async () => {
|
||||
|
||||
@@ -82,6 +82,19 @@ describe("commit-message", () => {
|
||||
assert.doesNotMatch(input, /staged line/);
|
||||
});
|
||||
|
||||
it("with paths, reads exactly those files' working-tree changes, untracked included", async () => {
|
||||
write("a.txt", "one\nticked edit\n");
|
||||
write("new.txt", "brand new\n");
|
||||
write("other.txt", "not ticked\n");
|
||||
write(".claude/settings.local.json", '{"env":{"KEY":"sk-ticked"}}\n');
|
||||
const input = await buildInput(REPO, ["a.txt", "new.txt", ".claude/settings.local.json"]);
|
||||
assert.match(input, /ticked edit/);
|
||||
assert.match(input, /brand new/);
|
||||
assert.doesNotMatch(input, /not ticked|staged line|sk-ticked|settings\.local/);
|
||||
await assert.rejects(buildInput(REPO, ["nope.txt"]), { code: "EBADPATH" });
|
||||
await assert.rejects(buildInput(REPO, []), { code: "ENOSTAGED" });
|
||||
});
|
||||
|
||||
it("reports a failing or silent CLI as EAI", async () => {
|
||||
await assert.rejects(suggestCommitMessage(REPO, { bin: fakeClaude("boom", 1) }), {
|
||||
code: "EAI",
|
||||
|
||||
@@ -0,0 +1,301 @@
|
||||
/**
|
||||
* @file The Changes panel's JetBrains-style commit against a REAL repo:
|
||||
* `commit`/`amend` with `paths` take exactly the ticked files' working-tree
|
||||
* content (an untracked one included) and leave every other change — staged
|
||||
* or not — where it was; a failed commit puts a ticked untracked file back
|
||||
* out of the index. And `ignore` (Add to .gitignore): an anchored, escaped
|
||||
* line in the workspace's own .gitignore, only for an untracked path, never
|
||||
* twice, never through a symlink. Then the hunk-level commit (`hunks`: only
|
||||
* the ticked change blocks, through a temporary index, the real index synced
|
||||
* afterwards and the working tree untouched), `blameFile` (Annotate) and a
|
||||
* file's history with its parents rewritten so the graph joins up.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach } = 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 { runGitAction } = require("../lib/git-ops");
|
||||
const { blameFile, listCommits } = require("../lib/git-diff");
|
||||
|
||||
const ROOT = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-commit-paths-"));
|
||||
const REPO = path.join(ROOT, "repo");
|
||||
|
||||
const g = (...args) => {
|
||||
const env = { ...process.env };
|
||||
delete env.GIT_DIR;
|
||||
delete env.GIT_WORK_TREE;
|
||||
delete env.GIT_INDEX_FILE;
|
||||
return execFileSync("git", args, { cwd: REPO, encoding: "utf8", env });
|
||||
};
|
||||
const write = (file, body) => fs.writeFileSync(path.join(REPO, file), body);
|
||||
const read = (file) => fs.readFileSync(path.join(REPO, file), "utf8");
|
||||
const status = () => g("status", "--porcelain=v1", "--untracked-files=all");
|
||||
const inHead = () => g("ls-tree", "-r", "--name-only", "HEAD").split("\n").filter(Boolean);
|
||||
|
||||
beforeEach(() => {
|
||||
fs.rmSync(REPO, { recursive: true, force: true });
|
||||
fs.mkdirSync(REPO, { recursive: true });
|
||||
g("init", "-q", "-b", "main");
|
||||
g("config", "user.email", "t@h");
|
||||
g("config", "user.name", "t");
|
||||
write("a.txt", "a\n");
|
||||
write("b.txt", "b\n");
|
||||
write("c.txt", "c\n");
|
||||
g("add", "-A");
|
||||
g("commit", "-qm", "init");
|
||||
});
|
||||
|
||||
describe("commit with paths", () => {
|
||||
it("commits only the ticked files, untracked included, and leaves the rest", async () => {
|
||||
write("a.txt", "a2\n"); // ticked, unstaged
|
||||
write("b.txt", "b2\n");
|
||||
g("add", "b.txt"); // staged but NOT ticked: must stay staged, uncommitted
|
||||
write("c.txt", "c2\n"); // not ticked
|
||||
write("new.txt", "n\n"); // ticked, untracked
|
||||
await runGitAction(REPO, "commit", { message: "pick", paths: ["a.txt", "new.txt"] });
|
||||
assert.equal(g("log", "-1", "--format=%s").trim(), "pick");
|
||||
assert.deepEqual(
|
||||
g("show", "--name-only", "--format=", "HEAD").split("\n").filter(Boolean).sort(),
|
||||
["a.txt", "new.txt"]
|
||||
);
|
||||
assert.equal(g("show", "HEAD:a.txt"), "a2\n");
|
||||
assert.match(status(), /^M {2}b\.txt$/m);
|
||||
assert.match(status(), /^ M c\.txt$/m);
|
||||
});
|
||||
|
||||
it("commits a partly staged file's working-tree content, a deletion too", async () => {
|
||||
write("a.txt", "staged\n");
|
||||
g("add", "a.txt");
|
||||
write("a.txt", "staged\nand more\n");
|
||||
fs.rmSync(path.join(REPO, "b.txt"));
|
||||
await runGitAction(REPO, "commit", { message: "m", paths: ["a.txt", "b.txt"] });
|
||||
assert.equal(g("show", "HEAD:a.txt"), "staged\nand more\n");
|
||||
assert.ok(!inHead().includes("b.txt"));
|
||||
assert.equal(status(), "");
|
||||
});
|
||||
|
||||
it("refuses an empty list and a path git does not report changed", async () => {
|
||||
write("a.txt", "a2\n");
|
||||
await assert.rejects(runGitAction(REPO, "commit", { message: "m", paths: [] }), {
|
||||
code: "EBADPATH",
|
||||
});
|
||||
await assert.rejects(runGitAction(REPO, "commit", { message: "m", paths: ["b.txt"] }), {
|
||||
code: "EBADPATH",
|
||||
});
|
||||
});
|
||||
|
||||
it("puts a ticked untracked file back out of the index when the commit fails", async () => {
|
||||
write("new.txt", "n\n");
|
||||
const hook = path.join(REPO, ".git", "hooks", "pre-commit");
|
||||
fs.writeFileSync(hook, "#!/bin/sh\nexit 1\n", { mode: 0o755 });
|
||||
await assert.rejects(runGitAction(REPO, "commit", { message: "m", paths: ["new.txt"] }));
|
||||
assert.match(status(), /^\?\? new\.txt$/m);
|
||||
});
|
||||
});
|
||||
|
||||
describe("amend with paths", () => {
|
||||
it("an empty list rewords only, leaving the staged change out", async () => {
|
||||
write("a.txt", "a2\n");
|
||||
g("add", "a.txt");
|
||||
const head = g("rev-parse", "HEAD").trim();
|
||||
await runGitAction(REPO, "amend", { message: "reworded", sha: head, paths: [] });
|
||||
assert.equal(g("log", "-1", "--format=%s").trim(), "reworded");
|
||||
assert.equal(g("show", "HEAD:a.txt"), "a\n");
|
||||
assert.match(status(), /^M {2}a\.txt$/m);
|
||||
});
|
||||
|
||||
it("folds the ticked files in, and refuses an empty list with no message", async () => {
|
||||
write("new.txt", "n\n");
|
||||
const head = g("rev-parse", "HEAD").trim();
|
||||
await assert.rejects(runGitAction(REPO, "amend", { sha: head, paths: [] }), {
|
||||
code: "ENOCHANGES",
|
||||
});
|
||||
await runGitAction(REPO, "amend", { sha: head, paths: ["new.txt"] });
|
||||
assert.equal(g("log", "-1", "--format=%s").trim(), "init");
|
||||
assert.ok(inHead().includes("new.txt"));
|
||||
assert.equal(g("rev-list", "--count", "HEAD").trim(), "1");
|
||||
});
|
||||
});
|
||||
|
||||
describe("ignore", () => {
|
||||
it("appends an anchored, escaped line once, keeping the file's last line", async () => {
|
||||
write(".gitignore", "node_modules");
|
||||
write("debug*.log", "x\n");
|
||||
write("note.md", "x\n");
|
||||
await runGitAction(REPO, "ignore", { paths: ["debug*.log", "note.md"] });
|
||||
assert.equal(read(".gitignore"), "node_modules\n/debug\\*.log\n/note.md\n");
|
||||
assert.doesNotMatch(status(), /debug|note/);
|
||||
});
|
||||
|
||||
it("creates the file when there is none, in a subdirectory too", async () => {
|
||||
fs.mkdirSync(path.join(REPO, "dir"));
|
||||
write("dir/x y.tmp", "x\n");
|
||||
await runGitAction(REPO, "ignore", { path: "dir/x y.tmp" });
|
||||
assert.equal(read(".gitignore"), "/dir/x y.tmp\n");
|
||||
assert.doesNotMatch(status(), /x y\.tmp/);
|
||||
});
|
||||
|
||||
it("refuses a tracked file and a .gitignore that is a symlink", async () => {
|
||||
write("a.txt", "a2\n");
|
||||
await assert.rejects(runGitAction(REPO, "ignore", { paths: ["a.txt"] }), { code: "ETRACKED" });
|
||||
write("new.txt", "n\n");
|
||||
const outside = path.join(ROOT, "outside-ignore");
|
||||
fs.writeFileSync(outside, "");
|
||||
fs.symlinkSync(outside, path.join(REPO, ".gitignore"));
|
||||
await assert.rejects(runGitAction(REPO, "ignore", { paths: ["new.txt"] }), {
|
||||
code: "EBADPATH",
|
||||
});
|
||||
assert.equal(fs.readFileSync(outside, "utf8"), "");
|
||||
});
|
||||
});
|
||||
|
||||
/** The `@@` lines of `file`'s HEAD → working tree diff at -U3, in order. */
|
||||
const headers = (file) =>
|
||||
g("diff", "-U3", "HEAD", "--", file)
|
||||
.split("\n")
|
||||
.filter((l) => l.startsWith("@@ "));
|
||||
|
||||
describe("commit with hunks", () => {
|
||||
beforeEach(() => {
|
||||
write("long.txt", `${Array.from({ length: 30 }, (_, i) => i + 1).join("\n")}\n`);
|
||||
g("add", "long.txt");
|
||||
g("commit", "-qm", "long");
|
||||
});
|
||||
const edit = (pairs) => {
|
||||
let text = read("long.txt");
|
||||
for (const [from, to] of pairs) text = text.replace(new RegExp(`^${from}$`, "m"), to);
|
||||
write("long.txt", text);
|
||||
};
|
||||
|
||||
it("commits only the ticked hunk, leaving the rest in the working tree, unstaged", async () => {
|
||||
edit([
|
||||
["3", "three"],
|
||||
["25", "twentyfive"],
|
||||
]);
|
||||
const [first] = headers("long.txt");
|
||||
await runGitAction(REPO, "commit", {
|
||||
message: "part",
|
||||
context: 3,
|
||||
hunks: { "long.txt": [{ hunk: first, blocks: [0] }] },
|
||||
});
|
||||
const committed = g("show", "HEAD:long.txt");
|
||||
assert.match(committed, /^three$/m);
|
||||
assert.match(committed, /^25$/m);
|
||||
assert.match(read("long.txt"), /^twentyfive$/m);
|
||||
// The index followed the commit: the rest is an unstaged change only.
|
||||
assert.match(status(), /^ M long\.txt$/m);
|
||||
});
|
||||
|
||||
it("takes one block of a hunk that holds two, with whole files beside it", async () => {
|
||||
edit([
|
||||
["3", "three"],
|
||||
["5", "five"],
|
||||
]);
|
||||
write("a.txt", "a2\n");
|
||||
const [only] = headers("long.txt");
|
||||
await runGitAction(REPO, "commit", {
|
||||
message: "block",
|
||||
context: 3,
|
||||
paths: ["a.txt"],
|
||||
hunks: { "long.txt": [{ hunk: only, blocks: [1] }] },
|
||||
});
|
||||
const committed = g("show", "HEAD:long.txt");
|
||||
assert.match(committed, /^3$/m);
|
||||
assert.match(committed, /^five$/m);
|
||||
assert.equal(g("show", "HEAD:a.txt"), "a2\n");
|
||||
assert.match(status(), /^ M long\.txt$/m);
|
||||
assert.doesNotMatch(status(), /a\.txt/);
|
||||
});
|
||||
|
||||
it("refuses a hunk no longer in the diff, an untracked file, and leaves the index alone", async () => {
|
||||
edit([["3", "three"]]);
|
||||
write("new.txt", "n\n");
|
||||
g("add", "b.txt");
|
||||
await assert.rejects(
|
||||
runGitAction(REPO, "commit", {
|
||||
message: "m",
|
||||
hunks: { "long.txt": [{ hunk: "@@ -1,1 +1,1 @@", blocks: [0] }] },
|
||||
}),
|
||||
{ code: "EHUNKGONE" }
|
||||
);
|
||||
await assert.rejects(
|
||||
runGitAction(REPO, "commit", {
|
||||
message: "m",
|
||||
hunks: { "new.txt": [{ hunk: "@@ -0,0 +1 @@", blocks: [0] }] },
|
||||
}),
|
||||
{ code: "EUNTRACKED" }
|
||||
);
|
||||
await assert.rejects(
|
||||
runGitAction(REPO, "commit", {
|
||||
message: "m",
|
||||
hunks: { "long.txt": [{ hunk: headers("long.txt")[0], blocks: ["x"] }] },
|
||||
}),
|
||||
{ code: "EBADPATH" }
|
||||
);
|
||||
assert.equal(g("rev-list", "--count", "HEAD").trim(), "2");
|
||||
assert.match(status(), /^ M long\.txt$/m);
|
||||
});
|
||||
|
||||
it("amends with only the ticked hunk", async () => {
|
||||
edit([
|
||||
["3", "three"],
|
||||
["25", "twentyfive"],
|
||||
]);
|
||||
const head = g("rev-parse", "HEAD").trim();
|
||||
const [, second] = headers("long.txt");
|
||||
await runGitAction(REPO, "amend", {
|
||||
sha: head,
|
||||
context: 3,
|
||||
paths: [],
|
||||
hunks: { "long.txt": [{ hunk: second, blocks: [0] }] },
|
||||
});
|
||||
assert.equal(g("log", "-1", "--format=%s").trim(), "long");
|
||||
assert.equal(g("rev-list", "--count", "HEAD").trim(), "2");
|
||||
const committed = g("show", "HEAD:long.txt");
|
||||
assert.match(committed, /^twentyfive$/m);
|
||||
assert.match(committed, /^3$/m);
|
||||
});
|
||||
});
|
||||
|
||||
describe("blameFile", () => {
|
||||
it("names each line's commit, and none for a line not committed yet", async () => {
|
||||
write("a.txt", "a\nnew line\n");
|
||||
const blame = await blameFile(REPO, "a.txt");
|
||||
assert.equal(blame.lines.length, 2);
|
||||
const first = blame.commits[blame.lines[0]];
|
||||
assert.equal(first.summary, "init");
|
||||
assert.equal(first.author, "t");
|
||||
assert.equal(blame.lines[1], null);
|
||||
});
|
||||
|
||||
it("refuses a path outside the lane and a secret file", async () => {
|
||||
await assert.rejects(blameFile(REPO, "../x"), { code: "EBADPATH" });
|
||||
fs.mkdirSync(path.join(REPO, ".claude"));
|
||||
write(".claude/settings.local.json", "{}\n");
|
||||
g("add", "-f", ".claude/settings.local.json");
|
||||
g("commit", "-qm", "secret");
|
||||
await assert.rejects(blameFile(REPO, ".claude/settings.local.json"), { code: "ESECRET" });
|
||||
});
|
||||
});
|
||||
|
||||
describe("a file's history", () => {
|
||||
it("rewrites parents to the file's own commits, so the graph joins up", async () => {
|
||||
write("a.txt", "a2\n");
|
||||
g("commit", "-qam", "a2");
|
||||
write("b.txt", "b2\n");
|
||||
g("commit", "-qam", "b only");
|
||||
write("a.txt", "a3\n");
|
||||
g("commit", "-qam", "a3");
|
||||
const commits = await listCommits(REPO, { file: "a.txt" });
|
||||
assert.deepEqual(
|
||||
commits.map((c) => c.subject),
|
||||
["a3", "a2", "init"]
|
||||
);
|
||||
assert.deepEqual(commits[0].parents, [commits[1].sha]);
|
||||
});
|
||||
});
|
||||
@@ -141,3 +141,12 @@ describe("install-hooks host-only guard (#193)", () => {
|
||||
assert.equal(isInsideContainer(), false);
|
||||
});
|
||||
});
|
||||
|
||||
describe("HOOK_TYPES", () => {
|
||||
it("is the event list the plugin ships, so the hook status checks every one", () => {
|
||||
const exported = require("../../scripts/install-hooks").HOOK_TYPES;
|
||||
const plugin = require("../../.claude-plugin/plugin.json");
|
||||
assert.deepEqual([...exported].sort(), Object.keys(plugin.hooks).sort());
|
||||
assert.deepEqual([...exported].sort(), [...HOOK_TYPES].sort());
|
||||
});
|
||||
});
|
||||
|
||||
@@ -146,6 +146,16 @@ describe("importing a .codex-pet bundle", () => {
|
||||
assert.equal(r.status, 201);
|
||||
});
|
||||
|
||||
it("accepts a v2 sheet (1536×2288, the 16 gaze poses in rows 9–10)", async () => {
|
||||
const r = await request("POST", "/api/pets/import", {
|
||||
body: bundle({
|
||||
"pet.json": { spriteVersionNumber: 2 },
|
||||
"spritesheet.webp": webp(1536, 2288),
|
||||
}),
|
||||
});
|
||||
assert.equal(r.status, 201);
|
||||
});
|
||||
|
||||
it("refuses a bundle missing a file, a sheet of the wrong size, and a non-zip", async () => {
|
||||
const missing = await request("POST", "/api/pets/import", { body: bundle({ "pet.json": {} }) });
|
||||
assert.equal(missing.status, 400);
|
||||
|
||||
@@ -205,6 +205,37 @@ describe("fast path", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("fast path after a Quit (issue #65)", () => {
|
||||
beforeEach(resetRuntime);
|
||||
|
||||
it("starts nothing while the stopped flag is set, and starts again once it is gone", () => {
|
||||
const pkg = require("../../package.json");
|
||||
const { markQuit, clearQuit } = require("../lib/quit");
|
||||
const prevHome = process.env.HOME;
|
||||
process.env.HOME = TMP_HOME; // the real unit may name another root: stale
|
||||
try {
|
||||
fs.mkdirSync(path.join(RT, "node_modules"), { recursive: true });
|
||||
boot.writeState(
|
||||
{
|
||||
pluginVersion: pkg.version,
|
||||
depsHash: boot.depsHash(),
|
||||
pluginRoot: path.resolve(__dirname, "..", ".."),
|
||||
},
|
||||
RT
|
||||
);
|
||||
// No server is live: without the flag the worker would start one.
|
||||
assert.equal(boot.fastPathOk(RT), false);
|
||||
markQuit(RT);
|
||||
assert.equal(boot.fastPathOk(RT), true);
|
||||
clearQuit(RT);
|
||||
assert.equal(boot.fastPathOk(RT), false);
|
||||
} finally {
|
||||
if (prevHome === undefined) delete process.env.HOME;
|
||||
else process.env.HOME = prevHome;
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("bootstrap lock", () => {
|
||||
beforeEach(resetRuntime);
|
||||
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
/**
|
||||
* @file Unit tests for the enhanced pricing calculator and the shared token-usage
|
||||
* normalizer: 5m/1h cache-write split, server-tool surcharges, and the per-bucket
|
||||
* pricing modifiers (fast mode, US data residency, Batch API).
|
||||
* pricing modifiers (fast mode, US data residency, Batch API), and the
|
||||
* long-context subsets priced at a rule's long_* rates.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -363,3 +364,72 @@ describe("DEFAULT_PRICING — Opus 5.5", () => {
|
||||
assert.equal(r.total_cost.toFixed(2), "2.72");
|
||||
});
|
||||
});
|
||||
|
||||
describe("long-context tier (prompt > 100k tokens)", () => {
|
||||
// Haiku 5.5: every rate x5 above a 100k-token prompt.
|
||||
const HAIKU = [
|
||||
{
|
||||
model_pattern: "claude-haiku-5-5%",
|
||||
display_name: "Claude Haiku 5.5",
|
||||
input_per_mtok: 0.1,
|
||||
output_per_mtok: 0.5,
|
||||
cache_read_per_mtok: 0.01,
|
||||
cache_write_per_mtok: 0.125,
|
||||
cache_write_1h_per_mtok: 0.2,
|
||||
long_input_per_mtok: 0.5,
|
||||
long_output_per_mtok: 2.5,
|
||||
},
|
||||
];
|
||||
const haiku = (extra) => bucket({ model: "claude-haiku-5-5", ...extra });
|
||||
|
||||
it("counts a request into the long subsets by its whole prompt: input + cache read + cache write", () => {
|
||||
const short = extractUsageFields({ input_tokens: 100_000, output_tokens: 5 });
|
||||
assert.equal(short.longInput, 0);
|
||||
const long = extractUsageFields({
|
||||
input_tokens: 10,
|
||||
output_tokens: 5,
|
||||
cache_read_input_tokens: 90_000,
|
||||
cache_creation_input_tokens: 10_000,
|
||||
cache_creation: { ephemeral_5m_input_tokens: 4_000, ephemeral_1h_input_tokens: 6_000 },
|
||||
});
|
||||
assert.deepEqual(
|
||||
[
|
||||
long.longInput,
|
||||
long.longOutput,
|
||||
long.longCacheRead,
|
||||
long.longCacheWrite,
|
||||
long.longCacheWrite1h,
|
||||
],
|
||||
[10, 5, 90_000, 10_000, 6_000]
|
||||
);
|
||||
});
|
||||
|
||||
it("prices the long subset at the long rates, cache rates scaled by the input ratio", () => {
|
||||
const all = {
|
||||
input_tokens: M,
|
||||
output_tokens: M,
|
||||
cache_read_tokens: M,
|
||||
cache_write_tokens: 2 * M,
|
||||
cache_write_1h_tokens: M,
|
||||
};
|
||||
const long = Object.fromEntries(Object.entries(all).map(([k, v]) => [`long_${k}`, v]));
|
||||
// 0.5 + 2.5 + 0.05 + 0.625 + 1.0
|
||||
assert.equal(calculateCost([haiku({ ...all, ...long })], HAIKU).total_cost, 4.675);
|
||||
});
|
||||
|
||||
it("prices only the long part of a bucket at the long rates", () => {
|
||||
const r = calculateCost([haiku({ input_tokens: 2 * M, long_input_tokens: M })], HAIKU);
|
||||
assert.equal(r.total_cost, 0.6);
|
||||
assert.equal(r.breakdown[0].long_input_tokens, M);
|
||||
});
|
||||
|
||||
it("never prices more long tokens than the bucket holds", () => {
|
||||
const r = calculateCost([haiku({ input_tokens: M, long_input_tokens: 3 * M })], HAIKU);
|
||||
assert.equal(r.total_cost, 0.5);
|
||||
});
|
||||
|
||||
it("prices a long subset at the standard rates when the rule has no long rates", () => {
|
||||
const r = calculateCost([bucket({ input_tokens: M, long_input_tokens: M })], RULES);
|
||||
assert.equal(r.total_cost, 5);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -4,7 +4,8 @@
|
||||
* before it is used, the sync's own rows follow a rate change while a user's
|
||||
* row and a deleted pattern are never touched, a bad list or a failed fetch
|
||||
* changes nothing, and an OpenAI model (a Codex session's) is priced from its
|
||||
* `openai` entry too.
|
||||
* `openai` entry too, and a Claude entry's above-100k rates fill the long_*
|
||||
* columns.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -428,3 +429,34 @@ describe("pricing sync", () => {
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("long-context rates", () => {
|
||||
it("takes Anthropic's above-100k input/output rates into the long columns", () => {
|
||||
const e = {
|
||||
...LIST["claude-opus-9-9"],
|
||||
input_cost_per_token_above_100k_tokens: 0.00002,
|
||||
output_cost_per_token_above_100k_tokens: 0.0001,
|
||||
};
|
||||
const r = toRow("claude-opus-9-9", e);
|
||||
assert.equal(r.longIn, 20);
|
||||
assert.equal(r.longOut, 100);
|
||||
assert.equal(toRow("claude-opus-9-9", LIST["claude-opus-9-9"]).longIn, 0);
|
||||
});
|
||||
|
||||
it("writes them, and fills them into a sync row that predates them", async () => {
|
||||
const e = {
|
||||
...LIST["claude-opus-9-9"],
|
||||
input_cost_per_token_above_100k_tokens: 0.00002,
|
||||
output_cost_per_token_above_100k_tokens: 0.0001,
|
||||
};
|
||||
useModel("claude-opus-9-9");
|
||||
await syncPricing(db, {
|
||||
fetchList: async () => ({ "claude-opus-9-9": LIST["claude-opus-9-9"] }),
|
||||
});
|
||||
assert.equal(row("claude-opus-9-9%").long_input_per_mtok, 0);
|
||||
_resetCache();
|
||||
await syncPricing(db, { fetchList: async () => ({ "claude-opus-9-9": e }) });
|
||||
assert.equal(row("claude-opus-9-9%").long_input_per_mtok, 20);
|
||||
assert.equal(row("claude-opus-9-9%").long_output_per_mtok, 100);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -651,7 +651,7 @@ describe("pty-run", () => {
|
||||
let savedPath;
|
||||
before(() => {
|
||||
fs.mkdirSync(BIN, { recursive: true });
|
||||
for (const name of ["agy", "gemini", "grok", "goose", "command-code"]) {
|
||||
for (const name of ["agy", "grok", "goose", "command-code"]) {
|
||||
fs.writeFileSync(path.join(BIN, name), "#!/bin/sh\n");
|
||||
fs.chmodSync(path.join(BIN, name), 0o755);
|
||||
}
|
||||
@@ -734,15 +734,11 @@ describe("pty-run", () => {
|
||||
pty.spawnRun({ laneId: 15, cwd: "/tmp/repo", tool, initialPrompt: "-x y", ...extra })
|
||||
)
|
||||
);
|
||||
assert.deepEqual(argvFor("gemini"), [at("gemini"), "--prompt-interactive", " -x y"]);
|
||||
assert.deepEqual(argvFor("antigravity"), [at("agy"), "-i", " -x y"]);
|
||||
// Behind a `--` separator the prompt is already a positional, kept as typed.
|
||||
assert.deepEqual(argvFor("grok"), [at("grok"), "--", "-x y"]);
|
||||
assert.deepEqual(argvFor("command-code"), [at("command-code"), "--trust", " -x y"]);
|
||||
assert.deepEqual(argvFor("gemini", { bypass: false }), [
|
||||
at("gemini"),
|
||||
"--prompt-interactive",
|
||||
" -x y",
|
||||
]);
|
||||
assert.deepEqual(argvFor("antigravity", { bypass: false }), [at("agy"), "-i", " -x y"]);
|
||||
});
|
||||
|
||||
it("pastes an after-start prompt later and adds bypassEnv only on bypass", (t) => {
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
/**
|
||||
* @file Tests Quit AgentHub (issue #65, server/lib/quit.js and
|
||||
* POST /api/settings/quit): the stopped flag, stopping through the service
|
||||
* manager when a unit of ours runs the server (a SIGTERM would be restarted),
|
||||
* and the route killing every lane pane before it stops. tmux runs only in the
|
||||
* isolated server `npm run test:server` sets up.
|
||||
* @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 http = require("node:http");
|
||||
const { execFileSync } = require("node:child_process");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-quit-"));
|
||||
process.env.DASHBOARD_DATA_DIR = TMP;
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const quit = require("../lib/quit");
|
||||
|
||||
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
||||
|
||||
describe("stopped flag", () => {
|
||||
it("is set by markQuit and cleared by clearQuit, in the data dir's runtime", () => {
|
||||
assert.equal(quit.flagPath(), path.join(TMP, "runtime", "stopped"));
|
||||
assert.equal(quit.isQuit(), false);
|
||||
quit.markQuit();
|
||||
assert.equal(quit.isQuit(), true);
|
||||
quit.clearQuit();
|
||||
assert.equal(quit.isQuit(), false);
|
||||
quit.clearQuit(); // already gone: fine
|
||||
});
|
||||
});
|
||||
|
||||
describe("stopServer", () => {
|
||||
const fakeSvc = (kind, active) => ({
|
||||
kind,
|
||||
systemctlAvailable: () => true,
|
||||
unitIsOurs: () => true,
|
||||
unitIsActive: () => active,
|
||||
});
|
||||
const spawned = [];
|
||||
const spawn = (cmd, args) => {
|
||||
spawned.push([cmd, ...args].join(" "));
|
||||
return { unref() {} };
|
||||
};
|
||||
|
||||
it("SIGTERMs itself when no unit of ours is running", () => {
|
||||
let killed = 0;
|
||||
quit.stopServer({ svc: fakeSvc("systemd", false), spawn, kill: () => killed++ });
|
||||
assert.equal(killed, 1);
|
||||
assert.deepEqual(spawned, []);
|
||||
});
|
||||
|
||||
it("stops a running unit through systemd, never by a signal Restart=always undoes", () => {
|
||||
let killed = 0;
|
||||
quit.stopServer({ svc: fakeSvc("systemd", true), spawn, kill: () => killed++ });
|
||||
assert.equal(killed, 0);
|
||||
assert.deepEqual(spawned.splice(0), ["systemctl --user stop --no-block agenthub.service"]);
|
||||
});
|
||||
|
||||
it("boots a running LaunchAgent out", { skip: process.platform === "win32" }, () => {
|
||||
quit.stopServer({ svc: fakeSvc("launchd", true), spawn, kill: () => {} });
|
||||
assert.match(spawned.splice(0)[0], /^launchctl bootout gui\/\d+\/io\.smartgift\.agenthub$/);
|
||||
});
|
||||
});
|
||||
|
||||
// Only inside the runner's private tmux server: never the developer's own.
|
||||
const isolatedTmux = !!process.env.TMUX_TMPDIR && !process.env.TMUX;
|
||||
let hasTmux = false;
|
||||
try {
|
||||
execFileSync("tmux", ["-V"], { stdio: "ignore" });
|
||||
hasTmux = true;
|
||||
} catch {
|
||||
/* not installed */
|
||||
}
|
||||
|
||||
describe(
|
||||
"POST /api/settings/quit",
|
||||
{ skip: !(isolatedTmux && hasTmux) && "needs the runner's isolated tmux" },
|
||||
() => {
|
||||
let server;
|
||||
let base;
|
||||
let stops = 0;
|
||||
const realStop = quit.stopServer;
|
||||
|
||||
before(async () => {
|
||||
quit.stopServer = () => stops++; // this process is the test runner
|
||||
const { createApp, startServer } = require("../index");
|
||||
server = await startServer(createApp(), 0);
|
||||
base = `http://127.0.0.1:${server.address().port}`;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
quit.stopServer = realStop;
|
||||
server?.close();
|
||||
require("../db").db.close();
|
||||
});
|
||||
|
||||
it("closes every lane pane, sets the flag, then stops the server", async () => {
|
||||
execFileSync("tmux", ["new-session", "-d", "-s", "agenthub-lane-9001", "sleep 60"]);
|
||||
execFileSync("tmux", ["new-session", "-d", "-s", "agenthub-lane-9001-2", "sleep 60"]);
|
||||
execFileSync("tmux", ["new-session", "-d", "-s", "not-ours", "sleep 60"]);
|
||||
try {
|
||||
const body = await new Promise((resolve, reject) => {
|
||||
const req = http.request(`${base}/api/settings/quit`, { method: "POST" }, (res) => {
|
||||
let text = "";
|
||||
res.on("data", (c) => (text += c));
|
||||
res.on("end", () => resolve(JSON.parse(text)));
|
||||
});
|
||||
req.on("error", reject);
|
||||
req.end();
|
||||
});
|
||||
assert.deepEqual(body, { ok: true, killed: 2 });
|
||||
assert.equal(quit.isQuit(), true);
|
||||
const left = execFileSync("tmux", ["list-sessions", "-F", "#{session_name}"], {
|
||||
encoding: "utf8",
|
||||
});
|
||||
assert.deepEqual(left.trim().split("\n"), ["not-ours"]);
|
||||
await new Promise((r) => setImmediate(r));
|
||||
assert.equal(stops, 1);
|
||||
} finally {
|
||||
execFileSync("tmux", ["kill-session", "-t", "not-ours"]);
|
||||
}
|
||||
});
|
||||
}
|
||||
);
|
||||
@@ -4,7 +4,11 @@
|
||||
* reset since Codex last wrote reading as empty, and the Codex login's
|
||||
* account + expiry read from `auth.json`; and a saved Claude profile asked
|
||||
* with its own token only while it is valid, never refreshed, and never shown
|
||||
* with the usage of a token that belongs to a different account.
|
||||
* with the usage of a token that belongs to a different account; each
|
||||
* tool's plan name; the Cursor CLI's login asked of cursor.com only while
|
||||
* valid; Antigravity's `agy --print /usage` pools, never on an old agy; and
|
||||
* the Grok, Kimi Code and OpenCode Go logins, each sent only to its own host
|
||||
* while valid, never refreshed.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
@@ -12,6 +16,19 @@ const { test } = require("node:test");
|
||||
const assert = require("node:assert");
|
||||
const { codexWindow, windowLabel } = require("../lib/quota");
|
||||
|
||||
// No test may read (or send) the machine's real Cursor login.
|
||||
process.env.XDG_CONFIG_HOME = require("node:fs").mkdtempSync(
|
||||
require("node:path").join(require("node:os").tmpdir(), "quota-xdg-")
|
||||
);
|
||||
// Nor run the machine's real agy: only the Antigravity test's fake one runs.
|
||||
process.env.AGENTHUB_AGY_USAGE = "0";
|
||||
// Nor its real Grok, Kimi or OpenCode logins.
|
||||
for (const k of ["GROK_HOME", "KIMI_CODE_HOME", "XDG_DATA_HOME"]) {
|
||||
process.env[k] = require("node:fs").mkdtempSync(
|
||||
require("node:path").join(require("node:os").tmpdir(), "quota-home-")
|
||||
);
|
||||
}
|
||||
|
||||
test("windowLabel names the plan windows", () => {
|
||||
assert.strictEqual(windowLabel(300), "5h");
|
||||
assert.strictEqual(windowLabel(10080), "wk");
|
||||
@@ -47,11 +64,18 @@ test("codexAccount reads the email and access-token expiry, never a token", () =
|
||||
fs.writeFileSync(
|
||||
path.join(home, "auth.json"),
|
||||
JSON.stringify({
|
||||
tokens: { id_token: jwt({ email: "a@b.c" }), access_token: jwt({ exp: 1_800_000_000 }) },
|
||||
tokens: {
|
||||
id_token: jwt({
|
||||
email: "a@b.c",
|
||||
"https://api.openai.com/auth": { chatgpt_plan_type: "plus" },
|
||||
}),
|
||||
access_token: jwt({ exp: 1_800_000_000 }),
|
||||
},
|
||||
})
|
||||
);
|
||||
assert.deepStrictEqual(codexAccount(), {
|
||||
account: "a@b.c",
|
||||
plan: "plus",
|
||||
expiresAt: new Date(1_800_000_000_000).toISOString(),
|
||||
});
|
||||
} finally {
|
||||
@@ -177,7 +201,13 @@ test("the live row is the account its token belongs to, not the one .claude.json
|
||||
// Thắng's identity, Victor's token.
|
||||
fs.writeFileSync(
|
||||
path.join(home, ".credentials.json"),
|
||||
JSON.stringify({ claudeAiOauth: { accessToken: "live-v", expiresAt: Date.now() + 3600_000 } })
|
||||
JSON.stringify({
|
||||
claudeAiOauth: {
|
||||
accessToken: "live-v",
|
||||
expiresAt: Date.now() + 3600_000,
|
||||
subscriptionType: "max",
|
||||
},
|
||||
})
|
||||
);
|
||||
fs.writeFileSync(
|
||||
`${home}.json`,
|
||||
@@ -200,6 +230,7 @@ test("the live row is the account its token belongs to, not the one .claude.json
|
||||
assert.strictEqual(live.label, "Victor");
|
||||
assert.strictEqual(live.account, "victor@x.y");
|
||||
assert.strictEqual(live.ownerUuid, undefined);
|
||||
assert.strictEqual(live.plan, "max");
|
||||
assert.deepStrictEqual(
|
||||
claude.filter((t) => !t.active).map((t) => t.label),
|
||||
["Thắng"]
|
||||
@@ -212,3 +243,344 @@ test("the live row is the account its token belongs to, not the one .claude.json
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Cursor: the CLI's own login asks cursor.com for its plan + cycle usage, never past expiry", async () => {
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const { quotas, __resetCache } = require("../lib/quota");
|
||||
const jwt = (claims) => `h.${Buffer.from(JSON.stringify(claims)).toString("base64url")}.s`;
|
||||
const root = fs.mkdtempSync(path.join(os.tmpdir(), "quota-cursor-"));
|
||||
const auth = path.join(root, "config", "cursor", "auth.json");
|
||||
fs.mkdirSync(path.dirname(auth), { recursive: true });
|
||||
fs.mkdirSync(path.join(root, "cursor"));
|
||||
fs.writeFileSync(
|
||||
path.join(root, "cursor", "cli-config.json"),
|
||||
JSON.stringify({ authInfo: { email: "c@d.e", authId: "auth0|user_1" } })
|
||||
);
|
||||
const prev = {
|
||||
xdg: process.env.XDG_CONFIG_HOME,
|
||||
dir: process.env.AGENTHUB_CURSOR_DIR,
|
||||
fetch: global.fetch,
|
||||
};
|
||||
process.env.XDG_CONFIG_HOME = path.join(root, "config");
|
||||
process.env.AGENTHUB_CURSOR_DIR = path.join(root, "cursor");
|
||||
const asked = [];
|
||||
global.fetch = async (url, opts) => {
|
||||
if (!url.includes("cursor.com")) return { ok: false, status: 401, json: async () => ({}) };
|
||||
asked.push(`${url} ${opts.headers.Cookie}`);
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
billingCycleEnd: "2099-01-01T00:00:00.000Z",
|
||||
membershipType: "pro",
|
||||
individualUsage: { plan: { totalPercentUsed: 9.5 } },
|
||||
}),
|
||||
};
|
||||
};
|
||||
const cursor = async () => (await quotas()).tools.find((t) => t.id === "cursor");
|
||||
try {
|
||||
__resetCache();
|
||||
assert.strictEqual(await cursor(), undefined); // not logged in: no row
|
||||
const token = jwt({ sub: "auth0|user_1", exp: Math.floor(Date.now() / 1000) + 3600 });
|
||||
fs.writeFileSync(auth, JSON.stringify({ accessToken: token, refreshToken: "r" }));
|
||||
__resetCache();
|
||||
assert.deepStrictEqual(await cursor(), {
|
||||
id: "cursor",
|
||||
active: true,
|
||||
account: "c@d.e",
|
||||
plan: "pro",
|
||||
expiresAt: new Date(
|
||||
JSON.parse(Buffer.from(token.split(".")[1], "base64url")).exp * 1000
|
||||
).toISOString(),
|
||||
windows: [{ label: "mo", usedPercent: 9.5, resetsAt: "2099-01-01T00:00:00.000Z" }],
|
||||
});
|
||||
assert.deepStrictEqual(asked, [
|
||||
`https://cursor.com/api/usage-summary WorkosCursorSessionToken=user_1::${token}`,
|
||||
]);
|
||||
fs.writeFileSync(auth, JSON.stringify({ accessToken: jwt({ sub: "auth0|user_1", exp: 1 }) }));
|
||||
__resetCache();
|
||||
assert.strictEqual((await cursor()).error, "ETOKENEXPIRED");
|
||||
assert.strictEqual(asked.length, 1);
|
||||
} finally {
|
||||
for (const [k, v] of [
|
||||
["XDG_CONFIG_HOME", prev.xdg],
|
||||
["AGENTHUB_CURSOR_DIR", prev.dir],
|
||||
]) {
|
||||
if (v === undefined) delete process.env[k];
|
||||
else process.env[k] = v;
|
||||
}
|
||||
global.fetch = prev.fetch;
|
||||
__resetCache();
|
||||
fs.rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("Antigravity: `agy --print /usage` in an empty dir, one row per pool window, never on an old agy", async () => {
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
const path = require("node:path");
|
||||
const aiTools = require("../lib/ai-tools");
|
||||
const { quotas, __resetCache } = require("../lib/quota");
|
||||
const bin = fs.mkdtempSync(path.join(os.tmpdir(), "quota-agy-"));
|
||||
const log = path.join(bin, "calls");
|
||||
const version = path.join(bin, "version");
|
||||
fs.writeFileSync(version, "1.2.17");
|
||||
const bucket = (id, window, rf) => ({
|
||||
id,
|
||||
window,
|
||||
remaining_fraction: rf,
|
||||
reset_time: "2099-01-01T00:00:00Z",
|
||||
});
|
||||
fs.writeFileSync(
|
||||
path.join(bin, "out.json"),
|
||||
JSON.stringify({
|
||||
status: "SUCCESS",
|
||||
num_turns: 0,
|
||||
command: {
|
||||
name: "usage",
|
||||
data: {
|
||||
groups: [
|
||||
{
|
||||
name: "Gemini Models",
|
||||
buckets: [bucket("g-w", "weekly", 0.75), bucket("g-5", "5h", 1)],
|
||||
},
|
||||
{ name: "Claude and GPT models", buckets: [bucket("c-5", "5h", 0.5)] },
|
||||
],
|
||||
},
|
||||
},
|
||||
})
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(bin, "agy"),
|
||||
`#!/bin/sh\necho "$PWD $*" >> '${log}'\n[ "$1" = --version ] && { cat '${version}'; exit 0; }\ncat '${path.join(bin, "out.json")}'\n`,
|
||||
{ mode: 0o755 }
|
||||
);
|
||||
const prevPath = process.env.PATH;
|
||||
process.env.PATH = `${bin}:${prevPath}`;
|
||||
delete process.env.AGENTHUB_AGY_USAGE;
|
||||
const agy = async () => (await quotas()).tools.find((t) => t.id === "antigravity");
|
||||
try {
|
||||
aiTools.refresh();
|
||||
__resetCache();
|
||||
const row = await agy();
|
||||
assert.strictEqual(row.active, true);
|
||||
assert.deepStrictEqual(row.windows, [
|
||||
{ group: "Gemini Models", label: "5h", usedPercent: 0, resetsAt: "2099-01-01T00:00:00.000Z" },
|
||||
{
|
||||
group: "Gemini Models",
|
||||
label: "wk",
|
||||
usedPercent: 25,
|
||||
resetsAt: "2099-01-01T00:00:00.000Z",
|
||||
},
|
||||
{
|
||||
group: "Claude and GPT models",
|
||||
label: "5h",
|
||||
usedPercent: 50,
|
||||
resetsAt: "2099-01-01T00:00:00.000Z",
|
||||
},
|
||||
]);
|
||||
const calls = fs.readFileSync(log, "utf8").trim().split("\n");
|
||||
assert.strictEqual(calls[0].split(" ").slice(1).join(" "), "--version");
|
||||
const [cwd, ...args] = calls[1].split(" ");
|
||||
assert.deepStrictEqual(args, ["--print", "/usage", "--output-format", "json"]);
|
||||
assert.notStrictEqual(cwd, process.cwd());
|
||||
assert.strictEqual(fs.existsSync(cwd), false); // the private dir is gone again
|
||||
// An agy older than 1.1.11 may bill /usage as a model request: never asked.
|
||||
fs.writeFileSync(version, "1.1.10");
|
||||
fs.writeFileSync(log, "");
|
||||
__resetCache();
|
||||
assert.strictEqual(await agy(), undefined);
|
||||
assert.match(fs.readFileSync(log, "utf8"), /^\S+ --version\n$/);
|
||||
} finally {
|
||||
process.env.PATH = prevPath;
|
||||
process.env.AGENTHUB_AGY_USAGE = "0";
|
||||
aiTools.refresh();
|
||||
__resetCache();
|
||||
fs.rmSync(bin, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
/** A fetch stub answering per host, recording every `url auth` it was asked. */
|
||||
function stubFetch(answers) {
|
||||
const asked = [];
|
||||
const prev = global.fetch;
|
||||
global.fetch = async (url, opts = {}) => {
|
||||
asked.push(`${url} ${opts.headers?.Authorization || ""}`.trim());
|
||||
const hit = Object.entries(answers).find(([k]) => url.startsWith(k));
|
||||
if (!hit) return { ok: false, status: 404, json: async () => ({}) };
|
||||
const [status, body] = hit[1];
|
||||
return { ok: status < 300, status, json: async () => body };
|
||||
};
|
||||
return { asked, restore: () => (global.fetch = prev) };
|
||||
}
|
||||
|
||||
test("Grok: the CLI's own auth.x.ai session asks cli-chat-proxy for credits + plan, never past expiry", async () => {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { quotas, __resetCache } = require("../lib/quota");
|
||||
const auth = path.join(process.env.GROK_HOME, "auth.json");
|
||||
const f = stubFetch({
|
||||
"https://cli-chat-proxy.grok.com/v1/billing?format=credits": [
|
||||
200,
|
||||
{
|
||||
config: {
|
||||
creditUsagePercent: 12,
|
||||
currentPeriod: { end: "2099-01-08T00:00:00Z" },
|
||||
subscriptionTier: "SuperGrok",
|
||||
monthlyLimit: { val: "200" },
|
||||
used: { val: "50" },
|
||||
billingPeriodEnd: "2099-02-01T00:00:00Z",
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const grok = async () => (await quotas()).tools.find((t) => t.id === "grok");
|
||||
try {
|
||||
__resetCache();
|
||||
assert.strictEqual(await grok(), undefined);
|
||||
const later = new Date(Date.now() + 3600_000).toISOString();
|
||||
fs.writeFileSync(
|
||||
auth,
|
||||
JSON.stringify({
|
||||
"https://other.example": { key: "tok-other", expires_at: later },
|
||||
"https://auth.x.ai": { key: "tok-x", user_id: "u1", email: "g@x.ai", expires_at: later },
|
||||
})
|
||||
);
|
||||
__resetCache();
|
||||
assert.deepStrictEqual(await grok(), {
|
||||
id: "grok",
|
||||
active: true,
|
||||
account: "g@x.ai",
|
||||
plan: "SuperGrok",
|
||||
expiresAt: later,
|
||||
windows: [
|
||||
{ label: "wk", usedPercent: 12, resetsAt: "2099-01-08T00:00:00.000Z" },
|
||||
{ label: "mo", usedPercent: 25, resetsAt: "2099-02-01T00:00:00.000Z" },
|
||||
],
|
||||
});
|
||||
assert.deepStrictEqual(
|
||||
f.asked.filter((a) => a.includes("grok.com")),
|
||||
["https://cli-chat-proxy.grok.com/v1/billing?format=credits Bearer tok-x"]
|
||||
);
|
||||
fs.writeFileSync(
|
||||
auth,
|
||||
JSON.stringify({ "https://auth.x.ai": { key: "tok-x", expires_at: "2000-01-01T00:00:00Z" } })
|
||||
);
|
||||
__resetCache();
|
||||
assert.strictEqual((await grok()).error, "ETOKENEXPIRED");
|
||||
assert.strictEqual(f.asked.filter((a) => a.includes("grok.com")).length, 1);
|
||||
} finally {
|
||||
f.restore();
|
||||
fs.rmSync(auth, { force: true });
|
||||
__resetCache();
|
||||
}
|
||||
});
|
||||
|
||||
test("Kimi Code: the CLI's own token asks api.kimi.com/coding for 5h, week and month", async () => {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { quotas, __resetCache } = require("../lib/quota");
|
||||
const dir = path.join(process.env.KIMI_CODE_HOME, "credentials");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, "kimi-code.json");
|
||||
const f = stubFetch({
|
||||
"https://api.kimi.com/coding/v1/usages": [
|
||||
200,
|
||||
{
|
||||
usages: {
|
||||
limit_5h: { used_ratio: 0.3, reset_time: "2099-01-01T05:00:00Z" },
|
||||
limit_7d: { used_ratio: "0.2", reset_time: "2099-01-07T00:00:00Z" },
|
||||
limit_month_total: { used_ratio: 0.4, reset_time: "2099-02-01T00:00:00Z" },
|
||||
limit_month_code: { used_ratio: 0.25, reset_time: "2099-02-01T00:00:00Z" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const kimi = async () => (await quotas()).tools.find((t) => t.id === "kimi");
|
||||
try {
|
||||
__resetCache();
|
||||
assert.strictEqual(await kimi(), undefined);
|
||||
const exp = Math.floor(Date.now() / 1000) + 600;
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({ access_token: "tok-k", refresh_token: "r", expires_at: exp })
|
||||
);
|
||||
__resetCache();
|
||||
const row = await kimi();
|
||||
assert.strictEqual(row.expiresAt, new Date(exp * 1000).toISOString());
|
||||
assert.deepStrictEqual(
|
||||
row.windows.map((w) => [w.label, Math.round(w.usedPercent)]),
|
||||
[
|
||||
["5h", 30],
|
||||
["wk", 20],
|
||||
["mo", 40],
|
||||
]
|
||||
);
|
||||
assert.deepStrictEqual(
|
||||
f.asked.filter((a) => a.includes("kimi")),
|
||||
["https://api.kimi.com/coding/v1/usages Bearer tok-k"]
|
||||
);
|
||||
fs.writeFileSync(file, JSON.stringify({ access_token: "tok-k", expires_at: 1 }));
|
||||
__resetCache();
|
||||
assert.strictEqual((await kimi()).error, "ETOKENEXPIRED");
|
||||
assert.strictEqual(f.asked.filter((a) => a.includes("kimi")).length, 1);
|
||||
} finally {
|
||||
f.restore();
|
||||
fs.rmSync(file, { force: true });
|
||||
__resetCache();
|
||||
}
|
||||
});
|
||||
|
||||
test("OpenCode Go: opencode's own `opencode-go` key asks opencode.ai for its meters", async () => {
|
||||
const fs = require("node:fs");
|
||||
const path = require("node:path");
|
||||
const { quotas, __resetCache } = require("../lib/quota");
|
||||
const dir = path.join(process.env.XDG_DATA_HOME, "opencode");
|
||||
fs.mkdirSync(dir, { recursive: true });
|
||||
const file = path.join(dir, "auth.json");
|
||||
const f = stubFetch({
|
||||
"https://opencode.ai/zen/go/v1/usage": [
|
||||
200,
|
||||
{
|
||||
usage: {
|
||||
rolling: { percent: 10, resetsAt: "2099-01-01T05:00:00Z" },
|
||||
weekly: { percent: 55.5, resetsAt: "2099-01-07T00:00:00Z" },
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
const go = async () => (await quotas()).tools.find((t) => t.id === "opencode");
|
||||
try {
|
||||
__resetCache();
|
||||
// A login with no Go key (Copilot through opencode, say) is no row.
|
||||
fs.writeFileSync(file, JSON.stringify({ "github-copilot": { type: "oauth", access: "a" } }));
|
||||
assert.strictEqual(await go(), undefined);
|
||||
fs.writeFileSync(
|
||||
file,
|
||||
JSON.stringify({
|
||||
"opencode-go": { type: "api", key: "sk-go" },
|
||||
other: { type: "api", key: "x" },
|
||||
})
|
||||
);
|
||||
__resetCache();
|
||||
assert.deepStrictEqual(await go(), {
|
||||
id: "opencode",
|
||||
active: true,
|
||||
plan: "Go",
|
||||
windows: [
|
||||
{ label: "5h", usedPercent: 10, resetsAt: "2099-01-01T05:00:00.000Z" },
|
||||
{ label: "wk", usedPercent: 55.5, resetsAt: "2099-01-07T00:00:00.000Z" },
|
||||
],
|
||||
});
|
||||
assert.deepStrictEqual(
|
||||
f.asked.filter((a) => a.includes("opencode")),
|
||||
["https://opencode.ai/zen/go/v1/usage Bearer sk-go"]
|
||||
);
|
||||
} finally {
|
||||
f.restore();
|
||||
fs.rmSync(file, { force: true });
|
||||
__resetCache();
|
||||
}
|
||||
});
|
||||
|
||||
@@ -0,0 +1,260 @@
|
||||
/**
|
||||
* @file server-mode-user-admin.test.js
|
||||
* @description Managing accounts from the dashboard (Users page): only an
|
||||
* admin may create a user, change a role, disable/enable, reset a password or
|
||||
* give a workspace, every change needs the admin's OWN password again
|
||||
* (EREAUTH, throttled), an admin can never demote or disable themselves, no
|
||||
* answer carries a password hash, each change lands in the audit log, and in
|
||||
* local mode — no login at all — the routes refuse.
|
||||
* @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("node:path");
|
||||
const fs = require("node:fs");
|
||||
const os = require("node:os");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "server-mode-user-admin-"));
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DASHBOARD_LIVENESS_PROBE = "0";
|
||||
|
||||
const { createApp, startServer } = require("../index");
|
||||
const { db } = require("../db");
|
||||
const auth = require("../lib/auth");
|
||||
|
||||
let server;
|
||||
let BASE;
|
||||
const ids = {};
|
||||
const BOSS_PASS = "admin-pass-1";
|
||||
|
||||
async function call(method, p, { cookie, body, origin } = {}) {
|
||||
const res = await fetch(BASE + p, {
|
||||
method,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(cookie ? { cookie } : {}),
|
||||
...(origin ? { origin } : {}),
|
||||
},
|
||||
body: body ? JSON.stringify(body) : undefined,
|
||||
});
|
||||
return { status: res.status, body: await res.json().catch(() => null) };
|
||||
}
|
||||
|
||||
async function login(username, password) {
|
||||
const res = await fetch(BASE + "/api/auth/login", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password }),
|
||||
});
|
||||
return res.status === 200 ? res.headers.get("set-cookie").split(";")[0] : null;
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
server = await startServer(createApp(), 0);
|
||||
BASE = `http://127.0.0.1:${server.address().port}`;
|
||||
ids.boss = (await auth.createUser("boss", BOSS_PASS, "admin")).id;
|
||||
ids.ana = (await auth.createUser("ana", "analyst-pass-1", "analyst")).id;
|
||||
ids.mia = (await auth.createUser("mia", "member-pass-1", "member")).id;
|
||||
ids.lane = db
|
||||
.prepare("INSERT INTO lanes (cwd, tool) VALUES ('/w/ownerless', 'claude')")
|
||||
.run().lastInsertRowid;
|
||||
});
|
||||
|
||||
after(() => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
server?.close();
|
||||
fs.rmSync(TMP, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe("managing accounts from the dashboard", () => {
|
||||
it("lets an admin create a user, with their own password again", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const r = await call("POST", "/api/users", {
|
||||
cookie: boss,
|
||||
body: {
|
||||
username: "neo",
|
||||
password: "neo-pass-123",
|
||||
role: "member",
|
||||
currentPassword: BOSS_PASS,
|
||||
},
|
||||
});
|
||||
assert.equal(r.status, 201);
|
||||
assert.deepEqual(
|
||||
{ username: r.body.user.username, role: r.body.user.role },
|
||||
{ username: "neo", role: "member" }
|
||||
);
|
||||
assert.ok(!JSON.stringify(r.body).includes("scrypt"), "no password hash");
|
||||
assert.ok(await login("neo", "neo-pass-123"), "the new user can log in");
|
||||
|
||||
const dup = await call("POST", "/api/users", {
|
||||
cookie: boss,
|
||||
body: { username: "neo", password: "neo-pass-123", currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(dup.status, 409);
|
||||
assert.equal(dup.body.error.code, "EEXISTS");
|
||||
const short = await call("POST", "/api/users", {
|
||||
cookie: boss,
|
||||
body: { username: "zed", password: "short", currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(short.status, 400);
|
||||
assert.equal(short.body.error.code, "EBADPASSWORD");
|
||||
});
|
||||
|
||||
it("refuses without the admin's password, or with a wrong one", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
for (const currentPassword of [undefined, "not-the-password"]) {
|
||||
const r = await call("POST", "/api/users", {
|
||||
cookie: boss,
|
||||
body: { username: "eve", password: "eve-pass-123", currentPassword },
|
||||
});
|
||||
assert.equal(r.status, 403);
|
||||
assert.equal(r.body.error.code, "EREAUTH");
|
||||
}
|
||||
assert.equal(auth.findUser("eve"), undefined);
|
||||
});
|
||||
|
||||
it("is an admin's only: an analyst is read-only, a member never reaches it", async () => {
|
||||
const body = { username: "eve", password: "eve-pass-123", currentPassword: "analyst-pass-1" };
|
||||
const ana = await call("POST", "/api/users", {
|
||||
cookie: await login("ana", "analyst-pass-1"),
|
||||
body,
|
||||
});
|
||||
assert.equal(ana.status, 403);
|
||||
const mia = await call("POST", "/api/users", {
|
||||
cookie: await login("mia", "member-pass-1"),
|
||||
body: { ...body, currentPassword: "member-pass-1" },
|
||||
});
|
||||
assert.equal(mia.status, 403);
|
||||
assert.equal(auth.findUser("eve"), undefined);
|
||||
});
|
||||
|
||||
it("refuses a write from another origin", async () => {
|
||||
const r = await call("POST", "/api/users", {
|
||||
cookie: await login("boss", BOSS_PASS),
|
||||
origin: "http://evil.example",
|
||||
body: { username: "eve", password: "eve-pass-123", currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(r.status, 403);
|
||||
assert.equal(auth.findUser("eve"), undefined);
|
||||
});
|
||||
|
||||
it("changes a role, disables and enables, and resets a password", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const neo = auth.findUser("neo");
|
||||
const role = await call("PATCH", `/api/users/${neo.id}`, {
|
||||
cookie: boss,
|
||||
body: { role: "analyst", currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(role.status, 200);
|
||||
assert.equal(auth.findUser("neo").role, "analyst");
|
||||
|
||||
const neoLogin = await login("neo", "neo-pass-123");
|
||||
const off = await call("PATCH", `/api/users/${neo.id}`, {
|
||||
cookie: boss,
|
||||
body: { disabled: true, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(off.status, 200);
|
||||
assert.equal(off.body.user.disabled, true);
|
||||
assert.equal((await call("GET", "/api/sessions", { cookie: neoLogin })).status, 401);
|
||||
assert.equal(await login("neo", "neo-pass-123"), null, "a disabled user cannot log in");
|
||||
await call("PATCH", `/api/users/${neo.id}`, {
|
||||
cookie: boss,
|
||||
body: { disabled: false, currentPassword: BOSS_PASS },
|
||||
});
|
||||
|
||||
const pw = await call("POST", `/api/users/${neo.id}/password`, {
|
||||
cookie: boss,
|
||||
body: { password: "neo-new-pass-9", currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(pw.status, 200);
|
||||
assert.equal(await login("neo", "neo-pass-123"), null);
|
||||
assert.ok(await login("neo", "neo-new-pass-9"));
|
||||
|
||||
const bad = await call("PATCH", `/api/users/${neo.id}`, {
|
||||
cookie: boss,
|
||||
body: { role: "root", currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(bad.status, 400);
|
||||
const none = await call("PATCH", "/api/users/9999", {
|
||||
cookie: boss,
|
||||
body: { role: "member", currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(none.status, 404);
|
||||
});
|
||||
|
||||
it("never lets an admin demote or disable themselves", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
for (const body of [{ role: "member" }, { disabled: true }]) {
|
||||
const r = await call("PATCH", `/api/users/${ids.boss}`, {
|
||||
cookie: boss,
|
||||
body: { ...body, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(r.status, 409);
|
||||
assert.equal(r.body.error.code, "ESELF");
|
||||
}
|
||||
assert.equal(auth.findUser("boss").role, "admin");
|
||||
});
|
||||
|
||||
it("gives a workspace to a user", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const r = await call("POST", `/api/users/${ids.mia}/workspaces`, {
|
||||
cookie: boss,
|
||||
body: { laneId: ids.lane, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(r.status, 200);
|
||||
assert.equal(
|
||||
db.prepare("SELECT owner_id FROM lanes WHERE id = ?").get(ids.lane).owner_id,
|
||||
ids.mia
|
||||
);
|
||||
const none = await call("POST", `/api/users/${ids.mia}/workspaces`, {
|
||||
cookie: boss,
|
||||
body: { laneId: 9999, currentPassword: BOSS_PASS },
|
||||
});
|
||||
assert.equal(none.status, 404);
|
||||
});
|
||||
|
||||
it("writes every change to the audit log", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
const entries = (await call("GET", "/api/users/audit", { cookie: boss })).body.entries;
|
||||
const mine = entries.filter((e) => e.reader === "boss" && e.method !== "GET");
|
||||
const paths = mine.map((e) => e.path).join("\n");
|
||||
assert.match(paths, /create neo/);
|
||||
assert.match(paths, /role analyst/);
|
||||
assert.match(paths, /disable/);
|
||||
assert.match(paths, /password reset/);
|
||||
assert.match(paths, new RegExp(`workspace ${ids.lane}`));
|
||||
assert.ok(!paths.includes("neo-new-pass-9"), "never the password itself");
|
||||
});
|
||||
|
||||
it("throttles wrong confirmation passwords", async () => {
|
||||
const boss = await login("boss", BOSS_PASS);
|
||||
let last;
|
||||
for (let i = 0; i < 12; i++) {
|
||||
last = await call("PATCH", `/api/users/${ids.mia}`, {
|
||||
cookie: boss,
|
||||
body: { role: "analyst", currentPassword: "guess-" + i },
|
||||
});
|
||||
}
|
||||
assert.equal(last.status, 429);
|
||||
assert.equal(last.body.error.code, "ETOOMANY");
|
||||
assert.equal(auth.findUser("mia").role, "member");
|
||||
});
|
||||
});
|
||||
|
||||
describe("in local mode", () => {
|
||||
it("refuses every account change — there is no login to check", async () => {
|
||||
delete process.env.AGENTHUB_MODE;
|
||||
try {
|
||||
const r = await call("POST", "/api/users", {
|
||||
body: { username: "eve", password: "eve-pass-123", currentPassword: "x" },
|
||||
});
|
||||
assert.equal(r.status, 403);
|
||||
assert.equal(r.body.error.code, "ELOCALMODE");
|
||||
assert.equal(auth.findUser("eve"), undefined);
|
||||
} finally {
|
||||
process.env.AGENTHUB_MODE = "server";
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -76,6 +76,15 @@ describe("systemd-service — unit content", linuxOnly, () => {
|
||||
assert.doesNotMatch(body, /^Restart=on-failure$/m);
|
||||
});
|
||||
|
||||
it("stops only the server process, so an update never takes the lane panes with it", () => {
|
||||
// tmux forks its server from whichever process first asks for a session —
|
||||
// the dashboard, after a boot with autostart on — and that server sits in
|
||||
// this unit's cgroup. The default KillMode=control-group would kill it,
|
||||
// and every running agent, on each update's restart.
|
||||
svc.writeUserService(UNIT);
|
||||
assert.match(unitFile(), /^KillMode=process$/m);
|
||||
});
|
||||
|
||||
it("carries the ownership marker, so a hand-written unit is never clobbered", () => {
|
||||
svc.writeUserService(UNIT);
|
||||
assert.match(unitFile(), /agenthub-managed-unit/);
|
||||
|
||||
@@ -57,6 +57,53 @@ describe("tmux wrapper", () => {
|
||||
assert.equal(tmux.liveSessions(), null);
|
||||
});
|
||||
|
||||
describe(
|
||||
"the tmux server outlives the dashboard (Linux)",
|
||||
{ skip: process.platform !== "linux" },
|
||||
() => {
|
||||
// Fake tmux: no server until a new-session, then every call succeeds.
|
||||
const fake =
|
||||
(calls, { running = false } = {}) =>
|
||||
(args) => {
|
||||
calls.push(["tmux", ...args].join(" "));
|
||||
if (args[0] === "display-message" && !running)
|
||||
throw Object.assign(new Error("no server"), { status: 1 });
|
||||
if (args[0] === "has-session" && !running)
|
||||
throw Object.assign(new Error("no session"), { status: 1 });
|
||||
return args[0] === "display-message" ? "4242\n" : "";
|
||||
};
|
||||
|
||||
it("starts the FIRST session in its own systemd scope, not the dashboard's cgroup", () => {
|
||||
const calls = [];
|
||||
tmux.__setExecImpl(fake(calls));
|
||||
tmux.__setScopeImpl((args) => calls.push(["systemd-run", "tmux", ...args].join(" ")));
|
||||
tmux.newSession({ name: "agenthub-lane-1", cwd: "/w", argv: ["claude"], env: {} });
|
||||
assert.ok(
|
||||
calls.includes("systemd-run tmux new-session -d -s agenthub-lane-1 -c /w -- claude")
|
||||
);
|
||||
assert.ok(!calls.some((c) => c.startsWith("tmux new-session")));
|
||||
});
|
||||
|
||||
it("adds a session to a running server directly", () => {
|
||||
const calls = [];
|
||||
tmux.__setExecImpl(fake(calls, { running: true }));
|
||||
tmux.__setScopeImpl(() => assert.fail("a running server needs no scope"));
|
||||
tmux.newSession({ name: "agenthub-lane-1", cwd: "/w", argv: ["claude"], env: {} });
|
||||
assert.ok(calls.includes("tmux new-session -d -s agenthub-lane-1 -c /w -- claude"));
|
||||
});
|
||||
|
||||
it("starts it plainly when no systemd user manager answers", () => {
|
||||
const calls = [];
|
||||
tmux.__setExecImpl(fake(calls));
|
||||
tmux.__setScopeImpl(() => {
|
||||
throw Object.assign(new Error("Failed to connect to bus"), { status: 1 });
|
||||
});
|
||||
tmux.newSession({ name: "agenthub-lane-1", cwd: "/w", argv: ["claude"], env: {} });
|
||||
assert.ok(calls.includes("tmux new-session -d -s agenthub-lane-1 -c /w -- claude"));
|
||||
});
|
||||
}
|
||||
);
|
||||
|
||||
it("newSession names a command that exited at once instead of tmux's set-option error", () => {
|
||||
tmux.__setExecImpl((args) => {
|
||||
if (args[0] === "new-session") return "";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
/**
|
||||
* @file Tests for replaceTokenUsage's compaction baseline semantics.
|
||||
* @file Tests for replaceTokenUsage's compaction baseline semantics, and for
|
||||
* the long-context subsets written beside it (high-water mark, exact reset).
|
||||
*
|
||||
* The effective total for a bucket is `live + baseline`. It must behave as a
|
||||
* monotonic HIGH-WATER MARK: never decrease (so a compaction that shrinks the
|
||||
@@ -121,3 +122,38 @@ describe("replaceTokenUsage — high-water-mark baseline", () => {
|
||||
assert.equal(rawRow("hw-recover").baseline_cache_read, 0);
|
||||
});
|
||||
});
|
||||
|
||||
describe("long-context subsets", () => {
|
||||
const { writeSessionTokens } = require("../../scripts/import-history");
|
||||
const { emptyBucket, accumulateBucket, extractUsageFields } = require("../lib/token-usage");
|
||||
const longRow = (sid) =>
|
||||
db
|
||||
.prepare("SELECT input_tokens, long_input_tokens FROM token_usage WHERE session_id = ?")
|
||||
.get(sid);
|
||||
const bucketOf = (...usages) => {
|
||||
const b = emptyBucket("claude-haiku-5-5", "standard", "global", "standard");
|
||||
for (const u of usages) accumulateBucket(b, extractUsageFields(u));
|
||||
return { k: b };
|
||||
};
|
||||
|
||||
before(() => {
|
||||
db.prepare(
|
||||
"INSERT OR IGNORE INTO sessions (id, name, status) VALUES ('long-1', 'x', 'active')"
|
||||
).run();
|
||||
});
|
||||
|
||||
it("stores the long part as a subset of the same row and keeps its high-water mark", () => {
|
||||
const short = { input_tokens: 1_000 };
|
||||
const long = { input_tokens: 150_000 };
|
||||
writeSessionTokens(dbModule, "long-1", bucketOf(short, long));
|
||||
assert.deepEqual(longRow("long-1"), { input_tokens: 151_000, long_input_tokens: 150_000 });
|
||||
// A smaller-scope writer never takes the subset back.
|
||||
writeSessionTokens(dbModule, "long-1", bucketOf(short));
|
||||
assert.equal(longRow("long-1").long_input_tokens, 150_000);
|
||||
});
|
||||
|
||||
it("lets the exact reconcile path clear a stale subset", () => {
|
||||
writeSessionTokens(dbModule, "long-1", bucketOf({ input_tokens: 1_000 }), { exact: true });
|
||||
assert.deepEqual(longRow("long-1"), { input_tokens: 1_000, long_input_tokens: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,100 @@
|
||||
/**
|
||||
* @file Tests the status-area icon's keeper (server/lib/tray.js): on by
|
||||
* default, never started once the user hid it, one icon at a time, a stale
|
||||
* heartbeat never trusted (its pid may be another program's by now), an icon
|
||||
* from another install root replaced only at server start, and no retry loop
|
||||
* for an icon that keeps dying. No real icon is spawned.
|
||||
* @author Nguyễn Ngọc Trí Vĩ <vinnt@smartgift.vn>
|
||||
*/
|
||||
|
||||
const { describe, it, beforeEach, 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 { EventEmitter } = require("node:events");
|
||||
|
||||
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-tray-"));
|
||||
process.env.DASHBOARD_DATA_DIR = TMP;
|
||||
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
||||
process.env.DISPLAY = process.env.DISPLAY || ":99"; // guiEnv() on Linux
|
||||
|
||||
const tray = require("../lib/tray");
|
||||
|
||||
after(() => fs.rmSync(TMP, { recursive: true, force: true }));
|
||||
|
||||
function fakeSpawn() {
|
||||
const calls = [];
|
||||
const spawn = (cmd, args, opts) => {
|
||||
calls.push({ cmd, args, opts });
|
||||
const child = new EventEmitter();
|
||||
child.unref = () => {};
|
||||
return child;
|
||||
};
|
||||
return { spawn, calls };
|
||||
}
|
||||
|
||||
describe("tray keeper", () => {
|
||||
beforeEach(() => {
|
||||
fs.rmSync(path.join(TMP, "runtime"), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it("is on by default and starts scripts/tray.js detached", () => {
|
||||
assert.equal(tray.isEnabled(), true);
|
||||
const { spawn, calls } = fakeSpawn();
|
||||
assert.equal(tray.ensureTray({ spawn }), "started");
|
||||
assert.equal(calls.length, 1);
|
||||
assert.match(calls[0].args[0], /scripts[\\/]tray\.js$/);
|
||||
assert.equal(calls[0].opts.detached, true);
|
||||
});
|
||||
|
||||
it("starts nothing once the user hid it", () => {
|
||||
fs.mkdirSync(path.dirname(tray.offPath()), { recursive: true });
|
||||
fs.writeFileSync(tray.offPath(), "x");
|
||||
const { spawn, calls } = fakeSpawn();
|
||||
assert.equal(tray.ensureTray({ spawn }), "skipped");
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
it("leaves a live icon alone, and replaces one from another root only when asked", () => {
|
||||
tray.writePid(process.pid, "/some/other/root");
|
||||
const { spawn, calls } = fakeSpawn();
|
||||
assert.equal(tray.ensureTray({ spawn }), "running");
|
||||
assert.equal(calls.length, 0);
|
||||
tray.writePid(process.pid); // this root: nothing to replace
|
||||
assert.equal(tray.ensureTray({ spawn, replace: true }), "running");
|
||||
assert.equal(calls.length, 0);
|
||||
});
|
||||
|
||||
it("does not trust a heartbeat that went stale", () => {
|
||||
tray.writePid(process.pid);
|
||||
const old = new Date(Date.now() - 10 * tray.HEARTBEAT_MS);
|
||||
fs.utimesSync(tray.pidPath(), old, old);
|
||||
assert.equal(tray.liveTray(), null);
|
||||
});
|
||||
|
||||
it("writes and clears the off flag", () => {
|
||||
assert.equal(tray.setEnabled(false).enabled, false);
|
||||
assert.equal(fs.existsSync(tray.offPath()), true);
|
||||
tray.writePid(999_999_999); // no such process: nothing to signal, nothing to trust
|
||||
const { spawn, calls } = fakeSpawn();
|
||||
assert.equal(tray.setEnabled(true, { spawn }).enabled, true);
|
||||
assert.equal(fs.existsSync(tray.offPath()), false);
|
||||
assert.equal(calls.length, 1, "turning it on shows the icon at once");
|
||||
});
|
||||
|
||||
it("gives up on an icon that keeps dying at once", () => {
|
||||
const spawned = [];
|
||||
const spawn = () => {
|
||||
const child = new EventEmitter();
|
||||
child.unref = () => {};
|
||||
spawned.push(child);
|
||||
return child;
|
||||
};
|
||||
for (let i = 0; i < 5; i++) {
|
||||
if (tray.ensureTray({ spawn }) === "started") spawned.at(-1).emit("exit", 1);
|
||||
}
|
||||
assert.ok(spawned.length <= 4, `retried ${spawned.length} times`);
|
||||
assert.equal(tray.ensureTray({ spawn }), "skipped");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user