ordinaryTranscriptV2.test.ts 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  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('deduplicates persisted source receipts after a restart', () => {
  143. const { store } = setup(),
  144. frame = {
  145. type: 'message.delta',
  146. session_id: 'runtime',
  147. epoch: 'upstream-epoch',
  148. seq: 3,
  149. payload: { text: '哈' },
  150. delivery_id: 'upstream-epoch:runtime:3',
  151. }
  152. store.recordEvent(...scope, frame)
  153. const before = messages(store)
  154. const home = stores.at(-1)!.home
  155. store.close()
  156. const reopened = new ChatCacheStore(home)
  157. try {
  158. expect(reopened.recordEvent(...scope, frame)).toBe(false)
  159. expect(messages(reopened)).toEqual(before)
  160. expect(reopened.transcripts.sourceCursor(...scope, 'runtime', 'upstream-epoch')).toBe(3)
  161. } finally {
  162. reopened.close()
  163. }
  164. })
  165. it('repairs a verified idle history in a new epoch without duplicating a partial page', () => {
  166. const { store, send } = setup()
  167. send('message.complete', { text: '最终结果' })
  168. const epoch = store.transcripts.epochFor(...scope)
  169. const result = store.applySync(
  170. ...scope,
  171. store.revision(...scope),
  172. [
  173. {
  174. key: 'history',
  175. kind: 'messages',
  176. startedAt: Date.now(),
  177. response: {
  178. status: 200,
  179. headers: new Headers(),
  180. body: Buffer.from(
  181. JSON.stringify({
  182. messages: [{ id: 'server-final', role: 'assistant', content: '最终结果' }],
  183. pagination: { offset: 0, total: 1 },
  184. }),
  185. ),
  186. },
  187. },
  188. ],
  189. true,
  190. )
  191. expect(result).toBe(true)
  192. expect(messages(store)).toHaveLength(1)
  193. expect(messages(store)[0]!.content).toBe('最终结果')
  194. expect(store.transcripts.epochFor(...scope)).not.toBe(epoch)
  195. expect(store.db.prepare('SELECT COUNT(*) n FROM chat_transcript_archives').get()!.n).toBeGreaterThan(0)
  196. })
  197. it('does not replace an active turn or its pending input while history is fetched', () => {
  198. const { store, send } = setup()
  199. store.recordCommand(...scope, 'prompt.submit', { text: '保留', _delivery_id: 'web:prompt:one' })
  200. send('message.delta', { text: '正在回复' })
  201. const before = messages(store)
  202. expect(store.applySync(...scope, store.revision(...scope), [], true)).toBe(false)
  203. expect(messages(store)).toEqual(before)
  204. })
  205. it('matches the shared protocol fixture including duplicate delivery', () => {
  206. let rows = fixture.snapshot.messages as any
  207. for (const event of fixture.events) rows = applyTranscriptEvent(rows, event as any)
  208. expect(rows).toEqual(fixture.expectedMessages)
  209. })
  210. it('does not append ambiguous replay bytes after an upstream reset snapshot', () => {
  211. const { store, send } = setup()
  212. send('message.delta', { text: '前' })
  213. const id = messages(store)[0]!.id
  214. send('gateway.reset', { reason: 'upstream_replay_unavailable' })
  215. send('route.resumed', { running: true, inflight: { assistant: '前后' } })
  216. send('message.delta', { text: '后' })
  217. expect(messages(store)[0]).toMatchObject({ id, content: '前后' })
  218. send('message.complete', { text: '前后完整结果' })
  219. expect(messages(store)).toHaveLength(1)
  220. expect(messages(store)[0]).toMatchObject({ id, content: '前后完整结果' })
  221. })
  222. it('does not allow another viewer resume to overwrite a newer live stream', () => {
  223. const { store, send } = setup()
  224. send('message.delta', { text: '较新的正文' })
  225. send('route.resumed', { running: true, inflight: { assistant: '旧快照' } })
  226. expect(messages(store).map((m) => m.content)).toEqual(['较新的正文'])
  227. })
  228. it('routes a late terminal snapshot to its identified old turn', () => {
  229. const { store, send } = setup()
  230. send('message.complete', { message_id: 'first', text: '第一轮' })
  231. store.recordCommand(...scope, 'prompt.submit', { text: '下一轮', _delivery_id: 'web:prompt:two' })
  232. send('message.delta', { message_id: 'second', text: '正在继续' })
  233. const current = store.transcripts.head(...scope).current
  234. send('message.complete', { message_id: 'first', text: '第一轮' })
  235. expect(store.transcripts.head(...scope)).toMatchObject({ current, running: true })
  236. expect(
  237. messages(store)
  238. .filter((m) => m.role === 'assistant')
  239. .map((m) => m.content),
  240. ).toEqual(['第一轮', '正在继续'])
  241. })
  242. it('keeps structured final attachments and error state in the canonical snapshot', () => {
  243. const { store, send } = setup()
  244. send('message.complete', { content: [{ type: 'file', path: '/result.pdf', name: '结果.pdf' }] })
  245. expect(messages(store)[0]!.content).toEqual([{ type: 'file', path: '/result.pdf', name: '结果.pdf' }])
  246. send('message.start')
  247. send('run.failed', { error: '工具执行失败' })
  248. expect(store.transcripts.snapshot(...scope, {}, 'current')).toMatchObject({
  249. running: false,
  250. error: '工具执行失败',
  251. })
  252. })
  253. it('does not resurrect a finished turn when a queued command is rejected late', () => {
  254. const { store, send } = setup()
  255. store.recordCommand(...scope, 'prompt.submit', { text: '第一问', _delivery_id: 'web:prompt:first' })
  256. send('message.delta', { text: '第一轮回复' })
  257. store.recordCommand(...scope, 'prompt.submit', { text: '排队', _delivery_id: 'web:prompt:queued' })
  258. send('message.complete', { text: '第一轮回复' })
  259. send('command.rejected', { delivery_id: 'web:prompt:queued', error: '未加入队列' })
  260. expect(store.transcripts.head(...scope)).toMatchObject({ running: false, queued: false })
  261. expect(messages(store).find((m) => m.client_message_id === 'queued')?.status).toBe('failed')
  262. })
  263. it('clears obsolete execution queues on an authoritative idle resume without deleting inputs', () => {
  264. const { store, send } = setup()
  265. store.recordCommand(...scope, 'prompt.submit', { text: '第一问', _delivery_id: 'web:prompt:first' })
  266. send('message.delta', { text: '部分回复' })
  267. store.recordCommand(...scope, 'prompt.submit', { text: '下一问', _delivery_id: 'web:prompt:queued' })
  268. send('route.resumed', { running: false, queued: null })
  269. expect(store.transcripts.head(...scope)).toMatchObject({ running: false, queued: false, queue: [] })
  270. expect(messages(store).filter((m) => m.role === 'user')).toHaveLength(2)
  271. })
  272. })