fix(ai-chat): accumulate output tokens across tool-use steps in a turn

Fold each finished round-trip's streamed total into a base so the thinking
indicator's token count stays monotonic across a multi-step turn instead of
dropping back to near-zero when the next step's message begins.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ruben Fiszel
2026-07-03 17:04:04 +02:00
co-authored by Claude Opus 4.8
parent 02f3a2927d
commit ecb406d1ef
@@ -273,9 +273,15 @@ export class AIChatManager {
currentReply = $state<string>('')
currentReasoning = $state<string>('')
currentReasoningActive = $state<boolean>(false)
// Live output-token count for the in-flight assistant message; drives the typing
// indicator's progress readout. Only populated by providers that stream usage.
// Live output-token count for the current turn; drives the typing indicator's
// progress readout. Only populated by providers that stream usage.
currentOutputTokens = $state<number>(0)
// A turn can span several API round-trips (one per tool-use step). Each round-trip's
// streamed usage.output_tokens is a running total scoped to that one message, so it
// restarts from near-zero every step. To keep the readout monotonic across the whole
// turn, fold each finished step's total into a base and add the in-flight step on top.
#outputTokensBase = 0
#outputTokensStep = 0
displayMessages = $state<DisplayMessage[]>([])
messages = $state<ChatCompletionMessageParam[]>([])
/** Provider-reported context size of the last committed turn (prompt +
@@ -1641,6 +1647,8 @@ export class AIChatManager {
this.currentReasoning = ''
this.currentReasoningActive = false
this.currentOutputTokens = 0
this.#outputTokensBase = 0
this.#outputTokensStep = 0
// Compaction trigger. Without a known context window there is no limit
// to enforce, so compaction stays off rather than guessing one.
@@ -1696,7 +1704,14 @@ export class AIChatManager {
onNewToken: (token) => (this.currentReply += token),
onReasoningDelta: (token) => (this.currentReasoning += token),
onReasoningStart: () => (this.currentReasoningActive = true),
onOutputTokens: (n) => (this.currentOutputTokens = n),
onOutputTokens: (n) => {
// A drop means a new step's message started; bank the prior step's total.
if (n < this.#outputTokensStep) {
this.#outputTokensBase += this.#outputTokensStep
}
this.#outputTokensStep = n
this.currentOutputTokens = this.#outputTokensBase + n
},
onMessageEnd: () => {
// Keep the streamed text for the abort/error paths. Non-empty only:
// parsers flush (and reset) when a tool call starts after text, and