workerCompaction.test.ts 5.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869
  1. // @vitest-environment node
  2. import {expect,it,vi} from 'vitest'
  3. import {DatabaseSync} from 'node:sqlite'
  4. import {mkdtemp,writeFile,rm} from 'node:fs/promises'
  5. import {tmpdir} from 'node:os'
  6. import {join} from 'node:path'
  7. import {randomUUID} from 'node:crypto'
  8. import {ComputerGateway,ComputerRuntime} from '../../src/runner/worker/gateway'
  9. async function fixture(){
  10. const home=await mkdtemp(join(tmpdir(),'yaoyao-compaction-')),db=new DatabaseSync(':memory:'),script=join(home,'worker.py')
  11. await writeFile(script,`import json,sys
  12. boot=json.loads(sys.stdin.readline())
  13. def emit(kind, **data):
  14. print(json.dumps({"nonce":boot["nonce"],"type":kind,**data}),flush=True)
  15. payload=json.loads(boot["prompt"])
  16. emit("checkpoint",messages=payload.pop("checkpoint",boot["history"]))
  17. emit("probe",boot=boot)
  18. emit("complete",**payload)
  19. for line in sys.stdin:
  20. if json.loads(line).get("type")=="interrupt":break
  21. `)
  22. const runtime=new ComputerRuntime(db,{protocol:1,runnerId:randomUUID(),serverURL:'http://127.0.0.1',hermesURL:'http://127.0.0.1',token:'fixture',allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',imageId:'sha256:'+'a'.repeat(64),python:'python3',hermesSource:home,hermesHome:home,network:'none'}},home,script)
  23. await runtime.ready;runtime.retainDesktops=true
  24. const id=randomUUID(),meta={environmentId:id,agentId:id,ownerKey:'owner'},gateways:ComputerGateway[]=[]
  25. vi.spyOn(runtime,'resolve').mockResolvedValue({type:'resolved',cwd:'/home/cua/workspace',configuredCwd:'.',model:{provider:'custom',api_mode:'chat_completions',model:'fixture'},contextConfig:{compression:{enabled:true,threshold:0.5}}})
  26. vi.spyOn(runtime.provider,'ensure').mockResolvedValue({id,containerId:'fixture',running:true,workspace:home,isolation:'container'})
  27. const stop=vi.spyOn(runtime.provider,'stop').mockResolvedValue(),remove=vi.spyOn(runtime.provider,'remove').mockResolvedValue()
  28. async function turn(payload:Record<string,unknown>,sessionId?:string){
  29. const gateway=new ComputerGateway(runtime,meta,randomUUID(),async()=>{},async()=>({}));gateways.push(gateway)
  30. const completed=new Promise<any>((resolve,reject)=>{gateway.onEvent=frame=>{if(frame.type==='message.complete')resolve(frame.payload)};gateway.onDisconnect=()=>reject(new Error('unexpected disconnection'))})
  31. await gateway.connect();const opened=await gateway.rpc(sessionId?'session.resume':'session.create',{profile:'default',...(sessionId?{session_id:sessionId}:{})})
  32. await gateway.rpc('prompt.submit',{session_id:opened.session_id,text:JSON.stringify(payload)})
  33. const worker=runtime.workers.get(gateway.workId)!.process,probe=worker.wait('probe',5000)
  34. return {gateway,sessionId:opened.session_id,result:await completed,boot:(await probe).boot,worker}
  35. }
  36. return {db,runtime,meta,stop,remove,turn,async close(){await Promise.all(gateways.map(g=>g.close()));await runtime.controls.close();await runtime.pool.close();db.close();vi.restoreAllMocks();await rm(home,{recursive:true,force:true})}}
  37. }
  38. it.each(['context_compaction_disabled','context_compaction_failed'])('keeps a desktop idle after %s and resumes the saved compacted history',async failureCode=>{
  39. const f=await fixture(),checkpoint=[{role:'system',content:'compacted checkpoint'}],history=[...checkpoint,{role:'user',content:'pending task'}]
  40. try{
  41. const first=await f.turn({completed:false,failureCode,checkpoint,messages:history,text:'engine context failure'})
  42. expect(first.result).toMatchObject({status:'failed',code:failureCode,error:expect.stringContaining('虚拟机已进入空闲')})
  43. expect(f.runtime.pool.status('owner')[0]?.status).toBe('idle');expect(f.stop).not.toHaveBeenCalled();expect(f.remove).not.toHaveBeenCalled()
  44. expect(f.runtime.session(first.sessionId,f.meta,'default')).toMatchObject({history,outcome:'failed'})
  45. first.worker.onEvent({type:'checkpoint',messages:[{role:'user',content:'late frame'}]})
  46. expect(f.runtime.session(first.sessionId,f.meta,'default').history).toEqual(history)
  47. await first.gateway.close();expect(f.stop).not.toHaveBeenCalled()
  48. const nextHistory=[...history,{role:'assistant',content:'continued'}],next=await f.turn({completed:true,messages:nextHistory,text:'continued'},first.sessionId)
  49. expect(next.boot.history).toEqual(history);expect(next.boot.contextConfig.compression.enabled).toBe(true)
  50. expect(f.runtime.session(first.sessionId,f.meta,'default')).toMatchObject({history:nextHistory,outcome:'complete'})
  51. }finally{await f.close()}
  52. })
  53. it.each([
  54. {completed:false,text:'Context overflow and auto-compaction is disabled'},
  55. {completed:false,failureCode:'unknown_failure'},
  56. {completed:false,failureCode:'context_compaction_failed',interrupted:true},
  57. ])('stops for unclassified or interrupted failures: %j',async payload=>{
  58. const f=await fixture()
  59. try{
  60. const checkpoint=[{role:'system',content:'latest compacted history'}],result=await f.turn({...payload,checkpoint})
  61. expect(result.result.status).toBe('failed');expect(result.result).not.toHaveProperty('code')
  62. expect(f.stop).toHaveBeenCalledOnce();expect(f.remove).toHaveBeenCalledOnce()
  63. expect(f.runtime.pool.status('owner')[0]?.status).toBe('free')
  64. expect(f.runtime.session(result.sessionId,f.meta,'default').history).toEqual(checkpoint)
  65. }finally{await f.close()}
  66. })