workspaceMessagePatch.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162
  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' }), 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('uses complete events for tools, rewritten content, visibility and terminal changes', () => {
  50. append('文字')
  51. for (const change of [() => { message.tools = [{ id: 'tool', status: 'running' }] },
  52. () => { message.content = '改写' }, () => { message.visible = false }, () => { message.visible = true },
  53. () => { message.status = 'complete' as const }]) {
  54. const cursor = store.cursor('owner'); change(); store.saveMessage('owner', message)
  55. expect(store.events('owner', cursor).some(e => e.type === 'message.changed')).toBe(true)
  56. expect(store.events('owner', cursor).some(e => e.type === 'message.patch')).toBe(false)
  57. }
  58. })
  59. it('detects missing revisions and task mismatches without manufacturing a second message', () => {
  60. const cursor = store.cursor('owner'), baseline = structuredClone(message)
  61. append('a'); append('b')
  62. const patches = store.events('owner', cursor).filter(e => e.type === 'message.patch')
  63. const folder = new WorkspaceMessageReconciler(); folder.remember(baseline)
  64. expect(() => folder.normalize(patches[1]!)).toThrow(WorkspacePatchGap)
  65. const first = folder.normalize(patches[0]!)
  66. expect(folder.normalize(patches[0]!).data).toEqual(first.data)
  67. expect(() => folder.normalize({ ...patches[1]!, data: { ...(patches[1]!.data as object), conversationTaskId: 'other' } })).toThrow(WorkspacePatchGap)
  68. })
  69. it('keeps persisted patches readable after restart and asks legacy replay to hydrate', async () => {
  70. const cursor = store.cursor('owner'); append('持久化')
  71. store.close(); store = new WorkspaceStore(home, { messagePatches: false })
  72. expect(store.require<WorkspaceMessage>('owner', 'message', 'm').content).toBe(message.content)
  73. expect(store.events('owner', cursor)[0]!.type).toBe('message.patch')
  74. const response = await fetch(url(), { headers: { 'Last-Event-ID': String(cursor) } })
  75. expect(await response.text()).toContain('event: reset')
  76. })
  77. it('sends a full live baseline then patches, with ordered legacy full frames and terminal flush', async () => {
  78. const cursor = store.cursor('owner')
  79. async function connect(format: string) {
  80. const controller = new AbortController()
  81. const response = await fetch(url(format), { headers: { 'Last-Event-ID': String(cursor) }, signal: controller.signal })
  82. const reader = response.body!.getReader(), decoder = new TextDecoder()
  83. let text = ''
  84. const until = async (needle: string) => { while (!text.includes(needle)) text += decoder.decode((await reader.read()).value); return text }
  85. await until('event: ready')
  86. return { controller, reader, until }
  87. }
  88. const modern = await connect('patch-v1'), legacy = await connect('')
  89. try {
  90. append('a'); append('b'); append('c')
  91. message.status = 'complete'; store.saveMessage('owner', message)
  92. const a = await modern.until('"status":"complete"'), b = await legacy.until('"status":"complete"')
  93. expect(a).toContain('"type":"message.patch"')
  94. expect(a).toContain('"content":"开始a"')
  95. expect(b).not.toContain('"type":"message.patch"')
  96. expect(b).toContain('"content":"开始abc"')
  97. const ids = [...b.matchAll(/^id: (\d+)/gm)].map(m => Number(m[1]))
  98. expect(ids).toEqual([...new Set(ids)].sort((a, b) => a - b))
  99. } finally { modern.controller.abort(); legacy.controller.abort(); await modern.reader.cancel().catch(() => {}); await legacy.reader.cancel().catch(() => {}) }
  100. })
  101. it('rolls back both the full row and patch event before any observer sees an update', () => {
  102. const cursor = store.cursor('owner'), before = structuredClone(message), observed: WorkspaceEvent[] = []
  103. store.observe((_, event) => observed.push(event))
  104. store.db.exec("CREATE TRIGGER reject_patch BEFORE INSERT ON workspace_events WHEN NEW.type='message.patch' BEGIN SELECT RAISE(ABORT,'fixture rollback'); END")
  105. expect(() => append('回滚文字')).toThrow('fixture rollback')
  106. expect(store.require('owner', 'message', 'm')).toEqual(before)
  107. expect(store.cursor('owner')).toBe(cursor); expect(observed).toEqual([])
  108. store.db.exec('DROP TRIGGER reject_patch')
  109. store.saveMessage('owner', message)
  110. expect(store.require<WorkspaceMessage>('owner', 'message', 'm').revision).toBe(2)
  111. expect(store.events('owner', cursor).filter(e => e.type === 'message.patch')).toHaveLength(1)
  112. })
  113. it('flushes the last paused-stream preview without creating an extra message revision', async () => {
  114. append('暂停前的最后文字')
  115. const revision = message.revision
  116. await new Promise(resolve => setTimeout(resolve, 280))
  117. expect(store.require<WorkspaceConversation>('owner', 'conversation', message.conversationId).preview).toContain('暂停前的最后文字')
  118. expect(store.require<WorkspaceMessage>('owner', 'message', 'm').revision).toBe(revision)
  119. })
  120. it('persists card-only messages and replays installation updates as full frames without losing the card on text patches',()=>{
  121. message.content='';message.visible=true
  122. message.browserCard={id:'browser-turn',agentId:'bot',agentName:'浏览器 Bot',status:'preparing',installation:{status:'installing',message:'下载中',progress:20,updatedAt:1},updatedAt:1}
  123. let cursor=store.cursor('owner')
  124. store.saveMessage('owner',message)
  125. expect(store.events('owner',cursor).find(e=>e.type==='message.changed')?.data).toMatchObject({browserCard:{installation:{progress:20}}})
  126. expect(store.require<WorkspaceConversation>('owner','conversation',message.conversationId).preview).toContain('托管浏览器')
  127. const folder=new WorkspaceMessageReconciler();folder.remember(structuredClone(message))
  128. cursor=store.cursor('owner')
  129. message.browserCard={...message.browserCard,status:'active',title:'测试网页',url:'https://example.com/',installation:{status:'ready',message:'已就绪',updatedAt:2},updatedAt:2}
  130. store.saveMessage('owner',message)
  131. const events=store.events('owner',cursor)
  132. expect(events.some(e=>e.type==='message.patch')).toBe(false)
  133. for(const e of events)folder.normalize(e)
  134. cursor=store.cursor('owner');append('浏览器已打开')
  135. const patched=folder.normalize(store.events('owner',cursor).find(e=>e.type==='message.patch')!)
  136. expect(patched.data).toMatchObject({content:'浏览器已打开',browserCard:{status:'active',title:'测试网页'}})
  137. message.content='';message.status='complete';message.browserCard={...message.browserCard,status:'closed'}
  138. store.saveMessage('owner',message)
  139. store.close();store=new WorkspaceStore(home,{messagePatches:true})
  140. expect(store.require<WorkspaceMessage>('owner','message',message.id)).toMatchObject({visible:true,status:'complete',content:'',browserCard:{status:'closed',title:'测试网页'}})
  141. expect(store.require<WorkspaceConversation>('owner','conversation',message.conversationId).unread).toBe(true)
  142. })
  143. it('persists nonfatal service warnings independently of reply text and keeps terminal warning-only replies visible',()=>{
  144. message.content='';message.visible=true
  145. message.serviceWarnings=[{service:'离线服务',code:'plugin_initialization_failed',message:'本轮未连接'}]
  146. const cursor=store.cursor('owner');store.saveMessage('owner',message)
  147. expect(store.events('owner',cursor).find(e=>e.type==='message.changed')?.data).toMatchObject({serviceWarnings:message.serviceWarnings})
  148. expect(store.require<WorkspaceConversation>('owner','conversation',message.conversationId).preview).toBe('部分服务暂时不可用')
  149. message.status='complete';store.saveMessage('owner',message)
  150. expect(store.require<WorkspaceMessage>('owner','message',message.id)).toMatchObject({visible:true,content:'',status:'complete',serviceWarnings:message.serviceWarnings})
  151. expect(store.require<WorkspaceConversation>('owner','conversation',message.conversationId).unread).toBe(true)
  152. })