feat(agent): add getUsage() aggregate usage accessor to ModelResult - #97
Conversation
) `getResponse()` resolves to the final round's response, so in a multi-round tool loop the tokens spent on the intermediate `tool_calls` generations were unreachable. `getItemsStream()` compounds this: it carries output items only and never surfaces the `response.completed` events that hold each round's usage block, so callers streaming items had no way to account for a run's real token spend without registering a hook. `await result.getUsage()` returns the same `SessionUsageTotals` shape as the `SessionEnd` hook's `totalUsage`, summed over every model call the run made — initial, each tool-round follow-up, the empty-final retry, the `allowFinalResponse` final turn, and approval-resume requests. It gates on run completion like `getResponse()` does, so totals are final whether awaited directly, after `getResponse()`, or after draining any streaming getter. Unlike `getResponse()` it never rejects: a failed run still consumed tokens, and cost accounting typically runs in a `finally` where a second throw would mask the original error. Two supporting changes make the aggregate correct for hook-less callers, who previously saw zeros because accumulation only advanced as a side effect of `PostModelCall` emission: - `emitPostModelCall` folds usage in before the `hooksManager` short-circuit; only the hook emit stays gated. - `finishHooksSessionForStream` materializes the parked model-call telemetry before its `hooksManager` guard — it is the only site that folds the no-tools streaming response into the aggregate. `SessionEnd.totalUsage` and `getUsage()` now both read from one `snapshotSessionUsage()` helper, so the two cannot drift. Also documents on `getItemsStream()` that the stream carries no usage or response-level metadata, pointing at `getUsage()`, the `PostModelCall` hook, and `getFullResponsesStream()`'s per-round `response.completed`. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Review follow-up on #97: the `isResumingFromApproval` guard in `getUsage()` was the one code path with no direct coverage. Adds four cases under a `paused at awaiting_approval` block, using an approval-gated tool plus an in-memory StateAccessor so a pause can be resumed: - a run parked at `awaiting_approval` reports only the model call that completed before the pause (the requested case) - repeated reads while paused are idempotent and dispatch no request — a further scripted response is queued so an accidental dispatch would surface as both a call-count bump and inflated totals - the aggregate is scoped per ModelResult, not per conversation: a resumed run reports only its own generation, so a caller summing across resumes adds rather than double-counts - reading usage as the FIRST call on an approval-resumed run does not advance the conversation That last case is what actually pins the guard. Verified by mutation: with the guard removed the run settles to 'complete' as a side effect of merely asking for usage, and the test fails ('complete' vs 'in_progress'). Asserting on totals alone could not catch it — the resume's generation is dispatched during initStream either way, so the numbers are identical with and without the guard; only run status distinguishes them. Also parameterizes the `toolCallResponse` fixture's function-call name, which previously hardcoded `echo` and silently routed to `awaiting_client_tools` instead of `awaiting_approval`. Test-only change; no source modifications. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
cortex review —
|
…failure; avoid teardown buffer replay Review feedback on #97: - getUsage() on an approval-resumed run whose resume dispatch returned a real event stream now consumes the buffered stream (a passive read) so the resume generation's tokens are counted — previously only the non-streaming resume branch was covered. The tool loop is still never advanced by a usage read. (Devin) - The never-rejects catch in getUsage() now console.warns the swallowed cause, matching every other never-throw path in the file. (cortex) - getUsage() JSDoc no longer claims totals are unconditionally final: documents the approval-resume carve-out. getResponse() JSDoc gains the final-round-only note that previously existed only in the README. (cortex) - Hook-less streaming teardown no longer replays the whole retained event buffer through an async consumer to find the completion event: extractCompletionFromBuffer() scans the buffer backwards synchronously. (cortex)
…he terminal event Devin's follow-up on #97: item/text consumers stop at the terminal event (streamTerminationEvents), but ReusableReadableStream.isComplete only flips when the pump reads the source close — which real network streams deliver AFTER response.completed. In that window teardown found isComplete=false and silently dropped the parked PostModelCall (a pre-existing hole for hook users, and since #97 also a getUsage() gap on the no-tools streaming path). finishHooksSessionForStream now falls back to a non-throwing buffered terminal-event scan (tryExtractCompletionFromBuffer) when the stream is not yet marked complete, emitting the parked telemetry when the terminal event is already buffered and staying silent otherwise (errored mid-flight streams have no materialized response). Regression test models the late close with a source that never closes: red without the fix (0 PostModelCall emits), green with it.
…ge() changeset Per .agents/skills/public-api-examples: every public-API changeset must carry a fenced consumer example so the generated CHANGELOG.md is self-documenting. Also scopes the 'totals are final' sentence with the approval-resume carve-out to match the JSDoc.
Closes #10
The gap
Reported against
getItemsStream()with a multi-round tool-calling run: "I only get the last generation withstop. I don't get the generations withtool_calls."Two things compound:
getResponse()returns only the final round.finalResponseis the last response the loop materialized, soresponse.usageaccounts for the closingstopgeneration alone. Every intermediatetool_callsgeneration's tokens are unreachable through it.getItemsStream()carries output items only. It yieldsmessage/function_call/reasoning/function_call_outputitems and never surfaces theresponse.completedevents that hold each round's usage block. So a caller who consumes items and then callsgetResponse()gets the final round twice over, and nothing else.Net effect: no pull-based way to answer "what did this run cost?"
What already existed
PostModelCallhook — fires once per completed model response withturnType/turnNumber/usage. Correct per-call granularity, but push-based: you must register a hook before the run.SessionEnd.totalUsage— the aggregate, already computed and already the right shape (SessionUsageTotals). Also push-based, and only emitted at teardown.getFullResponsesStream()— does surface each round'sresponse.completed, so per-round usage is technically reachable, but only by consuming the raw event stream instead of the items stream.allToolExecutionRounds— retains each round's fullresponse.The aggregate the issue wants was therefore already being computed on every run and simply had no public reader.
What this adds
ModelResult.getUsage(): Promise<SessionUsageTotals>— reuses the existing hook type/schema rather than inventing a shape, sogetUsage()andSessionEnd.totalUsageare the same numbers by construction (both read one new privatesnapshotSessionUsage()helper).Covers the initial request, each tool-round follow-up, the empty-final retry, the
allowFinalResponsefinal turn, and approval-resume requests.Two supporting fixes
The aggregate was previously only correct when hooks were configured — accumulation ran as a side effect of hook emission:
emitPostModelCallnow folds usage in before thehooksManagershort-circuit; only the emit stays gated.finishHooksSessionForStreamnow materializes the parked model-call telemetry before itshooksManagerguard. This is the only site that folds the no-tools streaming response into the aggregate, so without this a hook-lessgetItemsStream()/getTextStream()run reportedmodelCalls: 0.Neither changes hook-facing behavior (a test asserts
getUsage()equals theSessionEnd.totalUsagepayload); all 651 existing agent tests pass unchanged.Judgment calls
getResponse(). Gates oninitStreamGuarded()+executeToolsIfNeeded(), mirroringgetDoomLoopVerdict(). So the totals are final whether you await it directly, aftergetResponse(), or after draining any streaming getter.finally/catchwhere a second throw would mask the run's original error. A failed/paused run returns totals accrued so far;modelCalls: 0with zeroed tokens when nothing completed. Await the run itself if you need to observe the failure.coststays optional, matching the hook payload — a summed0from cost-less responses would read as "free" rather than "unknown".getRounds()) in this PR. It does not drop out cleanly: bothallToolExecutionRounds.pushsites store the response that produced the tool calls, so the closingstopround and thefinal/retryturns are never in that array. An accessor over it would silently under-report exactly the rounds the issue is about. Per-round usage is already reachable via thePostModelCallhook orgetFullResponsesStream()'sresponse.completed; a dedicated accessor would want its own per-call ledger and is better as a follow-up.Testing
Written red-first — all 8 failed with
result.getUsage is not a functionbefore implementation, then green.packages/agent/tests/unit/get-usage.test.tscovers the multi-round sum (asserting the same run'sgetResponse()still reports only 300 of 450 tokens), the streaming path via a fully-consumedgetItemsStream()(both tool-loop and no-tools), the no-hooks path, agreement withSessionEnd.totalUsage, usage-less responses, and a failed run.Build, typecheck, lint, and the full suite (754 tests) all green. Changeset included (minor — new public API).
🤖 Generated with Claude Code