Skip to content

fix(core): roll up subagent cost into parent session and fix fork double-counting - #43645

Open
AndyS77 wants to merge 5 commits into
anomalyco:devfrom
AndyS77:cost-subagent-rollup
Open

fix(core): roll up subagent cost into parent session and fix fork double-counting#43645
AndyS77 wants to merge 5 commits into
anomalyco:devfrom
AndyS77:cost-subagent-rollup

Conversation

@AndyS77

@AndyS77 AndyS77 commented Aug 20, 2026

Copy link
Copy Markdown

Issue for this PR

Closes #39740
Closes #31032
Closes #36944

Type of change

  • Bug fix
  • New feature

What does this PR do?

Subagent session costs were not rolled up to the parent session (#39740), and forked sessions double-counted pre-fork costs (#31032, #36944). This PR fixes both issues and exposes the aggregated cost through the protocol layer so the TUI, stats, and ACP can display it.

Fork double-counting fix: When a session is forked, the cloned step-finish parts now have their cost and tokens zeroed out. The forked session starts with zero cost and only accumulates cost from its own LLM calls, not the parent's pre-fork history.

Cost rollup: Added children() and totalCost() to both V1 Session.Service and V2 SessionV2.Interface. totalCost() does a BFS walk across all descendant sessions, summing cost and tokens.

Protocol endpoint: Added session.cost and session.children to the protocol session group (/api/session/:sessionID/cost, /api/session/:sessionID/children) with handlers in both the protocol handler (packages/server) and the server handler (packages/opencode). Both SDKs were regenerated.

TUI: refreshCost effect in stream.transport.ts calls sdk.session.cost() on message.updated and bootstrap. SessionData.totalCost field; footer formatUsage uses totalCost when > 0, falls back to info.cost.

Stats + ACP: Stats uses totalCost() for top-level sessions. ACP sendUpdate uses sdk.session.cost() with totalSessionCost(messages) fallback.

How did you verify your code works?

  • 9 service-level tests (cost-rollup.test.ts): fork zeroing, own cost, child cost, nested children, multiple children
  • 10 ACP usage tests (usage.test.ts): mock updated with cost method
  • 2 HTTP E2E tests (httpapi-session.test.ts): aggregated cost across nested children (parent -> child -> grandchild, verifying 0.06 = 0.01 + 0.02 + 0.03), 404 for missing session
  • bun typecheck clean (0 source-code errors)
  • Both SDKs regenerated via bun run generate

Related PRs (used as reference)

Checklist

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

AndyS77 and others added 5 commits August 20, 2026 12:50
…ble-counting

- Zero out cost/tokens on cloned step-finish parts in fork() to prevent
  double-counting pre-fork spend in forked sessions (anomalyco#31032, anomalyco#36944)
- Add Session.totalCost(sessionID) that BFS-walks descendant sessions via
  children() and aggregates cost + tokens (anomalyco#39740)
- Add GET /session/:id/cost endpoint exposing aggregated cost
- Add E2E tests: subagent cost rollup, fork with subagent history,
  nested child sessions, multiple child sessions

Closes anomalyco#39740, anomalyco#31032, anomalyco#36944
Supersedes anomalyco#25712

Co-Authored-By: zai-glm-52 <noreply@ai.local>
Agent: @bug-fix
Scope: cost-subagent-rollup
- Add totalCost field to SessionData, updated via refreshCost effect
- Footer formatUsage uses totalCost when available, falls back to info.cost
- Context % remains per-session (info.tokens) — correct for context window
- refreshCost fires on message.updated for parent session and on bootstrap
- Calls GET /session/:id/cost endpoint (requires SDK regeneration)

Co-Authored-By: zai-glm-52 <noreply@ai.local>
Agent: @bug-fix
Scope: cost-subagent-rollup
- stats: use totalCost() for top-level sessions (no parentID), skip
  child sessions to avoid double-counting
- acp: sendUpdate fetches aggregated cost via session.cost endpoint,
  falls back to totalSessionCost(messages) if unavailable
- messageLoaderFromSDK: add cost() method to MessageLoaderInterface

Co-Authored-By: zai-glm-52 <noreply@ai.local>
Agent: @bug-fix
Scope: cost-subagent-rollup
…tent SDK cost method

- Rename totalCost to subagentCost in SessionData (clarity: this is
  the aggregated child session cost, not the parent's own cost)
- Footer cost = info.cost (parent) + data.subagentCost (children)
- refreshCost uses existing session.children SDK endpoint instead of
  non-existent session.cost (requires Protocol registration for SDK gen)
- stats: catch NotFoundError from totalCost
- acp: revert to totalSessionCost(messages) until SDK supports cost
- handler: map NotFoundError to ApiNotFoundError

Co-Authored-By: zai-glm-52 <noreply@ai.local>
Agent: @bug-fix
Scope: cost-subagent-rollup
… layer

- Add session.cost and session.children endpoints to protocol session group
- Add children() and totalCost() to SessionV2 Interface + implementation
- Add handlers in protocol SessionHandler
- Regenerate both SDKs (client + legacy js)
- Switch TUI refreshCost to SDK session.cost() endpoint
- Switch ACP sendUpdate to SDK session.cost() with fallback
- Rename SessionData.subagentCost to totalCost (holds server-aggregated total)
- Footer uses totalCost when > 0, falls back to info.cost
- Add E2E tests: aggregated cost across nested children, 404 for missing session
- Update ACP usage test mock with cost method

Co-Authored-By: zai-glm-52 <noreply@ai.local>
Agent: @bug-fix
Scope: cost-subagent-rollup
@github-actions github-actions Bot added the needs:compliance This means the issue will auto-close after 2 hours. label Aug 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

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

Found Potential Duplicates/Related PRs

#25712 - #25712

#32301 - #32301

  • Title: feat: nested sub-agent spawning (up to 5 levels) + fixes for #23091 / #13715
  • Related to subagent session handling and may have overlapping cost tracking concerns.

These are the most relevant matches. PR #25712 is particularly important as it appears to be the companion UI PR that would consume the cost aggregation API being implemented in #43645.

@github-actions github-actions Bot removed the needs:compliance This means the issue will auto-close after 2 hours. label Aug 20, 2026
@github-actions

Copy link
Copy Markdown
Contributor

Thanks for updating your PR! It now meets our contributing guidelines. 👍

@greedyivan

Copy link
Copy Markdown

Reviewed against a billing-reconciled session tree (root + 3 subagents, cross-checked against the provider's invoice — context in #45417): the BFS rollup with the visited set is correct, fork zeroing works, and the test coverage (nested / multi-child / fork scenarios) is good. One real issue in the stats aggregation:

opencode stats grand total double-counts child sessions.

getAllSessions() returns every row from the session table (no parentID filter), and the aggregate does stats.totalCost += result.sessionCost per session. With this PR a top-level session contributes totalCost (own + all descendants) while its children still contribute their own session.cost — every child is counted twice in the grand total.

Numbers from my reconciled tree (root $0.168 own + children $0.0417 / $0.0395 / $0.0396):

  • correct total: $0.2887
  • stats.totalCost with this PR: 0.2887 + 0.1208 = $0.4095 (+42%)

Suggested fix: contribute 0 from child rows to the grand total when their parent is present in the aggregated set (or only aggregate top-level rows). Per-session rows can still display each session's own cost.

Two smaller notes:

@janorga

janorga commented Sep 10, 2026

Copy link
Copy Markdown

Heads-up: this PR's fork-zeroing fixes the over-count (#36944) but doesn't cover the deleted-parent case (#48306). After zeroing, an orphan fork's inherited spend becomes unrecoverable even from message-level data. Consider preserving deleted-session aggregates (rollup) or reattributing inherited cost at fork time.

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

Labels

None yet

Projects

None yet

3 participants