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:
parent
fe3a1cad6e
commit
0db11f9952
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -937,6 +937,7 @@ describe('resumeSession failure recovery', () => {
|
|||
interimBoundaryPending: false,
|
||||
interrupted: false,
|
||||
messages: [],
|
||||
adoptedRunningTurn: false,
|
||||
model: '',
|
||||
needsInput: false,
|
||||
pendingBranchGroup: null,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -52,6 +52,7 @@ export function createClientSessionState(
|
|||
awaitingResponse: false,
|
||||
streamId: null,
|
||||
sawAssistantPayload: false,
|
||||
adoptedRunningTurn: false,
|
||||
pendingBranchGroup: null,
|
||||
interrupted: false,
|
||||
interimBoundaryPending: false,
|
||||
|
|
|
|||
Loading…
Reference in New Issue