Skip to content

Web UI shows permanent Thinking spinner after stream interruption or server restart #17680

Closed as not planned

Description

@dzianisv

Sessions show permanent "Thinking..." spinner after stream interruption or server restart

Problem

When an assistant message's stream is interrupted (API error, server restart, network timeout), the message is left with incomplete parts (e.g., step-start + reasoning but no step-finish) and no time.completed set.

The web UI's pending memo identifies these as still-active messages:

// message-timeline.tsx:239
const pending = createMemo(() =>
  sessionMessages().findLast(
    (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
  ),
)

This causes:

  • Permanent spinner on the session in both the main chat area and sidebar
  • "Thinking..." display that never resolves
  • Users cannot tell if the session is stuck or just slow

Root cause

  1. The AI SDK stream can die mid-response (API timeout, rate limit, network error). When this happens, the assistant message never gets time.completed set in the database.

  2. Server restarts leave the same orphaned state: messages in-flight at shutdown time never get completed.

  3. Even with database-level recovery (setting time.completed on orphaned messages), the frontend's in-memory store may be stale if the page was loaded before recovery ran. No SSE event is emitted when recovery modifies messages.

Observed behavior

After server restart, sessions show:

  • Sidebar: spinner icon on sessions that are fully idle
  • Chat: "Thinking..." with a spinner that never resolves
  • Session status API returns {} (idle) — confirming the session is not actually active

Last message structure from API after recovery:

{
  "info": {
    "time": { "completed": 1773622653848 },
    ...
  },
  "parts": [
    { "type": "step-start" },
    { "type": "reasoning", "text": "..." },
    { "type": "reasoning", "text": "..." }
  ]
}

Note: time.completed is set correctly, but the message has no step-finish part. If the frontend loaded this data before recovery, the store still has time.completed as undefined.

Suggested fix

Two-sided fix:

1. Frontend (message-timeline.tsx): Make the pending memo also consider session status. If the session is idle with no pending permissions, no message should be considered "pending" regardless of time.completed:

const pending = createMemo(() => {
  if (sessionStatus().type === "idle") return undefined
  return sessionMessages().findLast(
    (item): item is AssistantMessage => item.role === "assistant" && typeof item.time.completed !== "number",
  )
})

2. Server (session/index.ts): Emit Bus events after Session.recover() so connected frontends update their stores immediately.

Environment

  • opencode serve (long-running, multiple sessions)
  • Multiple sessions interrupted by server restarts
  • Web UI accessed via browser

Activity

  1. added
    coreAnything pertaining to core functionality of the application (opencode server stuff)
    on Mar 16, 2026
  2. added a commit that references this issue on Mar 16, 2026
    4d4605a
  3. github-actions commented on Mar 16, 2026

    @github-actions
    Contributor

    This issue might be a duplicate of an existing issue. Please check:

    If you believe these should be tracked separately (e.g. the fix needs to happen in different parts of the codebase), please let a maintainer know.

  4. dzianisv commented on Mar 16, 2026

    @dzianisv
    Author

    Fixed in our fork: dzianisv@4d4605a4c

    The fix moves sessionStatus memo above pending and short-circuits: when the session is idle, no message is considered pending regardless of time.completed state. This handles both stale store data and recovery-before-SSE scenarios.

  5. dzianisv commented on Mar 16, 2026

    @dzianisv
    Author

    I dug further into this on a live broken session and the primary problem was in persisted state, not just frontend staleness.

    What I confirmed:

    • the session was not actually still running
    • the server had restarted mid-turn
    • Session.recover() already patches orphaned tool parts to error, but for orphaned assistant messages it only force-completes time.completed
    • if the assistant message had no existing error, recovery left it as error: null
    • that produced a dead-end assistant shell in storage (parts: [], error: null) that looked permanently stuck in the UI

    I repaired one broken live row directly and verified the right behavior is to persist:

    • MessageAbortedError("Server restarted while the response was in progress")

    I also patched this locally by changing restart recovery to mark orphaned assistant messages as aborted during startup, with a regression test covering it.

    So for this class of failure, the source-of-truth bug is backend recovery:

    • interrupted assistant messages need an explicit aborted error during recovery
    • otherwise the DB can contain an apparently completed-but-not-really-complete assistant message

    The frontend can still be more defensive, but the persisted row itself was wrong in the live repro I investigated.

  6. added a commit that references this issue on Mar 17, 2026
    2b8602a
  7. added a commit that references this issue on Apr 17, 2026
    859b010
  8. removed
    coreAnything pertaining to core functionality of the application (opencode server stuff)
    on May 3, 2026
  9. github-actions commented on Jul 7, 2026

    @github-actions
    Contributor

    To stay organized issues are automatically closed after 60 days of no activity. If the issue is still relevant please open a new one.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

Labels

No labels
No labels

Type

No type

Projects

No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions