From 93c97073d857230b7de095b7885e25a3be0306fa Mon Sep 17 00:00:00 2001 From: ethernet Date: Wed, 22 Jul 2026 19:32:20 -0400 Subject: [PATCH] fix(desktop): prevent stale optimistic tails after compression (#69682) --- .../hooks/use-session-actions/utils.test.ts | 36 +++++++++++++++++++ .../hooks/use-session-actions/utils.ts | 21 +++++++++-- 2 files changed, 54 insertions(+), 3 deletions(-) diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts index 652cd0af3717..75dd98bc2287 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.test.ts @@ -327,6 +327,42 @@ describe('preserveLocalPendingTurnMessages', () => { expect(preserveLocalPendingTurnMessages(next, previous)).toBe(next) }) + + it('drops stale optimistic history after compression and keeps only the live tail', () => { + const compressedAuthority = [ + msg('stored-user', 'user', 'first turn that survived compression'), + msg('stored-assistant', 'assistant', 'latest authoritative reply') + ] + + const pollutedWarmCache = [ + msg('user-old-1', 'user', 'compressed-away prompt one'), + msg('assistant-old-1', 'assistant', 'compressed-away reply one'), + msg('user-old-2', 'user', 'compressed-away prompt two'), + msg('assistant-old-2', 'assistant', 'compressed-away reply two'), + msg('user-inflight', 'user', 'the one genuinely in-flight prompt') + ] + + expect(preserveLocalPendingTurnMessages(compressedAuthority, pollutedWarmCache).map(message => message.id)).toEqual([ + 'stored-user', + 'stored-assistant', + 'user-inflight' + ]) + }) + + it('drops the live tail once the latest authoritative user has persisted it after compression', () => { + const compressedAuthority = [ + msg('stored-user', 'user', 'the one genuinely in-flight prompt'), + msg('stored-assistant', 'assistant', 'its authoritative reply') + ] + + const pollutedWarmCache = [ + msg('user-old-1', 'user', 'compressed-away prompt one'), + msg('assistant-old-1', 'assistant', 'compressed-away reply one'), + msg('user-inflight', 'user', 'the one genuinely in-flight prompt') + ] + + expect(preserveLocalPendingTurnMessages(compressedAuthority, pollutedWarmCache)).toBe(compressedAuthority) + }) }) describe('appendLiveSessionProjection', () => { diff --git a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts index 2604a68841df..4e31c2771bf2 100644 --- a/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts +++ b/apps/desktop/src/app/session/hooks/use-session-actions/utils.ts @@ -234,9 +234,12 @@ export function reconcileResumeMessages(nextMessages: ChatMessage[], previousMes * dropping either makes an accepted turn appear to vanish during transport * churn. * - * Authoritative rows use different ids, so match by role ordinal. A matching - * user row is considered committed only when its visible text also matches; - * any authoritative assistant at the same ordinal supersedes the local stream. + * A lagging projection can be behind by one live turn, never a whole local + * history window. Preserve only the newest optimistic user row: compression + * rewrites past context, so older `user-*` rows in a warm cache are stale + * history, not in-flight work. The latest authoritative user confirms whether + * that tail has persisted; any authoritative assistant at the same ordinal + * supersedes the local stream. */ export function preserveLocalPendingTurnMessages( nextMessages: ChatMessage[], @@ -257,6 +260,10 @@ export function preserveLocalPendingTurnMessages( const nextIds = new Set(nextMessages.map(message => message.id)) const previousRoleCounts = new Map() + const newestOptimisticUser = [...previousMessages] + .reverse() + .find(message => message.role === 'user' && message.id.startsWith('user-')) + const latestAuthoritativeUser = [...nextMessages].reverse().find(message => message.role === 'user') const preserved: ChatMessage[] = [] for (const message of previousMessages) { @@ -272,6 +279,14 @@ export function preserveLocalPendingTurnMessages( continue } + if (isOptimisticUser && message !== newestOptimisticUser) { + continue + } + + if (isOptimisticUser && latestAuthoritativeUser && chatMessageText(latestAuthoritativeUser).trim() === chatMessageText(message).trim()) { + continue + } + const authoritative = nextByRoleOrdinal.get(`${message.role}:${ordinal}`) if (authoritative) {