Skip to content

fix: accumulate session cost independently of message array (#1423)#1430

Open
trek-e wants to merge 1 commit intogsd-build:mainfrom
trek-e:fix/1423-session-cost-compaction
Open

fix: accumulate session cost independently of message array (#1423)#1430
trek-e wants to merge 1 commit intogsd-build:mainfrom
trek-e:fix/1423-session-cost-compaction

Conversation

@trek-e
Copy link
Collaborator

@trek-e trek-e commented Mar 19, 2026

Problem

getSessionStats() summed cost from assistant messages in state.messages. After auto-compaction, pre-compaction messages are replaced by a compactionSummary with no usage — causing reported cost to drop.

Fix

Added cumulative accumulators incremented on every assistant message event, independent of the message array. getSessionStats() returns max(array-sum, cumulative) for monotonically non-decreasing values.

Test Results

1844 pass, 0 fail, 3 skipped.

Fixes #1423

…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
Copy link
Contributor

@frizynn frizynn left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Session cost resets after auto-compaction

2 participants