fix(coding-agent): exclude cacheRead from token display, add per-subagent cost

Token counter (token_total status-line segment, subagent progress tree,
session-observer stats line) previously included cacheRead in its cumulative
sum. With Anthropic prompt caching, cacheRead per turn equals the full cached
context, so summing across N turns gives N*context_size -- a session with a 1M
context and 5 turns showed ~5M tokens despite no compaction occurring.

Fix: display shows input + output + cacheWrite per turn. cacheWrite is kept
because each byte is written once; cacheRead re-reads the same context every
turn. Dedicated cache_read/cache_write status-line segments still show cache
activity; billing cost is unaffected.

Also adds per-subagent cost display (dollar amount, statusLineCost color)
accumulated incrementally from message_end events. Hidden when cost is zero
(subscription/OAuth providers). Brings token and cost display in line with
what Claude Code shows per-agent.
This commit is contained in:
Miroslav Drbal
2026-05-13 19:26:38 +02:00
parent 7530114c01
commit 084488b680
7 changed files with 44 additions and 10 deletions
+17 -7
View File
@@ -379,21 +379,29 @@ function firstNumberField(record: Record<string, unknown>, keys: string[]): numb
}
/**
* Normalize usage objects from different event formats.
* Tokens for progress display: input + output + cacheWrite per turn.
*
* Deliberately excludes cacheRead. With prompt caching, cacheRead in each turn
* equals the full cached context (potentially hundreds of KB), so summing it
* across all turns produces a cumulative total that is N×context_size — far
* larger than the context window and misleading as a "work done" metric.
* cacheWrite is kept because each byte is written once, not repeated per turn.
* The cost segment handles billing; dedicated cache_read/cache_write segments
* handle cache-specific monitoring.
*/
function getUsageTokens(usage: unknown): number {
if (!usage || typeof usage !== "object") return 0;
const record = usage as Record<string, unknown>;
const totalTokens = firstNumberField(record, ["totalTokens", "total_tokens"]);
if (totalTokens !== undefined && totalTokens > 0) return totalTokens;
const input = firstNumberField(record, ["input", "input_tokens", "inputTokens"]) ?? 0;
const output = firstNumberField(record, ["output", "output_tokens", "outputTokens"]) ?? 0;
const cacheRead = firstNumberField(record, ["cacheRead", "cache_read", "cacheReadTokens"]) ?? 0;
const cacheWrite = firstNumberField(record, ["cacheWrite", "cache_write", "cacheWriteTokens"]) ?? 0;
return input + output + cacheRead + cacheWrite;
const computed = input + output + cacheWrite;
if (computed > 0) return computed;
// Fallback for providers that only surface a pre-summed total without individual
// field breakdown. This total includes cacheRead, but returning it is still better
// than silently showing 0 for those providers.
return firstNumberField(record, ["totalTokens", "total_tokens"]) ?? 0;
}
/**
@@ -497,6 +505,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
recentOutput: [],
toolCount: 0,
tokens: 0,
cost: 0,
durationMs: 0,
modelOverride,
};
@@ -892,6 +901,7 @@ export async function runSubprocess(options: ExecutorOptions): Promise<SingleRes
accumulatedUsage.cost.cacheRead += getNumberField(costRecord, "cacheRead") ?? 0;
accumulatedUsage.cost.cacheWrite += getNumberField(costRecord, "cacheWrite") ?? 0;
accumulatedUsage.cost.total += getNumberField(costRecord, "total") ?? 0;
progress.cost = accumulatedUsage.cost.total;
}
}
// Accumulate tokens for progress display