Skip to content

fix(core): roll up subagent cost into session cost across TUI, stats, and ACP - #50737

Closed
hunterchristian wants to merge 8 commits into
anomalyco:devfrom
chipp-ai:session-cost-rollup
Closed

hunterchristian wants to merge 8 commits into
anomalyco:devfrom
chipp-ai:session-cost-rollup

Conversation

@hunterchristian

Copy link
Copy Markdown

Issue for this PR

Closes #45417
Closes #39740
Closes #40114
Closes #31032

Type of change

  • Bug fix
  • New feature

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 by session.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:

  1. The V2 session runner never computed cost at all. Step.Ended published cost: 0 unconditionally — SessionRunnerModel.resolve picked the priced catalog model but only returned the unpriced llm.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.
  2. No code anywhere walked parent_id for cost. applyUsage in the projector increments exactly one session's row; every UI (sidebar, footer, stats, ACP) read that single row directly.
  3. Forking a session double-counted its pre-fork history ([BUG]: Session fork double-counts pre-fork API costs in opencode stats and cost reporting #31032) — Session.fork copies messages and step-finish parts with their cost/tokens intact, 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 legacy Session.getUsage, using the V2 catalog's flat cost array (context tiers already folded in by plugin/models-dev.ts) instead of the legacy object-with-tiers shape. SessionRunnerModel.resolve now returns { model, info } so the runner has the priced model at Step.Ended.
  • packages/core/src/session/projector.ts — Step.Ended now applies usage to the session row with a subtract-old/add-new pattern keyed on the projected assistant message (mirrors how PartUpdated already handles V1 usage), so event replay is a no-op instead of double-counting. RevertEvent.Committed now subtracts usage for the message rows it deletes — needed once Step.Ended actually contributes cost, otherwise a revert would leave stale cost behind.
  • packages/core/src/session/rollup.ts (new) + Session.Rollup schema — a pure fold over a session's descendants by parent_id (cycle-guarded), exposed as SessionV2.cost(sessionID) and a new GET /api/session/:sessionID/cost protocol endpoint for external/plugin consumers.
  • packages/opencode/src/session/session.ts — fork zeroes the cost on 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).
  • TUI sidebar/footer/subagent-footer now show own + subagents with a muted (+$X.XX subagents) breakdown when subagent spend exists; falls back to the exact prior display when there isn't any.
  • opencode stats attributes 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.
  • ACP's usage_update cost now includes a recursive subagent walk (shared between the production SDK-backed path and the test-injectable usage.ts service).

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 only info.cost — it's a natural follow-up now that the /cost endpoint exists, but wiring the UI wasn't in scope here; opencode stats' Sessions count is now roots-only, which is a visible behavior change worth calling out explicitly.

How did you verify your code works?

  • Added unit tests at every layer touched: pricing math and tier selection (core/test/session-runner-usage.test.ts), projector usage application/idempotency/revert-subtraction (core/test/session-projector.test.ts), the rollup fold plus the SessionV2.cost service 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 (via bun turbo typecheck) passes clean across all 30 packages.
  • Full bun test in packages/core, packages/tui, and packages/opencode pass (the last with 3600+ tests).
  • Manually ran a session that spawns subagents and confirmed the sidebar/footer show the combined total with the subagent breakdown, and opencode stats attributes the subtree to the root.

Checklist

  • I have tested my changes locally
  • I have not included unrelated changes in this PR

hunterchristian and others added 8 commits September 22, 2026 15:47
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>
@github-actions

Copy link
Copy Markdown
Contributor

The following comment was made by an LLM, it may be inaccurate:

Potential Duplicate Found

PR #25712 — feat(tui): show subagent cost rollup in sidebar and task history
#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.

@hunterchristian

Copy link
Copy Markdown
Author

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

1 participant