import fs from 'node:fs' import path from 'node:path' import pino from 'pino' import { afterAll, beforeAll, describe, expect, it } from 'vitest' import type { AgentEvent, ProviderStatusDto } 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' /** 假引擎:输出文本 → 请求审批 → 写产出文件 */ class FakeProvider implements AgentProvider { readonly id = 'claude' as const lastCtx: RunContext | null = null async run(ctx: RunContext) { this.lastCtx = ctx ctx.setProviderSessionId('fake-session') ctx.emit('message.delta', { messageId: 'm1', text: '你好' }) ctx.emit('message.completed', { messageId: 'm1', text: '你好' }) if (ctx.input.text.includes('审批')) { ctx.emit('tool.started', { toolCallId: 't1', name: 'Bash', title: 'echo', input: { command: 'echo' } }) const outcome = await ctx.interact({ kind: 'approval', tool: 'Bash', title: 'echo', input: {}, options: ['allow_once', 'allow_session', 'deny'] }) ctx.emit('tool.completed', { toolCallId: 't1', status: outcome.resolution === 'allowed' ? 'ok' : 'denied', output: '', truncated: false }) } if (ctx.input.text.includes('等待')) await new Promise((_, reject) => ctx.signal.addEventListener('abort', () => reject(new Error('aborted')))) fs.writeFileSync(path.join(ctx.workspaceDir, 'outputs/report.md'), '# 报告') return { usage: { inputTokens: 1, outputTokens: 2, cacheReadTokens: 0, cacheWriteTokens: 0, costUsd: 0.001 } } } async status(): Promise { 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> let apiKey: string const fake = new FakeProvider() const auth = () => ({ authorization: `Bearer ${apiKey}` }) async function waitFor(fn: () => T | undefined, ms = 3000): Promise { 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 events = async (sid: string) => (await server.inject({ method: 'GET', url: `/v1/sessions/${sid}/events`, headers: auth() })).json().items as AgentEvent[] beforeAll(async () => { const log = pino({ level: 'silent' }) app = createApp(log, { providers: { claude: fake } }) server = await buildServer(app, { logger: log }) apiKey = app.apiKeys.create('test').key }) afterAll(() => server.close()) describe('鉴权', () => { it('没有 Key 返回 401', async () => { expect((await server.inject({ method: 'GET', url: '/v1/sessions' })).statusCode).toBe(401) }) it('后台登录后可以访问管理接口', async () => { const login = await server.inject({ method: 'POST', url: '/admin/v1/auth/login', payload: { password: 'test-password' } }) expect(login.statusCode).toBe(200) const cookie = login.cookies[0]! const res = await server.inject({ method: 'GET', url: '/admin/v1/settings', cookies: { [cookie.name]: cookie.value } }) expect(res.statusCode).toBe(200) expect(res.json().settings.paths.workspaceRoot).toContain('workspaces') }) it('错误密码返回 401', async () => { expect((await server.inject({ method: 'POST', url: '/admin/v1/auth/login', payload: { password: 'nope' } })).statusCode).toBe(401) }) }) describe('会话与 Run', () => { it('完整流程:上传 → 发送 → 审批 → 产出文件', async () => { const created = await server.inject({ method: 'POST', url: '/v1/sessions', headers: auth(), payload: {} }) expect(created.statusCode).toBe(201) const sid = created.json().id as string const boundary = 'xBOUNDARYx' const upload = await server.inject({ method: 'POST', url: `/v1/sessions/${sid}/files`, headers: { ...auth(), 'content-type': `multipart/form-data; boundary=${boundary}` }, payload: `--${boundary}\r\nContent-Disposition: form-data; name="file"; filename="销售.csv"\r\nContent-Type: text/csv\r\n\r\na,b\n1,2\n\r\n--${boundary}--\r\n`, }) expect(upload.statusCode).toBe(201) const fileId = upload.json()[0].id as string expect(upload.json()[0].path).toBe('uploads/销售.csv') const sent = await server.inject({ method: 'POST', url: `/v1/sessions/${sid}/messages`, headers: auth(), payload: { text: '请审批', attachments: [fileId] } }) expect(sent.statusCode).toBe(202) const runId = sent.json().id as string // 会话忙时不能再发 const busy = await server.inject({ method: 'POST', url: `/v1/sessions/${sid}/messages`, headers: auth(), payload: { text: 'x' } }) expect(busy.statusCode).toBe(409) const itx = await waitFor(() => app.interactions.pendingFor(sid)[0]) expect(fake.lastCtx?.input.attachments[0]?.path).toBe('uploads/销售.csv') const detail = await server.inject({ method: 'GET', url: `/v1/sessions/${sid}`, headers: auth() }) expect(detail.json().status).toBe('waiting_input') const reply = await server.inject({ method: 'POST', url: `/v1/interactions/${itx.interactionId}`, headers: auth(), payload: { decision: 'allow_once' } }) expect(reply.statusCode).toBe(200) const again = await server.inject({ method: 'POST', url: `/v1/interactions/${itx.interactionId}`, headers: auth(), payload: { decision: 'allow_once' } }) expect(again.statusCode).toBe(409) await waitFor(() => app.runs.activeForSession(sid) === undefined || undefined) const run = (await server.inject({ method: 'GET', url: `/v1/runs/${runId}`, headers: auth() })).json() expect(run.status).toBe('completed') expect(run.usage.outputTokens).toBe(2) const types = (await events(sid)).map(e => e.type) expect(types).toEqual(expect.arrayContaining([ 'user.message', 'run.queued', 'run.started', 'message.completed', 'tool.started', 'interaction.required', 'interaction.resolved', 'tool.completed', 'file.changed', 'usage', 'run.completed', ])) // 内存缓冲中包含瞬时的 delta 事件 expect(types).toContain('message.delta') const files = (await server.inject({ method: 'GET', url: `/v1/sessions/${sid}/files`, headers: auth() })).json() const report = files.find((f: { path: string }) => f.path === 'outputs/report.md') expect(report.origin).toBe('agent') const dl = await server.inject({ method: 'GET', url: `/v1/files/${report.id}`, headers: auth() }) expect(dl.body).toBe('# 报告') const session = (await server.inject({ method: 'GET', url: `/v1/sessions/${sid}`, headers: auth() })).json() expect(session.title).toBe('请审批') }) it('中断运行中的 Run', async () => { const sid = (await server.inject({ method: 'POST', url: '/v1/sessions', headers: auth(), payload: {} })).json().id const runId = (await server.inject({ method: 'POST', url: `/v1/sessions/${sid}/messages`, headers: auth(), payload: { text: '等待' } })).json().id await waitFor(() => app.runs.activeForSession(sid)?.status === 'running' || undefined) expect((await server.inject({ method: 'POST', url: `/v1/runs/${runId}/interrupt`, headers: auth() })).statusCode).toBe(202) await waitFor(() => app.runs.activeForSession(sid) === undefined || undefined) expect(app.runs.get(runId).status).toBe('interrupted') expect((await events(sid)).at(-1)?.type).toBe('run.interrupted') }) it('其他 API Key 看不到这个会话', async () => { const sid = (await server.inject({ method: 'POST', url: '/v1/sessions', headers: auth(), payload: {} })).json().id const otherKey = app.apiKeys.create('other').key const res = await server.inject({ method: 'GET', url: `/v1/sessions/${sid}`, headers: { authorization: `Bearer ${otherKey}` } }) expect(res.statusCode).toBe(404) }) it('删除会话同时删除工作区', async () => { const created = await app.sessions.create(app.apiKeys.list()[0]!.id, {}) expect(fs.existsSync(created.workspaceDir)).toBe(true) await app.sessions.delete(created.id, 'admin') expect(fs.existsSync(created.workspaceDir)).toBe(false) }) }) describe('资料库', () => { it('拒绝不安全的路径', () => { expect(() => app.knowledge.create({ name: 'home', path: '~' })).toThrow(/全局禁止项/) expect(() => app.knowledge.create({ name: 'none', path: '/does/not/exist' })).toThrow(/不存在/) }) })