feat(agent-platform): auto-refresh the session detail page - #2043
Conversation
The page read the session and its conversation once at mount and never again, so someone watching an agent work saw a frozen page until they navigated away and back. Both reads now poll on the two-tier shape the agent views already use: the conversation at 10s while the newest task is in an active A2A state and recent, 60s otherwise; the session object on a flat 60s, since its fields do not move while an agent works. Polling also made an existing condition wrong. The page treated any error as fatal, but react-query keeps `data` and sets `error` on a failed refetch, so one proxy hiccup would have blanked a rendered conversation. The fatal branch is now gated on having no session at all, and an error with one in hand shows a warning notice instead. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
| // fatal would let one proxy hiccup replace a rendered conversation with an | ||
| // alert until the next successful poll. With a session in hand the page renders | ||
| // whatever the last read did, and says so in the notice below. | ||
| if (!detail || !row) { |
There was a problem hiding this comment.
A tasks read that fails on first load now renders a fabricated empty session.
Dropping error || fixes the failed-refetch case, but this branch also covered "the conversation never loaded at all". tasksQuery.isLoading is false once the query settles into error (status is error, not pending), tasks stays undefined, and so useSessionDetail returns timeline = EMPTY_TIMELINE, taskCount = 0, state = undefined — while detail/row are set from the session read that succeeded.
Concrete scenario: the session read succeeds, the tasks read fails on the initial load (ServiceUnavailableError, which the query client deliberately does not retry, or a 500 after the retry ladder). The page then renders the full layout with a no activity badge, Turns 0, 0 input/output tokens and "This session has no messages yet." — three statements that are false for a session with a real conversation — under a soft yellow "This session may be out of date / The last refresh failed" notice. Before this change the user got the danger alert with the actual message, which was correct.
Confirmed against the hook (session resolves, listSessionTasks rejects):
{ isLoading: false, detail: <set>, taskCount: 0, state: undefined, items: 0 }
The gate needs to distinguish "a refresh failed" from "the conversation has never loaded". E.g. expose something like hasConversation (tasksQuery.data !== undefined) from the hook and keep the fatal branch — or at least suppress the timeline/turns/tokens panels — when it is false.
There was a problem hiding this comment.
Confirmed and fixed in fc48d4d — this was a regression I introduced, and your reproduction is exact.
The two reads fail independently, and I only reasoned about the refetch case. tasksQuery.isLoading is false once the query settles into error, so with the session read succeeding the page rendered the full layout over EMPTY_TIMELINE.
The hook now reports hasConversation (tasksQuery.data !== undefined) and the page keeps the fatal branch when it is false. Naming it after the data rather than the failure keeps the distinction the bug turned on visible: absent is not empty. A session that genuinely never ran reads as [], which is data, so it still renders "no activity" as before.
Two tests: does not fabricate an empty session when the conversation never loaded at the page level, and reports no conversation when the tasks read fails on first load plus reports a conversation that is genuinely empty at the hook level, so the two directions are pinned as a pair.
|
|
||
| // No usable timestamp — absent, or Go zero time. Treat the task as | ||
| // just-changed rather than as stuck, mirroring `isAgentConverging`. | ||
| if (changedAt === undefined) { |
There was a problem hiding this comment.
The ACTIVE_MAX_AGE_MS bound does not apply on the one path where it is most needed: an active task with no usable timestamp pins the fast tier forever.
timestamp is a wireString (optional), and normalizeTimestamp also maps Go zero time and any unparseable value to undefined — so this early return is reached whenever an active task carries no usable timestamp, and it returns ACTIVE_REFETCH_INTERVAL_MS unconditionally, with no age check of any kind.
Scenario: an agent dies mid-turn leaving a task in working/submitted whose status.timestamp is absent (or a kagent version stops emitting it on task status). The page then re-reads the whole conversation — the module's own doc puts it at ~500 KB, re-parsed through zod and deep-compared on the main thread — every 10 s for as long as the tab stays open. That is exactly the failure ACTIVE_MAX_AGE_MS is documented to prevent ("an agent that died mid-turn without writing a terminal state would pin the fast tier for as long as anyone leaves the tab open"), and the bound silently doesn't cover it.
isAgentConverging has the same shape, but there the object always carries lastTransitionTime, so the hole is theoretical; here timestamp is genuinely optional at the parse boundary. A fallback that still ages out — e.g. query.state.dataUpdatedAt or the first time this query saw the task — would keep "treat as just-changed" for a genuinely new task without making it unbounded.
There was a problem hiding this comment.
Agreed and fixed in fc48d4d. The bound was documented as covering exactly the case it silently skipped, and your point about why isAgentConverging gets away with the same shape — lastTransitionTime is always present on a Kubernetes object, timestamp is genuinely optional here — is the part I had not thought through.
It now falls back to the newest usable status.timestamp anywhere in the conversation, and returns the baseline when nothing in the list carries one. I went with that over dataUpdatedAt because react-query bumps dataUpdatedAt on every successful fetch, including deep-equal ones, so it would read as "just changed" on every poll and stay unbounded in a less obvious way.
This costs the "state and timestamp from the same task" property in the fallback path, which is deliberate and now says so in the comment: an age basis that exists beats a fast tier nothing can stop. The trade-off in the last-resort case is that a genuinely new timestampless task is seen within a minute instead of ten seconds.
Also fixed a latent bug in my own test helper that hid this: it defaulted timestamp: undefined back to a real timestamp, so the three "no usable timestamp" cases were never actually testing that. Three new cases cover the fallback and its ageing out.
| // not-found state properly. | ||
| // not-found state properly. Note this is now the only thing holding that | ||
| // race off: both reads carry a refetch interval, so changing `refetchType` | ||
| // here would reintroduce the flash the next time a tick lands mid-navigation. |
There was a problem hiding this comment.
This comment is not true any more, and the race it describes is now reachable. refetchType: 'none' only stops invalidation-driven refetches; the new refetchInterval timers fire on their own schedule and are unaffected by it.
Scenario: the last task is active, so the tasks query is on the 10 s tier. The user confirms the delete. mutationFn awaits the DELETE, then awaits invalidateQueries on the fleet sessions list — which does refetch, so the window between "the session is gone server-side" and the navigate() in SessionActionsMenu.confirmDelete is a full list round-trip, not a tick. A scheduled interval refetch landing in that window 404s, flips isNotFound, and flashes "Session not found" under the still-open dialog — precisely the flash this comment claims is held off.
If it should actually be held off, the reads have to be stopped rather than just left unrefetched: gate both useQuerys on the mutation (enabled: !deletion.isDeleting && !deleteSucceeded, or refetchInterval: false once it has), or navigate before awaiting the invalidations.
There was a problem hiding this comment.
You are right, and the comment I added was worse than none — it asserted the race was held off while the change made it reachable.
Fixed in fc48d4d by stopping the reads rather than just leaving them unrefetched: useSessionDetail takes an enabled option and the page passes !deletion.isDeleting && !deletion.isDeleted. isDeleted (mutation.isSuccess) is there for the gap you identified between the mutation settling and confirmDelete navigating, which isDeleting alone reopens.
I kept refetchType: 'none' — it is still correct for what it does — but the comment now states plainly that it governs invalidation-driven refetches only and is not what holds the race off, with a pointer to where that actually happens. Covered by stops both reads while a delete is in flight.
| // A flat baseline, not the tasks' two tiers: this object is the title, the | ||
| // agent and the timestamps, none of which move while an agent works. It polls | ||
| // at all so a session renamed or deleted elsewhere stops looking current. | ||
| refetchInterval: BASELINE_REFETCH_INTERVAL_MS, |
There was a problem hiding this comment.
Polling this read makes the isNotFound path reachable on an already-rendered page, and it is not gated on "we have no data" the way the error path now is.
isNotFound is (sessionQuery.isSuccess && !sessionQuery.data) || name === 'NotFoundError', and getSessionDetail resolves undefined for any 200 whose body doesn't parse — normalizeSessionDetail returns { drift: 'unparseable-body' } with no detail, and the same for missing-payload. Since isNotFound is checked before the render branch, a single malformed 200 on a poll replaces a live conversation with the "Session not found … It may have been deleted, or belong to another user" empty state, and it stays there until the next 60 s poll succeeds.
A realistic trigger: an expired oauth2-proxy session in front of kagent answers with an HTML sign-in page under status 200, which parses to no session. Nothing was deleted and nothing is unreadable, but the user is told the session is gone.
This is the same class of problem the PR fixes for error (keep what you have, say it may be stale). Worth applying the same rule here: only treat !data as not-found when the query has never had data (sessionQuery.data === undefined && !previouslyLoaded), and otherwise fall through to the stale notice.
There was a problem hiding this comment.
Fixed in fc48d4d, though the mechanism turned out to be different from the one described — worth recording, because the real behaviour is worse in one way and better in another.
isSuccess && !data is unreachable, and was already unreachable on main: react-query v5 rejects an undefined resolve rather than storing it. Probed it directly —
{"isNotFound":false,"queryStatus":"error",
"queryError":"[\"agent-platform\",\"kagent\",\"session\",\"gazelle\",\"abc\"] data is undefined"}
So an unparseable 200 never produced the "Session not found" empty state. It produced a danger alert (now a staleness notice) whose message is a raw query key — user-visible internal noise, and a pre-existing bug rather than one this PR introduced.
Your underlying point stands entirely, so I fixed both halves. The query function coerces to null, which react-query stores, so the empty read is classifiable again and nothing leaks. And the branch is now gated the way you suggest: an empty read only means "not found" when nothing has ever loaded (lastGoodDetail.current === undefined), otherwise it degrades to the stale notice with the last good session retained. A genuine NotFoundError still counts at any point, since that is how a delete elsewhere surfaces — pinned by the existing reports not found when the session is deleted elsewhere test.
Two new tests: keeps a loaded session when a poll returns an unreadable 200, and still reports not found for an empty read before anything loaded so the gate cannot swallow the real case.
Four issues raised in review, all real. The fatal branch was gated on `!detail` alone, so a tasks read failing on first load rendered a fabricated empty session — "no activity", Turns 0, "no messages yet" — over a session with a full conversation. The hook now reports `hasConversation`, distinguishing absent from empty. The age bound did not apply when an active task carried no usable timestamp, which pinned the fast tier unboundedly on exactly the case the bound exists for. It now falls back to the newest usable timestamp in the conversation, and to the baseline when there is none. `refetchType: 'none'` never held off a scheduled interval tick, so a poll could 404 mid-delete and flash "Session not found". The page now disables both reads while a delete is in flight. An empty session read is no longer treated as "not found" once a session has been read. The query function also coerces the empty case to `null`: react-query rejects an `undefined` resolve, which surfaced a raw query key to the user and had made the `isSuccess && !data` branch unreachable. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
What does this PR do?
Makes the Agent Platform session detail page refresh itself. It read the session
and its conversation once at mount and never again, so someone watching an agent work
saw a frozen page until they navigated away and back.
Both reads now poll, on the two-tier shape the agent views already use
(
getAgentRefetchInterval), via a new pure modulelib/kagentSessionPolling.ts:/tasks)Three decisions worth reviewing, each argued in the code comments, the docs and the
changeset:
workflow execution, a kagent session can still be continued, renamed or deleted from
another client — returning
falsewould freeze the page for exactly the case thisfixes.
constants move a small Kubernetes object on a reconcile cadence; this moves the whole
conversation (~500 KB, re-parsed through zod each time) and tracks an agent turn,
which routinely runs minutes.
input-required/auth-requiredare handled by the age bound, not a special case.They are active but wait on a human; they start fast, relax after 5 min, and
re-engage on their own when someone answers elsewhere.
Polling also made an existing condition wrong, so that is fixed here too. The page
treated any error as fatal. react-query keeps
dataand setserroron a failedrefetch, and the query client deliberately does not retry
ServiceUnavailable/Unauthorized/Forbidden— so one proxy hiccup would havereplaced a rendered conversation with a danger alert for up to a minute. The fatal
branch is now gated on having no session at all; an error with one in hand shows a
warning notice above the page instead.
What is the effect of this change to users?
A session that is still producing output updates on its own, roughly every 10 seconds
— new turns, the state badge, and the turn/token/duration stats all move without
touching anything. Finished sessions still pick up changes made elsewhere within a
minute. A transient backend failure no longer wipes the conversation off the screen;
it shows a "This session may be out of date" notice and keeps what you were reading.
There is still no manual refresh button — deliberately, see the Open TODOs.
How does it look like?
Verified live against gazelle (measured from the page's own resource timings):
Watched a session go
Working→Completedwith no interaction: turns 1 → 3 → 5 → 10,tokens 32.4k → 57.4k, the full answer rendered, and the timeline's Collapsed toggle
survived every poll.
The 5-minute age bound, on a session parked in
Waiting for input(last activity 06:49):Also confirmed: 0 polls across 80 s with the tab hidden, resuming by itself
afterwards.
Any background context you can provide?
kagent offers no cheaper probe. Checked the v0.9.9 source: every route is
registered for a single method on a gorilla/mux router (so no
HEAD), and nothingsets
ETag,Last-ModifiedorCache-Control—RespondWithJSONmarshals andwrites, and the middleware chain only sets
Content-Type. There is no conditional GETto make "has this changed?" cheap, so a full re-read is the only option and the
interval carries the whole cost story.
One non-obvious thing that made the simple design viable. An unchanged poll costs
no re-render at all: react-query's structural sharing returns the previous reference
when the payload is deep-equal, so the
useMemos that rebuild the timeline neverre-run. No
select, memoisation or content hashing was needed.Two open items recorded in the docs rather than fixed here:
status.timestampadvances during a longworkingturn or only on statetransitions. Every turn in the live session finished well under a minute, so the
5-minute bound was never approached. If the timestamp is frozen, a >5-minute turn
drops to 60 s mid-run and
ACTIVE_MAX_AGE_MSshould go up.was not exercised end-to-end. Patching
window.fetchdoes not intercept(Backstage's fetch API holds its own captured reference) and CDP offline emulation
pauses react-query rather than failing it, so neither lever produces an error.
Two doc changes ride along that are not about polling — flagging them so they are
not a surprise in review:
agent-deploymenttemplate note, which claimed the template still had to beadded to
giantswarm/backstage-catalogs; it is now published there.input-required, the question the agent is waiting onlives in
status.messageand is parsed by the wire schema, butbuildTimelineonlyreads
status.timestamp, so nothing renders it. Found while verifying this PR ongazelle. Happy to split either out if you would rather.
Do the docs need to be updated?
Yes — done in this PR. New "Refreshing" section under "The session detail page", a
cross-reference from the agent detail page so the two read as one policy, and the
"Session detail doesn't refresh" Open TODO rewritten down to the manual-refresh
remainder.
Should this change be mentioned in the release notes?