workspaceRuntimeBrowserCards.test.ts 7.9 KB

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