taskCoordinator.test.ts 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213
  1. // @vitest-environment node
  2. import { afterEach, beforeEach, describe, 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 type { WorkspaceNodes } from '../../src/server/workspaceGateway'
  11. import type { WorkspaceAgent, WorkspaceConversation, WorkspaceRun } from '../../src/shared/workspace'
  12. import type { AgentGoal, AgentAssignment, TaskDelivery } from '../../src/shared/agentTasks'
  13. let home: string, store: WorkspaceStore, runtime: WorkspaceRuntime, uploads: UploadStore
  14. let lead: WorkspaceAgent, worker: WorkspaceAgent, second: WorkspaceAgent, team: WorkspaceConversation, goal: AgentGoal
  15. let active: boolean, authorizationVersion: number
  16. const owner = 'task-owner', sourceDevice=randomUUID()
  17. beforeEach(() => {
  18. home=mkdtempSync(join(tmpdir(),'yaoyao-goal-tests-'));store=new WorkspaceStore(home);uploads=new UploadStore(home);active=true;authorizationVersion=1
  19. runtime=new WorkspaceRuntime(store,{requireSource:()=>{}} as unknown as WorkspaceNodes,uploads,()=>active,()=>authorizationVersion)
  20. vi.spyOn(runtime,'wake').mockImplementation(()=>{})
  21. lead=store.createAgent(owner,{name:'老板',profile:'default',canManageTeam:true})
  22. worker=store.createAgent(owner,{name:'研究',profile:'default'})
  23. second=store.createAgent(owner,{name:'编辑',profile:'default'})
  24. team=store.createGroup(owner,{name:'工作团队',memberIds:[lead.id,worker.id,second.id],administratorId:lead.id})
  25. const source=store.list<WorkspaceConversation>(owner,'conversation').find(c=>c.kind==='direct'&&c.memberIds[0]===lead.id)!
  26. const origin=runtime.send(owner,source.id,{requestId:randomUUID(),content:'完成调研报告',deviceHost:sourceDevice})
  27. origin.status='complete';store.saveRun(owner,origin)
  28. goal=runtime.tasks.begin(owner,store.tasks(owner,team.id)[0]!,lead,'完成调研报告',{conversationId:source.id,runId:origin.id,agentId:lead.id},['内容可核对'])
  29. })
  30. afterEach(()=>{runtime.close();uploads.close();store.close();rmSync(home,{recursive:true,force:true})})
  31. function assign(name:string,agent=worker,dependsOn:string[]=[]) {
  32. return runtime.tasks.createAssignment(owner,lead.id,{requestId:randomUUID(),goalId:goal.id,agentId:agent.id,title:name,brief:'提交实际结果',acceptanceCriteria:['内容可核对'],dependsOn})
  33. }
  34. function settle(runId:string,status:WorkspaceRun['status']='complete',text='有依据的执行结果') {
  35. const run=store.require<WorkspaceRun>(owner,'run',runId)
  36. if(text)store.saveMessage(owner,{id:randomUUID(),conversationId:run.conversationId,conversationTaskId:run.conversationTaskId,
  37. seq:0,role:'assistant',agentId:run.targetAgentId||lead.id,content:text,reasoning:'',status:status==='complete'?'complete':'failed',runId,createdAt:Date.now(),attachments:[],tools:[]})
  38. run.status=status;store.saveRun(owner,run)
  39. }
  40. const assignment=(id:string)=>store.require<AgentAssignment>(owner,'assignment',id)
  41. describe('durable team task coordination',()=>{
  42. it('keeps chat lightweight and starts one explicit goal in the existing topic', () => {
  43. const conversation = store.createGroup(owner, {name:'日常群聊',memberIds:[lead.id,worker.id],administratorId:lead.id,mode:'free',autoReplyIds:[worker.id]})
  44. const task = store.tasks(owner, conversation.id)[0]!
  45. const chat = runtime.send(owner, conversation.id, {requestId:randomUUID(),taskId:task.id,content:'讨论一下方案'})
  46. expect(store.get(owner,'goal',task.id)).toBeUndefined()
  47. expect(runtime.tasks.assignments(owner,task.id)).toEqual([])
  48. settle(chat.id)
  49. const input = {requestId:randomUUID(),taskId:task.id,content:'完成方案并交付报告',mode:'goal'}
  50. const run = runtime.send(owner,conversation.id,input)
  51. expect(runtime.send(owner,conversation.id,input).id).toBe(run.id)
  52. expect(store.tasks(owner,conversation.id)).toHaveLength(1)
  53. expect(store.require<AgentGoal>(owner,'goal',task.id)).toMatchObject({objective:input.content,coordinatorId:lead.id})
  54. expect(run).toMatchObject({goalId:task.id,targetAgentId:lead.id})
  55. expect(store.list<{runId:string;agentId:string}>(owner,'turn').filter(w=>w.runId===run.id).map(w=>w.agentId)).toEqual([lead.id])
  56. expect(runtime.tasks.assignments(owner,task.id)).toEqual([])
  57. const review = runtime.dispatch(owner,conversation.id,{requestId:randomUUID(),taskId:task.id,content:'复核进展'}, {agentId:lead.id,kind:'task_review'})
  58. expect(store.list<{runId:string;agentId:string}>(owner,'turn').filter(w=>w.runId===review.id).map(w=>w.agentId)).toEqual([lead.id])
  59. settle(review.id)
  60. expect(runtime.tasks.finish(owner,lead.id,{requestId:randomUUID(),goalId:task.id,status:'complete',result:'交付完整报告',checks:[{criterion:0,passed:true,evidence:'报告已对照请求核验'}]},run.id).status).toBe('complete')
  61. })
  62. it('rejects delivery without a valid coordinator or during existing chat work without leaving a goal', () => {
  63. const conversation = store.createGroup(owner,{name:'讨论群',memberIds:[lead.id,worker.id],administratorId:worker.id})
  64. store.updateAgent(owner,worker.id,{archived:true})
  65. const task = store.tasks(owner,conversation.id)[0]!
  66. const input = {requestId:randomUUID(),taskId:task.id,mode:'goal',content:'完成报告'}
  67. expect(()=>runtime.send(owner,conversation.id,input)).toThrow('负责人需要是本机上未归档的 Bot')
  68. store.updateAgent(owner,worker.id,{archived:false})
  69. expect(store.get(owner,'goal',task.id)).toBeUndefined()
  70. expect(store.messages(owner,conversation.id)).toEqual([])
  71. store.updateConversation(owner,conversation.id,{administratorId:lead.id})
  72. runtime.send(owner,conversation.id,{requestId:randomUUID(),taskId:task.id,content:'先讨论'})
  73. expect(()=>runtime.send(owner,conversation.id,input)).toThrow('等待当前回复')
  74. expect(store.get(owner,'goal',task.id)).toBeUndefined()
  75. })
  76. it('edits criteria with concurrency protection and refuses completion against superseded criteria', () => {
  77. const input = {requestId:randomUUID(),expectedRevision:1,acceptanceCriteria:['报告应比较三种方案']}
  78. const edited = runtime.tasks.updateCriteria(owner,goal.id,input)
  79. expect(edited.acceptanceRevision).toBe(2)
  80. expect(runtime.tasks.updateCriteria(owner,goal.id,input)).toEqual(edited)
  81. expect(()=>runtime.tasks.updateCriteria(owner,goal.id,{...input,requestId:randomUUID(),acceptanceCriteria:['覆盖用户的新要求']})).toThrow('已更新')
  82. const finish = {requestId:randomUUID(),goalId:goal.id,status:'complete',result:'完成',checks:[{criterion:0,passed:true,evidence:'原有验收依据'}]}
  83. expect(()=>runtime.tasks.finish(owner,lead.id,finish)).toThrow('最新要求')
  84. expect(()=>runtime.tasks.updateCriteria('other',goal.id,{...input,requestId:randomUUID()})).toThrow()
  85. expect(runtime.tasks.finish(owner,lead.id,{...finish,acceptanceRevision:2,checks:[{criterion:0,passed:true,evidence:'报告包含三种方案及逐项比较'}]}).status).toBe('complete')
  86. expect(()=>runtime.tasks.updateCriteria(owner,goal.id,{...input,requestId:randomUUID(),expectedRevision:2})).toThrow('进行中的目标')
  87. })
  88. it('wakes an idle coordinator once for newly edited criteria without creating assignments', async () => {
  89. const run = runtime.dispatch(owner,team.id,{requestId:randomUUID(),taskId:goal.id,content:'直接交付报告'}, {agentId:lead.id,kind:'task_review'})
  90. settle(run.id)
  91. await vi.waitFor(()=>expect(store.list<TaskDelivery>(owner,'task-delivery')).toHaveLength(1))
  92. const firstDelivery = store.list<TaskDelivery>(owner,'task-delivery')[0]!
  93. settle(firstDelivery.runId!)
  94. runtime.tasks.updateCriteria(owner,goal.id,{requestId:randomUUID(),expectedRevision:1,acceptanceCriteria:['增加三种方案的比较']})
  95. await vi.waitFor(()=>expect(store.list<TaskDelivery>(owner,'task-delivery')).toHaveLength(2))
  96. runtime.tasks.wake()
  97. await new Promise(resolve=>setTimeout(resolve,5))
  98. expect(store.list<TaskDelivery>(owner,'task-delivery')).toHaveLength(2)
  99. expect(runtime.tasks.assignments(owner,goal.id)).toEqual([])
  100. })
  101. it('runs dependencies only after review, returns a result to the original conversation once',async()=>{
  102. const a=assign('调研'),b=assign('撰写',second,[a.id])
  103. await vi.waitFor(()=>expect(assignment(a.id).status).toBe('running'))
  104. expect(assignment(b.id).status).toBe('pending')
  105. const run=store.require<WorkspaceRun>(owner,'run',assignment(a.id).runId!)
  106. expect(run).toMatchObject({targetAgentId:worker.id,assignmentId:a.id,triggerKind:'assignment',deviceHost:sourceDevice})
  107. settle(run.id)
  108. await vi.waitFor(()=>expect(assignment(a.id).status).toBe('review'))
  109. expect(assignment(b.id).status).toBe('pending')
  110. await vi.waitFor(()=>expect(store.list<TaskDelivery>(owner,'task-delivery')).toHaveLength(1))
  111. const delivered=store.list<TaskDelivery>(owner,'task-delivery')[0]!
  112. expect(delivered.targetConversationId).toBe(goal.origin.conversationId)
  113. expect(store.require<WorkspaceRun>(owner,'run',delivered.runId!).deviceHost).toBe(sourceDevice)
  114. expect(store.messages(owner,goal.origin.conversationId).at(-1)?.role).toBe('system')
  115. runtime.tasks.wake();await new Promise(resolve=>setTimeout(resolve,5))
  116. expect(store.list(owner,'task-delivery')).toHaveLength(1)
  117. runtime.tasks.reviewAssignment(owner,lead.id,{requestId:randomUUID(),assignmentId:a.id,decision:'accept',review:'来源和内容均已核对'})
  118. await vi.waitFor(()=>expect(assignment(b.id).status).toBe('running'))
  119. expect(()=>runtime.tasks.finish(owner,lead.id,{requestId:randomUUID(),goalId:goal.id,status:'complete',result:'报告完成'})).toThrow('未验收')
  120. settle(assignment(b.id).runId!)
  121. await vi.waitFor(()=>expect(assignment(b.id).status).toBe('review'))
  122. runtime.tasks.reviewAssignment(owner,lead.id,{requestId:randomUUID(),assignmentId:b.id,decision:'accept',review:'报告满足要求'})
  123. const ended=runtime.tasks.finish(owner,lead.id,{requestId:randomUUID(),goalId:goal.id,status:'complete',result:'报告已完成,已复核来源和内容',checks:[{criterion:0,passed:true,evidence:'已逐项核对调研和撰写结果'}]},delivered.runId)
  124. expect(ended.status).toBe('complete')
  125. settle(delivered.runId!,'complete','报告已完成,已复核来源和内容')
  126. await vi.waitFor(()=>expect(store.list<TaskDelivery>(owner,'task-delivery').filter(d=>d.kind==='terminal'&&d.status==='delivered')).toHaveLength(1))
  127. const terminal=store.list<TaskDelivery>(owner,'task-delivery').find(d=>d.kind==='terminal')!
  128. expect(terminal.runId).toBe(delivered.runId)
  129. })
  130. it('does not treat a failed execution as accepted and supports bounded, explicit rework',async()=>{
  131. const a=assign('调研')
  132. await vi.waitFor(()=>expect(assignment(a.id).runId).toBeTruthy())
  133. settle(assignment(a.id).runId!,'failed','检索失败')
  134. await vi.waitFor(()=>expect(assignment(a.id).status).toBe('failed'))
  135. expect(()=>runtime.tasks.reviewAssignment(owner,lead.id,{requestId:randomUUID(),assignmentId:a.id,decision:'accept',review:'忽略错误'})).toThrow('失败')
  136. const old=assignment(a.id).runId
  137. runtime.tasks.reviewAssignment(owner,lead.id,{requestId:randomUUID(),assignmentId:a.id,decision:'retry',review:'缩小检索范围后重试'})
  138. await vi.waitFor(()=>expect(assignment(a.id).runId).not.toBe(old))
  139. expect(assignment(a.id).attempt).toBe(2)
  140. })
  141. it('rejects cross-goal dependencies and another coordinator',()=>{
  142. expect(()=>assign('非法依赖',worker,[randomUUID()])).toThrow('依赖')
  143. expect(()=>runtime.tasks.createAssignment(owner,worker.id,{requestId:randomUUID(),goalId:goal.id,agentId:second.id,title:'越权',brief:'不应执行'})).toThrow('无权')
  144. expect(()=>runtime.tasks.finish('another',lead.id,{requestId:randomUUID(),goalId:goal.id,status:'complete',result:'伪造'})).toThrow()
  145. })
  146. it.each(['disabled','credentials','archived','restored','origin_stopped'])('never resurrects a stopped or revoked goal: %s',async reason=>{
  147. const a=assign('调研')
  148. await vi.waitFor(()=>expect(assignment(a.id).runId).toBeTruthy())
  149. if(reason==='disabled')active=false
  150. if(reason==='credentials')authorizationVersion++
  151. if(reason==='archived'||reason==='restored')store.updateAgent(owner,lead.id,{archived:true})
  152. if(reason==='restored')store.updateAgent(owner,lead.id,{archived:false})
  153. if(reason==='origin_stopped'){
  154. const source=store.require<WorkspaceRun>(owner,'run',goal.origin.runId);source.stopRequested=true;store.saveRun(owner,source)
  155. }
  156. settle(assignment(a.id).runId!)
  157. runtime.tasks.wake()
  158. await new Promise(resolve=>setTimeout(resolve,30))
  159. expect(store.list<TaskDelivery>(owner,'task-delivery').filter(d=>d.status==='delivered')).toEqual([])
  160. })
  161. it('resumes only from a new user instruction after previous execution is confirmed stopped',async()=>{
  162. const a=assign('调研')
  163. await vi.waitFor(()=>expect(assignment(a.id).runId).toBeTruthy())
  164. const old=assignment(a.id).runId!
  165. runtime.tasks.cancel(owner,goal.id)
  166. const source=runtime.send(owner,goal.origin.conversationId,{requestId:randomUUID(),content:'继续这个任务'})
  167. const origin={...goal.origin,runId:source.id}
  168. expect(()=>runtime.tasks.resume(owner,lead.id,{requestId:randomUUID(),goalId:goal.id},origin)).toThrow('旧执行')
  169. settle(old,'interrupted','')
  170. await vi.waitFor(()=>expect(store.get<AgentGoal>(owner,'goal',goal.id)?.status).toBe('cancelled'))
  171. const resumed=runtime.tasks.resume(owner,lead.id,{requestId:randomUUID(),goalId:goal.id},origin)
  172. expect(resumed.activation).toBe(2)
  173. expect(resumed.origin.runId).toBe(source.id)
  174. await vi.waitFor(()=>expect(assignment(a.id).runId).not.toBe(old))
  175. expect(assignment(a.id).attempt).toBe(2)
  176. })
  177. it('requires a result for every acceptance criterion',()=>{
  178. expect(()=>runtime.tasks.finish(owner,lead.id,{requestId:randomUUID(),goalId:goal.id,status:'complete',result:'完成'})).toThrow('逐项')
  179. expect(()=>runtime.tasks.finish(owner,lead.id,{requestId:randomUUID(),goalId:goal.id,status:'complete',result:'完成',checks:[{criterion:0,passed:false,evidence:'尚未核对'}]})).toThrow('逐项')
  180. })
  181. it('repairs a missed result dispatch without duplicating the durable source message',async()=>{
  182. const a=assign('调研')
  183. await vi.waitFor(()=>expect(assignment(a.id).runId).toBeTruthy())
  184. const dispatch=vi.spyOn(runtime,'dispatch')
  185. dispatch.mockImplementationOnce(()=>{throw new Error('simulate dispatch crash')})
  186. settle(assignment(a.id).runId!)
  187. await vi.waitFor(()=>expect(assignment(a.id).status).toBe('review'))
  188. runtime.tasks.wake()
  189. await vi.waitFor(()=>expect(store.list<TaskDelivery>(owner,'task-delivery').filter(d=>d.status==='delivered')).toHaveLength(1))
  190. const count=store.messages(owner,goal.origin.conversationId).length
  191. runtime.tasks.wake();await new Promise(resolve=>setTimeout(resolve,10))
  192. expect(store.messages(owner,goal.origin.conversationId)).toHaveLength(count)
  193. })
  194. })
  195. it('pins new assignments to the current message device instead of the old goal origin',async()=>{
  196. const deviceHost=randomUUID()
  197. for(const [index,source] of [deviceHost,undefined].entries()){
  198. const message=runtime.send(owner,goal.origin.conversationId,{requestId:randomUUID(),content:'按当前设备继续',deviceHost:source})
  199. message.status='complete';store.saveRun(owner,message)
  200. const child=runtime.tasks.createAssignment(owner,lead.id,{requestId:randomUUID(),goalId:goal.id,agentId:index===0?worker.id:second.id,title:'新子任务'+index,brief:'使用本机文件',acceptanceCriteria:[],dependsOn:[]},message.id)
  201. await vi.waitFor(()=>expect(assignment(child.id).runId).toBeTruthy())
  202. expect(store.require<WorkspaceRun>(owner,'run',assignment(child.id).runId!).deviceHost).toBe(source)
  203. }
  204. expect(store.require<WorkspaceRun>(owner,'run',goal.origin.runId).deviceHost).toBe(sourceDevice)
  205. })