Skip to content

fix(desktop): keep the context meter live through a long agentic turn - #316

Merged
OmarB97 merged 1 commit into
mainfrom
fix/spawned-session-context-meter-fork-20260802
Aug 2, 2026
Merged

fix(desktop): keep the context meter live through a long agentic turn#316
OmarB97 merged 1 commit into
mainfrom
fix/spawned-session-context-meter-fork-20260802

Conversation

@OmarB97

@OmarB97 OmarB97 commented Aug 2, 2026

Copy link
Copy Markdown
Owner

The symptom

A chat started by hermes desktop spawn streams its tokens into the transcript
perfectly, but the status-bar context meter sits frozen for the entire run,
while a chat the user types updates as they go.

Observed 2026-08-02 on a long-running spawned agentic session. From the
operator's own state.db, session 20260802_062726_a24427:

first user message the --delegated contract (so: definitely a spawn)
user messages 1
API calls / tool calls 22 / 40
context_length 262,144 (correct — not a window-sizing problem)
last_prompt_tokens 95,377
compressions 0

The window really filled to 95K and the gauge never said so.

Why it happens

It is not that spawned sessions are wired differently. They go through the
same session.create, the same stores, and the same event handlers a typed
chat does — I checked that first, and it is a dead end.

The real cause is that context usage only ever crossed the wire at turn
boundaries
: session.info immediately before message.start (carrying the
previous turn's occupancy) and message.complete at the end. There is
nothing in between — no heartbeat, despite renderer comments in
use-session-state-cache.ts that talk about "periodic session.info
heartbeats". _get_usage is simply never called anywhere else.

A chat someone types turns over every few sentences, so the gauge looks live.
A --delegated spawn is by construction one long agentic turn, so it has
no boundary to report at until it is finished. Hence "spawned sessions are
broken, typed ones are fine" — the bug is not spawn-specific, it is
spawn-shaped.

The giveaway: the desktop has had a full token.usage handler for a long time
(gateway-event.ts, with its own tests). Nothing on the Python side had ever
emitted that event. This wires up the missing half.

The change

_on_tool_complete now also pushes a token.usage frame. Each completed tool
follows an API response, which is exactly when the compressor's occupancy
moved, so it is the natural sampling point — one small frame per round trip,
next to nothing beside the tool.start / tool.complete / message.delta
traffic already on that wire.

Deliberate details worth a reviewer's eye:

  • It sits outside the tool-progress gate. The meter must track a long turn
    even for a client that streams no tool cards, so it cannot piggyback on the
    tool.complete payload.
  • The renderer's handler reads a flat payload (context_tokens /
    context_length / context_pct) which is not what _get_usage calls
    those fields. The emitter translates. A rename on either side silently stops
    the meter again — hence an explicit test on the key names.
  • usageFromTokenUsagePayload dropped compressions, so a compaction landing
    mid-turn would read as context going backwards and the monotonic guard in
    mergeUsageSnapshot would pin the gauge high until the turn ended — exactly
    the window this frame exists to cover. Carried through now (3 lines).
  • Nothing is emitted while the window is still unknown: a fresh compressor
    reports last_prompt_tokens 0 and _get_usage deliberately omits the gauge
    rather than fabricate 0%, so a partial frame can never blank a live meter.

Evidence

Verified against the real app, not only in unit tests. The e2e mock inference
server can now drive a short tool-calling loop, so one agent turn spans several
round trips, and MOCK_TOOL_CALL_DELAY_MS paces it so a test can sample state
during that turn rather than watch it land between two polls.

With the fix — a spawned turn reports four times while it is still running:

[spawn] t=0.0s  meter=""
[spawn] t=2.4s  meter="16.3k/256k [█░░░░░░░░░] 6%"
[spawn] t=3.2s  meter="16.4k/256k [█░░░░░░░░░] 6%"
[spawn] t=4.0s  meter="16.5k/256k [█░░░░░░░░░] 6%"
[spawn] t=4.4s  meter="16.6k/256k [█░░░░░░░░░] 6%"

With only the _emit_token_usage call reverted — exactly one reading, at
the moment the turn ends:

[spawn] t=0.0s  meter=""
[spawn] t=5.0s  meter="16.6k/256k [█░░░░░░░░░] 6%"

The typed case behaves identically in both runs, which is the point: this was
never a spawn-only defect.

Checks

  • tests/tui_gateway/ — 422 passed. The 4 failures (test_goal_command,
    test_projects_rpc, test_subagent_child_mirror, test_turn_outcomes) are
    byte-identical with my change stashed, i.e. pre-existing on this machine.
  • apps/desktop npm run test:ui — 1925 passed, 1 skipped.
  • npm run typecheck (app + electron + e2e) clean; eslint clean on the files
    touched (the 9 repo-wide perfectionist/sort-* errors are all in files this
    PR does not touch).
  • Playwright e2e/context-meter-spawn.spec.ts + e2e/chat.spec.ts — 4 passed.
  • feat(desktop): local spawn API — start app-owned chats from the CLI, streamed live #298's guarantee test, use-session-actions.test.tsx "does not mutate the
    persisted composer selection", stays green; nothing here goes near
    desktopSessionCreateParams or any composer atom.

Two things found on the way, deliberately NOT in this PR

  1. The persisted composer model really does leak. feat(desktop): local spawn API — start app-owned chats from the CLI, streamed live #298's promise that a
    spawn's -m will not move the user's saved selection is broken in two
    places — applyRuntimeInfo (use-session-actions/utils.ts) calls
    setCurrentModel(info.model) with the spawn's override, and
    syncRuntimeMetadataToView (use-session-state-cache.ts) rewrites it on
    every session.info for the life of the session. The test that should
    catch this passes vacuously because its session.create stub omits info
    entirely; add info: { model: … } to it and it goes red today. That is a
    larger, riskier change and deserves its own PR — filed separately.
  2. A cold context-length cache can size a window wrong. The first spawn
    test on 2026-08-01 recorded context_length = 1000000 for
    deepseek-v4-flash-0731-ds4 (real window 262,144) because
    context_length_cache.yaml had not learned it yet, so resolution fell
    through to the generic prefix table in model_metadata.py. Not
    spawn-specific and not what this PR fixes, but it is why the meter format in
    the original report read /1M.

🤖 Generated with Claude Code

A chat started by `hermes desktop spawn` streams its tokens fine but its
context meter sits frozen for the whole run. Measured 2026-08-02 on session
20260802_062726_a24427: 22 API calls, 40 tool calls, 95,377 prompt tokens
against a 262,144 window, and the gauge never moved.

