diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx index 1da332aca6..ec00904c9c 100644 --- a/apps/desktop/src/app/contrib/wiring.tsx +++ b/apps/desktop/src/app/contrib/wiring.tsx @@ -276,6 +276,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { activeSessionIdRef, ensureSessionState, getRuntimeIdForStoredSession, + holdSessionTranscriptView, resetViewSync, runtimeIdByStoredSessionIdRef, selectedStoredSessionIdRef, @@ -494,6 +495,7 @@ export function ContribWiring({ children }: { children: ReactNode }) { ensureSessionState, getRouteToken, getRoutedStoredSessionId, + holdSessionTranscriptView, navigate, onFreshDraftRouteIntent: clearRoutedSessionIntent, requestGateway, diff --git a/apps/desktop/src/app/session/hooks/profile-rail-fresh-chat-owner.test.tsx b/apps/desktop/src/app/session/hooks/profile-rail-fresh-chat-owner.test.tsx index a592fe0cf9..c65106dff4 100644 --- a/apps/desktop/src/app/session/hooks/profile-rail-fresh-chat-owner.test.tsx +++ b/apps/desktop/src/app/session/hooks/profile-rail-fresh-chat-owner.test.tsx @@ -318,6 +318,7 @@ function Harness({ selectedStoredSessionId, selectedStoredSessionIdRef: cache.selectedStoredSessionIdRef, sessionStateByRuntimeIdRef: cache.sessionStateByRuntimeIdRef, + holdSessionTranscriptView: cache.holdSessionTranscriptView, syncSessionStateToView: cache.syncSessionStateToView, updateSessionState: cache.updateSessionState }) 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 fca3f31346..45dba0dc20 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 @@ -729,6 +729,7 @@ describe('createBackendSessionForSend profile routing', () => { // succeeds must NOT leave the flag armed. function ResumeHarness({ onStateUpdate, + onViewSync, onReady, requestGateway, runtimeIdByStoredSessionIdRef, @@ -736,6 +737,7 @@ function ResumeHarness({ sessionStateByRuntimeIdRef }: { onStateUpdate?: (sessionId: string, state: ClientSessionState) => void + onViewSync?: (sessionId: string, state: ClientSessionState) => void onReady: ( resume: (storedSessionId: string, replaceRoute?: boolean, ownerRoute?: SessionProfileRoute) => Promise ) => void @@ -763,7 +765,7 @@ function ResumeHarness({ selectedStoredSessionId, selectedStoredSessionIdRef: ref(selectedStoredSessionId), sessionStateByRuntimeIdRef: stateMapRef, - syncSessionStateToView: vi.fn(), + syncSessionStateToView: (sessionId, state) => onViewSync?.(sessionId, state), updateSessionState: (sessionId, updater, storedSessionId) => { // Full default shape (not a bare {} cast) so seeded/derived fields like // turnStartedAt behave as in production state updates. @@ -817,6 +819,7 @@ function ResumeTimerHarness({ selectedStoredSessionId: null, selectedStoredSessionIdRef: cache.selectedStoredSessionIdRef, sessionStateByRuntimeIdRef: cache.sessionStateByRuntimeIdRef, + holdSessionTranscriptView: cache.holdSessionTranscriptView, syncSessionStateToView: cache.syncSessionStateToView, getRoutedStoredSessionId: () => null, updateSessionState: cache.updateSessionState @@ -3485,6 +3488,102 @@ describe('resumeSession warm-cache mapping integrity', () => { expect(runtimeIdByStoredSessionIdRef.current.get('stored-A')).toBe('rt-A') expect(sessionStateByRuntimeIdRef.current.get('rt-A')?.messages[0]?.id).toBe('user-optimistic') }) + + it('never publishes a tail-only warm cache before the full persisted history', async () => { + const cachedState = clientState('stored-1') + cachedState.messages = [ + { + id: 'user-latest', + role: 'user', + parts: [{ type: 'text', text: 'latest question after long-context completion' }] + }, + { + id: 'assistant-latest', + role: 'assistant', + parts: [{ type: 'text', text: 'latest answer after long-context completion' }] + } + ] + + const runtimeIdByStoredSessionIdRef = { + current: new Map([['stored-1', 'runtime-warm']]) + } satisfies MutableRefObject> + const sessionStateByRuntimeIdRef = { + current: new Map([['runtime-warm', cachedState]]) + } satisfies MutableRefObject> + const persistedAuthority = deferred<{ + messages: Array<{ content: string; role: 'assistant' | 'user'; timestamp: number }> + session_id: string + }>() + const publications: Array<{ older: boolean; latest: boolean }> = [] + + setSessions([storedSession({ message_count: 4 })]) + vi.mocked(getLatestSessionMessages).mockReturnValue(persistedAuthority.promise as never) + + const requestGateway = vi.fn(async (method: string) => { + if (method === 'session.activate') { + return { + info: {}, + message_count: 2, + messages: [], + messages_omitted: true, + resumed: 'stored-1', + running: false, + session_id: 'runtime-warm', + session_key: 'stored-1' + } as never + } + + return {} as never + }) + + let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise) | null = null + render( + (resume = ready)} + onViewSync={(_sessionId, state) => { + const snapshot = JSON.stringify(state.messages) + publications.push({ + latest: snapshot.includes('latest question after long-context completion'), + older: snapshot.includes('earlier question before long-context completion') + }) + }} + requestGateway={requestGateway} + runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef} + sessionStateByRuntimeIdRef={sessionStateByRuntimeIdRef} + /> + ) + await waitFor(() => expect(resume).not.toBeNull()) + + const resumePromise = resume!('stored-1', true) + await waitFor(() => + expect(requestGateway).toHaveBeenCalledWith( + 'session.activate', + expect.objectContaining({ omit_messages: true, session_id: 'runtime-warm' }) + ) + ) + + expect( + publications.filter(snapshot => snapshot.latest && !snapshot.older), + `Tail-only warm-cache publication escaped before persisted authority: ${JSON.stringify(publications)}` + ).toEqual([]) + + persistedAuthority.resolve({ + messages: [ + { content: 'earlier question before long-context completion', role: 'user', timestamp: 1 }, + { content: 'earlier answer before long-context completion', role: 'assistant', timestamp: 2 }, + { content: 'latest question after long-context completion', role: 'user', timestamp: 3 }, + { content: 'latest answer after long-context completion', role: 'assistant', timestamp: 4 } + ], + session_id: 'stored-1' + }) + await resumePromise + + expect(publications.some(snapshot => snapshot.latest && snapshot.older)).toBe(true) + expect( + publications.filter(snapshot => snapshot.latest && !snapshot.older), + `Tail-only warm-cache publication escaped: ${JSON.stringify(publications)}` + ).toEqual([]) + }) }) describe('createBackendSessionForSend workspace target', () => { 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 a7b3446c38..5e7f506530 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 @@ -133,6 +133,12 @@ import { sessionContextDrift } from '../session-context-drift' import { singleFlightSessionResume } from '../use-prompt-actions/single-flight-resume' import { pendingClarifyToolPayload, restorePendingClarifyFromSnapshot } from './restore-pending-clarify' +import { + createPersistedDisplayTranscriptProvenance, + hasPersistedDisplayTranscriptProvenance, + suppressTranscriptForView, + withoutTranscriptProvenance +} from './transcript-provenance' import { appendLiveSessionProjection, applyRuntimeInfo, @@ -166,9 +172,10 @@ interface SessionActionsOptions { busyRef: MutableRefObject creatingSessionRef: MutableRefObject ensureSessionState: (sessionId: string, storedSessionId?: string | null) => ClientSessionState - getRouteToken: () => string - getRoutedStoredSessionId: () => null | string - navigate: NavigateFunction + getRouteToken: () => string + getRoutedStoredSessionId: () => null | string + holdSessionTranscriptView?: (runtimeId: string) => () => void + navigate: NavigateFunction onFreshDraftRouteIntent?: () => void requestGateway: (method: string, params?: Record) => Promise resetViewSync: () => void @@ -310,6 +317,7 @@ export function useSessionActions({ ensureSessionState, getRouteToken, getRoutedStoredSessionId, + holdSessionTranscriptView, navigate, onFreshDraftRouteIntent, requestGateway, @@ -1000,19 +1008,53 @@ export function useSessionActions({ publishSessionState(cachedRuntimeId, cachedViewState) } + const expectedProvenance = stored + ? createPersistedDisplayTranscriptProvenance({ + lineageRootId: stored._lineage_root_id ?? null, + scope: sessionRestScope, + storedSessionId + }) + : null + + const hasValidProvenance = Boolean( + expectedProvenance && hasPersistedDisplayTranscriptProvenance(cachedViewState, expectedProvenance) + ) + + if (!hasValidProvenance) { + cachedViewState = withoutTranscriptProvenance(cachedViewState) + } + if (sessionShouldHaveTranscript(stored) && cachedViewState.messages.length === 0) { runtimeIdByStoredSessionIdRef.current.delete(storedSessionId) sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId) dropSessionState(cachedRuntimeId) } else { - // Paint the warm cache immediately. The persisted transcript still - // needs a refresh because a resumed runtime may carry only the - // agent's compressed projection, but that read must start after - // session.activate reattaches the live transport. Otherwise a turn - // can finish between the early REST snapshot and the reattach: its - // terminal events go to the detached socket while the stale snapshot - // leaves Desktop showing only the pre-disconnect partial answer. + // Bind the warm runtime immediately so cwd/workspace ownership don't + // wait on session.activate (#71254). Unproven cache entries (no + // persisted-display provenance) stay off the view until REST + // authority lands — a compressed runtime tail is legal in cache and + // is exactly the session-switch flicker (#73646). Proven caches and + // same-session re-resumes still paint immediately. The persisted + // refresh itself still starts after activate reattaches the live + // transport, so a turn finishing between snapshot and reattach + // cannot leave a stale partial on screen. const shouldRefreshPersistedTranscript = !isWatchWindow() + const suppressUnprovenWarmTranscript = + !resumedSameSelectedSession && shouldRefreshPersistedTranscript && !hasValidProvenance + + let releaseHeldTranscriptView = suppressUnprovenWarmTranscript + ? holdSessionTranscriptView?.(cachedRuntimeId) + : undefined + + const releaseTranscriptView = () => { + releaseHeldTranscriptView?.() + releaseHeldTranscriptView = undefined + } + + const publishDegradedWarmCache = () => { + releaseTranscriptView() + syncSessionStateToView(cachedRuntimeId, cachedViewState) + } setFreshDraftReady(false) clearNotifications() @@ -1020,7 +1062,10 @@ export function useSessionActions({ selectedStoredSessionIdRef.current = storedSessionId setActiveSessionId(cachedRuntimeId) activeSessionIdRef.current = cachedRuntimeId - syncSessionStateToView(cachedRuntimeId, cachedViewState) + syncSessionStateToView( + cachedRuntimeId, + suppressTranscriptForView(cachedViewState, suppressUnprovenWarmTranscript) + ) setCurrentCwdTransient(cachedViewState.cwd) // The warm cache IS this conversation's own workspace truth, so the // switch is already re-homed here. This claim cannot wait for @@ -1061,6 +1106,8 @@ export function useSessionActions({ setCurrentUsage(current => ({ ...current, ...usage })) } + publishDegradedWarmCache() + return } @@ -1160,7 +1207,10 @@ export function useSessionActions({ busyRef.current = running setBusy(running) setAwaitingResponse(running && !pendingClarify) - syncSessionStateToView(cachedRuntimeId, activatedLivenessState) + syncSessionStateToView( + cachedRuntimeId, + suppressTranscriptForView(activatedLivenessState, suppressUnprovenWarmTranscript) + ) // session.activate is the ordering barrier for reconnect recovery: // it atomically rebinds a running turn before returning. If the @@ -1178,6 +1228,8 @@ export function useSessionActions({ // which is intentionally smaller than the user-visible conversation. // Reconcile its in-flight/queued tail onto the complete transcript // instead of replacing durable history while the turn is running. + let acceptedPersistedDisplayTranscript = false + if (persistedTranscriptPromise) { const persisted = await persistedTranscriptPromise @@ -1203,6 +1255,7 @@ export function useSessionActions({ persistedMatchesActivatedSession && (persisted.messages.length || !activatedMessages.length) ) { + acceptedPersistedDisplayTranscript = Boolean(expectedProvenance) // The REST hydration is a newest-tail page; graft it onto any // older pages the previous view already backfilled so // re-activating a scrolled-back session keeps its history. @@ -1253,11 +1306,17 @@ export function useSessionActions({ const visibleActivatedMessages = pendingClarifyProjection?.messages ?? clearedClarifyProjection?.messages ?? activatedMessages + releaseTranscriptView() + const activatedState = updateSessionState( cachedRuntimeId, state => ({ ...state, messages: visibleActivatedMessages, + transcriptProvenance: + acceptedPersistedDisplayTranscript || hasValidProvenance + ? (expectedProvenance ?? undefined) + : undefined, ...(pendingClarifyProjection ? { awaitingResponse: false, @@ -1304,12 +1363,16 @@ export function useSessionActions({ } if (!isSessionGoneError(error)) { + publishDegradedWarmCache() + return } runtimeIdByStoredSessionIdRef.current.delete(storedSessionId) sessionStateByRuntimeIdRef.current.delete(cachedRuntimeId) dropSessionState(cachedRuntimeId) + } finally { + releaseTranscriptView() } } } @@ -1854,6 +1917,7 @@ export function useSessionActions({ activeSessionIdRef, busyRef, copy, + holdSessionTranscriptView, requestGateway, resetViewSync, runtimeIdByStoredSessionIdRef, diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/transcript-provenance.test.ts b/apps/desktop/src/app/session/hooks/use-session-actions/transcript-provenance.test.ts new file mode 100644 index 0000000000..ef855fb7ba --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-session-actions/transcript-provenance.test.ts @@ -0,0 +1,59 @@ +import { describe, expect, it } from 'vitest' + +import { createClientSessionState } from '@/lib/chat-runtime' + +import { + createPersistedDisplayTranscriptProvenance, + hasPersistedDisplayTranscriptProvenance, + invalidatePersistedDisplayTranscriptAuthority, + suppressTranscriptForView, + withoutTranscriptProvenance +} from './transcript-provenance' + +const expected = createPersistedDisplayTranscriptProvenance({ + lineageRootId: 'root-1', + scope: { connectionId: 'conn-1', profile: 'coder' }, + storedSessionId: 'stored-1' +}) + +describe('transcript provenance', () => { + it('matches only the same connection, profile, stored id, and lineage', () => { + const state = createClientSessionState('stored-1') + state.transcriptProvenance = expected + + expect(hasPersistedDisplayTranscriptProvenance(state, expected)).toBe(true) + expect( + hasPersistedDisplayTranscriptProvenance(state, { + ...expected, + lineageRootId: 'root-2' + }) + ).toBe(false) + expect( + hasPersistedDisplayTranscriptProvenance(state, { + ...expected, + profile: 'default' + }) + ).toBe(false) + }) + + it('strips proof and bumps the authority epoch on invalidation', () => { + const state = createClientSessionState('stored-1') + state.transcriptProvenance = expected + state.transcriptAuthorityEpoch = 3 + + const next = invalidatePersistedDisplayTranscriptAuthority(state) + + expect(next.transcriptProvenance).toBeUndefined() + expect(next.transcriptAuthorityEpoch).toBe(4) + expect(withoutTranscriptProvenance(state).transcriptProvenance).toBeUndefined() + }) + + it('hides messages from the view without dropping the cache entry', () => { + const state = createClientSessionState('stored-1') + state.messages = [{ id: 'u1', role: 'user', parts: [{ type: 'text', text: 'hi' }] }] + + expect(suppressTranscriptForView(state, false)).toBe(state) + expect(suppressTranscriptForView(state, true).messages).toEqual([]) + expect(state.messages).toHaveLength(1) + }) +}) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/transcript-provenance.ts b/apps/desktop/src/app/session/hooks/use-session-actions/transcript-provenance.ts new file mode 100644 index 0000000000..7cd8c3d549 --- /dev/null +++ b/apps/desktop/src/app/session/hooks/use-session-actions/transcript-provenance.ts @@ -0,0 +1,69 @@ +import type { ClientSessionState, PersistedDisplayTranscriptProvenance } from '../../../types' + +export type TranscriptProvenanceScope = + string | null | undefined | { connectionId?: string | null; profile?: string | null } + +export function createPersistedDisplayTranscriptProvenance({ + lineageRootId, + scope, + storedSessionId +}: { + storedSessionId: string + lineageRootId: string | null + scope: TranscriptProvenanceScope +}): PersistedDisplayTranscriptProvenance { + const connectionId = typeof scope === 'object' && scope ? (scope.connectionId ?? '').trim() : '' + const rawProfile = typeof scope === 'string' ? scope : scope?.profile + + return { + connectionId, + coverage: 'latest-page', + lineageRootId, + profile: rawProfile?.trim() || 'default', + source: 'persisted-display', + storedSessionId + } +} + +export function hasPersistedDisplayTranscriptProvenance( + state: Pick, + expected: PersistedDisplayTranscriptProvenance +): boolean { + const actual = state.transcriptProvenance + + return Boolean( + actual && + actual.source === expected.source && + actual.connectionId === expected.connectionId && + actual.profile === expected.profile && + actual.storedSessionId === expected.storedSessionId && + actual.lineageRootId === expected.lineageRootId && + actual.coverage === expected.coverage + ) +} + +export function withoutTranscriptProvenance(state: ClientSessionState): ClientSessionState { + if (!state.transcriptProvenance) { + return state + } + + const { transcriptProvenance: _transcriptProvenance, ...withoutProvenance } = state + + return withoutProvenance +} + +export function invalidatePersistedDisplayTranscriptAuthority(state: ClientSessionState): ClientSessionState { + return { + ...state, + transcriptAuthorityEpoch: (state.transcriptAuthorityEpoch ?? 0) + 1, + transcriptProvenance: undefined + } +} + +export function suppressTranscriptForView(state: ClientSessionState, suppress: boolean): ClientSessionState { + if (!suppress || state.messages.length === 0) { + return state + } + + return { ...state, messages: [] } +} diff --git a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts index 89550e3985..2411a5c7c7 100644 --- a/apps/desktop/src/app/session/hooks/use-session-state-cache.ts +++ b/apps/desktop/src/app/session/hooks/use-session-state-cache.ts @@ -25,6 +25,10 @@ import { $sessionStates, $sessionTiles, publishSessionState, releaseSessionTrans import type { ClientSessionState } from '../../types' import { SessionStateCache } from '../session-state-cache' +import { + invalidatePersistedDisplayTranscriptAuthority, + suppressTranscriptForView +} from './use-session-actions/transcript-provenance' import { chatMessageArraysEquivalent } from './use-session-actions/utils' interface SessionStateCacheOptions { @@ -124,6 +128,7 @@ export function useSessionStateCache({ const sessionStateCache = sessionStateByRuntimeIdRef.current const pendingViewStateRef = useRef<{ sessionId: string; state: ClientSessionState } | null>(null) const viewSyncRafRef = useRef(null) + const transcriptViewGateByRuntimeIdRef = useRef(new Map()) // Runtime id whose transcript currently occupies `$messages` — lets the // flush below tell a same-session refresh from a thread switch. const viewSessionIdRef = useRef(null) @@ -142,7 +147,7 @@ export function useSessionStateCache({ // Stored id changed (e.g. auto-compression rotated it). Create a NEW // state object rather than mutating in place — updateSessionState needs // the PREVIOUS state to detect transitions (busy→idle, id rotation). - const updated = { ...existing, storedSessionId } + const updated = invalidatePersistedDisplayTranscriptAuthority({ ...existing, storedSessionId }) // Drop the obsolete stored→runtime reverse mapping as soon as the id // rotates (e.g. auto-compression forks a continuation). Leaving the @@ -202,6 +207,17 @@ export function useSessionStateCache({ } }, []) + const holdSessionTranscriptView = useCallback((runtimeId: string): (() => void) => { + const token = Symbol(runtimeId) + transcriptViewGateByRuntimeIdRef.current.set(runtimeId, token) + + return () => { + if (transcriptViewGateByRuntimeIdRef.current.get(runtimeId) === token) { + transcriptViewGateByRuntimeIdRef.current.delete(runtimeId) + } + } + }, []) + const flushPendingViewState = useCallback(() => { const pending = pendingViewStateRef.current pendingViewStateRef.current = null @@ -265,8 +281,10 @@ export function useSessionStateCache({ return } - syncRuntimeMetadataToView(state) - pendingViewStateRef.current = { sessionId, state } + const viewState = suppressTranscriptForView(state, transcriptViewGateByRuntimeIdRef.current.has(sessionId)) + + syncRuntimeMetadataToView(viewState) + pendingViewStateRef.current = { sessionId, state: viewState } // Terminal / attention transitions (turn finished, error, or the agent is // now waiting on the user) MUST reach the view immediately. Electron @@ -278,7 +296,7 @@ export function useSessionStateCache({ // state anyway). The plain busy heartbeat stays RAF-batched: that // coalescing exists only to keep periodic `session.info` updates from // churning `$messages` and jerking the scroll position while reading. - const isCriticalTransition = !state.busy || state.needsInput + const isCriticalTransition = !viewState.busy || viewState.needsInput if (isCriticalTransition) { if (viewSyncRafRef.current !== null && typeof window !== 'undefined') { @@ -382,6 +400,7 @@ export function useSessionStateCache({ activeSessionIdRef, ensureSessionState, getRuntimeIdForStoredSession, + holdSessionTranscriptView, resetViewSync, runtimeIdByStoredSessionIdRef, selectedStoredSessionIdRef, diff --git a/apps/desktop/src/app/types.ts b/apps/desktop/src/app/types.ts index d5afb3e479..0931ecf879 100644 --- a/apps/desktop/src/app/types.ts +++ b/apps/desktop/src/app/types.ts @@ -171,8 +171,19 @@ export interface SidebarNavItem { keybindActionId?: string } +export interface PersistedDisplayTranscriptProvenance { + source: 'persisted-display' + connectionId: string + profile: string + storedSessionId: string + lineageRootId: string | null + coverage: 'latest-page' +} + export interface ClientSessionState { storedSessionId: string | null + transcriptAuthorityEpoch?: number + transcriptProvenance?: PersistedDisplayTranscriptProvenance messages: ChatMessage[] branch: string cwd: string