chatCache.test.ts 6.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136
  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, chatCacheKey } from '../../src/server/chatCache.js'
  7. import type { UpstreamServiceSession } from '../../src/server/localAuth.js'
  8. import type { UpstreamResponse } from '../../src/server/upstream.js'
  9. const homes: string[] = []
  10. const owner = 'owner-1'
  11. const profile = 'default'
  12. const sessionID = 'session-web'
  13. function response(value: unknown, status = 200, contentType = 'application/json'): UpstreamResponse {
  14. return { status, headers: new Headers({ 'content-type': contentType }), body: Buffer.from(typeof value === 'string' ? value : JSON.stringify(value)) }
  15. }
  16. function fixture() {
  17. const home = mkdtempSync(join(tmpdir(), 'yaoyao-chat-cache-'))
  18. homes.push(home)
  19. const store = new ChatCacheStore(home)
  20. const upstream = { request: vi.fn() } as unknown as UpstreamServiceSession
  21. return { home, store, coordinator: new ChatCacheCoordinator(store, upstream, 'prefer-local') }
  22. }
  23. afterEach(() => {
  24. for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
  25. })
  26. describe('durable source=web chat cache', () => {
  27. it('serves a warm list without a second 9119 request and survives restart', async () => {
  28. const f = fixture()
  29. const search = new URLSearchParams({ source: 'web', profile })
  30. const key = chatCacheKey('list', profile, undefined, search)
  31. const load = vi.fn(async () => response({ sessions: [{ id: sessionID, profile, source: 'web', title: '本地会话' }] }))
  32. const first = await f.coordinator.read(owner, key, 'list', profile, undefined, load, true)
  33. const second = await f.coordinator.read(owner, key, 'list', profile, undefined, load, true)
  34. expect(first.source).toBe('upstream')
  35. expect(second.source).toBe('local')
  36. expect(load).toHaveBeenCalledTimes(1)
  37. f.store.close()
  38. const restored = new ChatCacheStore(f.home)
  39. const cached = restored.snapshot(owner, key)
  40. expect(JSON.parse(cached!.response.body.toString()).sessions[0].title).toBe('本地会话')
  41. restored.close()
  42. })
  43. it('materializes complete message pages and keeps owners isolated', () => {
  44. const f = fixture()
  45. f.store.putSnapshot(owner, 'detail', 'detail', profile, sessionID,
  46. response({ id: sessionID, profile, source: 'web', title: '对话' }))
  47. f.store.putSnapshot(owner, 'messages', 'messages', profile, sessionID, response({
  48. session: { id: sessionID, profile, source: 'web', title: '对话' },
  49. messages: [
  50. { id: 'm1', role: 'user', content: '开始' },
  51. { id: 'm2', role: 'assistant', content: '完成' },
  52. ],
  53. pagination: { total: 2, returned: 2, offset: 0, limit: 100, hasMore: false },
  54. }))
  55. const page = f.store.messagePage(owner, profile, sessionID, 0, 100)
  56. const payload = JSON.parse(page!.response.body.toString())
  57. expect(payload.session_id).toBe(sessionID)
  58. expect(payload.pagination).toMatchObject({ total: 2, returned: 2, offset: 0, limit: 100, has_more: false })
  59. expect(payload.messages.map((item: { id: string }) => item.id)).toEqual(['m1', 'm2'])
  60. expect(f.store.messagePage('another-owner', profile, sessionID, 0, 100)).toBeUndefined()
  61. f.store.close()
  62. })
  63. it('never persists non-Web history', () => {
  64. const f = fixture()
  65. expect(f.store.putSnapshot(owner, 'history', 'detail', profile, 'history-1',
  66. response({ id: 'history-1', profile, source: 'telegram', title: '历史' }))).toBe(false)
  67. expect(f.store.snapshot(owner, 'history')).toBeUndefined()
  68. f.store.close()
  69. })
  70. it('marks event gaps stale, deduplicates events, and falls back while 9119 is offline', async () => {
  71. const f = fixture()
  72. const key = 'detail-key'
  73. f.store.putSnapshot(owner, key, 'detail', profile, sessionID,
  74. response({ id: sessionID, profile, source: 'web', title: '稳定内容' }))
  75. const frame = { type: 'message.delta', seq: 2, payload: { text: '增量' } }
  76. f.store.recordEvent(owner, profile, sessionID, frame)
  77. f.store.recordEvent(owner, profile, sessionID, frame)
  78. const count = f.store.db.prepare('SELECT COUNT(*) count FROM chat_events').get() as { count: number }
  79. expect(count.count).toBe(1)
  80. f.store.recordRoute(owner, profile, sessionID, 'runtime-1')
  81. const route = f.store.db.prepare('SELECT runtime_id FROM chat_sessions WHERE owner=? AND profile=? AND session_id=?')
  82. .get(owner, profile, sessionID) as { runtime_id: string }
  83. expect(route.runtime_id).toBe('runtime-1')
  84. const fallback = await f.coordinator.read(owner, key, 'detail', profile, sessionID,
  85. async () => { throw new Error('9119 offline') })
  86. expect(fallback).toMatchObject({ source: 'local', state: 'stale' })
  87. const serverFailure = await f.coordinator.read(owner, key, 'detail', profile, sessionID,
  88. async () => response({ error: 'unavailable' }, 503))
  89. expect(serverFailure).toMatchObject({ source: 'local', state: 'stale' })
  90. f.store.close()
  91. })
  92. it('caches only attachment paths discovered in source=web messages', () => {
  93. const f = fixture()
  94. const sourcePath = '/Users/test/.hermes/profiles/default/images/result.png'
  95. f.store.putSnapshot(owner, 'messages', 'messages', profile, sessionID, response({
  96. session: { id: sessionID, profile, source: 'web' },
  97. messages: [{ id: 'm1', role: 'assistant', content: `![结果](${sourcePath})` }],
  98. pagination: { total: 1, offset: 0, hasMore: false },
  99. }))
  100. expect(f.store.knowsAttachment(owner, sourcePath)).toBe(true)
  101. const bytes = response('image-data', 200, 'image/png')
  102. expect(f.store.storeAttachment(owner, sourcePath, bytes)).toBeTruthy()
  103. expect(f.store.attachment(owner, sourcePath)?.mimeType).toBe('image/png')
  104. expect(f.store.knowsAttachment(owner, '/Users/test/history.png')).toBe(false)
  105. f.store.close()
  106. })
  107. it('supports shadow and upstream-only rollback modes', async () => {
  108. const home = mkdtempSync(join(tmpdir(), 'yaoyao-chat-cache-modes-')); homes.push(home)
  109. const store = new ChatCacheStore(home)
  110. const upstream = { request: vi.fn() } as unknown as UpstreamServiceSession
  111. const load = vi.fn(async () => response({ sessions: [{ id: sessionID, profile, source: 'web' }] }))
  112. const search = new URLSearchParams({ source: 'web' })
  113. const key = chatCacheKey('list', profile, undefined, search)
  114. const shadow = new ChatCacheCoordinator(store, upstream, 'shadow')
  115. await shadow.read(owner, key, 'list', profile, undefined, load, true)
  116. await shadow.read(owner, key, 'list', profile, undefined, load, true)
  117. expect(load).toHaveBeenCalledTimes(2)
  118. const upstreamOnly = new ChatCacheCoordinator(store, upstream, 'upstream-only')
  119. await upstreamOnly.read(owner, key, 'list', profile, undefined, load, true)
  120. expect(load).toHaveBeenCalledTimes(3)
  121. store.close()
  122. })
  123. })