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.
This commit is contained in:
Brooklyn Nicholson 2026-08-08 01:38:49 -05:00
parent fe3a1cad6e
commit 0db11f9952
5 changed files with 44 additions and 5 deletions

View File

@ -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,

View File

@ -937,6 +937,7 @@ describe('resumeSession failure recovery', () => {
interimBoundaryPending: false,
interrupted: false,
messages: [],
adoptedRunningTurn: false,
model: '',
needsInput: false,
pendingBranchGroup: null,

View File

@ -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,

View File

@ -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. */

View File

@ -52,6 +52,7 @@ export function createClientSessionState(
awaitingResponse: false,
streamId: null,
sawAssistantPayload: false,
adoptedRunningTurn: false,
pendingBranchGroup: null,
interrupted: false,
interimBoundaryPending: false,