workerCompaction.live.test.ts 8.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103
  1. // @vitest-environment node
  2. import {it,expect,vi} from 'vitest'
  3. import {createServer} from 'node:http'
  4. import {DatabaseSync} from 'node:sqlite'
  5. import {mkdtemp,mkdir,writeFile,readFile,rm} from 'node:fs/promises'
  6. import {join,resolve} from 'node:path'
  7. import {tmpdir} from 'node:os'
  8. import {randomUUID} from 'node:crypto'
  9. import {ComputerRuntime,ComputerGateway} from '../../src/runner/worker/gateway'
  10. import {LocalVmImages} from '../../src/runner/computers/localVm'
  11. const available=!!process.env.YAOYAO_HERMES_PYTHON&&!!process.env.YAOYAO_HERMES_SOURCE
  12. async function fixture(enabled:boolean,docker=false,overflow=!enabled,inPlace=true){
  13. const home=await mkdtemp(join(tmpdir(),'yaoyao-context-live-')),profile=join(home,'profile'),db=new DatabaseSync(':memory:')
  14. await mkdir(profile);const requests:any[]=[],gateways:ComputerGateway[]=[]
  15. const summary='## Task\nContinue the fixture task.\n## Progress\nCOMPACTED_WORKER_HISTORY\n'+'Earlier inspection is complete. Keep the user request and continue from the saved result.\n'.repeat(25)
  16. const server=createServer(async(req,res)=>{
  17. res.setHeader('Content-Type','application/json')
  18. if(req.method==='GET'){res.end(JSON.stringify({data:[{id:'fixture-context-model',context_length:65536}]}));return}
  19. let body='';for await(const chunk of req)body+=chunk
  20. const input=JSON.parse(body);requests.push(input)
  21. const modelCall=Array.isArray(input.tools)&&input.tools.length>0
  22. if(overflow&&modelCall){res.statusCode=400;res.end(JSON.stringify({error:{message:'maximum context length is 65536 tokens, requested 80000 tokens',code:'context_length_exceeded',type:'invalid_request_error'}}));return}
  23. const content=modelCall?'压缩后继续完成。':summary
  24. if(input.stream){res.setHeader('Content-Type','text/event-stream');res.end('data: '+JSON.stringify({id:randomUUID(),object:'chat.completion.chunk',model:'fixture-context-model',choices:[{index:0,delta:{role:'assistant',content},finish_reason:'stop'}],usage:{prompt_tokens:100,completion_tokens:20,total_tokens:120}})+'\n\ndata: [DONE]\n\n')}
  25. else res.end(JSON.stringify({id:randomUUID(),object:'chat.completion',model:'fixture-context-model',choices:[{index:0,message:{role:'assistant',content},finish_reason:'stop'}],usage:{prompt_tokens:100,completion_tokens:20,total_tokens:120}}))
  26. })
  27. await new Promise<void>(done=>server.listen(0,'127.0.0.1',done));const base=`http://127.0.0.1:${(server.address() as {port:number}).port}`
  28. await writeFile(join(profile,'config.yaml'),JSON.stringify({model:{default:'fixture-context-model',provider:'custom',base_url:base+'/v1',api_key:'fixture-secret',context_length:65536,max_tokens:4096},compression:{enabled,in_place:inPlace,threshold:0.5,target_ratio:0.2,protect_first_n:0,protect_last_n:2,tail_mode:'legacy',max_attempts:1},terminal:{cwd:'.'}}))
  29. const script=join(home,'diagnostic-worker.py')
  30. await writeFile(script,(await readFile(resolve('src/runner/worker/hermes_worker.py'),'utf8')).replace('NONCE = BOOT["nonce"]','NONCE = BOOT["nonce"]\nBOOT["diagnostic"] = True'))
  31. const runtime=new ComputerRuntime(db,{protocol:1,runnerId:randomUUID(),serverURL:base,hermesURL:base,token:'fixture',allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',imageId:process.env.YAOYAO_COMPUTER_IMAGE??'sha256:'+'a'.repeat(64),python:process.env.YAOYAO_HERMES_PYTHON!,hermesSource:process.env.YAOYAO_HERMES_SOURCE!,hermesHome:profile,network:'none'}},home,script)
  32. await runtime.ready;runtime.retainDesktops=true
  33. const id=randomUUID(),meta={agentId:id,environmentId:id,ownerKey:'context-live-fixture'}
  34. if(!docker){
  35. vi.spyOn(runtime.provider,'ensure').mockResolvedValue({id,containerId:id,running:true,workspace:home,isolation:'container'})
  36. vi.spyOn(runtime.provider,'stop').mockResolvedValue();vi.spyOn(runtime.provider,'remove').mockResolvedValue()
  37. }
  38. async function open(sessionId?:string){
  39. const gateway=new ComputerGateway(runtime,meta,randomUUID(),async()=>{},async()=>({}));gateways.push(gateway)
  40. await gateway.connect();const opened=await gateway.rpc(sessionId?'session.resume':'session.create',{profile:'default',...(sessionId?{session_id:sessionId}:{})})
  41. return {gateway,id:opened.session_id as string}
  42. }
  43. async function send(gateway:ComputerGateway,id:string,prompt:string){
  44. let diagnostics=''
  45. const completed=new Promise<any>((done,reject)=>{gateway.onEvent=frame=>{if(frame.type==='message.complete')done(frame.payload)};gateway.onDisconnect=()=>reject(new Error('unexpected Worker disconnection\n'+diagnostics))})
  46. await gateway.rpc('prompt.submit',{session_id:id,text:prompt})
  47. runtime.workers.get(gateway.workId)?.process.child.stderr.on('data',chunk=>{diagnostics+=String(chunk)})
  48. return completed
  49. }
  50. return {runtime,db,meta,requests,open,send,async close(){await Promise.all(gateways.map(g=>g.close()));await runtime.controls.close();await runtime.pool.close();db.close();vi.restoreAllMocks();await new Promise<void>(done=>{server.close(()=>done());server.closeAllConnections()});await rm(home,{recursive:true,force:true})}}
  51. }
  52. it.skipIf(!available).each([true,false])('inherits Profile compression and resumes compacted history with in_place=%s',async inPlace=>{
  53. const f=await fixture(true,false,false,inPlace)
  54. try{
  55. const seed=await f.open(),session=f.runtime.session(seed.id,f.meta,'default')
  56. session.history=Array.from({length:50},(_,i)=>({role:i%2?'assistant':'user',content:`HISTORY_PAYLOAD_${i}: `+'Long earlier fixture investigation with completed tool results. '.repeat(180)}))
  57. const initialSize=JSON.stringify(session.history).length;f.runtime.save(session)
  58. await seed.gateway.close();const first=await f.open(seed.id)
  59. const result=await f.send(first.gateway,first.id,'Continue the pending fixture task.')
  60. expect(result).toMatchObject({status:'complete',text:'压缩后继续完成。'})
  61. const history=f.runtime.session(first.id,f.meta,'default').history
  62. expect(JSON.stringify(history)).toContain('COMPACTED_WORKER_HISTORY')
  63. expect(JSON.stringify(history).length).toBeLessThan(initialSize/2)
  64. expect(f.requests.some(r=>!r.tools?.length)).toBe(true)
  65. await first.gateway.close()
  66. const next=await f.open(first.id);const again=await f.send(next.gateway,next.id,'Continue another step from the compacted history.')
  67. expect(again.status).toBe('complete')
  68. const last=f.requests.filter(r=>r.tools?.length).at(-1)!
  69. expect(JSON.stringify(last.messages)).toContain('COMPACTED_WORKER_HISTORY')
  70. expect(JSON.stringify(last.messages).length).toBeLessThan(initialSize/2)
  71. expect(last.tools.map((t:any)=>t.function.name)).toEqual(expect.arrayContaining(['computer_shell','computer_export']))
  72. expect(last.tools.map((t:any)=>t.function.name)).not.toContain('terminal')
  73. }finally{await f.close()}
  74. },120000)
  75. it.skipIf(!available||!process.env.YAOYAO_COMPUTER_IMAGE)('keeps a real Docker desktop running on a typed context error, then honors the idle policy',async()=>{
  76. const f=await fixture(false,true),manager=new LocalVmImages(f.runtime,f.db)
  77. try{
  78. await manager.ready;await manager.idlePolicy(0)
  79. const turn=await f.open(),spec=f.runtime.pool.definition(f.meta.ownerKey,f.meta.environmentId)!,before=await f.runtime.provider.inspect(spec)
  80. await f.runtime.provider.execute(spec,['/bin/sh','-c','printf context-proof > retained.txt'],{authorize:()=>{}})
  81. const result=await f.send(turn.gateway,turn.id,'Trigger the fixture context overflow.')
  82. expect(result).toMatchObject({status:'failed',code:'context_compaction_disabled',error:expect.stringContaining('虚拟机已进入空闲')})
  83. await turn.gateway.close();await f.runtime.pool.expire()
  84. expect(await f.runtime.provider.inspect(spec)).toMatchObject({running:true,containerId:before!.containerId})
  85. expect((await f.runtime.provider.execute(spec,['cat','retained.txt'],{authorize:()=>{}})).stdout).toBe('context-proof')
  86. await manager.idlePolicy(1)
  87. f.db.prepare("UPDATE computer_environments SET value=json_set(value,'$.updatedAt',?)").run(Date.now()-61000)
  88. await f.runtime.pool.expire();expect(await f.runtime.provider.inspect(spec)).toBeUndefined()
  89. }finally{await manager.close();await f.close()}
  90. },120000)
  91. it.skipIf(!available)('classifies exhausted automatic compression as a session error without retiring the desktop',async()=>{
  92. const f=await fixture(true,false,true)
  93. try{
  94. const turn=await f.open(),result=await f.send(turn.gateway,turn.id,'The fixture provider always rejects its context size.')
  95. expect(result).toMatchObject({status:'failed',code:'context_compaction_failed'})
  96. expect(f.runtime.pool.status(f.meta.ownerKey)[0]?.status).toBe('idle')
  97. expect(f.runtime.provider.stop).not.toHaveBeenCalled()
  98. }finally{await f.close()}
  99. },120000)