Summary
If an agent run takes a very long time or its event stream stalls — i.e. the backend
holds the SSE connection open without emitting a terminal RUN_FINISHED / RUN_ERROR
event — the chat UI becomes unusable: the message input stays disabled with no
feedback, there's no way to cancel, and the only recourse is to reload the page.
Because Ravnar persists a turn only when the run completes, reloading also makes the
in-progress turn (the user message + tool calls) disappear from the thread, so it
looks like the message vanished.
This surfaced while debugging a local agent whose tool call (synchronous document
extraction) blocked for several minutes, but it's a general front-end resilience gap
independent of that specific cause.
Where it comes from
src/chat/chatinput.tsx — the textarea and buttons are disabled={isSubmitting}.
isSubmitting is set true before await onSubmit(...) and only cleared in
finally, i.e. after the run's entire event stream has been consumed.
src/queries/threads.ts — createRunMutation drives the run with
for await (const evt of stream) { ... }, which doesn't resolve until the stream
ends.
src/api/threads.ts — createRun() opens the POST /run SSE with no
AbortController and no idle/stall timeout, so if the server keeps the connection
open without sending events, the for await never advances and never throws.
Net effect: a stalled or very slow run = indefinitely disabled input, with no progress
indicator and no way to cancel.
Impact
- No cancel. A user who fires a slow/expensive run can't stop it; they must reload.
- No feedback. A long tool call is indistinguishable from a hard freeze — there's
no "working…" / tool-running indicator.
- Looks like data loss. After reloading, the in-progress turn isn't in the thread
(the backend only persists on completion), so the user's message appears to vanish.
- One malformed event kills the run.
EventSchemas.parse(json) in createRun()
throws on any event that fails validation, tearing down the whole stream. (It does
re-enable the input via finally, but the partial turn is lost without a clear
message to the user.)
Suggested improvements
- Add an
AbortController to the run fetch and a Stop button to cancel an
in-flight run (abort → finally re-enables the input).
- Add a stream watchdog: if no event arrives for N seconds, surface a non-fatal
"still working / connection stalled" state with a cancel affordance, instead of
appearing frozen.
- Show a progress indicator while streaming (e.g. "running
<tool>…") so slow
tool calls read as progress, not a freeze.
- Decide on partial-run UX: keep the optimistic user message visible after reload,
or show a clear "this run didn't finish" affordance (depends on backend persistence).
- Make stream consumption resilient to a single bad event (skip + log rather than
tearing down the whole run), and surface to the user when a run ends in error.
Repro (one way)
Point an agent at a tool that blocks for minutes (e.g. synchronous extraction of a
large document) and send a message that triggers it. The input stays disabled for the
whole duration with no feedback or cancel; reloading mid-run clears the turn from the
thread.
Summary
If an agent run takes a very long time or its event stream stalls — i.e. the backend
holds the SSE connection open without emitting a terminal
RUN_FINISHED/RUN_ERRORevent — the chat UI becomes unusable: the message input stays disabled with no
feedback, there's no way to cancel, and the only recourse is to reload the page.
Because Ravnar persists a turn only when the run completes, reloading also makes the
in-progress turn (the user message + tool calls) disappear from the thread, so it
looks like the message vanished.
This surfaced while debugging a local agent whose tool call (synchronous document
extraction) blocked for several minutes, but it's a general front-end resilience gap
independent of that specific cause.
Where it comes from
src/chat/chatinput.tsx— the textarea and buttons aredisabled={isSubmitting}.isSubmittingis settruebeforeawait onSubmit(...)and only cleared infinally, i.e. after the run's entire event stream has been consumed.src/queries/threads.ts—createRunMutationdrives the run withfor await (const evt of stream) { ... }, which doesn't resolve until the streamends.
src/api/threads.ts—createRun()opens the POST/runSSE with noAbortControllerand no idle/stall timeout, so if the server keeps the connectionopen without sending events, the
for awaitnever advances and never throws.Net effect: a stalled or very slow run = indefinitely disabled input, with no progress
indicator and no way to cancel.
Impact
no "working…" / tool-running indicator.
(the backend only persists on completion), so the user's message appears to vanish.
EventSchemas.parse(json)increateRun()throws on any event that fails validation, tearing down the whole stream. (It does
re-enable the input via
finally, but the partial turn is lost without a clearmessage to the user.)
Suggested improvements
AbortControllerto the run fetch and a Stop button to cancel anin-flight run (abort →
finallyre-enables the input)."still working / connection stalled" state with a cancel affordance, instead of
appearing frozen.
<tool>…") so slowtool calls read as progress, not a freeze.
or show a clear "this run didn't finish" affordance (depends on backend persistence).
tearing down the whole run), and surface to the user when a run ends in error.
Repro (one way)
Point an agent at a tool that blocks for minutes (e.g. synchronous extraction of a
large document) and send a message that triggers it. The input stays disabled for the
whole duration with no feedback or cancel; reloading mid-run clears the turn from the
thread.