workspaceSync.test.ts 5.6 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889
  1. // @vitest-environment node
  2. import Koa from 'koa'
  3. import { once } from 'node:events'
  4. import { mkdtempSync, rmSync } from 'node:fs'
  5. import { tmpdir } from 'node:os'
  6. import { join } from 'node:path'
  7. import { afterEach, beforeEach, expect, it } from 'vitest'
  8. import { WorkspaceStore } from '../../src/server/workspaceStore'
  9. import { streamWorkspace, workspaceDetail } from '../../src/server/workspaceSync'
  10. import type { LocalAuthStore } from '../../src/server/localAuth'
  11. import type { WorkspaceRuntime } from '../../src/server/workspaceRuntime'
  12. import type { Server } from 'node:http'
  13. import type { WorkspaceConversation, WorkspaceMessage } from '../../src/shared/workspace'
  14. let home: string, store: WorkspaceStore, server: Server, version: number
  15. const auth = { require: () => ({ id: 'owner' }), currentFromCookieHeader: () => ({ id: 'owner' }), isUserActive: () => true, pushAuthorizationVersion: () => version } as unknown as LocalAuthStore
  16. beforeEach(async () => {
  17. home = mkdtempSync(join(tmpdir(), 'workspace-sync-')); store = new WorkspaceStore(home); version = 1
  18. const app = new Koa(); app.use(ctx => streamWorkspace(ctx, store, auth))
  19. server = app.listen(0, '127.0.0.1'); await once(server, 'listening')
  20. })
  21. afterEach(async () => { store.close(); server.closeAllConnections(); await new Promise<void>(resolve => server.close(() => resolve())); rmSync(home, { recursive: true, force: true }) })
  22. const url = () => `http://127.0.0.1:${(server.address() as { port: number }).port}/stream`
  23. it('replays more than one event page, stays owner-scoped, and continues live without a gap', async () => {
  24. for (let i = 0; i < 270; i++) store.event('owner', 'message.changed', { id: String(i), role: 'assistant', attachments: [], content: `消息${i}` }, 'c')
  25. store.event('someone-else', 'message.changed', { id: 'private', role: 'assistant', attachments: [], content: '别人的消息' }, 'private')
  26. const controller = new AbortController()
  27. const response = await fetch(url(), { signal: controller.signal, headers: { 'Last-Event-ID': '3' } })
  28. expect(response.headers.get('content-type')).toContain('text/event-stream')
  29. const reader = response.body!.getReader(), decoder = new TextDecoder()
  30. let text = ''
  31. while (!text.includes('event: ready')) text += decoder.decode((await reader.read()).value, { stream: true })
  32. expect(text.match(/event: workspace/g)).toHaveLength(267)
  33. expect(text).not.toContain('别人的消息')
  34. const seq = store.event('owner', 'conversation.changed', { id: 'c', preview: '新增' }, 'c')
  35. while (!text.includes(`id: ${seq}\n`)) text += decoder.decode((await reader.read()).value, { stream: true })
  36. expect(text).toContain('新增')
  37. controller.abort(); await reader.cancel().catch(() => {})
  38. })
  39. it('closes immediately on a live event after authorization changes', async () => {
  40. const response = await fetch(url())
  41. const reader = response.body!.getReader()
  42. await reader.read()
  43. version++
  44. store.event('owner', 'conversation.changed', { id: 'c' }, 'c')
  45. expect((await reader.read()).done).toBe(true)
  46. expect(store.changes.listenerCount('owner')).toBe(0)
  47. })
  48. it('requests a fresh snapshot when the database cursor has moved backwards', async () => {
  49. const response = await fetch(url(), { headers: { 'Last-Event-ID': '999' } })
  50. expect(await response.text()).toContain('event: reset')
  51. })
  52. it('replays a full lightweight baseline before patches for a background chat omitted from a partial snapshot', async () => {
  53. store.createAgent('owner', { name: 'Bot', nodeId: 'local', profile: 'default' })
  54. const c = store.list<WorkspaceConversation>('owner', 'conversation')[0]!
  55. const m: WorkspaceMessage = { id: 'background', conversationId: c.id, seq: 0, role: 'assistant', content: 'one', reasoning: '', status: 'streaming', tools: [{ id: 'large', result: 'private-output'.repeat(2000) }], attachments: [], createdAt: 1 }
  56. store.saveMessage('owner', m)
  57. const after = store.cursor('owner')
  58. store.saveMessage('owner', { ...m, content: 'one two' })
  59. const controller = new AbortController()
  60. const response = await fetch(`${url()}?after=${after}&format=patch-v1&toolDetails=lazy`, { signal: controller.signal })
  61. const reader = response.body!.getReader(), decoder = new TextDecoder()
  62. let text = ''
  63. while (!text.includes('event: ready')) text += decoder.decode((await reader.read()).value, { stream: true })
  64. const event = text.split('\n').find(line => line.startsWith('data: ') && line.includes('message.changed'))!
  65. expect(JSON.parse(event.slice(6)).data).toMatchObject({ id: m.id, content: 'one two', tools: [expect.objectContaining({ detailsUrl: expect.any(String) })] })
  66. expect(text).not.toContain('private-output')
  67. controller.abort(); await reader.cancel().catch(() => {})
  68. })
  69. it('returns only the latest 50 messages with an exact hasOlder flag and a matching cursor', () => {
  70. store.createAgent('owner', { name: 'Bot', nodeId: 'local', profile: 'default' })
  71. const conversation = store.list<WorkspaceConversation>('owner', 'conversation')[0]!
  72. for (let i = 1; i <= 51; i++) {
  73. const message: WorkspaceMessage = { id: `m${i}`, conversationId: conversation.id, seq: i, role: 'user', content: String(i), reasoning: '', status: 'complete', attachments: [], tools: [], createdAt: i }
  74. store.saveMessage('owner', message)
  75. }
  76. const runtime = { tasks: { assignments: () => [] } } as unknown as WorkspaceRuntime
  77. const detail = workspaceDetail(store, runtime, 'owner', conversation.id)
  78. expect(detail.messages).toHaveLength(50)
  79. expect(detail.messages[0]!.content).toBe('2')
  80. expect(detail.hasOlder).toBe(true)
  81. expect(detail.cursor).toBe(store.cursor('owner'))
  82. expect(() => workspaceDetail(store, runtime, 'another', conversation.id)).toThrow()
  83. })