| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290 |
- // Bot 工作组一期(docs/design/13-bot-teams.md §13):Bot Key 与鉴权隔离、组成规则、唤醒规则、顺序执行与合并、审批、生命周期
- import fs from 'node:fs'
- import path from 'node:path'
- import pino from 'pino'
- import { afterAll, beforeAll, describe, expect, it } from 'vitest'
- import type { ProviderStatusDto, TeamDetailDto, TeamEvent, TeamMessage } from '@agent-server/shared'
- import { createApp, type App } from '../src/app'
- import { buildServer } from '../src/http/server'
- import type { AgentProvider, RunContext } from '../src/providers/types'
- /** 假引擎:回复"收到:<输入最后一行>";输入含"审批"时请求审批;含"慢"时等待 release();把产出写到 outputs/ */
- class FakeProvider implements AgentProvider {
- readonly id = 'claude' as const
- runs: RunContext[] = []
- private gates: (() => void)[] = []
- release() {
- for (const g of this.gates.splice(0)) g()
- }
- async run(ctx: RunContext) {
- this.runs.push(ctx)
- ctx.setProviderSessionId(`fake-${ctx.sessionId}`)
- if (ctx.input.text.includes('慢')) {
- await new Promise<void>((resolve, reject) => {
- this.gates.push(resolve)
- ctx.signal.addEventListener('abort', () => reject(new Error('aborted')))
- })
- }
- if (ctx.input.text.includes('审批')) {
- const outcome = await ctx.interact({ kind: 'approval', tool: 'Bash', title: 'echo', input: {}, options: ['allow_once', 'deny'] })
- if (outcome.resolution !== 'allowed') throw new Error('denied')
- fs.mkdirSync(path.join(ctx.workspaceDir, 'outputs'), { recursive: true })
- fs.writeFileSync(path.join(ctx.workspaceDir, `outputs/${ctx.sessionId}.md`), '# 报告')
- ctx.emit('file.changed', { fileId: 'x', path: `outputs/${ctx.sessionId}.md`, kind: 'created', size: 8, mime: 'text/markdown' })
- }
- const last = ctx.input.text.trim().split('\n').filter(Boolean).at(-1) ?? ''
- ctx.emit('message.completed', { messageId: `m${this.runs.length}`, text: `收到:${last}` })
- return { usage: { inputTokens: 1, outputTokens: 1, cacheReadTokens: 0, cacheWriteTokens: 0, costUsd: 0.001 } }
- }
- async status(): Promise<ProviderStatusDto> {
- return { id: 'claude', enabled: true, available: true, loggedIn: true, account: 'test', cliPath: '', version: 'x', models: ['m'], defaultModel: 'm', message: null }
- }
- }
- let app: App
- let server: Awaited<ReturnType<typeof buildServer>>
- const fake = new FakeProvider()
- let adminCookie: Record<string, string>
- let botKey: string
- let apiKey: string
- const ids: Record<string, string> = {}
- const bk = () => ({ authorization: `Bearer ${botKey}` })
- const human = { id: 'u1', name: '张三' }
- async function waitFor<T>(fn: () => T | undefined | false, ms = 3000): Promise<T> {
- const end = Date.now() + ms
- while (Date.now() < end) {
- const v = fn()
- if (v) return v
- await new Promise(r => setTimeout(r, 20))
- }
- throw new Error('timeout')
- }
- const messages = (teamId: string) =>
- app.teams.events.list(teamId, 0, 5000, ['message.created']).map(e => (e as TeamEvent & { type: 'message.created' }).payload as TeamMessage)
- const botReplies = (teamId: string, botId: string) => messages(teamId).filter(m => m.sender.kind === 'bot' && m.sender.botId === botId)
- const idle = (teamId: string) => app.teams.toDto(app.teams.getRow(teamId)).bots.every(b => b.status === 'idle')
- async function createTeam(body: object, headers = bk()) {
- const res = await server.inject({ method: 'POST', url: '/bot/v1/teams', headers, payload: { human, ...body } })
- return res
- }
- beforeAll(async () => {
- const log = pino({ level: 'silent' })
- app = createApp(log, { providers: { claude: fake } })
- server = await buildServer(app, { logger: log })
- const login = await server.inject({ method: 'POST', url: '/admin/v1/auth/login', payload: { password: 'test-password' } })
- const c = login.cookies[0]!
- adminCookie = { [c.name]: c.value }
- const profileId = app.profiles.getBuiltin().id
- for (const [name, kind] of [['数据员', 'worker'], ['文档员', 'worker'], ['组长', 'lead'], ['外部员', 'worker']] as const) {
- const res = await server.inject({
- method: 'POST', url: '/admin/v1/bots', cookies: adminCookie,
- payload: { name, avatar: '🤖', kind, description: `${name}的岗位`, profileId, enabled: true },
- })
- expect(res.statusCode).toBe(201)
- ids[name] = res.json().id
- }
- const key = await server.inject({ method: 'POST', url: '/admin/v1/bot-keys', cookies: adminCookie, payload: { name: '平台A', allowedBotIds: [ids['数据员'], ids['文档员'], ids['组长']] } })
- expect(key.statusCode).toBe(201)
- botKey = key.json().key
- expect(botKey).toMatch(/^bk_/)
- apiKey = app.apiKeys.create('agent', { allowedProfileIds: [profileId], defaultProfileId: profileId }).key
- })
- afterAll(() => server.close())
- describe('Bot Key 与 API Key 互不通用(ADR-0024)', () => {
- it('Bot Key 不能访问 /v1,API Key 不能访问 /bot/v1', async () => {
- expect((await server.inject({ method: 'GET', url: '/v1/sessions', headers: bk() })).statusCode).toBe(401)
- expect((await server.inject({ method: 'GET', url: '/bot/v1/teams', headers: { authorization: `Bearer ${apiKey}` } })).statusCode).toBe(401)
- expect((await server.inject({ method: 'GET', url: '/bot/v1/teams', headers: bk() })).statusCode).toBe(200)
- })
- it('两种浏览器令牌不能互换', async () => {
- const botToken = (await server.inject({ method: 'POST', url: '/bot/v1/browser-tokens', headers: bk(), payload: {} })).json().token as string
- const agentToken = (await server.inject({ method: 'POST', url: '/v1/browser-tokens', headers: { authorization: `Bearer ${apiKey}` }, payload: {} })).json().token as string
- expect((await server.inject({ method: 'GET', url: '/bot/v1/bots', headers: { authorization: `Bearer ${botToken}` } })).statusCode).toBe(200)
- expect((await server.inject({ method: 'GET', url: '/v1/sessions', headers: { authorization: `Bearer ${botToken}` } })).statusCode).toBe(401)
- expect((await server.inject({ method: 'GET', url: '/bot/v1/bots', headers: { authorization: `Bearer ${agentToken}` } })).statusCode).toBe(401)
- // 浏览器令牌不能再签发令牌
- expect((await server.inject({ method: 'POST', url: '/bot/v1/browser-tokens', headers: { authorization: `Bearer ${botToken}` }, payload: {} })).statusCode).toBe(403)
- })
- it('只列出 Key 允许的机器人;后台 Cookie 可以访问 Bot API', async () => {
- const names = (await server.inject({ method: 'GET', url: '/bot/v1/bots', headers: bk() })).json().map((b: { name: string }) => b.name)
- expect(names.sort()).toEqual(['数据员', '文档员', '组长'].sort())
- const all = (await server.inject({ method: 'GET', url: '/bot/v1/bots', cookies: adminCookie })).json()
- expect(all).toHaveLength(4)
- })
- })
- describe('组成规则', () => {
- it('至少一个机器人;组长和组员类型要对;Key 不允许的机器人返回 403', async () => {
- expect((await createTeam({})).statusCode).toBe(400)
- expect((await createTeam({ leadBotId: ids['数据员'] })).statusCode).toBe(400)
- expect((await createTeam({ workerBotIds: [ids['组长']] })).statusCode).toBe(400)
- expect((await createTeam({ workerBotIds: [ids['外部员']] })).statusCode).toBe(403)
- })
- it('不能移除最后一个机器人,不能有两个组长', async () => {
- const t = (await createTeam({ leadBotId: ids['组长'] })).json() as TeamDetailDto
- expect((await server.inject({ method: 'DELETE', url: `/bot/v1/teams/${t.id}/bots/${ids['组长']}`, headers: bk() })).statusCode).toBe(400)
- const added = await server.inject({ method: 'POST', url: `/bot/v1/teams/${t.id}/bots`, headers: bk(), payload: { botId: ids['数据员'] } })
- expect(added.statusCode).toBe(200)
- expect(added.json().bots.map((b: { role: string }) => b.role)).toEqual(['lead', 'worker'])
- })
- it('其他 Key 看不到这个工作组', async () => {
- const t = (await createTeam({ workerBotIds: [ids['数据员']] })).json() as TeamDetailDto
- const other = await server.inject({ method: 'POST', url: '/admin/v1/bot-keys', cookies: adminCookie, payload: { name: '平台B', allowedBotIds: [ids['数据员']] } })
- const res = await server.inject({ method: 'GET', url: `/bot/v1/teams/${t.id}`, headers: { authorization: `Bearer ${other.json().key}` } })
- expect(res.statusCode).toBe(404)
- })
- })
- describe('消息与执行', () => {
- let team: TeamDetailDto
- beforeAll(async () => {
- team = (await createTeam({ title: '测试组', workerBotIds: [ids['数据员'], ids['文档员']] })).json()
- })
- it('多个组员且没有组长时必须 @', async () => {
- const res = await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '大家好' } })
- expect(res.statusCode).toBe(400)
- expect(res.json().error.code).toBe('mention_required')
- })
- it('@ 组员:在共享目录中执行,回复发到工作组;只唤醒被 @ 的机器人', async () => {
- const before = fake.runs.length
- const res = await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '@数据员 统计一下', mentions: [ids['数据员']] } })
- expect(res.statusCode).toBe(201)
- const reply = await waitFor(() => botReplies(team.id, ids['数据员']!)[0])
- expect(reply.text).toContain('统计一下')
- expect(fake.runs.length).toBe(before + 1)
- const ctx = fake.runs.at(-1)!
- expect(ctx.workspaceDir).toBe(app.teams.getRow(team.id).workspaceDir)
- expect(ctx.systemPromptAppend).toContain('工作组「测试组」中的机器人「数据员」')
- expect(ctx.input.text).toContain('【工作组新消息】')
- expect(botReplies(team.id, ids['文档员']!)).toHaveLength(0)
- await waitFor(() => idle(team.id))
- })
- it('再次 @ 同一个组员时续接同一个会话,只交给它新消息', async () => {
- const first = app.teams.toDto(app.teams.getRow(team.id)).bots.find(b => b.id === ids['数据员'])!.sessionId
- await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '再按月份分一下', mentions: [ids['数据员']] } })
- await waitFor(() => botReplies(team.id, ids['数据员']!).length === 2)
- const ctx = fake.runs.at(-1)!
- expect(ctx.sessionId).toBe(first)
- expect(ctx.input.text).toContain('再按月份分一下')
- expect(ctx.input.text).not.toContain('统计一下')
- await waitFor(() => idle(team.id))
- })
- it('执行期间收到的消息在本轮结束后合并成下一轮', async () => {
- const send = (text: string) => server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text, mentions: [ids['文档员']] } })
- const before = fake.runs.length
- await send('慢一点写')
- await waitFor(() => app.teams.toDto(app.teams.getRow(team.id)).bots.find(b => b.id === ids['文档员'])!.status === 'working')
- await send('补充一')
- await send('补充二')
- expect(fake.runs.length).toBe(before + 1)
- fake.release()
- await waitFor(() => botReplies(team.id, ids['文档员']!).length === 2)
- expect(fake.runs.length).toBe(before + 2)
- const merged = fake.runs.at(-1)!.input.text
- expect(merged).toContain('补充一')
- expect(merged).toContain('补充二')
- expect(merged).not.toContain('慢一点写')
- await waitFor(() => idle(team.id))
- })
- it('审批卡片显示在工作组中,由成员处理;交付文件出现在回复中', async () => {
- await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '这个需要审批', mentions: [ids['文档员']] } })
- const pending = await waitFor(() => app.teams.pendingInteractions(team.id)[0])
- expect(pending.botId).toBe(ids['文档员'])
- const detail = (await server.inject({ method: 'GET', url: `/bot/v1/teams/${team.id}`, headers: bk() })).json() as TeamDetailDto
- expect(detail.pendingInteractions).toHaveLength(1)
- expect(detail.bots.find(b => b.id === ids['文档员'])!.status).toBe('waiting_input')
- const events = (await server.inject({ method: 'GET', url: `/bot/v1/teams/${team.id}/events`, headers: bk() })).json().items as TeamEvent[]
- expect(events.some(e => e.type === 'interaction.required')).toBe(true)
- const res = await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/interactions/${pending.request.interactionId}`, headers: bk(), payload: { decision: 'allow_once' } })
- expect(res.statusCode).toBe(200)
- const reply = await waitFor(() => botReplies(team.id, ids['文档员']!).find(m => m.files.length))
- expect(reply.files[0]!.path).toMatch(/^outputs\/.*\.md$/)
- const file = await server.inject({ method: 'GET', url: `/bot/v1/files/${reply.files[0]!.fileId}`, headers: bk() })
- expect(file.statusCode).toBe(200)
- expect(file.body).toBe('# 报告')
- await waitFor(() => idle(team.id))
- })
- it('工作组中的会话不能经 /v1 直接发消息或删除', async () => {
- const sid = app.teams.toDto(app.teams.getRow(team.id)).bots[0]!.sessionId!
- expect((await server.inject({ method: 'POST', url: `/v1/sessions/${sid}/messages`, cookies: adminCookie, payload: { text: 'x' } })).statusCode).toBe(400)
- expect((await server.inject({ method: 'DELETE', url: `/v1/sessions/${sid}`, cookies: adminCookie })).statusCode).toBe(400)
- })
- it('上传文件作为附件交给机器人', async () => {
- const boundary = 'xBx'
- const up = await server.inject({
- method: 'POST', url: `/bot/v1/teams/${team.id}/files`,
- headers: { ...bk(), 'content-type': `multipart/form-data; boundary=${boundary}` },
- 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`,
- })
- expect(up.statusCode).toBe(201)
- const fileId = up.json()[0].id
- const replies = botReplies(team.id, ids['数据员']!).length
- await server.inject({ method: 'POST', url: `/bot/v1/teams/${team.id}/messages`, headers: bk(), payload: { text: '看看附件', mentions: [ids['数据员']], fileIds: [fileId] } })
- await waitFor(() => fake.runs.at(-1)?.input.attachments.some(a => a.path === 'uploads/a.csv'))
- expect(fake.runs.at(-1)!.input.text).toContain('附件:uploads/a.csv')
- await waitFor(() => idle(team.id))
- // 文档员之前产生的 outputs/ 文件不能算到数据员的这一轮上
- await waitFor(() => botReplies(team.id, ids['数据员']!).length === replies + 1)
- expect(botReplies(team.id, ids['数据员']!).at(-1)!.files).toEqual([])
- })
- })
- describe('默认处理人与生命周期', () => {
- it('只有一个组员时不 @ 也交给它;有组长时交给组长', async () => {
- const solo = (await createTeam({ workerBotIds: [ids['数据员']] })).json() as TeamDetailDto
- await server.inject({ method: 'POST', url: `/bot/v1/teams/${solo.id}/messages`, headers: bk(), payload: { text: '你好' } })
- await waitFor(() => botReplies(solo.id, ids['数据员']!)[0])
- const led = (await createTeam({ leadBotId: ids['组长'], workerBotIds: [ids['数据员']] })).json() as TeamDetailDto
- await server.inject({ method: 'POST', url: `/bot/v1/teams/${led.id}/messages`, headers: bk(), payload: { text: '安排一下' } })
- await waitFor(() => botReplies(led.id, ids['组长']!)[0])
- expect(botReplies(led.id, ids['数据员']!)).toHaveLength(0)
- await waitFor(() => idle(led.id))
- })
- it('暂停时消息只记录,恢复后处理;关闭后不能发消息;删除清理目录与会话', async () => {
- const t = (await createTeam({ workerBotIds: [ids['数据员']] })).json() as TeamDetailDto
- expect((await server.inject({ method: 'POST', url: `/bot/v1/teams/${t.id}/pause`, headers: bk() })).json().status).toBe('paused')
- await server.inject({ method: 'POST', url: `/bot/v1/teams/${t.id}/messages`, headers: bk(), payload: { text: '暂停期间的消息' } })
- await new Promise(r => setTimeout(r, 100))
- expect(botReplies(t.id, ids['数据员']!)).toHaveLength(0)
- await server.inject({ method: 'POST', url: `/bot/v1/teams/${t.id}/resume`, headers: bk() })
- const reply = await waitFor(() => botReplies(t.id, ids['数据员']!)[0])
- expect(reply.text).toContain('暂停期间的消息')
- await waitFor(() => idle(t.id))
- await server.inject({ method: 'POST', url: `/bot/v1/teams/${t.id}/close`, headers: bk() })
- expect((await server.inject({ method: 'POST', url: `/bot/v1/teams/${t.id}/messages`, headers: bk(), payload: { text: 'x' } })).statusCode).toBe(409)
- const dir = app.teams.getRow(t.id).workspaceDir
- const sid = app.teams.toDto(app.teams.getRow(t.id)).bots[0]!.sessionId!
- expect((await server.inject({ method: 'DELETE', url: `/bot/v1/teams/${t.id}`, headers: bk() })).statusCode).toBe(204)
- expect(fs.existsSync(dir)).toBe(false)
- expect(() => app.sessions.getRow(sid)).toThrow()
- expect((await server.inject({ method: 'GET', url: `/bot/v1/teams/${t.id}`, headers: bk() })).statusCode).toBe(404)
- })
- it('还在工作组中的机器人不能删除;用量记到 Bot Key 上', async () => {
- expect((await server.inject({ method: 'DELETE', url: `/admin/v1/bots/${ids['数据员']}`, cookies: adminCookie })).statusCode).toBe(400)
- const usage = app.usage.summary({ days: 1 })
- expect(usage.bySource.some(s => s.name === '平台A' && s.runs > 0)).toBe(true)
- })
- })
|