mirror of
https://github.com/NousResearch/hermes-agent.git
synced 2026-07-31 19:16:29 +00:00
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:
parent
d0f5ef7041
commit
46966123f4
3 changed files with 120 additions and 0 deletions
|
|
@ -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']])
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue