Skip to content

Chat UI can freeze indefinitely when an agent run stalls (no cancel / no stream timeout) #153

Description

@Adam-D-Lewis

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.tscreateRunMutation drives the run with
    for await (const evt of stream) { ... }, which doesn't resolve until the stream
    ends.
  • src/api/threads.tscreateRun() 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    Priority

    None yet

    Start date

    None yet

    Target date

    None yet

    Size

    None yet

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions