feat: implement service failure handling in background streaming

- Added a method to send failure notifications to Flutter when the background service fails to enter the foreground.
- Implemented a broadcast receiver to handle service failure notifications and notify Flutter about the failure.
- Enhanced the persistent streaming service to attempt recovery for failed streams.
- Introduced heartbeat monitoring for SSE streams to detect stale connections and trigger recovery actions.
This commit is contained in:
cogwheel0
2025-10-28 13:59:17 +05:30
parent 81eb38dc52
commit 7fb199b2e4
7 changed files with 265 additions and 25 deletions

View File

@@ -102,9 +102,19 @@ class BackgroundStreamingService : Service() {
} catch (e: Exception) { } catch (e: Exception) {
// Catch all exceptions including ForegroundServiceStartNotAllowedException // Catch all exceptions including ForegroundServiceStartNotAllowedException
println("BackgroundStreamingService: Failed to enter foreground: ${e.javaClass.simpleName}: ${e.message}") println("BackgroundStreamingService: Failed to enter foreground: ${e.javaClass.simpleName}: ${e.message}")
// Notify Flutter about the failure
sendFailureNotification(e)
false false
} }
} }
private fun sendFailureNotification(e: Exception) {
// Send broadcast intent to notify MainActivity
val intent = Intent("app.cogwheel.conduit.FOREGROUND_SERVICE_FAILED")
intent.putExtra("error", e.message ?: "Unknown error")
intent.putExtra("errorType", e.javaClass.simpleName)
sendBroadcast(intent)
}
private fun updateForegroundType(notification: Notification, type: Int) { private fun updateForegroundType(notification: Notification, type: Int) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) return
@@ -248,6 +258,7 @@ class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCal
private val streamsRequiringMic = mutableSetOf<String>() private val streamsRequiringMic = mutableSetOf<String>()
private var backgroundJob: Job? = null private var backgroundJob: Job? = null
private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.Main + SupervisorJob())
private var serviceFailureReceiver: android.content.BroadcastReceiver? = null
companion object { companion object {
private const val CHANNEL_NAME = "conduit/background_streaming" private const val CHANNEL_NAME = "conduit/background_streaming"
@@ -262,6 +273,38 @@ class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCal
sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) sharedPrefs = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
createNotificationChannel() createNotificationChannel()
setupServiceFailureReceiver()
}
private fun setupServiceFailureReceiver() {
serviceFailureReceiver = object : android.content.BroadcastReceiver() {
override fun onReceive(context: Context?, intent: Intent?) {
if (intent?.action == "app.cogwheel.conduit.FOREGROUND_SERVICE_FAILED") {
val error = intent.getStringExtra("error") ?: "Unknown error"
val errorType = intent.getStringExtra("errorType") ?: "Exception"
println("BackgroundStreamingHandler: Service failure received: $errorType - $error")
// Notify Flutter about the service failure
channel.invokeMethod("serviceFailed", mapOf(
"error" to error,
"errorType" to errorType,
"streamIds" to activeStreams.toList()
))
// Clear active streams since service failed
activeStreams.clear()
streamsRequiringMic.clear()
}
}
}
val filter = android.content.IntentFilter("app.cogwheel.conduit.FOREGROUND_SERVICE_FAILED")
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
context.registerReceiver(serviceFailureReceiver, filter, Context.RECEIVER_NOT_EXPORTED)
} else {
context.registerReceiver(serviceFailureReceiver, filter)
}
} }
override fun onMethodCall(call: MethodCall, result: Result) { override fun onMethodCall(call: MethodCall, result: Result) {
@@ -514,5 +557,15 @@ class BackgroundStreamingHandler(private val activity: MainActivity) : MethodCal
scope.cancel() scope.cancel()
stopBackgroundMonitoring() stopBackgroundMonitoring()
stopForegroundService() stopForegroundService()
// Unregister broadcast receiver
try {
serviceFailureReceiver?.let {
context.unregisterReceiver(it)
}
} catch (e: Exception) {
println("BackgroundStreamingHandler: Error unregistering receiver: ${e.message}")
}
serviceFailureReceiver = null
} }
} }

View File

