chatCache.test.ts 45 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867
  1. // @vitest-environment node
  2. import { mkdtempSync, readFileSync, rmSync } from 'node:fs'
  3. import { tmpdir } from 'node:os'
  4. import { join } from 'node:path'
  5. import { DatabaseSync } from 'node:sqlite'
  6. import { afterEach, describe, expect, it, vi } from 'vitest'
  7. import { ChatCacheCoordinator, ChatCacheStore, chatCacheKey } from '../../src/server/chatCache.js'
  8. import type { UpstreamServiceSession } from '../../src/server/localAuth.js'
  9. import type { UpstreamResponse } from '../../src/server/upstream.js'
  10. const homes: string[] = []
  11. const owner = 'owner-1'
  12. const profile = 'default'
  13. const sessionID = 'session-web'
  14. function response(value: unknown, status = 200, contentType = 'application/json'): UpstreamResponse {
  15. return { status, headers: new Headers({ 'content-type': contentType }), body: Buffer.from(typeof value === 'string' ? value : JSON.stringify(value)) }
  16. }
  17. function fixture() {
  18. const home = mkdtempSync(join(tmpdir(), 'yaoyao-chat-cache-'))
  19. homes.push(home)
  20. const store = new ChatCacheStore(home)
  21. const upstream = { request: vi.fn() } as unknown as UpstreamServiceSession
  22. return { home, store, coordinator: new ChatCacheCoordinator(store, upstream, 'prefer-local') }
  23. }
  24. afterEach(() => {
  25. for (const home of homes.splice(0)) rmSync(home, { recursive: true, force: true })
  26. })
  27. describe('durable source=web chat cache', () => {
  28. it.each(['prefer-local', 'shadow', 'upstream-only'] as const)(
  29. 'preserves newer session activity and cached files after a stale 404 in %s mode', async mode => {
  30. const f = fixture()
  31. const coordinator = new ChatCacheCoordinator(f.store, f.coordinator.upstream, mode)
  32. const sourcePath = '/tmp/final-report.png'
  33. try {
  34. f.store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'web' })
  35. f.store.putSnapshot(owner, 'tail', 'messages', profile, sessionID, response({
  36. messages: [{ id: 'latest', role: 'assistant', content: `![报告](${sourcePath})` }],
  37. pagination: { total: 2, offset: 0, has_more: true },
  38. }))
  39. f.store.storeAttachment(owner, sourcePath, response('report-bytes', 200, 'image/png'))
  40. const asset = f.store.attachment(owner, sourcePath)!
  41. const changed = vi.fn()
  42. coordinator.onSynchronized = changed
  43. const result = await coordinator.messages(owner, 'older', profile, sessionID, 1, 1, async () => {
  44. f.store.recordCommand(owner, profile, sessionID, 'prompt.submit', { text: '新消息' })
  45. return response({ detail: 'Session not found' }, 404)
  46. })
  47. expect(result.response.status).toBe(404)
  48. expect(f.store.ownsSession(owner, profile, sessionID)).toBe(true)
  49. expect(JSON.parse(f.store.messagePage(owner, profile, sessionID, 0, 1)!.response.body.toString()).messages)
  50. .toEqual([expect.objectContaining({ id: 'latest' })])
  51. expect(readFileSync(asset.localPath, 'utf8')).toBe('report-bytes')
  52. expect(changed).not.toHaveBeenCalled()
  53. await coordinator.messages(owner, 'older', profile, sessionID, 1, 1,
  54. async () => response({ detail: 'Session not found' }, 404))
  55. expect(f.store.ownsSession(owner, profile, sessionID)).toBe(false)
  56. expect(changed).toHaveBeenCalledWith(owner, profile, sessionID)
  57. } finally { coordinator.close(); f.store.close() }
  58. },
  59. )
  60. it('cleans confirmed missing sessions behind a warm list without pruning filtered or unavailable sessions', async () => {
  61. const f = fixture()
  62. const ids = ['missing', 'archived', 'unavailable', 'new-session', 'other-profile']
  63. for (const id of ids) {
  64. const scope = id === 'other-profile' ? 'another' : profile
  65. f.store.recordCommand(owner, scope, id, 'session.create', { source: 'web' })
  66. f.store.putSnapshot(owner, `detail:${id}`, 'detail', scope, id, response({ id, profile: scope, title: id }))
  67. }
  68. f.store.db.prepare('UPDATE chat_sessions SET owned_at=? WHERE session_id<>?').run(Date.now() - 120_000, 'new-session')
  69. const request = vi.mocked(f.coordinator.upstream.request).mockImplementation(async path => {
  70. if (path.endsWith('/archived')) return response({ id: 'archived', archived: true })
  71. if (path.endsWith('/unavailable')) return response({}, 503)
  72. return response({ detail: 'Session not found' }, 404)
  73. })
  74. const changed = vi.fn()
  75. f.coordinator.onSynchronized = changed
  76. const load = vi.fn(async () => response({ sessions: [], total: 0 }))
  77. await f.coordinator.read(owner, 'list', 'list', profile, undefined, load, true)
  78. await vi.waitFor(() => expect(f.store.ownsSession(owner, profile, 'missing')).toBe(false))
  79. expect(f.store.ownsSession(owner, profile, 'archived')).toBe(true)
  80. expect(f.store.ownsSession(owner, profile, 'unavailable')).toBe(true)
  81. expect(f.store.ownsSession(owner, profile, 'new-session')).toBe(true)
  82. expect(f.store.ownsSession(owner, 'another', 'other-profile')).toBe(true)
  83. expect(request.mock.calls.every(([, options]) => !options?.method || options.method === 'GET')).toBe(true)
  84. expect(changed).toHaveBeenCalledWith(owner, profile, 'missing')
  85. await f.coordinator.read(owner, 'list', 'list', profile, undefined, load, true)
  86. expect(load).toHaveBeenCalledTimes(1)
  87. f.store.close()
  88. })
  89. it('removes a missing session when reconciliation receives a definitive 404', async () => {
  90. const f = fixture()
  91. f.store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'web' })
  92. f.store.putSnapshot(owner, 'detail', 'detail', profile, sessionID, response({ id: sessionID, title: 'ghost' }))
  93. vi.mocked(f.coordinator.upstream.request).mockResolvedValue(response({ detail: 'Session not found' }, 404))
  94. await f.coordinator.reconcile(owner, profile, sessionID).catch(() => {})
  95. expect(f.store.ownsSession(owner, profile, sessionID)).toBe(false)
  96. expect(f.store.localDetail(owner, profile, sessionID)).toBeUndefined()
  97. f.store.close()
  98. })
  99. it('keeps local chats during a list outage and ignores deletion probes superseded by a new turn', async () => {
  100. const f = fixture()
  101. f.store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'web' })
  102. f.store.putSnapshot(owner, 'detail', 'detail', profile, sessionID, response({ id: sessionID, title: '保留' }))
  103. f.store.db.prepare('UPDATE chat_sessions SET owned_at=?').run(Date.now() - 120_000)
  104. const load = vi.fn().mockRejectedValueOnce(new Error('offline')).mockResolvedValue(response({ sessions: [] }))
  105. vi.useFakeTimers({ toFake: ['Date'] })
  106. try {
  107. await f.coordinator.read(owner, 'list', 'list', profile, undefined, load, true)
  108. await new Promise(resolve => setTimeout(resolve, 0))
  109. expect(f.store.ownsSession(owner, profile, sessionID)).toBe(true)
  110. vi.setSystemTime(Date.now() + 31_000)
  111. vi.mocked(f.coordinator.upstream.request).mockImplementation(async () => {
  112. f.store.recordCommand(owner, profile, sessionID, 'prompt.submit', { text: 'new turn' })
  113. return response({}, 404)
  114. })
  115. await f.coordinator.read(owner, 'list', 'list', profile, undefined, load, true)
  116. await vi.waitFor(() => expect(f.coordinator.upstream.request).toHaveBeenCalled())
  117. expect(f.store.ownsSession(owner, profile, sessionID)).toBe(true)
  118. } finally { vi.useRealTimers(); f.store.close() }
  119. })
  120. it('keeps acknowledged flags across aliases and rejects a deleted session sync', () => {
  121. const f = fixture()
  122. f.store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'web' })
  123. f.store.putSnapshot(owner, 'detail', 'detail', profile, sessionID, response({ id: sessionID, is_pinned: false }))
  124. const revision = f.store.revision(owner, profile, sessionID)
  125. f.store.recordSessionPatch(owner, profile, sessionID, { pinned: true })
  126. expect(JSON.parse(f.store.localDetail(owner, profile, sessionID)!.response.body.toString()).pinned).toBe(true)
  127. expect(f.store.applySync(owner, profile, sessionID, revision, [], false)).toBe(false)
  128. f.store.deleteSession(owner, profile, sessionID)
  129. expect(f.store.applySync(owner, profile, sessionID, 0, [], true)).toBe(false)
  130. f.store.close()
  131. })
  132. it('keeps session metadata when message envelopes only contain session_id and advances activity from messages', () => {
  133. const f = fixture()
  134. f.store.recordRoute(owner, profile, sessionID, 'runtime-web')
  135. f.store.putSnapshot(owner, 'detail', 'detail', profile, sessionID, response({
  136. id: sessionID, profile, source: 'ios', title: '准备 GPT-6 详细 Markdown 文档',
  137. started_at: 1_788_661_637, last_active: 1_788_661_640, pinned: true, message_count: 1,
  138. }))
  139. f.store.putSnapshot(owner, 'tail', 'messages', profile, sessionID, response({
  140. session_id: sessionID, model: 'test-model',
  141. messages: [{ id: 'latest', timestamp: 1_788_663_697.824613, role: 'assistant', content: '完成' }],
  142. pagination: { total: 2, offset: 0 },
  143. }))
  144. f.store.putSnapshot(owner, 'older', 'messages', profile, sessionID, response({
  145. session_id: sessionID,
  146. messages: [{ id: 'first', timestamp: 1_788_661_640_000, role: 'user', content: '开始' }],
  147. pagination: { total: 2, offset: 1 },
  148. }))
  149. const summary = JSON.parse(f.store.localDetail(owner, profile, sessionID)!.response.body.toString())
  150. expect(summary).toMatchObject({
  151. id: sessionID, profile, source: 'ios', title: '准备 GPT-6 详细 Markdown 文档',
  152. started_at: 1_788_661_637, last_active: 1_788_663_697.824613,
  153. pinned: true, model: 'test-model', message_count: 2,
  154. })
  155. expect(summary).not.toHaveProperty('messages')
  156. expect(summary).not.toHaveProperty('pagination')
  157. f.store.close()
  158. })
  159. it('repairs overwritten summaries on restart using message time instead of cache refresh time', () => {
  160. const f = fixture()
  161. f.store.recordRoute(owner, profile, sessionID, 'runtime-web')
  162. f.store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'ios' })
  163. f.store.putSnapshot(owner, 'messages', 'messages', profile, sessionID, response({
  164. session_id: sessionID,
  165. messages: [
  166. { id: 'first', timestamp: '2026-09-06T03:00:00Z', role: 'user', content: '开始' },
  167. { id: 'last', timestamp: 1_788_663_697.824613, role: 'assistant', content: '完成' },
  168. ],
  169. pagination: { total: 2, offset: 0 },
  170. }))
  171. f.store.db.prepare(`UPDATE chat_sessions SET data=?,owned_at=?,last_synced_at=?
  172. WHERE owner=? AND profile=? AND session_id=?`).run(JSON.stringify({
  173. session_id: sessionID, title: '准备 GPT-6 详细 Markdown 文档', model: 'test-model',
  174. messages: [], pagination: { total: 2 },
  175. }), 1_788_661_637_003, 1_900_000_000_000, owner, profile, sessionID)
  176. f.store.db.prepare("DELETE FROM chat_meta WHERE key='session-activity-v1'").run()
  177. f.store.close()
  178. const restored = new ChatCacheStore(f.home)
  179. const summary = JSON.parse(restored.localList(owner, profile, {}).body.toString()).sessions[0]
  180. expect(summary).toMatchObject({
  181. id: sessionID, source: 'ios', title: '准备 GPT-6 详细 Markdown 文档',
  182. started_at: 1_788_663_600, last_active: 1_788_663_697.824613, message_count: 2,
  183. })
  184. expect(summary).not.toHaveProperty('messages')
  185. expect(summary).not.toHaveProperty('pagination')
  186. restored.close()
  187. const again = new ChatCacheStore(f.home)
  188. expect(JSON.parse(again.localList(owner, profile, {}).body.toString()).sessions[0]).toEqual(summary)
  189. again.close()
  190. })
  191. it('orders owned conversations by real time across seconds and milliseconds', () => {
  192. const f = fixture()
  193. for (const [id, activity] of [['older', 1_788_561_050_759], ['newer', 1_788_663_697.824613]] as const) {
  194. f.store.recordRoute(owner, profile, id, `runtime-${id}`)
  195. f.store.putSnapshot(owner, id, 'detail', profile, id, response({ id, last_active: activity }))
  196. }
  197. expect(JSON.parse(f.store.localList(owner, profile, {}).body.toString()).sessions.map((s: { id: string }) => s.id))
  198. .toEqual(['newer', 'older'])
  199. f.store.close()
  200. })
  201. it('hydrates all owned list metadata before paging and keeps upstream activity aliases authoritative', async () => {
  202. const f = fixture()
  203. const sessions = Array.from({ length: 103 }, (_, index) => ({
  204. id: `owned-${index}`, profile, source: 'web', title: `Conversation ${index}`,
  205. started_at: 1_700_000_000 - index, last_active_at: 1_700_000_100 - index,
  206. message_count: 0, pinned: index === 102,
  207. }))
  208. for (const session of sessions) {
  209. f.store.recordCommand(owner, profile, session.id, 'session.create', { source: 'web' })
  210. }
  211. const load = vi.fn(async () => response({ sessions: [
  212. { id: 'unowned-history', profile, source: 'web', title: 'Private native history' }, ...sessions,
  213. ] }))
  214. const first = await f.coordinator.read(owner, 'first', 'list', profile, undefined, load, true, { limit: 100 })
  215. const firstPage = JSON.parse(first.response.body.toString())
  216. expect(firstPage.sessions[0]).toMatchObject({ id: 'owned-102', last_active: sessions[102]!.last_active_at })
  217. expect(firstPage.sessions[1]).toMatchObject({ id: 'owned-0', last_active: sessions[0]!.last_active_at })
  218. expect(f.store.needsListMetadata(owner, profile)).toBe(false)
  219. const second = await f.coordinator.read(owner, 'second', 'list', profile, undefined, load, true, { offset: 100, limit: 100 })
  220. expect(second.source).toBe('local')
  221. expect(load).toHaveBeenCalledTimes(1)
  222. const secondPage = JSON.parse(second.response.body.toString())
  223. const ids = [...firstPage.sessions, ...secondPage.sessions].map((session: { id: string }) => session.id)
  224. expect(ids).toEqual(['owned-102', ...sessions.slice(0, 102).map(session => session.id)])
  225. expect(f.store.snapshot(owner, 'first')!.response.body.toString()).not.toContain('unowned-history')
  226. expect(f.store.localDetail(owner, profile, 'unowned-history')).toBeUndefined()
  227. f.store.close()
  228. })
  229. it('serves a warm list without a second 9119 request and survives restart', async () => {
  230. const f = fixture()
  231. f.store.recordRoute(owner, profile, sessionID, 'runtime-web')
  232. f.store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'web' })
  233. const search = new URLSearchParams({ source: 'web', profile })
  234. const key = chatCacheKey('list', profile, undefined, search)
  235. const load = vi.fn(async () => response({ sessions: [{ id: sessionID, profile, source: 'web', title: '本地会话' }] }))
  236. f.store.putSnapshot(owner, 'seed-detail', 'detail', profile, sessionID, response({ id: sessionID, profile, title: '本地会话', message_count: 0 }))
  237. const first = await f.coordinator.read(owner, key, 'list', profile, undefined, load, true)
  238. const second = await f.coordinator.read(owner, key, 'list', profile, undefined, load, true)
  239. expect(first.source).toBe('local')
  240. expect(second.source).toBe('local')
  241. expect(load).not.toHaveBeenCalled()
  242. f.store.close()
  243. const restored = new ChatCacheStore(f.home)
  244. const cached = restored.localList(owner, profile, {})
  245. expect(JSON.parse(cached.body.toString()).sessions[0].title).toBe('本地会话')
  246. restored.close()
  247. })
  248. it('reprojects a pre-ownership list snapshot after restart before serving it', async () => {
  249. const f = fixture()
  250. const search = new URLSearchParams({ profile, offset: '0', limit: '100' })
  251. const key = chatCacheKey('list', profile, undefined, search)
  252. f.store.recordRoute(owner, profile, sessionID, 'runtime-web')
  253. f.store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'ios' })
  254. f.store.putSnapshot(owner, key, 'list', profile, undefined, response({
  255. sessions: [{ id: sessionID, profile, source: 'ios', title: '旧快照标题' }],
  256. total: 1,
  257. }), true)
  258. f.store.db.prepare(`UPDATE chat_sessions SET authoritative_title=NULL,authoritative_title_at=0
  259. WHERE owner=? AND profile=? AND session_id=?`).run(owner, profile, sessionID)
  260. expect(JSON.parse(f.store.snapshot(owner, key)!.response.body.toString()).sessions[0].owned)
  261. .toBeUndefined()
  262. f.store.close()
  263. const restored = new ChatCacheStore(f.home)
  264. expect(restored.snapshot(owner, key)?.state).toBe('stale')
  265. expect((restored.db.prepare(`SELECT authoritative_title FROM chat_sessions
  266. WHERE owner=? AND profile=? AND session_id=?`).get(owner, profile, sessionID) as { authoritative_title: string }).authoritative_title)
  267. .toBe('旧快照标题')
  268. const coordinator = new ChatCacheCoordinator(
  269. restored,
  270. { request: vi.fn() } as unknown as UpstreamServiceSession,
  271. 'prefer-local',
  272. )
  273. const projected = await coordinator.read(
  274. owner,
  275. key,
  276. 'list',
  277. profile,
  278. undefined,
  279. async () => { throw new Error('9119 offline') },
  280. true,
  281. { offset: 0, limit: 100 },
  282. )
  283. expect(projected).toMatchObject({ source: 'local', state: 'current' })
  284. expect(JSON.parse(projected.response.body.toString()).sessions[0]).toMatchObject({
  285. id: sessionID,
  286. source: 'ios',
  287. owned: true,
  288. })
  289. restored.close()
  290. })
  291. it('adopts the released v0.3.26 registry once without using source for later membership', () => {
  292. const home = mkdtempSync(join(tmpdir(), 'yaoyao-v026-registry-'))
  293. homes.push(home)
  294. const legacy = new DatabaseSync(join(home, 'chat-cache.sqlite3'))
  295. legacy.exec(`CREATE TABLE chat_sessions(
  296. owner TEXT NOT NULL, profile TEXT NOT NULL, session_id TEXT NOT NULL,
  297. runtime_id TEXT, source TEXT NOT NULL, data TEXT NOT NULL,
  298. sync_state TEXT NOT NULL DEFAULT 'stale', complete INTEGER NOT NULL DEFAULT 0,
  299. last_event_seq INTEGER NOT NULL DEFAULT 0, last_synced_at INTEGER NOT NULL,
  300. PRIMARY KEY(owner,profile,session_id))`)
  301. legacy.prepare(`INSERT INTO chat_sessions(
  302. owner,profile,session_id,runtime_id,source,data,sync_state,complete,last_event_seq,last_synced_at
  303. ) VALUES(?,?,?,?,?,?,?,?,?,?)`).run(
  304. owner,
  305. profile,
  306. 'v026-session',
  307. null,
  308. 'web',
  309. JSON.stringify({ id: 'v026-session', profile, source: 'web', title: '已发布旧会话' }),
  310. 'current',
  311. 0,
  312. 0,
  313. 1_000,
  314. )
  315. legacy.close()
  316. const migrated = new ChatCacheStore(home, owner)
  317. expect(migrated.ownsSession(owner, profile, 'v026-session')).toBe(true)
  318. expect(migrated.db.prepare(`SELECT ownership_evidence FROM chat_sessions
  319. WHERE owner=? AND profile=? AND session_id=?`).get(owner, profile, 'v026-session'))
  320. .toMatchObject({ ownership_evidence: 'legacy-single-user' })
  321. expect(migrated.putSnapshot(owner, 'new-source-row', 'detail', profile, 'new-source-row',
  322. response({ id: 'new-source-row', profile, source: 'web', title: '未登记新历史' }))).toBe(false)
  323. expect(migrated.ownsSession(owner, profile, 'new-source-row')).toBe(false)
  324. migrated.close()
  325. })
  326. it('does not adopt ambiguous multi-owner legacy cache rows without command evidence', () => {
  327. const home = mkdtempSync(join(tmpdir(), 'yaoyao-v026-multi-owner-'))
  328. homes.push(home)
  329. const legacy = new DatabaseSync(join(home, 'chat-cache.sqlite3'))
  330. legacy.exec(`CREATE TABLE chat_sessions(
  331. owner TEXT NOT NULL, profile TEXT NOT NULL, session_id TEXT NOT NULL,
  332. runtime_id TEXT, source TEXT NOT NULL, data TEXT NOT NULL,
  333. sync_state TEXT NOT NULL DEFAULT 'stale', complete INTEGER NOT NULL DEFAULT 0,
  334. last_event_seq INTEGER NOT NULL DEFAULT 0, last_synced_at INTEGER NOT NULL,
  335. PRIMARY KEY(owner,profile,session_id));
  336. CREATE TABLE chat_events(
  337. owner TEXT NOT NULL, profile TEXT NOT NULL, session_id TEXT NOT NULL,
  338. event_seq INTEGER NOT NULL, event_type TEXT NOT NULL, payload TEXT NOT NULL,
  339. received_at INTEGER NOT NULL, PRIMARY KEY(owner,profile,session_id,event_seq,event_type));`)
  340. const insertSession = legacy.prepare(`INSERT INTO chat_sessions(
  341. owner,profile,session_id,runtime_id,source,data,sync_state,complete,last_event_seq,last_synced_at
  342. ) VALUES(?,?,?,?,?,?,?,?,?,?)`)
  343. for (const [legacyOwner, id] of [
  344. ['owner-a', 'evidenced'],
  345. ['owner-a', 'source-only-a'],
  346. ['owner-b', 'source-only-b'],
  347. ] as const) {
  348. insertSession.run(
  349. legacyOwner,
  350. profile,
  351. id,
  352. null,
  353. 'web',
  354. JSON.stringify({ id, profile, source: 'web', title: id }),
  355. 'current',
  356. 0,
  357. 0,
  358. 1_000,
  359. )
  360. }
  361. legacy.prepare(`INSERT INTO chat_events(
  362. owner,profile,session_id,event_seq,event_type,payload,received_at
  363. ) VALUES(?,?,?,?,?,?,?)`).run(
  364. 'owner-a', profile, 'evidenced', 1, 'command:session.create', '{}', 900,
  365. )
  366. legacy.close()
  367. const migrated = new ChatCacheStore(home, 'owner-a')
  368. expect(migrated.ownsSession('owner-a', profile, 'evidenced')).toBe(true)
  369. expect(migrated.ownsSession('owner-a', profile, 'source-only-a')).toBe(false)
  370. expect(migrated.ownsSession('owner-b', profile, 'source-only-b')).toBe(false)
  371. migrated.close()
  372. })
  373. it('does not adopt one cached owner when the sole local user has another id', () => {
  374. const home = mkdtempSync(join(tmpdir(), 'yaoyao-v026-owner-mismatch-'))
  375. homes.push(home)
  376. const legacy = new DatabaseSync(join(home, 'chat-cache.sqlite3'))
  377. legacy.exec(`CREATE TABLE chat_sessions(
  378. owner TEXT NOT NULL, profile TEXT NOT NULL, session_id TEXT NOT NULL,
  379. runtime_id TEXT, source TEXT NOT NULL, data TEXT NOT NULL,
  380. sync_state TEXT NOT NULL DEFAULT 'stale', complete INTEGER NOT NULL DEFAULT 0,
  381. last_event_seq INTEGER NOT NULL DEFAULT 0, last_synced_at INTEGER NOT NULL,
  382. PRIMARY KEY(owner,profile,session_id))`)
  383. legacy.prepare(`INSERT INTO chat_sessions(
  384. owner,profile,session_id,runtime_id,source,data,sync_state,complete,last_event_seq,last_synced_at
  385. ) VALUES(?,?,?,?,?,?,?,?,?,?)`).run(
  386. 'cached-old-user', profile, 'source-only', null, 'web',
  387. JSON.stringify({ id: 'source-only', profile, source: 'web', title: '旧缓存' }),
  388. 'current', 0, 0, 1_000,
  389. )
  390. legacy.close()
  391. const migrated = new ChatCacheStore(home, 'current-sole-user')
  392. expect(migrated.ownsSession('cached-old-user', profile, 'source-only')).toBe(false)
  393. migrated.close()
  394. })
  395. it('re-enters ownership migration when columns exist but the completion marker is missing', () => {
  396. const home = mkdtempSync(join(tmpdir(), 'yaoyao-v026-interrupted-migration-'))
  397. homes.push(home)
  398. const interrupted = new DatabaseSync(join(home, 'chat-cache.sqlite3'))
  399. interrupted.exec(`CREATE TABLE chat_sessions(
  400. owner TEXT NOT NULL, profile TEXT NOT NULL, session_id TEXT NOT NULL,
  401. runtime_id TEXT, source TEXT NOT NULL, data TEXT NOT NULL,
  402. authoritative_title TEXT, authoritative_title_at INTEGER NOT NULL DEFAULT 0,
  403. owned_at INTEGER, ownership_evidence TEXT,
  404. sync_state TEXT NOT NULL DEFAULT 'stale', complete INTEGER NOT NULL DEFAULT 0,
  405. last_event_seq INTEGER NOT NULL DEFAULT 0, last_synced_at INTEGER NOT NULL,
  406. PRIMARY KEY(owner,profile,session_id))`)
  407. interrupted.prepare(`INSERT INTO chat_sessions(
  408. owner,profile,session_id,runtime_id,source,data,sync_state,complete,last_event_seq,last_synced_at
  409. ) VALUES(?,?,?,?,?,?,?,?,?,?)`).run(
  410. owner, profile, 'interrupted-row', null, 'web',
  411. JSON.stringify({ id: 'interrupted-row', profile, source: 'web', title: '可恢复旧会话' }),
  412. 'current', 0, 0, 1_000,
  413. )
  414. interrupted.close()
  415. const recovered = new ChatCacheStore(home, owner)
  416. expect(recovered.ownsSession(owner, profile, 'interrupted-row')).toBe(true)
  417. expect(recovered.db.prepare("SELECT value FROM chat_meta WHERE key='ownership-registry-v1'").get())
  418. .toMatchObject({ value: 'complete' })
  419. recovered.close()
  420. })
  421. it('materializes complete message pages and keeps owners isolated', () => {
  422. const f = fixture()
  423. f.store.recordRoute(owner, profile, sessionID, 'runtime-web')
  424. f.store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'web' })
  425. f.store.putSnapshot(owner, 'detail', 'detail', profile, sessionID,
  426. response({ id: sessionID, profile, source: 'web', title: '对话' }))
  427. f.store.putSnapshot(owner, 'messages', 'messages', profile, sessionID, response({
  428. session: { id: sessionID, profile, source: 'web', title: '对话' },
  429. messages: [
  430. { id: 'm1', role: 'user', content: '开始' },
  431. { id: 'm2', role: 'assistant', content: '完成' },
  432. ],
  433. pagination: { total: 2, returned: 2, offset: 0, limit: 100, hasMore: false },
  434. }))
  435. const page = f.store.messagePage(owner, profile, sessionID, 0, 100)
  436. const payload = JSON.parse(page!.response.body.toString())
  437. expect(payload.session_id).toBe(sessionID)
  438. expect(payload.pagination).toMatchObject({ total: 2, returned: 2, offset: 0, limit: 100, has_more: false })
  439. expect(payload.messages.map((item: { id: string }) => item.id)).toEqual(['m1', 'm2'])
  440. expect(f.store.messagePage('another-owner', profile, sessionID, 0, 100)).toBeUndefined()
  441. f.store.close()
  442. })
  443. it('never persists unregistered history regardless of source metadata', () => {
  444. const f = fixture()
  445. expect(f.store.putSnapshot(owner, 'history', 'detail', profile, 'history-1',
  446. response({ id: 'history-1', profile, source: 'telegram', title: '历史' }))).toBe(false)
  447. expect(f.store.putSnapshot(owner, 'unowned-web', 'detail', profile, 'unowned-web',
  448. response({ id: 'unowned-web', profile, source: 'web', title: '未登记 Web 历史' }))).toBe(false)
  449. expect(f.store.snapshot(owner, 'history')).toBeUndefined()
  450. expect(f.store.snapshot(owner, 'unowned-web')).toBeUndefined()
  451. f.store.close()
  452. })
  453. it('marks event gaps stale, deduplicates events, and falls back while 9119 is offline', async () => {
  454. const f = fixture()
  455. const key = 'detail-key'
  456. f.store.recordRoute(owner, profile, sessionID, 'runtime-1')
  457. f.store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'web' })
  458. f.store.putSnapshot(owner, key, 'detail', profile, sessionID,
  459. response({ id: sessionID, profile, source: 'web', title: '稳定内容' }))
  460. const frame = { type: 'message.delta', seq: 2, payload: { text: '增量' } }
  461. f.store.recordEvent(owner, profile, sessionID, frame)
  462. f.store.recordEvent(owner, profile, sessionID, frame)
  463. const count = f.store.db.prepare("SELECT COUNT(*) count FROM chat_events WHERE event_type='message.delta'").get() as { count: number }
  464. expect(count.count).toBe(1)
  465. expect(f.store.ownsSession(owner, profile, sessionID)).toBe(true)
  466. expect(f.store.ownsSession('another-owner', profile, sessionID)).toBe(false)
  467. const route = f.store.db.prepare('SELECT runtime_id FROM chat_sessions WHERE owner=? AND profile=? AND session_id=?')
  468. .get(owner, profile, sessionID) as { runtime_id: string }
  469. expect(route.runtime_id).toBe('runtime-1')
  470. const fallback = await f.coordinator.read(owner, key, 'detail', profile, sessionID,
  471. async () => { throw new Error('9119 offline') })
  472. expect(fallback).toMatchObject({ source: 'local', state: 'stale' })
  473. const serverFailure = await f.coordinator.read(owner, key, 'detail', profile, sessionID,
  474. async () => response({ error: 'unavailable' }, 503))
  475. expect(serverFailure).toMatchObject({ source: 'local', state: 'stale' })
  476. f.store.close()
  477. })
  478. it.each(['session.title', 'session.title.updated'])(
  479. 'projects %s into the owned summary without changing source metadata',
  480. (eventType) => {
  481. const f = fixture()
  482. const ownedSessionID = `owned-${eventType}`
  483. const listKey = chatCacheKey('list', profile, undefined, new URLSearchParams({ source: 'web', profile }))
  484. f.store.recordRoute(owner, profile, ownedSessionID, 'runtime-owned')
  485. f.store.recordCommand(owner, profile, ownedSessionID, 'session.create', { source: 'ios' })
  486. f.store.putSnapshot(owner, listKey, 'list', profile, undefined, response({ sessions: [] }), true)
  487. f.store.recordEvent(owner, profile, ownedSessionID, {
  488. type: eventType,
  489. payload: { session_id: ownedSessionID, title: '服务端生成的标题' },
  490. })
  491. expect(f.store.snapshot(owner, listKey)?.state).toBe('stale')
  492. const merged = f.store.mergeOwnedSessionsIntoList(owner, profile, response({ sessions: [], total: 0 }))
  493. expect(JSON.parse(merged.body.toString()).sessions).toContainEqual(expect.objectContaining({
  494. id: ownedSessionID,
  495. profile,
  496. source: 'ios',
  497. owned: true,
  498. title: '服务端生成的标题',
  499. }))
  500. const staleList = f.store.mergeOwnedSessionsIntoList(owner, profile, response({ sessions: [{
  501. id: ownedSessionID, profile, source: 'ios', title: '新对话',
  502. }] }))
  503. expect(JSON.parse(staleList.body.toString()).sessions[0].title).toBe('服务端生成的标题')
  504. const titleVersion = f.store.db.prepare(`SELECT authoritative_title_at FROM chat_sessions
  505. WHERE owner=? AND profile=? AND session_id=?`).get(owner, profile, ownedSessionID) as { authoritative_title_at: number }
  506. expect(f.store.putSnapshot(owner, 'delayed-detail', 'detail', profile, ownedSessionID,
  507. response({ id: ownedSessionID, profile, source: 'ios', title: '迟到的旧标题' }), false,
  508. titleVersion.authoritative_title_at - 1)).toBe(true)
  509. const afterDelayedDetail = f.store.markSessionOwnership(owner, profile, ownedSessionID,
  510. response({ id: ownedSessionID, profile, source: 'ios', title: '迟到的旧标题' }))
  511. expect(JSON.parse(afterDelayedDetail.body.toString()).title).toBe('服务端生成的标题')
  512. expect(f.store.putSnapshot(owner, 'newer-detail', 'detail', profile, ownedSessionID,
  513. response({ id: ownedSessionID, profile, source: 'ios', title: '后续详情标题' }), false,
  514. titleVersion.authoritative_title_at + 1)).toBe(true)
  515. expect(f.store.putSnapshot(owner, 'regressed-detail', 'detail', profile, ownedSessionID,
  516. response({ id: ownedSessionID, profile, source: 'ios', title: '新对话' }))).toBe(true)
  517. const afterDetail = f.store.markSessionOwnership(owner, profile, ownedSessionID,
  518. response({ id: ownedSessionID, profile, source: 'ios', title: '新对话' }))
  519. expect(JSON.parse(afterDetail.body.toString()).title).toBe('后续详情标题')
  520. f.store.close()
  521. },
  522. )
  523. it('accepts detail and messages for a registered session regardless of source metadata', () => {
  524. const f = fixture()
  525. const ownedSessionID = 'owned-ios-session'
  526. f.store.recordRoute(owner, profile, ownedSessionID, 'runtime-owned')
  527. f.store.recordCommand(owner, profile, ownedSessionID, 'session.create', { source: 'ios' })
  528. expect(f.store.putSnapshot(owner, 'owned-detail', 'detail', profile, ownedSessionID,
  529. response({ id: ownedSessionID, profile, source: 'ios', title: 'iOS 权威标题' }))).toBe(true)
  530. expect(f.store.putSnapshot(owner, 'owned-messages', 'messages', profile, ownedSessionID, response({
  531. session: { id: ownedSessionID, profile, source: 'ios', title: 'iOS 权威标题' },
  532. messages: [{ id: 'm-ios', role: 'assistant', content: '已同步' }],
  533. pagination: { total: 1, returned: 1, offset: 0, limit: 100, hasMore: false },
  534. }))).toBe(true)
  535. const page = f.store.messagePage(owner, profile, ownedSessionID, 0, 100)
  536. expect(JSON.parse(page!.response.body.toString())).toMatchObject({
  537. session_id: ownedSessionID,
  538. session: { source: 'ios', title: 'iOS 权威标题' },
  539. messages: [{ id: 'm-ios' }],
  540. })
  541. f.store.close()
  542. })
  543. it('caches only attachment paths discovered in source=web messages', () => {
  544. const f = fixture()
  545. f.store.recordRoute(owner, profile, sessionID, 'runtime-web')
  546. f.store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'web' })
  547. const sourcePath = '/Users/test/.hermes/profiles/default/images/result.png'
  548. f.store.putSnapshot(owner, 'messages', 'messages', profile, sessionID, response({
  549. session: { id: sessionID, profile, source: 'web' },
  550. messages: [{ id: 'm1', role: 'assistant', content: `![结果](${sourcePath})` }],
  551. pagination: { total: 1, offset: 0, hasMore: false },
  552. }))
  553. expect(f.store.knowsAttachment(owner, sourcePath)).toBe(true)
  554. const bytes = response('image-data', 200, 'image/png')
  555. expect(f.store.storeAttachment(owner, sourcePath, bytes)).toBeTruthy()
  556. expect(f.store.attachment(owner, sourcePath)?.mimeType).toBe('image/png')
  557. expect(f.store.knowsAttachment(owner, '/Users/test/history.png')).toBe(false)
  558. f.store.close()
  559. })
  560. it('supports shadow and upstream-only rollback modes', async () => {
  561. const home = mkdtempSync(join(tmpdir(), 'yaoyao-chat-cache-modes-')); homes.push(home)
  562. const store = new ChatCacheStore(home)
  563. const upstream = { request: vi.fn() } as unknown as UpstreamServiceSession
  564. store.recordRoute(owner, profile, sessionID, 'runtime-web')
  565. store.recordCommand(owner, profile, sessionID, 'session.create', { source: 'web' })
  566. const load = vi.fn(async () => response({ sessions: [{ id: sessionID, profile, source: 'web' }] }))
  567. const search = new URLSearchParams({ source: 'web' })
  568. const key = chatCacheKey('list', profile, undefined, search)
  569. const shadow = new ChatCacheCoordinator(store, upstream, 'shadow')
  570. await shadow.read(owner, key, 'list', profile, undefined, load, true)
  571. await shadow.read(owner, key, 'list', profile, undefined, load, true)
  572. expect(load).toHaveBeenCalledTimes(2)
  573. const upstreamOnly = new ChatCacheCoordinator(store, upstream, 'upstream-only')
  574. const upstreamResult = await upstreamOnly.read(owner, key, 'list', profile, undefined, load, true)
  575. expect(load).toHaveBeenCalledTimes(3)
  576. expect(JSON.parse(upstreamResult.response.body.toString()).sessions).toEqual([
  577. expect.objectContaining({ id: sessionID, owned: true }),
  578. ])
  579. upstreamOnly.route(owner, profile, 'upstream-only-created', 'runtime-upstream-only')
  580. expect(store.ownsSession(owner, profile, 'upstream-only-created')).toBe(true)
  581. store.close()
  582. })
  583. it('paginates the complete owner registry before slicing regardless of mixed upstream history', () => {
  584. const f = fixture()
  585. for (let index = 0; index < 15; index++) {
  586. const id = `owned-${index}`
  587. const source = index % 2 ? 'ios' : 'web'
  588. f.store.recordRoute(owner, profile, id, `runtime-${index}`)
  589. f.store.recordCommand(owner, profile, id, 'session.create', { source })
  590. expect(f.store.putSnapshot(owner, `detail-${index}`, 'detail', profile, id, response({
  591. id,
  592. profile,
  593. source,
  594. title: `Owned ${index}`,
  595. last_active: 1_000 + index,
  596. }))).toBe(true)
  597. }
  598. const upstreamSessions = [
  599. ...Array.from({ length: 40 }, (_, index) => ({
  600. id: `unowned-${index}`,
  601. profile,
  602. source: index % 2 ? 'ios' : 'web',
  603. title: `Unowned ${index}`,
  604. last_active: 10_000 + index,
  605. })),
  606. ...Array.from({ length: 15 }, (_, index) => ({
  607. id: `owned-${index}`,
  608. profile,
  609. source: index % 2 ? 'ios' : 'web',
  610. title: '新对话',
  611. last_active: 1_000 + index,
  612. })),
  613. ]
  614. const merged = f.store.mergeOwnedSessionsIntoList(
  615. owner,
  616. profile,
  617. response({ sessions: upstreamSessions, total: upstreamSessions.length }),
  618. { offset: 5, limit: 4, archived: 'exclude' },
  619. )
  620. const payload = JSON.parse(merged.body.toString())
  621. expect(payload).toMatchObject({ total: 15, offset: 5, limit: 4 })
  622. expect(payload.sessions.map((session: { id: string }) => session.id))
  623. .toEqual(['owned-9', 'owned-8', 'owned-7', 'owned-6'])
  624. expect(payload.sessions.every((session: { owned?: boolean }) => session.owned === true)).toBe(true)
  625. const history = f.store.excludeOwnedSessionsFromList(
  626. owner,
  627. profile,
  628. response({ sessions: upstreamSessions, total: upstreamSessions.length }),
  629. { offset: 7, limit: 5 },
  630. )
  631. const historyPayload = JSON.parse(history.body.toString())
  632. expect(historyPayload).toMatchObject({ total: 40, offset: 7, limit: 5 })
  633. expect(historyPayload.sessions.map((session: { id: string }) => session.id))
  634. .toEqual(['unowned-7', 'unowned-8', 'unowned-9', 'unowned-10', 'unowned-11'])
  635. expect(historyPayload.sessions.some((session: { source: string }) => session.source === 'web')).toBe(true)
  636. f.store.close()
  637. })
  638. it('keeps an old pinned owned session on the first page after registry projection', () => {
  639. const f = fixture()
  640. for (let index = 0; index < 101; index++) {
  641. const id = `owned-${index}`
  642. f.store.recordRoute(owner, profile, id, `runtime-${index}`)
  643. f.store.recordCommand(owner, profile, id, 'session.create', { source: 'ios' })
  644. expect(f.store.putSnapshot(owner, `detail-${index}`, 'detail', profile, id, response({
  645. id,
  646. profile,
  647. source: 'ios',
  648. title: `Owned ${index}`,
  649. last_active: 1_000 + index,
  650. pinned: index === 0,
  651. }))).toBe(true)
  652. }
  653. const merged = f.store.mergeOwnedSessionsIntoList(
  654. owner,
  655. profile,
  656. response({ sessions: [], total: 0 }),
  657. { offset: 0, limit: 100, archived: 'exclude' },
  658. )
  659. const payload = JSON.parse(merged.body.toString())
  660. expect(payload).toMatchObject({ total: 101, offset: 0, limit: 100 })
  661. expect(payload.sessions).toHaveLength(100)
  662. expect(payload.sessions[0]).toMatchObject({ id: 'owned-0', pinned: true, owned: true })
  663. expect(payload.sessions.map((session: { id: string }) => session.id)).not.toContain('owned-1')
  664. f.store.close()
  665. })
  666. })
  667. describe('local history and bounded tail synchronization', () => {
  668. function cached(count = 1_000) {
  669. const f = fixture()
  670. f.store.recordRoute(owner, profile, sessionID, 'runtime')
  671. const rows = Array.from({ length: count }, (_, i) => ({ id: `row-${i}`, role: i % 2 ? 'assistant' : 'user', content: `message ${i}` }))
  672. const summary = { id: sessionID, profile, title: 'cached', message_count: count }
  673. f.store.putSnapshot(owner, 'seed', 'messages', profile, sessionID, response({ session: summary, messages: rows,
  674. pagination: { total: count, offset: 0, has_more: false } }))
  675. return { ...f, rows, summary }
  676. }
  677. function upstreamFor(f: ReturnType<typeof cached>, rows: typeof f.rows) {
  678. const request = vi.fn(async (path: string, options: { search: URLSearchParams }) => {
  679. if (!path.endsWith('/messages')) return response({ ...f.summary, message_count: rows.length })
  680. const offset = Number(options.search.get('offset'))
  681. const limit = Number(options.search.get('limit'))
  682. const end = Math.max(0, rows.length - offset), start = Math.max(0, end - limit)
  683. return response({ messages: rows.slice(start, end), pagination: { total: rows.length, offset, has_more: start > 0 } })
  684. })
  685. const coordinator = new ChatCacheCoordinator(f.store, { request } as unknown as UpstreamServiceSession)
  686. return { coordinator, request }
  687. }
  688. it('never rereads stored pages while a new turn invalidates the tail', async () => {
  689. const f = cached()
  690. f.store.recordCommand(owner, profile, sessionID, 'prompt.submit', { text: 'next' })
  691. f.store.recordEvent(owner, profile, sessionID, { type: 'message.delta', seq: 1, payload: { delta: 'new' } })
  692. const load = vi.fn(async () => { throw new Error('must remain local') })
  693. for (let i = 0; i < 20; i++) {
  694. const page = await f.coordinator.messages(owner, `page-${i}`, profile, sessionID, (i % 10) * 100, 100, load)
  695. expect(page.source).toBe('local')
  696. expect(JSON.parse(page.response.body.toString()).messages).toHaveLength(100)
  697. }
  698. expect(load).not.toHaveBeenCalled()
  699. f.store.close()
  700. })
  701. it('persists new rows using one tail page and does not rewrite unchanged history', async () => {
  702. const f = cached()
  703. f.store.db.exec('CREATE TABLE changed_rows(id TEXT); CREATE TRIGGER changed_message AFTER UPDATE ON chat_messages BEGIN INSERT INTO changed_rows VALUES(new.message_id); END')
  704. const rows = [...f.rows, { id: 'new-user', role: 'user', content: 'next' }, { id: 'new-answer', role: 'assistant', content: 'answer' }]
  705. const { coordinator, request } = upstreamFor(f, rows)
  706. coordinator.observe(owner, profile, sessionID, { type: 'message.complete', seq: 1 })
  707. const page = await coordinator.messages(owner, 'last', profile, sessionID, 0, 10, async () => { throw new Error('unexpected read') })
  708. expect(JSON.parse(page.response.body.toString()).messages.at(-1).id).toBe('new-answer')
  709. expect(request.mock.calls.filter(([path]) => path.endsWith('/messages'))).toHaveLength(1)
  710. expect(f.store.db.prepare('SELECT * FROM changed_rows').all()).toEqual([])
  711. expect(f.store.messagePage(owner, profile, sessionID, 902, 100)).toBeDefined()
  712. coordinator.observe(owner, profile, sessionID, { type: 'message.complete', seq: 1 })
  713. expect(request).toHaveBeenCalledTimes(2)
  714. f.store.close()
  715. })
  716. it('fetches only the missing suffix after a replay gap', async () => {
  717. const f = cached()
  718. const rows = [...f.rows, ...Array.from({ length: 130 }, (_, i) => ({ id: `new-${i}`, role: 'assistant', content: 'reply' }))]
  719. const { coordinator, request } = upstreamFor(f, rows)
  720. await coordinator.reconcile(owner, profile, sessionID)
  721. expect(request.mock.calls.filter(([path]) => path.endsWith('/messages'))).toHaveLength(2)
  722. expect(JSON.parse(f.store.messagePage(owner, profile, sessionID, 0, 10)!.response.body.toString()).pagination.total).toBe(1130)
  723. f.store.close()
  724. })
  725. it('refreshes old edits and deletions only when explicitly forced', async () => {
  726. const f = cached()
  727. const rows = f.rows.slice(1).map(row => row.id === 'row-2' ? { ...row, content: 'edited old message' } : row)
  728. const { coordinator, request } = upstreamFor(f, rows)
  729. await expect(coordinator.reconcile(owner, profile, sessionID)).rejects.toThrow('explicit refresh')
  730. expect(JSON.parse(f.store.messagePage(owner, profile, sessionID, 900, 100)!.response.body.toString()).messages[0].id).toBe('row-0')
  731. request.mockClear()
  732. await coordinator.reconcile(owner, profile, sessionID, true)
  733. expect(request.mock.calls.filter(([path]) => path.endsWith('/messages'))).toHaveLength(10)
  734. const page = JSON.parse(f.store.messagePage(owner, profile, sessionID, 899, 100)!.response.body.toString())
  735. expect(page.messages[0].id).toBe('row-1')
  736. expect(page.messages[1].content).toBe('edited old message')
  737. expect(page.pagination.total).toBe(999)
  738. f.store.close()
  739. })
  740. it('does not require a full archive before serving a stored page', async () => {
  741. const f = fixture()
  742. f.store.recordRoute(owner, profile, sessionID, 'runtime')
  743. f.store.putSnapshot(owner, 'tail', 'messages', profile, sessionID, response({ messages: [{ id: 'last', role: 'assistant', content: 'tail' }], pagination: { total: 400, offset: 0, has_more: true } }))
  744. expect(f.store.messagePage(owner, profile, sessionID, 0, 1)).toBeDefined()
  745. expect(f.store.messagePage(owner, profile, sessionID, 1, 100)).toBeUndefined()
  746. f.store.close()
  747. })
  748. it('keeps the previous archive when a forced refresh fails halfway', async () => {
  749. const f = cached()
  750. let calls = 0
  751. const coordinator = new ChatCacheCoordinator(f.store, { request: async (path: string) => {
  752. if (!path.endsWith('/messages')) return response(f.summary)
  753. if (++calls > 1) throw new Error('offline')
  754. return response({ messages: f.rows.slice(-100), pagination: { total: 1000, offset: 0, has_more: true } })
  755. } } as unknown as UpstreamServiceSession)
  756. await expect(coordinator.reconcile(owner, profile, sessionID, true)).rejects.toThrow('offline')
  757. expect(f.store.messagePage(owner, profile, sessionID, 900, 100)).toBeDefined()
  758. f.store.close()
  759. })
  760. it('does not commit an older fetch over a newer event', async () => {
  761. const f = cached()
  762. const { coordinator, request } = upstreamFor(f, f.rows)
  763. const original = request.getMockImplementation()!
  764. request.mockImplementation(async (path, options) => {
  765. const result = await original(path, options)
  766. if (path.endsWith('/messages')) f.store.recordEvent(owner, profile, sessionID, { type: 'message.delta', seq: 2, payload: { delta: 'newer' } })
  767. return result
  768. })
  769. await coordinator.reconcile(owner, profile, sessionID)
  770. expect(f.store.localDetail(owner, profile, sessionID)?.state).toBe('stale')
  771. f.store.close()
  772. })
  773. it('joins an in-flight completion and publishes only after the newest revision commits', async () => {
  774. const f = cached()
  775. const rows = [...f.rows, { id: 'newest', role: 'assistant', content: 'newest reply' }]
  776. const { coordinator, request } = upstreamFor(f, rows)
  777. const original = request.getMockImplementation()!
  778. let release!: () => void
  779. const gate = new Promise<void>(resolve => { release = resolve })
  780. let first = true
  781. request.mockImplementation(async (path, options) => {
  782. if (first) { first = false; await gate }
  783. return original(path, options)
  784. })
  785. const published = vi.fn(() => {
  786. const stored = JSON.parse(f.store.messagePage(owner, profile, sessionID, 0, 1)!.response.body.toString())
  787. expect(stored.messages[0].id).toBe('newest')
  788. })
  789. coordinator.onSynchronized = published
  790. f.store.recordEvent(owner, profile, sessionID, { type: 'message.complete', seq: 1 })
  791. const firstSync = coordinator.reconcile(owner, profile, sessionID)
  792. f.store.recordEvent(owner, profile, sessionID, { type: 'message.complete', seq: 2 })
  793. const latestSync = coordinator.reconcile(owner, profile, sessionID)
  794. release()
  795. await Promise.all([firstSync, latestSync])
  796. expect(published).toHaveBeenCalledTimes(1)
  797. expect(request).toHaveBeenCalledTimes(4)
  798. expect(f.store.localDetail(owner, profile, sessionID)?.state).toBe('current')
  799. f.store.close()
  800. })
  801. it('serves a cached empty conversation without requesting it again', async () => {
  802. const f = cached(0)
  803. const load = vi.fn(async () => { throw new Error('unexpected read') })
  804. const page = await f.coordinator.messages(owner, 'empty', profile, sessionID, 0, 100, load)
  805. expect(page.source).toBe('local')
  806. expect(JSON.parse(page.response.body.toString()).messages).toEqual([])
  807. expect(load).not.toHaveBeenCalled()
  808. f.store.close()
  809. })
  810. })