feat: 重构为 Monorepo 架构并实现 HTTP Server
架构变更: - 采用 pnpm workspaces 实现 Monorepo 结构 - 将现有代码迁移到 packages/core - 新增 packages/server HTTP 服务层 Server 功能: - REST API: 会话管理、工具管理、配置管理 - WebSocket: 实时双向通信支持 - SSE: 服务端事件推送 - Hono + Bun 作为运行时 API 端点: - GET/POST /api/sessions - 会话 CRUD - GET/POST /api/sessions/:id/messages - 消息管理 - GET /api/sessions/:id/events - SSE 事件流 - WS /api/ws/:sessionId - WebSocket 连接 - GET/POST /api/tools - 工具管理 - GET/PUT /api/config - 配置管理
This commit is contained in:
@@ -0,0 +1,288 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
loadAgentConfig,
|
||||
saveAgentConfig,
|
||||
getConfigTemplate,
|
||||
} from '../../../src/agent/config-loader.js';
|
||||
import type { AgentConfigFile } from '../../../src/agent/types.js';
|
||||
|
||||
// Mock fs
|
||||
vi.mock('fs', () => ({
|
||||
existsSync: vi.fn(),
|
||||
promises: {
|
||||
readFile: vi.fn(),
|
||||
writeFile: vi.fn(),
|
||||
mkdir: vi.fn(),
|
||||
},
|
||||
}));
|
||||
|
||||
// Mock js-yaml
|
||||
vi.mock('js-yaml', () => ({
|
||||
load: vi.fn((content: string) => JSON.parse(content)),
|
||||
dump: vi.fn((obj: unknown) => JSON.stringify(obj, null, 2)),
|
||||
}));
|
||||
|
||||
import * as fs from 'fs';
|
||||
|
||||
describe('loadAgentConfig - 加载 Agent 配置', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('无配置文件时返回 null', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
const config = await loadAgentConfig('/test/project');
|
||||
|
||||
expect(config).toBeNull();
|
||||
});
|
||||
|
||||
it('加载 JSON 配置文件', async () => {
|
||||
const mockConfig: AgentConfigFile = {
|
||||
defaults: {
|
||||
maxSteps: 20,
|
||||
},
|
||||
agents: {
|
||||
'custom-agent': {
|
||||
description: '自定义 Agent',
|
||||
mode: 'subagent',
|
||||
prompt: '你是助手',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(fs.existsSync).mockImplementation((path: unknown) =>
|
||||
String(path).endsWith('.json')
|
||||
);
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(mockConfig));
|
||||
|
||||
const config = await loadAgentConfig('/test/project');
|
||||
|
||||
expect(config).not.toBeNull();
|
||||
expect(config?.defaults?.maxSteps).toBe(20);
|
||||
expect(config?.agents?.['custom-agent']).toBeDefined();
|
||||
});
|
||||
|
||||
it('加载 YAML 配置文件', async () => {
|
||||
const mockConfig: AgentConfigFile = {
|
||||
defaults: {
|
||||
maxSteps: 15,
|
||||
},
|
||||
agents: {},
|
||||
};
|
||||
|
||||
vi.mocked(fs.existsSync).mockImplementation((path: unknown) =>
|
||||
String(path).endsWith('.yaml')
|
||||
);
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(mockConfig));
|
||||
|
||||
const config = await loadAgentConfig('/test/project');
|
||||
|
||||
expect(config).not.toBeNull();
|
||||
});
|
||||
|
||||
it('无效配置格式返回 null', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue('invalid json');
|
||||
|
||||
// 会在解析时失败
|
||||
const config = await loadAgentConfig('/test/project');
|
||||
|
||||
// 取决于实现,可能是 null 或抛出错误后 null
|
||||
expect(config).toBeNull();
|
||||
});
|
||||
|
||||
it('配置搜索顺序', async () => {
|
||||
// 测试搜索多个路径
|
||||
const calls: string[] = [];
|
||||
vi.mocked(fs.existsSync).mockImplementation((path: unknown) => {
|
||||
calls.push(String(path));
|
||||
return false;
|
||||
});
|
||||
|
||||
await loadAgentConfig('/test/project');
|
||||
|
||||
// 应该搜索多个配置路径
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
expect(calls.some(p => p.includes('.ai-assist'))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('saveAgentConfig - 保存 Agent 配置', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
});
|
||||
|
||||
it('保存 JSON 格式配置', async () => {
|
||||
const config: AgentConfigFile = {
|
||||
defaults: {
|
||||
maxSteps: 10,
|
||||
},
|
||||
agents: {},
|
||||
};
|
||||
|
||||
await saveAgentConfig('/test/project', config, 'json');
|
||||
|
||||
expect(fs.promises.mkdir).toHaveBeenCalled();
|
||||
expect(fs.promises.writeFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('agents.json'),
|
||||
expect.any(String),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('保存 YAML 格式配置', async () => {
|
||||
const config: AgentConfigFile = {
|
||||
defaults: {
|
||||
maxSteps: 10,
|
||||
},
|
||||
agents: {},
|
||||
};
|
||||
|
||||
await saveAgentConfig('/test/project', config, 'yaml');
|
||||
|
||||
expect(fs.promises.writeFile).toHaveBeenCalledWith(
|
||||
expect.stringContaining('agents.yaml'),
|
||||
expect.any(String),
|
||||
'utf-8'
|
||||
);
|
||||
});
|
||||
|
||||
it('创建配置目录如果不存在', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(false);
|
||||
|
||||
await saveAgentConfig('/test/project', { agents: {} }, 'json');
|
||||
|
||||
expect(fs.promises.mkdir).toHaveBeenCalledWith(
|
||||
expect.stringContaining('.ai-assist'),
|
||||
{ recursive: true }
|
||||
);
|
||||
});
|
||||
|
||||
it('目录已存在时不重复创建', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
|
||||
await saveAgentConfig('/test/project', { agents: {} }, 'json');
|
||||
|
||||
expect(fs.promises.mkdir).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConfigTemplate - 获取配置模板', () => {
|
||||
it('返回有效的配置模板', () => {
|
||||
const template = getConfigTemplate();
|
||||
|
||||
expect(template).toBeDefined();
|
||||
expect(template.defaults).toBeDefined();
|
||||
expect(template.agents).toBeDefined();
|
||||
});
|
||||
|
||||
it('模板包含默认配置', () => {
|
||||
const template = getConfigTemplate();
|
||||
|
||||
expect(template.defaults?.maxSteps).toBeDefined();
|
||||
expect(template.defaults?.model).toBeDefined();
|
||||
});
|
||||
|
||||
it('模板包含示例 Agent', () => {
|
||||
const template = getConfigTemplate();
|
||||
|
||||
expect(template.agents).toBeDefined();
|
||||
expect(Object.keys(template.agents || {}).length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('示例 Agent 包含必要字段', () => {
|
||||
const template = getConfigTemplate();
|
||||
const agents = template.agents || {};
|
||||
const firstAgent = Object.values(agents)[0];
|
||||
|
||||
expect(firstAgent).toBeDefined();
|
||||
expect(firstAgent.description).toBeDefined();
|
||||
expect(firstAgent.mode).toBeDefined();
|
||||
});
|
||||
|
||||
it('模板包含权限配置示例', () => {
|
||||
const template = getConfigTemplate();
|
||||
|
||||
expect(template.defaults?.permission).toBeDefined();
|
||||
expect(template.defaults?.permission?.bash).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('配置验证', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('空对象是有效配置', async () => {
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify({}));
|
||||
|
||||
const config = await loadAgentConfig('/test/project');
|
||||
|
||||
expect(config).not.toBeNull();
|
||||
});
|
||||
|
||||
it('只有 defaults 的配置有效', async () => {
|
||||
const mockConfig = {
|
||||
defaults: {
|
||||
maxSteps: 10,
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(mockConfig));
|
||||
|
||||
const config = await loadAgentConfig('/test/project');
|
||||
|
||||
expect(config).not.toBeNull();
|
||||
expect(config?.defaults?.maxSteps).toBe(10);
|
||||
});
|
||||
|
||||
it('只有 agents 的配置有效', async () => {
|
||||
const mockConfig = {
|
||||
agents: {
|
||||
'test-agent': {
|
||||
description: 'Test',
|
||||
mode: 'subagent',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(mockConfig));
|
||||
|
||||
const config = await loadAgentConfig('/test/project');
|
||||
|
||||
expect(config).not.toBeNull();
|
||||
expect(config?.agents?.['test-agent']).toBeDefined();
|
||||
});
|
||||
|
||||
it('defaults 为非对象时配置无效', async () => {
|
||||
const mockConfig = {
|
||||
defaults: 'invalid',
|
||||
};
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(mockConfig));
|
||||
|
||||
const config = await loadAgentConfig('/test/project');
|
||||
|
||||
// 应该返回 null(无效配置)
|
||||
expect(config).toBeNull();
|
||||
});
|
||||
|
||||
it('agents 为非对象时配置无效', async () => {
|
||||
const mockConfig = {
|
||||
agents: 'invalid',
|
||||
};
|
||||
|
||||
vi.mocked(fs.existsSync).mockReturnValue(true);
|
||||
vi.mocked(fs.promises.readFile).mockResolvedValue(JSON.stringify(mockConfig));
|
||||
|
||||
const config = await loadAgentConfig('/test/project');
|
||||
|
||||
expect(config).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,429 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import type { LanguageModel } from 'ai';
|
||||
import type { Tool, AgentConfig } from '../../../src/types/index.js';
|
||||
import type { AgentInfo, AgentExecutionContext } from '../../../src/agent/types.js';
|
||||
|
||||
// Mock ai module
|
||||
const mockGenerateText = vi.fn();
|
||||
const mockStreamText = vi.fn();
|
||||
vi.mock('ai', () => ({
|
||||
generateText: (...args: unknown[]) => mockGenerateText(...args),
|
||||
streamText: (...args: unknown[]) => mockStreamText(...args),
|
||||
stepCountIs: vi.fn((n) => n),
|
||||
}));
|
||||
|
||||
// Mock buildZodSchema
|
||||
vi.mock('../../../src/types/index.js', async (importOriginal) => {
|
||||
const actual = await importOriginal() as Record<string, unknown>;
|
||||
return {
|
||||
...actual,
|
||||
buildZodSchema: vi.fn(() => ({})),
|
||||
};
|
||||
});
|
||||
|
||||
// Mock ToolRegistry
|
||||
class MockToolRegistry {
|
||||
getAllTools = vi.fn(() => []);
|
||||
}
|
||||
vi.mock('../../../src/tools/registry.js', () => ({
|
||||
ToolRegistry: MockToolRegistry,
|
||||
}));
|
||||
|
||||
// Mock permission merger
|
||||
const mockCheckBashPermission = vi.fn();
|
||||
vi.mock('../../../src/agent/permission-merger.js', () => ({
|
||||
checkBashPermission: (...args: unknown[]) => mockCheckBashPermission(...args),
|
||||
}));
|
||||
|
||||
// Mock providers
|
||||
const mockGetModelFactory = vi.fn();
|
||||
vi.mock('../../../src/core/providers.js', () => ({
|
||||
getModelFactory: (...args: unknown[]) => mockGetModelFactory(...args),
|
||||
}));
|
||||
|
||||
import { AgentExecutor } from '../../../src/agent/executor.js';
|
||||
|
||||
describe('AgentExecutor - Agent 执行器扩展测试', () => {
|
||||
let executor: AgentExecutor;
|
||||
let mockToolRegistry: MockToolRegistry;
|
||||
let mockModel: LanguageModel;
|
||||
|
||||
const baseConfig: AgentConfig = {
|
||||
provider: 'anthropic',
|
||||
apiKey: 'test-key',
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
maxTokens: 4096,
|
||||
systemPrompt: 'You are a helpful assistant',
|
||||
};
|
||||
|
||||
const basicAgentInfo: AgentInfo = {
|
||||
name: 'test-agent',
|
||||
description: 'Test agent',
|
||||
mode: 'subagent',
|
||||
};
|
||||
|
||||
const createMockTool = (name: string): Tool => ({
|
||||
name,
|
||||
description: `${name} tool`,
|
||||
parameters: {},
|
||||
execute: vi.fn().mockResolvedValue({ success: true, output: 'ok' }),
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockToolRegistry = new MockToolRegistry();
|
||||
mockModel = {} as LanguageModel;
|
||||
|
||||
mockGetModelFactory.mockReturnValue(() => mockModel);
|
||||
|
||||
mockGenerateText.mockResolvedValue({
|
||||
text: 'Response text',
|
||||
steps: [{ text: 'step1' }],
|
||||
});
|
||||
|
||||
mockStreamText.mockReturnValue({
|
||||
textStream: (async function* () {
|
||||
yield 'chunk1';
|
||||
yield 'chunk2';
|
||||
})(),
|
||||
response: Promise.resolve({}),
|
||||
});
|
||||
|
||||
mockCheckBashPermission.mockReturnValue('allow');
|
||||
|
||||
executor = new AgentExecutor(basicAgentInfo, baseConfig, mockToolRegistry as any);
|
||||
});
|
||||
|
||||
describe('constructor - 构造函数', () => {
|
||||
it('初始化执行器', () => {
|
||||
expect(executor).toBeDefined();
|
||||
expect(mockGetModelFactory).toHaveBeenCalledWith('anthropic', expect.any(Object));
|
||||
});
|
||||
|
||||
it('使用 Agent 指定的 provider', () => {
|
||||
const agentWithProvider: AgentInfo = {
|
||||
...basicAgentInfo,
|
||||
model: { provider: 'openai', model: 'gpt-4' },
|
||||
};
|
||||
|
||||
new AgentExecutor(agentWithProvider, baseConfig, mockToolRegistry as any);
|
||||
|
||||
expect(mockGetModelFactory).toHaveBeenCalledWith('openai', expect.any(Object));
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - 执行任务', () => {
|
||||
it('非流式模式返回结果', async () => {
|
||||
const context: AgentExecutionContext = {};
|
||||
|
||||
const result = await executor.execute('test prompt', context);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.text).toBe('Response text');
|
||||
expect(result.steps).toBe(1);
|
||||
expect(mockGenerateText).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('流式模式调用回调', async () => {
|
||||
const onStream = vi.fn();
|
||||
const context: AgentExecutionContext = { onStream };
|
||||
|
||||
const result = await executor.execute('test prompt', context);
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(onStream).toHaveBeenCalledWith('chunk1');
|
||||
expect(onStream).toHaveBeenCalledWith('chunk2');
|
||||
expect(mockStreamText).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('使用 Agent 的系统提示词', async () => {
|
||||
const agentWithPrompt: AgentInfo = {
|
||||
...basicAgentInfo,
|
||||
prompt: 'Custom system prompt',
|
||||
};
|
||||
const customExecutor = new AgentExecutor(agentWithPrompt, baseConfig, mockToolRegistry as any);
|
||||
|
||||
await customExecutor.execute('test', {});
|
||||
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
system: 'Custom system prompt',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('使用基础配置的系统提示词', async () => {
|
||||
await executor.execute('test', {});
|
||||
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
system: 'You are a helpful assistant',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('处理执行错误', async () => {
|
||||
mockGenerateText.mockRejectedValue(new Error('API Error'));
|
||||
|
||||
const result = await executor.execute('test', {});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('API Error');
|
||||
});
|
||||
|
||||
it('包含图片时构建多模态消息', async () => {
|
||||
const context: AgentExecutionContext = {
|
||||
images: [{ data: 'base64data', mimeType: 'image/png' }],
|
||||
};
|
||||
|
||||
await executor.execute('describe this', context);
|
||||
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messages: expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
content: expect.arrayContaining([
|
||||
expect.objectContaining({ type: 'image' }),
|
||||
expect.objectContaining({ type: 'text' }),
|
||||
]),
|
||||
}),
|
||||
]),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('使用 Agent 的 maxSteps 配置', async () => {
|
||||
const agentWithSteps: AgentInfo = {
|
||||
...basicAgentInfo,
|
||||
maxSteps: 20,
|
||||
};
|
||||
const customExecutor = new AgentExecutor(agentWithSteps, baseConfig, mockToolRegistry as any);
|
||||
|
||||
await customExecutor.execute('test', {});
|
||||
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
stopWhen: 20,
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('使用 Agent 的 maxTokens 配置', async () => {
|
||||
const agentWithTokens: AgentInfo = {
|
||||
...basicAgentInfo,
|
||||
model: { model: 'claude-sonnet', maxTokens: 8000 },
|
||||
};
|
||||
const customExecutor = new AgentExecutor(agentWithTokens, baseConfig, mockToolRegistry as any);
|
||||
|
||||
await customExecutor.execute('test', {});
|
||||
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
maxOutputTokens: 8000,
|
||||
})
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getFilteredTools - 工具过滤', () => {
|
||||
it('无配置返回所有工具', async () => {
|
||||
const tools = [createMockTool('read'), createMockTool('write')];
|
||||
mockToolRegistry.getAllTools.mockReturnValue(tools);
|
||||
|
||||
await executor.execute('test', {});
|
||||
|
||||
// 验证所有工具都被使用
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: expect.objectContaining({
|
||||
read: expect.any(Object),
|
||||
write: expect.any(Object),
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('enabled 只保留指定工具', async () => {
|
||||
const agentWithEnabled: AgentInfo = {
|
||||
...basicAgentInfo,
|
||||
tools: { enabled: ['read'] },
|
||||
};
|
||||
const tools = [createMockTool('read'), createMockTool('write')];
|
||||
mockToolRegistry.getAllTools.mockReturnValue(tools);
|
||||
|
||||
const customExecutor = new AgentExecutor(agentWithEnabled, baseConfig, mockToolRegistry as any);
|
||||
await customExecutor.execute('test', {});
|
||||
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
tools: expect.objectContaining({
|
||||
read: expect.any(Object),
|
||||
}),
|
||||
})
|
||||
);
|
||||
// write 不应该存在
|
||||
const call = mockGenerateText.mock.calls[0][0];
|
||||
expect(call.tools.write).toBeUndefined();
|
||||
});
|
||||
|
||||
it('disabled 移除指定工具', async () => {
|
||||
const agentWithDisabled: AgentInfo = {
|
||||
...basicAgentInfo,
|
||||
tools: { disabled: ['write'] },
|
||||
};
|
||||
const tools = [createMockTool('read'), createMockTool('write')];
|
||||
mockToolRegistry.getAllTools.mockReturnValue(tools);
|
||||
|
||||
const customExecutor = new AgentExecutor(agentWithDisabled, baseConfig, mockToolRegistry as any);
|
||||
await customExecutor.execute('test', {});
|
||||
|
||||
const call = mockGenerateText.mock.calls[0][0];
|
||||
expect(call.tools.read).toBeDefined();
|
||||
expect(call.tools.write).toBeUndefined();
|
||||
});
|
||||
|
||||
it('noTask 移除 task 工具', async () => {
|
||||
const agentWithNoTask: AgentInfo = {
|
||||
...basicAgentInfo,
|
||||
tools: { noTask: true },
|
||||
};
|
||||
const tools = [createMockTool('read'), createMockTool('task')];
|
||||
mockToolRegistry.getAllTools.mockReturnValue(tools);
|
||||
|
||||
const customExecutor = new AgentExecutor(agentWithNoTask, baseConfig, mockToolRegistry as any);
|
||||
await customExecutor.execute('test', {});
|
||||
|
||||
const call = mockGenerateText.mock.calls[0][0];
|
||||
expect(call.tools.task).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkToolPermission - 权限检查', () => {
|
||||
it('无权限配置时允许所有', async () => {
|
||||
const tools = [createMockTool('bash')];
|
||||
mockToolRegistry.getAllTools.mockReturnValue(tools);
|
||||
|
||||
await executor.execute('test', {});
|
||||
|
||||
// 工具应该被包含
|
||||
const call = mockGenerateText.mock.calls[0][0];
|
||||
expect(call.tools.bash).toBeDefined();
|
||||
});
|
||||
|
||||
it('bash 命令被禁止时拒绝', async () => {
|
||||
const agentWithBashDeny: AgentInfo = {
|
||||
...basicAgentInfo,
|
||||
permission: {
|
||||
bash: { allow: [], deny: ['rm'], ask: [] },
|
||||
},
|
||||
};
|
||||
mockCheckBashPermission.mockReturnValue('deny');
|
||||
|
||||
const bashTool = createMockTool('bash');
|
||||
mockToolRegistry.getAllTools.mockReturnValue([bashTool]);
|
||||
|
||||
const customExecutor = new AgentExecutor(agentWithBashDeny, baseConfig, mockToolRegistry as any);
|
||||
await customExecutor.execute('test', {});
|
||||
|
||||
// 模拟工具执行
|
||||
const call = mockGenerateText.mock.calls[0][0];
|
||||
const toolResult = await call.tools.bash.execute({ command: 'rm -rf /' });
|
||||
|
||||
expect(toolResult.success).toBe(false);
|
||||
expect(toolResult.error).toContain('权限拒绝');
|
||||
});
|
||||
|
||||
it('文件写入被禁止时拒绝', async () => {
|
||||
const agentWithFileDeny: AgentInfo = {
|
||||
...basicAgentInfo,
|
||||
permission: {
|
||||
file: { write: 'deny', edit: 'allow', delete: 'allow' },
|
||||
},
|
||||
};
|
||||
|
||||
const writeTool = createMockTool('write_file');
|
||||
mockToolRegistry.getAllTools.mockReturnValue([writeTool]);
|
||||
|
||||
const customExecutor = new AgentExecutor(agentWithFileDeny, baseConfig, mockToolRegistry as any);
|
||||
await customExecutor.execute('test', {});
|
||||
|
||||
const call = mockGenerateText.mock.calls[0][0];
|
||||
const toolResult = await call.tools.write_file.execute({ path: '/test.txt' });
|
||||
|
||||
expect(toolResult.success).toBe(false);
|
||||
expect(toolResult.error).toContain('权限拒绝');
|
||||
});
|
||||
|
||||
it('Git 写操作被禁止时拒绝', async () => {
|
||||
const agentWithGitDeny: AgentInfo = {
|
||||
...basicAgentInfo,
|
||||
permission: {
|
||||
git: { write: 'deny' },
|
||||
},
|
||||
};
|
||||
|
||||
const gitCommitTool = createMockTool('git_commit');
|
||||
mockToolRegistry.getAllTools.mockReturnValue([gitCommitTool]);
|
||||
|
||||
const customExecutor = new AgentExecutor(agentWithGitDeny, baseConfig, mockToolRegistry as any);
|
||||
await customExecutor.execute('test', {});
|
||||
|
||||
const call = mockGenerateText.mock.calls[0][0];
|
||||
const toolResult = await call.tools.git_commit.execute({ message: 'test' });
|
||||
|
||||
expect(toolResult.success).toBe(false);
|
||||
expect(toolResult.error).toContain('Git 写操作被禁止');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildMessageContent - 构建消息内容', () => {
|
||||
it('无图片时返回纯文本', async () => {
|
||||
await executor.execute('simple text', {});
|
||||
|
||||
expect(mockGenerateText).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
messages: [{ role: 'user', content: 'simple text' }],
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('有图片时返回多模态内容', async () => {
|
||||
const context: AgentExecutionContext = {
|
||||
images: [
|
||||
{ data: 'img1', mimeType: 'image/png' },
|
||||
{ data: 'img2', mimeType: 'image/jpeg' },
|
||||
],
|
||||
};
|
||||
|
||||
await executor.execute('describe images', context);
|
||||
|
||||
const call = mockGenerateText.mock.calls[0][0];
|
||||
const content = call.messages[0].content;
|
||||
|
||||
expect(Array.isArray(content)).toBe(true);
|
||||
expect(content.length).toBe(3); // 2 images + 1 text
|
||||
expect(content[0].type).toBe('image');
|
||||
expect(content[1].type).toBe('image');
|
||||
expect(content[2].type).toBe('text');
|
||||
});
|
||||
});
|
||||
|
||||
describe('sessionId 处理', () => {
|
||||
it('有 parentSessionId 时使用它', async () => {
|
||||
const context: AgentExecutionContext = {
|
||||
parentSessionId: 'parent-123',
|
||||
};
|
||||
|
||||
const result = await executor.execute('test', context);
|
||||
|
||||
expect(result.sessionId).toBe('parent-123');
|
||||
});
|
||||
|
||||
it('无 parentSessionId 时使用 standalone', async () => {
|
||||
const result = await executor.execute('test', {});
|
||||
|
||||
expect(result.sessionId).toBe('standalone');
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,363 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
// Mock state
|
||||
const mockState = {
|
||||
generateTextResult: {
|
||||
text: '任务完成',
|
||||
steps: [{ toolCalls: [] }],
|
||||
},
|
||||
streamTextResult: {
|
||||
textStream: (async function* () {
|
||||
yield '流式';
|
||||
yield '输出';
|
||||
})(),
|
||||
response: Promise.resolve({}),
|
||||
},
|
||||
};
|
||||
|
||||
// Mock @ai-sdk/anthropic
|
||||
vi.mock('@ai-sdk/anthropic', () => ({
|
||||
createAnthropic: vi.fn(() => vi.fn(() => ({ modelId: 'claude-3' }))),
|
||||
}));
|
||||
|
||||
// Mock @ai-sdk/deepseek
|
||||
vi.mock('@ai-sdk/deepseek', () => ({
|
||||
createDeepSeek: vi.fn(() => vi.fn(() => ({ modelId: 'deepseek' }))),
|
||||
}));
|
||||
|
||||
// Mock ai package
|
||||
vi.mock('ai', () => ({
|
||||
generateText: vi.fn(async () => mockState.generateTextResult),
|
||||
streamText: vi.fn(() => mockState.streamTextResult),
|
||||
stepCountIs: vi.fn(() => () => false),
|
||||
}));
|
||||
|
||||
// Mock permission-merger
|
||||
vi.mock('../../../src/agent/permission-merger.js', () => ({
|
||||
checkBashPermission: vi.fn(() => 'allow'),
|
||||
}));
|
||||
|
||||
// Mock types
|
||||
vi.mock('../../../src/types/index.js', () => ({
|
||||
buildZodSchema: vi.fn(() => ({})),
|
||||
}));
|
||||
|
||||
import { AgentExecutor } from '../../../src/agent/executor.js';
|
||||
import { generateText, streamText } from 'ai';
|
||||
import { checkBashPermission } from '../../../src/agent/permission-merger.js';
|
||||
|
||||
describe('AgentExecutor - Agent 执行器', () => {
|
||||
let executor: AgentExecutor;
|
||||
let mockToolRegistry: any;
|
||||
let mockAgentInfo: any;
|
||||
let mockBaseConfig: any;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
|
||||
mockToolRegistry = {
|
||||
getAllTools: vi.fn(() => [
|
||||
{
|
||||
name: 'bash',
|
||||
description: '执行命令',
|
||||
parameters: { command: { type: 'string', required: true } },
|
||||
execute: vi.fn().mockResolvedValue({ success: true, output: 'ok' }),
|
||||
},
|
||||
{
|
||||
name: 'read_file',
|
||||
description: '读取文件',
|
||||
parameters: { path: { type: 'string', required: true } },
|
||||
execute: vi.fn().mockResolvedValue({ success: true, output: 'content' }),
|
||||
},
|
||||
{
|
||||
name: 'task',
|
||||
description: '子任务',
|
||||
parameters: { prompt: { type: 'string', required: true } },
|
||||
execute: vi.fn().mockResolvedValue({ success: true, output: 'done' }),
|
||||
},
|
||||
]),
|
||||
};
|
||||
|
||||
mockAgentInfo = {
|
||||
name: 'test-agent',
|
||||
description: '测试 Agent',
|
||||
mode: 'subagent',
|
||||
prompt: '你是测试助手',
|
||||
};
|
||||
|
||||
mockBaseConfig = {
|
||||
provider: 'anthropic',
|
||||
model: 'claude-3-sonnet',
|
||||
apiKey: 'test-api-key',
|
||||
maxTokens: 4096,
|
||||
systemPrompt: '默认系统提示词',
|
||||
};
|
||||
|
||||
// 重置 mock 结果
|
||||
mockState.generateTextResult = {
|
||||
text: '任务完成',
|
||||
steps: [{ toolCalls: [] }],
|
||||
};
|
||||
|
||||
executor = new AgentExecutor(mockAgentInfo, mockBaseConfig, mockToolRegistry);
|
||||
});
|
||||
|
||||
describe('构造函数', () => {
|
||||
it('成功创建 Anthropic provider', () => {
|
||||
const exec = new AgentExecutor(mockAgentInfo, mockBaseConfig, mockToolRegistry);
|
||||
expect(exec).toBeDefined();
|
||||
});
|
||||
|
||||
it('成功创建 DeepSeek provider', () => {
|
||||
const config = { ...mockBaseConfig, provider: 'deepseek' as const };
|
||||
const exec = new AgentExecutor(mockAgentInfo, config, mockToolRegistry);
|
||||
expect(exec).toBeDefined();
|
||||
});
|
||||
|
||||
it('不支持的 provider 抛出错误', () => {
|
||||
const config = { ...mockBaseConfig, provider: 'unknown' as any };
|
||||
expect(() => new AgentExecutor(mockAgentInfo, config, mockToolRegistry)).toThrow('不支持的 provider');
|
||||
});
|
||||
|
||||
it('使用 Agent 指定的 provider', () => {
|
||||
const agentInfo = {
|
||||
...mockAgentInfo,
|
||||
model: { provider: 'deepseek' as const, model: 'deepseek-chat' },
|
||||
};
|
||||
const exec = new AgentExecutor(agentInfo, mockBaseConfig, mockToolRegistry);
|
||||
expect(exec).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - 执行', () => {
|
||||
it('非流式模式成功执行', async () => {
|
||||
const result = await executor.execute('测试任务', {});
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.text).toBe('任务完成');
|
||||
expect(generateText).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('流式模式成功执行', async () => {
|
||||
const onStream = vi.fn();
|
||||
|
||||
// 重置流式结果
|
||||
mockState.streamTextResult = {
|
||||
textStream: (async function* () {
|
||||
yield '流式';
|
||||
yield '输出';
|
||||
})(),
|
||||
response: Promise.resolve({}),
|
||||
};
|
||||
|
||||
const result = await executor.execute('测试任务', { onStream });
|
||||
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.text).toBe('流式输出');
|
||||
expect(streamText).toHaveBeenCalled();
|
||||
expect(onStream).toHaveBeenCalledWith('流式');
|
||||
expect(onStream).toHaveBeenCalledWith('输出');
|
||||
});
|
||||
|
||||
it('执行失败返回错误', async () => {
|
||||
vi.mocked(generateText).mockRejectedValueOnce(new Error('API 错误'));
|
||||
|
||||
const result = await executor.execute('测试任务', {});
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('API 错误');
|
||||
});
|
||||
|
||||
it('传递父会话 ID', async () => {
|
||||
const result = await executor.execute('测试任务', {
|
||||
parentSessionId: 'parent-123',
|
||||
});
|
||||
|
||||
expect(result.sessionId).toBe('parent-123');
|
||||
});
|
||||
|
||||
it('无父会话 ID 使用 standalone', async () => {
|
||||
const result = await executor.execute('测试任务', {});
|
||||
|
||||
expect(result.sessionId).toBe('standalone');
|
||||
});
|
||||
});
|
||||
|
||||
describe('工具过滤', () => {
|
||||
it('无配置返回所有工具', async () => {
|
||||
await executor.execute('测试', {});
|
||||
|
||||
const call = vi.mocked(generateText).mock.calls[0][0];
|
||||
expect(Object.keys(call.tools || {})).toHaveLength(3);
|
||||
});
|
||||
|
||||
it('enabled 配置只保留指定工具', async () => {
|
||||
mockAgentInfo.tools = { enabled: ['bash'] };
|
||||
executor = new AgentExecutor(mockAgentInfo, mockBaseConfig, mockToolRegistry);
|
||||
|
||||
await executor.execute('测试', {});
|
||||
|
||||
const call = vi.mocked(generateText).mock.calls[0][0];
|
||||
expect(Object.keys(call.tools || {})).toContain('bash');
|
||||
expect(Object.keys(call.tools || {})).not.toContain('read_file');
|
||||
});
|
||||
|
||||
it('disabled 配置移除指定工具', async () => {
|
||||
mockAgentInfo.tools = { disabled: ['task'] };
|
||||
executor = new AgentExecutor(mockAgentInfo, mockBaseConfig, mockToolRegistry);
|
||||
|
||||
await executor.execute('测试', {});
|
||||
|
||||
const call = vi.mocked(generateText).mock.calls[0][0];
|
||||
expect(Object.keys(call.tools || {})).not.toContain('task');
|
||||
});
|
||||
|
||||
it('noTask 配置移除 task 工具', async () => {
|
||||
mockAgentInfo.tools = { noTask: true };
|
||||
executor = new AgentExecutor(mockAgentInfo, mockBaseConfig, mockToolRegistry);
|
||||
|
||||
await executor.execute('测试', {});
|
||||
|
||||
const call = vi.mocked(generateText).mock.calls[0][0];
|
||||
expect(Object.keys(call.tools || {})).not.toContain('task');
|
||||
});
|
||||
});
|
||||
|
||||
describe('权限检查', () => {
|
||||
it('bash 命令被拒绝', async () => {
|
||||
vi.mocked(checkBashPermission).mockReturnValue('deny');
|
||||
mockAgentInfo.permission = { bash: { deny: ['rm *'] } };
|
||||
executor = new AgentExecutor(mockAgentInfo, mockBaseConfig, mockToolRegistry);
|
||||
|
||||
// 获取工具并执行
|
||||
await executor.execute('测试', {});
|
||||
|
||||
const call = vi.mocked(generateText).mock.calls[0][0];
|
||||
const bashTool = call.tools?.bash;
|
||||
|
||||
if (bashTool && 'execute' in bashTool) {
|
||||
const result = await bashTool.execute({ command: 'rm -rf /' });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('权限拒绝');
|
||||
}
|
||||
});
|
||||
|
||||
it('文件写入被拒绝', async () => {
|
||||
mockAgentInfo.permission = { file: { write: 'deny' } };
|
||||
|
||||
// 添加 write_file 工具
|
||||
mockToolRegistry.getAllTools.mockReturnValue([
|
||||
{
|
||||
name: 'write_file',
|
||||
description: '写文件',
|
||||
parameters: { path: { type: 'string', required: true } },
|
||||
execute: vi.fn().mockResolvedValue({ success: true, output: 'ok' }),
|
||||
},
|
||||
]);
|
||||
|
||||
executor = new AgentExecutor(mockAgentInfo, mockBaseConfig, mockToolRegistry);
|
||||
await executor.execute('测试', {});
|
||||
|
||||
const call = vi.mocked(generateText).mock.calls[0][0];
|
||||
const writeTool = call.tools?.write_file;
|
||||
|
||||
if (writeTool && 'execute' in writeTool) {
|
||||
const result = await writeTool.execute({ path: '/test.txt', content: 'test' });
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('权限拒绝');
|
||||
}
|
||||
});
|
||||
|
||||
it('Git 写操作被拒绝', async () => {
|
||||
mockAgentInfo.permission = { git: { write: 'deny' } };
|
||||
|
||||
mockToolRegistry.getAllTools.mockReturnValue([
|
||||
{
|
||||
name: 'git_push',
|
||||
description: 'Git push',
|
||||
parameters: {},
|
||||
execute: vi.fn().mockResolvedValue({ success: true, output: 'ok' }),
|
||||
},
|
||||
]);
|
||||
|
||||
executor = new AgentExecutor(mockAgentInfo, mockBaseConfig, mockToolRegistry);
|
||||
await executor.execute('测试', {});
|
||||
|
||||
const call = vi.mocked(generateText).mock.calls[0][0];
|
||||
const gitTool = call.tools?.git_push;
|
||||
|
||||
if (gitTool && 'execute' in gitTool) {
|
||||
const result = await gitTool.execute({});
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain('Git 写操作被禁止');
|
||||
}
|
||||
});
|
||||
|
||||
it('无权限配置允许所有操作', async () => {
|
||||
// 无 permission 配置
|
||||
delete mockAgentInfo.permission;
|
||||
executor = new AgentExecutor(mockAgentInfo, mockBaseConfig, mockToolRegistry);
|
||||
await executor.execute('测试', {});
|
||||
|
||||
const call = vi.mocked(generateText).mock.calls[0][0];
|
||||
const bashTool = call.tools?.bash;
|
||||
|
||||
if (bashTool && 'execute' in bashTool) {
|
||||
const result = await bashTool.execute({ command: 'ls' });
|
||||
expect(result.success).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('系统提示词', () => {
|
||||
it('使用 Agent 自定义提示词', async () => {
|
||||
mockAgentInfo.prompt = '自定义提示词';
|
||||
executor = new AgentExecutor(mockAgentInfo, mockBaseConfig, mockToolRegistry);
|
||||
|
||||
await executor.execute('测试', {});
|
||||
|
||||
const call = vi.mocked(generateText).mock.calls[0][0];
|
||||
expect(call.system).toBe('自定义提示词');
|
||||
});
|
||||
|
||||
it('无自定义提示词使用基础配置', async () => {
|
||||
delete mockAgentInfo.prompt;
|
||||
executor = new AgentExecutor(mockAgentInfo, mockBaseConfig, mockToolRegistry);
|
||||
|
||||
await executor.execute('测试', {});
|
||||
|
||||
const call = vi.mocked(generateText).mock.calls[0][0];
|
||||
expect(call.system).toBe('默认系统提示词');
|
||||
});
|
||||
});
|
||||
|
||||
describe('模型配置', () => {
|
||||
it('使用 Agent 指定的模型', async () => {
|
||||
mockAgentInfo.model = { model: 'claude-3-opus' };
|
||||
executor = new AgentExecutor(mockAgentInfo, mockBaseConfig, mockToolRegistry);
|
||||
|
||||
await executor.execute('测试', {});
|
||||
|
||||
expect(generateText).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('使用 Agent 指定的 maxSteps', async () => {
|
||||
mockAgentInfo.maxSteps = 5;
|
||||
executor = new AgentExecutor(mockAgentInfo, mockBaseConfig, mockToolRegistry);
|
||||
|
||||
await executor.execute('测试', {});
|
||||
|
||||
expect(generateText).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('使用 Agent 指定的 maxTokens', async () => {
|
||||
mockAgentInfo.model = { maxTokens: 8192 };
|
||||
executor = new AgentExecutor(mockAgentInfo, mockBaseConfig, mockToolRegistry);
|
||||
|
||||
await executor.execute('测试', {});
|
||||
|
||||
const call = vi.mocked(generateText).mock.calls[0][0];
|
||||
expect(call.maxOutputTokens).toBe(8192);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,326 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { AgentManager, resetAgentManager, getAgentManager } from '../../../src/agent/manager.js';
|
||||
import type { AgentInfo } from '../../../src/agent/types.js';
|
||||
import type { AgentConfig } from '../../../src/types/index.js';
|
||||
|
||||
// Mock AgentExecutor - 使用延迟执行
|
||||
vi.mock('../../../src/agent/executor.js', () => ({
|
||||
AgentExecutor: vi.fn().mockImplementation(() => ({
|
||||
execute: vi.fn().mockImplementation(async () => {
|
||||
// 延迟 200ms,让测试能在执行完成前检查状态
|
||||
await new Promise((r) => setTimeout(r, 200));
|
||||
return {
|
||||
success: true,
|
||||
text: '任务完成',
|
||||
steps: 3,
|
||||
sessionId: 'test-session',
|
||||
};
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
describe('AgentManager - Agent 管理器', () => {
|
||||
let manager: AgentManager;
|
||||
const mockAgentInfo: AgentInfo = {
|
||||
name: 'test-agent',
|
||||
description: '测试 Agent',
|
||||
mode: 'subagent',
|
||||
prompt: '你是一个测试助手',
|
||||
};
|
||||
|
||||
const mockConfig: AgentConfig = {
|
||||
provider: 'anthropic',
|
||||
apiKey: 'test-key',
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
maxTokens: 4096,
|
||||
systemPrompt: '测试系统提示词',
|
||||
};
|
||||
|
||||
const mockContext = {
|
||||
parentSessionId: 'parent-123',
|
||||
workdir: '/test',
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
resetAgentManager();
|
||||
manager = new AgentManager();
|
||||
});
|
||||
|
||||
describe('getAgentManager - 单例获取', () => {
|
||||
it('返回相同的实例', () => {
|
||||
resetAgentManager();
|
||||
const instance1 = getAgentManager();
|
||||
const instance2 = getAgentManager();
|
||||
expect(instance1).toBe(instance2);
|
||||
});
|
||||
|
||||
it('resetAgentManager 重置实例', () => {
|
||||
const instance1 = getAgentManager();
|
||||
resetAgentManager();
|
||||
const instance2 = getAgentManager();
|
||||
expect(instance1).not.toBe(instance2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('runInBackground - 后台运行', () => {
|
||||
it('返回 agent ID', async () => {
|
||||
const agentId = await manager.runInBackground(
|
||||
mockAgentInfo,
|
||||
'测试任务',
|
||||
'执行测试',
|
||||
mockConfig,
|
||||
{} as any,
|
||||
mockContext
|
||||
);
|
||||
|
||||
expect(agentId).toBeDefined();
|
||||
expect(typeof agentId).toBe('string');
|
||||
expect(agentId.length).toBe(8); // 短 ID
|
||||
});
|
||||
|
||||
it('创建初始状态的记录', async () => {
|
||||
const agentId = await manager.runInBackground(
|
||||
mockAgentInfo,
|
||||
'测试任务',
|
||||
'执行测试',
|
||||
mockConfig,
|
||||
{} as any,
|
||||
mockContext
|
||||
);
|
||||
|
||||
const agent = manager.getAgent(agentId);
|
||||
expect(agent).not.toBeNull();
|
||||
expect(agent!.agentName).toBe('test-agent');
|
||||
expect(agent!.description).toBe('测试任务');
|
||||
expect(agent!.prompt).toBe('执行测试');
|
||||
expect(agent!.startedAt).toBeInstanceOf(Date);
|
||||
// 由于是异步执行,初始状态应该是 running
|
||||
// 但由于测试环境的原因,可能已经变成其他状态
|
||||
expect(['running', 'completed', 'failed']).toContain(agent!.status);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgent - 获取 Agent', () => {
|
||||
it('返回存在的 Agent', async () => {
|
||||
const agentId = await manager.runInBackground(
|
||||
mockAgentInfo,
|
||||
'测试任务',
|
||||
'执行测试',
|
||||
mockConfig,
|
||||
{} as any,
|
||||
mockContext
|
||||
);
|
||||
|
||||
const agent = manager.getAgent(agentId);
|
||||
expect(agent).not.toBeNull();
|
||||
expect(agent!.id).toBe(agentId);
|
||||
});
|
||||
|
||||
it('返回 null 当 Agent 不存在', () => {
|
||||
const agent = manager.getAgent('non-existent');
|
||||
expect(agent).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('getAgentOutput - 获取输出', () => {
|
||||
it('返回 null 当 Agent 不存在', async () => {
|
||||
const result = await manager.getAgentOutput('non-existent', false);
|
||||
expect(result).toBeNull();
|
||||
});
|
||||
|
||||
it('非阻塞模式立即返回状态', async () => {
|
||||
const agentId = await manager.runInBackground(
|
||||
mockAgentInfo,
|
||||
'测试任务',
|
||||
'执行测试',
|
||||
mockConfig,
|
||||
{} as any,
|
||||
mockContext
|
||||
);
|
||||
|
||||
const result = await manager.getAgentOutput(agentId, false);
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.id).toBe(agentId);
|
||||
});
|
||||
|
||||
it('阻塞模式等待完成', async () => {
|
||||
const agentId = await manager.runInBackground(
|
||||
mockAgentInfo,
|
||||
'测试任务',
|
||||
'执行测试',
|
||||
mockConfig,
|
||||
{} as any,
|
||||
mockContext
|
||||
);
|
||||
|
||||
// 使用阻塞模式等待
|
||||
const result = await manager.getAgentOutput(agentId, true, 5);
|
||||
expect(result).not.toBeNull();
|
||||
// 等待后应该是完成状态
|
||||
expect(['completed', 'failed']).toContain(result!.status);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listAgents - 列出所有 Agent', () => {
|
||||
it('返回所有 Agent', async () => {
|
||||
await manager.runInBackground(
|
||||
mockAgentInfo,
|
||||
'任务1',
|
||||
'执行1',
|
||||
mockConfig,
|
||||
{} as any,
|
||||
mockContext
|
||||
);
|
||||
await manager.runInBackground(
|
||||
mockAgentInfo,
|
||||
'任务2',
|
||||
'执行2',
|
||||
mockConfig,
|
||||
{} as any,
|
||||
mockContext
|
||||
);
|
||||
|
||||
const agents = manager.listAgents();
|
||||
expect(agents.length).toBe(2);
|
||||
});
|
||||
|
||||
it('空管理器返回空数组', () => {
|
||||
const agents = manager.listAgents();
|
||||
expect(agents).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listRunningAgents - 列出运行中的 Agent', () => {
|
||||
it('返回数组', async () => {
|
||||
await manager.runInBackground(
|
||||
mockAgentInfo,
|
||||
'任务1',
|
||||
'执行1',
|
||||
mockConfig,
|
||||
{} as any,
|
||||
mockContext
|
||||
);
|
||||
|
||||
const running = manager.listRunningAgents();
|
||||
expect(Array.isArray(running)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('cleanup - 清理', () => {
|
||||
it('清理已完成的 Agent', async () => {
|
||||
const agentId = await manager.runInBackground(
|
||||
mockAgentInfo,
|
||||
'任务',
|
||||
'执行',
|
||||
mockConfig,
|
||||
{} as any,
|
||||
mockContext
|
||||
);
|
||||
|
||||
// 等待一段时间让 Agent 完成(无论成功或失败)
|
||||
await new Promise((r) => setTimeout(r, 100));
|
||||
|
||||
// 确认 Agent 不再是 running 状态
|
||||
const beforeCleanup = manager.getAgent(agentId);
|
||||
expect(beforeCleanup).not.toBeNull();
|
||||
expect(['completed', 'failed']).toContain(beforeCleanup?.status);
|
||||
|
||||
// 设置 maxAge 为 0,立即清理
|
||||
manager.cleanup(0);
|
||||
|
||||
const agent = manager.getAgent(agentId);
|
||||
expect(agent).toBeNull();
|
||||
});
|
||||
|
||||
it('不清理运行中的 Agent', async () => {
|
||||
const agentId = await manager.runInBackground(
|
||||
mockAgentInfo,
|
||||
'任务',
|
||||
'执行',
|
||||
mockConfig,
|
||||
{} as any,
|
||||
mockContext
|
||||
);
|
||||
|
||||
// 立即清理(不等待)
|
||||
manager.cleanup(0);
|
||||
|
||||
// cleanup 应该不会报错
|
||||
// Agent 可能还在运行(取决于执行速度)
|
||||
// 这里只验证 cleanup 不会崩溃
|
||||
expect(true).toBe(true);
|
||||
});
|
||||
|
||||
it('使用默认 maxAge', async () => {
|
||||
const agentId = await manager.runInBackground(
|
||||
mockAgentInfo,
|
||||
'任务',
|
||||
'执行',
|
||||
mockConfig,
|
||||
{} as any,
|
||||
mockContext
|
||||
);
|
||||
|
||||
// 等待完成
|
||||
await new Promise((r) => setTimeout(r, 300));
|
||||
|
||||
// 使用默认 maxAge(1 小时),不应该清理刚完成的
|
||||
manager.cleanup();
|
||||
|
||||
// Agent 应该还在(因为未超过 1 小时)
|
||||
const agent = manager.getAgent(agentId);
|
||||
expect(agent).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('完成回调', () => {
|
||||
it('多个等待者都收到通知', async () => {
|
||||
const agentId = await manager.runInBackground(
|
||||
mockAgentInfo,
|
||||
'任务',
|
||||
'执行',
|
||||
mockConfig,
|
||||
{} as any,
|
||||
mockContext
|
||||
);
|
||||
|
||||
// 并发等待
|
||||
const [result1, result2] = await Promise.all([
|
||||
manager.getAgentOutput(agentId, true, 5),
|
||||
manager.getAgentOutput(agentId, true, 5),
|
||||
]);
|
||||
|
||||
expect(result1).not.toBeNull();
|
||||
expect(result2).not.toBeNull();
|
||||
expect(result1!.id).toBe(agentId);
|
||||
expect(result2!.id).toBe(agentId);
|
||||
});
|
||||
});
|
||||
|
||||
describe('已完成 Agent 的阻塞查询', () => {
|
||||
it('已完成的 Agent 阻塞查询立即返回', async () => {
|
||||
const agentId = await manager.runInBackground(
|
||||
mockAgentInfo,
|
||||
'任务',
|
||||
'执行',
|
||||
mockConfig,
|
||||
{} as any,
|
||||
mockContext
|
||||
);
|
||||
|
||||
// 等待完成
|
||||
await manager.getAgentOutput(agentId, true, 5);
|
||||
|
||||
// 再次阻塞查询应立即返回
|
||||
const startTime = Date.now();
|
||||
const result = await manager.getAgentOutput(agentId, true, 10);
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
expect(result).not.toBeNull();
|
||||
expect(result!.status).not.toBe('running');
|
||||
// 应该立即返回,不需要等 10 秒
|
||||
expect(elapsed).toBeLessThan(1000);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,234 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
mergePermissions,
|
||||
matchRule,
|
||||
checkBashPermission,
|
||||
checkFilePathPermission,
|
||||
SYSTEM_DEFAULT_PERMISSION,
|
||||
} from '../../../src/agent/permission-merger.js';
|
||||
import type { AgentPermission, AgentBashPermission } from '../../../src/agent/types.js';
|
||||
|
||||
describe('matchRule - 命令规则匹配', () => {
|
||||
describe('通配符 * 匹配', () => {
|
||||
it('git diff* 匹配 git diff --staged', () => {
|
||||
expect(matchRule('git diff --staged', 'git diff*')).toBe(true);
|
||||
});
|
||||
|
||||
it('git diff* 不匹配 git status', () => {
|
||||
expect(matchRule('git status', 'git diff*')).toBe(false);
|
||||
});
|
||||
|
||||
it('rm -rf* 匹配危险命令', () => {
|
||||
expect(matchRule('rm -rf /', 'rm -rf*')).toBe(true);
|
||||
expect(matchRule('rm -rf /home/user', 'rm -rf*')).toBe(true);
|
||||
});
|
||||
|
||||
it('rm -rf* 不匹配普通 rm', () => {
|
||||
expect(matchRule('rm file.txt', 'rm -rf*')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('精确匹配', () => {
|
||||
it('pwd 精确匹配', () => {
|
||||
expect(matchRule('pwd', 'pwd')).toBe(true);
|
||||
});
|
||||
|
||||
it('pwd 不匹配带参数', () => {
|
||||
expect(matchRule('pwd /home', 'pwd')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('大小写不敏感', () => {
|
||||
it('忽略大小写', () => {
|
||||
expect(matchRule('GIT DIFF', 'git diff*')).toBe(true);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkBashPermission - Bash 权限检查', () => {
|
||||
it('禁用时返回 deny', () => {
|
||||
const permission: AgentBashPermission = { enabled: false };
|
||||
expect(checkBashPermission('ls', permission)).toBe('deny');
|
||||
});
|
||||
|
||||
it('匹配 allow 规则', () => {
|
||||
const permission: AgentBashPermission = {
|
||||
enabled: true,
|
||||
rules: [{ pattern: 'ls *', action: 'allow' }],
|
||||
default: 'deny',
|
||||
};
|
||||
expect(checkBashPermission('ls -la', permission)).toBe('allow');
|
||||
});
|
||||
|
||||
it('匹配 deny 规则', () => {
|
||||
const permission: AgentBashPermission = {
|
||||
enabled: true,
|
||||
rules: [{ pattern: 'rm -rf*', action: 'deny' }],
|
||||
default: 'allow',
|
||||
};
|
||||
expect(checkBashPermission('rm -rf /', permission)).toBe('deny');
|
||||
});
|
||||
|
||||
it('无匹配时返回默认值', () => {
|
||||
const permission: AgentBashPermission = {
|
||||
enabled: true,
|
||||
rules: [],
|
||||
default: 'ask',
|
||||
};
|
||||
expect(checkBashPermission('npm install', permission)).toBe('ask');
|
||||
});
|
||||
|
||||
it('规则优先级:先匹配的规则优先', () => {
|
||||
const permission: AgentBashPermission = {
|
||||
enabled: true,
|
||||
rules: [
|
||||
{ pattern: 'git push --force*', action: 'deny' },
|
||||
{ pattern: 'git push*', action: 'ask' },
|
||||
],
|
||||
default: 'allow',
|
||||
};
|
||||
expect(checkBashPermission('git push --force origin', permission)).toBe('deny');
|
||||
expect(checkBashPermission('git push origin', permission)).toBe('ask');
|
||||
});
|
||||
});
|
||||
|
||||
describe('checkFilePathPermission - 文件路径权限检查', () => {
|
||||
it('无敏感路径规则返回 null', () => {
|
||||
expect(checkFilePathPermission('/home/user/file.txt', undefined)).toBeNull();
|
||||
expect(checkFilePathPermission('/home/user/file.txt', [])).toBeNull();
|
||||
});
|
||||
|
||||
it('匹配敏感路径规则', () => {
|
||||
const rules = [
|
||||
{ pattern: '*.env', action: 'deny' as const },
|
||||
{ pattern: '/etc/*', action: 'ask' as const },
|
||||
];
|
||||
expect(checkFilePathPermission('.env', rules)).toBe('deny');
|
||||
expect(checkFilePathPermission('/etc/passwd', rules)).toBe('ask');
|
||||
});
|
||||
|
||||
it('不匹配时返回 null', () => {
|
||||
const rules = [{ pattern: '*.env', action: 'deny' as const }];
|
||||
expect(checkFilePathPermission('config.json', rules)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe('mergePermissions - 权限合并', () => {
|
||||
describe('优先级:Agent > Global > System', () => {
|
||||
it('Agent 配置覆盖 Global 和 System', () => {
|
||||
const system: AgentPermission = { file: { read: 'allow', write: 'ask' } };
|
||||
const global: AgentPermission = { file: { write: 'allow' } };
|
||||
const agent: AgentPermission = { file: { write: 'deny' } };
|
||||
|
||||
const merged = mergePermissions(system, global, agent);
|
||||
expect(merged.file?.write).toBe('deny');
|
||||
});
|
||||
|
||||
it('Global 配置覆盖 System', () => {
|
||||
const system: AgentPermission = { file: { write: 'ask' } };
|
||||
const global: AgentPermission = { file: { write: 'allow' } };
|
||||
|
||||
const merged = mergePermissions(system, global, undefined);
|
||||
expect(merged.file?.write).toBe('allow');
|
||||
});
|
||||
|
||||
it('无覆盖时使用 System 默认值', () => {
|
||||
const merged = mergePermissions(SYSTEM_DEFAULT_PERMISSION, undefined, undefined);
|
||||
expect(merged.file?.read).toBe('allow');
|
||||
expect(merged.file?.write).toBe('ask');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Bash 权限合并', () => {
|
||||
it('Agent 禁用 bash 覆盖全局', () => {
|
||||
const system: AgentPermission = { bash: { enabled: true } };
|
||||
const global: AgentPermission = { bash: { enabled: true } };
|
||||
const agent: AgentPermission = { bash: { enabled: false } };
|
||||
|
||||
const merged = mergePermissions(system, global, agent);
|
||||
expect(merged.bash?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('Global 禁用 bash 且 Agent 未覆盖', () => {
|
||||
const system: AgentPermission = { bash: { enabled: true } };
|
||||
const global: AgentPermission = { bash: { enabled: false } };
|
||||
|
||||
const merged = mergePermissions(system, global, undefined);
|
||||
expect(merged.bash?.enabled).toBe(false);
|
||||
});
|
||||
|
||||
it('规则按优先级合并:Agent > Global > System', () => {
|
||||
const system: AgentPermission = {
|
||||
bash: { rules: [{ pattern: 'ls *', action: 'allow' }] },
|
||||
};
|
||||
const global: AgentPermission = {
|
||||
bash: { rules: [{ pattern: 'cat *', action: 'allow' }] },
|
||||
};
|
||||
const agent: AgentPermission = {
|
||||
bash: { rules: [{ pattern: 'rm *', action: 'deny' }] },
|
||||
};
|
||||
|
||||
const merged = mergePermissions(system, global, agent);
|
||||
// Agent 规则在前
|
||||
expect(merged.bash?.rules?.[0].pattern).toBe('rm *');
|
||||
expect(merged.bash?.rules?.[1].pattern).toBe('cat *');
|
||||
expect(merged.bash?.rules?.[2].pattern).toBe('ls *');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Git 权限合并', () => {
|
||||
it('合并所有级别的 Git 权限', () => {
|
||||
const system: AgentPermission = { git: { read: 'allow', write: 'ask', dangerous: 'deny' } };
|
||||
const agent: AgentPermission = { git: { write: 'deny' } };
|
||||
|
||||
const merged = mergePermissions(system, undefined, agent);
|
||||
expect(merged.git?.read).toBe('allow'); // 来自 system
|
||||
expect(merged.git?.write).toBe('deny'); // 来自 agent
|
||||
expect(merged.git?.dangerous).toBe('deny'); // 来自 system
|
||||
});
|
||||
});
|
||||
|
||||
describe('Web 权限合并', () => {
|
||||
it('合并 Web 权限', () => {
|
||||
const system: AgentPermission = { web: 'ask' };
|
||||
const agent: AgentPermission = { web: 'deny' };
|
||||
|
||||
const merged = mergePermissions(system, undefined, agent);
|
||||
expect(merged.web).toBe('deny');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('SYSTEM_DEFAULT_PERMISSION - 系统默认权限', () => {
|
||||
it('文件读取默认允许', () => {
|
||||
expect(SYSTEM_DEFAULT_PERMISSION.file?.read).toBe('allow');
|
||||
});
|
||||
|
||||
it('文件写入默认询问', () => {
|
||||
expect(SYSTEM_DEFAULT_PERMISSION.file?.write).toBe('ask');
|
||||
});
|
||||
|
||||
it('Bash 默认启用', () => {
|
||||
expect(SYSTEM_DEFAULT_PERMISSION.bash?.enabled).toBe(true);
|
||||
});
|
||||
|
||||
it('包含安全命令白名单', () => {
|
||||
const rules = SYSTEM_DEFAULT_PERMISSION.bash?.rules ?? [];
|
||||
const lsRule = rules.find((r) => r.pattern === 'ls *');
|
||||
expect(lsRule?.action).toBe('allow');
|
||||
});
|
||||
|
||||
it('包含危险命令黑名单', () => {
|
||||
const rules = SYSTEM_DEFAULT_PERMISSION.bash?.rules ?? [];
|
||||
const rmRule = rules.find((r) => r.pattern === 'rm -rf *');
|
||||
expect(rmRule?.action).toBe('deny');
|
||||
});
|
||||
|
||||
it('Git 读取默认允许', () => {
|
||||
expect(SYSTEM_DEFAULT_PERMISSION.git?.read).toBe('allow');
|
||||
});
|
||||
|
||||
it('Git 危险操作默认拒绝', () => {
|
||||
expect(SYSTEM_DEFAULT_PERMISSION.git?.dangerous).toBe('deny');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,201 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import {
|
||||
presetAgents,
|
||||
getPresetAgentNames,
|
||||
isPresetAgent,
|
||||
generalAgent,
|
||||
exploreAgent,
|
||||
codeReviewerAgent,
|
||||
buildAgent,
|
||||
planAgent,
|
||||
visionAgent,
|
||||
} from '../../../../src/agent/presets/index.js';
|
||||
|
||||
describe('Agent Presets - 预设 Agent', () => {
|
||||
describe('presetAgents 集合', () => {
|
||||
it('包含所有预设 Agent', () => {
|
||||
expect(Object.keys(presetAgents)).toHaveLength(6);
|
||||
});
|
||||
|
||||
it('包含 general Agent', () => {
|
||||
expect(presetAgents['general']).toBeDefined();
|
||||
expect(presetAgents['general']).toBe(generalAgent);
|
||||
});
|
||||
|
||||
it('包含 explore Agent', () => {
|
||||
expect(presetAgents['explore']).toBeDefined();
|
||||
expect(presetAgents['explore']).toBe(exploreAgent);
|
||||
});
|
||||
|
||||
it('包含 code-reviewer Agent', () => {
|
||||
expect(presetAgents['code-reviewer']).toBeDefined();
|
||||
expect(presetAgents['code-reviewer']).toBe(codeReviewerAgent);
|
||||
});
|
||||
|
||||
it('包含 build Agent', () => {
|
||||
expect(presetAgents['build']).toBeDefined();
|
||||
expect(presetAgents['build']).toBe(buildAgent);
|
||||
});
|
||||
|
||||
it('包含 plan Agent', () => {
|
||||
expect(presetAgents['plan']).toBeDefined();
|
||||
expect(presetAgents['plan']).toBe(planAgent);
|
||||
});
|
||||
|
||||
it('包含 vision Agent', () => {
|
||||
expect(presetAgents['vision']).toBeDefined();
|
||||
expect(presetAgents['vision']).toBe(visionAgent);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getPresetAgentNames - 获取预设名称', () => {
|
||||
it('返回所有预设 Agent 名称', () => {
|
||||
const names = getPresetAgentNames();
|
||||
|
||||
expect(names).toContain('general');
|
||||
expect(names).toContain('explore');
|
||||
expect(names).toContain('code-reviewer');
|
||||
expect(names).toContain('build');
|
||||
expect(names).toContain('plan');
|
||||
expect(names).toContain('vision');
|
||||
});
|
||||
|
||||
it('返回数组类型', () => {
|
||||
const names = getPresetAgentNames();
|
||||
expect(Array.isArray(names)).toBe(true);
|
||||
});
|
||||
|
||||
it('返回正确数量', () => {
|
||||
const names = getPresetAgentNames();
|
||||
expect(names).toHaveLength(6);
|
||||
});
|
||||
});
|
||||
|
||||
describe('isPresetAgent - 检查是否为预设', () => {
|
||||
it('识别 general', () => {
|
||||
expect(isPresetAgent('general')).toBe(true);
|
||||
});
|
||||
|
||||
it('识别 explore', () => {
|
||||
expect(isPresetAgent('explore')).toBe(true);
|
||||
});
|
||||
|
||||
it('识别 code-reviewer', () => {
|
||||
expect(isPresetAgent('code-reviewer')).toBe(true);
|
||||
});
|
||||
|
||||
it('识别 build', () => {
|
||||
expect(isPresetAgent('build')).toBe(true);
|
||||
});
|
||||
|
||||
it('识别 plan', () => {
|
||||
expect(isPresetAgent('plan')).toBe(true);
|
||||
});
|
||||
|
||||
it('识别 vision', () => {
|
||||
expect(isPresetAgent('vision')).toBe(true);
|
||||
});
|
||||
|
||||
it('不识别未知 Agent', () => {
|
||||
expect(isPresetAgent('unknown')).toBe(false);
|
||||
});
|
||||
|
||||
it('不识别空字符串', () => {
|
||||
expect(isPresetAgent('')).toBe(false);
|
||||
});
|
||||
|
||||
it('区分大小写', () => {
|
||||
expect(isPresetAgent('General')).toBe(false);
|
||||
expect(isPresetAgent('GENERAL')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generalAgent - 通用 Agent', () => {
|
||||
it('有正确的描述', () => {
|
||||
expect(generalAgent.description).toContain('通用');
|
||||
});
|
||||
|
||||
it('mode 为 subagent', () => {
|
||||
expect(generalAgent.mode).toBe('subagent');
|
||||
});
|
||||
|
||||
it('禁止嵌套 Task', () => {
|
||||
expect(generalAgent.tools?.noTask).toBe(true);
|
||||
});
|
||||
|
||||
it('有 maxSteps 限制', () => {
|
||||
expect(generalAgent.maxSteps).toBeDefined();
|
||||
expect(generalAgent.maxSteps).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('exploreAgent - 探索 Agent', () => {
|
||||
it('有描述', () => {
|
||||
expect(exploreAgent.description).toBeDefined();
|
||||
});
|
||||
|
||||
it('mode 为 subagent', () => {
|
||||
expect(exploreAgent.mode).toBe('subagent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('codeReviewerAgent - 代码审查 Agent', () => {
|
||||
it('有描述', () => {
|
||||
expect(codeReviewerAgent.description).toBeDefined();
|
||||
});
|
||||
|
||||
it('mode 为 subagent', () => {
|
||||
expect(codeReviewerAgent.mode).toBe('subagent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildAgent - 构建 Agent', () => {
|
||||
it('有描述', () => {
|
||||
expect(buildAgent.description).toBeDefined();
|
||||
});
|
||||
|
||||
it('mode 为 primary(主模式)', () => {
|
||||
expect(buildAgent.mode).toBe('primary');
|
||||
});
|
||||
});
|
||||
|
||||
describe('planAgent - 计划 Agent', () => {
|
||||
it('有描述', () => {
|
||||
expect(planAgent.description).toBeDefined();
|
||||
});
|
||||
|
||||
it('mode 为 primary(主模式)', () => {
|
||||
expect(planAgent.mode).toBe('primary');
|
||||
});
|
||||
});
|
||||
|
||||
describe('visionAgent - 视觉 Agent', () => {
|
||||
it('有描述', () => {
|
||||
expect(visionAgent.description).toBeDefined();
|
||||
});
|
||||
|
||||
it('mode 为 subagent', () => {
|
||||
expect(visionAgent.mode).toBe('subagent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('所有预设 Agent 共同特性', () => {
|
||||
it('都有 description', () => {
|
||||
Object.entries(presetAgents).forEach(([name, agent]) => {
|
||||
expect(agent.description, `${name} 缺少 description`).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('都有 mode', () => {
|
||||
Object.entries(presetAgents).forEach(([name, agent]) => {
|
||||
expect(agent.mode, `${name} 缺少 mode`).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
it('mode 值是 subagent 或 primary', () => {
|
||||
Object.entries(presetAgents).forEach(([name, agent]) => {
|
||||
expect(['subagent', 'primary'], `${name} mode 无效`).toContain(agent.mode);
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,350 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { AgentRegistry } from '../../../src/agent/registry.js';
|
||||
import type { AgentInfo, AgentConfigFile } from '../../../src/agent/types.js';
|
||||
|
||||
// Mock config-loader
|
||||
vi.mock('../../../src/agent/config-loader.js', () => ({
|
||||
loadAgentConfig: vi.fn(),
|
||||
}));
|
||||
|
||||
// Mock presets
|
||||
vi.mock('../../../src/agent/presets/index.js', () => ({
|
||||
presetAgents: {
|
||||
explore: {
|
||||
description: '代码探索 Agent',
|
||||
mode: 'subagent' as const,
|
||||
prompt: '你是代码探索助手',
|
||||
maxSteps: 5,
|
||||
},
|
||||
'code-reviewer': {
|
||||
description: '代码审查 Agent',
|
||||
mode: 'subagent' as const,
|
||||
prompt: '你是代码审查助手',
|
||||
},
|
||||
build: {
|
||||
description: '构建 Agent',
|
||||
mode: 'all' as const,
|
||||
prompt: '你是构建助手',
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
import { loadAgentConfig } from '../../../src/agent/config-loader.js';
|
||||
|
||||
describe('AgentRegistry - Agent 注册表', () => {
|
||||
let registry: AgentRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
registry = new AgentRegistry();
|
||||
vi.clearAllMocks();
|
||||
vi.mocked(loadAgentConfig).mockResolvedValue(null);
|
||||
});
|
||||
|
||||
describe('init - 初始化', () => {
|
||||
it('初始化后注册预设 Agent', async () => {
|
||||
await registry.init('/test/project');
|
||||
|
||||
expect(registry.has('explore')).toBe(true);
|
||||
expect(registry.has('code-reviewer')).toBe(true);
|
||||
expect(registry.has('build')).toBe(true);
|
||||
});
|
||||
|
||||
it('初始化加载用户配置', async () => {
|
||||
const userConfig: AgentConfigFile = {
|
||||
defaults: {
|
||||
maxSteps: 20,
|
||||
},
|
||||
agents: {
|
||||
'custom-agent': {
|
||||
description: '自定义 Agent',
|
||||
mode: 'subagent',
|
||||
prompt: '你是自定义助手',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
vi.mocked(loadAgentConfig).mockResolvedValue(userConfig);
|
||||
|
||||
await registry.init('/test/project');
|
||||
|
||||
expect(registry.has('custom-agent')).toBe(true);
|
||||
});
|
||||
|
||||
it('重复初始化只执行一次', async () => {
|
||||
await registry.init('/test/project');
|
||||
await registry.init('/test/project');
|
||||
|
||||
expect(loadAgentConfig).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('get - 获取 Agent', () => {
|
||||
beforeEach(async () => {
|
||||
await registry.init('/test/project');
|
||||
});
|
||||
|
||||
it('获取存在的 Agent', () => {
|
||||
const agent = registry.get('explore');
|
||||
|
||||
expect(agent).toBeDefined();
|
||||
expect(agent?.name).toBe('explore');
|
||||
expect(agent?.description).toBe('代码探索 Agent');
|
||||
});
|
||||
|
||||
it('获取不存在的 Agent 返回 undefined', () => {
|
||||
const agent = registry.get('non-existent');
|
||||
|
||||
expect(agent).toBeUndefined();
|
||||
});
|
||||
|
||||
it('获取的 Agent 应用全局配置', async () => {
|
||||
vi.mocked(loadAgentConfig).mockResolvedValue({
|
||||
defaults: {
|
||||
maxSteps: 25,
|
||||
},
|
||||
});
|
||||
|
||||
const newRegistry = new AgentRegistry();
|
||||
await newRegistry.init('/test/project');
|
||||
|
||||
const agent = newRegistry.get('code-reviewer');
|
||||
|
||||
// code-reviewer 没有设置 maxSteps,应该使用全局默认值
|
||||
expect(agent?.maxSteps).toBe(25);
|
||||
});
|
||||
|
||||
it('Agent 自己的配置优先于全局配置', async () => {
|
||||
vi.mocked(loadAgentConfig).mockResolvedValue({
|
||||
defaults: {
|
||||
maxSteps: 25,
|
||||
},
|
||||
});
|
||||
|
||||
const newRegistry = new AgentRegistry();
|
||||
await newRegistry.init('/test/project');
|
||||
|
||||
const agent = newRegistry.get('explore');
|
||||
|
||||
// explore 设置了 maxSteps: 5
|
||||
expect(agent?.maxSteps).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list - 列出 Agent', () => {
|
||||
beforeEach(async () => {
|
||||
await registry.init('/test/project');
|
||||
});
|
||||
|
||||
it('列出所有 Agent', () => {
|
||||
const agents = registry.list();
|
||||
|
||||
expect(agents.length).toBeGreaterThan(0);
|
||||
expect(agents.some(a => a.name === 'explore')).toBe(true);
|
||||
expect(agents.some(a => a.name === 'code-reviewer')).toBe(true);
|
||||
});
|
||||
|
||||
it('按 mode 过滤 Agent', () => {
|
||||
const subagents = registry.list('subagent');
|
||||
|
||||
expect(subagents.every(a => a.mode === 'subagent' || a.mode === 'all')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listSubagents - 列出子 Agent', () => {
|
||||
beforeEach(async () => {
|
||||
await registry.init('/test/project');
|
||||
});
|
||||
|
||||
it('排除 primary-only 的 Agent', () => {
|
||||
const subagents = registry.listSubagents();
|
||||
|
||||
expect(subagents.every(a => a.mode !== 'primary')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('listPrimaryAgents - 列出主 Agent', () => {
|
||||
beforeEach(async () => {
|
||||
await registry.init('/test/project');
|
||||
});
|
||||
|
||||
it('排除 subagent-only 的 Agent', () => {
|
||||
const primaryAgents = registry.listPrimaryAgents();
|
||||
|
||||
expect(primaryAgents.every(a => a.mode !== 'subagent')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('register - 动态注册', () => {
|
||||
beforeEach(async () => {
|
||||
await registry.init('/test/project');
|
||||
});
|
||||
|
||||
it('注册新 Agent', () => {
|
||||
const newAgent: AgentInfo = {
|
||||
name: 'dynamic-agent',
|
||||
description: '动态注册的 Agent',
|
||||
mode: 'subagent',
|
||||
prompt: '你是动态 Agent',
|
||||
};
|
||||
|
||||
registry.register(newAgent);
|
||||
|
||||
expect(registry.has('dynamic-agent')).toBe(true);
|
||||
expect(registry.get('dynamic-agent')?.description).toBe('动态注册的 Agent');
|
||||
});
|
||||
|
||||
it('覆盖已有 Agent', () => {
|
||||
const updatedAgent: AgentInfo = {
|
||||
name: 'explore',
|
||||
description: '更新后的探索 Agent',
|
||||
mode: 'all',
|
||||
prompt: '更新后的提示',
|
||||
};
|
||||
|
||||
registry.register(updatedAgent);
|
||||
|
||||
const agent = registry.get('explore');
|
||||
expect(agent?.description).toBe('更新后的探索 Agent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('remove - 移除 Agent', () => {
|
||||
beforeEach(async () => {
|
||||
await registry.init('/test/project');
|
||||
});
|
||||
|
||||
it('移除存在的 Agent', () => {
|
||||
const result = registry.remove('explore');
|
||||
|
||||
expect(result).toBe(true);
|
||||
expect(registry.has('explore')).toBe(false);
|
||||
});
|
||||
|
||||
it('移除不存在的 Agent 返回 false', () => {
|
||||
const result = registry.remove('non-existent');
|
||||
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('has - 检查 Agent 是否存在', () => {
|
||||
beforeEach(async () => {
|
||||
await registry.init('/test/project');
|
||||
});
|
||||
|
||||
it('存在的 Agent 返回 true', () => {
|
||||
expect(registry.has('explore')).toBe(true);
|
||||
});
|
||||
|
||||
it('不存在的 Agent 返回 false', () => {
|
||||
expect(registry.has('non-existent')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('size - 获取 Agent 数量', () => {
|
||||
beforeEach(async () => {
|
||||
await registry.init('/test/project');
|
||||
});
|
||||
|
||||
it('返回正确的数量', () => {
|
||||
expect(registry.size).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('添加后数量增加', () => {
|
||||
const initialSize = registry.size;
|
||||
|
||||
registry.register({
|
||||
name: 'new-agent',
|
||||
description: 'New',
|
||||
mode: 'subagent',
|
||||
prompt: 'New agent',
|
||||
});
|
||||
|
||||
expect(registry.size).toBe(initialSize + 1);
|
||||
});
|
||||
|
||||
it('移除后数量减少', () => {
|
||||
const initialSize = registry.size;
|
||||
|
||||
registry.remove('explore');
|
||||
|
||||
expect(registry.size).toBe(initialSize - 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getNames - 获取所有 Agent 名称', () => {
|
||||
beforeEach(async () => {
|
||||
await registry.init('/test/project');
|
||||
});
|
||||
|
||||
it('返回所有 Agent 名称', () => {
|
||||
const names = registry.getNames();
|
||||
|
||||
expect(names).toContain('explore');
|
||||
expect(names).toContain('code-reviewer');
|
||||
expect(names).toContain('build');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getGlobalConfig - 获取全局配置', () => {
|
||||
it('无用户配置时返回 null', async () => {
|
||||
vi.mocked(loadAgentConfig).mockResolvedValue(null);
|
||||
|
||||
await registry.init('/test/project');
|
||||
|
||||
expect(registry.getGlobalConfig()).toBeNull();
|
||||
});
|
||||
|
||||
it('有用户配置时返回 defaults', async () => {
|
||||
vi.mocked(loadAgentConfig).mockResolvedValue({
|
||||
defaults: {
|
||||
maxSteps: 30,
|
||||
model: {
|
||||
temperature: 0.5,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
const newRegistry = new AgentRegistry();
|
||||
await newRegistry.init('/test/project');
|
||||
|
||||
const globalConfig = newRegistry.getGlobalConfig();
|
||||
|
||||
expect(globalConfig?.maxSteps).toBe(30);
|
||||
expect(globalConfig?.model?.temperature).toBe(0.5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('generateSubagentDescription - 生成子 Agent 描述', () => {
|
||||
beforeEach(async () => {
|
||||
await registry.init('/test/project');
|
||||
});
|
||||
|
||||
it('生成包含所有子 Agent 的描述', () => {
|
||||
const description = registry.generateSubagentDescription();
|
||||
|
||||
expect(description).toContain('explore');
|
||||
expect(description).toContain('代码探索');
|
||||
});
|
||||
|
||||
it('无子 Agent 时返回提示信息', async () => {
|
||||
// 移除所有 Agent
|
||||
for (const name of registry.getNames()) {
|
||||
registry.remove(name);
|
||||
}
|
||||
|
||||
const description = registry.generateSubagentDescription();
|
||||
|
||||
expect(description).toContain('没有可用');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('agentRegistry 单例', () => {
|
||||
it('导出单例实例', async () => {
|
||||
const { agentRegistry } = await import('../../../src/agent/registry.js');
|
||||
|
||||
expect(agentRegistry).toBeDefined();
|
||||
expect(agentRegistry).toBeInstanceOf(AgentRegistry);
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user