Report the context a session holds, not what it has spent

The number on the status row was a running total of tokens spent, so it
could only ever climb: a session compacted from 128k down to 10k, or
cleared outright, went on reporting the larger figure, and disagreed with
the divider directly above it saying what the compaction had recovered.

It now reports what the model is holding -- prompt plus both cache
figures -- folded through `driver::context_after`, which is the one rule
the pump, the transcript and the phone all use: a turn sets it, a
compaction replaces it with what the compaction measured, and a clear
leaves it unmeasured. Unmeasured says so in words, because an empty
context and one nobody has counted used to look identical.

Taken from the turn's last assistant message rather than its `result`:
measured against CLI 2.1.237, a two-message turn reported a cache read of
40,211, being 14,259 and 25,952 -- the same conversation counted twice,
and no size the model ever held.
This commit is contained in:
iris committed 2026-08-30 01:53:43 -04:00
1 parent 81c8a57181
commit 5e11b9da80
12 files changed
+442 -130

No files matched your search

@@ -113,14 +113,16 @@ sealed class SessionEvent {
data class Settings(val model: String?, val permissionMode: String?) : SessionEvent()
/**
* What a turn cost, and what the session has cost in total.
* What a turn cost, and how much the model was holding when it ended.
*
* [total] is the server's running figure, carried on the event so a reader never adds up its
* own: a phone opens a session on the newest page of the transcript, so a sum it computed would
* be that page's share of the conversation wearing the whole conversation's label. Zero on
* entries recorded before the backend sent it.
* [context] is prompt plus both cache figures, measured by the backend from the turn's own
* usage. Carried on the event rather than summed by the reader, because it is not a sum: a
* conversation's context drops at a compaction and a clear, so adding turns up would report a
* figure the session stopped being true of. Null where the dialect did not say, and on entries
* recorded before the backend sent it -- which leaves the context unmeasured rather than
* unchanged.
*/
data class UsageDelta(val tokens: Long, val total: Long) : SessionEvent()
data class UsageDelta(val tokens: Long, val context: Long?) : SessionEvent()
/**
* A compaction that finished, and how much context it recovered.
@@ -235,7 +237,10 @@ fun parseSeqEvent(json: String): SeqEvent {
permissionMode = body.optString("permissionMode").ifEmpty { null },
)
"usageDelta" ->
SessionEvent.UsageDelta(body.getLong("tokens"), body.optLong("total", 0))
SessionEvent.UsageDelta(
body.getLong("tokens"),
if (body.has("context")) body.getLong("context") else null,
)
"compacted" ->
SessionEvent.Compacted(
preTokens = if (body.has("preTokens")) body.getLong("preTokens") else null,
@@ -248,3 +253,29 @@ fun parseSeqEvent(json: String): SeqEvent {
}
return SeqEvent(seq = body.getLong("seq"), ts = body.getDouble("ts"), event = event)
}
/**
* The context after [event], given what it was before.
*
* The same rule the server folds with, because the screen has to keep up between page loads: the
* summary it opened with is a measurement from before this stream started, and every event that
* moves the figure arrives here.
*
* The two that lower it are the point. A clear takes the conversation away and a compaction
* replaces it with a summary, so a figure measured before either stopped being true at that moment
* -- and carrying it forward is how a session that had just been cleared went on reporting the
* context it no longer had.
*
* Null is "we don't know", which is a state each of them can reach: nothing measured yet, a
* compaction that finished without saying how much it recovered, or a clear nobody has run a turn
* since.
*/
fun contextAfter(current: Long?, event: SessionEvent): Long? =
when (event) {
// Falls back to what we had, so a turn the dialect reported no usage for is stale by a
// turn -- which every context figure is -- rather than unknown.
is SessionEvent.UsageDelta -> event.context ?: current
is SessionEvent.Compacted -> event.postTokens
is SessionEvent.Cleared -> null
else -> current
}