teams.test.ts 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300
  1. // Bot 工作组一期(docs/design/13-bot-teams.md §13):Bot Key 与鉴权隔离、组成规则、唤醒规则、顺序执行与合并、审批、生命周期
  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 { ProviderStatusDto, 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. /** 假引擎:回复"收到:<输入最后一行>";输入含"审批"时请求审批;含"慢"时等待 release();把产出写到 outputs/ */
  11. class FakeProvider implements AgentProvider {
  12. readonly id = 'claude' as const
  13. runs: RunContext[] = []
  14. private gates: (() => void)[] = []
  15. release() {
  16. for (const g of this.gates.splice(0)) g()
  17. }
  18. async run(ctx: RunContext) {
  19. this.runs.push(ctx)
  20. ctx.setProviderSessionId(`fake-${ctx.sessionId}`)
  21. if (ctx.input.text.includes('慢')) {
  22. await new Promise<void>((resolve, reject) => {
  23. this.gates.push(resolve)
  24. ctx.signal.addEventListener('abort', () => reject(new Error('aborted')))
  25. })
  26. }
  27. if (ctx.input.text.includes('审批')) {
  28. const outcome = await ctx.interact({ kind: 'approval', tool: 'Bash', title: 'echo', input: {}, options: ['allow_once', 'deny'] })
  29. if (outcome.resolution !== 'allowed') throw new Error('denied')
  30. fs.mkdirSync(path.join(ctx.workspaceDir, 'outputs'), { recursive: true })
  31. fs.writeFileSync(path.join(ctx.workspaceDir, `outputs/${ctx.sessionId}.md`), '# 报告')
  32. ctx.emit('file.changed', { fileId: 'x', path: `outputs/${ctx.sessionId}.md`, kind: 'created', size: 8, mime: 'text/markdown' })
  33. }
  34. const last = ctx.input.text.trim().split('\n').filter(Boolean).at(-1) ?? ''
  35. ctx.emit('message.completed', { messageId: `m${this.runs.length}`, text: `收到:${last}` })
  36. return { usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0, costUsd: 0.001 } }
  37. }
  38. async status(): Promise<ProviderStatusDto> {
  39. return { id: 'claude', enabled: true, available: true, loggedIn: true, account: 'test', cliPath: '', version: 'x', models: ['m'], defaultModel: 'm', message: null }
  40. }
  41. }
  42. let app: App
  43. let server: Awaited<ReturnType<typeof buildServer>>
  44. const fake = new FakeProvider()
  45. let adminCookie: Record<string, string>
  46. let botKey: string
  47. let apiKey: string
  48. const ids: Record<string, string> = {}
  49. const bk = () => ({ authorization: `Bearer ${botKey}` })
  50. const human = { id: 'u1', name: '张三' }
  51. async function waitFor<T>(fn: () => T | undefined | false, ms = 3000): Promise<T> {
  52. const end = Date.now() + ms
  53. while (Date.now() < end) {
  54. const v = fn()
  55. if (v) return v
  56. await new Promise(r => setTimeout(r, 20))
  57. }
  58. throw new Error('timeout')
  59. }
  60. const messages = (teamId: string) =>
  61. app.teams.events.list(teamId, 0, 5000, ['message.created']).map(e => (e as TeamEvent & { type: 'message.created' }).payload as TeamMessage)
  62. const botReplies = (teamId: string, botId: string) => messages(teamId).filter(m => m.sender.kind === 'bot' && m.sender.botId === botId)
  63. const idle = (teamId: string) => app.teams.toDto(app.teams.getRow(teamId)).bots.every(b => b.status === 'idle')
  64. async function createTeam(body: object, headers = bk()) {
  65. const res = await server.inject({ method: 'POST', url: '/bot/v1/teams', headers, payload: { human, ...body } })
  66. return res
  67. }
  68. beforeAll(async () => {
  69. const log = pino({ level: 'silent' })
  70. app = createApp(log, { providers: { claude: fake } })
  71. server = await buildServer(app, { logger: log })
  72. const login = await server.inject({ method: 'POST', url: '/admin/v1/auth/login', payload: { password: 'test-password' } })
  73. const c = login.cookies[0]!
  74. adminCookie = { [c.name]: c.value }
  75. const profileId = app.profiles.getBuiltin().id
  76. for (const [name, kind] of [['数据员', 'worker'], ['文档员', 'worker'], ['组长', 'lead'], ['外部员', 'worker']] as const) {
  77. const res = await server.inject({
  78. method: 'POST', url: '/admin/v1/bots', cookies: adminCookie,
  79. payload: { name, avatar: kind === 'lead' ? 'kl:brain:purple' : '🤖', kind, description: `${name}的岗位`, profileId, enabled: true },
  80. })
  81. expect(res.statusCode).toBe(201)
  82. ids[name] = res.json().id
  83. }
  84. const key = await server.inject({ method: 'POST', url: '/admin/v1/bot-keys', cookies: adminCookie, payload: { name: '平台A', allowedBotIds: [ids['数据员'], ids['文档员'], ids['组长']] } })
  85. expect(key.statusCode).toBe(201)
  86. botKey = key.json().key
  87. expect(botKey).toMatch(/^bk_/)
  88. apiKey = app.apiKeys.create('agent', { allowedProfileIds: [profileId], defaultProfileId: profileId }).key
  89. })
  90. afterAll(() => server.close())
  91. describe('Bot Key 与 API Key 互不通用(ADR-0024)', () => {
  92. it('Bot Key 不能访问 /v1,API Key 不能访问 /bot/v1', async () => {
  93. expect((await server.inject({ method: 'GET', url: '/v1/sessions', headers: bk() })).statusCode).toBe(401)
  94. expect((await server.inject({ method: 'GET', url: '/bot/v1/teams', headers: { authorization: `Bearer ${apiKey}` } })).statusCode).toBe(401)
  95. expect((await server.inject({ method: 'GET', url: '/bot/v1/teams', headers: bk() })).statusCode).toBe(200)
  96. })
  97. it('两种浏览器令牌不能互换', async () => {
  98. const botToken = (await server.inject({ method: 'POST', url: '/bot/v1/browser-tokens', headers: bk(), payload: {} })).json().token as string
  99. const agentToken = (await server.inject({ method: 'POST', url: '/v1/browser-tokens', headers: { authorization: `Bearer ${apiKey}` }, payload: {} })).json().token as string
  100. expect((await server.inject({ method: 'GET', url: '/bot/v1/bots', headers: { authorization: `Bearer ${botToken}` } })).statusCode).toBe(200)
  101. expect((await server.inject({ method: 'GET', url: '/v1/sessions', headers: { authorization: `Bearer ${botToken}` } })).statusCode).toBe(401)
  102. expect((await server.inject({ method: 'GET', url: '/bot/v1/bots', headers: { authorization: `Bearer ${agentToken}` } })).statusCode).toBe(401)
  103. // 浏览器令牌不能再签发令牌
  104. expect((await server.inject({ method: 'POST', url: '/bot/v1/browser-tokens', headers: { authorization: `Bearer ${botToken}` }, payload: {} })).statusCode).toBe(403)
  105. })
  106. it('只列出 Key 允许的机器人;后台 Cookie 可以访问 Bot API', async () => {
  107. const names = (await server.inject({ method: 'GET', url: '/bot/v1/bots', headers: bk() })).json().map((b: { name: string }) => b.name)
  108. expect(names.sort()).toEqual(['数据员', '文档员', '组长'].sort())
  109. const all = (await server.inject({ method: 'GET', url: '/bot/v1/bots', cookies: adminCookie })).json()
  110. expect(all).toHaveLength(4)
  111. })
  112. })
  113. describe('组成规则', () => {
  114. it('至少一个机器人;组长和组员类型要对;Key 不允许的机器人返回 403', async () => {
  115. expect((await createTeam({})).statusCode).toBe(400)
  116. expect((await createTeam({ leadBotId: ids['数据员'] })).statusCode).toBe(400)
  117. expect((await createTeam({ workerBotIds: [ids['组长']] })).statusCode).toBe(400)
  118. expect((await createTeam({ workerBotIds: [ids['外部员']] })).statusCode).toBe(403)
  119. })
  120. it('不能移除最后一个机器人,不能有两个组长', async () => {
  121. const t = (await createTeam({ leadBotId: ids['组长'] })).json() as TeamDetailDto
  122. expect((await server.inject({ method: 'DELETE', url: `/bot/v1/teams/${t.id}/bots/${ids['组长']}`, headers: bk() })).statusCode).toBe(400)
  123. const added = await server.inject({ method: 'POST', url: `/bot/v1/teams/${t.id}/bots`, headers: bk(), payload: { botId: ids['数据员'] } })
  124. expect(added.statusCode).toBe(200)
  125. expect(added.json().bots.map((b: { role: string }) => b.role)).toEqual(['lead', 'worker'])
  126. })
  127. it('其他 Key 看不到这个工作组', async () => {
  128. const t = (await createTeam({ workerBotIds: [ids['数据员']] })).json() as TeamDetailDto
  129. const other = await server.inject({ method: 'POST', url: '/admin/v1/bot-keys', cookies: adminCookie, payload: { name: '平台B', allowedBotIds: [ids['数据员']] } })
  130. const res = await server.inject({ method: 'GET', url: `/bot/v1/teams/${t.id}`, headers: { authorization: `Bearer ${other.json().key}` } })
  131. expect(res.statusCode).toBe(404)
  132. })
  133. })
  134. describe('消息与执行', () => {
  135. let team: TeamDetailDto
  136. beforeAll(async () => {
  137. team = (await createTeam({ title: '测试组', workerBotIds: [ids['数据员'], ids['文档员']] })).json()
  138. })
  139. it('多个组员且没有组长时必须 @', async () => {
  140. const res = await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '大家好' } })
  141. expect(res.statusCode).toBe(400)
  142. expect(res.json().error.code).toBe('mention_required')
  143. })
  144. it('@ 组员:在共享目录中执行,回复发到工作组;只唤醒被 @ 的机器人', async () => {
  145. const before = fake.runs.length
  146. const res = await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '@数据员 统计一下', mentions: [ids['数据员']] } })
  147. expect(res.statusCode).toBe(201)
  148. const reply = await waitFor(() => botReplies(team.id, ids['数据员']!)[0])
  149. expect(reply.text).toContain('统计一下')
  150. expect(fake.runs.length).toBe(before + 1)
  151. const ctx = fake.runs.at(-1)!
  152. expect(ctx.workspaceDir).toBe(app.teams.getRow(team.id).workspaceDir)
  153. expect(ctx.systemPromptAppend).toContain('工作组「测试组」中的机器人「数据员」')
  154. expect(ctx.input.text).toContain('【工作组新消息】')
  155. expect(botReplies(team.id, ids['文档员']!)).toHaveLength(0)
  156. await waitFor(() => idle(team.id))
  157. })
  158. it('再次 @ 同一个组员时续接同一个会话,只交给它新消息', async () => {
  159. const first = app.teams.toDto(app.teams.getRow(team.id)).bots.find(b => b.id === ids['数据员'])!.sessionId
  160. await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '再按月份分一下', mentions: [ids['数据员']] } })
  161. await waitFor(() => botReplies(team.id, ids['数据员']!).length === 2)
  162. const ctx = fake.runs.at(-1)!
  163. expect(ctx.sessionId).toBe(first)
  164. expect(ctx.input.text).toContain('再按月份分一下')
  165. expect(ctx.input.text).not.toContain('统计一下')
  166. await waitFor(() => idle(team.id))
  167. })
  168. it('执行期间收到的消息在本轮结束后合并成下一轮', async () => {
  169. const send = (text: string) => server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text, mentions: [ids['文档员']] } })
  170. const before = fake.runs.length
  171. await send('慢一点写')
  172. await waitFor(() => app.teams.toDto(app.teams.getRow(team.id)).bots.find(b => b.id === ids['文档员'])!.status === 'working')
  173. await send('补充一')
  174. await send('补充二')
  175. expect(fake.runs.length).toBe(before + 1)
  176. fake.release()
  177. await waitFor(() => botReplies(team.id, ids['文档员']!).length === 2)
  178. expect(fake.runs.length).toBe(before + 2)
  179. const merged = fake.runs.at(-1)!.input.text
  180. expect(merged).toContain('补充一')
  181. expect(merged).toContain('补充二')
  182. expect(merged).not.toContain('慢一点写')
  183. await waitFor(() => idle(team.id))
  184. })
  185. it('审批卡片显示在工作组中,由成员处理;交付文件出现在回复中', async () => {
  186. await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '这个需要审批', mentions: [ids['文档员']] } })
  187. const pending = await waitFor(() => app.teams.pendingInteractions(team.id)[0])
  188. expect(pending.botId).toBe(ids['文档员'])
  189. const detail = (await server.inject({ method: 'GET', url: `/bot/v1/teams/${team.id}`, headers: bk() })).json() as TeamDetailDto
  190. expect(detail.pendingInteractions).toHaveLength(1)
  191. expect(detail.bots.find(b => b.id === ids['文档员'])!.status).toBe('waiting_input')
  192. const events = (await server.inject({ method: 'GET', url: `/bot/v1/teams/${team.id}/events`, headers: bk() })).json().items as TeamEvent[]
  193. expect(events.some(e => e.type === 'interaction.required')).toBe(true)
  194. const res = await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/interactions/${pending.request.interactionId}`, headers: bk(), payload: { decision: 'allow_once' } })
  195. expect(res.statusCode).toBe(200)
  196. const reply = await waitFor(() => botReplies(team.id, ids['文档员']!).find(m => m.files.length))
  197. expect(reply.files[0]!.path).toMatch(/^outputs\/.*\.md$/)
  198. const file = await server.inject({ method: 'GET', url: `/bot/v1/files/${reply.files[0]!.fileId}`, headers: bk() })
  199. expect(file.statusCode).toBe(200)
  200. expect(file.body).toBe('# 报告')
  201. await waitFor(() => idle(team.id))
  202. })
  203. it('工作组中的会话不能经 /v1 直接发消息或删除', async () => {
  204. const sid = app.teams.toDto(app.teams.getRow(team.id)).bots[0]!.sessionId!
  205. expect((await server.inject({ method: 'POST', url: `/v1/sessions/${sid}/messages`, cookies: adminCookie, payload: { text: 'x' } })).statusCode).toBe(400)
  206. expect((await server.inject({ method: 'DELETE', url: `/v1/sessions/${sid}`, cookies: adminCookie })).statusCode).toBe(400)
  207. })
  208. it('上传文件作为附件交给机器人', async () => {
  209. const boundary = 'xBx'
  210. const up = await server.inject({
  211. method: 'POST', url: `/bot/v1/teams/${team.id}/files`,
  212. headers: { ...bk(), 'content-type': `multipart/form-data; boundary=${boundary}` },
  213. payload: `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="a.csv"\r\nContent-Type: text/csv\r\n\r\na,b\n\r\n--${boundary}--\r\n`,
  214. })
  215. expect(up.statusCode).toBe(201)
  216. const fileId = up.json()[0].id
  217. const replies = botReplies(team.id, ids['数据员']!).length
  218. await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '看看附件', mentions: [ids['数据员']], fileIds: [fileId] } })
  219. await waitFor(() => fake.runs.at(-1)?.input.attachments.some(a => a.path === 'uploads/a.csv'))
  220. expect(fake.runs.at(-1)!.input.text).toContain('附件:uploads/a.csv')
  221. await waitFor(() => idle(team.id))
  222. // 文档员之前产生的 outputs/ 文件不能算到数据员的这一轮上
  223. await waitFor(() => botReplies(team.id, ids['数据员']!).length === replies + 1)
  224. expect(botReplies(team.id, ids['数据员']!).at(-1)!.files).toEqual([])
  225. })
  226. })
  227. describe('默认处理人与生命周期', () => {
  228. it('只有一个组员时不 @ 也交给它;有组长时交给组长', async () => {
  229. const solo = (await createTeam({ workerBotIds: [ids['数据员']] })).json() as TeamDetailDto
  230. await server.inject({ method: 'POST', url: `/bot/v1/teams/${solo.id}/messages`, headers: bk(), payload: { text: '你好' } })
  231. await waitFor(() => botReplies(solo.id, ids['数据员']!)[0])
  232. const led = (await createTeam({ leadBotId: ids['组长'], workerBotIds: [ids['数据员']] })).json() as TeamDetailDto
  233. await server.inject({ method: 'POST', url: `/bot/v1/teams/${led.id}/messages`, headers: bk(), payload: { text: '安排一下' } })
  234. await waitFor(() => botReplies(led.id, ids['组长']!)[0])
  235. expect(botReplies(led.id, ids['数据员']!)).toHaveLength(0)
  236. await waitFor(() => idle(led.id))
  237. })
  238. it('暂停时消息只记录,恢复后处理;关闭后不能发消息;删除清理目录与会话', async () => {
  239. const t = (await createTeam({ workerBotIds: [ids['数据员']] })).json() as TeamDetailDto
  240. expect((await server.inject({ method: 'POST', url: `/bot/v1/teams/${t.id}/pause`, headers: bk() })).json().status).toBe('paused')
  241. await server.inject({ method: 'POST', url: `/bot/v1/teams/${t.id}/messages`, headers: bk(), payload: { text: '暂停期间的消息' } })
  242. await new Promise(r => setTimeout(r, 100))
  243. expect(botReplies(t.id, ids['数据员']!)).toHaveLength(0)
  244. await server.inject({ method: 'POST', url: `/bot/v1/teams/${t.id}/resume`, headers: bk() })
  245. const reply = await waitFor(() => botReplies(t.id, ids['数据员']!)[0])
  246. expect(reply.text).toContain('暂停期间的消息')
  247. await waitFor(() => idle(t.id))
  248. await server.inject({ method: 'POST', url: `/bot/v1/teams/${t.id}/close`, headers: bk() })
  249. expect((await server.inject({ method: 'POST', url: `/bot/v1/teams/${t.id}/messages`, headers: bk(), payload: { text: 'x' } })).statusCode).toBe(409)
  250. const dir = app.teams.getRow(t.id).workspaceDir
  251. const sid = app.teams.toDto(app.teams.getRow(t.id)).bots[0]!.sessionId!
  252. expect((await server.inject({ method: 'DELETE', url: `/bot/v1/teams/${t.id}`, headers: bk() })).statusCode).toBe(204)
  253. expect(fs.existsSync(dir)).toBe(false)
  254. expect(() => app.sessions.getRow(sid)).toThrow()
  255. expect((await server.inject({ method: 'GET', url: `/bot/v1/teams/${t.id}`, headers: bk() })).statusCode).toBe(404)
  256. })
  257. it('头像只接受图标格式或一个 emoji', async () => {
  258. const profileId = app.profiles.getBuiltin().id
  259. const bad = await server.inject({
  260. method: 'POST', url: '/admin/v1/bots', cookies: adminCookie,
  261. payload: { name: '坏头像', avatar: 'icon:Bad Name!', kind: 'worker', description: '', profileId, enabled: true },
  262. })
  263. expect(bad.statusCode).toBe(400)
  264. expect(app.bots.get(ids['组长']!).avatar).toBe('kl:brain:purple')
  265. })
  266. it('还在工作组中的机器人不能删除;用量记到 Bot Key 上', async () => {
  267. expect((await server.inject({ method: 'DELETE', url: `/admin/v1/bots/${ids['数据员']}`, cookies: adminCookie })).statusCode).toBe(400)
  268. const usage = app.usage.summary({ days: 1 })
  269. expect(usage.bySource.some(s => s.name === '平台A' && s.runs > 0)).toBe(true)
  270. })
  271. })