|
| 1 | +/** |
| 2 | + * @file Tests that a subagent's OWN cost is derived and surfaced per-agent. |
| 3 | + * |
| 4 | + * Subagent cards used to show the whole session's cost, which reads as if that |
| 5 | + * one subagent cost the entire session's spend. The importer now stamps each |
| 6 | + * subagent's own token buckets into its metadata, and the agent-list endpoints |
| 7 | + * compute a per-agent `cost` from them (priced at current rates, like session |
| 8 | + * cost). This suite verifies: |
| 9 | + * |
| 10 | + * 1. importSubagentFromJsonl stores the subagent's own token buckets in |
| 11 | + * agent.metadata.tokens. |
| 12 | + * 2. attachAgentCosts computes that subagent's cost from those buckets and the |
| 13 | + * current pricing rules — independent of the session total. |
| 14 | + * 3. A main agent (no per-agent tokens) gets cost 0 (its cost is the session |
| 15 | + * total, shown separately). |
| 16 | + * |
| 17 | + * @author Son Nguyen <hoangson091104@gmail.com> |
| 18 | + */ |
| 19 | + |
| 20 | +const { describe, it, before, after } = require("node:test"); |
| 21 | +const assert = require("node:assert/strict"); |
| 22 | +const path = require("path"); |
| 23 | +const fs = require("fs"); |
| 24 | +const os = require("os"); |
| 25 | + |
| 26 | +const TEST_DB = path.join(os.tmpdir(), `dashboard-subagent-cost-${Date.now()}-${process.pid}.db`); |
| 27 | +process.env.DASHBOARD_DB_PATH = TEST_DB; |
| 28 | + |
| 29 | +const dbModule = require("../db"); |
| 30 | +const { db, stmts } = dbModule; |
| 31 | +const importHistory = require("../../scripts/import-history"); |
| 32 | +const { attachAgentCosts, calculateCost } = require("../routes/pricing"); |
| 33 | + |
| 34 | +const SESSION = "cccccccc-3333-4333-8333-cccccccccccc"; |
| 35 | +const MAIN = `${SESSION}-main`; |
| 36 | +const SUB_AGENT_ID = "11112222-3333-4444-5555-666677778888"; |
| 37 | +const SUB_MODEL = "claude-haiku-4-5-20251001"; |
| 38 | + |
| 39 | +after(() => { |
| 40 | + if (db) db.close(); |
| 41 | + for (const suffix of ["", "-wal", "-shm"]) { |
| 42 | + try { |
| 43 | + fs.unlinkSync(TEST_DB + suffix); |
| 44 | + } catch { |
| 45 | + /* ignore */ |
| 46 | + } |
| 47 | + } |
| 48 | +}); |
| 49 | + |
| 50 | +function writeJsonl(filePath, lines) { |
| 51 | + fs.writeFileSync(filePath, lines.map((o) => JSON.stringify(o)).join("\n")); |
| 52 | +} |
| 53 | + |
| 54 | +/** A subagent transcript with one assistant turn carrying known Haiku usage. */ |
| 55 | +function subagentLines() { |
| 56 | + const base = "2026-04-18T12:00:00.000Z"; |
| 57 | + return [ |
| 58 | + { type: "user", sessionId: SESSION, timestamp: base, message: { content: "do the thing" } }, |
| 59 | + { |
| 60 | + type: "assistant", |
| 61 | + sessionId: SESSION, |
| 62 | + timestamp: "2026-04-18T12:00:05.000Z", |
| 63 | + message: { |
| 64 | + model: SUB_MODEL, |
| 65 | + content: [{ type: "text", text: "done" }], |
| 66 | + usage: { |
| 67 | + input_tokens: 1_000_000, |
| 68 | + output_tokens: 500_000, |
| 69 | + cache_read_input_tokens: 0, |
| 70 | + cache_creation_input_tokens: 0, |
| 71 | + }, |
| 72 | + }, |
| 73 | + }, |
| 74 | + ]; |
| 75 | +} |
| 76 | + |
| 77 | +let tmpDir; |
| 78 | + |
| 79 | +before(() => { |
| 80 | + tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "subcost-")); |
| 81 | + // Session + main agent so the subagent FK holds. |
| 82 | + stmts.insertSession.run( |
| 83 | + SESSION, |
| 84 | + "Cost test session", |
| 85 | + "completed", |
| 86 | + "/tmp/x", |
| 87 | + "claude-opus-4-8", |
| 88 | + null |
| 89 | + ); |
| 90 | + stmts.insertAgent.run(MAIN, SESSION, "Main Agent", "main", null, "completed", null, null, null); |
| 91 | + // Deterministic Haiku pricing: $1 in / $5 out per MTok. |
| 92 | + stmts.upsertPricing.run("claude-haiku%", "Claude Haiku 4.5", 1, 5, 0.1, 1.25, 2, 0, 0); |
| 93 | +}); |
| 94 | + |
| 95 | +describe("per-subagent cost", () => { |
| 96 | + it("stamps the subagent's own token buckets into its metadata on import", async () => { |
| 97 | + const file = path.join(tmpDir, `agent-${SUB_AGENT_ID}.jsonl`); |
| 98 | + writeJsonl(file, subagentLines()); |
| 99 | + const subData = await importHistory.parseSubagentFile(file); |
| 100 | + assert.ok(subData, "subData parsed"); |
| 101 | + importHistory.importSubagentFromJsonl(dbModule, SESSION, MAIN, subData); |
| 102 | + |
| 103 | + const row = stmts.getAgent.get(`${SESSION}-jsonl-${SUB_AGENT_ID}`); |
| 104 | + assert.ok(row, "jsonl subagent row created"); |
| 105 | + const meta = JSON.parse(row.metadata); |
| 106 | + assert.ok(Array.isArray(meta.tokens) && meta.tokens.length === 1, "one token bucket stored"); |
| 107 | + assert.equal(meta.tokens[0].model, SUB_MODEL); |
| 108 | + assert.equal(meta.tokens[0].input_tokens, 1_000_000); |
| 109 | + assert.equal(meta.tokens[0].output_tokens, 500_000); |
| 110 | + }); |
| 111 | + |
| 112 | + it("computes the subagent's own cost from its buckets, not the session total", () => { |
| 113 | + const agents = stmts.listAgentsBySession.all(SESSION); |
| 114 | + const withCosts = attachAgentCosts(agents); |
| 115 | + |
| 116 | + const sub = withCosts.find((a) => a.id === `${SESSION}-jsonl-${SUB_AGENT_ID}`); |
| 117 | + const main = withCosts.find((a) => a.id === MAIN); |
| 118 | + |
| 119 | + // 1M input @ $1 + 0.5M output @ $5 = $1 + $2.50 = $3.50. |
| 120 | + assert.equal(sub.cost, 3.5); |
| 121 | + // Cross-check against calculateCost directly. |
| 122 | + const rules = stmts.listPricing.all(); |
| 123 | + assert.equal( |
| 124 | + calculateCost(JSON.parse(sub.metadata).tokens, rules, "2026-04-18").total_cost, |
| 125 | + 3.5 |
| 126 | + ); |
| 127 | + |
| 128 | + // Main agent carries no per-agent tokens → 0 (its cost is the session total). |
| 129 | + assert.equal(main.cost, 0); |
| 130 | + }); |
| 131 | +}); |
0 commit comments