Files
aiturk-hermes-ide/apps/desktop/src/app/chat/transcript-backfill.test.ts
T

357 lines
12 KiB
TypeScript

import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import type { ChatMessage } from '@/lib/chat-messages'
import { $transcriptTailBySessionId, recordTranscriptTail, transcriptTailState } from '@/store/transcript-tail'
import {
_resetTranscriptBackfillForTests,
backfillOlderTranscriptPage,
graftRefreshedTailOntoBackfill,
mergeOlderTranscriptPage,
transcriptBackfillAvailable
} from './transcript-backfill'
vi.mock('@/hermes', () => ({
getOlderSessionMessages: vi.fn()
}))
const { getOlderSessionMessages } = await import('@/hermes')
const chat = (id: string, rowId?: number): ChatMessage => ({
id,
role: 'user',
parts: [{ type: 'text', text: id }],
...(rowId !== undefined ? { rowId } : {})
})
// A stored SessionMessage row: distinct timestamps keep toChatMessages ids
// unique and the row id survives as ChatMessage.rowId.
const row = (rowId: number, text: string) => ({
id: rowId,
role: 'user' as const,
content: text,
timestamp: 1_000 + rowId
})
describe('transcript tail bookkeeping', () => {
beforeEach(() => {
$transcriptTailBySessionId.set({})
})
it('marks a full page as possibly truncated with the next offset', () => {
recordTranscriptTail(
'stored-1',
{
messages: Array.from({ length: 120 }, (_, index) => row(index + 500, `m${index}`)),
pagination: { limit: 120, offset: 0, order: 'latest', returned: 120 }
},
'work'
)
expect(transcriptTailState('stored-1')).toEqual({ nextOffset: 120, possiblyTruncated: true, profile: 'work' })
expect(transcriptBackfillAvailable('stored-1')).toBe(true)
})
it('marks a short page as complete', () => {
recordTranscriptTail('stored-1', {
messages: [row(1, 'only')],
pagination: { limit: 120, offset: 0, order: 'latest', returned: 1 }
})
expect(transcriptBackfillAvailable('stored-1')).toBe(false)
})
it('treats a legacy response without pagination metadata as complete', () => {
recordTranscriptTail('stored-1', {
messages: Array.from({ length: 700 }, (_, index) => row(index, `m${index}`))
})
expect(transcriptBackfillAvailable('stored-1')).toBe(false)
})
it('counts the same session id on separate connections as separate entries', () => {
const page = {
messages: [row(1, 'tail')],
pagination: { limit: 120, offset: 0, order: 'latest' as const, returned: 1 }
}
const sourceA = { connectionId: 'source-a', profile: 'backend' }
const sourceB = { connectionId: 'source-b', profile: 'backend' }
recordTranscriptTail('same-session', page, sourceA)
recordTranscriptTail('same-session', page, sourceB)
expect(Object.keys($transcriptTailBySessionId.get())).toHaveLength(2)
expect(transcriptTailState('same-session', sourceA)?.profile).toEqual(sourceA)
expect(transcriptTailState('same-session', sourceB)?.profile).toEqual(sourceB)
expect(transcriptTailState('same-session')).toBeUndefined()
})
it('bounds entries and deterministically evicts the oldest scoped identity', () => {
const page = {
messages: [row(1, 'tail')],
pagination: { limit: 120, offset: 0, order: 'latest' as const, returned: 1 }
}
const scope = { connectionId: 'source-a', profile: 'backend' }
for (let index = 0; index < 257; index += 1) {
recordTranscriptTail(`bounded-${index}`, page, scope)
}
expect(Object.keys($transcriptTailBySessionId.get())).toHaveLength(256)
expect(transcriptTailState('bounded-0', scope)).toBeUndefined()
expect(transcriptTailState('bounded-1', scope)).toBeDefined()
expect(transcriptTailState('bounded-256', scope)).toBeDefined()
})
})
describe('mergeOlderTranscriptPage', () => {
it('prepends the older page and preserves chronological order', () => {
const existing = [chat('c', 3), chat('d', 4)]
const older = [chat('a', 1), chat('b', 2)]
expect(mergeOlderTranscriptPage(existing, older).map(m => m.id)).toEqual(['a', 'b', 'c', 'd'])
})
it('dedupes rows the store already holds by durable row id', () => {
const existing = [chat('b', 2), chat('c', 3)]
// Offset drift: the fetched page overlaps one row we already have.
const older = [chat('a', 1), chat('b-refetched', 2)]
expect(mergeOlderTranscriptPage(existing, older).map(m => m.rowId)).toEqual([1, 2, 3])
})
it('keeps reference identity when every older row is already present', () => {
const existing = [chat('a', 1), chat('b', 2)]
const older = [chat('a', 1)]
expect(mergeOlderTranscriptPage(existing, older)).toBe(existing)
})
it('refuses to paint an older page as the whole transcript', () => {
const existing: ChatMessage[] = []
expect(mergeOlderTranscriptPage(existing, [chat('a', 1)])).toBe(existing)
})
})
describe('graftRefreshedTailOntoBackfill', () => {
it('keeps the backfilled prefix when the refreshed tail anchors inside it', () => {
const previous = [chat('a', 1), chat('b', 2), chat('c', 3)]
const refreshed = [chat('b', 2), chat('c', 3), chat('d', 4)]
expect(graftRefreshedTailOntoBackfill(refreshed, previous).map(m => m.rowId)).toEqual([1, 2, 3, 4])
})
it('returns the refreshed tail unchanged when no anchor is found', () => {
const previous = [chat('x', 90), chat('y', 91), chat('z', 92)]
const refreshed = [chat('p', 200), chat('q', 201)]
expect(graftRefreshedTailOntoBackfill(refreshed, previous)).toBe(refreshed)
})
it('returns the refreshed tail when it is not shorter than the previous transcript', () => {
const previous = [chat('a', 1)]
const refreshed = [chat('a', 1), chat('b', 2)]
expect(graftRefreshedTailOntoBackfill(refreshed, previous)).toBe(refreshed)
})
})
describe('backfillOlderTranscriptPage', () => {
beforeEach(() => {
$transcriptTailBySessionId.set({})
_resetTranscriptBackfillForTests()
vi.mocked(getOlderSessionMessages).mockReset()
})
afterEach(() => {
vi.restoreAllMocks()
})
const truncatedTail = (nextOffset = 120) => {
recordTranscriptTail('stored-1', {
messages: Array.from({ length: 120 }, (_, index) => row(index + nextOffset, `tail${index}`)),
pagination: { limit: 120, offset: 0, order: 'latest', returned: 120 }
})
}
it('fetches the recorded next offset and applies the converted page', async () => {
truncatedTail()
vi.mocked(getOlderSessionMessages).mockResolvedValue({
messages: [row(1, 'older-1'), row(2, 'older-2')],
pagination: { limit: 120, offset: 120, order: 'latest', returned: 2 },
session_id: 'stored-1'
} as never)
const applyOlderPage = vi.fn()
const applied = await backfillOlderTranscriptPage({
storedSessionId: 'stored-1',
isCurrent: () => true,
applyOlderPage
})
expect(applied).toBe(true)
expect(getOlderSessionMessages).toHaveBeenCalledWith('stored-1', undefined, 120)
expect(applyOlderPage).toHaveBeenCalledTimes(1)
expect(applyOlderPage.mock.calls[0][0].map((m: ChatMessage) => m.rowId)).toEqual([1, 2])
// A short older page means the transcript is now fully loaded.
expect(transcriptBackfillAvailable('stored-1')).toBe(false)
})
it('backfills the matching connection when two owners share one session id', async () => {
const sourceA = { connectionId: 'source-a', profile: 'backend-a' }
const sourceB = { connectionId: 'source-b', profile: 'backend-b' }
const page = {
messages: Array.from({ length: 120 }, (_, index) => row(index, `tail${index}`)),
pagination: { limit: 120, offset: 0, order: 'latest' as const, returned: 120 }
}
recordTranscriptTail('same-session', page, sourceA)
recordTranscriptTail('same-session', page, sourceB)
vi.mocked(getOlderSessionMessages).mockResolvedValue({
messages: [row(1, 'older')],
pagination: { limit: 120, offset: 120, order: 'latest', returned: 1 },
session_id: 'same-session'
} as never)
await backfillOlderTranscriptPage({
storedSessionId: 'same-session',
profile: sourceB,
isCurrent: () => true,
applyOlderPage: vi.fn()
})
expect(getOlderSessionMessages).toHaveBeenCalledWith('same-session', sourceB, 120)
expect(transcriptTailState('same-session', sourceA)).toMatchObject({ possiblyTruncated: true })
expect(transcriptTailState('same-session', sourceB)).toMatchObject({ possiblyTruncated: false })
expect(transcriptTailState('same-session')).toBeUndefined()
})
it('keeps backfill available while pages keep coming back full', async () => {
truncatedTail()
vi.mocked(getOlderSessionMessages).mockResolvedValue({
messages: Array.from({ length: 120 }, (_, index) => row(index, `older${index}`)),
pagination: { limit: 120, offset: 120, order: 'latest', returned: 120 },
session_id: 'stored-1'
} as never)
await backfillOlderTranscriptPage({ storedSessionId: 'stored-1', isCurrent: () => true, applyOlderPage: vi.fn() })
expect(transcriptTailState('stored-1')).toMatchObject({ nextOffset: 240, possiblyTruncated: true })
})
it('falls back to the full transcript when a legacy backend returns no pagination metadata', async () => {
truncatedTail()
// Legacy backend: ignores limit/offset/order and one-shots everything.
vi.mocked(getOlderSessionMessages).mockResolvedValue({
messages: Array.from({ length: 700 }, (_, index) => row(index, `full${index}`)),
session_id: 'stored-1'
} as never)
const applyOlderPage = vi.fn()
const applied = await backfillOlderTranscriptPage({
storedSessionId: 'stored-1',
isCurrent: () => true,
applyOlderPage
})
expect(applied).toBe(true)
expect(applyOlderPage.mock.calls[0][0]).toHaveLength(700)
// One-shot full transcript: the REST action retires.
expect(transcriptBackfillAvailable('stored-1')).toBe(false)
})
it('discards a stale response after a session switch', async () => {
truncatedTail()
vi.mocked(getOlderSessionMessages).mockResolvedValue({
messages: [row(1, 'older-1')],
pagination: { limit: 120, offset: 120, order: 'latest', returned: 1 },
session_id: 'stored-1'
} as never)
const applyOlderPage = vi.fn()
const applied = await backfillOlderTranscriptPage({
storedSessionId: 'stored-1',
// The user switched sessions while the page was in flight.
isCurrent: () => false,
applyOlderPage
})
expect(applied).toBe(false)
expect(applyOlderPage).not.toHaveBeenCalled()
// Bookkeeping untouched: the next visit re-records the tail anyway.
expect(transcriptTailState('stored-1')).toMatchObject({ nextOffset: 120, possiblyTruncated: true })
})
it('shares one in-flight fetch per stored session', async () => {
truncatedTail()
let resolvePage: (value: unknown) => void = () => {}
vi.mocked(getOlderSessionMessages).mockReturnValue(
new Promise(resolve => {
resolvePage = resolve
}) as never
)
const first = backfillOlderTranscriptPage({
storedSessionId: 'stored-1',
isCurrent: () => true,
applyOlderPage: vi.fn()
})
const second = backfillOlderTranscriptPage({
storedSessionId: 'stored-1',
isCurrent: () => true,
applyOlderPage: vi.fn()
})
expect(second).toBe(first)
expect(getOlderSessionMessages).toHaveBeenCalledTimes(1)
resolvePage({
messages: [row(1, 'older-1')],
pagination: { limit: 120, offset: 120, order: 'latest', returned: 1 },
session_id: 'stored-1'
})
await first
})
it('resolves false without fetching when the tail is not truncated', async () => {
recordTranscriptTail('stored-1', {
messages: [row(1, 'only')],
pagination: { limit: 120, offset: 0, order: 'latest', returned: 1 }
})
const applied = await backfillOlderTranscriptPage({
storedSessionId: 'stored-1',
isCurrent: () => true,
applyOlderPage: vi.fn()
})
expect(applied).toBe(false)
expect(getOlderSessionMessages).not.toHaveBeenCalled()
})
it('survives a fetch failure and leaves the action retryable', async () => {
truncatedTail()
vi.mocked(getOlderSessionMessages).mockRejectedValue(new Error('network down'))
const applied = await backfillOlderTranscriptPage({
storedSessionId: 'stored-1',
isCurrent: () => true,
applyOlderPage: vi.fn()
})
expect(applied).toBe(false)
expect(transcriptBackfillAvailable('stored-1')).toBe(true)
})
})