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 quota?: AgentProvider['quota'] 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 }) const builtin = app.profiles.getBuiltin().id apiKey = app.apiKeys.create('test', { allowedProfileIds: [builtin], defaultProfileId: builtin }).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') // 每个 Run 签发网关令牌,结束后作废 expect(fake.lastCtx?.gateway.token).toMatch(/^rt_/) expect(app.gatewayTokens.get(fake.lastCtx!.gateway.token)).toBeNull() 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 b = app.profiles.getBuiltin().id const otherKey = app.apiKeys.create('other', { allowedProfileIds: [b], defaultProfileId: b }).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(/不存在/) }) }) describe('API Key 可用的 Profile', () => { const profileInput = (name: string, permissionMode: 'ask' | 'auto' | 'readonly' = 'ask') => ({ name, description: '', provider: 'claude' as const, model: 'haiku', systemPrompt: '', permissionMode, allowedTools: [], disallowedTools: [], skills: 'all' as const, fileAccess: { mode: 'restricted' as const, read: [], write: [], deny: [] }, knowledgeRootIds: [], isDefault: false, }) it('平台只能看到并使用配置给它的 Profile', async () => { const builtin = app.profiles.getBuiltin() const finance = app.profiles.create(profileInput('财务')) const secret = app.profiles.create(profileInput('其他')) const { key } = app.apiKeys.create('平台A', { allowedProfileIds: [builtin.id, finance.id], defaultProfileId: finance.id }) const h = { authorization: `Bearer ${key}` } const list = (await server.inject({ method: 'GET', url: '/v1/profiles', headers: h })).json() as { id: string; isDefault: boolean }[] expect(list.map(p => p.id).sort()).toEqual([builtin.id, finance.id].sort()) expect(list.find(p => p.isDefault)?.id).toBe(finance.id) // 不指定时用平台的默认 Profile,并使用其默认模型 const s1 = (await server.inject({ method: 'POST', url: '/v1/sessions', headers: h, payload: {} })).json() expect(s1.profileId).toBe(finance.id) expect(s1.model).toBe('haiku') const denied = await server.inject({ method: 'POST', url: '/v1/sessions', headers: h, payload: { profileId: secret.id } }) expect(denied.statusCode).toBe(403) expect(denied.json().error.code).toBe('profile_not_allowed') // 可以自选模型,但必须是可用模型 expect((await server.inject({ method: 'POST', url: '/v1/sessions', headers: h, payload: { model: 'opus' } })).statusCode).toBe(201) expect((await server.inject({ method: 'POST', url: '/v1/sessions', headers: h, payload: { model: 'gpt-x' } })).statusCode).toBe(400) }) it('权限模式由 Profile 决定:平台不能指定,修改 Profile 后立即生效', async () => { const p = app.profiles.create(profileInput('只读助手', 'readonly')) const { key } = app.apiKeys.create('平台B', { allowedProfileIds: [p.id], defaultProfileId: p.id }) const h = { authorization: `Bearer ${key}` } expect((await server.inject({ method: 'POST', url: '/v1/sessions', headers: h, payload: { permissionMode: 'auto' } })).statusCode).toBe(400) const sid = (await server.inject({ method: 'POST', url: '/v1/sessions', headers: h, payload: {} })).json().id expect((await server.inject({ method: 'PATCH', url: `/v1/sessions/${sid}`, headers: h, payload: { permissionMode: 'auto' } })).statusCode).toBe(400) expect((await server.inject({ method: 'GET', url: `/v1/sessions/${sid}`, headers: h })).json().permissionMode).toBe('readonly') app.profiles.update(p.id, { ...profileInput('只读助手', 'ask') }) const detail = (await server.inject({ method: 'GET', url: `/v1/sessions/${sid}`, headers: h })).json() expect(detail.permissionMode).toBe('ask') expect(detail.permissionModeOverridden).toBe(false) await server.inject({ method: 'POST', url: `/v1/sessions/${sid}/messages`, headers: h, payload: { text: 'hi' } }) await waitFor(() => app.runs.activeForSession(sid) === undefined || undefined) expect(fake.lastCtx?.permissionMode).toBe('ask') }) it('撤销 Profile 后,已有会话不能继续运行', async () => { const builtin = app.profiles.getBuiltin() const p = app.profiles.create(profileInput('临时')) const { key, apiKey } = app.apiKeys.create('平台C', { allowedProfileIds: [builtin.id, p.id], defaultProfileId: p.id }) const h = { authorization: `Bearer ${key}` } const sid = (await server.inject({ method: 'POST', url: '/v1/sessions', headers: h, payload: {} })).json().id app.apiKeys.setProfiles(apiKey.id, { allowedProfileIds: [builtin.id], defaultProfileId: builtin.id }) const res = await server.inject({ method: 'POST', url: `/v1/sessions/${sid}/messages`, headers: h, payload: { text: 'hi' } }) expect(res.statusCode).toBe(403) }) it('Key 配置校验;内置 Profile 和被引用的 Profile 不能删除', async () => { const builtin = app.profiles.getBuiltin() expect(builtin.builtin).toBe(true) expect(() => app.apiKeys.create('x', { allowedProfileIds: [], defaultProfileId: builtin.id })).toThrow(/至少选择一个/) const p = app.profiles.create(profileInput('另一个')) expect(() => app.apiKeys.create('x', { allowedProfileIds: [builtin.id], defaultProfileId: p.id })).toThrow(/默认 Profile 必须/) expect(() => app.profiles.delete(builtin.id)).toThrow(/内置/) app.apiKeys.create('平台D', { allowedProfileIds: [p.id], defaultProfileId: p.id }) const login = await server.inject({ method: 'POST', url: '/admin/v1/auth/login', payload: { password: 'test-password' } }) const cookie = login.cookies[0]! const del = await server.inject({ method: 'DELETE', url: `/admin/v1/profiles/${p.id}`, cookies: { [cookie.name]: cookie.value } }) expect(del.statusCode).toBe(400) expect(del.json().error.message).toContain('平台D') }) }) describe('引擎由 Profile 决定', () => { it('每个引擎一个内置通用助手:不可删除,引擎不可修改', () => { const claude = app.profiles.getBuiltin('claude') const codex = app.profiles.getBuiltin('codex') expect(claude).toMatchObject({ name: '通用助手(Claude)', provider: 'claude', builtin: true }) expect(codex).toMatchObject({ name: '通用助手(Codex)', provider: 'codex', builtin: true }) expect(() => app.profiles.delete(codex.id)).toThrow(/内置/) const { id: _i, version: _v, updatedAt: _u, builtin: _b, ...input } = codex expect(() => app.profiles.update(codex.id, { ...input, provider: 'claude' })).toThrow(/引擎不能修改/) }) it('平台和后台都不能单独指定引擎;会话引擎取自 Profile', async () => { const claude = app.profiles.getBuiltin('claude') const codex = app.profiles.getBuiltin('codex') const { key } = app.apiKeys.create('双引擎平台', { allowedProfileIds: [claude.id, codex.id], defaultProfileId: claude.id }) const h = { authorization: `Bearer ${key}` } const bad = await server.inject({ method: 'POST', url: '/v1/sessions', headers: h, payload: { provider: 'codex' } }) expect(bad.statusCode).toBe(400) expect(bad.json().error.message).toContain('引擎由 Profile 决定') await expect(app.sessions.create('admin', { provider: 'codex' } as never)).rejects.toThrow(/引擎由 Profile 决定/) // Codex 未启用时不能建会话;启用后按 Profile 使用 codex expect((await server.inject({ method: 'POST', url: '/v1/sessions', headers: h, payload: { profileId: codex.id } })).statusCode).toBe(400) app.settings.update({ providers: { codex: { enabled: true } } }) const s = (await server.inject({ method: 'POST', url: '/v1/sessions', headers: h, payload: { profileId: codex.id } })).json() expect(s.provider).toBe('codex') expect(s.model).toBe(codex.model) // 换 Profile 时引擎随之改变(尚未开始对话) const patched = (await server.inject({ method: 'PATCH', url: `/v1/sessions/${s.id}`, headers: h, payload: { profileId: claude.id } })).json() expect(patched.provider).toBe('claude') expect((await server.inject({ method: 'PATCH', url: `/v1/sessions/${s.id}`, headers: h, payload: { provider: 'codex' } })).statusCode).toBe(400) }) }) describe('内置 Profile 的兜底', () => { it('设置中的默认模型为空时,仍能创建内置 Profile', async () => { const { createDb } = await import('../src/db') const { SettingsService } = await import('../src/services/settings') const { ProfileService } = await import('../src/services/catalog') const os = await import('node:os') const db = createDb(path.join(fs.mkdtempSync(path.join(os.tmpdir(), 'seed-')), 'db.sqlite')) const { migrateDb } = await import('../src/db') migrateDb(db) const settings = new SettingsService(db) settings.update({ providers: { codex: { defaultModel: '' } } }) const profiles = new ProfileService(db, settings) expect(profiles.getBuiltin('codex').model).toBe('gpt-6.1-sol') }) }) describe('用量统计', () => { it('Run 结束后按天累加;删除会话后历史用量保留;平台只能看到自己的用量', async () => { const before = app.usage.summary({ days: 7 }).total.runs const sid = (await server.inject({ method: 'POST', url: '/v1/sessions', headers: auth(), payload: {} })).json().id as string await server.inject({ method: 'POST', url: `/v1/sessions/${sid}/messages`, headers: auth(), payload: { text: '统计' } }) await waitFor(() => app.runs.activeForSession(sid) === undefined || undefined) await waitFor(() => app.usage.summary({ days: 7 }).total.runs > before || undefined) await server.inject({ method: 'DELETE', url: `/v1/sessions/${sid}`, headers: auth() }) const s = app.usage.summary({ days: 7 }) expect(s.daily).toHaveLength(7) expect(s.total.runs).toBe(before + 1) expect(s.today.outputTokens).toBeGreaterThanOrEqual(2) expect(s.bySource.find(x => x.name === 'test')).toBeTruthy() expect(s.byModel[0]).toMatchObject({ provider: 'claude' }) const mine = (await server.inject({ method: 'GET', url: '/v1/usage?days=7', headers: auth() })).json() expect(mine.bySource.map((x: { name: string }) => x.name)).toEqual(['test']) const admin = await server.inject({ method: 'GET', url: '/admin/v1/usage', headers: auth() }) expect(admin.statusCode).toBe(401) }) it('区间按日期补零;同一键累加', () => { app.usage.record({ source: 'k', provider: 'codex', model: 'gpt', status: 'failed', inputTokens: 5, durationMs: 100 }, '2026-01-02') app.usage.record({ source: 'k', provider: 'codex', model: 'gpt', status: 'completed', inputTokens: 7, costUsd: 0.5 }, '2026-01-02') const s = app.usage.summary({ days: 3, today: '2026-01-03', source: 'k' }) expect(s.daily.map(d => [d.date, d.runs, d.inputTokens])).toEqual([['2026-01-01', 0, 0], ['2026-01-02', 2, 12], ['2026-01-03', 0, 0]]) expect(s.total).toMatchObject({ runs: 2, failed: 1, costUsd: 0.5, durationMs: 100 }) expect(s.bySource[0]!.name).toMatch(/已删除的 Key/) expect(s.byProvider).toEqual([{ provider: 'codex', total: expect.objectContaining({ runs: 2, inputTokens: 12 }), today: expect.objectContaining({ runs: 0 }) }]) const onDay = app.usage.summary({ days: 1, today: '2026-01-02', source: 'k' }) expect(onDay.byProvider[0]!.today).toMatchObject({ runs: 2, failed: 1 }) }) it('引擎不支持额度查询时返回 404', async () => { const cookie = { agent_admin: app.adminAuth.login('test-password').id } const r = await server.inject({ method: 'GET', url: '/admin/v1/providers/claude/quota', cookies: cookie }) expect(r.statusCode).toBe(404) fake.quota = async () => ({ provider: 'claude', plan: 'team', windows: [{ key: 'five_hour', label: '5 小时', usedPercent: 27, resetsAt: null }], source: 'live', fetchedAt: '', message: null }) const ok = await server.inject({ method: 'GET', url: '/admin/v1/providers/claude/quota', cookies: cookie }) expect(ok.json()).toMatchObject({ plan: 'team', windows: [{ usedPercent: 27 }] }) delete (fake as { quota?: unknown }).quota }) }) describe('浏览器令牌与 CORS', () => { it('API Key 签发令牌;令牌可调用 /v1,但不能再签发;停用 Key 后令牌失效', async () => { const builtin = app.profiles.getBuiltin().id const k = app.apiKeys.create('browser-test', { allowedProfileIds: [builtin], defaultProfileId: builtin }) const issued = await server.inject({ method: 'POST', url: '/v1/browser-tokens', headers: { authorization: `Bearer ${k.key}` }, payload: { ttlSeconds: 600 } }) expect(issued.statusCode).toBe(201) const { token, expiresAt } = issued.json() expect(token).toMatch(/^bt_/) expect(Date.parse(expiresAt) - Date.now()).toBeGreaterThan(590_000) const bt = { authorization: `Bearer ${token}` } const created = await server.inject({ method: 'POST', url: '/v1/sessions', headers: bt, payload: {} }) expect(created.statusCode).toBe(201) // 令牌创建的会话归属于签发它的 Key const list = (await server.inject({ method: 'GET', url: '/v1/sessions', headers: { authorization: `Bearer ${k.key}` } })).json() expect(list.items.map((s: { id: string }) => s.id)).toContain(created.json().id) expect((await server.inject({ method: 'POST', url: '/v1/browser-tokens', headers: bt, payload: {} })).statusCode).toBe(403) expect((await server.inject({ method: 'POST', url: '/v1/browser-tokens', headers: { authorization: `Bearer ${k.key}` }, payload: { ttlSeconds: 999999 } })).statusCode).toBe(400) // 篡改、过期 expect((await server.inject({ method: 'GET', url: '/v1/profiles', headers: { authorization: `Bearer ${token.slice(0, -2)}xx` } })).statusCode).toBe(401) const old = app.browserTokens.issue(k.apiKey.id, 60, Date.now() - 120_000) expect((await server.inject({ method: 'GET', url: '/v1/profiles', headers: { authorization: `Bearer ${old.token}` } })).statusCode).toBe(401) app.apiKeys.setEnabled(k.apiKey.id, false) const after = await server.inject({ method: 'GET', url: '/v1/profiles', headers: bt }) expect(after.statusCode).toBe(401) expect(after.json().error.code).toBe('invalid_token') }) it('只对设置中允许的来源返回 CORS 头;预检请求;不对后台开放', async () => { app.settings.update({ security: { corsOrigins: ['https://tools.example.com'] } }) const pre = await server.inject({ method: 'OPTIONS', url: '/v1/sessions', headers: { origin: 'https://tools.example.com', 'access-control-request-method': 'POST' } }) expect(pre.statusCode).toBe(204) expect(pre.headers['access-control-allow-origin']).toBe('https://tools.example.com') expect(pre.headers['access-control-allow-headers']).toContain('last-event-id') expect(pre.headers['access-control-allow-credentials']).toBeUndefined() const ok = await server.inject({ method: 'GET', url: '/v1/profiles', headers: { ...auth(), origin: 'https://tools.example.com' } }) expect(ok.headers['access-control-allow-origin']).toBe('https://tools.example.com') const other = await server.inject({ method: 'GET', url: '/v1/profiles', headers: { ...auth(), origin: 'https://evil.example.com' } }) expect(other.headers['access-control-allow-origin']).toBeUndefined() const adminRoute = await server.inject({ method: 'GET', url: '/admin/v1/overview', headers: { origin: 'https://tools.example.com' } }) expect(adminRoute.headers['access-control-allow-origin']).toBeUndefined() expect(() => app.settings.update({ security: { corsOrigins: ['https://x.com/path'] } })).toThrow() app.settings.update({ security: { corsOrigins: [] } }) }) }) describe('Profile 版本历史', () => { it('内容变化才产生新版本;可以查看改动字段并恢复(恢复保存为新版本)', () => { const base = app.profiles.getBuiltin() const { id: _id, version: _v, updatedAt: _u, builtin: _b, ...input } = base const p = app.profiles.create({ ...input, name: '版本测试', isDefault: false }) expect(p.version).toBe(1) const v2 = app.profiles.update(p.id, { ...input, name: '版本测试', isDefault: false, systemPrompt: '第二版' }) expect(v2.version).toBe(2) // 没有变化不产生新版本 expect(app.profiles.update(p.id, { ...input, name: '版本测试', isDefault: false, systemPrompt: '第二版' }).version).toBe(2) app.profiles.update(p.id, { ...input, name: '版本测试3', isDefault: false, systemPrompt: '第二版', model: 'opus' }) const versions = app.profiles.versions(p.id) expect(versions.map(v => [v.version, v.changed])).toEqual([[3, ['name', 'model']], [2, ['systemPrompt']], [1, []]]) const restored = app.profiles.restore(p.id, 1) expect(restored).toMatchObject({ version: 4, name: '版本测试', systemPrompt: input.systemPrompt, model: input.model }) expect(() => app.profiles.restore(p.id, 99)).toThrow(/不存在/) app.profiles.delete(p.id) expect(() => app.profiles.versions(p.id)).toThrow() }) })