Skip to content

fix(bb-agent): useChat survives mid-turn WebSocket reconnect and send-path failures - #501

Open
soberm wants to merge 3 commits into
fix/realtime-prod-reconnectfrom
fix/usechat-ws-reconnect-resync
Open

fix(bb-agent): useChat survives mid-turn WebSocket reconnect and send-path failures#501
soberm wants to merge 3 commits into
fix/realtime-prod-reconnectfrom
fix/usechat-ws-reconnect-resync

Conversation

@soberm

@soberm soberm commented Sep 7, 2026

Copy link
Copy Markdown
Contributor

Problem

Issue #, if available: internal — useChat WebSocket timeout handling for long agent turns

useChat (packages/bb-agent/src/index.hooks.ts) subscribes to the Realtime channel once per turn and assumes the subscription stays healthy for the whole turn. After the AgentCore migration a turn can run up to 8h, while the Realtime BB's API Gateway WebSocket caps at 2h connection duration and a 10-min idle timeout. So:

  • Chunks published during a reconnect gap are lost; if the done chunk lands in the gap, loading stays true forever (spinner never clears).
  • sendMessage / respondToInterrupt await the RPC with no try/catch; a rejected or 504-timed-out cold dispatch leaves loading stuck and an orphaned empty assistant bubble.

Stacked on #497 (PR 2 of 2). PR1 makes the production Realtime transport auto-reconnect + resubscribe and adds onReconnect; this PR consumes those primitives in useChat and adds a DB-backed recovery contract. Base branch is fix/realtime-prod-reconnect — review/merge #497 first.

Changes

  • Widened subscribe contract (backward compatible): the option now accepts { onMessage, onDisconnect?, onReconnect? } in addition to a bare handler, mirroring bb-realtime's SubscribeOptions.
  • DB re-sync on reconnect (source of truth, since WS pub/sub is not durable): getConversation recovers the final assistant text if the turn completed during the gap; getPendingInterrupts recovers a missed interrupt. If the turn is still running, loading is preserved and streaming resumes on the resubscribed channel.
  • Send-path failsafe: try/catch around api.sendMessage / api.resume resets loading, drops the empty placeholder, and surfaces onError on rejection/504.
  • Bounded post-reconnect failsafe: if no done/error/interrupt arrives within a window after a reconnect, loading is cleared (+onError) so the spinner can never hang indefinitely. Timer cleared on terminal chunk / unmount.

Validation

  • New unit tests in packages/bb-agent/src/index.test.ts: send-path rejection resets loading + onError (sendMessage and respondToInterrupt); reconnect re-syncs final assistant text when done was missed; reconnect while turn still running keeps loading true; reconnect re-checks pending interrupts; bounded failsafe clears loading with no terminal chunk (mock timers).
  • index.test.js 111/111 pass (6 new + existing useChat suite); tsc --build clean; biome lint 0 errors; cast-free.
  • Sandbox e2e exercising a mid-stream reconnect: tracked (serialization/behavior change → sandbox e2e per AGENTS.md §11).
  • Note: index.cdk.test.js has 4 pre-existing CannotFindAsset failures (sibling-package build-artifact ordering) unrelated to this change — reproduced on baseline with changes stashed.

Checklist

  • PR description included
  • Tests are changed or added
  • Relevant documentation is changed or added (README/DESIGN follow-up)

By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.

@changeset-bot

changeset-bot Bot commented Sep 7, 2026

Copy link
Copy Markdown

🦋 Changeset detected

Latest commit: 8dd6535

The changes in this PR will be included in the next version bump.

This PR includes changesets to release 2 packages
Name Type
@aws-blocks/bb-agent Minor
@aws-blocks/blocks Patch

Not sure what this means? Click here to learn what changesets are.

Click here if you're a maintainer who wants to add another changeset to this PR

@soberm
soberm force-pushed the fix/usechat-ws-reconnect-resync branch from 2e1a526 to a1a0c56 Compare September 7, 2026 13:09
@soberm
soberm marked this pull request as ready for review September 7, 2026 13:11
@soberm
soberm requested a review from a team as a code owner September 7, 2026 13:11
soberm added a commit that referenced this pull request Sep 7, 2026
…nc reaches the transport

