From b336664e8dfcea178fd3aa35dbe6f382030771a4 Mon Sep 17 00:00:00 2001 From: jh-block Date: Wed, 18 Mar 2026 18:00:06 +0100 Subject: [PATCH 1/4] fix: reset refs on session switch, prevent overflow duplication, sort active requests - Clear all request-tracking refs (active, pending, buffer) in the session-load effect cleanup so switching sessions doesn't leak state from the previous session. - Clear active request state in reloadConversation so the SSE reconnect doesn't reattach and replay deltas on top of the freshly-loaded conversation after a buffer overflow. - Sort ActiveRequests IDs lexicographically (uuidv7 is time-ordered) to always pick the newest reply, not an arbitrary HashMap key. --- ui/desktop/src/hooks/useChatStream.ts | 29 +++++++++++++++++++++++++-- 1 file changed, 27 insertions(+), 2 deletions(-) diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts index 6a94b0933888..c520ae14e5dc 100644 --- a/ui/desktop/src/hooks/useChatStream.ts +++ b/ui/desktop/src/hooks/useChatStream.ts @@ -453,6 +453,18 @@ export function useChatStream({ // Reload the full conversation from the server, e.g. after the SSE // stream indicates the client fell too far behind the replay buffer. const reloadConversation = useCallback(() => { + // Clear active request state so the SSE reconnect doesn't reattach + // to the same request and replay buffered deltas on top of the + // freshly-loaded conversation. + if (activeUnsubscribeRef.current) { + activeUnsubscribeRef.current(); + activeUnsubscribeRef.current = null; + } + activeRequestIdRef.current = null; + activeRequestSessionIdRef.current = null; + pendingReattachRequestIdRef.current = null; + pendingReattachBufferRef.current = []; + getSession({ path: { session_id: sessionId }, throwOnError: true, @@ -540,9 +552,11 @@ export function useChatStream({ if (activeRequestIdRef.current) return; if (requestIds.length === 0) return; - // Reattach to the first (most recent) active request. + // Reattach to the most recent active request (uuidv7 is time-ordered, + // so the lexicographically largest ID is the newest). // Multiple concurrent requests per session aren't supported in the UI. - const requestId = requestIds[0]; + const sorted = [...requestIds].sort(); + const requestId = sorted[sorted.length - 1]; const currentMessages = stateRef.current.messages; if (currentMessages.length === 0) { @@ -778,6 +792,17 @@ export function useChatStream({ return () => { cancelled = true; + // Reset request-tracking state so the previous session's in-flight + // request doesn't leak into the next session. + if (activeUnsubscribeRef.current) { + activeUnsubscribeRef.current(); + activeUnsubscribeRef.current = null; + } + activeRequestIdRef.current = null; + activeRequestSessionIdRef.current = null; + activeAbortRef.current = null; + pendingReattachRequestIdRef.current = null; + pendingReattachBufferRef.current = []; }; }, [sessionId, onSessionLoaded]); From 4471aacaad1db3aa99e23a407ad1f5ff70d9f06a Mon Sep 17 00:00:00 2001 From: jh-block Date: Wed, 18 Mar 2026 20:49:06 +0100 Subject: [PATCH 2/4] fix: move session-switch cleanup to dedicated effect, suppress reattach during reload - Extract request-tracking ref cleanup into a separate useEffect so it runs on ALL session changes, including cache hits (previously the cleanup was inside the resumeAgent branch and skipped for cached sessions) - Add reloadingConversationRef flag to suppress ActiveRequests reattach while getSession is in flight during buffer overflow recovery, preventing replayed deltas from being appended to stale messages --- ui/desktop/src/hooks/useChatStream.ts | 39 +++++++++++++++++++-------- 1 file changed, 28 insertions(+), 11 deletions(-) diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts index c520ae14e5dc..031cb76a5c0f 100644 --- a/ui/desktop/src/hooks/useChatStream.ts +++ b/ui/desktop/src/hooks/useChatStream.ts @@ -367,6 +367,10 @@ export function useChatStream({ // the full conversation history. Events are buffered in the meantime. const pendingReattachRequestIdRef = useRef(null); const pendingReattachBufferRef = useRef([]); + // Suppress ActiveRequests reattach while reloading conversation after + // buffer overflow — the reload replaces messages and we don't want the + // SSE reconnect to reattach with stale state before that completes. + const reloadingConversationRef = useRef(false); const namePollingRef = useRef | null>(null); // Ref to access latest state in callbacks (avoids stale closures) @@ -464,6 +468,8 @@ export function useChatStream({ activeRequestSessionIdRef.current = null; pendingReattachRequestIdRef.current = null; pendingReattachBufferRef.current = []; + // Suppress reattach until the reload completes + reloadingConversationRef.current = true; getSession({ path: { session_id: sessionId }, @@ -475,6 +481,8 @@ export function useChatStream({ } }).catch((e) => { console.warn('Failed to reload conversation after buffer overflow:', e); + }).finally(() => { + reloadingConversationRef.current = false; }); }, [sessionId]); @@ -550,6 +558,8 @@ export function useChatStream({ setActiveRequestsHandler((requestIds: string[]) => { // Only reattach if we don't already have an active request if (activeRequestIdRef.current) return; + // Don't reattach while reloading after buffer overflow + if (reloadingConversationRef.current) return; if (requestIds.length === 0) return; // Reattach to the most recent active request (uuidv7 is time-ordered, @@ -666,6 +676,24 @@ export function useChatStream({ [addListener, onFinish, reloadConversation] ); + // Reset request-tracking state when switching sessions so the previous + // session's in-flight request doesn't leak into the next one. + // This runs for ALL session changes (including cache hits). + useEffect(() => { + return () => { + if (activeUnsubscribeRef.current) { + activeUnsubscribeRef.current(); + activeUnsubscribeRef.current = null; + } + activeRequestIdRef.current = null; + activeRequestSessionIdRef.current = null; + activeAbortRef.current = null; + pendingReattachRequestIdRef.current = null; + pendingReattachBufferRef.current = []; + reloadingConversationRef.current = false; + }; + }, [sessionId]); + // Load session on mount or sessionId change useEffect(() => { if (!sessionId) return; @@ -792,17 +820,6 @@ export function useChatStream({ return () => { cancelled = true; - // Reset request-tracking state so the previous session's in-flight - // request doesn't leak into the next session. - if (activeUnsubscribeRef.current) { - activeUnsubscribeRef.current(); - activeUnsubscribeRef.current = null; - } - activeRequestIdRef.current = null; - activeRequestSessionIdRef.current = null; - activeAbortRef.current = null; - pendingReattachRequestIdRef.current = null; - pendingReattachBufferRef.current = []; }; }, [sessionId, onSessionLoaded]); From 56ed38bb2fc421b382e273b8f3e974cc7746af42 Mon Sep 17 00:00:00 2001 From: jh-block Date: Wed, 18 Mar 2026 21:23:59 +0100 Subject: [PATCH 3/4] fix: buffer ActiveRequests during overflow reload instead of dropping ActiveRequests is only sent once per SSE connection (before replay). If it arrives while reloadConversation's getSession is in flight, we now buffer events using the same cold-mount pattern instead of discarding the request IDs. Once the reload completes, the deferred reattach runs with the fresh conversation snapshot. --- ui/desktop/src/hooks/useChatStream.ts | 24 +++++++++++++++--------- 1 file changed, 15 insertions(+), 9 deletions(-) diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts index 031cb76a5c0f..26dc24f24bda 100644 --- a/ui/desktop/src/hooks/useChatStream.ts +++ b/ui/desktop/src/hooks/useChatStream.ts @@ -476,12 +476,19 @@ export function useChatStream({ throwOnError: true, }).then((response) => { const session = response.data as Session; - if (session?.conversation) { - dispatch({ type: 'SET_MESSAGES', payload: session.conversation }); + const messages = session?.conversation || []; + if (messages.length > 0) { + dispatch({ type: 'SET_MESSAGES', payload: messages }); + } + reloadingConversationRef.current = false; + // If ActiveRequests arrived during the reload, complete the + // deferred reattach now that we have fresh messages. + const pendingRequestId = pendingReattachRequestIdRef.current; + if (pendingRequestId) { + doReattachRef.current?.(pendingRequestId, messages); } }).catch((e) => { console.warn('Failed to reload conversation after buffer overflow:', e); - }).finally(() => { reloadingConversationRef.current = false; }); }, [sessionId]); @@ -558,8 +565,6 @@ export function useChatStream({ setActiveRequestsHandler((requestIds: string[]) => { // Only reattach if we don't already have an active request if (activeRequestIdRef.current) return; - // Don't reattach while reloading after buffer overflow - if (reloadingConversationRef.current) return; if (requestIds.length === 0) return; // Reattach to the most recent active request (uuidv7 is time-ordered, @@ -569,10 +574,11 @@ export function useChatStream({ const requestId = sorted[sorted.length - 1]; const currentMessages = stateRef.current.messages; - if (currentMessages.length === 0) { - // Cold mount: resumeAgent hasn't populated messages yet. - // Defer event processing until session load completes so the - // processor starts with the full conversation history. + if (currentMessages.length === 0 || reloadingConversationRef.current) { + // Either cold mount (resumeAgent hasn't populated messages) or + // overflow reload (getSession is replacing stale messages). + // Defer event processing until the load completes so the + // processor starts with the correct conversation history. // Register a buffering listener NOW so replayed events aren't // lost while we wait. pendingReattachRequestIdRef.current = requestId; From 351e17150b2dd5f3ef0e7c07cdefc7d17a31ae33 Mon Sep 17 00:00:00 2001 From: jh-block Date: Wed, 18 Mar 2026 21:56:38 +0100 Subject: [PATCH 4/4] fix: scope overflow reload to originating session, recover on failure - Capture sessionId at reload start and guard the .then()/.catch() callbacks so a stale reload from session A can't corrupt session B after a session switch - On getSession failure, attempt best-effort reattach with current messages so a transient error doesn't permanently lose the in-flight reply --- ui/desktop/src/hooks/useChatStream.ts | 20 ++++++++++++++++++-- 1 file changed, 18 insertions(+), 2 deletions(-) diff --git a/ui/desktop/src/hooks/useChatStream.ts b/ui/desktop/src/hooks/useChatStream.ts index 26dc24f24bda..9c58aad291e5 100644 --- a/ui/desktop/src/hooks/useChatStream.ts +++ b/ui/desktop/src/hooks/useChatStream.ts @@ -373,9 +373,11 @@ export function useChatStream({ const reloadingConversationRef = useRef(false); const namePollingRef = useRef | null>(null); - // Ref to access latest state in callbacks (avoids stale closures) + // Ref to access latest state/sessionId in callbacks (avoids stale closures) const stateRef = useRef(state); stateRef.current = state; + const sessionIdRef = useRef(sessionId); + sessionIdRef.current = sessionId; const doReattachRef = useRef<((requestId: string, messages: Message[]) => void) | null>(null); useEffect(() => { @@ -471,10 +473,17 @@ export function useChatStream({ // Suppress reattach until the reload completes reloadingConversationRef.current = true; + // Capture the session ID so we can guard against session switches + // that happen while getSession is in flight. + const reloadSessionId = sessionId; + getSession({ - path: { session_id: sessionId }, + path: { session_id: reloadSessionId }, throwOnError: true, }).then((response) => { + // Session switched while we were reloading — discard stale result + if (reloadSessionId !== sessionIdRef.current) return; + const session = response.data as Session; const messages = session?.conversation || []; if (messages.length > 0) { @@ -490,6 +499,13 @@ export function useChatStream({ }).catch((e) => { console.warn('Failed to reload conversation after buffer overflow:', e); reloadingConversationRef.current = false; + // Best-effort recovery: if ActiveRequests arrived during the failed + // reload, reattach with current messages so the reply isn't lost. + if (reloadSessionId !== sessionIdRef.current) return; + const pendingRequestId = pendingReattachRequestIdRef.current; + if (pendingRequestId) { + doReattachRef.current?.(pendingRequestId, stateRef.current.messages); + } }); }, [sessionId]);