workspaceReconnect.test.ts 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189
  1. // @vitest-environment node
  2. import {afterEach, beforeEach, expect, it, vi} from 'vitest'
  3. import {mkdtempSync, rmSync} from 'node:fs'
  4. import {tmpdir} from 'node:os'
  5. import {join} from 'node:path'
  6. import {randomUUID} from 'node:crypto'
  7. import {WebSocketServer, type WebSocket} from 'ws'
  8. import {WorkspaceStore} from '../../src/server/workspaceStore'
  9. import {WorkspaceRuntime} from '../../src/server/workspaceRuntime'
  10. import {type GatewayTarget, type WorkspaceNodes} from '../../src/server/workspaceGateway'
  11. import {type WorkspacePlugins} from '../../src/server/botPlugins/workspacePlugins'
  12. import {UploadStore} from '../../src/server/uploads'
  13. const owner = 'reconnect-user'
  14. let home: string, store: WorkspaceStore, uploads: UploadStore, runtime: WorkspaceRuntime
  15. let server: WebSocketServer, socket: WebSocket, target: GatewayTarget, nodes: WorkspaceNodes
  16. let running: boolean, authorized: boolean, acknowledgePrompt: boolean, holdCredential: (() => Promise<void>) | undefined
  17. let holdBind: (() => Promise<void>) | undefined
  18. let resumedRuntimeId: string, resumedStoredId: string, historyPaths: string[]
  19. let rpc: Array<{method: string; params: any}>, binds: any[], unbinds: any[], history: any[]
  20. let pluginDispose: ReturnType<typeof vi.fn>, pluginCall: ReturnType<typeof vi.fn>
  21. beforeEach(async () => {
  22. home = mkdtempSync(join(tmpdir(), 'yaoyao-workspace-reconnect-'))
  23. store = new WorkspaceStore(home); uploads = new UploadStore(home)
  24. running = true; authorized = true; acknowledgePrompt = true; holdCredential = undefined; holdBind = undefined
  25. resumedRuntimeId = 'runtime'; resumedStoredId = 'stored'; historyPaths = []
  26. rpc = []; binds = []; unbinds = []; history = []
  27. server = new WebSocketServer({port: 0, host: '127.0.0.1'})
  28. await new Promise<void>(resolve => server.once('listening', resolve))
  29. target = {
  30. url: new URL(`http://127.0.0.1:${(server.address() as {port: number}).port}`), client: {directAgent: undefined},
  31. session: {
  32. webSocketCredential: async () => { await holdCredential?.(); return {name: 'ticket', value: 'fixture'} },
  33. request: async (path: string, options: any) => {
  34. if (path.endsWith('/bind')) { binds.push(options.body); await holdBind?.() }
  35. if (path.endsWith('/unbind')) unbinds.push(options.body)
  36. if (path.startsWith('/api/sessions/')) historyPaths.push(path)
  37. const value = path.endsWith('/capabilities') ? {ready: true, native_tools: true, in_process: true}
  38. : path.startsWith('/api/plugins/yaoyao-bot-bridge/') ? {ok: true, native_tools: true}
  39. : path === '/api/config' ? {terminal: {}} : {messages: history}
  40. return {status: 200, body: Buffer.from(JSON.stringify(value)), headers: new Headers()}
  41. },
  42. },
  43. } as unknown as GatewayTarget
  44. nodes = {requireSource: () => {}, target: () => target} as unknown as WorkspaceNodes
  45. runtime = new WorkspaceRuntime(store, nodes, uploads, () => authorized)
  46. pluginDispose = vi.fn(async () => {})
  47. pluginCall = vi.fn(async () => ({message: '插件仍可使用'}))
  48. runtime.plugins = {selected: () => true, open: async () => ({services: () => [], warnings: () => [],
  49. catalog: () => [{id: 'plugin_fixture', name: 'plugin_fixture', description: '回归测试', inputSchema: {type: 'object'}}],
  50. call: pluginCall, dispose: pluginDispose,
  51. })} as unknown as WorkspacePlugins
  52. server.on('connection', connection => {
  53. socket = connection
  54. connection.send(JSON.stringify({method: 'event', params: {type: 'gateway.ready'}}))
  55. connection.on('message', raw => {
  56. const frame = JSON.parse(String(raw)); rpc.push(frame)
  57. const result = frame.method === 'session.create' ? {session_id: 'runtime', stored_session_id: 'stored', running: false, info: {profile_name: 'default'}}
  58. : frame.method === 'session.resume' ? {session_id: resumedRuntimeId, stored_session_id: resumedStoredId, running, info: {profile_name: 'default'}}
  59. : {status: 'streaming'}
  60. if (frame.method !== 'prompt.submit' || acknowledgePrompt) connection.send(JSON.stringify({id: frame.id, result}))
  61. })
  62. })
  63. })
  64. afterEach(async () => {
  65. runtime.close()
  66. await new Promise(resolve => setTimeout(resolve, 20))
  67. for (const client of server.clients) client.terminate()
  68. await new Promise<void>(resolve => server.close(() => resolve()))
  69. uploads.close(); store.close(); rmSync(home, {recursive: true, force: true})
  70. })
  71. async function start() {
  72. const agent = store.createAgent(owner, {name: '恢复测试', profile: 'default'})
  73. const conversation = store.list<any>(owner, 'conversation').find(c => c.kind === 'direct' && c.memberIds[0] === agent.id)
  74. const run = runtime.send(owner, conversation.id, {requestId: randomUUID(), content: '只执行一次'})
  75. await expect.poll(() => rpc.filter(r => r.method === 'prompt.submit')).toHaveLength(1)
  76. return {run, conversation, agent}
  77. }
  78. async function callback(path: string, body: unknown) {
  79. const lease = binds[0]
  80. return fetch(lease.bridge_url + path, {method: 'POST', headers: {Authorization: `Bearer ${lease.token}`, 'Content-Type': 'application/json'}, body: JSON.stringify(body)})
  81. }
  82. function complete(text = '恢复完成') { socket.send(JSON.stringify({method: 'event', params: {type: 'message.complete', session_id: 'runtime', payload: {text}}})) }
  83. it.each([true, false])('keeps the original tool and plugin grants through running recovery (prompt receipt=%s)', async receipt => {
  84. acknowledgePrompt = receipt
  85. const {run, conversation, agent} = await start()
  86. const first = binds[0]
  87. socket.terminate()
  88. await expect.poll(() => binds.length).toBe(2)
  89. expect(binds[1]).toMatchObject({generation: first.generation, token: first.token, bridge_url: first.bridge_url})
  90. expect(unbinds).toHaveLength(0)
  91. expect(pluginDispose).not.toHaveBeenCalled()
  92. const catalog = await (await callback('/tools/list', {})).json()
  93. const tool = catalog.tools.find((t: any) => t.name === 'workspace_list_agents')
  94. const result = await (await callback('/tools/call', {toolId: tool.id, arguments: {}, callId: 'after-reconnect'})).json()
  95. expect(result.structuredContent.agents).toEqual(expect.arrayContaining([expect.objectContaining({id: agent.id})]))
  96. const plugin = catalog.tools.find((t: any) => t.name === 'plugin_fixture')
  97. await callback('/tools/call', {toolId: plugin.id, arguments: {}, callId: 'plugin-after-reconnect'})
  98. expect(pluginCall).toHaveBeenCalledOnce()
  99. complete()
  100. await expect.poll(() => store.require<any>(owner, 'run', run.id).status).toBe('complete')
  101. expect(store.messages(owner, conversation.id).at(-1)?.content).toBe('恢复完成')
  102. expect(rpc.filter(r => r.method === 'prompt.submit')).toHaveLength(1)
  103. await expect.poll(() => unbinds.length).toBe(1)
  104. expect(pluginDispose).toHaveBeenCalledOnce()
  105. })
  106. it('reconciles completion during a disconnect without renewing or resubmitting the finished turn', async () => {
  107. const {run, conversation} = await start()
  108. history = [{role: 'user', content: rpc.find(r => r.method === 'prompt.submit')!.params.text}, {role: 'assistant', content: '断线期间完成'}]
  109. running = false; socket.terminate()
  110. await expect.poll(() => store.require<any>(owner, 'run', run.id).status).toBe('complete')
  111. expect(store.messages(owner, conversation.id).at(-1)?.content).toBe('断线期间完成')
  112. expect(binds).toHaveLength(1)
  113. expect(rpc.filter(r => r.method === 'prompt.submit')).toHaveLength(1)
  114. })
  115. it('continues reconnecting if the replacement socket closes while the original grant is renewing', async () => {
  116. const {run} = await start()
  117. let release!: () => void
  118. holdBind = () => new Promise<void>(resolve => { release = resolve })
  119. socket.terminate()
  120. await expect.poll(() => binds.length).toBe(2)
  121. socket.terminate()
  122. await expect.poll(() => server.clients.size).toBe(0)
  123. holdBind = undefined; release()
  124. await expect.poll(() => binds.length, {timeout: 3000}).toBe(3)
  125. expect(new Set(binds.map(b => b.generation)).size).toBe(1)
  126. complete()
  127. await expect.poll(() => store.require<any>(owner, 'run', run.id).status).toBe('complete')
  128. expect(rpc.filter(r => r.method === 'prompt.submit')).toHaveLength(1)
  129. })
  130. it('follows resumed runtime and compacted stored IDs for tool renewal and subsequent history reconciliation', async () => {
  131. const {run} = await start()
  132. resumedRuntimeId = 'runtime-after-resume'; resumedStoredId = 'compacted-tip'
  133. socket.terminate()
  134. await expect.poll(() => binds.length).toBe(2)
  135. expect(binds[1]).toMatchObject({session_id: resumedRuntimeId, stored_session_id: resumedStoredId, generation: binds[0].generation})
  136. expect(store.list<any>(owner, 'binding')[0]).toMatchObject({runtimeId: resumedRuntimeId, storedId: resumedStoredId, aliases: ['stored', 'compacted-tip']})
  137. running = false
  138. history = [{role: 'user', content: rpc.find(r => r.method === 'prompt.submit')!.params.text}, {role: 'assistant', content: '压缩后完成'}]
  139. socket.terminate()
  140. await expect.poll(() => store.require<any>(owner, 'run', run.id).status).toBe('complete')
  141. expect(historyPaths).toEqual(['/api/sessions/compacted-tip/messages'])
  142. expect(rpc.filter(r => r.method === 'prompt.submit')).toHaveLength(1)
  143. })
  144. it.each(['stop', 'revoke', 'close'] as const)('revokes callbacks and cannot revive a detached turn after %s', async action => {
  145. const {run} = await start()
  146. socket.terminate()
  147. await expect.poll(() => store.require<any>(owner, 'run', run.id).error).toContain('正在恢复原执行')
  148. if (action === 'stop') await runtime.stop(owner, run.id)
  149. else if (action === 'revoke') authorized = false
  150. else runtime.close()
  151. await expect.poll(() => unbinds.length).toBe(1)
  152. await expect(callback('/tools/list', {})).rejects.toThrow()
  153. expect(pluginDispose).toHaveBeenCalledOnce()
  154. expect(binds).toHaveLength(1)
  155. expect(rpc.filter(r => r.method === 'prompt.submit')).toHaveLength(1)
  156. if (action === 'stop') expect(store.require<any>(owner, 'run', run.id).status).toBe('interrupted')
  157. })
  158. it('does not create a late WebSocket when the service closes during reconnect credentials', async () => {
  159. const {run} = await start()
  160. let release!: () => void
  161. const credential = new Promise<void>(resolve => { release = resolve })
  162. let waiting = false
  163. holdCredential = () => { waiting = true; return credential }
  164. socket.terminate()
  165. await expect.poll(() => waiting).toBe(true)
  166. runtime.close(); release()
  167. await expect.poll(() => unbinds.length).toBe(1)
  168. expect(server.clients.size).toBe(0)
  169. expect(rpc.filter(r => r.method === 'session.resume')).toHaveLength(0)
  170. expect(store.require<any>(owner, 'run', run.id).status).toBe('uncertain')
  171. })
  172. it('keeps a running turn uncertain after process restart when its original tools cannot be restored', async () => {
  173. const {run} = await start()
  174. runtime.close()
  175. runtime = new WorkspaceRuntime(store, nodes, uploads, () => authorized)
  176. runtime.start()
  177. await expect.poll(() => store.require<any>(owner, 'run', run.id).error).toContain('本轮工具授权已丢失')
  178. expect(store.require<any>(owner, 'run', run.id).status).toBe('uncertain')
  179. expect(binds).toHaveLength(1)
  180. expect(rpc.filter(r => r.method === 'prompt.submit')).toHaveLength(1)
  181. })