fix: accumulate session cost independently of message array (#1423)#1430
fix: accumulate session cost independently of message array (#1423)#1430trek-e wants to merge 1 commit intogsd-build:mainfrom
Conversation
…d#1423) getSessionStats() calculated cost by summing usage from assistant messages in state.messages. After auto-compaction, pre-compaction messages are replaced by a compactionSummary with no usage field — dropping the cost. Fix: Added cumulative accumulators (_cumulativeCost, _cumulativeInputTokens, _cumulativeOutputTokens, _cumulativeToolCalls) that are incremented on every assistant message event, independent of the message array. getSessionStats() now returns max(array-sum, cumulative) to ensure monotonically non-decreasing values. Fixes gsd-build#1423
frizynn
left a comment
There was a problem hiding this comment.
The core idea is right, but there are two bugs and one missing reset that need to be addressed.
Blockers
cacheRead and cacheWrite are not accumulated, causing underreported totals after compaction
agent-session.ts lines 446-449 accumulate input, output, cost, and toolCalls, but skip cacheRead and cacheWrite. After compaction, totalCacheRead and totalCacheWrite will come from the compacted messages array (which has no usage), so they'll be 0. The total calculation on the returned tokens object then mixes the cumulative input/output with post-compaction (zeroed) cache values:
total: Math.max(totalInput + totalOutput, this._cumulativeInputTokens + this._cumulativeOutputTokens) + totalCacheRead + totalCacheWrite,Once compaction has happened and we're taking the cumulative branch, totalCacheRead + totalCacheWrite will be 0. Cache tokens get silently dropped from the total. We need two more accumulators, _cumulativeCacheRead and _cumulativeCacheWrite, incremented alongside the others, and the return value should use Math.max(totalCacheRead, this._cumulativeCacheRead) and Math.max(totalCacheWrite, this._cumulativeCacheWrite).
Cumulative accumulators are not reset on switchSession
switchSession (around line 2843) resets steering messages, follow-up messages, and pending messages, but the new _cumulativeCost, _cumulativeInputTokens, _cumulativeOutputTokens, and _cumulativeToolCalls fields are never cleared. If a user switches from session A (which had compaction) to session B, getSessionStats() will return inflated numbers from session A until session B generates enough messages to push the Math.max values past the stale accumulators. The fix is to zero all four (or six, once cache is added) accumulators at the top of switchSession.
Suggestions
Accumulating error messages may not be desirable
The existing getSessionStats loop has no special handling for stopReason === "error", so the new accumulators match that behavior. That's consistent. But it's worth a comment noting this is intentional, since other parts of the file explicitly gate on stopReason !== "error". A failed LLM call that charged tokens should still be counted, but a reader of this code might wonder.
Optional chaining in the event handler vs. direct access in getSessionStats
The accumulator code uses assistantMsg.usage?.cost?.total ?? 0 (defensive), while the existing getSessionStats loop uses assistantMsg.usage.cost.total (direct). These should be consistent. If the field can be missing, getSessionStats has an existing latent crash. If it can't be missing, the ?. is unnecessary noise. Pick one style.
Problem
getSessionStats()summed cost from assistant messages instate.messages. After auto-compaction, pre-compaction messages are replaced by acompactionSummarywith no usage — causing reported cost to drop.Fix
Added cumulative accumulators incremented on every assistant message event, independent of the message array.
getSessionStats()returnsmax(array-sum, cumulative)for monotonically non-decreasing values.Test Results
1844 pass, 0 fail, 3 skipped.
Fixes #1423