diff --git a/app/src/providers/ChatRuntimeProvider.tsx b/app/src/providers/ChatRuntimeProvider.tsx index b9a5c304e..465bad93c 100644 --- a/app/src/providers/ChatRuntimeProvider.tsx +++ b/app/src/providers/ChatRuntimeProvider.tsx @@ -45,7 +45,10 @@ const logChatRuntime = debug('openhuman:chat-runtime'); const USER_FACING_AGENT_ERROR_MESSAGE = 'Something went wrong. Please try again.\nThis error has been reported. You can also report it on Discord.\nReport on Discord'; -type SegmentDelivery = { segments: Map }; +const SEGMENT_DELIVERY_TTL_MS = 5 * 60 * 1000; +const MAX_SEGMENT_DELIVERIES = 100; + +type SegmentDelivery = { segments: Map; createdAt: number; lastSeenAt: number }; function rtLog(message: string, fields?: Record) { if (IS_PROD) return; @@ -63,6 +66,60 @@ function segmentDeliveryKey(threadId: string, requestId?: string | null): string return `${threadId}:${requestId ?? 'none'}`; } +function pruneSegmentDeliveries(deliveries: Map, now = Date.now()) { + for (const [key, delivery] of deliveries) { + if (now - delivery.createdAt > SEGMENT_DELIVERY_TTL_MS) { + deliveries.delete(key); + } + } + + while (deliveries.size > MAX_SEGMENT_DELIVERIES) { + let oldestKey: string | undefined; + let oldestLastSeenAt = Number.POSITIVE_INFINITY; + for (const [key, delivery] of deliveries) { + if (delivery.lastSeenAt < oldestLastSeenAt) { + oldestKey = key; + oldestLastSeenAt = delivery.lastSeenAt; + } + } + if (!oldestKey) break; + deliveries.delete(oldestKey); + } +} + +function getOrCreateSegmentDelivery( + deliveries: Map, + key: string, + now = Date.now() +): SegmentDelivery { + pruneSegmentDeliveries(deliveries, now); + const existing = deliveries.get(key); + if (existing) { + existing.lastSeenAt = now; + return existing; + } + const delivery = { segments: new Map(), createdAt: now, lastSeenAt: now }; + deliveries.set(key, delivery); + pruneSegmentDeliveries(deliveries, now); + return delivery; +} + +function takeSegmentDelivery( + deliveries: Map, + key: string, + now = Date.now() +): SegmentDelivery | undefined { + pruneSegmentDeliveries(deliveries, now); + const delivery = deliveries.get(key); + deliveries.delete(key); + return delivery; +} + +function deleteSegmentDelivery(deliveries: Map, key: string) { + pruneSegmentDeliveries(deliveries); + deliveries.delete(key); +} + function hasCompleteSegmentDelivery( event: ChatDoneEvent, delivery: SegmentDelivery | undefined @@ -71,11 +128,13 @@ function hasCompleteSegmentDelivery( if (expected <= 0 || !delivery) return false; if (delivery.segments.size < expected) return false; + let reconstructed = ''; for (let i = 0; i < expected; i += 1) { const segment = delivery.segments.get(i); - if (segment === undefined || !event.full_response.includes(segment)) return false; + if (segment === undefined) return false; + reconstructed += segment; } - return true; + return reconstructed === event.full_response; } function chatDoneExtraMetadata(event: ChatDoneEvent): Record | undefined { @@ -533,11 +592,8 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => { return; const content = segmentText(event); const deliveryKey = segmentDeliveryKey(event.thread_id, event.request_id); - const delivery = segmentDeliveriesRef.current.get(deliveryKey) ?? { - segments: new Map(), - }; + const delivery = getOrCreateSegmentDelivery(segmentDeliveriesRef.current, deliveryKey); delivery.segments.set(event.segment_index, content); - segmentDeliveriesRef.current.set(deliveryKey, delivery); void dispatch( addInferenceResponse({ content, @@ -662,9 +718,8 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => { }); const deliveryKey = segmentDeliveryKey(event.thread_id, event.request_id); - const segmentDelivery = segmentDeliveriesRef.current.get(deliveryKey); + const segmentDelivery = takeSegmentDelivery(segmentDeliveriesRef.current, deliveryKey); const completeSegmentDelivery = hasCompleteSegmentDelivery(event, segmentDelivery); - segmentDeliveriesRef.current.delete(deliveryKey); dispatch( recordChatTurnUsage({ @@ -766,7 +821,10 @@ const ChatRuntimeProvider = ({ children }: { children: React.ReactNode }) => { err: event.error_type, }); - segmentDeliveriesRef.current.delete(segmentDeliveryKey(event.thread_id, event.request_id)); + deleteSegmentDelivery( + segmentDeliveriesRef.current, + segmentDeliveryKey(event.thread_id, event.request_id) + ); dispatch(clearInferenceStatusForThread({ threadId: event.thread_id })); dispatch(clearStreamingAssistantForThread({ threadId: event.thread_id })); diff --git a/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx b/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx index 31a0a04f2..0655b47e8 100644 --- a/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx +++ b/app/src/providers/__tests__/ChatRuntimeProvider.test.tsx @@ -293,7 +293,7 @@ describe('ChatRuntimeProvider — dedupe, proactive resolution, mid-turn invaria listeners.onSegment?.({ thread_id: 't-complete', request_id: 'r-complete', - full_response: 'Part one.', + full_response: 'Part one.\n\n', segment_index: 0, segment_total: 2, }); @@ -324,6 +324,85 @@ describe('ChatRuntimeProvider — dedupe, proactive resolution, mid-turn invaria expect(threadApi.appendMessage).toHaveBeenCalledTimes(2); }); + it('reconciles when segments are present but not in full_response order', async () => { + const listeners = renderProvider(); + + act(() => { + listeners.onSegment?.({ + thread_id: 't-out-of-order', + request_id: 'r-out-of-order', + full_response: 'Alpha', + segment_index: 0, + segment_total: 2, + }); + listeners.onSegment?.({ + thread_id: 't-out-of-order', + request_id: 'r-out-of-order', + full_response: 'Beta', + segment_index: 1, + segment_total: 2, + }); + }); + + await waitFor(() => expect(threadApi.appendMessage).toHaveBeenCalledTimes(2)); + + act(() => { + listeners.onDone?.({ + thread_id: 't-out-of-order', + request_id: 'r-out-of-order', + full_response: 'BetaAlpha', + rounds_used: 1, + total_input_tokens: 10, + total_output_tokens: 20, + segment_total: 2, + }); + }); + + await waitFor(() => + expect(threadApi.appendMessage).toHaveBeenCalledWith( + 't-out-of-order', + expect.objectContaining({ content: 'BetaAlpha', sender: 'agent' }) + ) + ); + expect(threadApi.appendMessage).toHaveBeenCalledTimes(3); + }); + + it('expires stale segment delivery state before chat_done reconciliation', async () => { + const nowSpy = vi.spyOn(Date, 'now').mockReturnValue(1_000); + const listeners = renderProvider(); + + try { + act(() => { + listeners.onSegment?.({ + thread_id: 't-stale', + request_id: 'r-stale', + full_response: 'Stale segment.', + segment_index: 0, + segment_total: 1, + }); + }); + + await waitFor(() => expect(threadApi.appendMessage).toHaveBeenCalledTimes(1)); + nowSpy.mockReturnValue(1_000 + 5 * 60 * 1000 + 1); + + act(() => { + listeners.onDone?.({ + thread_id: 't-stale', + request_id: 'r-stale', + full_response: 'Stale segment.', + rounds_used: 1, + total_input_tokens: 10, + total_output_tokens: 20, + segment_total: 1, + }); + }); + + await waitFor(() => expect(threadApi.appendMessage).toHaveBeenCalledTimes(2)); + } finally { + nowSpy.mockRestore(); + } + }); + it('accumulates text_delta chunks within the same request_id', () => { const listeners = renderProvider();