ordinaryTranscriptV2.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246
  1. // @vitest-environment node
  2. import { afterEach, describe, expect, it } from 'vitest'
  3. import { mkdtempSync, rmSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { ChatCacheStore } from '../../src/server/chatCache'
  7. import { applyTranscriptEvent } from '../../src/shared/chatTranscript'
  8. import fixture from '../fixtures/ordinary-chat-v2/protocol.json'
  9. const scope = ['owner', 'p', 's'] as const
  10. const stores: Array<{ home: string; store: ChatCacheStore }> = []
  11. function setup() {
  12. const home = mkdtempSync(join(tmpdir(), 'ordinary-v2-')),
  13. store = new ChatCacheStore(home)
  14. stores.push({ home, store })
  15. store.recordCommand(...scope, 'session.create', { source: 'web' })
  16. let seq = 0
  17. return {
  18. store,
  19. send: (type: string, payload: object = {}, id?: string) =>
  20. store.recordEvent(...scope, { type, payload, delivery_id: id ?? String(++seq) }),
  21. }
  22. }
  23. afterEach(() => {
  24. for (const f of stores.splice(0)) {
  25. if (!f.store.isClosed) f.store.close()
  26. rmSync(f.home, { recursive: true, force: true })
  27. }
  28. })
  29. const messages = (store: ChatCacheStore) => store.transcripts.all(...scope)
  30. describe('ordinary v2 canonical writer', () => {
  31. it('updates a final preview in place and never writes another live representation', () => {
  32. const { store, send } = setup()
  33. send('message.start')
  34. send('message.delta', { text: '已经完成。' })
  35. const id = messages(store)[0]!.id
  36. for (let i = 0; i < 3; i++) send('message.interim', { text: '已经完成。', already_streamed: true })
  37. send('message.complete', { text: '已经完成。', response_previewed: true })
  38. send('run.completed', { text: '已经完成。' })
  39. expect(messages(store)).toHaveLength(1)
  40. expect(messages(store)[0]).toMatchObject({
  41. id,
  42. content: '已经完成。',
  43. status: 'complete',
  44. final_result: true,
  45. })
  46. expect(store.db.prepare('SELECT COUNT(*) n FROM chat_messages').get()!.n).toBe(0)
  47. })
  48. it('restores an active turn through a snapshot then promotes its upstream ID', () => {
  49. const { store, send } = setup()
  50. send('route.resumed', { running: true, inflight: { assistant: '前半段' } })
  51. const id = messages(store)[0]!.id
  52. send('message.delta', { message_id: 'upstream-event', text: '后半段' })
  53. send('message.complete', { message_id: 'persisted-42', text: '前半段后半段' })
  54. expect(messages(store)).toHaveLength(1)
  55. expect(messages(store)[0]).toMatchObject({
  56. id,
  57. source_message_id: 'persisted-42',
  58. content: '前半段后半段',
  59. })
  60. })
  61. it('preserves two identical actual sends and their queued turns across restart', () => {
  62. const { store, send } = setup()
  63. store.recordCommand(...scope, 'prompt.submit', { text: '相同问题', _delivery_id: 'web:prompt:one' })
  64. send('command.confirmed', { delivery_id: 'web:prompt:one' })
  65. send('message.start')
  66. send('message.delta', { text: '相同回复' })
  67. store.recordCommand(...scope, 'prompt.submit', {
  68. text: '相同问题',
  69. _delivery_id: 'web:prompt:two',
  70. queued: true,
  71. })
  72. send('command.confirmed', { delivery_id: 'web:prompt:two', status: 'queued' })
  73. send('message.complete', { text: '相同回复', queue_remaining: 1 })
  74. send('message.start')
  75. send('message.delta', { text: '相同回复' })
  76. send('message.complete', { text: '相同回复' })
  77. const all = messages(store)
  78. expect(all.filter((m) => m.role === 'user')).toHaveLength(2)
  79. expect(all.filter((m) => m.final_result)).toHaveLength(2)
  80. expect(new Set(all.filter((m) => m.role === 'assistant').map((m) => m.turn_id)).size).toBe(2)
  81. const { home } = stores.at(-1)!
  82. store.close()
  83. const reopened = new ChatCacheStore(home)
  84. try {
  85. expect(messages(reopened)).toEqual(all)
  86. } finally {
  87. reopened.close()
  88. }
  89. })
  90. it('keeps steer in the current turn without moving the reply behind a future user', () => {
  91. const { store, send } = setup()
  92. store.recordCommand(...scope, 'prompt.submit', { text: '开始', _delivery_id: 'web:prompt:one' })
  93. send('message.delta', { text: '前' })
  94. const assistant = messages(store).find((m) => m.role === 'assistant')!
  95. store.recordCommand(...scope, 'session.steer', { text: '补充', _delivery_id: 'web:prompt:steer' })
  96. send('message.delta', { text: '后' })
  97. send('message.complete')
  98. expect(messages(store).filter((m) => m.role === 'assistant')).toMatchObject([
  99. { id: assistant.id, content: '前后' },
  100. ])
  101. expect(new Set(messages(store).map((m) => m.turn_id)).size).toBe(1)
  102. })
  103. it('keeps a delayed tool result attached to its original segment', () => {
  104. const { store, send } = setup()
  105. send('message.interim', { text: '开始检查' })
  106. send('tool.start', { tool_id: 't', name: 'terminal' })
  107. send('message.interim', { text: '过程说明' })
  108. send('message.delta', { text: '完成' })
  109. send('tool.complete', { tool_id: 't', result: 'ok' })
  110. send('message.complete', { text: '完成' })
  111. expect(messages(store).map((m) => m.content)).toEqual(['开始检查', '过程说明', '完成'])
  112. expect(messages(store).flatMap((m) => (m.tool_calls ?? []) as object[])).toMatchObject([
  113. { id: 't', result: 'ok' },
  114. ])
  115. })
  116. it('deduplicates persisted source receipts after a restart', () => {
  117. const { store } = setup(),
  118. frame = {
  119. type: 'message.delta',
  120. session_id: 'runtime',
  121. epoch: 'upstream-epoch',
  122. seq: 3,
  123. payload: { text: '哈' },
  124. delivery_id: 'upstream-epoch:runtime:3',
  125. }
  126. store.recordEvent(...scope, frame)
  127. const before = messages(store)
  128. const home = stores.at(-1)!.home
  129. store.close()
  130. const reopened = new ChatCacheStore(home)
  131. try {
  132. expect(reopened.recordEvent(...scope, frame)).toBe(false)
  133. expect(messages(reopened)).toEqual(before)
  134. expect(reopened.transcripts.sourceCursor(...scope, 'runtime', 'upstream-epoch')).toBe(3)
  135. } finally {
  136. reopened.close()
  137. }
  138. })
  139. it('repairs a verified idle history in a new epoch without duplicating a partial page', () => {
  140. const { store, send } = setup()
  141. send('message.complete', { text: '最终结果' })
  142. const epoch = store.transcripts.epochFor(...scope)
  143. const result = store.applySync(
  144. ...scope,
  145. store.revision(...scope),
  146. [
  147. {
  148. key: 'history',
  149. kind: 'messages',
  150. startedAt: Date.now(),
  151. response: {
  152. status: 200,
  153. headers: new Headers(),
  154. body: Buffer.from(
  155. JSON.stringify({
  156. messages: [{ id: 'server-final', role: 'assistant', content: '最终结果' }],
  157. pagination: { offset: 0, total: 1 },
  158. }),
  159. ),
  160. },
  161. },
  162. ],
  163. true,
  164. )
  165. expect(result).toBe(true)
  166. expect(messages(store)).toHaveLength(1)
  167. expect(messages(store)[0]!.content).toBe('最终结果')
  168. expect(store.transcripts.epochFor(...scope)).not.toBe(epoch)
  169. expect(store.db.prepare('SELECT COUNT(*) n FROM chat_transcript_archives').get()!.n).toBeGreaterThan(0)
  170. })
  171. it('does not replace an active turn or its pending input while history is fetched', () => {
  172. const { store, send } = setup()
  173. store.recordCommand(...scope, 'prompt.submit', { text: '保留', _delivery_id: 'web:prompt:one' })
  174. send('message.delta', { text: '正在回复' })
  175. const before = messages(store)
  176. expect(store.applySync(...scope, store.revision(...scope), [], true)).toBe(false)
  177. expect(messages(store)).toEqual(before)
  178. })
  179. it('matches the shared protocol fixture including duplicate delivery', () => {
  180. let rows = fixture.snapshot.messages as any
  181. for (const event of fixture.events) rows = applyTranscriptEvent(rows, event as any)
  182. expect(rows).toEqual(fixture.expectedMessages)
  183. })
  184. it('does not append ambiguous replay bytes after an upstream reset snapshot', () => {
  185. const { store, send } = setup()
  186. send('message.delta', { text: '前' })
  187. const id = messages(store)[0]!.id
  188. send('gateway.reset', { reason: 'upstream_replay_unavailable' })
  189. send('route.resumed', { running: true, inflight: { assistant: '前后' } })
  190. send('message.delta', { text: '后' })
  191. expect(messages(store)[0]).toMatchObject({ id, content: '前后' })
  192. send('message.complete', { text: '前后完整结果' })
  193. expect(messages(store)).toHaveLength(1)
  194. expect(messages(store)[0]).toMatchObject({ id, content: '前后完整结果' })
  195. })
  196. it('does not allow another viewer resume to overwrite a newer live stream', () => {
  197. const { store, send } = setup()
  198. send('message.delta', { text: '较新的正文' })
  199. send('route.resumed', { running: true, inflight: { assistant: '旧快照' } })
  200. expect(messages(store).map((m) => m.content)).toEqual(['较新的正文'])
  201. })
  202. it('routes a late terminal snapshot to its identified old turn', () => {
  203. const { store, send } = setup()
  204. send('message.complete', { message_id: 'first', text: '第一轮' })
  205. store.recordCommand(...scope, 'prompt.submit', { text: '下一轮', _delivery_id: 'web:prompt:two' })
  206. send('message.delta', { message_id: 'second', text: '正在继续' })
  207. const current = store.transcripts.head(...scope).current
  208. send('message.complete', { message_id: 'first', text: '第一轮' })
  209. expect(store.transcripts.head(...scope)).toMatchObject({ current, running: true })
  210. expect(
  211. messages(store)
  212. .filter((m) => m.role === 'assistant')
  213. .map((m) => m.content),
  214. ).toEqual(['第一轮', '正在继续'])
  215. })
  216. it('keeps structured final attachments and error state in the canonical snapshot', () => {
  217. const { store, send } = setup()
  218. send('message.complete', { content: [{ type: 'file', path: '/result.pdf', name: '结果.pdf' }] })
  219. expect(messages(store)[0]!.content).toEqual([{ type: 'file', path: '/result.pdf', name: '结果.pdf' }])
  220. send('message.start')
  221. send('run.failed', { error: '工具执行失败' })
  222. expect(store.transcripts.snapshot(...scope, {}, 'current')).toMatchObject({
  223. running: false,
  224. error: '工具执行失败',
  225. })
  226. })
  227. it('does not resurrect a finished turn when a queued command is rejected late', () => {
  228. const { store, send } = setup()
  229. store.recordCommand(...scope, 'prompt.submit', { text: '第一问', _delivery_id: 'web:prompt:first' })
  230. send('message.delta', { text: '第一轮回复' })
  231. store.recordCommand(...scope, 'prompt.submit', { text: '排队', _delivery_id: 'web:prompt:queued' })
  232. send('message.complete', { text: '第一轮回复' })
  233. send('command.rejected', { delivery_id: 'web:prompt:queued', error: '未加入队列' })
  234. expect(store.transcripts.head(...scope)).toMatchObject({ running: false, queued: false })
  235. expect(messages(store).find((m) => m.client_message_id === 'queued')?.status).toBe('failed')
  236. })
  237. it('clears obsolete execution queues on an authoritative idle resume without deleting inputs', () => {
  238. const { store, send } = setup()
  239. store.recordCommand(...scope, 'prompt.submit', { text: '第一问', _delivery_id: 'web:prompt:first' })
  240. send('message.delta', { text: '部分回复' })
  241. store.recordCommand(...scope, 'prompt.submit', { text: '下一问', _delivery_id: 'web:prompt:queued' })
  242. send('route.resumed', { running: false, queued: null })
  243. expect(store.transcripts.head(...scope)).toMatchObject({ running: false, queued: false, queue: [] })
  244. expect(messages(store).filter((m) => m.role === 'user')).toHaveLength(2)
  245. })
  246. })