workspaceRuntimeBrowserCards.test.ts 8.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152
  1. // @vitest-environment node
  2. import {afterEach,expect,it,vi} from 'vitest'
  3. import {randomUUID} from 'node:crypto'
  4. import {mkdtempSync,rmSync} from 'node:fs'
  5. import {tmpdir} from 'node:os'
  6. import {join} from 'node:path'
  7. import {WebSocketServer} from 'ws'
  8. import {WorkspaceStore} from '../../src/server/workspaceStore'
  9. import {WorkspaceRuntime} from '../../src/server/workspaceRuntime'
  10. import type {WorkspaceNodes} from '../../src/server/workspaceGateway'
  11. import {UploadStore} from '../../src/server/uploads'
  12. import {saveHostTools} from '../../src/server/hostToolSettings'
  13. import type {ManagedBrowsers,BrowserTurnOptions} from '../../src/server/managedBrowsers'
  14. import type {ManagedBrowserCard} from '../../src/shared/managedBrowser'
  15. import type {WorkspaceConversation,WorkspaceMessage,WorkspaceRun} from '../../src/shared/workspace'
  16. const owner='browser-card-user'
  17. afterEach(()=>vi.restoreAllMocks())
  18. async function fixture(group=false){
  19. const home=mkdtempSync(join(tmpdir(),'yaoyao-runtime-browser-card-'))
  20. const store=new WorkspaceStore(home),uploads=new UploadStore(home)
  21. saveHostTools(home,{managedBrowser:true,vm:false,serverComputer:false,scriptMachine:false})
  22. const server=new WebSocketServer({port:0,host:'127.0.0.1'})
  23. await new Promise<void>(resolve=>server.once('listening',resolve))
  24. const bindings=new Map<string,{bridge_url:string;token:string}>()
  25. const browserClosures:{retain:boolean|undefined;aborted:boolean}[]=[]
  26. let toolError:unknown,options:BrowserTurnOptions|undefined,card:ManagedBrowserCard|undefined
  27. const target={
  28. url:new URL(`http://127.0.0.1:${(server.address() as {port:number}).port}`),
  29. client:{directAgent:undefined},
  30. session:{
  31. webSocketCredential:async()=>({name:'ticket',value:'test'}),
  32. request:async(path:string,input?:{body?:any})=>{
  33. if(path.endsWith('/bind'))bindings.set(input?.body.session_id,input?.body)
  34. return {status:200,headers:new Headers(),body:Buffer.from(JSON.stringify(path.startsWith('/api/plugins/yaoyao-bot-bridge/')
  35. ? {ok:true,version:1,ready:true,in_process:true,native_tools:true}
  36. : path==='/api/config'?{terminal:{}}:{messages:[]}))}
  37. },
  38. },
  39. }
  40. const nodes={requireSource:()=>{},target:()=>target} as unknown as WorkspaceNodes
  41. const runtime=new WorkspaceRuntime(store,nodes,uploads)
  42. runtime.managedBrowsers={available:()=>true,openTurn:(_owner,agent,input)=>{
  43. options=input
  44. card={id:input.workId,agentId:agent.id,agentName:agent.name,status:'active',title:'浏览器页面',url:'https://example.com/page',updatedAt:Date.now()}
  45. return {close:async(value?:{retain?:boolean})=>{browserClosures.push({retain:value?.retain,aborted:input.signal.aborted})},call:async()=>{input.authorize();input.onCard?.(card!);return {ok:true}}}
  46. }} as ManagedBrowsers
  47. server.on('connection',socket=>{
  48. socket.send(JSON.stringify({method:'event',params:{type:'gateway.ready'}}))
  49. socket.on('message',data=>{
  50. const frame=JSON.parse(String(data)),respond=(result:unknown)=>socket.send(JSON.stringify({id:frame.id,result}))
  51. if(frame.method==='session.create'||frame.method==='session.resume')respond({session_id:randomUUID(),stored_session_id:randomUUID(),running:false,info:{profile_name:frame.params.profile}})
  52. else if(frame.method==='session.cwd.set')respond({cwd:frame.params.cwd})
  53. else if(frame.method==='session.usage')respond({context_used:10,context_max:1000})
  54. else if(frame.method==='prompt.submit'){
  55. respond({status:'streaming'})
  56. void(async()=>{
  57. try{
  58. const binding=bindings.get(frame.params.session_id)!
  59. const toolRequest=async(path:string,body:unknown)=>{
  60. const response=await fetch(binding.bridge_url+path,{method:'POST',headers:{'Content-Type':'application/json',Authorization:`Bearer ${binding.token}`},body:JSON.stringify(body)})
  61. expect(response.status).toBe(200);return response.json() as Promise<any>
  62. }
  63. const catalog=await toolRequest('/tools/list',{}),tool=catalog.tools.find((entry:any)=>entry.name==='managed_browser_open')
  64. expect(tool).toBeDefined()
  65. const result=await toolRequest('/tools/call',{toolId:tool.id,arguments:{url:'https://example.com/page'},callId:'open-browser'})
  66. expect(result).toMatchObject({structuredContent:{ok:true}})
  67. }catch(error){toolError=error}
  68. socket.send(JSON.stringify({method:'event',params:{type:'message.complete',session_id:frame.params.session_id,payload:{text:'',status:'complete'}}}))
  69. })()
  70. }else respond({ok:true,status:'interrupted'})
  71. })
  72. })
  73. const bot=store.createAgent(owner,{name:'浏览器 Bot',profile:'default'})
  74. const conversation=group
  75. ? store.createGroup(owner,{name:'浏览器群',memberIds:[bot.id,store.createAgent(owner,{name:'群成员',profile:'default'}).id],administratorId:bot.id})
  76. : store.list<WorkspaceConversation>(owner,'conversation').find(value=>value.kind==='direct'&&value.memberIds[0]===bot.id)!
  77. return {store,runtime,conversation,browserClosures,
  78. get browserOptions(){return options},
  79. async run(){
  80. const run=runtime.send(owner,conversation.id,{requestId:randomUUID(),content:'打开网页'})
  81. await vi.waitFor(()=>expect(['complete','failed']).toContain(store.require<WorkspaceRun>(owner,'run',run.id).status))
  82. expect(toolError).toBeUndefined()
  83. const settled=store.require<WorkspaceRun>(owner,'run',run.id)
  84. expect(settled.status,settled.error).toBe('complete')
  85. const message=store.list<WorkspaceMessage>(owner,'message').find(value=>value.browserCard?.id===card?.id)!
  86. expect(message).toBeDefined()
  87. return message
  88. },
  89. update(){options!.onCard?.({...card!,status:'closed',message:'本轮浏览器操作已结束',updatedAt:Date.now()})},
  90. async close(){
  91. runtime.close();await new Promise(resolve=>setTimeout(resolve,10))
  92. for(const client of server.clients)client.terminate()
  93. await new Promise<void>(resolve=>server.close(()=>resolve()))
  94. uploads.close();store.close();rmSync(home,{recursive:true,force:true})
  95. },
  96. }
  97. }
  98. it('retains a successful browser turn before aborting its tools and revokes the finished task authorization',async()=>{
  99. const f=await fixture()
  100. try{
  101. await f.run()
  102. expect(f.browserClosures).toEqual([{retain:true,aborted:false}])
  103. expect(f.browserOptions!.signal.aborted).toBe(true)
  104. expect(()=>f.browserOptions!.authorize()).toThrow('本轮运行已结束')
  105. }finally{await f.close()}
  106. })
  107. it('persists a card-only native browser tool reply and accepts later human-session updates',async()=>{
  108. const f=await fixture()
  109. try{
  110. const message=await f.run()
  111. expect(message).toMatchObject({content:'',status:'complete',visible:true,browserCard:{status:'active',title:'浏览器页面'}})
  112. expect(message.attachments).toEqual([]);expect(message.tools).toEqual([])
  113. f.update()
  114. expect(f.store.require<WorkspaceMessage>(owner,'message',message.id)).toMatchObject({content:'',status:'complete',browserCard:{status:'closed'}})
  115. f.store.saveMessage(owner,{...f.store.require<WorkspaceMessage>(owner,'message',message.id),content:'后来保存的消息内容'})
  116. f.update()
  117. expect(f.store.require<WorkspaceMessage>(owner,'message',message.id).content).toBe('后来保存的消息内容')
  118. }finally{await f.close()}
  119. })
  120. it('does not recreate a deleted task or its messages from a later browser card update',async()=>{
  121. const f=await fixture(true)
  122. try{
  123. const message=await f.run(),taskId=message.conversationTaskId!
  124. expect(taskId).toBeTruthy()
  125. f.store.deleteTask(owner,f.conversation.id,taskId)
  126. const replacementTasks=f.store.tasks(owner,f.conversation.id)
  127. const save=vi.spyOn(f.store,'saveMessage')
  128. expect(()=>f.update()).not.toThrow()
  129. expect(save).not.toHaveBeenCalled()
  130. expect(f.store.get(owner,'message',message.id)).toBeUndefined()
  131. expect(f.store.get(owner,'conversation-task',taskId)).toBeUndefined()
  132. expect(f.store.tasks(owner,f.conversation.id)).toEqual(replacementTasks)
  133. }finally{await f.close()}
  134. })
  135. it('isolates a card persistence failure from browser cleanup and allows a later update',async()=>{
  136. const f=await fixture()
  137. try{
  138. const message=await f.run()
  139. const save=vi.spyOn(f.store,'saveMessage').mockImplementationOnce(()=>{throw new Error('card storage unavailable')})
  140. expect(()=>f.update()).not.toThrow()
  141. expect(save).toHaveBeenCalledOnce()
  142. expect(f.store.require<WorkspaceMessage>(owner,'message',message.id).browserCard?.status).toBe('active')
  143. f.update()
  144. expect(f.store.require<WorkspaceMessage>(owner,'message',message.id).browserCard?.status).toBe('closed')
  145. }finally{await f.close()}
  146. })