background-calls.test.ts 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255
  1. // 后台调用(docs/design/15-background-calls.md):外部工具的长调用转到后台,结束后把结果发给发起方
  2. import http from 'node:http'
  3. import type { AddressInfo } from 'node:net'
  4. import pino from 'pino'
  5. import { afterAll, beforeAll, describe, expect, it } from 'vitest'
  6. import { z } from 'zod'
  7. import { Client } from '@modelcontextprotocol/sdk/client/index.js'
  8. import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'
  9. import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js'
  10. import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js'
  11. import type { BackgroundCallDto, ProviderStatusDto, TeamDetailDto, TeamEvent, TeamMessage } from '@agent-server/shared'
  12. import { createApp, type App } from '../src/app'
  13. import { buildServer } from '../src/http/server'
  14. import type { AgentProvider, RunContext } from '../src/providers/types'
  15. type ToolCall = (tool: string, args?: Record<string, unknown>) => Promise<string>
  16. type Script = (input: string, tool: ToolCall, collab: ToolCall) => Promise<string>
  17. /** 外部服务的慢工具:等测试放行后才返回 */
  18. const gates = new Map<string, () => void>()
  19. const waitGate = (key: string) => new Promise<void>(r => gates.set(key, r))
  20. const release = async (key: string) => {
  21. const r = await waitFor(() => gates.get(key))
  22. gates.delete(key)
  23. r()
  24. }
  25. class ScriptedProvider implements AgentProvider {
  26. readonly id = 'claude' as const
  27. scripts: Record<string, Script> = {}
  28. inputs: { bot: string; text: string; sessionId: string }[] = []
  29. hang = false
  30. async run(ctx: RunContext) {
  31. ctx.setProviderSessionId(`fake-${ctx.sessionId}`)
  32. const title = app.sessions.getRow(ctx.sessionId).title
  33. const bot = title.includes(' · ') ? title.split(' · ').at(-1)! : 'session'
  34. this.inputs.push({ bot, text: ctx.input.text, sessionId: ctx.sessionId })
  35. if (this.hang) await new Promise((_, reject) => ctx.signal.addEventListener('abort', () => reject(new Error('aborted'))))
  36. // 与真实引擎一样经 HTTP 连接网关(测试服务的端口与配置不同,替换主机和端口)
  37. const url = new URL(ctx.gateway.url)
  38. const client = new Client({ name: 'fake', version: '1' })
  39. await client.connect(new StreamableHTTPClientTransport(new URL(`${url.pathname}${url.search}`, base), { requestInit: { headers: { Authorization: `Bearer ${ctx.gateway.token}` } } }))
  40. const tool: ToolCall = async (name, args = {}) => {
  41. const r = await client.callTool({ name: `slowsvc__${name}`, arguments: args }, undefined, { timeout: 60_000 })
  42. return (r.content as { text: string }[])[0]!.text
  43. }
  44. const gctx = app.gatewayTokens.get(ctx.gateway.token)!
  45. const collab: ToolCall = async (name, args = {}) => {
  46. const t = gctx.extraTools!.find(x => x.name === name)!
  47. const r = await t.handler(t.schema.parse(args) as Record<string, unknown>, gctx)
  48. return String(r.content[0]?.text ?? '')
  49. }
  50. try {
  51. const reply = await (this.scripts[bot] ?? (async () => 'ok'))(ctx.input.text, tool, collab)
  52. ctx.emit('message.completed', { messageId: `m${this.inputs.length}`, text: reply })
  53. } finally {
  54. await client.close()
  55. }
  56. return { usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0, costUsd: 0.001 } }
  57. }
  58. async status(): Promise<ProviderStatusDto> {
  59. return { id: 'claude', enabled: true, available: true, loggedIn: true, account: 'test', cliPath: '', version: 'x', models: ['m'], defaultModel: 'm', message: null }
  60. }
  61. }
  62. let app: App
  63. let server: Awaited<ReturnType<typeof buildServer>>
  64. let base: string
  65. let upstream: http.Server
  66. let apiKey: string
  67. let botKey: string
  68. let serverId: string
  69. let adminCookie: Record<string, string>
  70. const fake = new ScriptedProvider()
  71. const auth = (key = apiKey) => ({ authorization: `Bearer ${key}` })
  72. async function waitFor<T>(fn: () => T | undefined | false | null, ms = 6000): Promise<T> {
  73. const end = Date.now() + ms
  74. while (Date.now() < end) {
  75. const v = fn()
  76. if (v) return v
  77. await new Promise(r => setTimeout(r, 20))
  78. }
  79. throw new Error('timeout')
  80. }
  81. function startUpstream(): Promise<string> {
  82. upstream = http.createServer(async (req, res) => {
  83. const mcp = new McpServer({ name: 'slow', version: '1.0.0' })
  84. mcp.registerTool('export', { description: '导出(很慢)', inputSchema: { key: z.string() }, annotations: { readOnlyHint: true } }, async ({ key }) => {
  85. await waitGate(key)
  86. return { content: [{ type: 'text', text: key === 'big' ? `结果:${'数'.repeat(9000)}` : `导出完成:${key}` }] }
  87. })
  88. mcp.registerTool('quick', { description: '很快', inputSchema: {}, annotations: { readOnlyHint: true } }, async () => ({ content: [{ type: 'text', text: '马上好了' }] }))
  89. const t = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined })
  90. res.on('close', () => { void t.close(); void mcp.close() })
  91. await mcp.connect(t)
  92. let body = ''
  93. req.on('data', c => (body += c))
  94. req.on('end', () => void t.handleRequest(req, res, body ? JSON.parse(body) : undefined))
  95. })
  96. return new Promise(r => upstream.listen(0, '127.0.0.1', () => r(`http://127.0.0.1:${(upstream.address() as AddressInfo).port}/mcp`)))
  97. }
  98. beforeAll(async () => {
  99. const log = pino({ level: 'silent' })
  100. app = createApp(log, { providers: { claude: fake } })
  101. server = await buildServer(app, { logger: log })
  102. base = await server.listen({ port: 0, host: '127.0.0.1' })
  103. const url = await startUpstream()
  104. serverId = app.mcpServers.create({
  105. name: 'slowsvc', title: '慢服务', description: '', transport: 'http', enabled: true, url, headers: [], command: '', args: [], env: [],
  106. allowNetwork: false, readPaths: [], writePaths: [],
  107. }).id
  108. await app.mcpServers.refresh(serverId)
  109. const builtin = app.profiles.getBuiltin()
  110. const { id: _id, version: _v, updatedAt: _u, builtin: _b, builtinKey: _k, ...input } = builtin
  111. const profile = app.profiles.create({ ...input, name: '带慢服务', isDefault: false, mcpServerIds: [serverId] })
  112. apiKey = app.apiKeys.create('平台A', { allowedProfileIds: [profile.id], defaultProfileId: profile.id }).key
  113. for (const [name, kind] of [['组长', 'lead'], ['导出员', 'worker']] as const) app.bots.create({ name, avatar: 'kl:bot:blue', kind, description: name, profileId: profile.id, enabled: true })
  114. botKey = app.botKeys.create('平台A', app.bots.list().map(b => b.id)).key
  115. app.settings.update({ backgroundCalls: { autoAfterSec: 1 } })
  116. const login = await server.inject({ method: 'POST', url: '/admin/v1/auth/login', payload: { password: 'test-password' } })
  117. adminCookie = { [login.cookies[0]!.name]: login.cookies[0]!.value }
  118. })
  119. afterAll(async () => {
  120. app.backgroundCalls.stop()
  121. await app.mcpUpstream.closeAll()
  122. await server.close()
  123. upstream.close()
  124. })
  125. const newSession = async () => (await server.inject({ method: 'POST', url: '/v1/sessions', headers: auth(), payload: {} })).json().id as string
  126. const calls = async (sid: string) => (await server.inject({ method: 'GET', url: `/v1/sessions/${sid}/background-calls`, headers: auth() })).json() as BackgroundCallDto[]
  127. const send = (sid: string, text: string) => server.inject({ method: 'POST', url: `/v1/sessions/${sid}/messages`, headers: auth(), payload: { text } })
  128. const idle = (sid: string) => !app.runs.activeForSession(sid)
  129. describe('会话中的后台调用', () => {
  130. it('很快返回的调用照常返回;超过时限的转为后台,完成后把结果作为新消息发到会话', async () => {
  131. const sid = await newSession()
  132. let reply = ''
  133. fake.scripts.session = async (input, tool) => {
  134. if (input.startsWith('【后台调用')) return '收到结果'
  135. reply = await tool(input.includes('快') ? 'quick' : 'export', input.includes('快') ? {} : { key: 'q3' })
  136. return reply
  137. }
  138. await send(sid, '快一点')
  139. await waitFor(() => idle(sid) && reply)
  140. expect(reply).toBe('马上好了')
  141. expect(await calls(sid)).toHaveLength(0)
  142. reply = ''
  143. await send(sid, '导出 Q3')
  144. await waitFor(() => idle(sid) && reply)
  145. expect(reply).toMatch(/已转为后台执行(后台调用 bg_/)
  146. const [running] = await calls(sid)
  147. expect(running).toMatchObject({ status: 'running', serverTitle: '慢服务', tool: 'export', args: { key: 'q3' } })
  148. const before = fake.inputs.length
  149. await release('q3')
  150. const delivered = await waitFor(() => fake.inputs.slice(before).find(i => i.sessionId === sid))
  151. expect(delivered.text).toMatch(/^【后台调用完成】慢服务 · export(bg_.+,耗时 \d+ 分钟)/)
  152. expect(delivered.text).toContain('结果:\n导出完成:q3')
  153. await waitFor(() => idle(sid))
  154. expect((await calls(sid))[0]).toMatchObject({ status: 'completed', result: '导出完成:q3', delivered: true })
  155. })
  156. it('会话正在执行时,等这一轮结束再通知;较长的结果保存为文件', async () => {
  157. const sid = await newSession()
  158. fake.scripts.session = async (input, tool) => (input.startsWith('【后台调用') || input === '别的事' ? 'ok' : tool('export', { key: 'big' }))
  159. await send(sid, '导出大文件')
  160. await waitFor(() => idle(sid) && app.backgroundCalls.list({ sessionId: sid })[0])
  161. fake.hang = true
  162. await send(sid, '别的事')
  163. await waitFor(() => !idle(sid))
  164. await release('big')
  165. await waitFor(() => app.backgroundCalls.list({ sessionId: sid })[0]?.status === 'completed')
  166. const before = fake.inputs.length
  167. await new Promise(r => setTimeout(r, 100))
  168. expect(fake.inputs.length).toBe(before)
  169. expect(app.backgroundCalls.list({ sessionId: sid })[0]!.delivered).toBe(false)
  170. fake.hang = false
  171. app.runs.interrupt(app.runs.activeForSession(sid)!.runId, sid)
  172. const delivered = await waitFor(() => fake.inputs.slice(before).find(i => i.text.startsWith('【后台调用完成】')))
  173. expect(delivered.text).toMatch(/完整结果已保存到 \.agent\/mcp\/background-export-bg_.+\.txt/)
  174. await waitFor(() => idle(sid))
  175. })
  176. it('标记为总是后台执行的工具立即转到后台;平台可以取消,AI 收到取消的消息', async () => {
  177. const r = await server.inject({ method: 'PUT', url: `/admin/v1/mcp/servers/${serverId}/tools/export/background`, cookies: adminCookie, payload: { background: true } })
  178. expect(r.json().tools.find((t: { name: string }) => t.name === 'export').background).toBe(true)
  179. const sid = await newSession()
  180. let reply = ''
  181. const t0 = Date.now()
  182. fake.scripts.session = async (input, tool) => (input.startsWith('【后台调用') ? 'ok' : (reply = await tool('export', { key: 'never' })))
  183. await send(sid, '导出')
  184. await waitFor(() => idle(sid) && reply)
  185. expect(reply).toContain('已转为后台执行')
  186. expect(Date.now() - t0).toBeLessThan(900)
  187. const [call] = await calls(sid)
  188. // 其他平台不能取消
  189. const other = app.apiKeys.create('平台B', { allowedProfileIds: [app.profiles.getBuiltin().id], defaultProfileId: app.profiles.getBuiltin().id }).key
  190. expect((await server.inject({ method: 'POST', url: `/v1/background-calls/${call!.id}/cancel`, headers: auth(other) })).statusCode).toBe(404)
  191. const before = fake.inputs.length
  192. const cancelled = await server.inject({ method: 'POST', url: `/v1/background-calls/${call!.id}/cancel`, headers: auth() })
  193. expect(cancelled.json()).toMatchObject({ status: 'cancelled', error: '成员取消了这次调用' })
  194. const delivered = await waitFor(() => fake.inputs.slice(before).find(i => i.sessionId === sid))
  195. expect(delivered.text).toMatch(/^【后台调用已取消】/)
  196. expect((await server.inject({ method: 'POST', url: `/v1/background-calls/${call!.id}/cancel`, headers: auth() })).statusCode).toBe(409)
  197. await waitFor(() => idle(sid))
  198. gates.delete('never')
  199. await server.inject({ method: 'PUT', url: `/admin/v1/mcp/servers/${serverId}/tools/export/background`, cookies: adminCookie, payload: { background: false } })
  200. })
  201. })
  202. describe('工作组中的后台调用', () => {
  203. it('组员在子任务中发起的调用转到后台时,子任务等待;结果到达后组员继续并汇报给组长', async () => {
  204. const [lead, worker] = [app.bots.list().find(b => b.kind === 'lead')!, app.bots.list().find(b => b.kind === 'worker')!]
  205. const team = (await server.inject({ method: 'POST', url: '/bot/v1/teams', headers: auth(botKey), payload: { human: { id: 'u', name: '张三' }, leadBotId: lead.id, workerBotIds: [worker.id] } })).json() as TeamDetailDto
  206. fake.scripts['组长'] = async (input, _tool, collab) => {
  207. if (input.includes('子任务 #') && input.includes('已完成')) {
  208. await collab('job_complete', { summary: '导出好了' })
  209. return '完成'
  210. }
  211. if (input.includes('导出')) {
  212. await collab('job_start', { title: '导出', plan: '' })
  213. await collab('task_create', { bot_id: worker.id, title: '导出数据', instructions: '调用 export' })
  214. }
  215. return '安排了'
  216. }
  217. fake.scripts['导出员'] = async (input, tool, collab) => {
  218. if (input.includes('继续完成这个子任务')) {
  219. expect(input).toContain('【后台调用完成】慢服务 · export')
  220. await collab('task_complete', { result: input.includes('导出完成:team') ? '已导出' : '?' })
  221. return '汇报了'
  222. }
  223. return tool('export', { key: 'team' })
  224. }
  225. await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: auth(botKey), payload: { text: '帮我导出' } })
  226. const task = await waitFor(() => app.teams.jobs.tasks(team.id, { status: ['background'] })[0])
  227. expect(task.title).toBe('导出数据')
  228. const list = (await server.inject({ method: 'GET', url: `/bot/v1/teams/${team.id}/background-calls`, headers: auth(botKey) })).json() as BackgroundCallDto[]
  229. expect(list[0]).toMatchObject({ status: 'running', teamId: team.id, botId: worker.id, taskId: task.id })
  230. // 组长没有被唤醒去处理"已转为后台"的回复
  231. const messages = () => app.teams.events.list(team.id, 0, 5000, ['message.created']).map(e => (e as TeamEvent & { type: 'message.created' }).payload as TeamMessage)
  232. expect(messages().some(m => m.card?.kind === 'task' && m.card.event === 'completed')).toBe(false)
  233. await release('team')
  234. const job = await waitFor(() => app.teams.jobs.jobs(team.id)[0]?.status === 'completed' && app.teams.jobs.jobs(team.id)[0])
  235. expect(job.summary).toBe('导出好了')
  236. expect(app.teams.jobs.tasks(team.id)[0]).toMatchObject({ status: 'completed', result: '已导出', followUp: null })
  237. const overview = (await server.inject({ method: 'GET', url: '/admin/v1/background-calls', cookies: adminCookie })).json()
  238. expect(overview.find((x: { id: string }) => x.id === list[0]!.id)).toMatchObject({ status: 'completed', targetTitle: team.title })
  239. })
  240. })