The cause is not that spawned sessions are wired differently — they use the
same stores and the same handlers a typed chat does. It is that context usage
only ever crossed the wire at TURN boundaries: `session.info` immediately
before `message.start` (carrying the previous turn's occupancy) and
`message.complete` at the end. Nothing in between. A chat someone types turns
over every few sentences, so the gauge looks live; a `--delegated` spawn is by
construction ONE long agentic turn, so it has no boundary to report at until
it finishes. The renderer comments that speak of "periodic session.info
heartbeats" describe something the backend does not actually do.

So `_on_tool_complete` now also pushes a `token.usage` frame. Each completed
tool follows an API response, which is exactly when the compressor's occupancy
moved, making it the natural sampling point — one small frame per round trip,
next to nothing beside the tool.start/tool.complete/message.delta already on
that wire. It sits OUTSIDE the tool-progress gate on purpose: the meter has to
track a long turn even for a client that streams no tool cards.

The desktop has handled `token.usage` since long before this; nothing had ever
emitted it. Two details that bite if you only wire up half of it:

- The handler reads a FLAT payload (`context_tokens` / `context_length` /
  `context_pct`), which is NOT what `_get_usage` calls those fields. The
  emitter translates; a rename on either side silently stops the meter again.
- `usageFromTokenUsagePayload` dropped `compressions`, so a compaction landing
  mid-turn would look like context going backwards, and the monotonic guard in
  `mergeUsageSnapshot` would pin the gauge high until the turn ended — exactly
  the window this frame exists to cover. It is carried through now.

Nothing is emitted while the window is still unknown: a fresh compressor
reports `last_prompt_tokens` 0 and `_get_usage` deliberately omits the gauge
rather than fabricate 0%, so a partial frame can never blank a live meter.

Verified against the real app, not just in unit tests. The e2e mock inference
server can now drive a short tool-calling loop, so a single agent turn spans
several round trips, and `MOCK_TOOL_CALL_DELAY_MS` paces it so a test can
sample state DURING that turn instead of watching it land between two polls.
With the fix a spawned turn reports four times while it runs
(16.3k -> 16.4k -> 16.5k -> 16.6k, first at t=2.4s); with the emit reverted it
reports exactly once, at t=5.0s, when the turn ends. The typed case moves the
same way — the bug was never spawn-specific, only spawn-shaped.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@OmarB97
OmarB97 merged commit 64a14ed into main Aug 2, 2026
39 checks passed
OmarB97 added a commit that referenced this pull request Aug 2, 2026
…ount (#332)

The status-bar meter still lags through a long agentic turn. Observed
2026-08-02: it sat at 55.8k while the server was serving that same session at
65.7k+ across in-turn tool rounds.

#316 made a completed TOOL push a `token.usage` frame, which is what got the
gauge moving mid-turn at all. But a tool completion is the wrong signal for the
quantity being published. It reports the request that *produced* that tool call,
before its result was appended, so the reading is a full round trip behind
whatever is in flight — and a round that ends without calling a tool (a
reasoning-only round, a `length` continuation, the final round of every turn)
says nothing at all. When several of those land in a row the gauge does not
drift, it sits still, which is what "pinned" looked like.

That last step is an argument from the code, not a reproduction: the harness
cannot currently drive a turn that keeps going without calling a tool, so the
field symptom itself was not reproduced here. What is reproduced, and asserted,
is that occupancy now reaches the wire on the response rather than on the tool —
see the verification note at the end for exactly where the line is drawn.

Usage now goes out from `record_canonical_usage`, the one place that sees the
provider's own prompt count land on the compressor. That is one frame per
usage-bearing API response, on the real number rather than an estimate, and
because both the chat loop and the codex app-server runtime account through it,
both are covered by the same three lines. #316's tool-completion sample stays as
a cheap second one — with de-duplication it costs nothing, and it still covers a
runtime that accounts outside that recorder.

The agent loop had offered a hook for exactly this since the live context bar
was added, and nothing ever assigned it, so the branch was dead. It was also
offered at the wrong moment — *before* the request, carrying
`estimate_messages_tokens_rough`. That estimate assumes ~4 chars/token while
dense tool-call/JSON transcripts tokenize nearer ~3.4, so it reads materially
low against the server: the over-reservation note in `model_metadata` measured
est 58,039 against server >=65,797 on deepseek-v4-flash-w2, a gap the same shape
and size as the one reported here. Publishing it would have put a number on the
meter that under-reads the window by ~13%. The estimate still does its real job
(sizing compression decisions); it just no longer pretends to be a measurement,
and the helper is renamed to `_raise_preflight_context_estimate` to stop the
name promising otherwise.

Three details that matter more than the wiring:

- **The hook is a post-construction attribute, never an AIAgent kwarg.**
  `delegate_tool` forwards ~30 constructor kwargs from parent to child, so a
  kwarg is precisely how a subagent would inherit this and start reporting its
  own much smaller window on the parent's gauge. Auxiliary calls — goal judge,
  title, compaction — never reach the recorder at all; they account through
  `record_aux_usage`, which touches neither the compressor nor the session
  counters. Both isolations are asserted, the goal judge through the real
  auxiliary path with its recorded row checked, so the test cannot pass by the
  call simply never happening.
- **Frames are de-duplicated and floored at 1/s.** Every tool in a parallel
  batch reads the same occupancy, so an unchanged gauge is dropped outright and
  costs nothing. A moved gauge waits out the floor; dropping one is safe because
  occupancy only climbs within a turn, so a later frame supersedes it and
  `message.complete` still carries the turn's final number. A compaction is
  exempt — it is the one legitimate fall, and it is the drop the client's
  monotonic guard is watching `compressions` to authorize.
- **The usage frame now precedes the tool card.** `tool.complete` is the one
  emit in that callback not wrapped in `try/except`; a tool result that fails to
  serialize raises through it and the tool executor swallows that, which
  silently took the usage frame down with it.

Verified on the real stack, not just in unit tests. The e2e mock answered
`stream_options: {include_usage: true}` with nothing at all, so every streamed
response looked usage-less and the whole recorder path went unexercised; it now
reports a real prompt count that grows each round, the way a provider does. A
spawned turn then tracks the server's own number live — 15k -> 18k -> 21k ->
24k, +3k per round trip — and readings are asserted never to dip, which is what
a subagent's or an aux call's window reaching the gauge would look like. The
typed case moves the same way.

Be clear about what that run does and does not prove: against this mock, `main`
renders the identical series, because every mock round calls a tool, so tool
completion and provider response coincide. The e2e establishes that the meter
tracks the real count live and that nothing regressed. The behaviour that
actually changes — a round reporting with no tool completion anywhere in it — is
pinned at the seam instead, by a test that drives the real `record_canonical_usage`
and asserts the frame reaches the wire without a single tool involved. A mock
turn cannot reach that state today: it would need a round that continues without
calling a tool (a `length` continuation or a reasoning-only retry), which is
worth adding to the harness separately rather than smuggling into this fix.

Co-authored-by: Omar Baradei <omar@kostudios.io>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
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.

1 participant