feat: split session and subagent cost under the context meter

This commit is contained in:
Igor Velho
2026-08-24 15:53:16 +01:00
parent ce870b7134
commit 87509a2395
15 changed files with 68 additions and 7 deletions
@@ -194,8 +194,13 @@ export const WorkStatusPrimaryGroup: React.FC<Props> = ({ sessionId, directory,
// Rollup total: own cost plus every descendant subagent's cost, recursively
// (see useSubagentCostRollup). Shown here instead of session.cost alone, so
// spend that ran in a spawned subagent doesn't hide from the reader.
const { totalCost } = useSubagentCostRollup(sessionId);
const { totalCost, ownCost, subagentCost, subagentCount } = useSubagentCostRollup(sessionId);
const cost = totalCost !== null && totalCost > 0 ? totalCost : null;
// The total answers "what has this cost"; the split answers "why is it more
// than the session I am looking at". Only worth a line once subagents exist —
// without them the total *is* the session's own cost and the row would
// restate the number directly above it.
const showCostBreakdown = cost !== null && subagentCount > 0 && subagentCost > 0;
const hasSession = showSession && (usagePercent !== null || cost !== null || Boolean(goalRow));
const hasRepository = showRepository && Boolean(branch || changed || prSummary || attentionLabel);
@@ -224,6 +229,17 @@ export const WorkStatusPrimaryGroup: React.FC<Props> = ({ sessionId, directory,
)}
/>
<WorkStatusMeter percent={usagePercent} color={meterColor} />
{/* Caption, not a row: it explains the figure above it rather
than reporting a reading of its own, so it carries no icon
and no label column. */}
{showCostBreakdown ? (
<p className="mx-1 mb-1 truncate text-[11px] leading-4 text-muted-foreground tabular-nums">
{t('chat.workStatus.cost.breakdown', {
session: formatCost(ownCost),
subagents: formatCost(subagentCost),
})}
</p>
) : null}
</>
) : null}
{/* Below the context readout: the goal is a standing instruction,
@@ -20,6 +20,20 @@ describe('computeRollup', () => {
expect(result.subagentCount).toBe(3);
});
test('splits the total into the session own cost and the subagent share', () => {
const result = computeRollup(sessions, 'root');
expect(result.ownCost).toBe(1);
expect(result.subagentCost).toBe(10);
expect(result.ownCost + result.subagentCost).toBe(result.totalCost);
});
test('reports a zero subagent share for a session with no children', () => {
const result = computeRollup(sessions, 'a1');
expect(result.ownCost).toBe(5);
expect(result.subagentCost).toBe(0);
expect(result.totalCost).toBe(5);
});
test('maps each direct child to its own subtree cost', () => {
const result = computeRollup(sessions, 'root');
expect(result.perChildCost.get('a')).toBe(7);
@@ -5,11 +5,21 @@ import { buildChildrenIndex, computeSubtreeCost } from './subagentCost';
export type SubagentCostRollup = {
totalCost: number | null;
/** The root session's own spend, excluding every subagent. */
ownCost: number;
/** Everything the subagents cost between them: `totalCost - ownCost`. */
subagentCost: number;
subagentCount: number;
perChildCost: Map<string, number>;
};
const EMPTY_ROLLUP: SubagentCostRollup = { totalCost: null, subagentCount: 0, perChildCost: new Map() };
const EMPTY_ROLLUP: SubagentCostRollup = {
totalCost: null,
ownCost: 0,
subagentCost: 0,
subagentCount: 0,
perChildCost: new Map(),
};
function countDescendants(id: string, childrenByParent: Map<string, Session[]>, visited: Set<string>): number {
if (visited.has(id)) return 0;
@@ -34,13 +44,20 @@ export function computeRollup(liveSessions: Session[], sessionId: string | null)
const totalCost = computeSubtreeCost(sessionId, sessionsById, childrenByParent);
const perChildCost = new Map<string, number>();
let subagentCost = 0;
for (const child of childrenByParent.get(sessionId) ?? []) {
perChildCost.set(child.id, computeSubtreeCost(child.id, sessionsById, childrenByParent));
const childSubtree = computeSubtreeCost(child.id, sessionsById, childrenByParent);
perChildCost.set(child.id, childSubtree);
subagentCost += childSubtree;
}
// Derived by subtraction rather than read back off the session, so the split
// always adds up to the total the panel shows even if a cycle guard trimmed
// part of the walk.
const ownCost = totalCost - subagentCost;
const subagentCount = countDescendants(sessionId, childrenByParent, new Set());
return { totalCost, subagentCount, perChildCost };
return { totalCost, ownCost, subagentCost, subagentCount, perChildCost };
}
/**