@@ -3067,6 +3067,29 @@ class ApiService {
final Map<String, CancelToken> _streamCancelTokens = {}; final Map<String, CancelToken> _streamCancelTokens = {};
final Map<String, String> _messagePersistentStreamIds = {}; final Map<String, String> _messagePersistentStreamIds = {};
/// Associates a streaming message with its persistent stream identifier.
void registerPersistentStreamForMessage(String messageId, String streamId) {
_messagePersistentStreamIds[messageId] = streamId;
}
/// Removes the persistent stream mapping for a message if it matches.
///
/// Returns the removed persistent stream identifier when one existed and
/// matched the optional [expectedStreamId].
String? clearPersistentStreamForMessage(
String messageId, {
String? expectedStreamId,
}) {
final current = _messagePersistentStreamIds[messageId];
if (current == null) {
return null;
}
if (expectedStreamId != null && current != expectedStreamId) {
return null;
}
return _messagePersistentStreamIds.remove(messageId);
}
// Send message using dual-stream approach (HTTP SSE + WebSocket events). // Send message using dual-stream approach (HTTP SSE + WebSocket events).
// Matches OpenWebUI web client behavior: // Matches OpenWebUI web client behavior:
// - HTTP SSE stream provides immediate content chunks // - HTTP SSE stream provides immediate content chunks
@@ -3205,15 +3228,19 @@ class ApiService {
try { try {
final userParams = userSettings?['params'] as Map<String, dynamic>?; final userParams = userSettings?['params'] as Map<String, dynamic>?;
final functionCallingMode = userParams?['function_calling'] as String?; final functionCallingMode = userParams?['function_calling'] as String?;
if (functionCallingMode != null) { if (functionCallingMode != null) {
final params = final params =
(data['params'] as Map<String, dynamic>?) ?? <String, dynamic>{}; (data['params'] as Map<String, dynamic>?) ?? <String, dynamic>{};
params['function_calling'] = functionCallingMode; params['function_calling'] = functionCallingMode;
data['params'] = params; data['params'] = params;
_traceApi('Set params.function_calling = $functionCallingMode (from user settings)'); _traceApi(
'Set params.function_calling = $functionCallingMode (from user settings)',
);
} else { } else {
_traceApi('No function_calling preference in user settings, backend will use default mode'); _traceApi(
'No function_calling preference in user settings, backend will use default mode',
);
} }
} catch (_) { } catch (_) {
// Non-fatal; continue without setting function_calling mode // Non-fatal; continue without setting function_calling mode
@@ -3288,20 +3315,29 @@ class ApiService {
data: data, data: data,
options: Options( options: Options(
responseType: ResponseType.stream, responseType: ResponseType.stream,
// Extended timeout for streaming responses - allow up to 10 minutes
// for long-running tool calls and reasoning
receiveTimeout: const Duration(minutes: 10),
// Shorter send timeout for the initial request
sendTimeout: const Duration(seconds: 30),
headers: { headers: {
'Accept': 'text/event-stream', 'Accept': 'text/event-stream',
// Enable HTTP keep-alive to maintain connection in background
'Connection': 'keep-alive',
// Request server to send keep-alive messages
'Cache-Control': 'no-cache',
}, },
), ),
cancelToken: cancelToken, cancelToken: cancelToken,
); );
final respData = resp.data; final respData = resp.data;
// Check if we got a task_id response (non-streaming) // Check if we got a task_id response (non-streaming)
if (respData is Map && respData['task_id'] != null) { if (respData is Map && respData['task_id'] != null) {
final taskId = respData['task_id'].toString(); final taskId = respData['task_id'].toString();
_traceApi('Background task created: $taskId'); _traceApi('Background task created: $taskId');
// In this case, all streaming will happen via WebSocket // In this case, all streaming will happen via WebSocket
// Close HTTP stream but keep WebSocket active // Close HTTP stream but keep WebSocket active
if (!streamController.isClosed) { if (!streamController.isClosed) {
@@ -3309,15 +3345,26 @@ class ApiService {
} }
return; return;
} }
// We have a streaming response body // We have a streaming response body
if (respData is ResponseBody) { if (respData is ResponseBody) {
_traceApi('HTTP SSE stream started for message: $messageId'); _traceApi('HTTP SSE stream started for message: $messageId');
// Parse SSE stream and forward chunks to controller // Parse SSE stream and forward chunks to controller
await for (final chunk in SSEStreamParser.parseResponseStream( await for (final chunk in SSEStreamParser.parseResponseStream(
respData, respData,
splitLargeDeltas: false, splitLargeDeltas: false,
heartbeatTimeout: const Duration(minutes: 2),
onHeartbeat: () {
// Notify persistent streaming service that connection is alive
final persistentStreamId = _messagePersistentStreamIds[messageId];
if (persistentStreamId != null) {
PersistentStreamingService().updateStreamProgress(
persistentStreamId,
chunkSequence: DateTime.now().millisecondsSinceEpoch,
);
}
},
)) { )) {
if (!streamController.isClosed) { if (!streamController.isClosed) {
streamController.add(chunk); streamController.add(chunk);
@@ -3326,12 +3373,12 @@ class ApiService {
break; break;
} }
} }
_traceApi('HTTP SSE stream completed for message: $messageId'); _traceApi('HTTP SSE stream completed for message: $messageId');
} else { } else {
_traceApi('Unexpected response type: ${respData.runtimeType}'); _traceApi('Unexpected response type: ${respData.runtimeType}');
} }
// Close the HTTP stream controller // Close the HTTP stream controller
// WebSocket events will continue independently via streaming_helper // WebSocket events will continue independently via streaming_helper
if (!streamController.isClosed) { if (!streamController.isClosed) {
@@ -3399,7 +3446,7 @@ class ApiService {
} catch (_) {} } catch (_) {}
try { try {
final pid = _messagePersistentStreamIds.remove(messageId); final pid = clearPersistentStreamForMessage(messageId);
if (pid != null) { if (pid != null) {
PersistentStreamingService().unregisterStream(pid); PersistentStreamingService().unregisterStream(pid);
} }

View File

@@ -27,9 +27,11 @@ class BackgroundStreamingHandler {
void Function(List<String> streamIds)? onStreamsSuspending; void Function(List<String> streamIds)? onStreamsSuspending;
void Function()? onBackgroundTaskExpiring; void Function()? onBackgroundTaskExpiring;
void Function(List<String> streamIds, int estimatedSeconds)? void Function(List<String> streamIds, int estimatedSeconds)?
onBackgroundTaskExtended; onBackgroundTaskExtended;
void Function()? onBackgroundKeepAlive; void Function()? onBackgroundKeepAlive;
bool Function()? shouldContinueInBackground; bool Function()? shouldContinueInBackground;
void Function(String error, String errorType, List<String> streamIds)?
onServiceFailed;
void _setupMethodCallHandler() { void _setupMethodCallHandler() {
_channel.setMethodCallHandler((call) async { _channel.setMethodCallHandler((call) async {
@@ -79,6 +81,31 @@ class BackgroundStreamingHandler {
DebugLogger.stream('keepalive-signal', scope: 'background'); DebugLogger.stream('keepalive-signal', scope: 'background');
onBackgroundKeepAlive?.call(); onBackgroundKeepAlive?.call();
break; break;
case 'serviceFailed':
final Map<String, dynamic> args =
call.arguments as Map<String, dynamic>;
final String error = args['error'] as String? ?? 'Unknown error';
final String errorType = args['errorType'] as String? ?? 'Exception';
final List<String> streamIds =
(args['streamIds'] as List?)?.cast<String>() ?? [];
DebugLogger.error(
'service-failed',
scope: 'background',
error: error,
data: {'type': errorType, 'streams': streamIds.length},
);
// Notify callback about service failure
onServiceFailed?.call(error, errorType, streamIds);
// Clean up failed streams
for (final streamId in streamIds) {
_activeStreamIds.remove(streamId);
_streamStates.remove(streamId);
}
break;
} }
}); });
} }

View File

@@ -46,6 +46,28 @@ class PersistentStreamingService with WidgetsBindingObserver {
} }
void _setupBackgroundHandlerCallbacks() { void _setupBackgroundHandlerCallbacks() {
_backgroundHandler.onServiceFailed = (error, errorType, streamIds) {
DebugLogger.error(
'background-service-failed',
scope: 'streaming/persistent',
error: '$errorType: $error',
data: {'affectedStreams': streamIds},
);
// Attempt immediate recovery for failed streams
for (final streamId in streamIds) {
final callback = _streamRecoveryCallbacks[streamId];
if (callback != null) {
// Schedule recovery after a short delay
Future.delayed(const Duration(seconds: 2), () {
if (_activeStreams.containsKey(streamId)) {
_attemptStreamRecovery(streamId, callback);
}
});
}
}
};
_backgroundHandler.onStreamsSuspending = (streamIds) { _backgroundHandler.onStreamsSuspending = (streamIds) {
DebugLogger.stream( DebugLogger.stream(
'PersistentStreaming: Streams suspending - $streamIds', 'PersistentStreaming: Streams suspending - $streamIds',
@@ -123,9 +145,46 @@ class PersistentStreamingService with WidgetsBindingObserver {
_heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (_) { _heartbeatTimer = Timer.periodic(const Duration(seconds: 30), (_) {
if (_activeStreams.isNotEmpty && _isInBackground) { if (_activeStreams.isNotEmpty && _isInBackground) {
_backgroundHandler.keepAlive(); _backgroundHandler.keepAlive();
// Check for stale streams during background operation
_checkStreamHealth();
} }
}); });
} }
void _checkStreamHealth() {
final now = DateTime.now();
final staleStreams = <String>[];
for (final entry in _streamMetadata.entries) {
final streamId = entry.key;
final metadata = entry.value;
final lastUpdate = metadata['lastUpdate'] as DateTime?;
if (lastUpdate != null) {
final timeSinceUpdate = now.difference(lastUpdate);
// If no update in 90 seconds while in background, consider stale
if (timeSinceUpdate > const Duration(seconds: 90)) {
DebugLogger.warning(
'Stream $streamId appears stale: ${timeSinceUpdate.inSeconds}s since last update',
);
staleStreams.add(streamId);
}
}
}
// Attempt recovery for stale streams
for (final streamId in staleStreams) {
final callback = _streamRecoveryCallbacks[streamId];
if (callback != null && _retryAttempts[streamId] == null) {
DebugLogger.stream(
'Initiating recovery for stale stream: $streamId',
);
_attemptStreamRecovery(streamId, callback);
}
}
}
@override @override
void didChangeAppLifecycleState(AppLifecycleState state) { void didChangeAppLifecycleState(AppLifecycleState state) {
@@ -385,13 +444,26 @@ class PersistentStreamingService with WidgetsBindingObserver {
final metadata = _streamMetadata[streamId]; final metadata = _streamMetadata[streamId];
if (metadata == null) return false; if (metadata == null) return false;
// Check if stream is marked as suspended
if (metadata['suspended'] == true) {
final suspendedAt = metadata['suspendedAt'] as DateTime?;
if (suspendedAt != null) {
final timeSinceSuspend = DateTime.now().difference(suspendedAt);
// Try to recover suspended streams after 10 seconds
return timeSinceSuspend > const Duration(seconds: 10);
}
}
// Check if stream has been inactive for too long // Check if stream has been inactive for too long
final lastUpdate = metadata['lastUpdate'] as DateTime?; final lastUpdate = metadata['lastUpdate'] as DateTime?;
if (lastUpdate != null) { if (lastUpdate != null) {
final timeSinceUpdate = DateTime.now().difference(lastUpdate); final timeSinceUpdate = DateTime.now().difference(lastUpdate);
// Align with app-side watchdogs: be less aggressive than UI guard // In background: 90 seconds
// but still attempt recovery before server timeouts become likely. // In foreground: 2 minutes
return timeSinceUpdate > const Duration(minutes: 2); final threshold = _isInBackground
? const Duration(seconds: 90)
: const Duration(minutes: 2);
return timeSinceUpdate > threshold;
} }
return false; return false;

View File

@@ -12,15 +12,45 @@ class SSEStreamParser {
/// ///
/// Returns a stream of content strings extracted from OpenAI-style /// Returns a stream of content strings extracted from OpenAI-style
/// completion chunks. /// completion chunks.
///
/// [heartbeatTimeout] - Maximum time without data before considering
/// the connection stale (default: 2 minutes)
/// [onHeartbeat] - Callback invoked when any data is received
static Stream<String> parseResponseStream( static Stream<String> parseResponseStream(
ResponseBody responseBody, { ResponseBody responseBody, {
bool splitLargeDeltas = false, bool splitLargeDeltas = false,
Duration heartbeatTimeout = const Duration(minutes: 2),
void Function()? onHeartbeat,
}) async* { }) async* {
DateTime lastDataReceived = DateTime.now();
Timer? heartbeatTimer;
// Set up heartbeat monitoring
if (heartbeatTimeout.inMilliseconds > 0) {
heartbeatTimer = Timer.periodic(
const Duration(seconds: 30),
(timer) {
final timeSinceLastData = DateTime.now().difference(lastDataReceived);
if (timeSinceLastData > heartbeatTimeout) {
DebugLogger.warning(
'SSE stream heartbeat timeout: No data received for ${timeSinceLastData.inSeconds}s',
data: {'timeout': heartbeatTimeout.inSeconds},
);
timer.cancel();
}
},
);
}
try { try {
// Buffer for accumulating incomplete SSE messages // Buffer for accumulating incomplete SSE messages
String buffer = ''; String buffer = '';
await for (final chunk in responseBody.stream) { await for (final chunk in responseBody.stream) {
// Update last data timestamp and invoke heartbeat callback
lastDataReceived = DateTime.now();
onHeartbeat?.call();
// Convert bytes to string (Dio ResponseBody.stream always emits Uint8List) // Convert bytes to string (Dio ResponseBody.stream always emits Uint8List)
final text = utf8.decode(chunk as List<int>, allowMalformed: true); final text = utf8.decode(chunk as List<int>, allowMalformed: true);
buffer += text; buffer += text;
@@ -68,6 +98,9 @@ class SSEStreamParser {
stackTrace: stackTrace, stackTrace: stackTrace,
); );
rethrow; rethrow;
} finally {
// Clean up heartbeat timer
heartbeatTimer?.cancel();
} }
} }

View File

@@ -16,6 +16,7 @@ import '../../shared/theme/theme_extensions.dart';
import '../utils/debug_logger.dart'; import '../utils/debug_logger.dart';
import '../utils/openwebui_source_parser.dart'; import '../utils/openwebui_source_parser.dart';
import 'streaming_response_controller.dart'; import 'streaming_response_controller.dart';
import 'api_service.dart';
// Keep local verbosity toggle for socket logs // Keep local verbosity toggle for socket logs
const bool kSocketVerboseLogging = false; const bool kSocketVerboseLogging = false;
@@ -67,7 +68,7 @@ ActiveSocketStream attachUnifiedChunkedStreaming({
required Map<String, dynamic> modelItem, required Map<String, dynamic> modelItem,
required String sessionId, required String sessionId,
required String? activeConversationId, required String? activeConversationId,
required dynamic api, required ApiService api,
required SocketService? socketService, required SocketService? socketService,
RegisterConversationDeltaListener? registerDeltaListener, RegisterConversationDeltaListener? registerDeltaListener,
// Message update callbacks // Message update callbacks
@@ -169,6 +170,7 @@ ActiveSocketStream attachUnifiedChunkedStreaming({
'modelId': modelId, 'modelId': modelId,
}, },
); );
api.registerPersistentStreamForMessage(assistantMessageId, streamId);
InactivityWatchdog? socketWatchdog; InactivityWatchdog? socketWatchdog;
final socketSubscriptions = <VoidCallback>[]; final socketSubscriptions = <VoidCallback>[];
@@ -318,8 +320,6 @@ ActiveSocketStream attachUnifiedChunkedStreaming({
if (chatId == null || chatId.isEmpty) { if (chatId == null || chatId.isEmpty) {
return; return;
} }
if (api == null) return;
refreshingSnapshot = true; refreshingSnapshot = true;
try { try {
final conversation = await api.getConversation(chatId); final conversation = await api.getConversation(chatId);
@@ -376,7 +376,7 @@ ActiveSocketStream attachUnifiedChunkedStreaming({
try { try {
// Fire and forget // Fire and forget
// ignore: unawaited_futures // ignore: unawaited_futures
api?.sendChatCompleted( api.sendChatCompleted(
chatId: activeConversationId ?? '', chatId: activeConversationId ?? '',
messageId: assistantMessageId, messageId: assistantMessageId,
messages: const [], messages: const [],
@@ -397,7 +397,7 @@ ActiveSocketStream attachUnifiedChunkedStreaming({
} catch (_) {} } catch (_) {}
try { try {
// ignore: unawaited_futures // ignore: unawaited_futures
api?.sendChatCompleted( api.sendChatCompleted(
chatId: activeConversationId ?? '', chatId: activeConversationId ?? '',
messageId: assistantMessageId, messageId: assistantMessageId,
messages: const [], messages: const [],
@@ -594,7 +594,7 @@ ActiveSocketStream attachUnifiedChunkedStreaming({
if (payload['done'] == true) { if (payload['done'] == true) {
try { try {
// ignore: unawaited_futures // ignore: unawaited_futures
api?.sendChatCompleted( api.sendChatCompleted(
chatId: activeConversationId ?? '', chatId: activeConversationId ?? '',
messageId: assistantMessageId, messageId: assistantMessageId,
messages: const [], messages: const [],
@@ -614,8 +614,8 @@ ActiveSocketStream attachUnifiedChunkedStreaming({
try { try {
final chatId = activeConversationId; final chatId = activeConversationId;
if (chatId != null && chatId.isNotEmpty) { if (chatId != null && chatId.isNotEmpty) {
final resp = await api?.dio.get('/api/v1/chats/$chatId'); final resp = await api.dio.get('/api/v1/chats/$chatId');
final data = resp?.data as Map<String, dynamic>?; final data = resp.data as Map<String, dynamic>?;
String content = ''; String content = '';
final chatObj = data?['chat'] as Map<String, dynamic>?; final chatObj = data?['chat'] as Map<String, dynamic>?;
if (chatObj != null) { if (chatObj != null) {
@@ -1137,6 +1137,10 @@ ActiveSocketStream attachUnifiedChunkedStreaming({
} }
}, },
onComplete: () { onComplete: () {
api.clearPersistentStreamForMessage(
assistantMessageId,
expectedStreamId: streamId,
);
// Unregister from persistent service // Unregister from persistent service
persistentService.unregisterStream(streamId); persistentService.unregisterStream(streamId);
@@ -1159,6 +1163,10 @@ ActiveSocketStream attachUnifiedChunkedStreaming({
}, },
); );
api.clearPersistentStreamForMessage(
assistantMessageId,
expectedStreamId: streamId,
);
try { try {
persistentService.unregisterStream(streamId); persistentService.unregisterStream(streamId);
} catch (_) {} } catch (_) {}

View File

@@ -1447,7 +1447,7 @@ Future<void> regenerateMessage(
modelItem: modelItem, modelItem: modelItem,
sessionId: effectiveSessionId, sessionId: effectiveSessionId,
activeConversationId: activeConversation.id, activeConversationId: activeConversation.id,
api: api, api: api!,
socketService: socketService, socketService: socketService,
registerDeltaListener: registerDeltaListener, registerDeltaListener: registerDeltaListener,
appendToLastMessage: (c) => appendToLastMessage: (c) =>
@@ -1719,7 +1719,7 @@ Future<void> _sendMessageInternal(
final List<String> ids = msg.attachmentIds ?? const <String>[]; final List<String> ids = msg.attachmentIds ?? const <String>[];
if (ids.isNotEmpty) { if (ids.isNotEmpty) {
final messageMap = await _buildMessagePayloadWithAttachments( final messageMap = await _buildMessagePayloadWithAttachments(
api: api, api: api!,
role: msg.role, role: msg.role,
cleanedText: cleaned, cleanedText: cleaned,
attachmentIds: ids, attachmentIds: ids,
@@ -1995,7 +1995,7 @@ Future<void> _sendMessageInternal(
modelItem: modelItem, modelItem: modelItem,
sessionId: effectiveSessionId, sessionId: effectiveSessionId,
activeConversationId: activeConversation?.id, activeConversationId: activeConversation?.id,
api: api, api: api!,
socketService: socketService, socketService: socketService,
registerDeltaListener: registerDeltaListener, registerDeltaListener: registerDeltaListener,
appendToLastMessage: (c) => appendToLastMessage: (c) =>