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 eac5b0a8ac)
This commit is contained in:
墨綠BG 2026-07-21 02:21:13 +08:00 committed by Brooklyn Nicholson
parent d0f5ef7041
commit 46966123f4
3 changed files with 120 additions and 0 deletions

View file

@ -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<Map<string, string>> = {
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<Map<string, ClientSessionState>> = {
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<unknown>) | null = null
render(
<ResumeHarness
onReady={ready => (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<Map<string, string>> = {
current: new Map([['stored-A', 'rt-A']])

View file

@ -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', () => {

View file

@ -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)