57dc91585d
Internal SmartGift build of a Claude Code monitoring dashboard. Lanes: a durable unit of parallel agent work, one per working directory, tracked across session restarts. Managed lanes are git worktrees the dashboard provisions and can reset or remove behind a three-check destroy guard and a counted preflight; adopted lanes are directories you already own and are never destroyable. Pipelines: a lane moves through pipeline stages. A stage the agent declares with evidence renders green; a stage inferred from the tool-event stream renders dashed amber and never counts as done. Detection is forward-only within a 30-minute window, and never writes the declared stage. Workspace: one page at /run with a lane grid, the selected lane's pipeline, and a full Claude console behind a disclosure.
132 lines
4.6 KiB
JavaScript
132 lines
4.6 KiB
JavaScript
/**
|
|
* @file Tests that a subagent's OWN cost is derived and surfaced per-agent.
|
|
*
|
|
* Subagent cards used to show the whole session's cost, which reads as if that
|
|
* one subagent cost the entire session's spend. The importer now stamps each
|
|
* subagent's own token buckets into its metadata, and the agent-list endpoints
|
|
* compute a per-agent `cost` from them (priced at current rates, like session
|
|
* cost). This suite verifies:
|
|
*
|
|
* 1. importSubagentFromJsonl stores the subagent's own token buckets in
|
|
* agent.metadata.tokens.
|
|
* 2. attachAgentCosts computes that subagent's cost from those buckets and the
|
|
* current pricing rules — independent of the session total.
|
|
* 3. A main agent (no per-agent tokens) gets cost 0 (its cost is the session
|
|
* total, shown separately).
|
|
*
|
|
* @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 TEST_DB = path.join(os.tmpdir(), `dashboard-subagent-cost-${Date.now()}-${process.pid}.db`);
|
|
process.env.DASHBOARD_DB_PATH = TEST_DB;
|
|
|
|
const dbModule = require("../db");
|
|
const { db, stmts } = dbModule;
|
|
const importHistory = require("../../scripts/import-history");
|
|
const { attachAgentCosts, calculateCost } = require("../routes/pricing");
|
|
|
|
const SESSION = "cccccccc-3333-4333-8333-cccccccccccc";
|
|
const MAIN = `${SESSION}-main`;
|
|
const SUB_AGENT_ID = "11112222-3333-4444-5555-666677778888";
|
|
const SUB_MODEL = "claude-haiku-4-5-20251001";
|
|
|
|
after(() => {
|
|
if (db) db.close();
|
|
for (const suffix of ["", "-wal", "-shm"]) {
|
|
try {
|
|
fs.unlinkSync(TEST_DB + suffix);
|
|
} catch {
|
|
/* ignore */
|
|
}
|
|
}
|
|
});
|
|
|
|
function writeJsonl(filePath, lines) {
|
|
fs.writeFileSync(filePath, lines.map((o) => JSON.stringify(o)).join("\n"));
|
|
}
|
|
|
|
/** A subagent transcript with one assistant turn carrying known Haiku usage. */
|
|
function subagentLines() {
|
|
const base = "2026-04-18T12:00:00.000Z";
|
|
return [
|
|
{ type: "user", sessionId: SESSION, timestamp: base, message: { content: "do the thing" } },
|
|
{
|
|
type: "assistant",
|
|
sessionId: SESSION,
|
|
timestamp: "2026-04-18T12:00:05.000Z",
|
|
message: {
|
|
model: SUB_MODEL,
|
|
content: [{ type: "text", text: "done" }],
|
|
usage: {
|
|
input_tokens: 1_000_000,
|
|
output_tokens: 500_000,
|
|
cache_read_input_tokens: 0,
|
|
cache_creation_input_tokens: 0,
|
|
},
|
|
},
|
|
},
|
|
];
|
|
}
|
|
|
|
let tmpDir;
|
|
|
|
before(() => {
|
|
tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "subcost-"));
|
|
// Session + main agent so the subagent FK holds.
|
|
stmts.insertSession.run(
|
|
SESSION,
|
|
"Cost test session",
|
|
"completed",
|
|
"/tmp/x",
|
|
"claude-opus-4-8",
|
|
null
|
|
);
|
|
stmts.insertAgent.run(MAIN, SESSION, "Main Agent", "main", null, "completed", null, null, null);
|
|
// Deterministic Haiku pricing: $1 in / $5 out per MTok.
|
|
stmts.upsertPricing.run("claude-haiku%", "Claude Haiku 4.5", 1, 5, 0.1, 1.25, 2, 0, 0);
|
|
});
|
|
|
|
describe("per-subagent cost", () => {
|
|
it("stamps the subagent's own token buckets into its metadata on import", async () => {
|
|
const file = path.join(tmpDir, `agent-${SUB_AGENT_ID}.jsonl`);
|
|
writeJsonl(file, subagentLines());
|
|
const subData = await importHistory.parseSubagentFile(file);
|
|
assert.ok(subData, "subData parsed");
|
|
importHistory.importSubagentFromJsonl(dbModule, SESSION, MAIN, subData);
|
|
|
|
const row = stmts.getAgent.get(`${SESSION}-jsonl-${SUB_AGENT_ID}`);
|
|
assert.ok(row, "jsonl subagent row created");
|
|
const meta = JSON.parse(row.metadata);
|
|
assert.ok(Array.isArray(meta.tokens) && meta.tokens.length === 1, "one token bucket stored");
|
|
assert.equal(meta.tokens[0].model, SUB_MODEL);
|
|
assert.equal(meta.tokens[0].input_tokens, 1_000_000);
|
|
assert.equal(meta.tokens[0].output_tokens, 500_000);
|
|
});
|
|
|
|
it("computes the subagent's own cost from its buckets, not the session total", () => {
|
|
const agents = stmts.listAgentsBySession.all(SESSION);
|
|
const withCosts = attachAgentCosts(agents);
|
|
|
|
const sub = withCosts.find((a) => a.id === `${SESSION}-jsonl-${SUB_AGENT_ID}`);
|
|
const main = withCosts.find((a) => a.id === MAIN);
|
|
|
|
// 1M input @ $1 + 0.5M output @ $5 = $1 + $2.50 = $3.50.
|
|
assert.equal(sub.cost, 3.5);
|
|
// Cross-check against calculateCost directly.
|
|
const rules = stmts.listPricing.all();
|
|
assert.equal(
|
|
calculateCost(JSON.parse(sub.metadata).tokens, rules, "2026-04-18").total_cost,
|
|
3.5
|
|
);
|
|
|
|
// Main agent carries no per-agent tokens → 0 (its cost is the session total).
|
|
assert.equal(main.cost, 0);
|
|
});
|
|
});
|