Skip to content

Commit ab4f36a

Browse files
hoangsonwwclaude
andcommitted
feat(cost): show each subagent's own cost on its card, not the session total
Subagent cards (Dashboard tree, Session Detail tree, Kanban) rendered the session's total cost because AgentCard always read session.cost. On a subagent card that reads as if that one subagent cost the entire session's spend — misleading. Derive per-subagent cost accurately instead: - Import: parseSubagentFile already extracts each subagent's own token buckets (their transcripts are separate JSONL files). importSubagentFromJsonl now stamps those buckets into the agent's metadata.tokens — on both the JSONL-keyed row and, via backfill, the live hook-created row. Covers the bulk-import and live SubagentStop-scan paths (both funnel through it). - API: new pricing.attachAgentCosts() computes each agent's OWN cost from metadata.tokens with the CURRENT pricing rules (priced at the agent's start date, so promo/standard cutovers apply — same as session cost, and consistent with editable intro pricing). Wired into GET /api/agents and GET /api/sessions/:id. - UI: AgentCard shows session.cost for a main agent (it stands in for the whole session) and agent.cost for a subagent. A subagent with no recorded usage shows no cost — truthful rather than misleading. Session total is unchanged (combineSessionTokens still sums main + all subagents). Existing subagent rows backfill their metadata.tokens on the next import/rescan of their transcript; until then their card simply omits cost. Adds server + client tests. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1 parent 71d70c1 commit ab4f36a

8 files changed

Lines changed: 302 additions & 7 deletions

File tree

client/src/components/AgentCard.tsx

