SSE and Streaming Edge Cases¶
This document describes the design, mechanics, and edge-case handling of the Server-Sent Events (SSE) and HTTP streaming infrastructure in Companion.
Architecture Overview¶
Companion uses two distinct streaming channels between the FastAPI backend and React frontend:
┌─────────────────────────────────────────────────────────────────────────────┐
│ BACKEND SERVICES │
│ │
│ astream v2 / ainvoke StreamBus (_bus singleton) │
│ (Supervisor Graph) (Application-wide SSE Broadcaster) │
└──────────┬─────────────────────────────────────┬────────────────────────────┘
│ │
POST /api/chat/stream GET /api/sse
POST /api/threads/{id}/stream (EventSource)
│ │
┌──────────┴─────────────────────────────────────┴────────────────────────────┐
│ FRONTEND LAYER │
│ │
│ streamThreadMessage() connectSSE() │
│ (Fetch + ReadableStream Reader) (EventSource Connection) │
│ │ │
│ ▼ │
│ useChatStream Hook │
│ (State, Queue & Interrupt Sync) │
└─────────────────────────────────────────────────────────────────────────────┘
- Request-Scoped Streaming (
POST /api/chat/stream,POST /api/threads/{thread_id}/stream): - Transports agent reasoning, state updates, errors, and task updates for a specific user prompt.
- Powered by FastAPI
StreamingResponsewrappingastreamv2 orainvoke. -
Consumed via
fetchwithReadableStreamreader infrontend/src/api/threads.ts. -
Global Event Bus Streaming (
GET /api/sse): - Transports asynchronous system-wide events (
agent.progress,idea.created,idea.scored,interrupt.created,interrupt.approved,interrupt.rejected). - Powered by
StreamBusbroadcast bus inbackend/app/infrastructure/events/stream_bus.py. - Consumed via browser native
EventSourceinfrontend/src/api/threads.ts.
Edge Cases and Behavior Specifications¶
1. Connection Loss and Reconnect Semantics¶
Backend: Client Queue Management and Eviction¶
- Implementation:
backend/app/infrastructure/events/stream_bus.py(StreamBus.subscribe,StreamBus.publish) - Behavior:
- Each connected
EventSourceclient is allocated anasyncio.Queuewith a maximum capacity (_MAX_QUEUE = 256). - When a client disconnects or falls behind, its queue fills up. Before publishing an event,
StreamBus.publishinspects all queues and evicts dead clients whose queue depth exceeds_MAX_QUEUE. - If a client queue is full at the moment of publishing,
asyncio.QueueFullis caught, the event is dropped for that specific client, and other healthy clients continue receiving events without blocking. - Disconnected clients raise
asyncio.CancelledErrorinsideStreamBus.subscribe(), which triggers cleanup removing the queue from the client list.
Frontend: SSE Reconnection and State Reconciliation¶
- Implementation:
frontend/src/api/threads.ts(connectSSE),frontend/src/hooks/useChatStream.ts(useEffecthook) - Behavior:
- Browser
EventSourceautomatically attempts reconnection upon network loss or server restart. - When
EventSourcetriggers theonerrorevent,connectSSEdelegates to theonErrorcallback provided byuseChatStream. useChatStreaminvokes a state recovery handler that fetches pending Human-In-The-Loop (HITL) interrupts fromGET /api/interrupts/pending.- If pending interrupts exist on the server, the frontend state (
pendingInterrupt) is re-hydrated with the latest pending interrupt; otherwise, stale pending interrupts are cleared.
Frontend: Stream Cancellation and Abort Handling¶
- Implementation:
frontend/src/hooks/useChatStream.ts(handleStopGeneration,useEffectonactiveThreadId) - Behavior:
- An
AbortController(abortRef) controls active fetch streams. - Manual Cancellation: User clicking stop calls
handleStopGeneration(), which invokesabortRef.current.abort(), setsisGeneratingtofalse, and clears the pendingmessageQueue. - Thread Switch Cancellation: Switching
activeThreadIdautomatically aborts any in-flight stream for the previous thread before loading checkpoint messages for the new thread. - Abort errors (
AbortError) are caught silently inexecuteSendto prevent error alerts or false system messages when stream cancellation is intentional.
2. Partial Frame and Buffer Handling¶
- Implementation:
frontend/src/api/threads.ts(streamChat,streamThreadMessage) - Behavior:
- SSE frames sent over HTTP chunked transfer encoding may be fragmented across multiple TCP packets or buffer boundaries.
- Frontend stream readers process incoming bytes using a
TextDecoderstream reader (decoder.decode(value, { stream: true })). - Read chunks are appended to a persistent string
buffer. - The buffer is split by newline
\ncharacters:const lines = buffer.split('\n'). - Incomplete Line Preservation: The trailing item
buffer = lines.pop() ?? ''retains any incomplete trailing frame line in the buffer until the next read chunk supplies the remaining line content. - Lines starting with
data:are extracted and parsed viaJSON.parse(). Malformed or incomplete JSON syntax errors are swallowed silently (catch {}) until complete lines arrive.
3. Event Ordering and Stream Lifecycle¶
- Implementation:
backend/app/api/routes/chat.py(_chat_stream_generator),backend/app/api/routes/threads.py(_thread_stream_generator),frontend/src/hooks/useChatStream.ts(executeSend)
Stream Event Lifecycle Sequence¶
state_update: Emitted as the agent reasoning or output progresses. Frontend appends stream chunks incrementally to the message response (streamMsgIdRef).tasks_update: Emitted when agent execution updates task execution progress or status counts.interrupt: Emitted when an agent requires human approval before proceeding with tool execution.error: Emitted when processing fails or graph execution raises an exception.done: Guaranteed Terminal Event. Always emitted as the final frame.
Terminal Frame Guarantee¶
- Both
_chat_stream_generatorand_thread_stream_generatortrack whether a terminal event (state_updatewith response/error or expliciterror) was emitted (emitted_doneflag). - In a
finally:block, ifemitted_doneisFalse, the backend explicitly yieldsdata: {"type": "done"}\n\n. - This ensures the frontend
executeSendhandler receives completion notification even when empty responses or unhandled edge cases occur, resettingisGeneratingtofalse.
4. Error Propagation and Normalization¶
- Implementation:
backend/app/api/routes/chat.py(_error_shape,_chat_stream_generator),backend/app/api/routes/threads.py(_thread_stream_generator),frontend/src/hooks/useChatStream.ts(executeSend)
Error Payload Structure¶
Backend stream errors are normalized into a standardized error object:
{
"type": "error",
"error": {
"code": "streaming_failure",
"message": "An error occurred while processing your request. Please try again.",
"retryable": true
},
"routing_key": "general"
}
Exception Guarding¶
- Never Crash SSE: The backend stream generators wrap execution in
try ... except Exception as exc. Unhandled exceptions do not break the HTTP connection abruptly; instead, an error event payload is serialized and yielded over SSE. - Frontend Presentation: When an
errorevent is received,useChatStreamstops streaming, resetsstreamMsgIdRef = null, setsisGenerating = false, and appends aSystemerror message to the transcript containing error details and retryability metadata.
5. Interrupt State Reload on Reconnect¶
- Implementation:
backend/app/api/routes/interrupts.py,backend/app/services/interrupt_service.py,frontend/src/hooks/useChatStream.ts
Interrupt Flow and Deduplication¶
- Creation: When an agent triggers an interrupt, an
interrupt.createdevent is published overStreamBusand stored in SQLite viaInterruptService. - Stream Interception: The active chat stream yields an
interruptstream event.useChatStreamsetspendingInterruptand appends a visual system approval notification torawMessages. - Deduplication (
activeInterruptIdRef): - To prevent duplicate approval notifications when both the stream reader and
StreamBusdeliver the same interrupt event,useChatStreammaintainsactiveInterruptIdRef. - If an incoming interrupt event ID matches
activeInterruptIdRef.current, it is skipped. - Reconnection Sync:
- If the network drops while an interrupt is active, the frontend
EventSourcereconnect handler callsGET /api/interrupts/pending. - The latest pending interrupt is re-restored to
pendingInterruptandactiveInterruptIdRef, ensuring the user approval dialog remains visible and actionable. - Resolution: When approved (
PATCH /api/interrupts/{id}/approve) or rejected (PATCH /api/interrupts/{id}/reject),interrupt.approvedorinterrupt.rejectedevents clearpendingInterruptand resetactiveInterruptIdReftonull.
File Reference Summary¶
| Layer | File Path | Responsible Function / Logic |
|---|---|---|
| Backend Stream API | backend/app/api/routes/chat.py | _chat_stream_generator, _error_shape, stream_chat |
| Backend Thread API | backend/app/api/routes/threads.py | _thread_stream_generator, api_stream_message |
| Backend Event Bus | backend/app/infrastructure/events/stream_bus.py | StreamBus.subscribe, StreamBus.publish |
| Backend SSE Route | backend/app/api/routes/sse.py | sse endpoint (_bus.subscribe) |
| Backend Interrupts | backend/app/api/routes/interrupts.py | list_pending, approve_interrupt, reject_interrupt |
| Frontend API Client | frontend/src/api/threads.ts | connectSSE, streamChat, streamThreadMessage, fetchPendingInterrupts |
| Frontend Hook | frontend/src/hooks/useChatStream.ts | useChatStream (stream consuming, queueing, interrupt reload, abort control) |