A long-running tool's follow-up turn is generated server-to-server and never streamed to the browser — the UI needs an F5 to show what already happened
When an agent's long-running tool finishes, the framework resumes the agent by making a second, server-to-server call that the *original browser connection never sees* — so the result exists (DB row, model turn, progress record) before the live UI has any way to learn about it, and only a manual reload picks it up.
The signal that betrays it
You're looking at this exact architecture gap, not a generic "flaky live update," when:
- Some parts of the UI update live and others don't, for the same event. A common shape: a step's progress indicator and a "tool finished" chip both flip live, but the narrative text turn that follows — a sentence reporting the research/lookup is done, a summary of what got drafted or updated as a result, and a short list of follow-up questions the agent still needs answered — never appears in the chat — until the user hits F5, at which point it's there, correctly ordered, as if it had always been live.
- The missing content is provably already persisted before the reload — a direct query against the sessions/events store shows a role=model row with the narrative text, timestamped *after* the tool's return, well before the user refreshed. This is the single most important diagnostic fact: it turns "is this broken?" into "this data exists, so the bug is 100% about visibility, not generation."
- The tool in question is a long-running / human-in-the-loop pattern (in ADK terms, a LongRunningFunctionTool): the LLM calls it, the tool acknowledges immediately, and — per the documented framework contract — "the agent runner pauses the agent run" while something else decides when and how to continue it. That pause point is exactly where the live channel gets cut: the direct request/response stream the browser opened closes as soon as the tool acknowledges, and everything that happens afterward (the actual resume, the model's follow-up turn) happens on a connection the browser was never party to.
- A background worker (queue consumer, cron, whatever drives the resume) is the one that triggers the second half of the turn, by calling back into the agent runtime internally — not through the client's original streaming endpoint. If you can find in your logs "resume" or "continue" being invoked from a worker process rather than from the request that originally opened the stream, you've found the mechanism.
- It reproduces deterministically: fresh session → trigger the long-running tool → step visibly completes in whatever live-progress UI you have → narrative/result text is absent from the conversation → F5 → text appears. No race, no "sometimes" — it is architectural, not a timing fluke, though *when* the live channel eventually recovers on its own (see the sibling fix below) can look timing-dependent.
Diagnosis, in order — don't skip to the frontend
1. Confirm the backend actually produced the result. Query the durable store for the turn/event directly (a sessions-style table, an events table, whatever holds conversation history) filtered to the session and ordered by time. If the row with the missing text is there, the bug is downstream of generation — proceed. If it's *not* there, you have a different, worse problem (the resume itself failed) and this page doesn't apply.
2. Instrument the live channel, not the UI. Open the page, subscribe to *every* topic/category on the event bus from the browser console (a blanket on(*, console.log)-style hook), and trigger the tool. Confirm what *does* arrive live (progress ticks, a "tool finished" chip) versus what never shows up (the narrative turn). This tells you the transport is healthy for some payloads and simply never carries others — ruling out "the whole pub/sub connection is broken."
3. Probe the live channel synthetically. From the backend, manually publish a fake event on the *same* topic/shape the missing content would need, while the page is open. If it renders instantly, the subscriber/store/render path is fine — stop suspecting the frontend, the gap is entirely "nobody publishes this."
4. Confirm the reload path is the only path that surfaces it. After a refresh, pull the persisted events for the session client-side and check that the parser used at hydrate-time actually recognizes the missing turn's shape. If it does, you've fully localized the bug: generation ✔, persistence ✔, live transport ✘, reload-time read ✔.
The fix that doesn't require touching the backend contract
Piggyback on event-bus topics that already fire around the moment the tool completes, instead of inventing a new dedicated "new message" channel. Two topics that were already being published for other reasons (a pipeline/step "completed|failed" event, and a "new data available for step N" event) both reliably fire at the exact moments a new conversational turn might now exist. Attach a listener that, on either firing:
1. Debounces (a few hundred milliseconds — e.g. 300ms — is a reasonable starting point to tune per system) — the bus is not guaranteed exactly-once; the same logical completion can fire several times in quick succession (e.g. 4 times within a second is a realistic order of magnitude), and an un-debounced handler would refetch and re-merge that many times. 2. Refetches the session's persisted events (the same call the reload path uses) and re-parses them with the same parser the reload path uses — reusing the hydrate logic rather than writing a second one is what keeps the two paths from ever disagreeing about shape. 3. Merges, never replaces, into the live message store. A wholesale "set the message list to what I just fetched" breaks whatever keyed/mounted rendering the live path was doing (component identity resets, scroll position jumps, in-flight animations cut). The only safe operation is "append what's new." 4. Applies multi-level dedup to decide what's new, because a single ID scheme isn't available across both the live path and the persisted path: - a stable ID from the persisted-event parser (DB-backed, exists after refetch), - a domain-specific secondary key where one exists (e.g., a step number for structured turns), - a text fingerprint — first ~80 characters, lowercased — for narrative turns, because the ID the live SSE assigns in-memory and the ID the DB assigns on persistence are never the same value, so ID-matching alone always produces a duplicate on first merge.
A sibling fix for the same root cause, different consumer
any other UI element whose only "am I updated" data source is that same closed-after-resume path has the identical bug, and the identical fix shape doesn't always apply — sometimes the cleaner fix is to add a "truth" publish at the one moment you *know* with certainty the underlying job is finished: the resume/continue handler itself, since it necessarily receives the tool's result before doing anything else. Publishing "completed|failed" right there — before kicking off the internal resume call, not after — closes the timing gap where the tool's own completion publish is fire-and-forget and got lost (bus blip, no retry) or where a client's connection dropped and reconnected without picking up the gap. Make this re-publish idempotent against the original one (a short dedup window keyed on session+event-type is enough) so a healthy path never double-renders.
Closing the reconnect gap too
even with both of the above, a browser tab that's been open for a while and has its live connection silently die (laptop sleep, hub restart, network blip) will miss events published during the dead window — and if your reconnect logic opens a brand-new connection with no continuation token, that window is unrecoverable except by reload. If your event bus supports resumption (Mercure, and SSE via EventSource's native Last-Event-ID, both do: the receiving hub is specified to replay everything published after the last ID a client acknowledges), track the last event ID client-side and pass it back on reconnect instead of opening cold. Pair it with a "wake" listener on tab-visibility/online events to reconnect immediately rather than waiting out a backoff timer, and a one-shot re-hydrate on "I just reconnected after a gap" as a catch-all. Done right, the principle holds completely: a reload becomes a pure convenience, never a requirement — which is the actual fix to the class of bug this page is about, not just a patch on one symptom of it.
A related, but distinct, failure: reload doesn't help either
Don't conflate this page's bug with a second, unrelated way the same symptom ("stale until F5") can happen: the persisted data itself never got written, so even the reload-time read finds nothing. The tell is in the query from diagnostic step 1 above — if the durable-store row for the progress/history you expect is empty or absent, not just "not yet live," you're in this other failure mode, and the fix is entirely different: audit the write path for (a) a direct-call code path that bypasses the wrapper responsible for persistence — anything importing the low-level publish function directly instead of the wrapper silently skips the DB write while still succeeding on the live-publish side, so live looks fine and only reload is empty; (b) a naming mismatch between the identifier the producer uses and the identifier the persistence layer's lookup expects (an alias/mapping table is the fix, and it must audit both the write endpoint and the trigger-time lookup, since it's easy to fix one and miss the other); (c) an update that overwrites the running history instead of accumulating into it (every write to that state needs to read-modify-append, capped at a fixed window, not replace); and (d) a read-time rule that deliberately hides "old" completed items (e.g., anything finished more than N seconds ago) which then also strips the one piece of history a later "show details" panel needed — if you add an auto-hide rule for a live view, keep a second, uncapped index of the same data for anything that needs to render it after the fact.
What does NOT work
- Blaming the subscriber / frontend store first. In cases matching this shape, the transport, store, and render path are typically healthy; the missing publish is the actual defect. Jumping to "the frontend must be dropping events" wastes the diagnostic budget — always confirm with a synthetic publish (step 3 above) before touching frontend code. - Assuming a framework/SDK streaming fix (final-frame handling, abort-on-disconnect, etc.) will resolve this. Those fixes govern the direct client stream. This bug lives entirely on the *internal* resume path, which by design never touches that stream — a streaming bugfix on the wrong side of that boundary changes nothing here. Confirm which side you're on before spending time upgrading anything. - A blanket "poll the session for new events every few seconds after any tool completes." It works, but it's constant background network traffic scaled by however many long-running tools are in flight, for a problem that has an event-driven trigger available (the tool's own completion is *already* an event on the bus) — a workaround standing in for an architectural fix, not one. - Trusting that fixing the "no live update" bug also fixes every tool. The merge-on-event fix only produces something to merge if the tool's result actually contains human-readable narrative content in the first place. A tool that returns purely structured data with no message/summary field gives the agent nothing to narrate, so there is no follow-up turn to ever surface, live or otherwise — that's a backend contract gap (see No long-running agent framework handles failure for you — you need an explicit error contract), not a transport gap, and no amount of frontend listening fixes it. - A full `setMessages`-style replace on every refetch, "just to be safe." It reliably reintroduces the exact bug the merge approach was built to avoid: resetting a keyed list's identity breaks whatever the live path had already mounted, trading a visibility bug for a rendering one.
Related: the framework-level pause/resume contract this bug depends on, and its edge cases with confirmation/gated turns, is covered in Answering a gate with text + functionResponse together silently breaks Workflow resume and Seven verified gotchas in a pause/resume-native workflow framework's primitives (checkpoint, resume_inputs, compaction, disconnect). For the case where the *duplicate* rendering risk from imperfect dedup comes from the SDK itself rather than your own merge logic, see The AI's text appears duplicated in the same bubble — upstream SDK bug, 4-layer defense. For what a long-running tool's result contract needs to guarantee so there's always something narratable to surface, see No long-running agent framework handles failure for you — you need an explicit error contract.
Verified against
30 claims checked against these sources
What links here
Source: Sinapsi — verified compositional memory, queryable by LLMs. Query this wiki live from your assistant over MCP, or build your own verified wiki (public, or private for your team). CC BY 4.0 — reuse with attribution to Sinapsi.