Lines changed: 14 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,7 +50,20 @@ export function AgentCard({ agent, session, label, onClick }: AgentCardProps) {
5050
// subagent_type label (more useful than repeating the session model).
5151
const model = formatModelName(session?.model);
5252
const cwdBase = pathBasename(session?.cwd);
53-
const cost = typeof session?.cost === "number" ? session.cost : 0;
53+
// Cost shown on the card is scoped to what the card represents: a main agent's
54+
// card stands in for the whole session, so it shows the session total; a
55+
// subagent's card shows that subagent's OWN cost (server-computed from its
56+
// token buckets). Showing the session total on a subagent card is misleading —
57+
// it reads as if that one subagent cost the whole session's spend. A subagent
58+
// with no recorded usage shows no cost (the cost > 0 guard below hides it),
59+
// which is truthful rather than misleading.
60+
const cost = isMain
61+
? typeof session?.cost === "number"
62+
? session.cost
63+
: 0
64+
: typeof agent.cost === "number"
65+
? agent.cost
66+
: 0;
5467
// Real (user-given) session name - the auto-generated "Session <id8>"
5568
// fallback carries no extra info next to the ID, so it is suppressed.
5669
const sessionName = session?.name?.trim() || "";

client/src/components/__tests__/AgentCard.test.tsx

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ import { render, screen, fireEvent } from "@testing-library/react";
1010
import { MemoryRouter } from "react-router-dom";
1111
import { AgentCard } from "../AgentCard";
1212
import type { Agent } from "../../lib/types";
13-
import { formatModelName } from "../../lib/format";
13+
import { formatModelName, fmtCost } from "../../lib/format";
1414

1515
function renderCard(element: JSX.Element) {
1616
return render(<MemoryRouter>{element}</MemoryRouter>);
@@ -113,6 +113,56 @@ describe("AgentCard", () => {
113113
expect(screen.getAllByText(formatModelName("claude-opus-4-8")!)).toHaveLength(1);
114114
});
115115

116+
it("shows a subagent's OWN cost, not the session total (avoids misleading spend)", () => {
117+
renderCard(
118+
<AgentCard
119+
agent={makeAgent({ type: "subagent", subagent_type: "qa", cost: 3.5 })}
120+
session={
121+
{
122+
id: "s",
123+
name: "S",
124+
status: "active",
125+
cwd: "/x",
126+
model: "claude-opus-4-8",
127+
cost: 646.5,
128+
} as never
129+
}
130+
/>
131+
);
132+
expect(screen.getByText(fmtCost(3.5))).toBeInTheDocument();
133+
// The session total must NOT appear on a subagent card.
134+
expect(screen.queryByText(fmtCost(646.5))).not.toBeInTheDocument();
135+
});
136+
137+
it("shows the session total on a main-agent card", () => {
138+
renderCard(
139+
<AgentCard
140+
agent={makeAgent({ type: "main" })}
141+
session={
142+
{
143+
id: "s",
144+
name: "S",
145+
status: "active",
146+
cwd: "/x",
147+
model: "claude-opus-4-8",
148+
cost: 646.5,
149+
} as never
150+
}
151+
/>
152+
);
153+
expect(screen.getByText(fmtCost(646.5))).toBeInTheDocument();
154+
});
155+
156+
it("shows no cost on a subagent card with no recorded usage", () => {
157+
renderCard(
158+
<AgentCard
159+
agent={makeAgent({ type: "subagent", subagent_type: "qa" })}
160+
session={{ id: "s", name: "S", status: "active", cwd: "/x", cost: 646.5 } as never}
161+
/>
162+
);
163+
expect(screen.queryByText(fmtCost(646.5))).not.toBeInTheDocument();
164+
});
165+
116166
it("swaps the real session title into the hook-style placeholder (Session <id8>)", () => {
117167
renderCard(
118168
<AgentCard

client/src/lib/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,12 @@ export interface Agent {
5252
metadata: string | null;
5353
/** Mirrors the parent session: ISO timestamp when set, null otherwise. */
5454
awaiting_input_since?: string | null;
55+
/**
56+
* The agent's OWN cost (USD), computed server-side from its per-agent token
57+
* buckets. Present for subagents that carry usage in their metadata; 0/absent
58+
* for main agents (whose cost is the session total) and compaction agents.
59+
*/
60+
cost?: number;
5561
}
5662

5763
/** True when a session is paused on a permission prompt or input request. */

scripts/import-history.js

Lines changed: 56 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -776,6 +776,43 @@ function writeSessionTokens(dbModule, sessionId, tokensByModel) {
776776
return written;
777777
}
778778

779+
/**
780+
* Flatten a subagent's per-model token buckets (from parseSubagentFile) into the
781+
* row shape calculateCost() consumes, so a subagent's OWN cost can be recomputed
782+
* at read time with current pricing (never a stored, stale figure). Buckets with
783+
* no tokens at all are dropped to keep the stored metadata lean. This is what
784+
* lets the UI show a subagent's real cost instead of the whole session's total.
785+
*/
786+
function subagentTokenRows(tokensByModel) {
787+
const rows = [];
788+
for (const b of Object.values(tokensByModel || {})) {
789+
if (!b || !b.model) continue;
790+
const row = {
791+
model: b.model,
792+
speed: b.speed,
793+
inference_geo: b.geo,
794+
service_tier: b.tier,
795+
input_tokens: b.input || 0,
796+
output_tokens: b.output || 0,
797+
cache_read_tokens: b.cacheRead || 0,
798+
cache_write_tokens: b.cacheWrite || 0,
799+
cache_write_1h_tokens: b.cacheWrite1h || 0,
800+
web_search_requests: b.webSearch || 0,
801+
web_fetch_requests: b.webFetch || 0,
802+
code_execution_requests: b.codeExec || 0,
803+
};
804+
const hasUsage =
805+
row.input_tokens ||
806+
row.output_tokens ||
807+
row.cache_read_tokens ||
808+
row.cache_write_tokens ||
809+
row.web_search_requests ||
810+
row.code_execution_requests;
811+
if (hasUsage) rows.push(row);
812+
}
813+
return rows;
814+
}
815+
779816
/**
780817
* Import a parsed subagent from its own JSONL file into the agents + events tables.
781818
* Idempotent: re-running on an already-imported subagent backfills any tool events
@@ -798,6 +835,9 @@ function importSubagentFromJsonl(dbModule, sessionId, mainAgentId, subData) {
798835
const existingJsonl = stmts.getAgent.get(jsonlSubId);
799836

800837
const subName = subData.agentType ? subData.agentType : `Subagent ${subData.agentId.slice(0, 8)}`;
838+
// This subagent's OWN token usage, so the UI can price it independently of the
839+
// session total (which would otherwise be shown on every card, misleadingly).
840+
const tokenRows = subagentTokenRows(subData.tokensByModel);
801841
let created = 0;
802842

803843
// Only create a JSONL-keyed row when there's no live subagent to merge into.
@@ -822,6 +862,7 @@ function importSubagentFromJsonl(dbModule, sessionId, mainAgentId, subData) {
822862
user_messages: subData.userMessages,
823863
assistant_messages: subData.assistantMessages,
824864
thinking_blocks: subData.thinkingBlockCount,
865+
tokens: tokenRows,
825866
})
826867
);
827868
db.prepare("UPDATE agents SET started_at = ?, ended_at = ?, updated_at = ? WHERE id = ?").run(
@@ -838,7 +879,12 @@ function importSubagentFromJsonl(dbModule, sessionId, mainAgentId, subData) {
838879
// model, so without this they get read as the parent/orchestrator model
839880
// (issue #185). The JSONL-keyed row already records model at creation; this
840881
// backfills the live row (and is a no-op once model is set).
841-
if (subData.model) {
882+
// Backfill the target row's metadata: stamp the subagent's real model (issue
883+
// #185) and refresh its own token buckets so its cost stays priced at current
884+
// rates. Both are idempotent — model is only filled when missing; tokens are
885+
// rewritten only when the transcript actually has usage (append-only JSONL, so
886+
// the sum only grows) and when they differ from what's stored.
887+
{
842888
const row = stmts.getAgent.get(targetAgentId);
843889
if (row) {
844890
let meta = {};
@@ -847,8 +893,16 @@ function importSubagentFromJsonl(dbModule, sessionId, mainAgentId, subData) {
847893
} catch {
848894
meta = {};
849895
}
850-
if (!meta.model) {
896+
let changed = false;
897+
if (subData.model && !meta.model) {
851898
meta.model = subData.model;
899+
changed = true;
900+
}
901+
if (tokenRows.length > 0 && JSON.stringify(meta.tokens || []) !== JSON.stringify(tokenRows)) {
902+
meta.tokens = tokenRows;
903+
changed = true;
904+
}
905+
if (changed) {
852906
db.prepare("UPDATE agents SET metadata = ? WHERE id = ?").run(
853907
JSON.stringify(meta),
854908
targetAgentId
Lines changed: 131 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,131 @@
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+
});

server/routes/agents.js

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
const { Router } = require("express");
77
const { stmts } = require("../db");
88
const { broadcast } = require("../websocket");
9+
const { attachAgentCosts } = require("./pricing");
910

1011
const router = Router();
1112

@@ -25,7 +26,9 @@ router.get("/", (req, res) => {
2526
rows = stmts.listAgents.all(limit, offset);
2627
}
2728

28-
res.json({ agents: rows, limit, offset });
29+
// Attach each agent's OWN cost (from its metadata token buckets) so subagent
30+
// cards can show their real cost instead of the session total.
31+
res.json({ agents: attachAgentCosts(rows), limit, offset });
2932
});
3033

3134
router.get("/:id", (req, res) => {

server/routes/pricing.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -383,5 +383,41 @@ router.get("/cost/:sessionId", (req, res) => {
383383
res.json({ ...result, daily_costs });
384384
});
385385

386+
/**
387+
* Compute a single agent's own cost from the token buckets stashed in its
388+
* metadata by the importer (agent.metadata.tokens). Returns 0 when the agent has
389+
* no per-agent usage recorded (e.g. main agents — whose cost is the session
390+
* total — compaction pseudo-agents, or live subagents not yet backfilled from
391+
* their transcript). Priced with the agent's start date so a promo/standard
392+
* cutover is respected, exactly like session cost.
393+
*/
394+
function agentOwnCost(agent, pricingRules) {
395+
if (!agent || !agent.metadata) return 0;
396+
let meta;
397+
try {
398+
meta = JSON.parse(agent.metadata);
399+
} catch {
400+
return 0;
401+
}
402+
const rows = Array.isArray(meta.tokens) ? meta.tokens : null;
403+
if (!rows || rows.length === 0) return 0;
404+
const asOf = agent.started_at ? String(agent.started_at).slice(0, 10) : undefined;
405+
return calculateCost(rows, pricingRules, asOf).total_cost;
406+
}
407+
408+
/**
409+
* Return a shallow copy of each agent row with a computed `cost` field — the
410+
* agent's OWN cost (see agentOwnCost). Pricing rules are read once for the whole
411+
* batch. Used by the agent-list endpoints so subagent cards can show their real
412+
* cost instead of the session total.
413+
*/
414+
function attachAgentCosts(agents) {
415+
if (!Array.isArray(agents) || agents.length === 0) return agents;
416+
const rules = stmts.listPricing.all();
417+
return agents.map((a) => ({ ...a, cost: agentOwnCost(a, rules) }));
418+
}
419+
386420
module.exports = router;
387421
module.exports.calculateCost = calculateCost;
422+
module.exports.agentOwnCost = agentOwnCost;
423+
module.exports.attachAgentCosts = attachAgentCosts;

0 commit comments

Comments
 (0)