workspaceMemoryBridge.live.test.ts 9.2 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105
  1. // @vitest-environment node
  2. import {it,expect} from 'vitest'
  3. import {createServer} from 'node:http'
  4. import {spawn} from 'node:child_process'
  5. import {mkdtemp,mkdir,writeFile,readFile,cp,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 {DatabaseSync} from 'node:sqlite'
  10. import {WorkspaceStore} from '../../src/server/workspaceStore'
  11. import {WorkspaceRuntime} from '../../src/server/workspaceRuntime'
  12. import {UploadStore} from '../../src/server/uploads'
  13. import {UpstreamClient} from '../../src/server/upstream'
  14. import {UpstreamServiceSession} from '../../src/server/localAuth'
  15. import {ComputerRuntime,ComputerGateway} from '../../src/runner/worker/gateway'
  16. import type {WorkspaceNodes,GatewayTarget} from '../../src/server/workspaceGateway'
  17. const available=['YAOYAO_HERMES_PYTHON','YAOYAO_HERMES_SOURCE','YAOYAO_COMPUTER_IMAGE'].every(key=>!!process.env[key])
  18. it.skipIf(!available)('runs the production WorkspaceRuntime create, bind, prompt and resume chain with isolated memory and context files on real Hermes',async()=>{
  19. const home=await mkdtemp(join(tmpdir(),'yaoyao-memory-bridge-live-')),webHome=join(home,'web'),python=process.env.YAOYAO_HERMES_PYTHON!,source=process.env.YAOYAO_HERMES_SOURCE!
  20. const profileMemory='PROFILE_PRIVATE_MEMORY_MUST_NOT_REACH_BOT',botMemory='BOT_SCOPED_MEMORY_MUST_REACH_MODEL'
  21. await mkdir(join(home,'memories'));await mkdir(join(home,'plugins'))
  22. await writeFile(join(home,'memories','MEMORY.md'),profileMemory)
  23. await writeFile(join(home,'memories','USER.md'),'PROFILE_PRIVATE_USER_MUST_NOT_REACH_BOT')
  24. const contextFiles={'SOUL.md':'PROFILE_SOUL_MUST_NOT_REACH_BOT','AGENTS.md':'HOST_AGENTS_MUST_NOT_REACH_BOT','CLAUDE.md':'HOST_CLAUDE_MUST_NOT_REACH_BOT','.cursorrules':'HOST_CURSOR_RULES_MUST_NOT_REACH_BOT'}
  25. for(const [file,marker] of Object.entries(contextFiles))await writeFile(join(home,file),marker)
  26. await writeFile(join(home,'.env'),'')
  27. await cp(resolve('integrations/hermes-bots-bridge'),join(home,'plugins','yaoyao-bot-bridge'),{recursive:true})
  28. const modelCalls:any[]=[]
  29. const model=createServer(async(req,res)=>{
  30. res.setHeader('Content-Type','application/json')
  31. if(req.method==='GET'){res.end(JSON.stringify({data:[{id:'memory-fixture-model',context_length:64000}]}));return}
  32. let raw='';for await(const chunk of req)raw+=chunk
  33. const input=JSON.parse(raw);modelCalls.push(input)
  34. const message={role:'assistant',content:'BOT_MEMORY_BRIDGE_OK'}
  35. if(input.stream){res.setHeader('Content-Type','text/event-stream');res.end('data: '+JSON.stringify({id:randomUUID(),object:'chat.completion.chunk',model:'memory-fixture-model',choices:[{index:0,delta:message,finish_reason:'stop'}]})+'\n\ndata: [DONE]\n\n')}
  36. else res.end(JSON.stringify({id:randomUUID(),object:'chat.completion',model:'memory-fixture-model',choices:[{index:0,message,finish_reason:'stop'}],usage:{prompt_tokens:20,completion_tokens:5,total_tokens:25}}))
  37. })
  38. await new Promise<void>(done=>model.listen(0,'127.0.0.1',done))
  39. const modelURL=`http://127.0.0.1:${(model.address() as {port:number}).port}/v1`
  40. const config={model:{provider:'custom',default:'memory-fixture-model',base_url:modelURL,api_key:'fixture',context_length:64000},terminal:{cwd:home},memory:{memory_enabled:true,user_profile_enabled:true},plugins:{enabled:['yaoyao-bot-bridge']},tools:{tool_search:{enabled:'off'}},dashboard:{turn_isolation:false},compression:{enabled:false}}
  41. await writeFile(join(home,'config.yaml'),JSON.stringify(config))
  42. const reservation=createServer();await new Promise<void>(done=>reservation.listen(0,'127.0.0.1',done))
  43. const port=(reservation.address() as {port:number}).port;await new Promise<void>(done=>reservation.close(()=>done()))
  44. const url=new URL(`http://127.0.0.1:${port}`)
  45. let logs=''
  46. const child=spawn(python,['-m','hermes_cli.main','serve','--host','127.0.0.1','--port',String(port),'--skip-build','--isolated'],{cwd:home,env:{PATH:process.env.PATH,HOME:process.env.HOME,HERMES_HOME:home,PYTHONPATH:source,HERMES_YOLO_MODE:'1',HERMES_DASHBOARD_SESSION_TOKEN:'fixture-memory-bridge-local-token',NO_PROXY:'127.0.0.1,localhost'},stdio:['ignore','pipe','pipe']})
  47. child.stdout.on('data',c=>{logs=(logs+String(c)).slice(-8000)});child.stderr.on('data',c=>{logs=(logs+String(c)).slice(-8000)})
  48. const client=new UpstreamClient(url),session=new UpstreamServiceSession(client,()=>undefined),target={url,client,session}
  49. const store=new WorkspaceStore(webHome),uploads=new UploadStore(webHome),db=new DatabaseSync(':memory:')
  50. const computer=new ComputerRuntime(db,{protocol:1,runnerId:randomUUID(),serverURL:url.href,hermesURL:url.href,token:'fixture',allowedProfiles:['default'],artifactRoots:[],computers:{runtime:'docker',imageId:process.env.YAOYAO_COMPUTER_IMAGE!,python:'/must-not-run',hermesSource:'/must-not-read',hermesHome:'/must-not-read',network:'none'}},webHome,resolve('src/runner/worker/hermes_worker.py'),undefined,target)
  51. const owner='fixture-owner',agent=store.createAgent(owner,{name:'production-pipeline-fixture',profile:'default',execution:'computer',computer:'vm',vmExecution:'worker'}),gateways:ComputerGateway[]=[],grants:boolean[]=[],rpc:any[]=[]
  52. let current:ComputerGateway
  53. const bridged:GatewayTarget={...target,runner:{id:computer.config.runtime,computer:true,hermesComputer:true,
  54. open:async(onEvent,onDisconnect,scope)=>{
  55. current=new ComputerGateway(computer,{agentId:agent.id,environmentId:agent.id,ownerKey:owner,hermesRuntime:true},scope!.workId,async()=>scope!.authorize(),async()=>({}),target)
  56. gateways.push(current);current.onEvent=onEvent;current.onDisconnect=onDisconnect;await current.connect()
  57. const gateway=current
  58. return {rpc:(method,params)=>{rpc.push({method,params});return gateway.rpc(method,params)},close:()=>{void gateway.close()} }
  59. },
  60. lease:async(input)=>{
  61. const gateway=current;grants.push(input.workspaceMemory===true)
  62. gateway.installTeamLease(input.workId,input.catalog(),async(name,args,id)=>input.call(name,args,id),input.workspaceMemory===true)
  63. return {bind:async()=>{},dispose:async()=>gateway.removeTeamLease(input.workId)}
  64. },
  65. }}
  66. const nodes={requireSource(){},target:()=>bridged,targetForAgent:()=>bridged} as unknown as WorkspaceNodes
  67. const runtime=new WorkspaceRuntime(store,nodes,uploads)
  68. try{
  69. await expect.poll(async()=>{if(child.exitCode!==null)throw new Error(logs);try{return(await fetch(new URL('/api/status',url))).status}catch{return 0}},{timeout:45000}).toBe(200)
  70. runtime.knowledge.writeMemory(owner,{requestId:randomUUID(),scope:'agent',agentId:agent.id,content:botMemory,tier:'profile'})
  71. const conversation=store.list<any>(owner,'conversation').find(c=>c.memberIds[0]===agent.id)!
  72. for(const content of ['验证首次发言','验证恢复会话']){
  73. const run=runtime.send(owner,conversation.id,{requestId:randomUUID(),content})
  74. await expect.poll(()=>store.require<any>(owner,'run',run.id).status,{timeout:45000}).toMatch(/^(complete|failed|interrupted)$/)
  75. const saved=store.require<any>(owner,'run',run.id);expect(saved.status,saved.error+'\n'+logs).toBe('complete')
  76. expect(store.list<any>(owner,'message').find(m=>m.runId===run.id&&m.role==='assistant')?.content).toBe('BOT_MEMORY_BRIDGE_OK')
  77. }
  78. expect(grants).toEqual([true,true])
  79. expect(rpc.filter(r=>r.method==='session.create')).toHaveLength(1)
  80. expect(rpc.filter(r=>r.method==='session.resume')).toHaveLength(1)
  81. for(const row of rpc.filter(r=>r.method==='session.create')){
  82. expect(row.params).not.toHaveProperty('skip_memory');expect(row.params).not.toHaveProperty('workspace_memory')
  83. expect(row.params).toMatchObject({hidden:true,room_plumbing:true,source:'yaoyao_workspace',disabled_toolsets:['terminal','file']})
  84. }
  85. const sent=JSON.stringify(modelCalls)
  86. expect(sent).toContain(botMemory);expect(sent).not.toContain(profileMemory);expect(sent).not.toContain('PROFILE_PRIVATE_USER_MUST_NOT_REACH_BOT')
  87. for(const [file,marker] of Object.entries(contextFiles)){
  88. expect(sent).not.toContain(marker)
  89. expect(await readFile(join(home,file),'utf8')).toBe(marker)
  90. }
  91. const resumed=modelCalls.find(c=>JSON.stringify(c.messages).includes('验证恢复会话'))
  92. expect(JSON.stringify(resumed?.messages)).toContain('验证首次发言')
  93. expect(JSON.stringify(resumed?.messages)).toContain('BOT_MEMORY_BRIDGE_OK')
  94. expect(sent).toContain('production-pipeline-fixture')
  95. expect(modelCalls.flatMap(c=>c.tools??[]).some(t=>t.function?.name==='memory')).toBe(false)
  96. expect(await readFile(join(home,'memories','MEMORY.md'),'utf8')).toBe(profileMemory)
  97. expect(JSON.parse(await readFile(join(home,'config.yaml'),'utf8')).memory).toEqual(config.memory)
  98. }finally{
  99. runtime.close();await Promise.all(gateways.map(g=>g.close().catch(()=>{})));await computer.controls.close();await computer.pool.close()
  100. db.close();uploads.close();store.close();client.close()
  101. child.kill('SIGTERM');await new Promise<void>(done=>{if(child.exitCode!==null){done();return}const timer=setTimeout(()=>child.kill('SIGKILL'),5000);child.once('exit',()=>{clearTimeout(timer);done()})})
  102. await new Promise<void>(done=>{model.close(()=>done());model.closeAllConnections()});await rm(home,{recursive:true,force:true})
  103. }
  104. },120000)