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)