Skip to content
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
84 changes: 74 additions & 10 deletions ui/desktop/src/hooks/useChatStream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -367,11 +367,17 @@ export function useChatStream({
// the full conversation history. Events are buffered in the meantime.
const pendingReattachRequestIdRef = useRef<string | null>(null);
const pendingReattachBufferRef = useRef<SessionEvent[]>([]);
// 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<ReturnType<typeof setTimeout> | 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(() => {
Expand Down Expand Up @@ -453,16 +459,53 @@ 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 = [];
Comment thread
jh-block marked this conversation as resolved.
// 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;
Comment on lines +484 to +485
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Reject stale overflow reloads from an earlier visit to the same session

This guard only compares the stored session ID to sessionIdRef.current, so a getSession() started during an earlier visit to session A is still accepted if the user navigates A → B → A before it resolves. Unlike the resumeAgent effect below, there is no per-load cancellation token, so that late callback can overwrite the newer A state with an outdated snapshot and optionally run a deferred reattach against the wrong generation of the chat.

Useful? React with 👍 / 👎.


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;
Comment on lines +489 to +492
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Keep overflow reload suppressed until the new messages render

Fresh evidence in this follow-up patch: reloadingConversationRef is cleared here before React has committed the SET_MESSAGES dispatch, and stateRef.current is only refreshed on the next render (ui/desktop/src/hooks/useChatStream.ts:377-380). If the reconnected SSE delivers ActiveRequests in that gap, the handler at ui/desktop/src/hooks/useChatStream.ts:593-615 stops buffering and calls doReattach(...) with the stale pre-overflow messages, so replayed deltas get appended to the wrong snapshot again.

Useful? React with 👍 / 👎.

// 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);
Comment on lines +495 to +497
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Scope deferred overflow reattach to the session being reloaded

If the user switches from session A to session B while reloadConversation() for A is still waiting on getSession(), these lines read pendingReattachRequestIdRef and doReattachRef after those shared refs have already been repointed at B by the new render. Unlike the resumeAgent load effect later in this file, reloadConversation has no cancellation/session guard, so A's stale reload can invoke doReattach for B with A's message snapshot and corrupt the newly opened chat.

Useful? React with 👍 / 👎.

}
}).catch((e) => {
console.warn('Failed to reload conversation after buffer overflow:', e);
reloadingConversationRef.current = false;
Comment on lines 499 to +501
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Recover buffered events when the overflow reload fetch fails

By the time execution reaches this catch, reloadConversation() has already unsubscribed the active processor and cleared the request refs. If /sessions/{id} transiently fails during buffer-overflow recovery, the replacement listener created by ActiveRequests is still only appending to pendingReattachBufferRef, but this path never calls doReattach or restores the old listener. Because ActiveRequests is only sent once per SSE connection, the in-flight reply can stay permanently stuck until the user remounts the session.

Useful? React with 👍 / 👎.

// 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]);

Expand Down Expand Up @@ -540,15 +583,18 @@ 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) {
// 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;
Expand Down Expand Up @@ -652,6 +698,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;
Expand Down
Loading