fix(chat): keep sends FIFO across the streaming-to-idle drain gap

This commit is contained in:
Siddharth Ganesan
2026-08-13 16:07:07 -07:00
parent 0cab4d0589
commit 0f3f2faeb3
2 changed files with 54 additions and 2 deletions
@@ -14,6 +14,7 @@ import {
reconcileLiveAssistantTurn,
selectReconnectReplayState,
shouldActivateResourceEvent,
shouldQueueOutgoingMessage,
waitForDetachedChatResolution,
} from '@/app/workspace/[workspaceId]/home/hooks/use-chat'
import type {
@@ -45,6 +46,27 @@ describe('shouldActivateResourceEvent', () => {
})
})
describe('shouldQueueOutgoingMessage', () => {
it('queues while a send is in flight', () => {
expect(shouldQueueOutgoingMessage(true, false, 0)).toBe(true)
})
it('queues while a stop is still settling', () => {
expect(shouldQueueOutgoingMessage(false, true, 0)).toBe(true)
})
it('queues behind messages still waiting after the turn ended', () => {
// The regression: a message queued mid-stream must dispatch before one
// typed in the idle gap after the turn stopped — a direct send here would
// jump the queue and swap the user's message order.
expect(shouldQueueOutgoingMessage(false, false, 1)).toBe(true)
})
it('sends directly on an idle chat with an empty queue', () => {
expect(shouldQueueOutgoingMessage(false, false, 0)).toBe(false)
})
})
function userMessage(id: string): PersistedMessage {
return {
id,
@@ -1202,6 +1202,25 @@ export function shouldActivateResourceEvent(
return options?.activate === true || !activeResourceId || activeResourceId === resourceId
}
/**
* Whether a fresh outbound message must join the chat's send queue instead of
* dispatching directly. Queueing while a send or stop is in flight is the
* obvious half; the queued-ahead term preserves FIFO across the
* streaming→idle boundary — a message queued while the previous turn streamed
* must reach the model before one typed after that turn ended but before the
* queue drained. Without it the fresh send jumps the queue and both the
* transcript and the model see the user's messages in swapped order. The two
* signals never gap mid-dispatch: a queued message stays in the queue until
* its optimistic send applies, which is after the in-flight flag is set.
*/
export function shouldQueueOutgoingMessage(
sendInFlight: boolean,
stopPending: boolean,
queuedAheadCount: number
): boolean {
return sendInFlight || stopPending || queuedAheadCount > 0
}
export interface UseChatOptions {
onResourceEvent?: ResourceEventHandler
apiPath?: string
@@ -4188,12 +4207,23 @@ export function useChat(
// An in-flight send drains the queue from `finalize`; a pending stop kicks
// the dispatcher itself, since nothing else will once the stop settles.
if (sendingRef.current || pendingStopPromiseRef.current) {
// A non-empty queue forces queueing even on an idle chat: messages
// queued while the previous turn streamed must go out first, so a fresh
// send lands behind them instead of jumping the line in the drain gap
// after a turn ends.
const queuedAheadCount = (queueStore.queues[activeChatKey] ?? EMPTY_MESSAGE_QUEUE).length
if (
shouldQueueOutgoingMessage(
Boolean(sendingRef.current),
Boolean(pendingStopPromiseRef.current),
queuedAheadCount
)
) {
queueStore.enqueue(
activeChatKey,
createQueuedMessage(message, fileAttachments, contexts, options?.resumeUserMessageId)
)
if (pendingStopPromiseRef.current) {
if (pendingStopPromiseRef.current || (queuedAheadCount > 0 && !sendingRef.current)) {
void enqueueQueueDispatchRef.current({ type: 'send_head' })
}
return