team-jobs.test.ts 20 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342
  1. // Bot 工作组二期(docs/design/13-bot-teams.md §5、§7、§12.2):能力卡片、组长申请加入机器人、任务与子任务、限额、替身组员、逐步执行
  2. import fs from 'node:fs'
  3. import path from 'node:path'
  4. import pino from 'pino'
  5. import { afterAll, beforeAll, describe, expect, it } from 'vitest'
  6. import type { JobDto, ProviderStatusDto, TaskDto, TeamDetailDto, TeamEvent, TeamMessage } from '@agent-server/shared'
  7. import { createApp, type App } from '../src/app'
  8. import { buildServer } from '../src/http/server'
  9. import type { AgentProvider, RunContext } from '../src/providers/types'
  10. type Call = (tool: string, args: Record<string, unknown>) => Promise<{ text: string; isError: boolean }>
  11. /** 按机器人名称编排的假引擎:脚本可以调用协作工具(与引擎经 MCP Gateway 调用的是同一组工具) */
  12. type Script = (input: string, call: Call, ctx: RunContext) => Promise<string>
  13. class ScriptedProvider implements AgentProvider {
  14. readonly id = 'claude' as const
  15. scripts: Record<string, Script> = {}
  16. inputs: { bot: string; text: string; prompt: string }[] = []
  17. async run(ctx: RunContext) {
  18. ctx.setProviderSessionId(`fake-${ctx.sessionId}`)
  19. const bot = app.sessions.getRow(ctx.sessionId).title.split(' · ').at(-1)!
  20. this.inputs.push({ bot, text: ctx.input.text, prompt: ctx.systemPromptAppend })
  21. const gctx = app.gatewayTokens.get(ctx.gateway.token)!
  22. const call: Call = async (tool, args) => {
  23. const t = gctx.extraTools?.find(x => x.name === tool)
  24. if (!t) return { text: `没有工具 ${tool}`, isError: true }
  25. const r = await t.handler(t.schema.parse(args) as Record<string, unknown>, gctx)
  26. return { text: String(r.content[0]?.text ?? ''), isError: !!r.isError }
  27. }
  28. const reply = await (this.scripts[bot] ?? (async () => `${bot} 收到`))(ctx.input.text, call, ctx)
  29. ctx.emit('message.completed', { messageId: `m${this.inputs.length}`, text: reply })
  30. return { usage: { inputTokens: 10, outputTokens: 5, cacheReadTokens: 0, cacheWriteTokens: 0, costUsd: 0.01 } }
  31. }
  32. async status(): Promise<ProviderStatusDto> {
  33. return { id: 'claude', enabled: true, available: true, loggedIn: true, account: 'test', cliPath: '', version: 'x', models: ['m'], defaultModel: 'm', message: null }
  34. }
  35. }
  36. let app: App
  37. let server: Awaited<ReturnType<typeof buildServer>>
  38. const fake = new ScriptedProvider()
  39. let adminCookie: Record<string, string>
  40. let botKey: string
  41. const ids: Record<string, string> = {}
  42. const bk = () => ({ authorization: `Bearer ${botKey}` })
  43. const human = { id: 'u1', name: '张三' }
  44. async function waitFor<T>(fn: () => T | undefined | false | null, ms = 4000): Promise<T> {
  45. const end = Date.now() + ms
  46. while (Date.now() < end) {
  47. const v = fn()
  48. if (v) return v
  49. await new Promise(r => setTimeout(r, 20))
  50. }
  51. throw new Error('timeout')
  52. }
  53. const messages = (teamId: string) =>
  54. app.teams.events.list(teamId, 0, 5000, ['message.created']).map(e => (e as TeamEvent & { type: 'message.created' }).payload as TeamMessage)
  55. const jobsOf = async (teamId: string, cookies?: Record<string, string>) =>
  56. (await server.inject({ method: 'GET', url: `/bot/v1/teams/${teamId}/jobs`, ...(cookies ? { cookies } : { headers: bk() }) })).json() as { jobs: JobDto[]; tasks: TaskDto[] }
  57. const idle = (teamId: string) => app.teams.toDto(app.teams.getRow(teamId)).bots.every(b => b.status === 'idle')
  58. beforeAll(async () => {
  59. const log = pino({ level: 'silent' })
  60. app = createApp(log, { providers: { claude: fake } })
  61. server = await buildServer(app, { logger: log })
  62. const login = await server.inject({ method: 'POST', url: '/admin/v1/auth/login', payload: { password: 'test-password' } })
  63. const c = login.cookies[0]!
  64. adminCookie = { [c.name]: c.value }
  65. const profileId = app.profiles.getBuiltin().id
  66. for (const [name, kind] of [['组长', 'lead'], ['数据员', 'worker'], ['文档员', 'worker'], ['禁用员', 'worker']] as const) {
  67. const res = await server.inject({
  68. method: 'POST', url: '/admin/v1/bots', cookies: adminCookie,
  69. payload: { name, avatar: 'kl:bot:blue', kind, description: `${name}:负责${name.slice(0, 2)}相关工作`, profileId, enabled: true },
  70. })
  71. expect(res.statusCode).toBe(201)
  72. ids[name] = res.json().id
  73. }
  74. const key = await server.inject({ method: 'POST', url: '/admin/v1/bot-keys', cookies: adminCookie, payload: { name: '平台A', allowedBotIds: [ids['组长'], ids['数据员'], ids['文档员']] } })
  75. botKey = key.json().key
  76. })
  77. afterAll(() => server.close())
  78. describe('能力卡片', () => {
  79. it('GET /bots 返回能力卡片;组长的提示词中带有组员的卡片', async () => {
  80. const cards = (await server.inject({ method: 'GET', url: '/bot/v1/bots', headers: bk() })).json()
  81. const data = cards.find((b: { name: string }) => b.name === '数据员')
  82. expect(data).toMatchObject({ kind: 'worker', permissionMode: expect.any(String) })
  83. expect(Array.isArray(data.skills)).toBe(true)
  84. fake.scripts['组长'] = async () => '你好'
  85. const team = (await server.inject({ method: 'POST', url: '/bot/v1/teams', headers: bk(), payload: { human, leadBotId: ids['组长'], workerBotIds: [ids['数据员']] } })).json() as TeamDetailDto
  86. await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '在吗' } })
  87. await waitFor(() => messages(team.id).some(m => m.sender.kind === 'bot'))
  88. const prompt = fake.inputs.filter(i => i.bot === '组长').at(-1)!.prompt
  89. expect(prompt).toContain('当前组员的能力卡片')
  90. expect(prompt).toContain(`### 数据员(id: ${ids['数据员']})`)
  91. })
  92. it('工作组的限额只能调低;Bot Key 不能设置调试选项', async () => {
  93. const tooHigh = await server.inject({ method: 'POST', url: '/bot/v1/teams', headers: bk(), payload: { human, leadBotId: ids['组长'], limits: { maxTasksPerJob: 999 } } })
  94. expect(tooHigh.statusCode).toBe(400)
  95. const debug = await server.inject({ method: 'POST', url: '/bot/v1/teams', headers: bk(), payload: { human, leadBotId: ids['组长'], debug: { stepMode: true } } })
  96. expect(debug.statusCode).toBe(403)
  97. })
  98. })
  99. describe('任务流程', () => {
  100. it('只有组长:申请加入两个机器人(经审批),拆成有依赖的子任务,组员交付文件,组长汇总交付', async () => {
  101. const team = (await server.inject({ method: 'POST', url: '/bot/v1/teams', headers: bk(), payload: { human, title: '周报', leadBotId: ids['组长'] } })).json() as TeamDetailDto
  102. let leadTurn = 0
  103. fake.scripts['组长'] = async (input, call) => {
  104. leadTurn++
  105. if (leadTurn === 1) {
  106. const catalog = await call('team_bot_catalog', {})
  107. expect(catalog.text).toContain('数据员')
  108. expect(catalog.text).not.toContain('禁用员') // 不在 Bot Key 的范围内
  109. for (const name of ['数据员', '文档员']) {
  110. const r = await call('team_request_bot', { bot_id: ids[name], reason: `需要${name}` })
  111. expect(r.text).toContain('已加入')
  112. }
  113. await call('job_start', { title: '周报', plan: '先统计再写报告' })
  114. await call('task_create', { bot_id: ids['数据员'], title: '统计', instructions: '统计销量,写到 outputs/data.csv' })
  115. const t2 = await call('task_create', { bot_id: ids['文档员'], title: '写报告', instructions: '根据 #1 写报告', depends_on: [1] })
  116. expect(t2.isError).toBe(false)
  117. return '已安排:数据员统计,文档员写报告'
  118. }
  119. if (input.includes('子任务 #2 已完成')) {
  120. const r = await call('job_complete', { summary: '周报已完成', files: ['outputs/report.md'] })
  121. expect(r.isError).toBe(false)
  122. return '交付完毕'
  123. }
  124. return '收到,等待其他子任务'
  125. }
  126. fake.scripts['数据员'] = async (input, call, ctx) => {
  127. if (!input.includes('【子任务')) return '我是数据员'
  128. fs.mkdirSync(path.join(ctx.workspaceDir, 'outputs'), { recursive: true })
  129. fs.writeFileSync(path.join(ctx.workspaceDir, 'outputs/data.csv'), 'a,b\n1,2\n')
  130. await call('task_complete', { result: '销量 100', files: ['outputs/data.csv'] })
  131. return '完成'
  132. }
  133. fake.scripts['文档员'] = async (input, call, ctx) => {
  134. // 依赖的子任务结果随输入一起交给它
  135. expect(input).toContain('【依赖的子任务 #')
  136. expect(input).toContain('销量 100')
  137. fs.writeFileSync(path.join(ctx.workspaceDir, 'outputs/report.md'), '# 周报')
  138. await call('task_complete', { result: '报告已写好', files: ['outputs/report.md'] })
  139. return '完成'
  140. }
  141. await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '帮我做本周周报' } })
  142. // 两次申请都显示审批卡片,由成员批准
  143. for (let i = 0; i < 2; i++) {
  144. const pending = await waitFor(() => app.teams.detail(app.teams.getRow(team.id)).pendingInteractions[0])
  145. expect(pending.request.kind === 'approval' && pending.request.title).toContain('申请让')
  146. const r = await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/interactions/${pending.request.interactionId}`, headers: bk(), payload: { decision: 'allow_once' } })
  147. expect(r.statusCode).toBe(200)
  148. }
  149. const done = await waitFor(() => {
  150. const j = app.teams.jobs.jobs(team.id)[0]
  151. return j?.status === 'completed' && idle(team.id) ? j : null
  152. })
  153. expect(done.summary).toBe('周报已完成')
  154. expect(done.files.map(f => f.path)).toEqual(['outputs/report.md'])
  155. const { jobs, tasks } = await jobsOf(team.id)
  156. expect(jobs[0]).toMatchObject({ status: 'completed', taskCount: 2 })
  157. expect(jobs[0]!.leadRuns).toBeGreaterThanOrEqual(2)
  158. expect(tasks.map(t => [t.no, t.status])).toEqual([[1, 'completed'], [2, 'completed']])
  159. expect(tasks[0]!.files.map(f => f.path)).toEqual(['outputs/data.csv'])
  160. expect(tasks[1]!.dependsOn).toEqual([1])
  161. const msgs = messages(team.id)
  162. expect(msgs.some(m => m.card?.kind === 'job' && m.card.event === 'started')).toBe(true)
  163. expect(msgs.filter(m => m.card?.kind === 'task' && m.card.event === 'assigned')).toHaveLength(2)
  164. // 组员的结果发给组长(唤醒组长)
  165. const result = msgs.find(m => m.card?.kind === 'task' && m.card.event === 'completed' && m.card.no === 1)!
  166. expect(result.targets).toEqual([ids['组长']])
  167. expect(result.files.map(f => f.path)).toEqual(['outputs/data.csv'])
  168. expect(msgs.some(m => m.card?.kind === 'job' && m.card.event === 'completed' && m.files.length === 1)).toBe(true)
  169. expect(msgs.filter(m => m.sender.kind === 'system').some(m => m.text.includes('成员已批准'))).toBe(true)
  170. // 用量按机器人、任务、子任务统计
  171. const usage = (await server.inject({ method: 'GET', url: `/bot/v1/teams/${team.id}/usage`, headers: bk() })).json()
  172. expect(usage.total.runs).toBeGreaterThanOrEqual(4)
  173. expect(usage.jobs[0].runs).toBe(usage.total.runs)
  174. expect(usage.tasks).toHaveLength(2)
  175. expect(usage.bots.map((b: { botId: string }) => b.botId).sort()).toEqual([ids['组长'], ids['数据员'], ids['文档员']].sort())
  176. })
  177. it('同一时间只有一个任务;组员不在子任务中时 task_complete 失败', async () => {
  178. const team = (await server.inject({ method: 'POST', url: '/bot/v1/teams', headers: bk(), payload: { human, leadBotId: ids['组长'], workerBotIds: [ids['数据员']] } })).json() as TeamDetailDto
  179. let second: { text: string; isError: boolean } | undefined
  180. fake.scripts['组长'] = async (_input, call) => {
  181. await call('job_start', { title: 'A', plan: '' })
  182. second = await call('job_start', { title: 'B', plan: '' })
  183. return 'ok'
  184. }
  185. let direct: { text: string; isError: boolean } | undefined
  186. fake.scripts['数据员'] = async (_input, call) => {
  187. direct = await call('task_complete', { result: 'x' })
  188. return '直接回复'
  189. }
  190. await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '开始' } })
  191. await waitFor(() => second)
  192. expect(second).toMatchObject({ isError: true })
  193. expect(second!.text).toContain('已有进行中的任务')
  194. await waitFor(() => idle(team.id) && messages(team.id).some(m => m.sender.kind === 'bot' && m.text === 'ok'))
  195. await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '单项工作', mentions: [ids['数据员']] } })
  196. await waitFor(() => direct)
  197. expect(direct).toMatchObject({ isError: true })
  198. // 成员取消任务
  199. const job = app.teams.jobs.active(team.id)!
  200. expect((await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/jobs/${job.id}/cancel`, headers: bk() })).statusCode).toBe(200)
  201. expect(app.teams.jobs.job(job.id).status).toBe('cancelled')
  202. expect((await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/jobs/${job.id}/cancel`, headers: bk() })).statusCode).toBe(409)
  203. })
  204. })
  205. describe('限额', () => {
  206. it('超出子任务数上限时任务暂停,成员选择继续后放宽一倍、组长被唤醒;也可以取消', async () => {
  207. const team = (await server.inject({
  208. method: 'POST', url: '/bot/v1/teams', headers: bk(),
  209. payload: { human, leadBotId: ids['组长'], workerBotIds: [ids['数据员']], limits: { maxTasksPerJob: 1 } },
  210. })).json() as TeamDetailDto
  211. expect(team.limits.maxTasksPerJob).toBe(1)
  212. const results: boolean[] = []
  213. let turn = 0
  214. fake.scripts['组长'] = async (_input, call) => {
  215. turn++
  216. if (turn === 1) {
  217. await call('job_start', { title: '大任务', plan: '' })
  218. results.push((await call('task_create', { bot_id: ids['数据员'], title: 't1', instructions: 'x' })).isError)
  219. results.push((await call('task_create', { bot_id: ids['数据员'], title: 't2', instructions: 'x' })).isError)
  220. return '分派中'
  221. }
  222. return `第 ${turn} 轮`
  223. }
  224. fake.scripts['数据员'] = async (_input, call) => {
  225. await call('task_complete', { result: 'ok' })
  226. return 'done'
  227. }
  228. await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '开始' } })
  229. const blocked = await waitFor(() => {
  230. const j = app.teams.jobs.active(team.id)
  231. return j?.status === 'blocked' && idle(team.id) ? j : null
  232. })
  233. expect(results).toEqual([false, true])
  234. expect(blocked.blockedBy).toBe('maxTasksPerJob')
  235. expect(messages(team.id).some(m => m.card?.kind === 'limit')).toBe(true)
  236. // 暂停期间组员的结果不会唤醒组长
  237. const leadTurns = turn
  238. await new Promise(r => setTimeout(r, 100))
  239. expect(turn).toBe(leadTurns)
  240. expect((await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/jobs/${blocked.id}/continue`, headers: bk() })).statusCode).toBe(200)
  241. await waitFor(() => turn > leadTurns && idle(team.id))
  242. const job = app.teams.jobs.job(blocked.id)
  243. expect(job).toMatchObject({ status: 'running', blockedBy: null })
  244. expect(job.limits.maxTasksPerJob).toBe(2)
  245. expect((await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/jobs/${blocked.id}/continue`, headers: bk() })).statusCode).toBe(409)
  246. })
  247. it('组长执行轮数达到上限时不再唤醒组长', async () => {
  248. const team = (await server.inject({
  249. method: 'POST', url: '/bot/v1/teams', headers: bk(),
  250. payload: { human, leadBotId: ids['组长'], limits: { maxLeadRunsPerJob: 1 } },
  251. })).json() as TeamDetailDto
  252. let turns = 0
  253. fake.scripts['组长'] = async (_input, call) => {
  254. turns++
  255. if (turns === 1) await call('job_start', { title: 'x', plan: '' })
  256. return 'ok'
  257. }
  258. await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '一' } })
  259. await waitFor(() => turns === 1 && idle(team.id))
  260. await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '二' } })
  261. const job = await waitFor(() => app.teams.jobs.active(team.id)?.status === 'blocked' && app.teams.jobs.active(team.id))
  262. expect(job.blockedBy).toBe('maxLeadRunsPerJob')
  263. expect(turns).toBe(1)
  264. })
  265. })
  266. describe('Bot Playground 的调试功能', () => {
  267. it('全部组员设为替身时,由管理员填写结果完成一项任务', async () => {
  268. const created = await server.inject({
  269. method: 'POST', url: '/bot/v1/teams', cookies: adminCookie,
  270. payload: { human, leadBotId: ids['组长'], workerBotIds: [ids['数据员']], debug: { stubBotIds: [ids['数据员']] } },
  271. })
  272. expect(created.statusCode).toBe(201)
  273. const team = created.json() as TeamDetailDto
  274. fake.scripts['组长'] = async (input, call) => {
  275. if (input.includes('子任务 #')) {
  276. expect(input).not.toContain('替身')
  277. await call('job_complete', { summary: `汇总:${input.includes('算出 42') ? '42' : '?'}` })
  278. return '完成'
  279. }
  280. await call('job_start', { title: '替身测试', plan: '' })
  281. await call('task_create', { bot_id: ids['数据员'], title: '算数', instructions: '算 6×7' })
  282. return '已分派'
  283. }
  284. const before = fake.inputs.filter(i => i.bot === '数据员').length
  285. await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, cookies: adminCookie, payload: { text: '开始' } })
  286. const tasks = await waitFor(() => {
  287. const t = app.teams.jobs.tasks(team.id, { status: ['stub'] })
  288. return t.length ? t : null
  289. })
  290. expect(messages(team.id).some(m => m.card?.kind === 'stub')).toBe(true)
  291. // Bot Key 不能填写替身结果
  292. expect((await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/tasks/${tasks[0]!.id}/stub`, headers: bk(), payload: { outcome: 'completed', result: 'x' } })).statusCode).toBe(404)
  293. const r = await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/tasks/${tasks[0]!.id}/stub`, cookies: adminCookie, payload: { outcome: 'completed', result: '算出 42' } })
  294. expect(r.statusCode).toBe(200)
  295. const job = await waitFor(() => app.teams.jobs.jobs(team.id)[0]?.status === 'completed' && app.teams.jobs.jobs(team.id)[0])
  296. expect(job.summary).toBe('汇总:42')
  297. // 替身的子任务没有真正执行
  298. expect(fake.inputs.filter(i => i.bot === '数据员').length).toBe(before)
  299. })
  300. it('逐步执行:每次唤醒前停下,点"继续"后才执行;关闭后放行', async () => {
  301. const team = (await server.inject({
  302. method: 'POST', url: '/bot/v1/teams', cookies: adminCookie,
  303. payload: { human, workerBotIds: [ids['数据员']], debug: { stepMode: true } },
  304. })).json() as TeamDetailDto
  305. fake.scripts['数据员'] = async input => `做了:${input.split('\n').at(-1)}`
  306. await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, cookies: adminCookie, payload: { text: '第一步' } })
  307. const step = await waitFor(() => app.teams.detail(app.teams.getRow(team.id)).pendingSteps[0])
  308. expect(step.reason).toContain('1 条新消息')
  309. await new Promise(r => setTimeout(r, 50))
  310. expect(messages(team.id).some(m => m.sender.kind === 'bot')).toBe(false)
  311. expect((await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/steps/${step.id}/continue`, cookies: adminCookie })).statusCode).toBe(200)
  312. await waitFor(() => messages(team.id).some(m => m.sender.kind === 'bot'))
  313. expect(app.teams.detail(app.teams.getRow(team.id)).pendingSteps).toEqual([])
  314. await waitFor(() => idle(team.id))
  315. await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, cookies: adminCookie, payload: { text: '第二步' } })
  316. await waitFor(() => app.teams.detail(app.teams.getRow(team.id)).pendingSteps[0])
  317. const patched = await server.inject({ method: 'PATCH', url: `/bot/v1/teams/${team.id}`, cookies: adminCookie, payload: { debug: { stepMode: false } } })
  318. expect(patched.json().debug.stepMode).toBe(false)
  319. await waitFor(() => messages(team.id).filter(m => m.sender.kind === 'bot').length === 2)
  320. })
  321. })