Repository navigation
fix(core): roll up subagent cost into session cost across TUI, stats, and ACP - #50737
hunterchristian wants to merge 8 commits into
Conversation
The V2 runner hardcoded cost: 0 at Step.Ended and the projector never applied V2 usage to the session row, so V2 sessions reported zero cost and zero tokens. SessionRunnerModel.resolve now returns the priced catalog info alongside the llm model, the runner prices the settlement via the new SessionRunnerUsage helper (context-tier rates, Copilot totalNanoAiu override), and the projector applies Step.Ended usage with a subtract-then-add keyed on the projected assistant row so event replay stays a no-op. RevertEvent.Committed now subtracts usage for the SessionMessageTable rows it deletes. Co-Authored-By: Claude Code <noreply@anthropic.com>
Session.Tokens is extracted from Info and a new Session.Rollup contract carries the inclusive total plus the subagent-only breakdown. A pure SessionRollup.rollup fold walks parent_id across a structural session list (visited set guards hand-edited cycles), and SessionV2.cost folds the project's session rows for one session, failing with NotFoundError. Co-Authored-By: Claude Code <noreply@anthropic.com>
New session.cost endpoint in the server.session group returning Session.Rollup with the NotFoundError mapping shared with session.get; client SDKs regenerated. Co-Authored-By: Claude Code <noreply@anthropic.com>
session.fork copied assistant messages and step-finish parts with their cost and tokens intact, so re-projecting the copies re-applied the source session's usage to the fork's row (anomalyco#31032). Copied step-finish parts and assistant message costs now zero out while token counts stay so overflow checks still see the inherited context. Co-Authored-By: Claude Code <noreply@anthropic.com>
The sidebar now shows the session total with a muted (+subagents) line when children spend is present; the prompt footer and the subagent footer use the same subtree fold. A new session.children accessor on the TuiPlugin state exposes child sessions to plugins. Co-Authored-By: Claude Code <noreply@anthropic.com>
Sessions whose parent is outside the window now report their subtree rollup (SessionRollup), so subagent spend and token counts attribute to the session that spawned them; the Sessions count and per-session averages stop inflating from child rows. The per-model breakdown also scans SessionMessageTable assistant rows so V2 runner sessions contribute their model usage. Co-Authored-By: Claude Code <noreply@anthropic.com>
ACP's usage_update only reported the current session's own cost, so subagent-heavy prompts under-reported spend to ACP clients the same way the TUI and stats did. A shared UsageService.subagentCost recursively walks session.children (visited-set guarded) and both the production sendUpdate (service.ts, backed by the SDK) and the test-injectable Effect service (usage.ts, backed by a new ChildrenLoader) add it to the reported cost. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
stats.ts: remove sessionCost/sessionTokens left over from the rollup refactor. tui: narrow subtreeCost's parameter to the fields it actually reads, matching the SessionRollup.Source pattern in core, so tests can pass minimal fixtures instead of a full SDK Session. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
|
The following comment was made by an LLM, it may be inaccurate: Potential Duplicate FoundPR #25712 — This may be related as it addresses displaying subagent cost rollup in the TUI sidebar and task history, which overlaps with the TUI cost display components modified in PR #50737. However, this appears to be an older feature PR that may have been superseded or is addressing a different aspect of the same feature area. All other searches returned only the current PR #50737 itself, indicating no other directly competing or duplicate PRs addressing the session cost rollup issue. |
|
Closing this — per internal discussion, we're maintaining this fix on our own fork rather than waiting on upstream review. The commits remain available at https://github.com/chipp-ai/opencode/tree/session-cost-rollup if a maintainer wants to pick this up later. |
Issue for this PR
Closes #45417
Closes #39740
Closes #40114
Closes #31032
Type of change
What does this PR do?
Session cost shown in the TUI sidebar/footer,
opencode stats, and ACP only ever reflected the root session. Subagent sessions (spawned via the Task tool, linked bysession.parent_id) accumulate their own cost in their own row, and nothing ever rolled that up — billing-level reconciliations in #45417 measured 42-84% of real spend invisible in the UI. Digging in, there were three separate bugs behind that:Step.Endedpublishedcost: 0unconditionally —SessionRunnerModel.resolvepicked the priced catalog model but only returned the unpricedllm.Model, throwing the pricing info away before it reached the cost calculation. The projector also never applied V2 step usage to the session row, so V2-run sessions stayed at $0/0 tokens no matter what they actually cost.parent_idfor cost.applyUsagein the projector increments exactly one session's row; every UI (sidebar, footer,stats, ACP) read that single row directly.Session.forkcopies messages and step-finish parts with theircost/tokensintact, so replaying those copies through the projector added the source session's cost to the fork's row a second time.Fix, by layer:
packages/core/src/session/runner/usage.ts(new) — a pure pricing function ported from the legacySession.getUsage, using the V2 catalog's flatcostarray (context tiers already folded in byplugin/models-dev.ts) instead of the legacy object-with-tiers shape.SessionRunnerModel.resolvenow returns{ model, info }so the runner has the priced model atStep.Ended.packages/core/src/session/projector.ts—Step.Endednow applies usage to the session row with a subtract-old/add-new pattern keyed on the projected assistant message (mirrors howPartUpdatedalready handles V1 usage), so event replay is a no-op instead of double-counting.RevertEvent.Committednow subtracts usage for the message rows it deletes — needed onceStep.Endedactually contributes cost, otherwise a revert would leave stale cost behind.packages/core/src/session/rollup.ts(new) +Session.Rollupschema — a pure fold over a session's descendants byparent_id(cycle-guarded), exposed asSessionV2.cost(sessionID)and a newGET /api/session/:sessionID/costprotocol endpoint for external/plugin consumers.packages/opencode/src/session/session.ts—forkzeroes thecoston copied step-finish parts and the copied assistant message (keeping token counts intact, since context-overflow detection and the UI's context display still need them).own + subagentswith a muted(+$X.XX subagents)breakdown when subagent spend exists; falls back to the exact prior display when there isn't any.opencode statsattributes each top-level session's rollup (including descendants) instead of flattening every row into one global sum with per-row inflation from forks; also picks up V2 assistant rows for the per-model breakdown, which previously only scanned V1 message history.usage_updatecost now includes a recursive subagent walk (shared between the production SDK-backed path and the test-injectableusage.tsservice).Known gaps this PR does not attempt to fix (flagged rather than silently left out): compaction/summary LLM calls still aren't metered (separate pre-existing gap, same root cause as #7175/#46371); the web/desktop app (
packages/app) still reads onlyinfo.cost— it's a natural follow-up now that the/costendpoint exists, but wiring the UI wasn't in scope here;opencode stats'Sessionscount is now roots-only, which is a visible behavior change worth calling out explicitly.How did you verify your code works?
core/test/session-runner-usage.test.ts), projector usage application/idempotency/revert-subtraction (core/test/session-projector.test.ts), the rollup fold plus theSessionV2.costservice method (core/test/session-rollup.test.ts), fork cost zeroing (opencode/test/session/session.test.ts), the TUI subtree fold (tui/test/session-subtree.test.ts), and ACP's subagent walk (opencode/test/acp/usage.test.ts).bun typecheck(viabun turbo typecheck) passes clean across all 30 packages.bun testinpackages/core,packages/tui, andpackages/opencodepass (the last with 3600+ tests).opencode statsattributes the subtree to the root.Checklist