Skip to content

fix(events): reconcile open session transcript after SSE reconnect - #183

Closed
omgoshjosh wants to merge 1 commit into
dzianisv:mainfrom
omgoshjosh:fix/reconnect-transcript-reconcile
Closed

fix(events): reconcile open session transcript after SSE reconnect#183
omgoshjosh wants to merge 1 commit into
dzianisv:mainfrom
omgoshjosh:fix/reconnect-transcript-reconcile

Conversation

@omgoshjosh

Copy link
Copy Markdown

A prompt submitted from another client (CLI, TUI, a second device) while this client's SSE stream was down never appears in an already-open session until the user navigates away and back.

Root cause

resyncBusySessions() is the only reconnect-time reconciliation, and it returns immediately when no session is marked busy:

const busySessionIDs = Object.entries(useEvents.getState().sessionStatus)
  .filter(([, status]) => status.type === "busy")
  .map(([sessionID]) => sessionID)
if (busySessionIDs.length === 0) return

But a client only learns a session is busy from an SSE session.status event. If the stream was down when the other client submitted the prompt, this client never saw that event — so the session is still idle in its store, resyncBusySessions() finds nothing to do, and since reconnect resumes the stream from "now" without replaying missed events (as the comment above that function already notes), the messages from the gap are never fetched by anything.

The session screen does subscribe to reconnectAttempts, but only to render the reconnect banner. Nothing refetches on reconnect.

The change

reconcileOpenSession(), invoked alongside resyncBusySessions() in the same once-per-reconnect block: refetch the currently-open session's transcript unconditionally.

refreshMessages() replaces messages/parts without touching isLoading, so this lands as a silent background reconcile rather than a spinner over content the user is already reading — which only holds because #151 stopped same-session refreshes from forcing the loading state. The two changes are complementary:

  • Closes #150 #151 fixed a spinner hiding content that was arriving.
  • This fixes content that never arrives at all.

Same user-visible symptom, different layer.

It can overlap with resyncBusySessions() for a session that was busy and has since gone idle — both would refresh. That costs one redundant GET on an infrequent event, which seemed cheaper than the coupling needed to dedupe; happy to change that if you'd prefer.

Verification

Reproduced and verified on an Android 12 emulator against a live server:

Step Result
Post a message while connected Appears live — baseline SSE delivery works
Enable airplane mode Banner shows "Reconnecting… (attempt 5)"
Post from another client while offline Not shown on device — reproduces the bug's precondition
Restore network, wait, do not navigate Message backfills automatically; banner clears; no spinner

The last step is the regression check: before this change the transcript stayed stale until the user left the session and re-entered. noReply: true was used throughout so the test exercises prompt acceptance, persistence and realtime delivery without model timing.

tsc --noEmit clean; full suite passes (286 tests) on this branch.

A prompt submitted from another client (CLI, TUI, another device) while
this client's stream was down never appeared in an already-open session
until the user navigated away and back.

resyncBusySessions() was the only reconnect-time reconciliation, and it
only considers sessions this client has marked "busy". A client only
learns a session is busy from an SSE `session.status` event -- so if the
stream was down when the other client prompted, this client never saw
that event, the session is still "idle" in its store, and
resyncBusySessions() finds nothing to do and returns immediately.
Reconnect resumes the stream from "now" without replaying missed events,
so the messages from the gap are never fetched by anything.

Add reconcileOpenSession(), invoked alongside resyncBusySessions() in the
same once-per-reconnect block: refetch the currently-open session's
transcript unconditionally. refreshMessages() replaces messages/parts
without touching isLoading, so this is a silent background reconcile
rather than a spinner over content the user is already reading -- which
only holds because dzianisv#150's fix stopped same-session refreshes from
forcing the loading state.

Distinct from dzianisv#150: that fixed a spinner hiding content that was
arriving. This fixes content that never arrives at all. Same symptom,
different layer.

Server-side contract was verified to already pass (an already-connected
/global/event subscriber does receive another client's prompt
immediately), so this closes the remaining client-side gap.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@omgoshjosh

Copy link
Copy Markdown
Author

Folding this into #182 to keep the tracker tidy — same commit, same verification, just consolidated with the other two Android fixes rather than three separate PRs. Sorry for the noise.

@omgoshjosh omgoshjosh closed this Aug 15, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant