Skip to content

fix(console): the turn timeline waited on every body in the turn (v0.7.3) - #195

Merged
vaderyang merged 2 commits into
mainfrom
fix/turn-detail-eager-bodies
Aug 17, 2026
Merged

fix(console): the turn timeline waited on every body in the turn (v0.7.3)#195
vaderyang merged 2 commits into
mainfrom
fix/turn-detail-eager-bodies

Conversation

@vaderyang

Copy link
Copy Markdown
Collaborator

Opening an Agent Turn made the detail panel wait on every request and
response body in the turn. Nothing the reader is looking at reads a body: the
timeline, the stat cards, the agent breakdown and every collapsed call card are
built from scalars.

Measured against a production store, over a LAN, from a browser:

turn /traces/{id} /spans?lite=1 /spans (with bodies)
28 calls 13 ms 20 ms / 16 KB 0.42 s / 2.3 MB
45 calls 12 ms 29 ms / 26 KB 0.98 s / 3.6 MB
102 calls 16 ms 35 ms / 59 KB 4.83 s / 20.5 MB

The panel waited for the right-hand column, then parsed those megabytes on the
main thread. The storage backend is not implicated — it answers the small shape
in 6–16 ms, and still does while a body-bearing request for the same turn is in
flight.

The second defect: over the threshold it fetched the bodies anyway

CALLS_LITE_THRESHOLD exists precisely so a large turn does not fetch bodies.
But whether to ask for them was derived from turn.call_count, which arrives
from a different request — so on mount the answer defaulted to "yes" and the
body-bearing fetch went out for every turn, to be abandoned a few milliseconds
later when the count came back over the threshold.

apiFetch passed no AbortSignal, so abandoned meant the browser downloaded
it, parsed it, and held it in the query cache for the gc window. The threshold
was read after the fetch it was meant to prevent.

Fix

Two fetches of the same list; the panel paints off the cheap one. ?lite=1 is
unconditional. The body-bearing shape is requested only once call_count is
known and under the threshold, and then only as a background upgrade for the
three views that do derive from bodies — the timeline's call-type icons,
StatsCards' tool/text/final counts, and the tool index. Above the threshold it
never lands and those degrade, exactly as they already did.

The span and body endpoints now pass the query's AbortSignal. That matters
more after this change than before it: bodies are now fetched in the background,
so clicking through turns would otherwise leave one abandoned multi-MB download
running per turn.

Verification

A real browser against a live instance, before and after, recording what the
panel actually requests:

before / 102 calls:  <turn> → <turn>/spans (21,547,808 B) → <turn>/spans?lite=1
                     spans traffic 20.61 MB   ← 20.55 MB of it unrenderable
after  / 102 calls:  <turn> → <turn>/spans?lite=1
                     spans traffic  0.06 MB   ← 343x

before /  45 calls:  <turn> → <turn>/spans (3.6 MB)   ← the timeline waits on it
after  /  45 calls:  <turn> → <turn>/spans?lite=1 → <turn>/spans (background)

tsc -b and the production build pass; bun test 136/136; lint is unchanged
from baseline.

Not fixed here

The three body-derived views still degrade above the threshold. The proper fix
is to classify server-side at write time, the way tokens_estimated and
app_hint already are — that is a change across all storage backends, not a
console one.

Vader Yang added 2 commits August 17, 2026 17:02
Opening an Agent Turn felt slow because the panel would not paint until
the calls list arrived body-bearing. Nothing above the call cards reads a
body: the timeline, the stat cards, the agent breakdown and every
collapsed card are built from scalars.

Measured against a production store, that list is 2-20 MB and 0.4-4.8 s
where the same list with `?lite=1` is 16-60 KB and ~10 ms, and the
browser then parsed those megabytes on the main thread. Paint off the
small shape; fetch bodies afterwards, as a background upgrade for the
three views that do derive from them (timeline call-type icons,
StatsCards type counts, tool index).

