From 0db11f9952f97921810ba386c17cc71d55e7fb35 Mon Sep 17 00:00:00 2001 From: Brooklyn Nicholson Date: Sat, 8 Aug 2026 01:38:49 -0500 Subject: [PATCH] fix(desktop): keep the transcript whole when resuming a running session Resuming a session that is mid-turn somewhere else collapsed the thread down to the in-flight prompt, and the user's own message never appeared at all until a reload. Two wrong premises in the resume path, neither specific to any one surface: `omit_messages` was read as "empty transcript" rather than "no transcript in this response". Desktop asks session.resume/activate to omit messages because REST is the transcript authority, so mid-turn the live projection got reconciled against an empty list and rebuilt the thread out of itself. The response already carries `messages_omitted`; nothing read it. It now grafts the projection onto the cache (or the REST prefetch) instead. The settle path skipped hydration whenever the window had streamed the reply, on the assumption that streaming a reply means owning the whole turn. True for a turn you started, false for one you adopted: it arrives reply-first with no prompt row, and nothing ever backfilled it. Sessions now carry `adoptedRunningTurn`, set when a resume lands on an already-running turn and consumed when it settles. --- .../session/hooks/use-message-stream/index.ts | 11 ++++++- .../hooks/use-session-actions.test.tsx | 1 + .../hooks/use-session-actions/index.ts | 30 ++++++++++++++++--- apps/desktop/src/app/types.ts | 6 ++++ apps/desktop/src/lib/chat-runtime.ts | 1 + 5 files changed, 44 insertions(+), 5 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts index a73852f6e923f..979bbc1e2f5e2 100644 --- a/apps/desktop/src/app/session/hooks/use-message-stream/index.ts +++ b/apps/desktop/src/app/session/hooks/use-message-stream/index.ts @@ -656,12 +656,21 @@ export function useMessageStream({ const hasInlineError = nextMessages.some(m => m.role === 'assistant' && m.error && !m.hidden) const lastVisible = [...nextMessages].reverse().find(m => !m.hidden) const unresolvedUserTail = lastVisible?.role === 'user' + // Having streamed the reply normally means this window owns the whole + // turn and re-reading stored history would be wasted work. That only + // holds for a turn it STARTED: an adopted one (resumed onto a session + // already running elsewhere) arrives reply-first, with no prompt row, + // so it has to hydrate or the user's own message never shows up. shouldHydrate = - !completionError && !hasInlineError && !unresolvedUserTail && (!state.sawAssistantPayload || !finalText) + !completionError && + !hasInlineError && + !unresolvedUserTail && + (state.adoptedRunningTurn || !state.sawAssistantPayload || !finalText) return { ...state, messages: nextMessages, + adoptedRunningTurn: false, streamId: null, pendingBranchGroup: null, awaitingResponse: false, diff --git a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx index 1bdb521efabcb..cb3f6df916d2b 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx +++ b/apps/desktop/src/app/session/hooks/use-session-actions.test.tsx @@ -937,6 +937,7 @@ describe('resumeSession failure recovery', () => { interimBoundaryPending: false, interrupted: false, messages: [], + adoptedRunningTurn: false, model: '', needsInput: false, pendingBranchGroup: null, diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts index 0f13a375c0837..b09db251ccae7 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/index.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/index.ts @@ -735,8 +735,17 @@ export function useSessionActions({ } else { const runtimeInfo = applyRuntimeInfo(activated.info) - let activatedMessages = - activated.messages.length || activated.inflight || activated.queued + // `omit_messages` means the response carries NO transcript, not + // an empty one — the cache is the base and the live projection is + // a tail to graft onto it. Reconciling against the empty list + // instead rebuilds the thread out of the projection alone, so + // activating a session that is mid-turn somewhere else (leaving + // HUD mode is exactly that) collapsed the whole conversation down + // to the in-flight prompt until the turn finished and the + // post-turn hydrate restored it. + let activatedMessages = activated.messages_omitted + ? appendLiveSessionProjection(cachedViewState.messages, activated) + : activated.messages.length || activated.inflight || activated.queued ? reconcileAuthoritativeMessages(activated.messages, cachedViewState.messages, activated) : cachedViewState.messages @@ -773,7 +782,11 @@ export function useSessionActions({ ...(runtimeInfo ?? {}), messages: activatedMessages, busy: running, - awaitingResponse: running + awaitingResponse: running, + // Adopting someone else's turn: we'll stream its reply + // without ever having received its prompt, so the settle + // path must not take the "I saw it all" shortcut. + adoptedRunningTurn: state.adoptedRunningTurn || running }), storedSessionId ) @@ -933,7 +946,15 @@ export function useSessionActions({ ? preserveLocalPendingTurnMessages(currentMessages, resumeStartMessages) : currentMessages - const resumedMessages = reconcileAuthoritativeMessages(resumed.messages, previousMessages, resumed) + // Omitted, not empty — same trap as the activate path above. + // The REST prefetch IS the transcript here; the resume payload + // only contributes the live tail, so graft rather than rebuild. + // (Without a usable prefetch there is nothing better to stand + // on, so the projection alone remains the degraded fallback.) + const resumedMessages = + resumed.messages_omitted && prefetchApplied && prefetchMatchesResumedSession + ? appendLiveSessionProjection(localSnapshot, resumed) + : reconcileAuthoritativeMessages(resumed.messages, previousMessages, resumed) return chatMessageArraysEquivalent(currentMessages, resumedMessages) ? currentMessages : resumedMessages })() @@ -990,6 +1011,7 @@ export function useSessionActions({ messages: messagesForView, busy: resumedRunning, awaitingResponse: resumedRunning && !recoveredInFlightTail, + adoptedRunningTurn: state.adoptedRunningTurn || resumedRunning, ...(inFlightRecovery.applied ? { sawAssistantPayload: true, diff --git a/apps/desktop/src/app/types.ts b/apps/desktop/src/app/types.ts index f7452607f7c83..743c2b95be019 100644 --- a/apps/desktop/src/app/types.ts +++ b/apps/desktop/src/app/types.ts @@ -187,6 +187,12 @@ export interface ClientSessionState { awaitingResponse: boolean streamId: string | null sawAssistantPayload: boolean + /** This window picked up a turn it did not start — it resumed onto a session + * that was already running somewhere else (leaving HUD mode, opening a + * pop-out mid-turn). It therefore holds the reply but never received the + * prompt, so the usual "I streamed it, my transcript is complete" shortcut + * is false and the turn must hydrate from stored history when it settles. */ + adoptedRunningTurn: boolean pendingBranchGroup: string | null interrupted: boolean /** True after message.interim finalized a bubble in the still-running turn. */ diff --git a/apps/desktop/src/lib/chat-runtime.ts b/apps/desktop/src/lib/chat-runtime.ts index 99585a3c7642f..c581daaea9c7e 100644 --- a/apps/desktop/src/lib/chat-runtime.ts +++ b/apps/desktop/src/lib/chat-runtime.ts @@ -52,6 +52,7 @@ export function createClientSessionState( awaitingResponse: false, streamId: null, sawAssistantPayload: false, + adoptedRunningTurn: false, pendingBranchGroup: null, interrupted: false, interimBoundaryPending: false,