chatMessageIdentity.test.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169
  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 { ChatCacheStore } from '../../src/server/chatCache.js'
  7. import { mergeChatMessages } from '../../src/client/utils/messageReducer'
  8. import { normalizeChatMessage } from '../../src/client/utils/normalize'
  9. const fixtures: { home: string; store: ChatCacheStore }[] = []
  10. const now = 1_789_089_547_214
  11. const text = '查看服务器情况'
  12. const scope = ['owner', 'server', 'session'] as const
  13. type Row = Record<string, unknown>
  14. function fixture() {
  15. const home = mkdtempSync(join(tmpdir(), 'chat-identity-'))
  16. const f = { home, store: new ChatCacheStore(home) }
  17. fixtures.push(f)
  18. f.store.recordCommand(...scope, 'session.create', { source: 'web' })
  19. return f
  20. }
  21. function response(messages: Row[], offset = 0, total = messages.length + offset) {
  22. return { status: 200, headers: new Headers(), body: Buffer.from(JSON.stringify({
  23. session_id: scope[2], messages, pagination: { total, offset },
  24. })) }
  25. }
  26. function submit(store: ChatCacheStore, client = 'local', at = now, options: Row = {}, outcome = 'confirmed') {
  27. const clock = vi.spyOn(Date, 'now').mockReturnValue(at)
  28. store.recordCommand(...scope, 'prompt.submit', { text, _delivery_id: `web:prompt:${client}`, ...options })
  29. clock.mockReturnValue(at + 34)
  30. if (outcome !== 'pending') store.recordEvent(...scope, {
  31. type: `command.${outcome}`, payload: { delivery_id: `web:prompt:${client}` }, delivery_id: `receipt:${client}`,
  32. })
  33. clock.mockRestore()
  34. }
  35. function row(id = '1155', at = now + 39): Row { return { id, role: 'user', content: text, timestamp: at / 1000 } }
  36. function sync(store: ChatCacheStore, messages: Row[], force = false) {
  37. return store.applySync(...scope, store.revision(...scope), [{
  38. key: 'messages', kind: 'messages', response: response(messages), startedAt: now + 2000,
  39. }], force)
  40. }
  41. function read(store: ChatCacheStore, offset = 0, limit = 100): Row[] {
  42. return JSON.parse(store.messagePage(...scope, offset, limit)!.response.body.toString()).messages
  43. }
  44. afterEach(() => {
  45. vi.restoreAllMocks()
  46. for (const f of fixtures.splice(0)) {
  47. if (!f.store.isClosed) f.store.close()
  48. rmSync(f.home, { recursive: true, force: true })
  49. }
  50. })
  51. describe('ordinary chat submission identities', () => {
  52. it('reconciles the real receipt-before-persistence shape all the way through the client reducer', () => {
  53. const { store } = fixture()
  54. submit(store)
  55. expect(sync(store, [row()])).toBe(true)
  56. const history = read(store).map(value => normalizeChatMessage(value, scope[2], scope[1]))
  57. expect(history[0]).toMatchObject({ id: '1155', clientMessageId: 'local' })
  58. const optimistic = { ...history[0]!, id: 'local', serverMessageId: undefined, stage: 'accepted' as const }
  59. const oldHistory = { ...history[0]!, clientMessageId: undefined }
  60. const merged = mergeChatMessages([optimistic, oldHistory], history, 'snapshot')
  61. expect(merged).toHaveLength(1)
  62. expect(merged[0]).toMatchObject({ id: '1155', stage: 'settled' })
  63. expect(read(store)).toHaveLength(1)
  64. })
  65. it.each([100, 3000])('preserves independent identical sends %i ms apart and unrelated older history', gap => {
  66. const { store } = fixture()
  67. submit(store, 'first')
  68. submit(store, 'second', now + gap)
  69. sync(store, [row('old', now - 60_000), row('1'), row('2', now + gap + 39)])
  70. expect(read(store).map(value => [value.id, value.client_message_id]))
  71. .toEqual([['old', undefined], ['1', 'first'], ['2', 'second']])
  72. })
  73. it('retains bindings across restart, forced refresh and subsequent upstream pages without client IDs', () => {
  74. const f = fixture()
  75. submit(f.store)
  76. sync(f.store, [row()])
  77. f.store.close()
  78. f.store = new ChatCacheStore(f.home)
  79. f.store.db.prepare('DELETE FROM chat_events').run()
  80. sync(f.store, [row()], true)
  81. f.store.putSnapshot(scope[0], 'again', 'messages', scope[1], scope[2], response([row()]))
  82. expect(read(f.store)[0]).toMatchObject({ id: '1155', client_message_id: 'local' })
  83. })
  84. it('repairs existing local history from durable commands without fetching upstream or restarting', () => {
  85. const { store } = fixture()
  86. submit(store)
  87. sync(store, [row()])
  88. // Simulate a pre-upgrade cache which already replaced its temporary row.
  89. store.db.prepare('DELETE FROM chat_message_identities').run()
  90. store.db.prepare("UPDATE chat_messages SET data=json_remove(data,'$.client_message_id')").run()
  91. expect(read(store)[0]).toMatchObject({ id: '1155', client_message_id: 'local' })
  92. })
  93. it.each(['pending', 'rejected', 'queued', 'gateway-queued', 'ambiguous', 'outside-window'])('does not guess identity for %s submissions', mode => {
  94. const { store } = fixture()
  95. submit(store, 'local', now, mode === 'queued' ? { queued: true } : {},
  96. mode === 'pending' || mode === 'rejected' ? mode : 'confirmed')
  97. if (mode === 'gateway-queued') store.db.prepare("UPDATE chat_events SET payload=json_set(payload,'$.status','queued') WHERE event_type='command.confirmed'").run()
  98. const rows = mode === 'ambiguous' ? [row('1'), row('2', now + 40)]
  99. : [row('1', mode === 'outside-window' ? now + 30_000 : now + 39)]
  100. sync(store, rows, true)
  101. expect(read(store).map(value => value.client_message_id)).toEqual(rows.map(() => undefined))
  102. })
  103. it('keeps an unconfirmed send when an older identical user message occupies its position', () => {
  104. const { store } = fixture()
  105. submit(store, 'pending', now, {}, 'pending')
  106. expect(() => sync(store, [row('old', now - 60_000)]))
  107. .toThrow('Unconfirmed submission must be reconciled')
  108. expect(read(store)[0]).toMatchObject({ id: 'user:web:prompt:pending', status: 'pending' })
  109. })
  110. it('uses an explicit upstream identity for queued messages without timestamp heuristics', () => {
  111. const { store } = fixture()
  112. submit(store, 'queued', now, { queued: true })
  113. sync(store, [{ ...row('1', now + 3600_000), client_message_id: 'queued' }])
  114. expect(read(store)[0]).toMatchObject({ id: '1', client_message_id: 'queued' })
  115. })
  116. it('includes other cached pages when refusing an ambiguous legacy match', () => {
  117. const { store } = fixture()
  118. submit(store)
  119. sync(store, [row('1'), row('2', now + 40)], true)
  120. expect(read(store, 0, 1)[0]).not.toHaveProperty('client_message_id')
  121. expect(read(store, 1, 1)[0]).not.toHaveProperty('client_message_id')
  122. })
  123. it('does not bind the first page of an ambiguous multi-page refresh', () => {
  124. const { store } = fixture()
  125. submit(store)
  126. store.applySync(...scope, store.revision(...scope), [
  127. { key: 'tail', kind: 'messages', response: response([row('2', now + 40)], 0, 2), startedAt: now + 2000 },
  128. { key: 'older', kind: 'messages', response: response([row('1')], 1, 2), startedAt: now + 2000 },
  129. ], true)
  130. expect(read(store).map(value => value.client_message_id)).toEqual([undefined, undefined])
  131. })
  132. it('supports delayed agent startup using its run-start event', () => {
  133. const { store } = fixture()
  134. submit(store)
  135. vi.spyOn(Date, 'now').mockReturnValue(now + 5000)
  136. store.recordEvent(...scope, { type: 'message.start', payload: {}, delivery_id: 'start' })
  137. sync(store, [row('1', now + 4999), { id: 'answer', role: 'assistant', content: '完成', timestamp: (now + 6000) / 1000 }])
  138. expect(read(store)[0]).toMatchObject({ id: '1', client_message_id: 'local' })
  139. })
  140. it('scopes bindings to owner, profile and session, migrates them with runtime routes and deletes them with the session', () => {
  141. const { store } = fixture()
  142. store.recordRoute(...scope, 'runtime')
  143. submit(store)
  144. sync(store, [row()])
  145. for (const other of [['other', scope[1], scope[2]], [scope[0], 'other', scope[2]], [scope[0], scope[1], 'other']]) {
  146. const [owner, profile, session] = other as [string, string, string]
  147. store.recordCommand(owner, profile, session, 'session.create', {})
  148. store.putSnapshot(owner, `other-${other.join(':')}`, 'messages', profile, session, response([row()]))
  149. const result = JSON.parse(store.messagePage(owner, profile, session, 0, 100)!.response.body.toString())
  150. expect(result.messages[0]).not.toHaveProperty('client_message_id')
  151. }
  152. store.recordRoute(scope[0], scope[1], 'compressed', 'runtime')
  153. expect(store.db.prepare('SELECT session_id FROM chat_message_identities').all()).toEqual([{ session_id: 'compressed' }])
  154. store.deleteSession(scope[0], scope[1], 'compressed')
  155. expect(store.db.prepare('SELECT * FROM chat_message_identities').all()).toEqual([])
  156. })
  157. })