chatCachePagination.test.ts 4.0 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364
  1. // @vitest-environment node
  2. import { mkdtempSync, rmSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { afterEach, describe, expect, it, vi } from 'vitest'
  6. import { ChatCacheCoordinator, ChatCacheStore } from '../../src/server/chatCache.js'
  7. import type { UpstreamResponse } from '../../src/server/upstream.js'
  8. import type { UpstreamServiceSession } from '../../src/server/localAuth.js'
  9. const homes: string[] = []
  10. afterEach(() => homes.splice(0).forEach(home => rmSync(home, { recursive: true, force: true })))
  11. function response(body: unknown): UpstreamResponse {
  12. return { status: 200, headers: new Headers({ 'content-type': 'application/json' }), body: Buffer.from(JSON.stringify(body)) }
  13. }
  14. function fixture(count: number) {
  15. const home = mkdtempSync(join(tmpdir(), 'hermes-pagination-')); homes.push(home)
  16. const store = new ChatCacheStore(home)
  17. store.recordRoute('owner', 'default', 'chat', 'runtime')
  18. const rows = Array.from({ length: count }, (_, id) => ({ id: String(id), role: 'assistant', content: `row ${id}` }))
  19. const request = vi.fn(async (path: string, options: { search: URLSearchParams }) => {
  20. if (!path.endsWith('/messages')) return response({ id: 'chat', profile: 'default', title: 'History', message_count: count })
  21. const offset = Number(options.search.get('offset')), limit = Number(options.search.get('limit'))
  22. const end = Math.max(0, rows.length - offset)
  23. const messages = rows.slice(Math.max(0, end - limit), end)
  24. // Real Hermes 0.21 response: deliberately no total or has_more.
  25. return response({ session_id: 'chat', messages, pagination: { offset, limit, returned: messages.length, order: 'latest' } })
  26. })
  27. const coordinator = new ChatCacheCoordinator(store, { request } as unknown as UpstreamServiceSession)
  28. return { store, rows, request, coordinator }
  29. }
  30. describe('Hermes pagination without total and has_more', () => {
  31. it.each([250, 300])('force refresh preserves all %i messages and correct page positions', async count => {
  32. const f = fixture(count)
  33. await f.coordinator.reconcile('owner', 'default', 'chat', true)
  34. const page = f.store.sourceMessagePage('owner', 'default', 'chat', count - 100, 100)!
  35. const payload = JSON.parse(page.response.body.toString())
  36. expect(payload.pagination.total).toBe(count)
  37. expect(payload.messages[0].id).toBe('0')
  38. expect(payload.messages.at(-1).id).toBe('99')
  39. expect(f.request.mock.calls.filter(([path]) => path.endsWith('/messages'))).toHaveLength(Math.floor(count / 100) + 1)
  40. f.store.close()
  41. })
  42. it('uses known metadata for a partial page instead of declaring that page to be the entire history', () => {
  43. const f = fixture(250)
  44. f.store.putSnapshot('owner', 'detail', 'detail', 'default', 'chat', response({ id: 'chat', message_count: 250, title: 'History' }))
  45. f.store.putSnapshot('owner', 'tail', 'messages', 'default', 'chat', response({ session_id: 'chat', messages: f.rows.slice(-100), pagination: { offset: 0, limit: 100, returned: 100 } }))
  46. const payload = JSON.parse(f.store.sourceMessagePage('owner', 'default', 'chat', 0, 100)!.response.body.toString())
  47. expect(payload.pagination).toMatchObject({ total: 250, has_more: true })
  48. expect(f.store.sourceMessagePage('owner', 'default', 'chat', 100, 100)).toBeUndefined()
  49. f.store.close()
  50. })
  51. it('still reads only one overlapping tail page for an existing cache', async () => {
  52. const f = fixture(252)
  53. f.store.putSnapshot('owner', 'seed', 'messages', 'default', 'chat', response({ session: { id: 'chat', message_count: 250 }, messages: f.rows.slice(0, 250), pagination: { total: 250, offset: 0, has_more: false } }))
  54. await f.coordinator.reconcile('owner', 'default', 'chat')
  55. expect(f.request.mock.calls.filter(([path]) => path.endsWith('/messages'))).toHaveLength(1)
  56. const page = JSON.parse(f.store.sourceMessagePage('owner', 'default', 'chat', 0, 100)!.response.body.toString())
  57. expect(page.pagination.total).toBe(252)
  58. expect(page.messages.at(-1).id).toBe('251')
  59. f.store.close()
  60. })
  61. })