workspaceMessagePatch.test.ts 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182
  1. // @vitest-environment node
  2. import { afterEach, beforeEach, 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 Koa from 'koa'
  7. import { once } from 'node:events'
  8. import type { Server } from 'node:http'
  9. import { WorkspaceStore } from '../../src/server/workspaceStore'
  10. import { streamWorkspace } from '../../src/server/workspaceSync'
  11. import { WorkspaceMessageReconciler, WorkspacePatchGap } from '../../src/shared/workspaceMessagePatch'
  12. import type { WorkspaceConversation, WorkspaceEvent, WorkspaceMessage } from '../../src/shared/workspace'
  13. import type { LocalAuthStore } from '../../src/server/localAuth'
  14. let home: string, store: WorkspaceStore, message: WorkspaceMessage, server: Server
  15. const auth = { require: () => ({ id: 'owner' }), currentFromCookieHeader: () => ({ id: 'owner' }), isUserActive: () => true, pushAuthorizationVersion: () => 1 } as unknown as LocalAuthStore
  16. beforeEach(async () => {
  17. home = mkdtempSync(join(tmpdir(), 'workspace-patches-'))
  18. store = new WorkspaceStore(home, { messagePatches: true })
  19. store.createAgent('owner', { name: 'Patch', profile: 'default' })
  20. const conversation = store.list<WorkspaceConversation>('owner', 'conversation')[0]!
  21. message = { id: 'm', conversationId: conversation.id, seq: 0, role: 'assistant', content: '开始', reasoning: '', status: 'streaming', attachments: [], tools: [], createdAt: Date.now() }
  22. store.saveMessage('owner', message)
  23. const app = new Koa(); app.use(ctx => streamWorkspace(ctx, store, auth))
  24. server = app.listen(0, '127.0.0.1'); await once(server, 'listening')
  25. })
  26. afterEach(async () => {
  27. store.close(); server.closeAllConnections()
  28. await new Promise<void>(resolve => server.close(() => resolve()))
  29. rmSync(home, { recursive: true, force: true })
  30. })
  31. const url = (format = '') => `http://127.0.0.1:${(server.address() as { port: number }).port}/?format=${format}`
  32. const append = (text: string) => { message.content += text; store.saveMessage('owner', message) }
  33. it('persists compact patches atomically while observers see complete stable messages', () => {
  34. const cursor = store.cursor('owner'), events: WorkspaceEvent[] = []
  35. store.observe((_, event) => events.push(structuredClone(event)))
  36. const folder = new WorkspaceMessageReconciler(); folder.remember(structuredClone(message))
  37. for (let i = 0; i < 40; i++) append(`中文🙂组合e\u0301-${i}`)
  38. const patches = store.events('owner', cursor).filter(e => e.type === 'message.patch')
  39. expect(patches).toHaveLength(40)
  40. for (const event of patches) folder.normalize(event)
  41. const last = folder.normalize(patches.at(-1)!)
  42. expect((last.data as WorkspaceMessage).content).toBe(message.content)
  43. expect(store.require<WorkspaceMessage>('owner', 'message', 'm')).toEqual(message)
  44. expect(events.filter(e => e.type === 'message.changed')).toHaveLength(40)
  45. expect(events.some(e => e.type === 'message.patch')).toBe(false)
  46. expect(events.filter(e => e.type === 'conversation.changed').length).toBeLessThan(5)
  47. expect(message.revision).toBe(41)
  48. })
  49. it('replays compression activity as a full snapshot and removes it before resuming text patches', () => {
  50. const cursor = store.cursor('owner'), folder = new WorkspaceMessageReconciler()
  51. folder.remember(structuredClone(message))
  52. message.activity = 'compacting'; store.saveMessage('owner', message)
  53. delete message.activity; message.content += '继续'; store.saveMessage('owner', message)
  54. append('正文')
  55. const events = store.events('owner', cursor).filter(e => e.type.startsWith('message.'))
  56. expect(events.map(e => e.type)).toEqual(['message.changed', 'message.changed', 'message.patch'])
  57. expect((folder.normalize(events[0]!).data as WorkspaceMessage).activity).toBe('compacting')
  58. expect((folder.normalize(events[1]!).data as WorkspaceMessage).activity).toBeUndefined()
  59. expect(folder.normalize(events[2]!).data).toEqual(message)
  60. })
  61. it('uses complete events for tools, rewritten content, visibility and terminal changes', () => {
  62. append('文字')
  63. for (const change of [() => { message.tools = [{ id: 'tool', status: 'running' }] },
  64. () => { message.content = '改写' }, () => { message.visible = false }, () => { message.visible = true },
  65. () => { message.status = 'complete' as const }]) {
  66. const cursor = store.cursor('owner'); change(); store.saveMessage('owner', message)
  67. expect(store.events('owner', cursor).some(e => e.type === 'message.changed')).toBe(true)
  68. expect(store.events('owner', cursor).some(e => e.type === 'message.patch')).toBe(false)
  69. }
  70. })
  71. it('detects missing revisions and task mismatches without manufacturing a second message', () => {
  72. const cursor = store.cursor('owner'), baseline = structuredClone(message)
  73. append('a'); append('b')
  74. const patches = store.events('owner', cursor).filter(e => e.type === 'message.patch')
  75. const folder = new WorkspaceMessageReconciler(); folder.remember(baseline)
  76. expect(() => folder.normalize(patches[1]!)).toThrow(WorkspacePatchGap)
  77. const first = folder.normalize(patches[0]!)
  78. expect(folder.normalize(patches[0]!).data).toEqual(first.data)
  79. expect(() => folder.normalize({ ...patches[1]!, data: { ...(patches[1]!.data as object), conversationTaskId: 'other' } })).toThrow(WorkspacePatchGap)
  80. })
  81. it('keeps persisted patches readable after restart and asks legacy replay to hydrate', async () => {
  82. const cursor = store.cursor('owner'); append('持久化')
  83. store.close(); store = new WorkspaceStore(home, { messagePatches: false })
  84. expect(store.require<WorkspaceMessage>('owner', 'message', 'm').content).toBe(message.content)
  85. expect(store.events('owner', cursor)[0]!.type).toBe('message.patch')
  86. const response = await fetch(url(), { headers: { 'Last-Event-ID': String(cursor) } })
  87. expect(await response.text()).toContain('event: reset')
  88. })
  89. it('sends a full live baseline then patches, with ordered legacy full frames and terminal flush', async () => {
  90. const cursor = store.cursor('owner')
  91. async function connect(format: string) {
  92. const controller = new AbortController()
  93. const response = await fetch(url(format), { headers: { 'Last-Event-ID': String(cursor) }, signal: controller.signal })
  94. const reader = response.body!.getReader(), decoder = new TextDecoder()
  95. let text = ''
  96. const until = async (needle: string) => {
  97. while (!text.includes(needle)) {
  98. const chunk = await reader.read()
  99. if (chunk.done) throw new Error(`Stream ended before ${needle}`)
  100. text += decoder.decode(chunk.value)
  101. }
  102. return text
  103. }
  104. await until('event: ready')
  105. return { controller, reader, until }
  106. }
  107. const modern = await connect('patch-v1'), legacy = await connect('')
  108. try {
  109. append('a'); append('b'); append('c')
  110. message.status = 'complete'; store.saveMessage('owner', message)
  111. const a = await modern.until('"status":"complete"'), b = await legacy.until('"status":"complete"')
  112. expect(a).toContain('"type":"message.patch"')
  113. expect(a).toContain('"content":"开始a"')
  114. expect(b).not.toContain('"type":"message.patch"')
  115. expect(b).toContain('"content":"开始abc"')
  116. const ids = [...b.matchAll(/^id: (\d+)/gm)].map(m => Number(m[1]))
  117. expect(ids).toEqual([...new Set(ids)].sort((a, b) => a - b))
  118. } finally { modern.controller.abort(); legacy.controller.abort(); await modern.reader.cancel().catch(() => {}); await legacy.reader.cancel().catch(() => {}) }
  119. })
  120. it('rolls back both the full row and patch event before any observer sees an update', () => {
  121. const cursor = store.cursor('owner'), before = structuredClone(message), observed: WorkspaceEvent[] = []
  122. store.observe((_, event) => observed.push(event))
  123. store.db.exec("CREATE TRIGGER reject_patch BEFORE INSERT ON workspace_events WHEN NEW.type='message.patch' BEGIN SELECT RAISE(ABORT,'fixture rollback'); END")
  124. expect(() => append('回滚文字')).toThrow('fixture rollback')
  125. expect(store.require('owner', 'message', 'm')).toEqual(before)
  126. expect(store.cursor('owner')).toBe(cursor); expect(observed).toEqual([])
  127. store.db.exec('DROP TRIGGER reject_patch')
  128. store.saveMessage('owner', message)
  129. expect(store.require<WorkspaceMessage>('owner', 'message', 'm').revision).toBe(2)
  130. expect(store.events('owner', cursor).filter(e => e.type === 'message.patch')).toHaveLength(1)
  131. })
  132. it('flushes the last paused-stream preview without creating an extra message revision', async () => {
  133. append('暂停前的最后文字')
  134. const revision = message.revision
  135. await new Promise(resolve => setTimeout(resolve, 280))
  136. expect(store.require<WorkspaceConversation>('owner', 'conversation', message.conversationId).preview).toContain('暂停前的最后文字')
  137. expect(store.require<WorkspaceMessage>('owner', 'message', 'm').revision).toBe(revision)
  138. })
  139. it('persists card-only messages and replays installation updates as full frames without losing the card on text patches',()=>{
  140. message.content='';message.visible=true
  141. message.browserCard={id:'browser-turn',agentId:'bot',agentName:'浏览器 Bot',status:'preparing',installation:{status:'installing',message:'下载中',progress:20,updatedAt:1},updatedAt:1}
  142. let cursor=store.cursor('owner')
  143. store.saveMessage('owner',message)
  144. expect(store.events('owner',cursor).find(e=>e.type==='message.changed')?.data).toMatchObject({browserCard:{installation:{progress:20}}})
  145. expect(store.require<WorkspaceConversation>('owner','conversation',message.conversationId).preview).toContain('托管浏览器')
  146. const folder=new WorkspaceMessageReconciler();folder.remember(structuredClone(message))
  147. cursor=store.cursor('owner')
  148. message.browserCard={...message.browserCard,status:'active',title:'测试网页',url:'https://example.com/',installation:{status:'ready',message:'已就绪',updatedAt:2},updatedAt:2}
  149. store.saveMessage('owner',message)
  150. const events=store.events('owner',cursor)
  151. expect(events.some(e=>e.type==='message.patch')).toBe(false)
  152. for(const e of events)folder.normalize(e)
  153. cursor=store.cursor('owner');append('浏览器已打开')
  154. const patched=folder.normalize(store.events('owner',cursor).find(e=>e.type==='message.patch')!)
  155. expect(patched.data).toMatchObject({content:'浏览器已打开',browserCard:{status:'active',title:'测试网页'}})
  156. message.content='';message.status='complete';message.browserCard={...message.browserCard,status:'closed'}
  157. store.saveMessage('owner',message)
  158. store.close();store=new WorkspaceStore(home,{messagePatches:true})
  159. expect(store.require<WorkspaceMessage>('owner','message',message.id)).toMatchObject({visible:true,status:'complete',content:'',browserCard:{status:'closed',title:'测试网页'}})
  160. expect(store.require<WorkspaceConversation>('owner','conversation',message.conversationId).unread).toBe(false)
  161. })
  162. it('persists nonfatal service warnings independently of reply text and keeps terminal warning-only replies visible',()=>{
  163. message.content='';message.visible=true
  164. message.serviceWarnings=[{service:'离线服务',code:'plugin_initialization_failed',message:'本轮未连接'}]
  165. const cursor=store.cursor('owner');store.saveMessage('owner',message)
  166. expect(store.events('owner',cursor).find(e=>e.type==='message.changed')?.data).toMatchObject({serviceWarnings:message.serviceWarnings})
  167. expect(store.require<WorkspaceConversation>('owner','conversation',message.conversationId).preview).toBe('部分服务暂时不可用')
  168. message.status='complete';store.saveMessage('owner',message)
  169. expect(store.require<WorkspaceMessage>('owner','message',message.id)).toMatchObject({visible:true,content:'',status:'complete',serviceWarnings:message.serviceWarnings})
  170. expect(store.require<WorkspaceConversation>('owner','conversation',message.conversationId).unread).toBe(false)
  171. })