From 46966123f482e88067c227c74a027d27c5d8bbbc Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=A2=A8=E7=B6=A0BG?= Date: Tue, 21 Jul 2026 02:21:13 +0800 Subject: [PATCH] fix(desktop): keep cached attachment refs on session resume Persisted history carries no attachment metadata for non-image refs, so resume reconciliation dropped `@file:` chips off a user turn whose text matched. Carry the warm cache's refs forward when the resumed message has none of its own, never replacing refs that are already present. (cherry picked from commit eac5b0a8ac39eab242a5d571531e386ec70e2435) --- .../hooks/use-session-actions.test.tsx | 70 +++++++++++++++++++ .../hooks/use-session-actions/utils.test.ts | 46 ++++++++++++ .../hooks/use-session-actions/utils.ts | 4 ++ 3 files changed, 120 insertions(+) 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 2472dccb113..603b45a1a2b 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 @@ -1307,6 +1307,76 @@ describe('resumeSession warm-cache mapping integrity', () => { expect(runtimeIdByStoredSessionIdRef.current.get('stored-A')).toBe('rt-A') }) + it('preserves cached image attachments through an idle persisted transcript refresh', async () => { + const runtimeIdByStoredSessionIdRef: MutableRefObject> = { + current: new Map([['stored-A', 'rt-A']]) + } + + const state = clientState('stored-A') + state.messages = [ + { + id: 'cached-user', + role: 'user', + parts: [{ type: 'text', text: 'describe this image' }], + attachmentRefs: ['@image:/tmp/photo.png'] + }, + { + id: 'cached-assistant', + role: 'assistant', + parts: [{ type: 'text', text: 'It is a photo.' }] + } + ] + + const sessionStateByRuntimeIdRef: MutableRefObject> = { + current: new Map([['rt-A', state]]) + } + + const persistedMessages = [ + { content: 'describe this image', role: 'user', timestamp: 1 }, + { content: 'It is a photo.', role: 'assistant', timestamp: 2 } + ] + + vi.mocked(getSessionMessages).mockResolvedValue({ + messages: persistedMessages, + session_id: 'stored-A' + } as never) + + const requestGateway = vi.fn(async (method: string) => { + if (method === 'session.activate') { + return { + session_id: 'rt-A', + session_key: 'stored-A', + resumed: 'stored-A', + message_count: persistedMessages.length, + messages: persistedMessages, + running: false, + info: {} + } as never + } + + return {} as never + }) + + let resumedState: ClientSessionState | undefined + let resume: ((storedSessionId: string, replaceRoute?: boolean) => Promise) | null = null + + render( + (resume = ready)} + onStateUpdate={(_sessionId, next) => (resumedState = next)} + requestGateway={requestGateway} + runtimeIdByStoredSessionIdRef={runtimeIdByStoredSessionIdRef} + sessionStateByRuntimeIdRef={sessionStateByRuntimeIdRef} + /> + ) + await waitFor(() => expect(resume).not.toBeNull()) + await resume!('stored-A', true) + + expect(requestGateway.mock.calls.map(([method]) => method)).toContain('session.activate') + expect(getSessionMessages).toHaveBeenCalledWith('stored-A', undefined) + expect(resumedState?.messages[0]?.attachmentRefs).toEqual(['@image:/tmp/photo.png']) + }) + it('repairs an idle warm cache from a divergent equal-length persisted transcript', async () => { const runtimeIdByStoredSessionIdRef: MutableRefObject> = { current: new Map([['stored-A', 'rt-A']]) 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 5c3dc83a6de..6da126b9933 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 @@ -317,6 +317,52 @@ describe('reconcileResumeMessages', () => { const [out] = reconcileResumeMessages(next, previous) expect(out.parts.some(p => p.type === 'reasoning')).toBe(true) }) + + it('preserves attachment refs for a matching user turn', () => { + const next = [msg('stored-user', 'user', 'describe this image')] + + const previous = [ + msg('live-user', 'user', 'describe this image', { + attachmentRefs: ['@image:/tmp/photo.png'] + }) + ] + + const [out] = reconcileResumeMessages(next, previous) + + expect(out.attachmentRefs).toEqual(['@image:/tmp/photo.png']) + }) + + it('does not overwrite attachment refs already present on the resumed message', () => { + const next = [ + msg('stored-user', 'user', 'describe this image', { + attachmentRefs: ['@image:/tmp/authoritative.png'] + }) + ] + + const previous = [ + msg('live-user', 'user', 'describe this image', { + attachmentRefs: ['@image:/tmp/cached.png'] + }) + ] + + const [out] = reconcileResumeMessages(next, previous) + + expect(out.attachmentRefs).toEqual(['@image:/tmp/authoritative.png']) + }) + + it('does not preserve attachment refs when the user text differs', () => { + const next = [msg('stored-user', 'user', 'a different prompt')] + + const previous = [ + msg('live-user', 'user', 'describe this image', { + attachmentRefs: ['@image:/tmp/photo.png'] + }) + ] + + const [out] = reconcileResumeMessages(next, previous) + + expect(out.attachmentRefs).toBeUndefined() + }) }) describe('preserveLocalPendingTurnMessages', () => { 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 f7287497bc5..bc5cb6cec3b 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 @@ -211,6 +211,10 @@ export function reconcileResumeMessages(nextMessages: ChatMessage[], previousMes if (nextText === previousVisibleText || nextText === previousText.trim()) { preserved = preserveReasoningParts(preserved, previous) + + if (message.role === 'user' && preserved.attachmentRefs === undefined && previous.attachmentRefs?.length) { + preserved = { ...preserved, attachmentRefs: [...previous.attachmentRefs] } + } } const previousImages = embeddedImageUrls(previousText)