Skip to content

feat(ai-chat): Stream assistant turns over Reverb websockets - #224

Draft
austin047 wants to merge 1 commit into
devfrom
feat/ai-chat-websockets
Draft

austin047 wants to merge 1 commit into
devfrom
feat/ai-chat-websockets

Conversation

@austin047

Copy link
Copy Markdown
Collaborator

Description

Streams AI chat progress live over Laravel Reverb instead of relying on polling alone.

  • Adds a Pusher-protocol client for Reverb (lib/core/realtime/): private channel auth through /broadcasting/auth, ping/pong keepalive and reconnect.
  • AiChatCubit subscribes to chat-session.{id} and handles the server's turn events: progress adds the step label to the reply in flight, settled refetches the message right away.
  • Polling keeps running, so chat still works when the socket is down or not configured.
  • Connection settings come from --dart-define: REVERB_APP_KEY, REVERB_HOST, REVERB_PORT, REVERB_SCHEME. Without a key the socket stays off and chat runs on polling alone.
  • Unit tests for the protocol frames and the socket client.

The server side is already on webservice dev (ChatTurnEvent, trakli/webservice@32db7ba), and the web app already uses it.

Draft until:

  • The build workflows pass the REVERB_* values for staging and production. None do today, so builds would run on polling only.
  • Reverb is confirmed running in production. The webservice production compose file and deploy workflow don't mention it, and .env.example defaults to BROADCAST_DRIVER=log.

Type of Change

  • ✨ New feature (non-breaking change which adds functionality)
  • 🛠️ Bug fix (non-breaking change which fixes an issue)
  • ❌ Breaking change (fix or feature that would cause existing functionality to change)
  • 🧹 Code refactor
  • ✅ Build configuration change
  • 📝 Documentation
  • 🗑️ Chore

Adds a Pusher-protocol client for Laravel Reverb (private channel auth,
ping/pong keepalive, reconnect) and wires ai_chat_cubit to consume turn
events live instead of polling.
@sourceant

sourceant Bot commented Oct 9, 2026

Copy link
Copy Markdown

Code Review Summary

Adds a hand-rolled Pusher/Reverb websocket client (lib/core/realtime/) that authenticates a session's private channel and streams turn events. AiChatCubit subscribes to chat-session.{id}, appends progress step labels to state, and refetches immediately on settled, while polling continues as fallback. REVERB_* dart-defines configure the socket; without an app key it stays disabled and chat runs on polling alone.

🚀 Key Improvements

  • Non-throwing frame parser keeps malformed or future server messages from breaking the connection.
  • Reconnect backoff with automatic re-subscription, plus ping/pong keepalive.
  • New unit tests cover protocol frames, auth, subscription switching, and failure handling.

💡 Minor Suggestions

  • If subscribeToSession reconnects during the backoff window, _channel is already non-null when this timer fires, so _connectSocket opens a second socket and orphans the first subscription, which is never cancelled or closed. Bail out when a channel already exists.
  • If the socket drops while the auth POST is in flight, _scheduleReconnect nulls _channel/_socketId, but this code then sets _subscribedChannel anyway. On the next connection_established, _subscribeToTarget sees channel == _subscribedChannel and returns without subscribing, so no live updates arrive until the session is reopened. Re-check the connection before writing.
  • The reconnect timer only checks _disposed, so a timer scheduled before unsubscribe() still fires and reconnects a socket that no longer has a target session — an idle websocket kept open with no subscription. Include _targetSessionId != null in the guard.
  • Review coverage is incomplete. Inspect the execution details before approving.

@sourceant sourceant Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review complete. See the overview comment for a summary.

? _reconnectAttempt
: _reconnectBackoff.length - 1;
_reconnectAttempt++;
_reconnectTimer = Timer(_reconnectBackoff[i], () {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If subscribeToSession reconnects during the backoff window, _channel is already non-null when this timer fires, so _connectSocket opens a second socket and orphans the first subscription, which is never cancelled or closed. Bail out when a channel already exists.

Suggested change
_reconnectTimer = Timer(_reconnectBackoff[i], () {
_reconnectTimer = Timer(_reconnectBackoff[i], () {
if (!_disposed && _channel == null) unawaited(_connectSocket());
});

return;
}
// The target may have changed while the auth request was in flight.
if (_targetSessionId != sessionId) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If the socket drops while the auth POST is in flight, _scheduleReconnect nulls _channel/_socketId, but this code then sets _subscribedChannel anyway. On the next connection_established, _subscribeToTarget sees channel == _subscribedChannel and returns without subscribing, so no live updates arrive until the session is reopened. Re-check the connection before writing.

Suggested change
if (_targetSessionId != sessionId) return;
+ if (_targetSessionId != sessionId) return;
+ if (_channel == null || _socketId != socketId) return;
+ _channel!.sink.add(encodeSubscribe(channel: channel, auth: auth));
+ _subscribedChannel = channel;

? _reconnectAttempt
: _reconnectBackoff.length - 1;
_reconnectAttempt++;
_reconnectTimer = Timer(_reconnectBackoff[i], () {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The reconnect timer only checks _disposed, so a timer scheduled before unsubscribe() still fires and reconnects a socket that no longer has a target session — an idle websocket kept open with no subscription. Include _targetSessionId != null in the guard.

Suggested change
_reconnectTimer = Timer(_reconnectBackoff[i], () {
_reconnectTimer = Timer(_reconnectBackoff[i], () {
if (!_disposed && _targetSessionId != null) unawaited(_connectSocket());
});

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