Over the 50-call threshold it was worse than slow, it was waste. Whether
to ask for bodies came from `call_count`, which arrives from a different
request — so on mount the answer defaulted to yes, the body-bearing
fetch went out for every turn, and it was abandoned a few ms later when
the count came back over the threshold. `apiFetch` passed no
AbortSignal, so abandoned still meant downloaded, parsed and cached: a
102-call turn pulled 20.6 MB it could never render. Confirmed with a
real browser against a live instance, before and after — 20.61 MB of
spans traffic to open that turn, now 0.06 MB.

The span and body endpoints now pass the query's signal, so a panel
closed mid-download stops the download. That matters more now that
bodies are fetched in the background, not less: clicking through turns
would otherwise leave one abandoned multi-MB fetch running per turn.
@vaderyang vaderyang changed the title fix(console): the turn timeline waited on every body in the turn fix(console): the turn timeline waited on every body in the turn (v0.7.3) Aug 17, 2026
@vaderyang
vaderyang merged commit 35a48b0 into main Aug 17, 2026
2 checks passed

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

I've completed my investigation. Here's my review.

Summary

Two commits: (1) a console perf fix that splits the turn-calls list fetch into a cheap ?lite=1 paint path plus a background body-bearing upgrade, and threads the TanStack Query AbortSignal through apiFetch so abandoned multi-MB fetches actually abort; (2) a version bump to v0.7.3. The change is console-only, well-documented, and the version bump is consistent across VERSION / Cargo.toml / package.json / CHANGELOG. The core logic is sound and the perf/behaviour claims hold up against the surrounding code. I found no blocking defects; a few non-blocking observations.

Blocking

None.

Suggestions

  • console/src/pages/agent-turn-detail-panel.tsx:235 — The body-bearing upgrade query runs even when the caller has already opened a full-body per-call detail: bodiedCalls and useLlmCallDetail (fired by CallCard on expand, gated separately per card) fetch the same body data in parallel, and calls.find at :251 references the array before the upgrade lands. Not a bug (the upgrade purely improves the tool index/type counts), but with a <50-call turn where the reader clicks into a card immediately, the browser holds two near-identical body fetches. Consider keying the upgrade off whether any card detail is open, or letting it share an entry with useLlmCallDetail.
  • console/src/hooks/use-agent-turns.ts:86 / console/src/lib/api.ts:36fetch aborts on AbortSignal but TanStack Query REST-query culture often uses retry; retries of an aborted fetch could re-thrash. TanStack Query handles abort as terminal (no retry) here, so this is only worth a second look if you later add .retry to these queries.
  • server/h-api/src/routes/traces.rs:317 — unrelated to this diff, but worth a glance: the lite/full split decides bodies on the per-item NULL-ing path (lite==0). Since the panel now always paints off ?lite=1 even when it later fetches bodies, the "bodies reachable per-card" promise held by useLlmCallDetail and ?lite only holds if /api/spans/{id} bypasses the lite path — confirmed elsewhere in this file (include_bodies).

Questions

  • The enabled gate for the bodied query at :235 is turn != null && !liteMode. Since liteMode derives from turn.call_count, this still races the lite response against the detail response — is firing the body fetch before call_count arrives (then cancelling via signal) the intended behaviour, or should enabled also wait on the lite list being present?

Verified

  • useAgentTurnCalls callers: only agent-turn-detail-panel.tsx:233,235 — both new calls match the new (id, lite, enabled) signature; no other consumers to break.
  • No remaining apiFetch call sites pass a different arity — the third-arg opts is optional and backward-compatible; the two hook call sites (use-agent-turns.ts:86, use-llm-call-detail.ts:10) and downloadFile all remain valid.
  • Query keys: ["agent-turn-calls", id, lite] correctly includes lite, so the lite vs. full shapes live in distinct cache entries and don't clobber each other.
  • Version bump consistent: VERSION, server/Cargo.toml:11, console/package.json, and CHANGELOG header 0.7.3 all agree; the two commits are cleanly separated (console fix commit touches only console + CHANGELOG; bump commit only the four version files).
  • No sensitive-content risk: the only changed repo file touching URLs/config is console TS; no new IPs, hostnames, key material, or paths were added in the diff.
  • Relevance check for the "window-width" and "body-scan" gotchas: no SQL / body-window aggregation was touched in this PR (console-only).

Recommendation: APPROVE.


🤖 Reviewed by the review botworkflow run

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