Files
hermes-agent/apps/desktop/src/store/transcript-tail-cache.test.ts
T
Teknium 3b672a6897 fix: delete-path drops every scope of a removed id; legacy purge latches on success only
Review follow-ups on the salvage (#94914): the delete path derives its
scope from the removed row while saves derive from ownerRoute — a shape
drift orphaned the entry until LRU eviction. Deletes now sweep every
scope of the stored id (safe: deleted ids are never reused), while the
failed-resume path keeps the exact-scope drop (a same-id twin in another
profile must keep its tail). purgeLegacyV1 latches only after a
completed sweep so a mid-sweep throw retries next touch.
2026-08-26 18:23:44 -07:00

213 lines
8.3 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ChatMessage } from '@/lib/chat-messages'
import {
clearTranscriptTails,
dropTranscriptTail,
dropTranscriptTailEverywhere,
loadTranscriptTail,
saveTranscriptTail
} from './transcript-tail-cache'
const msg = (id: string, chars = 10): ChatMessage =>
({ id, parts: [{ text: 'x'.repeat(chars), type: 'text' }], role: 'assistant' }) as never
beforeEach(() => {
window.localStorage.clear()
})
afterEach(() => {
window.localStorage.clear()
})
describe('transcript tail cache', () => {
it('round-trips a session tail keyed by stored id', () => {
saveTranscriptTail('sess-a', [msg('1'), msg('2')])
const loaded = loadTranscriptTail('sess-a')
expect(loaded).toHaveLength(2)
expect(loaded?.[0].id).toBe('1')
expect(loadTranscriptTail('sess-other')).toBeNull()
})
it('persists only the bounded tail of a long transcript', () => {
const long = Array.from({ length: 200 }, (_, index) => msg(`m${index}`))
saveTranscriptTail('sess-long', long)
const loaded = loadTranscriptTail('sess-long')
expect(loaded).toHaveLength(40)
expect(loaded?.[0].id).toBe('m160')
expect(loaded?.[39].id).toBe('m199')
})
it('falls back to a shorter tail rather than caching an oversized entry', () => {
// ~40 x 30KB ≈ 1.2MB > 256KB cap; 8 x 30KB ≈ 240KB fits.
const heavy = Array.from({ length: 40 }, (_, index) => msg(`h${index}`, 30_000))
saveTranscriptTail('sess-heavy', heavy)
const loaded = loadTranscriptTail('sess-heavy')
expect(loaded).toHaveLength(8)
expect(loaded?.[7].id).toBe('h39')
})
it('ignores empty saves and blank ids', () => {
saveTranscriptTail('', [msg('1')])
saveTranscriptTail('sess-empty', [])
expect(loadTranscriptTail('')).toBeNull()
expect(loadTranscriptTail('sess-empty')).toBeNull()
})
it('self-evicts a corrupt entry instead of returning garbage', () => {
window.localStorage.setItem('hermes.transcript-tail.v2:sess-bad', '{not json')
expect(loadTranscriptTail('sess-bad')).toBeNull()
expect(window.localStorage.getItem('hermes.transcript-tail.v2:sess-bad')).toBeNull()
})
it('drops a deleted session and wipes everything on a gateway re-home', () => {
saveTranscriptTail('sess-1', [msg('a')])
saveTranscriptTail('sess-2', [msg('b')])
dropTranscriptTail('sess-1')
expect(loadTranscriptTail('sess-1')).toBeNull()
expect(loadTranscriptTail('sess-2')).not.toBeNull()
clearTranscriptTails()
expect(loadTranscriptTail('sess-2')).toBeNull()
})
it('LRU-evicts the oldest sessions past the entry cap', () => {
for (let index = 0; index < 55; index += 1) {
saveTranscriptTail(`sess-${index}`, [msg(`m${index}`)])
}
// 50-entry cap: the first five saved are evicted, the newest survive.
expect(loadTranscriptTail('sess-0')).toBeNull()
expect(loadTranscriptTail('sess-4')).toBeNull()
expect(loadTranscriptTail('sess-5')).not.toBeNull()
expect(loadTranscriptTail('sess-54')).not.toBeNull()
})
it('repairs a poisoned persisted tail carrying a duplicate toolCallId (#87857)', () => {
// A tail written by an older build can hold one message with two tool-call
// parts sharing an id. This path paints DIRECTLY into the view and the same
// bytes are re-read every launch — without repair-on-read, an affected
// install crash-loops forever even after upgrading.
const tool = (toolCallId: string) => ({
type: 'tool-call',
toolCallId,
toolName: 'terminal',
args: {},
argsText: ''
})
const poisoned = {
messages: [{ id: 'assistant-p', role: 'assistant', parts: [tool('call-b'), tool('call-b')] }],
savedAt: Date.now()
}
window.localStorage.setItem('hermes.transcript-tail.v2:sess-poisoned', JSON.stringify(poisoned))
const loaded = loadTranscriptTail('sess-poisoned')
expect(loaded).toHaveLength(1)
const ids = (loaded![0].parts as { type: string; toolCallId?: string }[])
.filter(part => part.type === 'tool-call')
.map(part => part.toolCallId)
expect(ids).toHaveLength(2)
expect(new Set(ids).size).toBe(2)
expect(ids[0]).toBe('call-b')
})
// ── Profile scoping (#94828) ─────────────────────────────────────────────
// Stored session ids are only unique WITHIN a profile's state.db, and
// localStorage survives profile switches in the same window. An unscoped
// key lets profile A's tail be painted against profile B's backend — the
// view then retries a session id that does not exist there ("session not
// found") on every wake. The durable cache must carry the same
// {connectionId, profile} scope as its in-memory twin (transcript-tail.ts).
describe('profile scoping (#94828)', () => {
it('never returns a tail cached under another profile', () => {
saveTranscriptTail('sess-x', [msg('a')], { profile: 'ai-energy' })
expect(loadTranscriptTail('sess-x', { profile: 'ai-energy' })).toHaveLength(1)
expect(loadTranscriptTail('sess-x', { profile: 'default' })).toBeNull()
expect(loadTranscriptTail('sess-x')).toBeNull()
})
it('keeps same-id tails on different connections distinct', () => {
saveTranscriptTail('sess-y', [msg('local-row')], { connectionId: 'local', profile: 'default' })
saveTranscriptTail('sess-y', [msg('remote-row')], { connectionId: 'conn:mimir', profile: 'default' })
expect(loadTranscriptTail('sess-y', { connectionId: 'local', profile: 'default' })?.[0].id).toBe('local-row')
expect(loadTranscriptTail('sess-y', { connectionId: 'conn:mimir', profile: 'default' })?.[0].id).toBe(
'remote-row'
)
})
it('accepts a plain-profile string scope like the in-memory twin', () => {
saveTranscriptTail('sess-s', [msg('s')], 'rwa-africa')
expect(loadTranscriptTail('sess-s', 'rwa-africa')).toHaveLength(1)
expect(loadTranscriptTail('sess-s', 'other')).toBeNull()
})
it('drops only the scoped entry when a scope is given', () => {
saveTranscriptTail('sess-d', [msg('p1-row')], { profile: 'p1' })
saveTranscriptTail('sess-d', [msg('p2-row')], { profile: 'p2' })
dropTranscriptTail('sess-d', { profile: 'p1' })
expect(loadTranscriptTail('sess-d', { profile: 'p1' })).toBeNull()
expect(loadTranscriptTail('sess-d', { profile: 'p2' })).not.toBeNull()
})
it('delete-path everywhere-drop clears every scope of the id even when scopes drifted', () => {
// Save under a routed scope (connectionId present); the delete path
// derives its scope from the removed row, which may lack the tag —
// the everywhere-drop must still clear the entry (#94914 defect 1).
saveTranscriptTail('sess-gone', [msg('routed-row')], { connectionId: 'homelab', profile: 'ops' })
saveTranscriptTail('sess-gone', [msg('local-row')], { profile: 'ops' })
saveTranscriptTail('sess-kept', [msg('other-row')], { profile: 'ops' })
dropTranscriptTailEverywhere('sess-gone')
expect(loadTranscriptTail('sess-gone', { connectionId: 'homelab', profile: 'ops' })).toBeNull()
expect(loadTranscriptTail('sess-gone', { profile: 'ops' })).toBeNull()
expect(loadTranscriptTail('sess-kept', { profile: 'ops' })).not.toBeNull()
})
it('purges pre-scoping v1 entries so a stale tail can never paint again', async () => {
const stale = {
messages: [{ id: 'old', parts: [{ text: 'x', type: 'text' }], role: 'assistant' }],
savedAt: Date.now()
}
window.localStorage.setItem('hermes.transcript-tail.v1:sess-legacy', JSON.stringify(stale))
window.localStorage.setItem('hermes.transcript-tail.v1-index', JSON.stringify(['sess-legacy']))
// Fresh module instance: the sweep runs once per window, and earlier
// tests in this file have already touched storage.
vi.resetModules()
try {
const fresh = await import('./transcript-tail-cache')
expect(fresh.loadTranscriptTail('sess-legacy')).toBeNull()
expect(fresh.loadTranscriptTail('sess-legacy', { profile: 'ai-energy' })).toBeNull()
expect(window.localStorage.getItem('hermes.transcript-tail.v1:sess-legacy')).toBeNull()
expect(window.localStorage.getItem('hermes.transcript-tail.v1-index')).toBeNull()
} finally {
vi.resetModules()
}
})
})
})