From 84b502dfe959783e9de5e4b09c5d1de2d5b0b0e8 Mon Sep 17 00:00:00 2001 From: Vader Yang Date: Mon, 17 Aug 2026 17:02:01 +0800 Subject: [PATCH 1/2] fix(console): the turn timeline waited on every body in the turn MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- CHANGELOG.md | 20 ++++++++++ console/src/hooks/use-agent-turns.ts | 16 ++++++-- console/src/hooks/use-llm-call-detail.ts | 4 +- console/src/lib/api.ts | 10 ++++- console/src/pages/agent-turn-detail-panel.tsx | 37 ++++++++++++++++--- 5 files changed, 75 insertions(+), 12 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa2e2279..295e3077 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,26 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +### Fixed + +- **Opening an agent turn made the timeline wait on every request and response + body in it.** The detail panel fetched the calls list body-bearing, and the + timeline, stat cards, agent breakdown and collapsed cards are built from + scalars — none of them reads a body. Measured against a production store, that + list is 2–20 MB and 0.4–4.8 s where the same list without bodies is 16–60 KB + and ~10 ms; the panel then parsed those megabytes on the main thread. It now + paints off the small shape and pulls bodies afterwards as a background + upgrade, so nothing the reader is waiting for is behind them. +- **Turns over the 50-call threshold fetched the bodies anyway, then threw them + away.** Whether to ask for bodies was decided from `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 ms later + when the count came back over the threshold. `apiFetch` passed no + `AbortSignal`, so "abandoned" still meant downloading, parsing and caching the + full response: a 102-call turn pulled 20.6 MB it could never render. The span + and body endpoints now pass the signal, and the query no longer fires before + its own precondition is known — the same turn now costs 60 KB to open. + ## [0.7.2] — 2026-08-15 ### Added diff --git a/console/src/hooks/use-agent-turns.ts b/console/src/hooks/use-agent-turns.ts index 38dd60a5..a3ce4e0f 100644 --- a/console/src/hooks/use-agent-turns.ts +++ b/console/src/hooks/use-agent-turns.ts @@ -70,13 +70,21 @@ export function useAgentTurnDetail(id: string | null) { * expanded call body in lite mode should fall back to * `useLlmCallDetail(callId)` to fetch that one call's full bodies on * demand. + * + * The two shapes are separate cache entries, so a caller can hold both: + * lite to paint with, full as a later upgrade. `enabled` exists for that + * second one — a query whose desirability isn't known until some other + * request answers must not fire in the meantime (see the detail panel). */ -export function useAgentTurnCalls(id: string | null, lite = false) { +export function useAgentTurnCalls(id: string | null, lite = false, enabled = true) { return useQuery({ queryKey: ["agent-turn-calls", id, lite], - queryFn: () => - apiFetch(`/api/traces/${id}/spans`, lite ? { lite: 1 } : {}), - enabled: id != null, + // The body-bearing shape runs to tens of MB, and this query goes unobserved + // often — the panel closes, the reader clicks straight to the next turn. + // Without the signal each of those leaves a full download running. + queryFn: ({ signal }) => + apiFetch(`/api/traces/${id}/spans`, lite ? { lite: 1 } : {}, { signal }), + enabled: id != null && enabled, }) } diff --git a/console/src/hooks/use-llm-call-detail.ts b/console/src/hooks/use-llm-call-detail.ts index 22ffd611..c5ba2dd6 100644 --- a/console/src/hooks/use-llm-call-detail.ts +++ b/console/src/hooks/use-llm-call-detail.ts @@ -5,7 +5,9 @@ import type { LlmCallDetail } from "@/types/api" export function useLlmCallDetail(id: string | null) { return useQuery({ queryKey: ["llm-call-detail", id], - queryFn: () => apiFetch(`/api/spans/${id}`), + // Carries both bodies, and CallCard enables/disables it as cards expand and + // collapse — so abandoned fetches are the normal case, not the edge one. + queryFn: ({ signal }) => apiFetch(`/api/spans/${id}`, undefined, { signal }), enabled: id != null, }) } diff --git a/console/src/lib/api.ts b/console/src/lib/api.ts index d00d9b2f..b5fe003d 100644 --- a/console/src/lib/api.ts +++ b/console/src/lib/api.ts @@ -12,9 +12,17 @@ export class ApiError extends Error { } } +/** + * `signal` is worth passing for any endpoint that can answer with megabytes: + * TanStack Query aborts it when a query loses its last observer — the panel + * closes, the queryKey changes — and without it the browser downloads, parses + * and caches a response nothing will ever render. A KB-scale list can skip it; + * the span/body endpoints cannot. + */ export async function apiFetch( path: string, params?: Record, + opts?: { signal?: AbortSignal }, ): Promise { const url = new URL(path, window.location.origin) if (params) { @@ -25,7 +33,7 @@ export async function apiFetch( } } - const res = await fetch(`${BASE_URL}${url.pathname}${url.search}`) + const res = await fetch(`${BASE_URL}${url.pathname}${url.search}`, { signal: opts?.signal }) if (!res.ok) { const body = await res.json().catch(() => ({ code: res.status, message: res.statusText })) throw new ApiError(body.code ?? res.status, body.message ?? res.statusText) diff --git a/console/src/pages/agent-turn-detail-panel.tsx b/console/src/pages/agent-turn-detail-panel.tsx index bbefcd60..974c823e 100644 --- a/console/src/pages/agent-turn-detail-panel.tsx +++ b/console/src/pages/agent-turn-detail-panel.tsx @@ -191,11 +191,10 @@ function TabButton({ ) } -/// Above this call_count threshold, the calls list switches to lite -/// mode — server NULLs the four heavy body/header fields so a -/// mega-turn (hundreds of agentic iterations × hundreds of KB -/// request_body each) doesn't OOM the browser. Individual call bodies -/// are still reachable per-card via `useLlmCallDetail`. +/// Above this call_count threshold the panel never fetches bodies for the +/// list at all; individual call bodies stay reachable per-card via +/// `useLlmCallDetail`. Below it they're still fetched, but as a background +/// upgrade (see below) rather than something the panel waits on. /// /// The threshold was set from browser-side cost alone, which is only half the /// bill. Measured against a live store, a 184-call turn takes ~6s to answer @@ -207,8 +206,34 @@ const CALLS_LITE_THRESHOLD = 50 export function AgentTurnDetailPanel({ id, onClose }: Props) { const { data: turn, isLoading: loadingTurn, isError: errorTurn } = useAgentTurnDetail(id) + + // Two fetches of the same list, and the panel paints off the cheap one. + // + // `?lite=1` is 140-350x smaller than the body-bearing shape and answers in + // ~10 ms against a production store where the full shape takes 0.4-4.8 s + // (2-20 MB over the wire, then a main-thread JSON.parse of the same). The + // timeline, stats, agent breakdown and every collapsed card are built from + // scalars only — none of them reads a body — so making them wait on those + // megabytes cost seconds of blank panel for nothing. + // + // Three views DO derive from bodies: the timeline's call-type icons, + // StatsCards' tool/text/final counts, and the tool index. They upgrade in + // place when `bodiedCalls` lands. Above the threshold it never lands and + // they degrade — as they already did before this split, since a turn that + // large was fetched lite regardless. + // + // The lite fetch is unconditional. Deriving *whether to fetch bodies* from + // `turn.call_count` means it can't be decided until the detail query + // answers, and a query gated on another query's result must not fire on the + // default in the meantime: `useAgentTurnCalls(id, liteMode)` did exactly + // that, issuing the full-body request for every turn and then abandoning it + // when call_count came back over the threshold. `apiFetch` passes no + // AbortSignal, so "abandoned" meant the browser still downloaded, parsed and + // cached ~20 MB it would never show. + const { data: liteCalls = [], isLoading: loadingCalls } = useAgentTurnCalls(id, true) const liteMode = (turn?.call_count ?? 0) > CALLS_LITE_THRESHOLD - const { data: calls = [], isLoading: loadingCalls } = useAgentTurnCalls(id, liteMode) + const { data: bodiedCalls } = useAgentTurnCalls(id, false, turn != null && !liteMode) + const calls = bodiedCalls ?? liteCalls // Call-level proxy-duplicate fold: when two captured calls represent // the same LLM round-trip (e.g. client→litellm + litellm→upstream), From a11472d6564dc227235b30bb009afd04897724b3 Mon Sep 17 00:00:00 2001 From: Vader Yang Date: Mon, 17 Aug 2026 17:23:25 +0800 Subject: [PATCH 2/2] bump: v0.7.3 --- CHANGELOG.md | 2 ++ VERSION | 2 +- console/package.json | 2 +- server/Cargo.toml | 2 +- 4 files changed, 5 insertions(+), 3 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 295e3077..4ca57cf7 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,8 @@ adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ## [Unreleased] +## [0.7.3] — 2026-08-17 + ### Fixed - **Opening an agent turn made the timeline wait on every request and response diff --git a/VERSION b/VERSION index 7486fdbc..f38fc539 100644 --- a/VERSION +++ b/VERSION @@ -1 +1 @@ -0.7.2 +0.7.3 diff --git a/console/package.json b/console/package.json index 755ef3f9..a7cba0ec 100644 --- a/console/package.json +++ b/console/package.json @@ -1,7 +1,7 @@ { "name": "console", "private": true, - "version": "0.7.2", + "version": "0.7.3", "type": "module", "scripts": { "dev": "vite", diff --git a/server/Cargo.toml b/server/Cargo.toml index 4fd67a3f..ea95b58a 100644 --- a/server/Cargo.toml +++ b/server/Cargo.toml @@ -8,7 +8,7 @@ exclude = ["h-ebpf-prog"] resolver = "2" [workspace.package] -version = "0.7.2" +version = "0.7.3" edition = "2021" license = "Apache-2.0"