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,298 @@
|
||||
import { describe, it, expect, beforeEach } from 'vitest';
|
||||
import type { Tool } from '../../../src/types/index.js';
|
||||
import type { AgentInfo, AgentToolConfig } from '../../../src/agent/types.js';
|
||||
|
||||
/**
|
||||
* 模拟 Agent 类中的 filterToolsByAgentConfig 逻辑
|
||||
* 由于 Agent 类有复杂的依赖,我们提取核心过滤逻辑进行测试
|
||||
*/
|
||||
function filterToolsByAgentConfig(
|
||||
tools: Tool[],
|
||||
toolConfig: AgentToolConfig | undefined
|
||||
): Tool[] {
|
||||
if (!toolConfig) return tools;
|
||||
|
||||
let filteredTools = tools;
|
||||
|
||||
// 如果设置了 enabled 列表,只保留这些工具
|
||||
if (toolConfig.enabled && toolConfig.enabled.length > 0) {
|
||||
const enabledSet = new Set(toolConfig.enabled);
|
||||
filteredTools = filteredTools.filter((t) => enabledSet.has(t.name));
|
||||
}
|
||||
|
||||
// 如果设置了 disabled 列表,排除这些工具
|
||||
if (toolConfig.disabled && toolConfig.disabled.length > 0) {
|
||||
const disabledSet = new Set(toolConfig.disabled);
|
||||
filteredTools = filteredTools.filter((t) => !disabledSet.has(t.name));
|
||||
}
|
||||
|
||||
// 如果禁止嵌套 Task,移除 task 工具
|
||||
if (toolConfig.noTask) {
|
||||
filteredTools = filteredTools.filter((t) => t.name !== 'task');
|
||||
}
|
||||
|
||||
return filteredTools;
|
||||
}
|
||||
|
||||
// 创建测试用的 mock 工具
|
||||
function createMockTool(name: string): Tool {
|
||||
return {
|
||||
name,
|
||||
description: `Mock tool: ${name}`,
|
||||
parameters: { type: 'object', properties: {}, required: [] },
|
||||
execute: async () => ({ success: true, output: 'mock' }),
|
||||
};
|
||||
}
|
||||
|
||||
describe('Agent 工具过滤 - filterToolsByAgentConfig', () => {
|
||||
let allTools: Tool[];
|
||||
|
||||
beforeEach(() => {
|
||||
// 创建一组测试工具
|
||||
allTools = [
|
||||
createMockTool('read_file'),
|
||||
createMockTool('write_file'),
|
||||
createMockTool('bash'),
|
||||
createMockTool('task'),
|
||||
createMockTool('tool_search'),
|
||||
createMockTool('glob'),
|
||||
createMockTool('grep'),
|
||||
];
|
||||
});
|
||||
|
||||
describe('无过滤配置', () => {
|
||||
it('toolConfig 为 undefined 时返回所有工具', () => {
|
||||
const result = filterToolsByAgentConfig(allTools, undefined);
|
||||
expect(result).toHaveLength(allTools.length);
|
||||
expect(result).toEqual(allTools);
|
||||
});
|
||||
|
||||
it('toolConfig 为空对象时返回所有工具', () => {
|
||||
const result = filterToolsByAgentConfig(allTools, {});
|
||||
expect(result).toHaveLength(allTools.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('enabled 白名单过滤', () => {
|
||||
it('只保留 enabled 列表中的工具', () => {
|
||||
const config: AgentToolConfig = {
|
||||
enabled: ['read_file', 'glob', 'grep'],
|
||||
};
|
||||
const result = filterToolsByAgentConfig(allTools, config);
|
||||
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.map((t) => t.name)).toEqual(['read_file', 'glob', 'grep']);
|
||||
});
|
||||
|
||||
it('enabled 为空数组时返回空列表', () => {
|
||||
const config: AgentToolConfig = {
|
||||
enabled: [],
|
||||
};
|
||||
const result = filterToolsByAgentConfig(allTools, config);
|
||||
expect(result).toHaveLength(allTools.length); // 空数组不触发过滤
|
||||
});
|
||||
|
||||
it('enabled 中不存在的工具被忽略', () => {
|
||||
const config: AgentToolConfig = {
|
||||
enabled: ['read_file', 'non_existent_tool'],
|
||||
};
|
||||
const result = filterToolsByAgentConfig(allTools, config);
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0].name).toBe('read_file');
|
||||
});
|
||||
});
|
||||
|
||||
describe('disabled 黑名单过滤', () => {
|
||||
it('排除 disabled 列表中的工具', () => {
|
||||
const config: AgentToolConfig = {
|
||||
disabled: ['bash', 'write_file'],
|
||||
};
|
||||
const result = filterToolsByAgentConfig(allTools, config);
|
||||
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result.map((t) => t.name)).not.toContain('bash');
|
||||
expect(result.map((t) => t.name)).not.toContain('write_file');
|
||||
});
|
||||
|
||||
it('disabled 为空数组时返回所有工具', () => {
|
||||
const config: AgentToolConfig = {
|
||||
disabled: [],
|
||||
};
|
||||
const result = filterToolsByAgentConfig(allTools, config);
|
||||
expect(result).toHaveLength(allTools.length);
|
||||
});
|
||||
|
||||
it('disabled 中不存在的工具被忽略', () => {
|
||||
const config: AgentToolConfig = {
|
||||
disabled: ['non_existent_tool'],
|
||||
};
|
||||
const result = filterToolsByAgentConfig(allTools, config);
|
||||
expect(result).toHaveLength(allTools.length);
|
||||
});
|
||||
});
|
||||
|
||||
describe('noTask 过滤', () => {
|
||||
it('noTask=true 时移除 task 工具', () => {
|
||||
const config: AgentToolConfig = {
|
||||
noTask: true,
|
||||
};
|
||||
const result = filterToolsByAgentConfig(allTools, config);
|
||||
|
||||
expect(result).toHaveLength(6);
|
||||
expect(result.map((t) => t.name)).not.toContain('task');
|
||||
});
|
||||
|
||||
it('noTask=false 时保留 task 工具', () => {
|
||||
const config: AgentToolConfig = {
|
||||
noTask: false,
|
||||
};
|
||||
const result = filterToolsByAgentConfig(allTools, config);
|
||||
|
||||
expect(result).toHaveLength(allTools.length);
|
||||
expect(result.map((t) => t.name)).toContain('task');
|
||||
});
|
||||
|
||||
it('noTask 未设置时保留 task 工具', () => {
|
||||
const config: AgentToolConfig = {};
|
||||
const result = filterToolsByAgentConfig(allTools, config);
|
||||
|
||||
expect(result.map((t) => t.name)).toContain('task');
|
||||
});
|
||||
});
|
||||
|
||||
describe('组合过滤', () => {
|
||||
it('enabled + noTask 组合', () => {
|
||||
const config: AgentToolConfig = {
|
||||
enabled: ['read_file', 'task', 'glob'],
|
||||
noTask: true,
|
||||
};
|
||||
const result = filterToolsByAgentConfig(allTools, config);
|
||||
|
||||
// enabled 先过滤为 [read_file, task, glob]
|
||||
// noTask 再移除 task
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((t) => t.name)).toEqual(['read_file', 'glob']);
|
||||
});
|
||||
|
||||
it('disabled + noTask 组合', () => {
|
||||
const config: AgentToolConfig = {
|
||||
disabled: ['bash'],
|
||||
noTask: true,
|
||||
};
|
||||
const result = filterToolsByAgentConfig(allTools, config);
|
||||
|
||||
// 原始 7 个工具,移除 bash 和 task
|
||||
expect(result).toHaveLength(5);
|
||||
expect(result.map((t) => t.name)).not.toContain('bash');
|
||||
expect(result.map((t) => t.name)).not.toContain('task');
|
||||
});
|
||||
|
||||
it('enabled + disabled 组合(enabled 优先)', () => {
|
||||
const config: AgentToolConfig = {
|
||||
enabled: ['read_file', 'bash', 'glob'],
|
||||
disabled: ['bash'], // bash 在 enabled 中,也在 disabled 中
|
||||
};
|
||||
const result = filterToolsByAgentConfig(allTools, config);
|
||||
|
||||
// enabled 先过滤为 [read_file, bash, glob]
|
||||
// disabled 再移除 bash
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((t) => t.name)).toEqual(['read_file', 'glob']);
|
||||
});
|
||||
|
||||
it('enabled + disabled + noTask 全部组合', () => {
|
||||
const config: AgentToolConfig = {
|
||||
enabled: ['read_file', 'write_file', 'task', 'glob'],
|
||||
disabled: ['write_file'],
|
||||
noTask: true,
|
||||
};
|
||||
const result = filterToolsByAgentConfig(allTools, config);
|
||||
|
||||
// enabled: [read_file, write_file, task, glob]
|
||||
// disabled: 移除 write_file -> [read_file, task, glob]
|
||||
// noTask: 移除 task -> [read_file, glob]
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result.map((t) => t.name)).toEqual(['read_file', 'glob']);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('AgentInfo 工具配置集成', () => {
|
||||
it('explore agent 典型配置:只读工具', () => {
|
||||
const exploreAgent: AgentInfo = {
|
||||
name: 'explore',
|
||||
description: '代码探索 Agent',
|
||||
mode: 'subagent',
|
||||
tools: {
|
||||
enabled: ['read_file', 'glob', 'grep', 'tool_search'],
|
||||
noTask: true,
|
||||
},
|
||||
};
|
||||
|
||||
const allTools = [
|
||||
createMockTool('read_file'),
|
||||
createMockTool('write_file'),
|
||||
createMockTool('bash'),
|
||||
createMockTool('task'),
|
||||
createMockTool('glob'),
|
||||
createMockTool('grep'),
|
||||
createMockTool('tool_search'),
|
||||
];
|
||||
|
||||
const result = filterToolsByAgentConfig(allTools, exploreAgent.tools);
|
||||
|
||||
expect(result).toHaveLength(4);
|
||||
expect(result.map((t) => t.name).sort()).toEqual(['glob', 'grep', 'read_file', 'tool_search']);
|
||||
});
|
||||
|
||||
it('code-reviewer agent 典型配置:禁用写操作', () => {
|
||||
const reviewerAgent: AgentInfo = {
|
||||
name: 'code-reviewer',
|
||||
description: '代码审查 Agent',
|
||||
mode: 'subagent',
|
||||
tools: {
|
||||
disabled: ['write_file', 'bash'],
|
||||
noTask: true,
|
||||
},
|
||||
};
|
||||
|
||||
const allTools = [
|
||||
createMockTool('read_file'),
|
||||
createMockTool('write_file'),
|
||||
createMockTool('bash'),
|
||||
createMockTool('task'),
|
||||
createMockTool('glob'),
|
||||
createMockTool('grep'),
|
||||
];
|
||||
|
||||
const result = filterToolsByAgentConfig(allTools, reviewerAgent.tools);
|
||||
|
||||
// 移除 write_file, bash, task
|
||||
expect(result).toHaveLength(3);
|
||||
expect(result.map((t) => t.name).sort()).toEqual(['glob', 'grep', 'read_file']);
|
||||
});
|
||||
|
||||
it('build agent 典型配置:允许嵌套 Task', () => {
|
||||
const buildAgent: AgentInfo = {
|
||||
name: 'build',
|
||||
description: '构建 Agent',
|
||||
mode: 'primary',
|
||||
tools: {
|
||||
noTask: false, // 明确允许 task
|
||||
},
|
||||
};
|
||||
|
||||
const allTools = [
|
||||
createMockTool('read_file'),
|
||||
createMockTool('write_file'),
|
||||
createMockTool('bash'),
|
||||
createMockTool('task'),
|
||||
];
|
||||
|
||||
const result = filterToolsByAgentConfig(allTools, buildAgent.tools);
|
||||
|
||||
expect(result).toHaveLength(4);
|
||||
expect(result.map((t) => t.name)).toContain('task');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,411 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { Agent } from '../../../src/core/agent.js';
|
||||
import { ToolRegistry } from '../../../src/tools/registry.js';
|
||||
import { SessionManager } from '../../../src/session/index.js';
|
||||
import type { AgentConfig, Tool } from '../../../src/types/index.js';
|
||||
import type { AgentInfo } from '../../../src/agent/types.js';
|
||||
|
||||
// Mock providers
|
||||
vi.mock('../../../src/core/providers.js', () => ({
|
||||
getModelFactory: vi.fn(() => {
|
||||
return (model: string) => ({
|
||||
modelId: `mock:${model}`,
|
||||
doGenerate: vi.fn(),
|
||||
doStream: vi.fn(),
|
||||
});
|
||||
}),
|
||||
}));
|
||||
|
||||
// Mock AI SDK
|
||||
vi.mock('ai', () => ({
|
||||
generateText: vi.fn().mockResolvedValue({
|
||||
text: 'Mock response',
|
||||
response: { messages: [] },
|
||||
steps: [],
|
||||
}),
|
||||
streamText: vi.fn(() => ({
|
||||
textStream: (async function* () {
|
||||
yield 'Mock ';
|
||||
yield 'streamed ';
|
||||
yield 'response';
|
||||
})(),
|
||||
response: Promise.resolve({ messages: [] }),
|
||||
})),
|
||||
stepCountIs: vi.fn(() => () => false),
|
||||
}));
|
||||
|
||||
// Mock agent registry and executor
|
||||
vi.mock('../../../src/agent/index.js', () => ({
|
||||
agentRegistry: {
|
||||
get: vi.fn(),
|
||||
},
|
||||
AgentExecutor: vi.fn().mockImplementation(() => ({
|
||||
execute: vi.fn().mockResolvedValue({
|
||||
success: true,
|
||||
text: 'Vision analysis result',
|
||||
steps: 1,
|
||||
sessionId: 'test',
|
||||
}),
|
||||
})),
|
||||
}));
|
||||
|
||||
// Mock vision config
|
||||
vi.mock('../../../src/utils/config.js', () => ({
|
||||
loadVisionConfig: vi.fn(() => null),
|
||||
}));
|
||||
|
||||
// Create mock tool
|
||||
function createMockTool(name: string, category = 'core'): Tool & { metadata?: { deferLoading?: boolean } } {
|
||||
return {
|
||||
name,
|
||||
description: `Mock ${name} tool`,
|
||||
parameters: { type: 'object', properties: {}, required: [] },
|
||||
execute: vi.fn().mockResolvedValue({ success: true, output: `${name} result` }),
|
||||
metadata: { deferLoading: category !== 'core' },
|
||||
};
|
||||
}
|
||||
|
||||
// Default test config
|
||||
const testConfig: AgentConfig = {
|
||||
provider: 'anthropic',
|
||||
apiKey: 'test-api-key',
|
||||
model: 'claude-3-sonnet',
|
||||
systemPrompt: 'You are a helpful assistant.',
|
||||
maxTokens: 4096,
|
||||
};
|
||||
|
||||
describe('Agent', () => {
|
||||
let agent: Agent;
|
||||
let registry: ToolRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
registry = new ToolRegistry();
|
||||
|
||||
// Register some tools
|
||||
registry.register(createMockTool('tool_search', 'core') as any);
|
||||
registry.register(createMockTool('bash', 'core') as any);
|
||||
registry.register(createMockTool('read_file', 'filesystem') as any);
|
||||
registry.register(createMockTool('write_file', 'filesystem') as any);
|
||||
|
||||
agent = new Agent(testConfig);
|
||||
agent.setRegistry(registry);
|
||||
});
|
||||
|
||||
describe('constructor', () => {
|
||||
it('初始化 Agent 配置', () => {
|
||||
const config = agent.getConfig();
|
||||
expect(config.provider).toBe('anthropic');
|
||||
expect(config.model).toBe('claude-3-sonnet');
|
||||
expect(config.systemPrompt).toBe('You are a helpful assistant.');
|
||||
});
|
||||
|
||||
it('支持自定义压缩配置', () => {
|
||||
const agentWithCompression = new Agent(testConfig, {
|
||||
maxContextTokens: 50000,
|
||||
compressionThreshold: 0.7,
|
||||
});
|
||||
|
||||
expect(agentWithCompression.getCompressionManager()).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('setRegistry', () => {
|
||||
it('设置工具注册表', () => {
|
||||
const newAgent = new Agent(testConfig);
|
||||
expect(newAgent.getToolCount()).toEqual({ core: 0, discovered: 0, total: 0 });
|
||||
|
||||
newAgent.setRegistry(registry);
|
||||
const counts = newAgent.getToolCount();
|
||||
expect(counts.core).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('setSessionManager', () => {
|
||||
it('设置会话管理器', async () => {
|
||||
// Mock SessionManager
|
||||
const mockSessionManager = {
|
||||
getSession: vi.fn().mockReturnValue(null),
|
||||
setMessages: vi.fn(),
|
||||
setDiscoveredTools: vi.fn(),
|
||||
newSession: vi.fn(),
|
||||
} as unknown as SessionManager;
|
||||
|
||||
agent.setSessionManager(mockSessionManager);
|
||||
expect(agent.getSessionManager()).toBe(mockSessionManager);
|
||||
});
|
||||
|
||||
it('从会话恢复状态', async () => {
|
||||
// Mock SessionManager with existing session
|
||||
const mockSessionManager = {
|
||||
getSession: vi.fn().mockReturnValue({
|
||||
id: 'test-session',
|
||||
messages: [{ role: 'user', content: 'Hello' }],
|
||||
discoveredTools: ['read_file', 'write_file'],
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
}),
|
||||
setMessages: vi.fn(),
|
||||
setDiscoveredTools: vi.fn(),
|
||||
newSession: vi.fn(),
|
||||
} as unknown as SessionManager;
|
||||
|
||||
agent.setSessionManager(mockSessionManager);
|
||||
|
||||
const counts = agent.getToolCount();
|
||||
expect(counts.discovered).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getToolCount', () => {
|
||||
it('返回工具数量统计', () => {
|
||||
const counts = agent.getToolCount();
|
||||
expect(counts).toHaveProperty('core');
|
||||
expect(counts).toHaveProperty('discovered');
|
||||
expect(counts).toHaveProperty('total');
|
||||
expect(counts.total).toBe(counts.core + counts.discovered);
|
||||
});
|
||||
|
||||
it('registry 未设置时返回 0', () => {
|
||||
const newAgent = new Agent(testConfig);
|
||||
const counts = newAgent.getToolCount();
|
||||
expect(counts).toEqual({ core: 0, discovered: 0, total: 0 });
|
||||
});
|
||||
});
|
||||
|
||||
describe('getHistory', () => {
|
||||
it('初始历史为空', () => {
|
||||
expect(agent.getHistory()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('clearHistory', () => {
|
||||
it('清空对话历史和发现的工具', async () => {
|
||||
// Add some history by calling chat (mocked)
|
||||
await agent.chat('Hello');
|
||||
|
||||
await agent.clearHistory();
|
||||
expect(agent.getHistory()).toEqual([]);
|
||||
expect(agent.getToolCount().discovered).toBe(0);
|
||||
});
|
||||
|
||||
it('有会话管理器时创建新会话', async () => {
|
||||
const mockSessionManager = {
|
||||
getSession: vi.fn().mockReturnValue(null),
|
||||
setMessages: vi.fn(),
|
||||
setDiscoveredTools: vi.fn(),
|
||||
newSession: vi.fn().mockResolvedValue(undefined),
|
||||
} as unknown as SessionManager;
|
||||
|
||||
agent.setSessionManager(mockSessionManager);
|
||||
await agent.clearHistory();
|
||||
|
||||
expect(mockSessionManager.newSession).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent Mode', () => {
|
||||
const exploreAgent: AgentInfo = {
|
||||
name: 'explore',
|
||||
description: '代码探索 Agent',
|
||||
mode: 'subagent',
|
||||
prompt: 'You are an explore agent.',
|
||||
tools: {
|
||||
enabled: ['read_file', 'tool_search'],
|
||||
noTask: true,
|
||||
},
|
||||
};
|
||||
|
||||
it('设置 Agent 模式', () => {
|
||||
agent.setAgentMode(exploreAgent);
|
||||
expect(agent.getAgentMode()).toBe(exploreAgent);
|
||||
expect(agent.getAgentModeName()).toBe('explore');
|
||||
});
|
||||
|
||||
it('切换 Agent 时更新 system prompt', () => {
|
||||
agent.setAgentMode(exploreAgent);
|
||||
expect(agent.getConfig().systemPrompt).toBe('You are an explore agent.');
|
||||
});
|
||||
|
||||
it('切换回 default 时恢复原始 prompt', () => {
|
||||
agent.setAgentMode(exploreAgent);
|
||||
agent.setAgentMode(null);
|
||||
|
||||
expect(agent.getAgentModeName()).toBe('default');
|
||||
expect(agent.getConfig().systemPrompt).toBe('You are a helpful assistant.');
|
||||
});
|
||||
|
||||
it('Agent 没有自定义 prompt 时保持原始 prompt', () => {
|
||||
const agentWithoutPrompt: AgentInfo = {
|
||||
name: 'simple',
|
||||
description: 'Simple agent',
|
||||
mode: 'subagent',
|
||||
};
|
||||
|
||||
agent.setAgentMode(agentWithoutPrompt);
|
||||
expect(agent.getConfig().systemPrompt).toBe('You are a helpful assistant.');
|
||||
});
|
||||
});
|
||||
|
||||
describe('supportsVision', () => {
|
||||
it('Anthropic Claude-3 支持 vision', () => {
|
||||
const claudeAgent = new Agent({
|
||||
...testConfig,
|
||||
provider: 'anthropic',
|
||||
model: 'claude-3-opus',
|
||||
});
|
||||
expect(claudeAgent.supportsVision()).toBe(true);
|
||||
});
|
||||
|
||||
it('Anthropic Claude-4 支持 vision', () => {
|
||||
const claudeAgent = new Agent({
|
||||
...testConfig,
|
||||
provider: 'anthropic',
|
||||
model: 'claude-4-sonnet',
|
||||
});
|
||||
expect(claudeAgent.supportsVision()).toBe(true);
|
||||
});
|
||||
|
||||
it('OpenAI GPT-4 支持 vision', () => {
|
||||
const gptAgent = new Agent({
|
||||
...testConfig,
|
||||
provider: 'openai',
|
||||
model: 'gpt-4-turbo',
|
||||
});
|
||||
expect(gptAgent.supportsVision()).toBe(true);
|
||||
});
|
||||
|
||||
it('DeepSeek 不支持 vision', () => {
|
||||
const deepseekAgent = new Agent({
|
||||
...testConfig,
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-chat',
|
||||
});
|
||||
expect(deepseekAgent.supportsVision()).toBe(false);
|
||||
});
|
||||
|
||||
it('旧版 Claude 不支持 vision', () => {
|
||||
const oldClaudeAgent = new Agent({
|
||||
...testConfig,
|
||||
provider: 'anthropic',
|
||||
model: 'claude-2',
|
||||
});
|
||||
expect(oldClaudeAgent.supportsVision()).toBe(false);
|
||||
});
|
||||
|
||||
it('GPT-3.5 不支持 vision', () => {
|
||||
const gpt35Agent = new Agent({
|
||||
...testConfig,
|
||||
provider: 'openai',
|
||||
model: 'gpt-3.5-turbo',
|
||||
});
|
||||
expect(gpt35Agent.supportsVision()).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContextUsage', () => {
|
||||
it('返回 token 使用情况', () => {
|
||||
const usage = agent.getContextUsage();
|
||||
expect(usage).toHaveProperty('input');
|
||||
expect(usage).toHaveProperty('contextLimit');
|
||||
expect(usage).toHaveProperty('available');
|
||||
expect(usage).toHaveProperty('usagePercent');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getContextUsageFormatted', () => {
|
||||
it('返回格式化的使用情况字符串', () => {
|
||||
const formatted = agent.getContextUsageFormatted();
|
||||
expect(typeof formatted).toBe('string');
|
||||
expect(formatted).toContain('k');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getCompressionManager', () => {
|
||||
it('返回压缩管理器实例', () => {
|
||||
const manager = agent.getCompressionManager();
|
||||
expect(manager).toBeDefined();
|
||||
expect(manager).toHaveProperty('compress');
|
||||
expect(manager).toHaveProperty('forceCompress');
|
||||
});
|
||||
});
|
||||
|
||||
describe('compactHistory', () => {
|
||||
it('手动压缩对话历史', async () => {
|
||||
const result = await agent.compactHistory();
|
||||
expect(result).toHaveProperty('freedTokens');
|
||||
expect(result).toHaveProperty('type');
|
||||
});
|
||||
});
|
||||
|
||||
describe('getConfig', () => {
|
||||
it('返回配置副本', () => {
|
||||
const config = agent.getConfig();
|
||||
expect(config).toEqual(testConfig);
|
||||
|
||||
// 修改返回值不影响原始配置
|
||||
config.model = 'modified';
|
||||
expect(agent.getConfig().model).toBe('claude-3-sonnet');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent - getAvailableTools error handling', () => {
|
||||
it('registry 未设置时抛出错误', () => {
|
||||
const agent = new Agent(testConfig);
|
||||
|
||||
// 使用 getToolCount 间接测试(因为 getAvailableTools 是 private)
|
||||
// 当 registry 为 null 时,getToolCount 返回 0
|
||||
const counts = agent.getToolCount();
|
||||
expect(counts.total).toBe(0);
|
||||
});
|
||||
});
|
||||
|
||||
describe('Agent - chat with images', () => {
|
||||
let agent: Agent;
|
||||
let registry: ToolRegistry;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
registry = new ToolRegistry();
|
||||
registry.register(createMockTool('tool_search', 'core') as any);
|
||||
|
||||
agent = new Agent(testConfig);
|
||||
agent.setRegistry(registry);
|
||||
});
|
||||
|
||||
it('支持 vision 的模型直接处理图片', async () => {
|
||||
const visionAgent = new Agent({
|
||||
...testConfig,
|
||||
model: 'claude-3-opus',
|
||||
});
|
||||
visionAgent.setRegistry(registry);
|
||||
|
||||
const result = await visionAgent.chat({
|
||||
text: 'What is in this image?',
|
||||
images: [
|
||||
{ data: 'base64data', mimeType: 'image/png' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toBeDefined();
|
||||
});
|
||||
|
||||
it('不支持 vision 时返回错误消息(Vision 未配置)', async () => {
|
||||
const deepseekAgent = new Agent({
|
||||
...testConfig,
|
||||
provider: 'deepseek',
|
||||
model: 'deepseek-chat',
|
||||
});
|
||||
deepseekAgent.setRegistry(registry);
|
||||
|
||||
const result = await deepseekAgent.chat({
|
||||
text: 'What is in this image?',
|
||||
images: [
|
||||
{ data: 'base64data', mimeType: 'image/png' },
|
||||
],
|
||||
});
|
||||
|
||||
expect(result).toContain('无法处理图片');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,264 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { getModelFactory, providers } from '../../../src/core/providers.js';
|
||||
|
||||
// Mock AI SDK providers
|
||||
vi.mock('@ai-sdk/anthropic', () => ({
|
||||
createAnthropic: vi.fn(() => {
|
||||
const modelFn = (model: string) => ({ modelId: `anthropic:${model}` });
|
||||
return modelFn;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@ai-sdk/deepseek', () => ({
|
||||
createDeepSeek: vi.fn(() => {
|
||||
const modelFn = (model: string) => ({ modelId: `deepseek:${model}` });
|
||||
return modelFn;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('@ai-sdk/openai', () => ({
|
||||
createOpenAI: vi.fn(() => {
|
||||
const modelFn = (model: string) => ({ modelId: `openai:${model}` });
|
||||
return modelFn;
|
||||
}),
|
||||
}));
|
||||
|
||||
vi.mock('qwen-ai-provider-v5', () => ({
|
||||
createQwen: vi.fn(() => {
|
||||
const modelFn = (model: string) => ({ modelId: `qwen:${model}` });
|
||||
return modelFn;
|
||||
}),
|
||||
}));
|
||||
|
||||
import { createAnthropic } from '@ai-sdk/anthropic';
|
||||
import { createDeepSeek } from '@ai-sdk/deepseek';
|
||||
import { createOpenAI } from '@ai-sdk/openai';
|
||||
import { createQwen } from 'qwen-ai-provider-v5';
|
||||
|
||||
describe('providers', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('providers 注册表', () => {
|
||||
it('包含所有支持的 provider 类型', () => {
|
||||
expect(providers).toHaveProperty('anthropic');
|
||||
expect(providers).toHaveProperty('deepseek');
|
||||
expect(providers).toHaveProperty('openai');
|
||||
});
|
||||
|
||||
it('每个 provider 是一个工厂函数', () => {
|
||||
expect(typeof providers.anthropic).toBe('function');
|
||||
expect(typeof providers.deepseek).toBe('function');
|
||||
expect(typeof providers.openai).toBe('function');
|
||||
});
|
||||
});
|
||||
|
||||
describe('Anthropic provider', () => {
|
||||
it('创建 Anthropic 客户端', () => {
|
||||
const factory = providers.anthropic({
|
||||
apiKey: 'test-api-key',
|
||||
});
|
||||
|
||||
expect(createAnthropic).toHaveBeenCalledWith({
|
||||
apiKey: 'test-api-key',
|
||||
baseURL: undefined,
|
||||
});
|
||||
expect(typeof factory).toBe('function');
|
||||
});
|
||||
|
||||
it('支持自定义 baseUrl', () => {
|
||||
providers.anthropic({
|
||||
apiKey: 'test-api-key',
|
||||
baseUrl: 'https://custom.anthropic.com',
|
||||
});
|
||||
|
||||
expect(createAnthropic).toHaveBeenCalledWith({
|
||||
apiKey: 'test-api-key',
|
||||
baseURL: 'https://custom.anthropic.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('返回的工厂函数可以创建模型', () => {
|
||||
const factory = providers.anthropic({
|
||||
apiKey: 'test-api-key',
|
||||
});
|
||||
|
||||
const model = factory('claude-3-opus');
|
||||
expect(model).toEqual({ modelId: 'anthropic:claude-3-opus' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('DeepSeek provider', () => {
|
||||
it('创建 DeepSeek 客户端', () => {
|
||||
const factory = providers.deepseek({
|
||||
apiKey: 'test-deepseek-key',
|
||||
});
|
||||
|
||||
expect(createDeepSeek).toHaveBeenCalledWith({
|
||||
apiKey: 'test-deepseek-key',
|
||||
baseURL: undefined,
|
||||
});
|
||||
expect(typeof factory).toBe('function');
|
||||
});
|
||||
|
||||
it('支持自定义 baseUrl', () => {
|
||||
providers.deepseek({
|
||||
apiKey: 'test-deepseek-key',
|
||||
baseUrl: 'https://custom.deepseek.com',
|
||||
});
|
||||
|
||||
expect(createDeepSeek).toHaveBeenCalledWith({
|
||||
apiKey: 'test-deepseek-key',
|
||||
baseURL: 'https://custom.deepseek.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('返回的工厂函数可以创建模型', () => {
|
||||
const factory = providers.deepseek({
|
||||
apiKey: 'test-deepseek-key',
|
||||
});
|
||||
|
||||
const model = factory('deepseek-chat');
|
||||
expect(model).toEqual({ modelId: 'deepseek:deepseek-chat' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('OpenAI provider', () => {
|
||||
it('创建 OpenAI 客户端(标准 URL)', () => {
|
||||
const factory = providers.openai({
|
||||
apiKey: 'test-openai-key',
|
||||
});
|
||||
|
||||
expect(createOpenAI).toHaveBeenCalledWith({
|
||||
apiKey: 'test-openai-key',
|
||||
baseURL: undefined,
|
||||
});
|
||||
expect(createQwen).not.toHaveBeenCalled();
|
||||
expect(typeof factory).toBe('function');
|
||||
});
|
||||
|
||||
it('支持自定义 baseUrl(非 DashScope)', () => {
|
||||
providers.openai({
|
||||
apiKey: 'test-openai-key',
|
||||
baseUrl: 'https://custom.openai.com/v1',
|
||||
});
|
||||
|
||||
expect(createOpenAI).toHaveBeenCalledWith({
|
||||
apiKey: 'test-openai-key',
|
||||
baseURL: 'https://custom.openai.com/v1',
|
||||
});
|
||||
expect(createQwen).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('返回的工厂函数可以创建模型', () => {
|
||||
const factory = providers.openai({
|
||||
apiKey: 'test-openai-key',
|
||||
});
|
||||
|
||||
const model = factory('gpt-4');
|
||||
expect(model).toEqual({ modelId: 'openai:gpt-4' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('DashScope (Qwen) 检测', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('检测 dashscope URL 并使用 Qwen provider', () => {
|
||||
providers.openai({
|
||||
apiKey: 'test-qwen-key',
|
||||
baseUrl: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
});
|
||||
|
||||
expect(createQwen).toHaveBeenCalledWith({
|
||||
apiKey: 'test-qwen-key',
|
||||
baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1',
|
||||
});
|
||||
expect(createOpenAI).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('检测包含 dashscope 的任意 URL', () => {
|
||||
providers.openai({
|
||||
apiKey: 'test-qwen-key',
|
||||
baseUrl: 'https://api.dashscope.example.com/v1',
|
||||
});
|
||||
|
||||
expect(createQwen).toHaveBeenCalled();
|
||||
expect(createOpenAI).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('Qwen 工厂函数可以创建模型', () => {
|
||||
const factory = providers.openai({
|
||||
apiKey: 'test-qwen-key',
|
||||
baseUrl: 'https://dashscope.aliyuncs.com/v1',
|
||||
});
|
||||
|
||||
const model = factory('qwen-turbo');
|
||||
expect(model).toEqual({ modelId: 'qwen:qwen-turbo' });
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('getModelFactory', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('获取 Anthropic 模型工厂', () => {
|
||||
const factory = getModelFactory('anthropic', {
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
|
||||
expect(typeof factory).toBe('function');
|
||||
expect(createAnthropic).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('获取 DeepSeek 模型工厂', () => {
|
||||
const factory = getModelFactory('deepseek', {
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
|
||||
expect(typeof factory).toBe('function');
|
||||
expect(createDeepSeek).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('获取 OpenAI 模型工厂', () => {
|
||||
const factory = getModelFactory('openai', {
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
|
||||
expect(typeof factory).toBe('function');
|
||||
expect(createOpenAI).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('传递正确的选项给 provider', () => {
|
||||
getModelFactory('anthropic', {
|
||||
apiKey: 'my-api-key',
|
||||
baseUrl: 'https://my-proxy.com',
|
||||
});
|
||||
|
||||
expect(createAnthropic).toHaveBeenCalledWith({
|
||||
apiKey: 'my-api-key',
|
||||
baseURL: 'https://my-proxy.com',
|
||||
});
|
||||
});
|
||||
|
||||
it('不支持的 provider 抛出错误', () => {
|
||||
expect(() => {
|
||||
getModelFactory('unsupported' as any, {
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
}).toThrow('不支持的 provider: unsupported');
|
||||
});
|
||||
|
||||
it('返回的工厂函数可以创建模型实例', () => {
|
||||
const factory = getModelFactory('anthropic', {
|
||||
apiKey: 'test-key',
|
||||
});
|
||||
|
||||
const model = factory('claude-3-sonnet');
|
||||
expect(model).toEqual({ modelId: 'anthropic:claude-3-sonnet' });
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user