worker.live.test.ts 24 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204
  1. // @vitest-environment node
  2. import {expect,it} from 'vitest'
  3. import {createServer} from 'node:http'
  4. import {mkdtemp,rm,access} from 'node:fs/promises'
  5. import {tmpdir} from 'node:os'
  6. import {join,resolve} from 'node:path'
  7. import {randomUUID} from 'node:crypto'
  8. import {HermesWorkerProcess} from '../../src/runner/worker/process'
  9. it.skipIf(!process.env.YAOYAO_HERMES_PYTHON)('uses the actual Hermes model loop with only the closed parent tool catalog',async()=>{
  10. const home=await mkdtemp(join(tmpdir(),'yaoyao-worker-live-'))
  11. const requests:any[]=[],calls:any[]=[]
  12. const server=createServer(async(req,res)=>{
  13. let body='';for await(const chunk of req)body+=chunk
  14. if(req.method==='GET'){res.setHeader('Content-Type','application/json');res.end(JSON.stringify({object:'list',data:[{id:'fixture-model',object:'model',context_length:65536}]}));return}
  15. const input=JSON.parse(body||'{}');if(!Array.isArray(input.messages)){res.statusCode=404;res.end('{}');return}requests.push(input)
  16. const called=input.messages.some((message:any)=>message.role==='tool')
  17. const message=called?{role:'assistant',content:'隔离 Worker 已完成。'}:{role:'assistant',content:null,tool_calls:[{id:'fixture-call',type:'function',function:{name:'computer_shell',arguments:JSON.stringify({command:'printf fixture'})}},{id:'forbidden-native',type:'function',function:{name:'terminal',arguments:JSON.stringify({command:`printf escaped > ${home}/host-escape.txt`})}}]}
  18. if(input.stream){
  19. res.setHeader('Content-Type','text/event-stream')
  20. res.end('data: '+JSON.stringify({id:'fixture-response',object:'chat.completion.chunk',model:'fixture-model',choices:[{index:0,delta:{...message,...(message.tool_calls?{tool_calls:message.tool_calls.map((tool:any,index:number)=>({...tool,index}))}:{})},finish_reason:message.tool_calls?'tool_calls':'stop'}]})+'\n\ndata: [DONE]\n\n')
  21. }else{res.setHeader('Content-Type','application/json');res.end(JSON.stringify({id:'fixture-response',object:'chat.completion',model:'fixture-model',choices:[{index:0,message,finish_reason:message.tool_calls?'tool_calls':'stop'}],usage:{prompt_tokens:10,completion_tokens:10,total_tokens:20}}))}
  22. })
  23. await new Promise<void>(resolve=>server.listen(0,'127.0.0.1',resolve))
  24. let worker:HermesWorkerProcess|undefined,diagnostics=''
  25. try{
  26. worker=new HermesWorkerProcess(process.env.YAOYAO_HERMES_PYTHON!,resolve('src/runner/worker/hermes_worker.py'),{diagnostic:true,mode:'run',home,hermesSource:process.env.YAOYAO_HERMES_SOURCE,model:{provider:'custom',api_mode:'chat_completions',base_url:`http://127.0.0.1:${(server.address() as {port:number}).port}/v1`,api_key:'fixture-private-key',model:'fixture-model'},sessionId:randomUUID(),taskId:randomUUID(),cwd:'/workspace',tools:[{name:'computer_shell',description:'fixture shell',inputSchema:{type:'object',properties:{command:{type:'string'}},required:['command'],additionalProperties:false}}],prompt:'执行当前测试工具后总结。',history:[]},text=>{diagnostics+=text})
  27. worker.onTool=async(name,args)=>{calls.push({name,args});return {stdout:'fixture',exitCode:0}}
  28. const complete=await worker.wait('complete',45000).catch(error=>{throw new Error(`${String(error)}\n${diagnostics}`)})
  29. expect(complete.text).toBe('隔离 Worker 已完成。')
  30. expect(calls,JSON.stringify(requests.flatMap(r=>r.messages.filter((m:any)=>m.role==='tool')))+'\n'+diagnostics).toEqual([{name:'computer_shell',args:{command:'printf fixture'}}])
  31. await expect(access(join(home,'host-escape.txt'))).rejects.toMatchObject({code:'ENOENT'})
  32. expect(requests.flatMap(input=>input.messages).find((message:any)=>message.tool_call_id==='forbidden-native')?.content).toMatch(/not authorized|does not exist/)
  33. expect(requests.length).toBeGreaterThanOrEqual(2)
  34. for(const request of requests)expect(request.tools.map((tool:any)=>tool.function.name)).toEqual(['computer_shell'])
  35. }finally{await worker?.close();await new Promise<void>(resolve=>{server.close(()=>resolve());server.closeAllConnections()});await rm(home,{recursive:true,force:true})}
  36. },60000)
  37. it.skipIf(!process.env.YAOYAO_HERMES_PYTHON||!process.env.YAOYAO_COMPUTER_IMAGE)('runs Bot tasks and team creation through a real Hermes worker and a real isolated computer',async()=>{
  38. const {writeFile,mkdir,readFile}=await import('node:fs/promises')
  39. const {createApplication}=await import('../../src/server/app')
  40. const {loadServerConfig}=await import('../../src/server/config')
  41. const {RunnerAgent}=await import('../../src/runner/agent')
  42. const request=(await import('supertest')).default
  43. const home=await mkdtemp(join(tmpdir(),'yaoyao-worker-platform-'))
  44. const hermesHome=join(home,'hermes'),webHome=join(home,'web')
  45. let runnerHome=join(home,'runner')
  46. await Promise.all([hermesHome,runnerHome,webHome].map(path=>mkdir(path,{recursive:true})))
  47. const commands:any[]=[]
  48. let configuredCwd='/projects/worker-fixture'
  49. const upstream=createServer(async(req,res)=>{
  50. let body='';for await(const chunk of req)body+=chunk
  51. const path=new URL(req.url!,'http://fixture').pathname
  52. res.setHeader('Content-Type','application/json')
  53. if(path==='/api/config'){res.end(JSON.stringify({terminal:{cwd:configuredCwd}}));return}
  54. if(path==='/api/profiles'){res.end(JSON.stringify({profiles:[{name:'default'}]}));return}
  55. if(req.method==='GET'){res.end(JSON.stringify(path.startsWith('/v1')?{data:[{id:'fixture-model',context_length:65536}]}:{ok:true}));return}
  56. const input=JSON.parse(body||'{}')
  57. if(!input.messages){res.statusCode=404;res.end('{}');return}
  58. if(req.headers.authorization!=='Bearer fixture-private-key'){res.statusCode=401;res.end('{}');return}
  59. commands.push(input)
  60. const user=input.messages.filter((message:any)=>message.role==='user').at(-1)?.content??''
  61. const helper=/fixture:helper ([0-9a-f-]{36})/.exec(String(user)),team=String(user).includes('fixture:team'),slow=String(user).includes('fixture:slow'),control=String(user).includes('fixture:control')
  62. const current=input.messages.slice(input.messages.findLastIndex((message:any)=>message.role==='user')+1)
  63. const called=current.some((message:any)=>message.role==='tool')
  64. const exported=current.some((message:any)=>message.role==='tool'&&message.name==='computer_export')
  65. const message=called&&!team&&!helper&&!exported?{role:'assistant',content:null,tool_calls:['result.txt','bulk.bin'].map(path=>({id:randomUUID(),type:'function',function:{name:'computer_export',arguments:JSON.stringify({path})}}))}:called?{role:'assistant',content:'隔离任务完成'}:{role:'assistant',content:null,tool_calls:[{id:randomUUID(),type:'function',function:{name:helper?'workspace_create_helper':team?'workspace_create_agent':'computer_shell',arguments:JSON.stringify(helper?{requestId:randomUUID(),goalId:helper[1],name:'临时核验助手',title:'临时核验',brief:'在隔离电脑完成 fixture:helper-work 并导出结果'}:team?{requestId:randomUUID(),name:'隔离创建成员',profile:'default'}:{command:String(user).includes('fixture:shared-write')?'sleep 6; printf shared-proof > result.txt; head -c 10 /dev/zero > bulk.bin':String(user).includes('fixture:shared-read')?'cat result.txt':control?'sleep 2; printf paused > pause-proof.txt':slow?'sleep 30; printf forbidden > late-write.txt':(process.env.YAOYAO_WORKER_PUBLIC_NETWORK==='1'?'curl --fail --silent --show-error --max-time 20 https://example.com > public.html; ':'')+'printf worker-proof > result.txt; head -c 2100000 /dev/zero > bulk.bin; id -u; pwd'})}}]}
  66. if(input.stream){res.setHeader('Content-Type','text/event-stream');res.end('data: '+JSON.stringify({id:randomUUID(),object:'chat.completion.chunk',model:'fixture-model',choices:[{index:0,delta:{...message,...(message.tool_calls?{tool_calls:message.tool_calls.map((tool:any,index:number)=>({...tool,index}))}:{})},finish_reason:message.tool_calls?'tool_calls':'stop'}]})+'\n\ndata: [DONE]\n\n')}
  67. else res.end(JSON.stringify({id:randomUUID(),object:'chat.completion',model:'fixture-model',choices:[{index:0,message,finish_reason:message.tool_calls?'tool_calls':'stop'}],usage:{prompt_tokens:10,completion_tokens:10,total_tokens:20}}))
  68. })
  69. await new Promise<void>(resolve=>upstream.listen(0,'127.0.0.1',resolve));const upstreamURL=`http://127.0.0.1:${(upstream.address() as {port:number}).port}`
  70. await writeFile(join(hermesHome,'config.yaml'),`model:\n provider: custom\n default: fixture-model\n base_url: ${upstreamURL}/v1\n api_key: \${WORKER_FIXTURE_KEY}\n api_mode: chat_completions\nterminal:\n cwd: /projects/worker-fixture\n`,{mode:0o600})
  71. await writeFile(join(hermesHome,'.env'),'WORKER_FIXTURE_KEY=fixture-private-key\n',{mode:0o600})
  72. const app=createApplication({config:loadServerConfig({HERMES_YAOYAO_HOME:webHome,HERMES_YAOYAO_UPSTREAM:upstreamURL})})
  73. const server=createServer(app.app.callback());await new Promise<void>(resolve=>server.listen(0,'127.0.0.1',resolve));app.config.port=(server.address() as {port:number}).port
  74. const address=`http://127.0.0.1:${app.config.port}`,abort=new AbortController();let running:Promise<void>|undefined
  75. try{
  76. const bootstrap=await request(address).get('/api/app/bootstrap').set('Host',`127.0.0.1:${app.config.port}`).expect(200)
  77. const setup=await request(address).post('/api/app/setup').set('Host',`127.0.0.1:${app.config.port}`).set('Origin',address).set('Cookie',bootstrap.headers['set-cookie'].map((cookie:string)=>cookie.split(';')[0]).join('; ')).set('X-CSRF-Token',bootstrap.body.csrfToken).send({username:'worker-fixture',password:'worker-fixture-password'}).expect(200)
  78. const owner=setup.body.user.id,registered=app.runners.enroll(owner,{name:'Worker 验收节点',allowedProfiles:['default']})
  79. const runnerConfig={protocol:1,serverURL:address,runnerId:registered.runner.id,token:registered.token,hermesURL:upstreamURL,allowedProfiles:['default'],artifactRoots:[],computers:{network:process.env.YAOYAO_WORKER_PUBLIC_NETWORK==='1'?'public-proxy':'none',runtime:'docker',imageId:process.env.YAOYAO_COMPUTER_IMAGE!,python:process.env.YAOYAO_HERMES_PYTHON!,hermesSource:process.env.YAOYAO_HERMES_SOURCE!,hermesHome}}
  80. if(process.env.YAOYAO_WORKER_RUNNER_BUNDLE){
  81. const {spawn}=await import('node:child_process')
  82. const path=join(home,'runner-config.json');await writeFile(path,JSON.stringify(runnerConfig),{mode:0o600})
  83. runnerHome=join(home,'state',registered.runner.id)
  84. const child=spawn(process.execPath,[process.env.YAOYAO_WORKER_RUNNER_BUNDLE,'--config',path],{stdio:'ignore'})
  85. const stop=()=>{child.kill('SIGTERM')};abort.signal.addEventListener('abort',stop,{once:true})
  86. running=new Promise<void>((resolve,reject)=>{child.once('error',reject);child.once('exit',code=>{abort.signal.removeEventListener('abort',stop);code===0?resolve():reject(new Error(`Runner bundle exited ${code}`))})})
  87. }else{const runner=new RunnerAgent(runnerConfig as any,runnerHome);running=runner.run(abort.signal)}
  88. void running.catch(()=>{})
  89. await expect.poll(()=>app.runners.summary(app.runners.records()[0]!).online).toBe(true)
  90. const vmCookies=setup.headers['set-cookie'].map((cookie:string)=>cookie.split(';')[0]).join('; ')
  91. const vmBootstrap=await request(address).get('/api/app/bootstrap').set('Host',`127.0.0.1:${app.config.port}`).set('Cookie',vmCookies).expect(200)
  92. const vmGet=async()=>(await request(address).get('/api/app/admin/local-vm').set('Host',`127.0.0.1:${app.config.port}`).set('Cookie',vmCookies).expect(200)).body
  93. const prepareId=randomUUID()
  94. await request(address).post('/api/app/admin/local-vm/prepare').set('Host',`127.0.0.1:${app.config.port}`).set('Origin',address).set('Cookie',vmCookies).set('X-CSRF-Token',vmBootstrap.body.csrfToken).send({requestId:prepareId}).expect(200)
  95. await expect.poll(async()=>(await vmGet()).job?.state,{timeout:90000}).toBe('complete')
  96. expect((await vmGet()).image).toBe(true)
  97. const worker=app.workspace.createAgent(owner,{name:'隔离执行成员',profile:'default',execution:'computer'})
  98. const direct=app.workspace.list<any>(owner,'conversation').find(c=>c.memberIds[0]===worker.id)!
  99. const run=app.workspaceRuntime.send(owner,direct.id,{requestId:randomUUID(),content:'在电脑里执行 fixture:shell 并汇报。'})
  100. await expect.poll(()=>app.workspace.require<any>(owner,'run',run.id).status,{timeout:45000}).toMatch(/complete|failed/)
  101. expect(app.workspace.require<any>(owner,'run',run.id).status,JSON.stringify(app.workspace.messages(owner,direct.id))).toBe('complete')
  102. expect(await readFile(join(runnerHome,'computer-workspaces',worker.id,'result.txt'),'utf8')).toBe('worker-proof')
  103. if(process.env.YAOYAO_WORKER_PUBLIC_NETWORK==='1')expect(await readFile(join(runnerHome,'computer-workspaces',worker.id,'public.html'),'utf8')).toContain('Example Domain')
  104. const message=app.workspace.messages(owner,direct.id).find(message=>message.role==='assistant')!
  105. expect(message.attachments).toHaveLength(2)
  106. const report=message.attachments.find(file=>file.name==='result.txt')!
  107. expect(report).toBeDefined()
  108. const download=await request(address).get(`/api/app/files/${report.id}/download`).set('Host',`127.0.0.1:${app.config.port}`).set('Cookie',setup.headers['set-cookie'].map((cookie:string)=>cookie.split(';')[0]).join('; ')).expect(200)
  109. expect(download.text).toBe('worker-proof')
  110. const files=app.workspace.list<any>(owner,'file');expect(await readFile(files.find(file=>file.name==='result.txt').path,'utf8')).toBe('worker-proof')
  111. const bulk=files.find(file=>file.name==='bulk.bin');expect((await readFile(bulk.path)).length).toBe(2100000)
  112. const toolMessages=commands.flatMap(input=>input.messages.filter((message:any)=>message.role==='tool'))
  113. expect(JSON.parse(toolMessages[0].content).stdout).toBe('1000\n/projects/worker-fixture\n')
  114. configuredCwd='/projects/changed-fixture'
  115. const configuration=await readFile(join(hermesHome,'config.yaml'),'utf8');await writeFile(join(hermesHome,'config.yaml'),configuration.replace('/projects/worker-fixture',configuredCwd))
  116. const next=app.workspaceRuntime.send(owner,direct.id,{requestId:randomUUID(),content:'再次执行并使用新的工作目录。'})
  117. await expect.poll(()=>app.workspace.require<any>(owner,'run',next.id).status,{timeout:45000}).toMatch(/complete|failed/)
  118. expect(app.workspace.require<any>(owner,'run',next.id).status).toBe('complete')
  119. expect(commands.flatMap(input=>input.messages).some((message:any)=>message.role==='tool'&&String(message.content).includes('/projects/changed-fixture'))).toBe(true)
  120. const controlled=app.workspaceRuntime.send(owner,direct.id,{requestId:randomUUID(),content:'fixture:control'})
  121. await expect.poll(()=>app.workspace.messages(owner,direct.id).some(message=>message.runId===controlled.id&&message.tools.some(tool=>tool.name==='computer_shell')),{timeout:30000}).toBe(true)
  122. const cookies=setup.headers['set-cookie'].map((cookie:string)=>cookie.split(';')[0]).join('; ')
  123. const fresh=await request(address).get('/api/app/bootstrap').set('Host',`127.0.0.1:${app.config.port}`).set('Cookie',cookies).expect(200)
  124. const endpoint=`/api/app/agents/${worker.id}/computer`
  125. const api=(path:string,data:unknown)=>request(address).post(endpoint+path).set('Host',`127.0.0.1:${app.config.port}`).set('Origin',address).set('Cookie',cookies).set('X-CSRF-Token',fresh.body.csrfToken).send(data)
  126. const taken=await api('/take',{requestId:randomUUID()}).expect(200),ticket={controlId:taken.body.controlId,token:taken.body.token}
  127. const status=async()=>(await request(address).get(endpoint).set('Host',`127.0.0.1:${app.config.port}`).set('Cookie',cookies)).body
  128. await expect.poll(async()=>(await status()).mode,{timeout:30000}).toBe('human')
  129. expect(app.workspace.require<any>(owner,'run',controlled.id).status).toBe('waiting')
  130. const capture=async()=>(await request(address).get(endpoint+'/frame').set('Host',`127.0.0.1:${app.config.port}`).set('Cookie',cookies)).body
  131. let frame:any
  132. await expect.poll(async()=>{frame=await capture();return frame.width},{timeout:20000}).toBeGreaterThan(0)
  133. const click=await api('/input',{...ticket,requestId:randomUUID(),generation:frame.generation,frameId:frame.id,action:{kind:'click',x:568,y:875}}).expect(200)
  134. expect(click.body.ok).toBe(true)
  135. await new Promise(resolve=>setTimeout(resolve,1200));frame=await capture()
  136. await mkdir('test-results/control',{recursive:true});await writeFile('test-results/control/after-click.png',Buffer.from(frame.data,'base64'))
  137. const typed=await api('/input',{...ticket,requestId:randomUUID(),generation:frame.generation,frameId:frame.id,action:{kind:'text',text:'printf control-proof > /projects/changed-fixture/manual.txt'}}).expect(200)
  138. await api('/input',{...ticket,requestId:randomUUID(),generation:frame.generation,frameId:frame.id,action:{kind:'key',key:'Return'}}).expect(200)
  139. frame=await capture();await writeFile('test-results/control/after-text.png',Buffer.from(frame.data,'base64'))
  140. await expect.poll(async()=>readFile(join(runnerHome,'computer-workspaces',worker.id,'manual.txt'),'utf8').catch(()=>''),{timeout:10000}).toBe('control-proof')
  141. await api('/input',{...ticket,token:'x'.repeat(40),requestId:randomUUID(),generation:frame.generation,frameId:frame.id,action:{kind:'key',key:'Return'}}).expect(403)
  142. await api('/giveback',{...ticket,notes:'用户已在终端完成验证,请继续并核对结果。'}).expect(200)
  143. await expect.poll(()=>app.workspace.require<any>(owner,'run',controlled.id).status,{timeout:45000}).toMatch(/complete|failed/)
  144. expect(app.workspace.require<any>(owner,'run',controlled.id).status).toBe('complete')
  145. const cancelling=app.workspaceRuntime.send(owner,direct.id,{requestId:randomUUID(),content:'fixture:slow'})
  146. await expect.poll(()=>app.workspace.messages(owner,direct.id).some(message=>message.runId===cancelling.id&&message.tools.some(tool=>tool.name==='computer_shell')),{timeout:30000}).toBe(true)
  147. await app.workspaceRuntime.stop(owner,cancelling.id)
  148. await expect.poll(()=>app.workspace.require<any>(owner,'run',cancelling.id).status,{timeout:20000}).toBe('interrupted')
  149. await expect(access(join(runnerHome,'computer-workspaces',worker.id,'late-write.txt'))).rejects.toMatchObject({code:'ENOENT'})
  150. const second=app.workspace.createAgent(owner,{name:'共享核验成员',profile:'default',execution:'computer'})
  151. const secondChat=app.workspace.list<any>(owner,'conversation').find(c=>c.memberIds[0]===second.id)!
  152. const sharedAPI=(method:'post'|'delete',path:string,data:unknown)=>request(address)[method](path).set('Host',`127.0.0.1:${app.config.port}`).set('Origin',address).set('Cookie',cookies).set('X-CSRF-Token',fresh.body.csrfToken).send(data)
  153. const shared=await sharedAPI('post','/api/app/computers/shared',{requestId:randomUUID(),name:'真实共享电脑',memberIds:[worker.id,second.id],trusted:true}).expect(201)
  154. const sharedWrite=app.workspaceRuntime.send(owner,direct.id,{requestId:randomUUID(),content:'fixture:shared-write'})
  155. await expect.poll(()=>app.workspace.messages(owner,direct.id).some(message=>message.runId===sharedWrite.id&&message.tools.some(tool=>tool.name==='computer_shell')),{timeout:30000}).toBe(true)
  156. const sharedRead=app.workspaceRuntime.send(owner,secondChat.id,{requestId:randomUUID(),content:'fixture:shared-read'})
  157. await expect.poll(()=>app.workspace.require<any>(owner,'run',sharedRead.id).status,{timeout:45000}).toMatch(/complete|failed/)
  158. expect(app.workspace.require<any>(owner,'run',sharedWrite.id).status).toBe('complete')
  159. expect(app.workspace.require<any>(owner,'run',sharedRead.id).status,JSON.stringify(app.workspace.messages(owner,secondChat.id))).toBe('complete')
  160. expect(await readFile(join(runnerHome,'computer-workspaces',shared.body.computer.id,'result.txt'),'utf8')).toBe('shared-proof')
  161. expect(await readFile(join(runnerHome,'computer-workspaces',worker.id,'result.txt'),'utf8')).toBe('worker-proof')
  162. const sharedReaderRequests=commands.filter(input=>String(input.messages.filter((message:any)=>message.role==='user').at(-1)?.content).includes('fixture:shared-read'))
  163. expect(sharedReaderRequests.length).toBeGreaterThan(0)
  164. expect(sharedReaderRequests.some(input=>input.messages.some((message:any)=>message.role==='user'&&String(message.content).includes('fixture:shared-write')))).toBe(false)
  165. expect(sharedReaderRequests.some(input=>input.messages.some((message:any)=>message.role==='tool'&&String(message.content).includes('shared-proof')))).toBe(true)
  166. const sharedTake=await api('/take',{requestId:randomUUID()}).expect(200)
  167. await sharedAPI('post',`/api/app/agents/${second.id}/computer/take`,{requestId:randomUUID()}).expect(409)
  168. await sharedAPI('delete',`/api/app/computers/shared/${shared.body.computer.id}`,{}).expect(409)
  169. await api('/giveback',{controlId:sharedTake.body.controlId,token:sharedTake.body.token,notes:''}).expect(200)
  170. await sharedAPI('delete',`/api/app/computers/shared/${shared.body.computer.id}`,{}).expect(200)
  171. expect(app.workspace.require<any>(owner,'agent',worker.id).computerEnvironmentId).toBeUndefined()
  172. await sharedAPI('post',`/api/app/admin/local-vm/instances/${shared.body.computer.id}/stop`,{requestId:randomUUID()}).expect(200)
  173. const manager=app.workspace.createAgent(owner,{name:'隔离组队者',profile:'default',execution:'computer',canManageTeam:true})
  174. const managerChat=app.workspace.list<any>(owner,'conversation').find(c=>c.memberIds[0]===manager.id)!
  175. const managed=app.workspaceRuntime.send(owner,managerChat.id,{requestId:randomUUID(),content:'fixture:team'})
  176. await expect.poll(()=>app.workspace.require<any>(owner,'run',managed.id).status,{timeout:45000}).toMatch(/complete|failed/)
  177. expect(app.workspace.require<any>(owner,'run',managed.id).status,JSON.stringify(app.workspace.messages(owner,managerChat.id))).toBe('complete')
  178. expect(app.workspace.list<any>(owner,'agent').find(agent=>agent.name==='隔离创建成员')).toMatchObject({execution:'computer',canManageTeam:true,createdByAgentId:manager.id})
  179. const group=app.workspace.createGroup(owner,{name:'临时助手验收团队',memberIds:[manager.id,worker.id],administratorId:manager.id})
  180. const task=app.workspace.tasks(owner,group.id)[0]!
  181. const goal=app.workspaceRuntime.tasks.begin(owner,task,manager,'临时助手必须完成隔离工作并回传文件',{conversationId:managerChat.id,runId:managed.id,agentId:manager.id})
  182. const helperRun=app.workspaceRuntime.send(owner,managerChat.id,{requestId:randomUUID(),content:`fixture:helper ${goal.id}`})
  183. await expect.poll(()=>app.workspace.require<any>(owner,'run',helperRun.id).status,{timeout:45000}).toMatch(/complete|failed/)
  184. expect(app.workspace.require<any>(owner,'run',helperRun.id).status).toBe('complete')
  185. const temporary=app.workspace.list<any>(owner,'agent').find(agent=>agent.temporaryGoalId===goal.id)!
  186. expect(temporary).toMatchObject({execution:'computer',canManageTeam:false,helperRunnerId:registered.runner.id})
  187. expect(app.workspace.list<any>(owner,'conversation').some(conversation=>conversation.kind==='direct'&&conversation.memberIds.includes(temporary.id))).toBe(false)
  188. await expect.poll(()=>app.workspaceRuntime.tasks.assignments(owner,goal.id)[0]?.status,{timeout:45000}).toMatch(/review|failed|blocked/)
  189. const assignment=app.workspaceRuntime.tasks.assignments(owner,goal.id)[0]!
  190. expect(assignment.status,assignment.result).toBe('review')
  191. expect(assignment.artifactIds.length).toBeGreaterThan(0)
  192. app.workspaceRuntime.tasks.reviewAssignment(owner,manager.id,{requestId:randomUUID(),assignmentId:assignment.id,decision:'accept',review:'已核对隔离执行和真实产物'})
  193. app.workspaceRuntime.tasks.finish(owner,manager.id,{requestId:randomUUID(),goalId:goal.id,status:'complete',result:'临时助手执行与产物均已核验',checks:[{criterion:0,passed:true,evidence:'隔离执行返回了真实文件,文件已保存到当前任务'}]})
  194. await expect.poll(()=>app.workspace.require<any>(owner,'agent',temporary.id).cleanupState,{timeout:45000}).toBe('complete')
  195. expect(app.workspace.require<any>(owner,'agent',temporary.id).archived).toBe(true)
  196. await expect(access(join(runnerHome,'computer-workspaces',temporary.id))).rejects.toMatchObject({code:'ENOENT'})
  197. expect(app.workspace.list<any>(owner,'file').filter(file=>assignment.artifactIds.includes(file.id)).length).toBe(assignment.artifactIds.length)
  198. }finally{abort.abort();await running?.catch(()=>{});app.close();await Promise.all([server,upstream].map(server=>new Promise<void>(resolve=>{server.close(()=>resolve());server.closeAllConnections()})));await rm(home,{recursive:true,force:true})}
  199. },420000)