ordinaryTranscriptV2.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288
  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('drops the local queue entry once the upstream accepts the prompt immediately', () => {
  91. const { store, send } = setup()
  92. store.recordCommand(...scope, 'prompt.submit', { text: '第一问', _delivery_id: 'web:prompt:one' })
  93. send('command.confirmed', { delivery_id: 'web:prompt:one' })
  94. send('message.start')
  95. send('message.delta', { text: '第一答' })
  96. // A lingering background job keeps the transcript head "running" after
  97. // the visible turn finished.
  98. send('message.complete', { text: '第一答', background_pending: 1 })
  99. store.recordCommand(...scope, 'prompt.submit', { text: '第二问', _delivery_id: 'web:prompt:two' })
  100. expect(store.transcripts.head(...scope).queued).toBe(true)
  101. // The upstream was actually idle and accepted the second prompt for
  102. // immediate execution; the local queue entry was only a stale mirror.
  103. send('command.confirmed', { delivery_id: 'web:prompt:two' })
  104. const head = store.transcripts.head(...scope)
  105. expect(head.queued).toBe(false)
  106. expect(head.queue).toHaveLength(0)
  107. send('message.start')
  108. send('message.delta', { text: '第二答' })
  109. send('message.complete', { text: '第二答' })
  110. const all = messages(store)
  111. expect(all.filter((m) => m.role === 'user')).toHaveLength(2)
  112. expect(all.filter((m) => m.final_result)).toHaveLength(2)
  113. expect(new Set(all.filter((m) => m.role === 'assistant').map((m) => m.turn_id)).size).toBe(2)
  114. expect(store.transcripts.head(...scope).running).toBe(false)
  115. })
  116. it('keeps steer in the current turn without moving the reply behind a future user', () => {
  117. const { store, send } = setup()
  118. store.recordCommand(...scope, 'prompt.submit', { text: '开始', _delivery_id: 'web:prompt:one' })
  119. send('message.delta', { text: '前' })
  120. const assistant = messages(store).find((m) => m.role === 'assistant')!
  121. store.recordCommand(...scope, 'session.steer', { text: '补充', _delivery_id: 'web:prompt:steer' })
  122. send('message.delta', { text: '后' })
  123. send('message.complete')
  124. expect(messages(store).filter((m) => m.role === 'assistant')).toMatchObject([
  125. { id: assistant.id, content: '前后' },
  126. ])
  127. expect(new Set(messages(store).map((m) => m.turn_id)).size).toBe(1)
  128. })
  129. it('keeps a delayed tool result attached to its original segment', () => {
  130. const { store, send } = setup()
  131. send('message.interim', { text: '开始检查' })
  132. send('tool.start', { tool_id: 't', name: 'terminal' })
  133. send('message.interim', { text: '过程说明' })
  134. send('message.delta', { text: '完成' })
  135. send('tool.complete', { tool_id: 't', result: 'ok' })
  136. send('message.complete', { text: '完成' })
  137. expect(messages(store).map((m) => m.content)).toEqual(['开始检查', '过程说明', '完成'])
  138. expect(messages(store).flatMap((m) => (m.tool_calls ?? []) as object[])).toMatchObject([
  139. { id: 't', result: 'ok' },
  140. ])
  141. })
  142. it('keeps the visible wait through a tool-round complete', () => {
  143. const { store, send } = setup()
  144. send('message.delta', { text: '先查一下' })
  145. send('message.complete', { text: '先查一下', finish_reason: 'tool_calls' })
  146. expect(store.transcripts.head(...scope)).toMatchObject({ running: true, terminal: false })
  147. send('tool.start', { tool_id: 't', name: 'terminal' })
  148. send('message.complete', { text: '先查一下' })
  149. expect(store.transcripts.head(...scope).running).toBe(true)
  150. const calls = messages(store).flatMap((m) => (m.tool_calls ?? []) as { id: string; status: string }[])
  151. expect(calls).toMatchObject([{ id: 't', status: 'running' }])
  152. send('tool.complete', { tool_id: 't', result: 'ok' })
  153. send('message.delta', { text: '查完了' })
  154. send('message.complete', { text: '查完了', finish_reason: 'stop' })
  155. expect(store.transcripts.head(...scope).running).toBe(false)
  156. expect(messages(store).filter((m) => m.final_result)).toHaveLength(1)
  157. })
  158. it('deduplicates persisted source receipts after a restart', () => {
  159. const { store } = setup(),
  160. frame = {
  161. type: 'message.delta',
  162. session_id: 'runtime',
  163. epoch: 'upstream-epoch',
  164. seq: 3,
  165. payload: { text: '哈' },
  166. delivery_id: 'upstream-epoch:runtime:3',
  167. }
  168. store.recordEvent(...scope, frame)
  169. const before = messages(store)
  170. const home = stores.at(-1)!.home
  171. store.close()
  172. const reopened = new ChatCacheStore(home)
  173. try {
  174. expect(reopened.recordEvent(...scope, frame)).toBe(false)
  175. expect(messages(reopened)).toEqual(before)
  176. expect(reopened.transcripts.sourceCursor(...scope, 'runtime', 'upstream-epoch')).toBe(3)
  177. } finally {
  178. reopened.close()
  179. }
  180. })
  181. it('repairs a verified idle history in a new epoch without duplicating a partial page', () => {
  182. const { store, send } = setup()
  183. send('message.complete', { text: '最终结果' })
  184. const epoch = store.transcripts.epochFor(...scope)
  185. const result = store.applySync(
  186. ...scope,
  187. store.revision(...scope),
  188. [
  189. {
  190. key: 'history',
  191. kind: 'messages',
  192. startedAt: Date.now(),
  193. response: {
  194. status: 200,
  195. headers: new Headers(),
  196. body: Buffer.from(
  197. JSON.stringify({
  198. messages: [{ id: 'server-final', role: 'assistant', content: '最终结果' }],
  199. pagination: { offset: 0, total: 1 },
  200. }),
  201. ),
  202. },
  203. },
  204. ],
  205. true,
  206. )
  207. expect(result).toBe(true)
  208. expect(messages(store)).toHaveLength(1)
  209. expect(messages(store)[0]!.content).toBe('最终结果')
  210. expect(store.transcripts.epochFor(...scope)).not.toBe(epoch)
  211. expect(store.db.prepare('SELECT COUNT(*) n FROM chat_transcript_archives').get()!.n).toBeGreaterThan(0)
  212. })
  213. it('does not replace an active turn or its pending input while history is fetched', () => {
  214. const { store, send } = setup()
  215. store.recordCommand(...scope, 'prompt.submit', { text: '保留', _delivery_id: 'web:prompt:one' })
  216. send('message.delta', { text: '正在回复' })
  217. const before = messages(store)
  218. expect(store.applySync(...scope, store.revision(...scope), [], true)).toBe(false)
  219. expect(messages(store)).toEqual(before)
  220. })
  221. it('matches the shared protocol fixture including duplicate delivery', () => {
  222. let rows = fixture.snapshot.messages as any
  223. for (const event of fixture.events) rows = applyTranscriptEvent(rows, event as any)
  224. expect(rows).toEqual(fixture.expectedMessages)
  225. })
  226. it('does not append ambiguous replay bytes after an upstream reset snapshot', () => {
  227. const { store, send } = setup()
  228. send('message.delta', { text: '前' })
  229. const id = messages(store)[0]!.id
  230. send('gateway.reset', { reason: 'upstream_replay_unavailable' })
  231. send('route.resumed', { running: true, inflight: { assistant: '前后' } })
  232. send('message.delta', { text: '后' })
  233. expect(messages(store)[0]).toMatchObject({ id, content: '前后' })
  234. send('message.complete', { text: '前后完整结果' })
  235. expect(messages(store)).toHaveLength(1)
  236. expect(messages(store)[0]).toMatchObject({ id, content: '前后完整结果' })
  237. })
  238. it('does not allow another viewer resume to overwrite a newer live stream', () => {
  239. const { store, send } = setup()
  240. send('message.delta', { text: '较新的正文' })
  241. send('route.resumed', { running: true, inflight: { assistant: '旧快照' } })
  242. expect(messages(store).map((m) => m.content)).toEqual(['较新的正文'])
  243. })
  244. it('routes a late terminal snapshot to its identified old turn', () => {
  245. const { store, send } = setup()
  246. send('message.complete', { message_id: 'first', text: '第一轮' })
  247. store.recordCommand(...scope, 'prompt.submit', { text: '下一轮', _delivery_id: 'web:prompt:two' })
  248. send('message.delta', { message_id: 'second', text: '正在继续' })
  249. const current = store.transcripts.head(...scope).current
  250. send('message.complete', { message_id: 'first', text: '第一轮' })
  251. expect(store.transcripts.head(...scope)).toMatchObject({ current, running: true })
  252. expect(
  253. messages(store)
  254. .filter((m) => m.role === 'assistant')
  255. .map((m) => m.content),
  256. ).toEqual(['第一轮', '正在继续'])
  257. })
  258. it('keeps structured final attachments and error state in the canonical snapshot', () => {
  259. const { store, send } = setup()
  260. send('message.complete', { content: [{ type: 'file', path: '/result.pdf', name: '结果.pdf' }] })
  261. expect(messages(store)[0]!.content).toEqual([{ type: 'file', path: '/result.pdf', name: '结果.pdf' }])
  262. send('message.start')
  263. send('run.failed', { error: '工具执行失败' })
  264. expect(store.transcripts.snapshot(...scope, {}, 'current')).toMatchObject({
  265. running: false,
  266. error: '工具执行失败',
  267. })
  268. })
  269. it('does not resurrect a finished turn when a queued command is rejected late', () => {
  270. const { store, send } = setup()
  271. store.recordCommand(...scope, 'prompt.submit', { text: '第一问', _delivery_id: 'web:prompt:first' })
  272. send('message.delta', { text: '第一轮回复' })
  273. store.recordCommand(...scope, 'prompt.submit', { text: '排队', _delivery_id: 'web:prompt:queued' })
  274. send('message.complete', { text: '第一轮回复' })
  275. send('command.rejected', { delivery_id: 'web:prompt:queued', error: '未加入队列' })
  276. expect(store.transcripts.head(...scope)).toMatchObject({ running: false, queued: false })
  277. expect(messages(store).find((m) => m.client_message_id === 'queued')?.status).toBe('failed')
  278. })
  279. it('clears obsolete execution queues on an authoritative idle resume without deleting inputs', () => {
  280. const { store, send } = setup()
  281. store.recordCommand(...scope, 'prompt.submit', { text: '第一问', _delivery_id: 'web:prompt:first' })
  282. send('message.delta', { text: '部分回复' })
  283. store.recordCommand(...scope, 'prompt.submit', { text: '下一问', _delivery_id: 'web:prompt:queued' })
  284. send('route.resumed', { running: false, queued: null })
  285. expect(store.transcripts.head(...scope)).toMatchObject({ running: false, queued: false, queue: [] })
  286. expect(messages(store).filter((m) => m.role === 'user')).toHaveLength(2)
  287. })
  288. })