workspaceCollaboration.test.ts 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175
  1. // @vitest-environment node
  2. import { afterEach, beforeEach, expect, it, vi } from 'vitest'
  3. import { mkdtempSync, rmSync } from 'node:fs'
  4. import { tmpdir } from 'node:os'
  5. import { join } from 'node:path'
  6. import { randomUUID } from 'node:crypto'
  7. import { WorkspaceStore } from '../../src/server/workspaceStore'
  8. import { WorkspaceRuntime } from '../../src/server/workspaceRuntime'
  9. import { UploadStore } from '../../src/server/uploads'
  10. import { discussionRounds } from '../../src/server/workspaceDiscussion'
  11. import type { Work } from '../../src/server/workspaceScheduler'
  12. import type { WorkspaceAgent as Agent, WorkspaceConversation as Conversation, WorkspaceMessage as Message, WorkspaceRun as Run } from '../../src/shared/workspace'
  13. import type { WorkspaceNodes } from '../../src/server/workspaceGateway'
  14. const owner = 'collaboration-owner'
  15. let home: string, store: WorkspaceStore, runtime: FakeRuntime, uploads: UploadStore
  16. class FakeRuntime extends WorkspaceRuntime {
  17. calls: Array<{ agentId: string; work: Work }> = []
  18. reply: (agent: Agent, work: Work) => string = agent => `${agent.name} 的实质结果`
  19. interrupt: (work: Work) => Promise<void> = work => {
  20. work.status = 'interrupted'; this.saveWork('collaboration-owner', work)
  21. return Promise.resolve()
  22. }
  23. protected override async performTurn(user: string, c: Conversation, agent: Agent, work: Work): Promise<Message> {
  24. this.calls.push({ agentId: agent.id, work })
  25. const content = this.reply(agent, work)
  26. const message: Message = { id: randomUUID(), conversationId: c.id, conversationTaskId: work.conversationTaskId, seq: 0, role: 'assistant', agentId: agent.id, agentName: agent.name, content, reasoning: '', runId: work.runId, taskId: work.id, status: 'complete', attachments: [], tools: [], createdAt: Date.now() }
  27. this.store.saveMessage(user, message)
  28. work.status = 'complete'; work.currentMessageId = message.id; this.saveWork(user, work)
  29. return message
  30. }
  31. protected override async interruptTurn(user: string, work: Work): Promise<void> { await this.interrupt(work) }
  32. }
  33. beforeEach(() => {
  34. home = mkdtempSync(join(tmpdir(), 'yaoyao-collaboration-')); store = new WorkspaceStore(home); uploads = new UploadStore(home)
  35. runtime = new FakeRuntime(store, { requireSource: () => {} } as unknown as WorkspaceNodes, uploads)
  36. })
  37. afterEach(() => { runtime.close(); uploads.close(); store.close(); rmSync(home, { recursive: true, force: true }) })
  38. const bot = (name: string) => store.createAgent(owner, { name, profile: 'default' })
  39. const direct = (id: string) => store.list<Conversation>(owner, 'conversation').find(c => c.kind === 'direct' && c.memberIds[0] === id)!
  40. it('settles a stop locally while remote interruption is unavailable', async () => {
  41. vi.spyOn(runtime, 'wake').mockImplementation(() => {})
  42. const a = bot('停止挂起')
  43. const run = runtime.send(owner, direct(a.id).id, { requestId: randomUUID(), content: '等待停止' })
  44. const work = store.list<Work>(owner, 'turn').find(item => item.runId === run.id)!
  45. work.status = 'running'
  46. work.submitted = true
  47. store.put(owner, 'turn', work.id, work)
  48. let finishInterrupt!: () => void
  49. runtime.interrupt = () => new Promise(resolve => { finishInterrupt = resolve })
  50. await Promise.race([
  51. runtime.stop(owner, run.id),
  52. new Promise((_, reject) => setTimeout(() => reject(new Error('stop timed out')), 50)),
  53. ])
  54. expect(store.require<Run>(owner, 'run', run.id)).toMatchObject({ status: 'interrupted', stopRequested: true })
  55. expect(store.require<Work>(owner, 'turn', work.id)).toMatchObject({ status: 'interrupted', cleanupPending: true })
  56. expect(store.require<Conversation>(owner, 'conversation', run.conversationId).activeRunId).toBeUndefined()
  57. finishInterrupt()
  58. await vi.waitFor(() => expect(store.require<Work>(owner, 'turn', work.id).cleanupPending).toBe(false))
  59. })
  60. it('projects real deliveries into compact direction metadata for live events and history without rewriting raw messages', () => {
  61. vi.spyOn(runtime, 'wake').mockImplementation(() => {})
  62. const a = bot('竹儿'), b = bot('研究员')
  63. runtime.send(owner, direct(a.id).id, { requestId: randomUUID(), content: '请核对接口' })
  64. const work = store.list<Work>(owner, 'turn')[0]!
  65. work.status = 'running'; store.put(owner, 'turn', work.id, work)
  66. const events: Message[] = []
  67. const unsubscribe = store.observe((_owner, event) => { if (event.type === 'message.changed') events.push(event.data as Message) })
  68. const input = { requestId: randomUUID(), agentId: b.id, content: '请核对接口版本,确认旧客户端是否兼容。', fileIds: [] }
  69. const peer = runtime.collaboration.send(owner, work.id, input)
  70. const deliveries = events.filter(m => m.peerMessageId === peer.id)
  71. expect(deliveries.map(m => m.communication?.direction)).toEqual(['incoming', 'outgoing'])
  72. expect(deliveries.every(m => m.role === 'system' && m.communication?.content === input.content)).toBe(true)
  73. expect(deliveries[0]?.content).toContain('来自 Bot')
  74. expect(deliveries[1]?.content).toContain('已向')
  75. expect(deliveries[0]?.communication).toMatchObject({ peerId: a.id, peerName: '竹儿' })
  76. expect(deliveries[1]?.communication).toMatchObject({ peerId: b.id, peerName: '研究员' })
  77. const history = store.messages(owner, direct(a.id).id).find(m => m.peerMessageId === peer.id)!
  78. expect(history.communication).toEqual(deliveries[1]?.communication)
  79. expect(store.require<Message>(owner, 'message', history.id).content).toContain('已向')
  80. const ordinary: Message = { ...history, id: randomUUID(), peerMessageId: undefined, communication: undefined, content: '已向 Bot 发送只是普通文本' }
  81. expect(store.messageForDisplay(owner, ordinary).communication).toBeUndefined()
  82. unsubscribe()
  83. })
  84. it('honors explicit round counts and finishes all eight speakers in twelve rounds', async () => {
  85. expect(discussionRounds('请讨论一轮')).toBe(1); expect(discussionRounds('请讨论三轮')).toBe(3); expect(discussionRounds('讨论一下')).toBe(2)
  86. const members = Array.from({ length: 8 }, (_, i) => bot(`成员${i}`))
  87. const c = store.createGroup(owner, { name: '平等讨论', memberIds: members.map(a => a.id), collaborationMode: 'discussion' })
  88. const run = runtime.send(owner, c.id, { requestId: randomUUID(), content: '请讨论十二轮' })
  89. await vi.waitFor(() => expect(store.require<Run>(owner, 'run', run.id).status).toBe('complete'))
  90. expect(runtime.calls).toHaveLength(96)
  91. expect(runtime.calls.slice(8, 16).map(c => c.agentId)).toEqual([...members.slice(1), members[0]!].map(a => a.id))
  92. for (const member of members) expect(runtime.calls.filter(c => c.agentId === member.id)).toHaveLength(12)
  93. })
  94. it('selects mentioned speakers and continues after an individual failure without starting a goal', async () => {
  95. const a = bot('甲'), b = bot('乙'), c = bot('丙')
  96. const group = store.createGroup(owner, { name: '评审', memberIds: [a.id, b.id, c.id], collaborationMode: 'discussion' })
  97. runtime.reply = agent => { if (agent.id === b.id) throw new Error('成员暂不可用'); return '核对后的结果' }
  98. const run = runtime.send(owner, group.id, { requestId: randomUUID(), content: '回答一轮', mentionIds: [b.id, c.id] })
  99. await vi.waitFor(() => expect(store.require<Run>(owner, 'run', run.id).status).toBe('complete'))
  100. expect(runtime.calls.map(c => c.agentId)).toEqual([b.id, c.id])
  101. expect(store.list(owner, 'goal')).toEqual([])
  102. expect(store.list<Message>(owner, 'message').some(m => m.agentId === b.id && m.status === 'failed')).toBe(true)
  103. })
  104. it('delivers A → B → A asynchronously and returns the reply to the originating group topic', async () => {
  105. const a = bot('甲'), b = bot('乙')
  106. const group = store.createGroup(owner, { name: '原始话题', memberIds: [a.id, b.id], administratorId: a.id, mode: 'host' })
  107. const task = store.tasks(owner, group.id)[0]!
  108. runtime.reply = (agent, work) => {
  109. const run = store.require<Run>(owner, 'run', work.runId)
  110. if (!run.peerMessageId) runtime.collaboration.send(owner, work.id, { requestId: randomUUID(), agentId: b.id, content: '请核对接口事实', fileIds: [] })
  111. else if (agent.id === b.id) runtime.collaboration.send(owner, work.id, { requestId: randomUUID(), agentId: a.id, content: '已查明接口版本为二', fileIds: [], replyTo: run.peerMessageId })
  112. return agent.id === b.id ? '已发送核对结果' : run.peerMessageId ? '向用户交付最终结果' : '已请乙协助'
  113. }
  114. const deviceHost=randomUUID()
  115. runtime.send(owner, group.id, { requestId: randomUUID(), taskId: task.id, content: '请找乙核对接口后告诉我',deviceHost })
  116. await vi.waitFor(() => expect(runtime.calls).toHaveLength(3))
  117. await vi.waitFor(() => expect(store.list<Run>(owner, 'run').every(r => r.status === 'complete')).toBe(true))
  118. const reply = store.list<Message>(owner, 'message').find(m => m.content === '向用户交付最终结果')!
  119. expect(reply.conversationId).toBe(group.id); expect(reply.conversationTaskId).toBe(task.id)
  120. expect(runtime.collaboration.list(owner)).toHaveLength(2)
  121. expect(store.list<Run>(owner,'run').every(run=>run.deviceHost===deviceHost)).toBe(true)
  122. expect(store.list<Message>(owner, 'message').filter(m => m.peerMessageId).every(m => m.role !== 'user')).toBe(true)
  123. })
  124. it('deduplicates sends, suppresses acknowledgements and stops pending peers after the source turn ends', async () => {
  125. vi.spyOn(runtime, 'wake').mockImplementation(() => {})
  126. const a = bot('甲'), b = bot('乙'), run = runtime.send(owner, direct(a.id).id, { requestId: randomUUID(), content: '联系同伴' })
  127. const work = store.list<Work>(owner, 'turn')[0]!; work.status = 'running'; store.put(owner, 'turn', work.id, work)
  128. const input = { requestId: randomUUID(), agentId: b.id, content: '请核对接口', fileIds: [] }
  129. const first = runtime.collaboration.send(owner, work.id, input)
  130. expect(runtime.collaboration.send(owner, work.id, input).id).toBe(first.id)
  131. expect(() => runtime.collaboration.send(owner, work.id, { ...input, requestId: randomUUID() })).toThrowError(expect.objectContaining({ code: 'peer_repeated' }))
  132. expect(() => runtime.collaboration.send(owner, work.id, { ...input, requestId: randomUUID(), content: '收到,谢谢' })).toThrowError(expect.objectContaining({ code: 'peer_acknowledgement' }))
  133. run.status = 'complete'; store.saveRun(owner, run)
  134. await runtime.stop(owner, run.id)
  135. expect(store.require<any>(owner, 'peer-message', first.id).status).toBe('stopped')
  136. expect(store.require<Run>(owner, 'run', first.runId!).stopRequested).toBe(true)
  137. })
  138. it('bounds a persisted asynchronous chain and rejects disabled recipients and unrelated files', () => {
  139. vi.spyOn(runtime, 'wake').mockImplementation(() => {})
  140. const a = bot('甲'), b = bot('乙')
  141. runtime.send(owner, direct(a.id).id, { requestId: randomUUID(), content: '开始协作' })
  142. let work = store.list<Work>(owner, 'turn')[0]!
  143. work.status = 'running'; store.put(owner, 'turn', work.id, work)
  144. expect(() => runtime.collaboration.send(owner, work.id, { requestId: randomUUID(), agentId: b.id, content: '文件请求', fileIds: [randomUUID()] })).toThrowError(expect.objectContaining({ code: 'peer_file_forbidden' }))
  145. const hiddenFile = randomUUID()
  146. store.saveMessage(owner, { id: randomUUID(), conversationId: work.conversationId, seq: 0, role: 'assistant', agentId: a.id, visible: false, content: '内部执行资料', reasoning: '', status: 'complete', attachments: [{ id: hiddenFile, name: 'internal.txt', mimeType: 'text/plain', size: 1, sender: 'user', createdAt: Date.now() }], tools: [], createdAt: Date.now() })
  147. expect(() => runtime.collaboration.send(owner, work.id, { requestId: randomUUID(), agentId: b.id, content: '不能分享内部附件', fileIds: [hiddenFile] })).toThrowError(expect.objectContaining({ code: 'peer_file_forbidden' }))
  148. for (let depth = 1; depth <= 8; depth++) {
  149. const peer = runtime.collaboration.send(owner, work.id, { requestId: randomUUID(), agentId: work.agentId === a.id ? b.id : a.id, content: `第 ${depth} 次新的事实和请求`, fileIds: [] })
  150. expect(peer.depth).toBe(depth)
  151. work = store.list<Work>(owner, 'turn').find(w => w.runId === peer.runId)!
  152. work.status = 'running'; store.put(owner, 'turn', work.id, work)
  153. }
  154. expect(() => runtime.collaboration.send(owner, work.id, { requestId: randomUUID(), agentId: work.agentId === a.id ? b.id : a.id, content: '第九次请求', fileIds: [] })).toThrowError(expect.objectContaining({ code: 'peer_chain_limit' }))
  155. expect(runtime.collaboration.list(owner)).toHaveLength(8)
  156. })
  157. it('restores the original project on reply without granting that project to an outside peer', async () => {
  158. const a = bot('项目成员'), b = bot('项目外顾问')
  159. const project = runtime.knowledge.saveProject(owner, { requestId: randomUUID(), name: '私有项目', description: '', memberIds: [a.id], groupIds: [] })
  160. const seen: Array<{ agentId: string; projectId?: string }> = []
  161. runtime.reply = (agent, work) => {
  162. const run = store.require<Run>(owner, 'run', work.runId)
  163. seen.push({ agentId: agent.id, projectId: run.projectId })
  164. if (!run.peerMessageId) runtime.collaboration.send(owner, work.id, { requestId: randomUUID(), agentId: b.id, content: '请对公开接口提供建议', fileIds: [] })
  165. else if (agent.id === b.id) runtime.collaboration.send(owner, work.id, { requestId: randomUUID(), agentId: a.id, content: '建议保留旧版本兼容', fileIds: [], replyTo: run.peerMessageId })
  166. return '完成当前阶段'
  167. }
  168. runtime.send(owner, direct(a.id).id, { requestId: randomUUID(), content: '请参考顾问建议推进当前项目', projectId: project.id })
  169. await vi.waitFor(() => expect(seen).toHaveLength(3))
  170. expect(seen).toEqual([{ agentId: a.id, projectId: project.id }, { agentId: b.id, projectId: undefined }, { agentId: a.id, projectId: project.id }])
  171. expect(runtime.knowledge.memberships(owner, b.id)).toEqual([])
  172. })