Address PR #501 review (BLOCKER + HIGH + MEDIUM): useChat built its subscribe
arg as a callable-with-props (Object.assign(fn, {onMessage,onReconnect})). Both
bb-realtime middlewares resolve subscribe with `typeof arg === 'function'` FIRST
and treat any function as a bare handler, never reading its properties — so
onReconnect/onDisconnect were silently dropped and the DB re-sync + bounded
failsafe (this PR's headline stuck-spinner fix) never fired on the real
transport for the documented adapter usage.

- index.hooks.ts: pass a plain ChatSubscribeOptions object; the function-first
  middleware now hits its object branch and reads onReconnect. subscribe param
  type still accepts a bare handler OR options (back-compat).
- index.test.ts: the subscribeCapture shim was options-first (read props off a
  function), the reverse of the real middleware, so every reconnect test passed
  vacuously. Rewrote it to mirror the real function-first precedence — a function
  is always a bare handler — so a regression to a callable now fails the tests.
- README.md: both useChat adapter snippets now forward the subscribe argument
  (`sub`) verbatim to channel.subscribe, with a note that a bare handler drops
  the reconnect callbacks.

bb-agent 114/114, build clean, biome 0 errors, cast-free.
…-path failures

Long agent turns (up to 8h on AgentCore) can outlive API Gateway WebSocket
limits (2h connection, 10-min idle). useChat subscribed once and assumed the
socket stayed healthy, so a reconnect gap could swallow the done chunk (loading
stuck true forever) and a rejected/504 send left a hung spinner + orphaned
empty assistant bubble.

- Widen the subscribe option to accept { onMessage, onDisconnect?, onReconnect? }
  (backward compatible with the bare-handler form).
- On reconnect, re-sync authoritative state from the DB: getConversation to
  recover the final assistant text if the turn completed during the gap, and
  getPendingInterrupts to recover a missed interrupt. If the turn is still
  running, keep loading and resume streaming on the resubscribed channel.
- Wrap api.sendMessage / api.resume in try/catch: reset loading, drop the empty
  placeholder, and surface onError on rejection.
- Bounded post-reconnect failsafe clears loading if no terminal chunk arrives
  within a window, so the spinner can never hang indefinitely.

Adds unit tests for send-path reset, reconnect re-sync (done missed / still
running), pending-interrupt re-check, and the bounded failsafe.
…turn-identity guard, single onError

Resolve code-review findings on the useChat reconnect/re-sync path:

- Failsafe no longer misfires on legitimately long/silent turns: any received
  chunk re-arms the post-reconnect failsafe, so it only fires after a window of
  COMPLETE silence, and the window is raised to 11 min (RECONNECT_FAILSAFE_MS =
  660_000) to sit above the Realtime 10-min idle-timeout + reconnect budget.
- Reconnect re-sync can no longer clobber correct text or adopt a stale/previous
  turn: assistantId is nulled on done/error (reliable in-flight signal), and
  handleReconnect captures turnAtStart before the getConversation await and only
  adopts the persisted text when it is still the same in-flight turn AND the
  persisted content extends what was already streamed.
- A send-path rejection that may have started the turn server-side no longer
  double-reports: onError is guarded to fire at most once per turn (reportError),
  matching the error-chunk path.
- The widened subscribe arg is a callable that also carries
  onMessage/onReconnect/onDisconnect, so existing adapters that invoke arg2 keep
  working while new adapters can read the reconnect hooks.
- Teardown guard: destroy() sets `destroyed`, and handleReconnect/armFailsafe
  early-return so no callback fires after unmount.

Tests updated/added for failsafe liveness, live-done-vs-late-getConversation,
stale-snapshot rejection, and single-onError.
…nc reaches the transport

Address PR #501 review (BLOCKER + HIGH + MEDIUM): useChat built its subscribe
arg as a callable-with-props (Object.assign(fn, {onMessage,onReconnect})). Both
bb-realtime middlewares resolve subscribe with `typeof arg === 'function'` FIRST
and treat any function as a bare handler, never reading its properties — so
onReconnect/onDisconnect were silently dropped and the DB re-sync + bounded
failsafe (this PR's headline stuck-spinner fix) never fired on the real
transport for the documented adapter usage.

- index.hooks.ts: pass a plain ChatSubscribeOptions object; the function-first
  middleware now hits its object branch and reads onReconnect. subscribe param
  type still accepts a bare handler OR options (back-compat).
- index.test.ts: the subscribeCapture shim was options-first (read props off a
  function), the reverse of the real middleware, so every reconnect test passed
  vacuously. Rewrote it to mirror the real function-first precedence — a function
  is always a bare handler — so a regression to a callable now fails the tests.
- README.md: both useChat adapter snippets now forward the subscribe argument
  (`sub`) verbatim to channel.subscribe, with a note that a bare handler drops
  the reconnect callbacks.

bb-agent 114/114, build clean, biome 0 errors, cast-free.
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