team-jobs.test.ts 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547
  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; sessionId: string }[] = []
  17. /** 报告的上下文大小(判断是否开始新会话) */
  18. context = 0
  19. async run(ctx: RunContext) {
  20. ctx.setProviderSessionId(`fake-${ctx.sessionId}`)
  21. const bot = app.sessions.getRow(ctx.sessionId).title.split(' · ').at(-1)!
  22. this.inputs.push({ bot, text: ctx.input.text, prompt: ctx.systemPromptAppend, sessionId: ctx.sessionId })
  23. const gctx = app.gatewayTokens.get(ctx.gateway.token)!
  24. const call: Call = async (tool, args) => {
  25. const t = gctx.extraTools?.find(x => x.name === tool)
  26. if (!t) return { text: `没有工具 ${tool}`, isError: true }
  27. const r = await t.handler(t.schema.parse(args) as Record<string, unknown>, gctx)
  28. return { text: String(r.content[0]?.text ?? ''), isError: !!r.isError }
  29. }
  30. const reply = await (this.scripts[bot] ?? (async () => `${bot} 收到`))(ctx.input.text, call, ctx)
  31. ctx.emit('message.completed', { messageId: `m${this.inputs.length}`, text: reply })
  32. return { usage: { inputTokens: 10, outputTokens: 5, cacheReadTokens: 0, cacheWriteTokens: 0, costUsd: 0.01, ...(this.context ? { contextTokens: this.context } : {}) } }
  33. }
  34. async status(): Promise<ProviderStatusDto> {
  35. return { id: 'claude', enabled: true, available: true, loggedIn: true, account: 'test', cliPath: '', version: 'x', models: ['m'], defaultModel: 'm', message: null }
  36. }
  37. }
  38. let app: App
  39. let server: Awaited<ReturnType<typeof buildServer>>
  40. const fake = new ScriptedProvider()
  41. let adminCookie: Record<string, string>
  42. let botKey: string
  43. const ids: Record<string, string> = {}
  44. const bk = () => ({ authorization: `Bearer ${botKey}` })
  45. const human = { id: 'u1', name: '张三' }
  46. async function waitFor<T>(fn: () => T | undefined | false | null, ms = 4000): Promise<T> {
  47. const end = Date.now() + ms
  48. while (Date.now() < end) {
  49. const v = fn()
  50. if (v) return v
  51. await new Promise(r => setTimeout(r, 20))
  52. }
  53. throw new Error('timeout')
  54. }
  55. const messages = (teamId: string) =>
  56. app.teams.events.list(teamId, 0, 5000, ['message.created']).map(e => (e as TeamEvent & { type: 'message.created' }).payload as TeamMessage)
  57. const jobsOf = async (teamId: string, cookies?: Record<string, string>) =>
  58. (await server.inject({ method: 'GET', url: `/bot/v1/teams/${teamId}/jobs`, ...(cookies ? { cookies } : { headers: bk() }) })).json() as { jobs: JobDto[]; tasks: TaskDto[] }
  59. const idle = (teamId: string) => app.teams.toDto(app.teams.getRow(teamId)).bots.every(b => b.status === 'idle')
  60. beforeAll(async () => {
  61. const log = pino({ level: 'silent' })
  62. app = createApp(log, { providers: { claude: fake } })
  63. server = await buildServer(app, { logger: log })
  64. const login = await server.inject({ method: 'POST', url: '/admin/v1/auth/login', payload: { password: 'test-password' } })
  65. const c = login.cookies[0]!
  66. adminCookie = { [c.name]: c.value }
  67. const profileId = app.profiles.getBuiltin().id
  68. for (const [name, kind] of [['组长', 'lead'], ['数据员', 'worker'], ['文档员', 'worker'], ['禁用员', 'worker']] as const) {
  69. const res = await server.inject({
  70. method: 'POST', url: '/admin/v1/bots', cookies: adminCookie,
  71. payload: { name, avatar: 'kl:bot:blue', kind, description: `${name}:负责${name.slice(0, 2)}相关工作`, profileId, enabled: true },
  72. })
  73. expect(res.statusCode).toBe(201)
  74. ids[name] = res.json().id
  75. }
  76. const key = await server.inject({ method: 'POST', url: '/admin/v1/bot-keys', cookies: adminCookie, payload: { name: '平台A', allowedBotIds: [ids['组长'], ids['数据员'], ids['文档员']] } })
  77. botKey = key.json().key
  78. })
  79. afterAll(() => server.close())
  80. describe('能力卡片', () => {
  81. it('GET /bots 返回能力卡片;组长的提示词中带有组员的卡片', async () => {
  82. const cards = (await server.inject({ method: 'GET', url: '/bot/v1/bots', headers: bk() })).json()
  83. const data = cards.find((b: { name: string }) => b.name === '数据员')
  84. expect(data).toMatchObject({ kind: 'worker', permissionMode: expect.any(String) })
  85. expect(Array.isArray(data.skills)).toBe(true)
  86. fake.scripts['组长'] = async () => '你好'
  87. const team = (await server.inject({ method: 'POST', url: '/bot/v1/teams', headers: bk(), payload: { human, leadBotId: ids['组长'], workerBotIds: [ids['数据员']] } })).json() as TeamDetailDto
  88. await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '在吗' } })
  89. await waitFor(() => messages(team.id).some(m => m.sender.kind === 'bot'))
  90. const prompt = fake.inputs.filter(i => i.bot === '组长').at(-1)!.prompt
  91. expect(prompt).toContain('当前组员的能力卡片')
  92. expect(prompt).toContain(`### 数据员(id: ${ids['数据员']})`)
  93. })
  94. it('工作组的限额只能调低;Bot Key 不能设置调试选项', async () => {
  95. const tooHigh = await server.inject({ method: 'POST', url: '/bot/v1/teams', headers: bk(), payload: { human, leadBotId: ids['组长'], limits: { maxTasksPerJob: 999 } } })
  96. expect(tooHigh.statusCode).toBe(400)
  97. const debug = await server.inject({ method: 'POST', url: '/bot/v1/teams', headers: bk(), payload: { human, leadBotId: ids['组长'], debug: { stepMode: true } } })
  98. expect(debug.statusCode).toBe(403)
  99. })
  100. })
  101. describe('内置的组长 Profile', () => {
  102. it('每个引擎一个只读的组长 Profile,协作规范在系统提示词中,带 team-lead 技能;不影响通用助手', async () => {
  103. const lead = app.profiles.findBuiltinLead('claude')!
  104. expect(lead).toMatchObject({ builtin: true, builtinKey: 'lead', permissionMode: 'readonly', skills: ['custom-skills:team-lead'] })
  105. expect(lead.systemPrompt).toContain('# 组长的协作规范')
  106. expect(app.profiles.findBuiltinLead('codex')).toMatchObject({ provider: 'codex', builtinKey: 'lead' })
  107. expect(app.profiles.getBuiltin('claude').builtinKey).toBeNull()
  108. const skill = app.customSkills.list().find(x => x.name === 'team-lead')!
  109. expect(skill).toMatchObject({ includeInAll: false, publishedVersion: 2 })
  110. expect((await server.inject({ method: 'DELETE', url: `/admin/v1/profiles/${lead.id}`, cookies: adminCookie })).statusCode).toBe(400)
  111. })
  112. })
  113. describe('任务流程', () => {
  114. it('只有组长:申请加入两个机器人(经审批),拆成有依赖的子任务,组员交付文件,组长汇总交付', async () => {
  115. const team = (await server.inject({ method: 'POST', url: '/bot/v1/teams', headers: bk(), payload: { human, title: '周报', leadBotId: ids['组长'] } })).json() as TeamDetailDto
  116. let leadTurn = 0
  117. fake.scripts['组长'] = async (input, call) => {
  118. leadTurn++
  119. if (leadTurn === 1) {
  120. const catalog = await call('team_bot_catalog', {})
  121. expect(catalog.text).toContain('数据员')
  122. expect(catalog.text).not.toContain('禁用员') // 不在 Bot Key 的范围内
  123. for (const name of ['数据员', '文档员']) {
  124. const r = await call('team_request_bot', { bot_id: ids[name], reason: `需要${name}` })
  125. expect(r.text).toContain('已加入')
  126. }
  127. await call('job_start', { title: '周报', plan: '先统计再写报告' })
  128. await call('task_create', { bot_id: ids['数据员'], title: '统计', instructions: '统计销量,写到 outputs/data.csv' })
  129. const t2 = await call('task_create', { bot_id: ids['文档员'], title: '写报告', instructions: '根据 #1 写报告', depends_on: [1] })
  130. expect(t2.isError).toBe(false)
  131. return '已安排:数据员统计,文档员写报告'
  132. }
  133. if (input.includes('子任务 #2 已完成')) {
  134. const r = await call('job_complete', { summary: '周报已完成', files: ['outputs/report.md'], notes: '成员要求周报用中文' })
  135. expect(r.isError).toBe(false)
  136. return '交付完毕'
  137. }
  138. return '收到,等待其他子任务'
  139. }
  140. fake.scripts['数据员'] = async (input, call, ctx) => {
  141. if (!input.includes('【子任务')) return '我是数据员'
  142. fs.mkdirSync(path.join(ctx.workspaceDir, 'outputs'), { recursive: true })
  143. fs.writeFileSync(path.join(ctx.workspaceDir, 'outputs/data.csv'), 'a,b\n1,2\n')
  144. await call('task_complete', { result: '销量 100', files: ['outputs/data.csv'] })
  145. return '完成'
  146. }
  147. fake.scripts['文档员'] = async (input, call, ctx) => {
  148. // 依赖的子任务结果随输入一起交给它
  149. expect(input).toContain('【依赖的子任务 #')
  150. expect(input).toContain('销量 100')
  151. fs.writeFileSync(path.join(ctx.workspaceDir, 'outputs/report.md'), '# 周报')
  152. await call('task_complete', { result: '报告已写好', files: ['outputs/report.md'] })
  153. return '完成'
  154. }
  155. await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '帮我做本周周报' } })
  156. // 两次申请都显示审批卡片,由成员批准
  157. for (let i = 0; i < 2; i++) {
  158. const pending = await waitFor(() => app.teams.detail(app.teams.getRow(team.id)).pendingInteractions[0])
  159. expect(pending.request.kind === 'approval' && pending.request.title).toContain('申请让')
  160. const r = await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/interactions/${pending.request.interactionId}`, headers: bk(), payload: { decision: 'allow_once' } })
  161. expect(r.statusCode).toBe(200)
  162. }
  163. const done = await waitFor(() => {
  164. const j = app.teams.jobs.jobs(team.id)[0]
  165. return j?.status === 'completed' && idle(team.id) ? j : null
  166. })
  167. expect(done.summary).toBe('周报已完成')
  168. expect(done.files.map(f => f.path)).toEqual(['outputs/report.md'])
  169. const { jobs, tasks } = await jobsOf(team.id)
  170. expect(jobs[0]).toMatchObject({ status: 'completed', taskCount: 2 })
  171. expect(jobs[0]!.leadRuns).toBeGreaterThanOrEqual(2)
  172. expect(tasks.map(t => [t.no, t.status])).toEqual([[1, 'completed'], [2, 'completed']])
  173. expect(tasks[0]!.files.map(f => f.path)).toEqual(['outputs/data.csv'])
  174. expect(tasks[1]!.dependsOn).toEqual([1])
  175. const msgs = messages(team.id)
  176. expect(msgs.some(m => m.card?.kind === 'job' && m.card.event === 'started')).toBe(true)
  177. expect(msgs.filter(m => m.card?.kind === 'task' && m.card.event === 'assigned')).toHaveLength(2)
  178. // 组员的结果发给组长(唤醒组长)
  179. const result = msgs.find(m => m.card?.kind === 'task' && m.card.event === 'completed' && m.card.no === 1)!
  180. expect(result.targets).toEqual([ids['组长']])
  181. expect(result.files.map(f => f.path)).toEqual(['outputs/data.csv'])
  182. expect(msgs.some(m => m.card?.kind === 'job' && m.card.event === 'completed' && m.files.length === 1)).toBe(true)
  183. expect(msgs.filter(m => m.sender.kind === 'system').some(m => m.text.includes('成员已批准'))).toBe(true)
  184. // 用量按机器人、任务、子任务统计
  185. const usage = (await server.inject({ method: 'GET', url: `/bot/v1/teams/${team.id}/usage`, headers: bk() })).json()
  186. expect(usage.total.runs).toBeGreaterThanOrEqual(4)
  187. expect(usage.jobs[0].runs).toBe(usage.total.runs)
  188. expect(usage.tasks).toHaveLength(2)
  189. expect(usage.bots.map((b: { botId: string }) => b.botId).sort()).toEqual([ids['组长'], ids['数据员'], ids['文档员']].sort())
  190. // 任务纪要:计划、子任务、交付和组长的 notes 保存到共享目录,以后组长的提示词中列出
  191. const memo = fs.readFileSync(path.join(app.teams.getRow(team.id).workspaceDir, '.team/jobs/job-1.md'), 'utf8')
  192. expect(memo).toContain('# 任务 #1:周报')
  193. expect(memo).toContain('先统计再写报告')
  194. expect(memo).toContain('### #1 数据员 · 统计 · 已完成')
  195. expect(memo).toContain('销量 100')
  196. expect(memo).toContain('交付文件:outputs/report.md')
  197. expect(memo).toContain('成员要求周报用中文')
  198. await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '谢谢' } })
  199. await waitFor(() => idle(team.id) && fake.inputs.at(-1)?.text.includes('谢谢'))
  200. expect(fake.inputs.at(-1)!.prompt).toContain('- .team/jobs/job-1.md:#1「周报」已完成')
  201. })
  202. it('同一时间只有一个任务;组员不在子任务中时 task_complete 失败', async () => {
  203. const team = (await server.inject({ method: 'POST', url: '/bot/v1/teams', headers: bk(), payload: { human, leadBotId: ids['组长'], workerBotIds: [ids['数据员']] } })).json() as TeamDetailDto
  204. let second: { text: string; isError: boolean } | undefined
  205. fake.scripts['组长'] = async (_input, call) => {
  206. await call('job_start', { title: 'A', plan: '' })
  207. second = await call('job_start', { title: 'B', plan: '' })
  208. return 'ok'
  209. }
  210. let direct: { text: string; isError: boolean } | undefined
  211. fake.scripts['数据员'] = async (_input, call) => {
  212. direct = await call('task_complete', { result: 'x' })
  213. return '直接回复'
  214. }
  215. await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '开始' } })
  216. await waitFor(() => second)
  217. expect(second).toMatchObject({ isError: true })
  218. expect(second!.text).toContain('已有进行中的任务')
  219. await waitFor(() => idle(team.id) && messages(team.id).some(m => m.sender.kind === 'bot' && m.text === 'ok'))
  220. await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '单项工作', mentions: [ids['数据员']] } })
  221. await waitFor(() => direct)
  222. expect(direct).toMatchObject({ isError: true })
  223. // 成员取消任务
  224. const job = app.teams.jobs.active(team.id)!
  225. expect((await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/jobs/${job.id}/cancel`, headers: bk() })).statusCode).toBe(200)
  226. expect(app.teams.jobs.job(job.id).status).toBe('cancelled')
  227. expect((await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/jobs/${job.id}/cancel`, headers: bk() })).statusCode).toBe(409)
  228. })
  229. })
  230. describe('子任务的依赖', () => {
  231. it('依赖失败时后续子任务无法开始;task_retry 重试后继续;还有未结束的子任务时不能 job_complete', async () => {
  232. const team = (await server.inject({
  233. method: 'POST', url: '/bot/v1/teams', cookies: adminCookie,
  234. payload: { human, leadBotId: ids['组长'], workerBotIds: [ids['数据员'], ids['文档员']], debug: { stubBotIds: [ids['数据员'], ids['文档员']] } },
  235. })).json() as TeamDetailDto
  236. const checks: Record<string, { text: string; isError: boolean }> = {}
  237. let progress = ''
  238. fake.scripts['组长'] = async (input, call) => {
  239. if (input.includes('子任务 #2 已完成')) {
  240. await call('job_complete', { summary: '完成' })
  241. return '交付'
  242. }
  243. if (input.includes('子任务 #1 失败')) {
  244. progress = input
  245. checks.dep = await call('task_create', { bot_id: ids['文档员'], title: 'x', instructions: 'x', depends_on: [1] })
  246. checks.complete = await call('job_complete', { summary: '提前交付' })
  247. checks.retry = await call('task_retry', { task_no: 1, instructions: '换个数据源' })
  248. checks.retryAgain = await call('task_retry', { task_no: 1 })
  249. return '重试 #1'
  250. }
  251. if (input.includes('子任务 #')) return '等待'
  252. await call('job_start', { title: '依赖', plan: '' })
  253. await call('task_create', { bot_id: ids['数据员'], title: '取数', instructions: '取数据' })
  254. await call('task_create', { bot_id: ids['文档员'], title: '写报告', instructions: '写', depends_on: [1] })
  255. return '已分派'
  256. }
  257. const stub = async (no: number, outcome: 'completed' | 'failed', result: string) => {
  258. const task = await waitFor(() => app.teams.jobs.tasks(team.id, { status: ['stub'] }).find(t => t.no === no))
  259. const r = await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/tasks/${task.id}/stub`, cookies: adminCookie, payload: { outcome, result } })
  260. expect(r.statusCode).toBe(200)
  261. return task
  262. }
  263. await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, cookies: adminCookie, payload: { text: '开始' } })
  264. await stub(1, 'failed', '数据缺失')
  265. await waitFor(() => checks.retryAgain)
  266. // 交给组长的进度中标出无法开始的子任务
  267. expect(progress).toContain('#2 文档员 · 写报告 · 等待依赖 · 依赖的 #1 没有完成,无法开始')
  268. expect(checks.dep).toMatchObject({ isError: true })
  269. expect(checks.dep!.text).toContain('task_retry')
  270. expect(checks.complete).toMatchObject({ isError: true })
  271. expect(checks.complete!.text).toContain('#2(等待依赖)')
  272. expect(checks.retry).toMatchObject({ isError: false })
  273. expect(checks.retryAgain).toMatchObject({ isError: true })
  274. const retried = await stub(1, 'completed', '取到了')
  275. expect(retried.instructions).toBe('换个数据源')
  276. await stub(2, 'completed', '报告好了')
  277. const job = await waitFor(() => app.teams.jobs.jobs(team.id)[0]?.status === 'completed' && app.teams.jobs.jobs(team.id)[0])
  278. expect(job.summary).toBe('完成')
  279. expect(app.teams.jobs.tasks(team.id, { jobId: job.id }).map(t => [t.no, t.status])).toEqual([[1, 'completed'], [2, 'completed']])
  280. // 编号不变:没有新建子任务
  281. expect((await jobsOf(team.id, adminCookie)).jobs[0]!.taskCount).toBe(2)
  282. })
  283. })
  284. describe('限额', () => {
  285. it('超出子任务数上限时任务暂停,成员选择继续后放宽一倍、组长被唤醒;也可以取消', async () => {
  286. const team = (await server.inject({
  287. method: 'POST', url: '/bot/v1/teams', headers: bk(),
  288. payload: { human, leadBotId: ids['组长'], workerBotIds: [ids['数据员']], limits: { maxTasksPerJob: 1 } },
  289. })).json() as TeamDetailDto
  290. expect(team.limits.maxTasksPerJob).toBe(1)
  291. const results: boolean[] = []
  292. let turn = 0
  293. fake.scripts['组长'] = async (_input, call) => {
  294. turn++
  295. if (turn === 1) {
  296. await call('job_start', { title: '大任务', plan: '' })
  297. results.push((await call('task_create', { bot_id: ids['数据员'], title: 't1', instructions: 'x' })).isError)
  298. results.push((await call('task_create', { bot_id: ids['数据员'], title: 't2', instructions: 'x' })).isError)
  299. return '分派中'
  300. }
  301. return `第 ${turn} 轮`
  302. }
  303. fake.scripts['数据员'] = async (_input, call) => {
  304. await call('task_complete', { result: 'ok' })
  305. return 'done'
  306. }
  307. await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '开始' } })
  308. const blocked = await waitFor(() => {
  309. const j = app.teams.jobs.active(team.id)
  310. return j?.status === 'blocked' && idle(team.id) ? j : null
  311. })
  312. expect(results).toEqual([false, true])
  313. expect(blocked.blockedBy).toBe('maxTasksPerJob')
  314. expect(messages(team.id).some(m => m.card?.kind === 'limit')).toBe(true)
  315. // 暂停期间组员的结果不会唤醒组长
  316. const leadTurns = turn
  317. await new Promise(r => setTimeout(r, 100))
  318. expect(turn).toBe(leadTurns)
  319. expect((await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/jobs/${blocked.id}/continue`, headers: bk() })).statusCode).toBe(200)
  320. await waitFor(() => turn > leadTurns && idle(team.id))
  321. const job = app.teams.jobs.job(blocked.id)
  322. expect(job).toMatchObject({ status: 'running', blockedBy: null })
  323. expect(job.limits.maxTasksPerJob).toBe(2)
  324. expect((await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/jobs/${blocked.id}/continue`, headers: bk() })).statusCode).toBe(409)
  325. })
  326. it('组长执行轮数达到上限时不再唤醒组长', async () => {
  327. const team = (await server.inject({
  328. method: 'POST', url: '/bot/v1/teams', headers: bk(),
  329. payload: { human, leadBotId: ids['组长'], limits: { maxLeadRunsPerJob: 1 } },
  330. })).json() as TeamDetailDto
  331. let turns = 0
  332. fake.scripts['组长'] = async (_input, call) => {
  333. turns++
  334. if (turns === 1) await call('job_start', { title: 'x', plan: '' })
  335. return 'ok'
  336. }
  337. await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '一' } })
  338. await waitFor(() => turns === 1 && idle(team.id))
  339. await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '二' } })
  340. const job = await waitFor(() => app.teams.jobs.active(team.id)?.status === 'blocked' && app.teams.jobs.active(team.id))
  341. expect(job.blockedBy).toBe('maxLeadRunsPerJob')
  342. expect(turns).toBe(1)
  343. })
  344. })
  345. describe('Bot Playground 的调试功能', () => {
  346. it('全部组员设为替身时,由管理员填写结果完成一项任务', async () => {
  347. const created = await server.inject({
  348. method: 'POST', url: '/bot/v1/teams', cookies: adminCookie,
  349. payload: { human, leadBotId: ids['组长'], workerBotIds: [ids['数据员']], debug: { stubBotIds: [ids['数据员']] } },
  350. })
  351. expect(created.statusCode).toBe(201)
  352. const team = created.json() as TeamDetailDto
  353. fake.scripts['组长'] = async (input, call) => {
  354. if (input.includes('子任务 #')) {
  355. expect(input).not.toContain('替身')
  356. await call('job_complete', { summary: `汇总:${input.includes('算出 42') ? '42' : '?'}` })
  357. return '完成'
  358. }
  359. await call('job_start', { title: '算数测试', plan: '' })
  360. await call('task_create', { bot_id: ids['数据员'], title: '算数', instructions: '算 6×7' })
  361. return '已分派'
  362. }
  363. const before = fake.inputs.filter(i => i.bot === '数据员').length
  364. await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, cookies: adminCookie, payload: { text: '开始' } })
  365. const tasks = await waitFor(() => {
  366. const t = app.teams.jobs.tasks(team.id, { status: ['stub'] })
  367. return t.length ? t : null
  368. })
  369. expect(messages(team.id).some(m => m.card?.kind === 'stub')).toBe(true)
  370. // Bot Key 不能填写替身结果
  371. 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)
  372. 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' } })
  373. expect(r.statusCode).toBe(200)
  374. const job = await waitFor(() => app.teams.jobs.jobs(team.id)[0]?.status === 'completed' && app.teams.jobs.jobs(team.id)[0])
  375. expect(job.summary).toBe('汇总:42')
  376. // 替身的子任务没有真正执行
  377. expect(fake.inputs.filter(i => i.bot === '数据员').length).toBe(before)
  378. })
  379. it('逐步执行:每次唤醒前停下,点"继续"后才执行;关闭后放行', async () => {
  380. const team = (await server.inject({
  381. method: 'POST', url: '/bot/v1/teams', cookies: adminCookie,
  382. payload: { human, workerBotIds: [ids['数据员']], debug: { stepMode: true } },
  383. })).json() as TeamDetailDto
  384. fake.scripts['数据员'] = async input => `做了:${input.split('\n').at(-1)}`
  385. await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, cookies: adminCookie, payload: { text: '第一步' } })
  386. const step = await waitFor(() => app.teams.detail(app.teams.getRow(team.id)).pendingSteps[0])
  387. expect(step.reason).toContain('1 条新消息')
  388. await new Promise(r => setTimeout(r, 50))
  389. expect(messages(team.id).some(m => m.sender.kind === 'bot')).toBe(false)
  390. expect((await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/steps/${step.id}/continue`, cookies: adminCookie })).statusCode).toBe(200)
  391. await waitFor(() => messages(team.id).some(m => m.sender.kind === 'bot'))
  392. expect(app.teams.detail(app.teams.getRow(team.id)).pendingSteps).toEqual([])
  393. await waitFor(() => idle(team.id))
  394. await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, cookies: adminCookie, payload: { text: '第二步' } })
  395. await waitFor(() => app.teams.detail(app.teams.getRow(team.id)).pendingSteps[0])
  396. const patched = await server.inject({ method: 'PATCH', url: `/bot/v1/teams/${team.id}`, cookies: adminCookie, payload: { debug: { stepMode: false } } })
  397. expect(patched.json().debug.stepMode).toBe(false)
  398. await waitFor(() => messages(team.id).filter(m => m.sender.kind === 'bot').length === 2)
  399. })
  400. })
  401. describe('文件归属', () => {
  402. it('两个机器人同时工作时,脚本生成的文件不算给任何一方;写文件工具写的文件准确归属', async () => {
  403. const team = (await server.inject({ method: 'POST', url: '/bot/v1/teams', headers: bk(), payload: { human, workerBotIds: [ids['数据员'], ids['文档员']] } })).json() as TeamDetailDto
  404. let bStarted!: () => void
  405. const started = new Promise<void>(r => (bStarted = r))
  406. let aDone!: () => void
  407. const aFinished = new Promise<void>(r => (aDone = r))
  408. fake.scripts['数据员'] = async (_input, _call, ctx) => {
  409. await started
  410. fs.mkdirSync(path.join(ctx.workspaceDir, 'outputs'), { recursive: true })
  411. fs.writeFileSync(path.join(ctx.workspaceDir, 'outputs/a-script.txt'), 'a')
  412. ctx.emit('tool.started', { toolCallId: 'w1', name: 'Write', title: 'a-tool', input: { file_path: path.join(ctx.workspaceDir, 'outputs/a-tool.txt') } })
  413. fs.writeFileSync(path.join(ctx.workspaceDir, 'outputs/a-tool.txt'), 'a')
  414. ctx.emit('tool.completed', { toolCallId: 'w1', status: 'ok', output: '', truncated: false })
  415. aDone()
  416. return 'A 完成'
  417. }
  418. fake.scripts['文档员'] = async (_input, _call, ctx) => {
  419. bStarted()
  420. fs.mkdirSync(path.join(ctx.workspaceDir, 'outputs'), { recursive: true })
  421. fs.writeFileSync(path.join(ctx.workspaceDir, 'outputs/b-script.txt'), 'b')
  422. await aFinished
  423. return 'B 完成'
  424. }
  425. await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '各自干活', mentions: [ids['数据员'], ids['文档员']] } })
  426. await waitFor(() => idle(team.id) && messages(team.id).filter(m => m.sender.kind === 'bot').length === 2)
  427. const reply = (text: string) => messages(team.id).find(m => m.text === text)!.files.map(f => f.path)
  428. expect(reply('A 完成')).toEqual(['outputs/a-tool.txt'])
  429. expect(reply('B 完成')).toEqual([])
  430. // 文件都在共享目录中
  431. const all = app.teams.events.list(team.id, 0, 5000, ['file.changed']).length
  432. expect(all).toBeGreaterThanOrEqual(3)
  433. })
  434. })
  435. describe('开始新的会话', () => {
  436. it('组员的上下文超过限额时下一轮开始新会话;组长在任务进行中不换,任务结束后再换;成员可以要求开始新会话', async () => {
  437. const team = (await server.inject({
  438. method: 'POST', url: '/bot/v1/teams', headers: bk(),
  439. payload: { human, leadBotId: ids['组长'], workerBotIds: [ids['数据员']], limits: { sessionMaxContextTokens: 10_000 } },
  440. })).json() as TeamDetailDto
  441. const sessionsOf = (bot: string) => [...new Set(fake.inputs.filter(i => i.bot === bot && app.sessions.getRow(i.sessionId).source === `team:${team.id}`).map(i => i.sessionId))]
  442. const say = async (text: string, mentions?: string[]) => {
  443. const before = fake.inputs.length
  444. await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text, ...(mentions ? { mentions } : {}) } })
  445. await waitFor(() => fake.inputs.length > before && idle(team.id))
  446. }
  447. fake.scripts['数据员'] = async () => 'ok'
  448. fake.scripts['组长'] = async (input, call) => {
  449. if (input.includes('开始任务')) await call('job_start', { title: '长任务', plan: '' })
  450. if (input.includes('结束任务')) await call('job_complete', { summary: '完成', notes: '' })
  451. return 'ok'
  452. }
  453. fake.context = 20_000
  454. await say('一', [ids['数据员']!])
  455. await say('二', [ids['数据员']!])
  456. expect(sessionsOf('数据员')).toHaveLength(2)
  457. expect(messages(team.id).some(m => m.sender.kind === 'system' && m.text.includes('数据员 开始新的会话:上一个会话的上下文已有 20k token'))).toBe(true)
  458. await say('开始任务')
  459. await say('补充说明')
  460. await say('结束任务')
  461. // 第一轮之后上下文已超限,但任务进行中不换
  462. expect(sessionsOf('组长')).toHaveLength(1)
  463. await say('新的问题')
  464. expect(sessionsOf('组长')).toHaveLength(2)
  465. fake.context = 0
  466. const r = await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/bots/${ids['数据员']}/reset-session`, headers: bk() })
  467. expect(r.statusCode).toBe(200)
  468. await say('三', [ids['数据员']!])
  469. expect(sessionsOf('数据员')).toHaveLength(3)
  470. expect(messages(team.id).some(m => m.text.includes('成员要求开始新的会话'))).toBe(true)
  471. })
  472. })
  473. describe('复制到 Playground 与回归用例', () => {
  474. it('从平台的工作组复制、保存用例(含附件),重放后列出结果', async () => {
  475. const team = (await server.inject({ method: 'POST', url: '/bot/v1/teams', headers: bk(), payload: { human, title: '数据分析', leadBotId: ids['组长'], workerBotIds: [ids['数据员']] } })).json() as TeamDetailDto
  476. const boundary = 'xBx'
  477. const up = await server.inject({
  478. method: 'POST', url: `/bot/v1/teams/${team.id}/files`,
  479. headers: { ...bk(), 'content-type': `multipart/form-data; boundary=${boundary}` },
  480. payload: `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="q3.csv"\r\nContent-Type: text/csv\r\n\r\na,b\n1,2\n\r\n--${boundary}--\r\n`,
  481. })
  482. const fileId = up.json()[0].id
  483. fake.scripts['组长'] = async input => (input.includes('uploads/q3.csv') ? '看到附件了' : '没有附件')
  484. await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '分析 Q3 数据', fileIds: [fileId] } })
  485. await waitFor(() => idle(team.id) && messages(team.id).some(m => m.sender.kind === 'bot'))
  486. // 复制到 Playground:相同的成员组成,来源为后台,不复制消息
  487. const copied = await server.inject({ method: 'POST', url: `/admin/v1/teams/${team.id}/copy`, cookies: adminCookie })
  488. expect(copied.statusCode).toBe(201)
  489. expect(copied.json()).toMatchObject({ source: 'admin', title: '数据分析(副本)' })
  490. expect(copied.json().bots.map((b: { id: string; role: string }) => [b.id, b.role])).toEqual([[ids['组长'], 'lead'], [ids['数据员'], 'worker']])
  491. expect(messages(copied.json().id).every(m => m.sender.kind === 'system')).toBe(true)
  492. expect((await server.inject({ method: 'POST', url: `/admin/v1/teams/${team.id}/copy`, headers: bk() })).statusCode).toBe(401)
  493. const saved = await server.inject({ method: 'POST', url: '/admin/v1/team-cases', cookies: adminCookie, payload: { name: 'Q3 分析', teamId: team.id } })
  494. expect(saved.statusCode).toBe(201)
  495. const c = saved.json()
  496. expect(c).toMatchObject({ name: 'Q3 分析', leadBotId: ids['组长'], workerBotIds: [ids['数据员']], text: '分析 Q3 数据', files: [{ name: 'q3.csv', size: 8 }], runCount: 0 })
  497. const replayed = await server.inject({ method: 'POST', url: '/admin/v1/team-cases/replay', cookies: adminCookie, payload: { ids: [c.id] } })
  498. expect(replayed.statusCode).toBe(201)
  499. const run = replayed.json()[0]
  500. expect(run.source).toBe('admin')
  501. await waitFor(() => idle(run.id) && messages(run.id).some(m => m.sender.kind === 'bot'))
  502. const first = messages(run.id).find(m => m.sender.kind === 'human')!
  503. expect(first.files.map(f => f.path)).toEqual(['uploads/q3.csv'])
  504. const runs = (await server.inject({ method: 'GET', url: `/admin/v1/team-cases/${c.id}/runs`, cookies: adminCookie })).json()
  505. expect(runs).toHaveLength(1)
  506. expect(runs[0]).toMatchObject({ teamId: run.id, state: 'done', job: null, reply: '看到附件了', runs: 1 })
  507. const list = (await server.inject({ method: 'GET', url: '/admin/v1/team-cases', cookies: adminCookie })).json()
  508. expect(list.find((x: { id: string }) => x.id === c.id)).toMatchObject({ runCount: 1, lastRun: { teamId: run.id } })
  509. expect((await server.inject({ method: 'DELETE', url: `/admin/v1/team-cases/${c.id}`, cookies: adminCookie })).statusCode).toBe(204)
  510. // 重放产生的工作组保留
  511. expect(app.teams.getRow(run.id).caseId).toBeNull()
  512. })
  513. })