c8bc4a95a9
Workspace groups: named, foldable, ordered sidebar sections kept per account (agenthub.workspace.groups); a workspace joins one from its menu, a drop on the header, a drag under a row, or Alt+Up/Down past a group's edge; headers drag to reorder; a folded header shows its most urgent state; New workspace in this group; fold/unfold all. Inner repos: GET /api/lanes/:id/repos + ?repo= on git/fs routes; the Changes panel shows a parent folder's repos only when Settings -> Git -> Changes panel turns it on (agenthub.changes.innerRepos, off by default). Cost: the hook writer adds a session's sub-agent transcripts to its tokens and hours (server/lib/subagent-usage.js) — a sub-agent on the session's own model was never counted live (~8% of a week here); an imported session is re-raised on every pass, so a claude -p run first seen with an empty transcript no longer stays at $0 (~10%). A one-time raise-only catch-up (user_version 5) re-reads ~/.claude/projects. Also: omp install script may redirect to its exact install.redirect URL (#85, repo-links test exempts it), native paste in the terminal.
185 lines
6.7 KiB
JavaScript
185 lines
6.7 KiB
JavaScript
/**
|
|
* @file Tests for counting every token a Claude session spent: the hook
|
|
* writer adds the session's sub-agent transcripts (`lib/subagent-usage.js`)
|
|
* to its main one — total and per hour, a sub-agent on the session's own
|
|
* model included, and a sub-agent that grew alone is picked up — and the
|
|
* importer raises a session it first saw with an empty transcript once the
|
|
* transcript has grown (`importSession`).
|
|
* @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("path");
|
|
const fs = require("fs");
|
|
const os = require("os");
|
|
const http = require("http");
|
|
|
|
const TMP = fs.mkdtempSync(path.join(os.tmpdir(), "agenthub-subusage-"));
|
|
process.env.DASHBOARD_DB_PATH = path.join(TMP, "dashboard.db");
|
|
process.env.CLAUDE_HOME = path.join(TMP, "home");
|
|
process.env.DASHBOARD_DATA_DIR = path.join(TMP, "data");
|
|
|
|
const { createApp, startServer } = require("../index");
|
|
const dbModule = require("../db");
|
|
const { db } = dbModule;
|
|
const importHistory = require("../../scripts/import-history");
|
|
const { subagentUsage } = require("../lib/subagent-usage");
|
|
|
|
let server;
|
|
let BASE;
|
|
|
|
function post(body) {
|
|
return new Promise((resolve, reject) => {
|
|
const url = new URL("/api/hooks/event", BASE);
|
|
const payload = JSON.stringify(body);
|
|
const r = http.request(
|
|
{
|
|
hostname: url.hostname,
|
|
port: url.port,
|
|
path: url.pathname,
|
|
method: "POST",
|
|
headers: {
|
|
"Content-Type": "application/json",
|
|
"Content-Length": Buffer.byteLength(payload),
|
|
},
|
|
},
|
|
(res) => {
|
|
res.resume();
|
|
res.on("end", () => resolve(res.statusCode));
|
|
}
|
|
);
|
|
r.on("error", reject);
|
|
r.write(payload);
|
|
r.end();
|
|
});
|
|
}
|
|
|
|
const line = (o) => JSON.stringify(o) + "\n";
|
|
const assistant = (id, inTok, ts, model = "claude-sonnet-5") => ({
|
|
type: "assistant",
|
|
timestamp: ts,
|
|
message: {
|
|
id,
|
|
model,
|
|
content: [{ type: "text", text: "ok" }],
|
|
usage: { input_tokens: inTok, output_tokens: 1 },
|
|
},
|
|
});
|
|
|
|
const total = (sid) =>
|
|
db
|
|
.prepare(
|
|
"SELECT model, SUM(input_tokens + baseline_input) AS input FROM token_usage WHERE session_id = ? GROUP BY model ORDER BY model"
|
|
)
|
|
.all(sid)
|
|
.map((r) => ({ ...r }));
|
|
const hours = (sid) =>
|
|
db
|
|
.prepare(
|
|
"SELECT hour, model, SUM(input_tokens) AS input FROM token_usage_hourly WHERE session_id = ? GROUP BY hour, model ORDER BY hour, model"
|
|
)
|
|
.all(sid)
|
|
.map((r) => ({ ...r }));
|
|
|
|
before(async () => {
|
|
server = await startServer(createApp(), 0);
|
|
BASE = `http://127.0.0.1:${server.address().port}`;
|
|
});
|
|
|
|
after(() => {
|
|
server?.close();
|
|
fs.rmSync(TMP, { recursive: true, force: true });
|
|
});
|
|
|
|
describe("sub-agent usage", () => {
|
|
it("counts a session's sub-agents, own model included, in its total and its hours", async () => {
|
|
const sid = "cdcdcdcd-1111-4111-8111-cdcdcdcdcdcd";
|
|
const cwd = path.join(TMP, "work");
|
|
fs.mkdirSync(cwd, { recursive: true });
|
|
const tpath = path.join(TMP, `${sid}.jsonl`);
|
|
fs.writeFileSync(tpath, line(assistant("m1", 100, "2026-10-22T01:00:00.000Z")));
|
|
const subDir = path.join(TMP, sid, "subagents");
|
|
fs.mkdirSync(subDir, { recursive: true });
|
|
const sub1 = path.join(subDir, "agent-a1.jsonl");
|
|
fs.writeFileSync(sub1, line(assistant("s1", 40, "2026-10-22T02:00:00.000Z")));
|
|
fs.writeFileSync(
|
|
path.join(subDir, "agent-a2.jsonl"),
|
|
line(assistant("s2", 7, "2026-10-22T02:30:00.000Z", "claude-haiku-5-5"))
|
|
);
|
|
// A Workflow agent is booked by workflow-ingest under its own tier: not here.
|
|
fs.mkdirSync(path.join(subDir, "workflows", "run1"), { recursive: true });
|
|
fs.writeFileSync(
|
|
path.join(subDir, "workflows", "run1", "agent-w.jsonl"),
|
|
line(assistant("w1", 1000, "2026-10-22T02:00:00.000Z"))
|
|
);
|
|
const data = { session_id: sid, cwd, transcript_path: tpath };
|
|
|
|
assert.equal(await post({ hook_type: "PostToolUse", data }), 200);
|
|
assert.deepEqual(total(sid), [
|
|
{ model: "claude-haiku-5-5", input: 7 },
|
|
{ model: "claude-sonnet-5", input: 140 },
|
|
]);
|
|
assert.deepEqual(hours(sid), [
|
|
{ hour: "2026-10-22T01", model: "claude-sonnet-5", input: 100 },
|
|
{ hour: "2026-10-22T02", model: "claude-haiku-5-5", input: 7 },
|
|
{ hour: "2026-10-22T02", model: "claude-sonnet-5", input: 40 },
|
|
]);
|
|
|
|
// Only the sub-agent grew: the next hook still counts it, once.
|
|
fs.appendFileSync(sub1, line(assistant("s3", 10, "2026-10-22T03:00:00.000Z")));
|
|
assert.equal(await post({ hook_type: "PostToolUse", data: { ...data, tool_name: "x" } }), 200);
|
|
assert.equal(await post({ hook_type: "PostToolUse", data: { ...data, tool_name: "y" } }), 200);
|
|
assert.deepEqual(total(sid), [
|
|
{ model: "claude-haiku-5-5", input: 7 },
|
|
{ model: "claude-sonnet-5", input: 150 },
|
|
]);
|
|
assert.equal(hours(sid).find((h) => h.hour === "2026-10-22T03").input, 10);
|
|
|
|
// The importer's full re-read agrees and changes nothing (high-water).
|
|
await importHistory.reconcileTokens(dbModule, { exact: false });
|
|
assert.deepEqual(total(sid), [
|
|
{ model: "claude-haiku-5-5", input: 7 },
|
|
{ model: "claude-sonnet-5", input: 150 },
|
|
]);
|
|
});
|
|
|
|
it("is null for a session with no sub-agent transcript", () => {
|
|
assert.equal(subagentUsage(path.join(TMP, "nope.jsonl")), null);
|
|
assert.equal(subagentUsage(null), null);
|
|
});
|
|
|
|
it("raises an imported session first seen before its transcript had any usage", async () => {
|
|
const sid = "efefefef-2222-4222-8222-efefefefefef";
|
|
const proj = path.join(TMP, "projects", "-tmp-eval");
|
|
fs.mkdirSync(proj, { recursive: true });
|
|
const tpath = path.join(proj, `${sid}.jsonl`);
|
|
const user = {
|
|
type: "user",
|
|
sessionId: sid,
|
|
cwd: "/tmp/eval",
|
|
timestamp: "2026-10-23T03:00:00.000Z",
|
|
message: { role: "user", content: "fix the bug" },
|
|
};
|
|
fs.writeFileSync(tpath, line(user));
|
|
const parse = async () => {
|
|
const s = await importHistory.parseSessionFile(tpath);
|
|
s.parsedSubagents = [];
|
|
return s;
|
|
};
|
|
importHistory.importSession(dbModule, await parse());
|
|
assert.deepEqual(total(sid), []);
|
|
|
|
fs.appendFileSync(
|
|
tpath,
|
|
line({ ...assistant("e1", 500, "2026-10-23T03:01:00.000Z"), sessionId: sid })
|
|
);
|
|
const again = importHistory.importSession(dbModule, await parse());
|
|
assert.equal(again.backfilled, true);
|
|
assert.deepEqual(total(sid), [{ model: "claude-sonnet-5", input: 500 }]);
|
|
// Nothing new: no backfill reported, no change.
|
|
assert.equal(importHistory.importSession(dbModule, await parse()).backfilled, undefined);
|
|
assert.deepEqual(total(sid), [{ model: "claude-sonnet-5", input: 500 }]);
|
|
});
|
|
});
|