357 lines
12 KiB
TypeScript
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)
|
|
})
|
|
})
|