chore: v2.8.23 — workspace groups, inner repos in Changes (opt-in), sub-agent tokens counted
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.
This commit is contained in:
@@ -0,0 +1,184 @@
|
||||
/**
|
||||
* @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 }]);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user