feat: implement Phase 4 - Multi-Agent Swarm + Skill Discovery
Phase 4a: Agent Swarm Collaboration Framework (agent-swarm.ts) - AgentSwarm class with configurable coordinator + specialist agents - Three collaboration modes: Sequential (chain), Parallel (concurrent), Debate (multi-round) - Auto task decomposition based on specialist capabilities - Debate consensus detection with keyword similarity heuristic - Rule-based result aggregation with structured markdown output - Specialist management (add/update/remove) and config updates - History persistence to localStorage (last 25 tasks) - Memory integration: saves task completion as lesson memories Phase 4b: Skill Discovery Engine (skill-discovery.ts) - SkillDiscoveryEngine with 12 built-in skill definitions from skills/ directory - Multi-signal search: name, description, triggers, capabilities, category matching - Conversation-based skill recommendation via topic extraction (CN + EN patterns) - Memory-augmented confidence scoring for suggestions - Skill registration, install status toggle, category filtering - localStorage persistence for skill index and suggestion cache Phase 4c: chatStore Integration - dispatchSwarmTask(description, style): creates and executes swarm task, adds result as message - searchSkills(query): exposes skill search to UI layer Tests: 317 passing across 13 test files (43 new for swarm + skills) - AgentSwarm: createTask, sequential/parallel/debate execution, history, specialist mgmt - SkillDiscovery: search, suggest, register, persist, categories Refs: ZCLAW_AGENT_INTELLIGENCE_EVOLUTION.md updated - all 4 phases complete
This commit is contained in:
483
tests/desktop/swarm-skills.test.ts
Normal file
483
tests/desktop/swarm-skills.test.ts
Normal file
@@ -0,0 +1,483 @@
|
||||
/**
|
||||
* Tests for Phase 4: Agent Swarm + Skill Discovery
|
||||
*/
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import {
|
||||
AgentSwarm,
|
||||
resetAgentSwarm,
|
||||
getAgentSwarm,
|
||||
AgentExecutor,
|
||||
} from '../../desktop/src/lib/agent-swarm';
|
||||
import {
|
||||
SkillDiscoveryEngine,
|
||||
resetSkillDiscovery,
|
||||
getSkillDiscovery,
|
||||
} from '../../desktop/src/lib/skill-discovery';
|
||||
import { MemoryManager, resetMemoryManager } from '../../desktop/src/lib/agent-memory';
|
||||
|
||||
// === localStorage mock ===
|
||||
const store: Record<string, string> = {};
|
||||
const localStorageMock = {
|
||||
getItem: (key: string) => store[key] ?? null,
|
||||
setItem: (key: string, value: string) => { store[key] = value; },
|
||||
removeItem: (key: string) => { delete store[key]; },
|
||||
clear: () => { for (const k of Object.keys(store)) delete store[k]; },
|
||||
get length() { return Object.keys(store).length; },
|
||||
key: (i: number) => Object.keys(store)[i] ?? null,
|
||||
};
|
||||
Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock, writable: true });
|
||||
|
||||
// === Agent Swarm Tests ===
|
||||
|
||||
describe('AgentSwarm', () => {
|
||||
let swarm: AgentSwarm;
|
||||
let mockExecutor: ReturnType<typeof vi.fn<AgentExecutor>>;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear();
|
||||
resetAgentSwarm();
|
||||
resetMemoryManager();
|
||||
mockExecutor = vi.fn<AgentExecutor>(
|
||||
async (agentId: string, prompt: string, _context?: string) => `[${agentId}] 完成: ${prompt.slice(0, 30)}`
|
||||
);
|
||||
swarm = new AgentSwarm({
|
||||
coordinator: 'main-agent',
|
||||
specialists: [
|
||||
{ agentId: 'dev-agent', role: '开发工程师', capabilities: ['coding', 'review'] },
|
||||
{ agentId: 'pm-agent', role: '产品经理', capabilities: ['planning', 'requirements'] },
|
||||
{ agentId: 'qa-agent', role: '测试工程师', capabilities: ['testing', 'qa'] },
|
||||
],
|
||||
});
|
||||
swarm.setExecutor(mockExecutor as unknown as AgentExecutor);
|
||||
});
|
||||
|
||||
describe('createTask', () => {
|
||||
it('creates task with auto decomposition', () => {
|
||||
const task = swarm.createTask('实现用户登录功能');
|
||||
expect(task.id).toMatch(/^swarm_/);
|
||||
expect(task.description).toBe('实现用户登录功能');
|
||||
expect(task.status).toBe('planning');
|
||||
expect(task.subtasks.length).toBe(3); // one per specialist
|
||||
});
|
||||
|
||||
it('creates task with manual subtasks', () => {
|
||||
const task = swarm.createTask('发布新版本', {
|
||||
subtasks: [
|
||||
{ assignedTo: 'dev-agent', description: '打包构建' },
|
||||
{ assignedTo: 'qa-agent', description: '回归测试' },
|
||||
],
|
||||
});
|
||||
expect(task.subtasks.length).toBe(2);
|
||||
expect(task.subtasks[0].assignedTo).toBe('dev-agent');
|
||||
expect(task.subtasks[1].assignedTo).toBe('qa-agent');
|
||||
});
|
||||
|
||||
it('creates task with custom communication style', () => {
|
||||
const task = swarm.createTask('讨论技术选型', {
|
||||
communicationStyle: 'debate',
|
||||
});
|
||||
expect(task.communicationStyle).toBe('debate');
|
||||
});
|
||||
|
||||
it('falls back to coordinator when no specialists', () => {
|
||||
const emptySwarm = new AgentSwarm({ coordinator: 'solo' });
|
||||
emptySwarm.setExecutor(mockExecutor as unknown as AgentExecutor);
|
||||
const task = emptySwarm.createTask('单人任务');
|
||||
expect(task.subtasks.length).toBe(1);
|
||||
expect(task.subtasks[0].assignedTo).toBe('solo');
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - sequential', () => {
|
||||
it('executes subtasks in order with context chaining', async () => {
|
||||
const task = swarm.createTask('设计并实现API', {
|
||||
communicationStyle: 'sequential',
|
||||
subtasks: [
|
||||
{ assignedTo: 'pm-agent', description: '需求分析' },
|
||||
{ assignedTo: 'dev-agent', description: '编码实现' },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await swarm.execute(task);
|
||||
expect(result.task.status).toBe('done');
|
||||
expect(mockExecutor).toHaveBeenCalledTimes(2); // 2 subtasks
|
||||
// First call has empty context
|
||||
expect(mockExecutor.mock.calls[0][2]).toBe('');
|
||||
// Second call has previous result as context
|
||||
expect(mockExecutor.mock.calls[1][2]).toContain('前一个Agent的输出');
|
||||
});
|
||||
|
||||
it('handles subtask failure gracefully', async () => {
|
||||
mockExecutor.mockImplementationOnce(async () => { throw new Error('Agent offline'); });
|
||||
|
||||
const task = swarm.createTask('有风险的任务', {
|
||||
subtasks: [
|
||||
{ assignedTo: 'dev-agent', description: '可能失败的任务' },
|
||||
{ assignedTo: 'qa-agent', description: '后续任务' },
|
||||
],
|
||||
});
|
||||
|
||||
const result = await swarm.execute(task);
|
||||
expect(result.task.subtasks[0].status).toBe('failed');
|
||||
expect(result.task.subtasks[0].error).toBe('Agent offline');
|
||||
expect(result.task.status).toBe('done'); // task still completes
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - parallel', () => {
|
||||
it('executes all subtasks simultaneously', async () => {
|
||||
const task = swarm.createTask('全方位分析', {
|
||||
communicationStyle: 'parallel',
|
||||
});
|
||||
|
||||
const result = await swarm.execute(task);
|
||||
expect(result.task.status).toBe('done');
|
||||
expect(result.participantCount).toBe(3);
|
||||
// All subtasks should be done
|
||||
const doneCount = result.task.subtasks.filter(s => s.status === 'done').length;
|
||||
expect(doneCount).toBe(3);
|
||||
});
|
||||
|
||||
it('continues even if some parallel subtasks fail', async () => {
|
||||
mockExecutor
|
||||
.mockImplementationOnce(async () => 'success-1')
|
||||
.mockImplementationOnce(async () => { throw new Error('fail'); })
|
||||
.mockImplementationOnce(async () => 'success-2');
|
||||
|
||||
const task = swarm.createTask('混合结果', { communicationStyle: 'parallel' });
|
||||
const result = await swarm.execute(task);
|
||||
|
||||
const done = result.task.subtasks.filter(s => s.status === 'done');
|
||||
const failed = result.task.subtasks.filter(s => s.status === 'failed');
|
||||
expect(done.length).toBe(2);
|
||||
expect(failed.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('execute - debate', () => {
|
||||
it('runs multiple rounds of debate', async () => {
|
||||
const task = swarm.createTask('选择数据库: PostgreSQL vs MongoDB', {
|
||||
communicationStyle: 'debate',
|
||||
});
|
||||
|
||||
const result = await swarm.execute(task);
|
||||
expect(result.task.status).toBe('done');
|
||||
// Should have subtasks from multiple rounds
|
||||
expect(result.task.subtasks.length).toBeGreaterThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('stops early on consensus', async () => {
|
||||
// Make all agents return identical responses to trigger consensus
|
||||
mockExecutor.mockImplementation(async () => '我建议使用 PostgreSQL 因为它支持 JSONB 和强一致性');
|
||||
|
||||
const task = swarm.createTask('数据库选型', { communicationStyle: 'debate' });
|
||||
const result = await swarm.execute(task);
|
||||
|
||||
expect(result.task.status).toBe('done');
|
||||
// Should stop before max rounds due to consensus
|
||||
});
|
||||
});
|
||||
|
||||
describe('history', () => {
|
||||
it('stores executed tasks in history', async () => {
|
||||
const task1 = swarm.createTask('任务1', {
|
||||
subtasks: [{ assignedTo: 'dev-agent', description: '小任务' }],
|
||||
});
|
||||
await swarm.execute(task1);
|
||||
|
||||
const history = swarm.getHistory();
|
||||
expect(history.length).toBe(1);
|
||||
expect(history[0].description).toBe('任务1');
|
||||
});
|
||||
|
||||
it('retrieves task by ID', async () => {
|
||||
const task = swarm.createTask('查找任务', {
|
||||
subtasks: [{ assignedTo: 'dev-agent', description: '测试' }],
|
||||
});
|
||||
await swarm.execute(task);
|
||||
|
||||
const found = swarm.getTask(task.id);
|
||||
expect(found).toBeDefined();
|
||||
expect(found!.description).toBe('查找任务');
|
||||
});
|
||||
|
||||
it('persists history to localStorage', async () => {
|
||||
const task = swarm.createTask('持久化测试', {
|
||||
subtasks: [{ assignedTo: 'dev-agent', description: '测试' }],
|
||||
});
|
||||
await swarm.execute(task);
|
||||
|
||||
// Create new instance — should load from localStorage
|
||||
const swarm2 = new AgentSwarm();
|
||||
const history = swarm2.getHistory();
|
||||
expect(history.length).toBe(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('specialist management', () => {
|
||||
it('lists specialists', () => {
|
||||
expect(swarm.getSpecialists().length).toBe(3);
|
||||
});
|
||||
|
||||
it('adds a specialist', () => {
|
||||
swarm.addSpecialist({ agentId: 'design-agent', role: '设计师', capabilities: ['UI', 'UX'] });
|
||||
expect(swarm.getSpecialists().length).toBe(4);
|
||||
});
|
||||
|
||||
it('updates existing specialist', () => {
|
||||
swarm.addSpecialist({ agentId: 'dev-agent', role: '高级开发', capabilities: ['coding', 'architecture'] });
|
||||
const specs = swarm.getSpecialists();
|
||||
expect(specs.length).toBe(3);
|
||||
expect(specs.find(s => s.agentId === 'dev-agent')!.role).toBe('高级开发');
|
||||
});
|
||||
|
||||
it('removes a specialist', () => {
|
||||
swarm.removeSpecialist('qa-agent');
|
||||
expect(swarm.getSpecialists().length).toBe(2);
|
||||
});
|
||||
});
|
||||
|
||||
describe('config', () => {
|
||||
it('returns current config', () => {
|
||||
const config = swarm.getConfig();
|
||||
expect(config.coordinator).toBe('main-agent');
|
||||
expect(config.specialists.length).toBe(3);
|
||||
});
|
||||
|
||||
it('updates config', () => {
|
||||
swarm.updateConfig({ maxRoundsDebate: 5 });
|
||||
expect(swarm.getConfig().maxRoundsDebate).toBe(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('singleton', () => {
|
||||
it('returns same instance', () => {
|
||||
const a = getAgentSwarm();
|
||||
const b = getAgentSwarm();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it('resets singleton', () => {
|
||||
const a = getAgentSwarm();
|
||||
resetAgentSwarm();
|
||||
const b = getAgentSwarm();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
|
||||
describe('error handling', () => {
|
||||
it('throws if no executor set', async () => {
|
||||
const noExecSwarm = new AgentSwarm();
|
||||
const task = noExecSwarm.createTask('无执行器');
|
||||
await expect(noExecSwarm.execute(task)).rejects.toThrow('No executor');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
// === Skill Discovery Tests ===
|
||||
|
||||
describe('SkillDiscoveryEngine', () => {
|
||||
let engine: SkillDiscoveryEngine;
|
||||
|
||||
beforeEach(() => {
|
||||
localStorageMock.clear();
|
||||
resetSkillDiscovery();
|
||||
resetMemoryManager();
|
||||
engine = new SkillDiscoveryEngine();
|
||||
});
|
||||
|
||||
describe('searchSkills', () => {
|
||||
it('returns all skills for empty query', () => {
|
||||
const result = engine.searchSkills('');
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
expect(result.totalAvailable).toBe(result.results.length);
|
||||
});
|
||||
|
||||
it('finds skills by name', () => {
|
||||
const result = engine.searchSkills('Code Review');
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
expect(result.results[0].id).toBe('code-review');
|
||||
});
|
||||
|
||||
it('finds skills by Chinese trigger', () => {
|
||||
const result = engine.searchSkills('审查代码');
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
expect(result.results[0].id).toBe('code-review');
|
||||
});
|
||||
|
||||
it('finds skills by capability', () => {
|
||||
const result = engine.searchSkills('安全审计');
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
const ids = result.results.map(s => s.id);
|
||||
expect(ids).toContain('security-engineer');
|
||||
});
|
||||
|
||||
it('finds skills by category keyword', () => {
|
||||
const result = engine.searchSkills('development');
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('returns empty for non-matching query', () => {
|
||||
const result = engine.searchSkills('量子计算');
|
||||
expect(result.results.length).toBe(0);
|
||||
});
|
||||
|
||||
it('ranks exact trigger match higher', () => {
|
||||
const result = engine.searchSkills('git');
|
||||
expect(result.results.length).toBeGreaterThan(0);
|
||||
expect(result.results[0].id).toBe('git');
|
||||
});
|
||||
});
|
||||
|
||||
describe('suggestSkills', () => {
|
||||
it('suggests skills based on conversation content', async () => {
|
||||
const conversations = [
|
||||
{ role: 'user', content: '帮我审查一下这段代码的安全性' },
|
||||
{ role: 'assistant', content: '好的,我来检查...' },
|
||||
{ role: 'user', content: '还需要做一下API测试' },
|
||||
];
|
||||
|
||||
const suggestions = await engine.suggestSkills(conversations, 'agent-1');
|
||||
expect(suggestions.length).toBeGreaterThan(0);
|
||||
// Should suggest security or code review related skills
|
||||
const ids = suggestions.map(s => s.skill.id);
|
||||
expect(ids.some(id => ['code-review', 'security-engineer', 'api-tester'].includes(id))).toBe(true);
|
||||
});
|
||||
|
||||
it('returns empty for unrelated conversations', async () => {
|
||||
const conversations = [
|
||||
{ role: 'user', content: '今天天气真好' },
|
||||
{ role: 'assistant', content: '是的' },
|
||||
];
|
||||
|
||||
const suggestions = await engine.suggestSkills(conversations, 'agent-1');
|
||||
// May or may not have suggestions, but shouldn't crash
|
||||
expect(Array.isArray(suggestions)).toBe(true);
|
||||
});
|
||||
|
||||
it('limits results to specified count', async () => {
|
||||
const conversations = [
|
||||
{ role: 'user', content: '帮我做代码审查、数据分析、API测试、安全检查、前端开发、写文章' },
|
||||
];
|
||||
|
||||
const suggestions = await engine.suggestSkills(conversations, 'agent-1', 3);
|
||||
expect(suggestions.length).toBeLessThanOrEqual(3);
|
||||
});
|
||||
|
||||
it('includes confidence score and reason', async () => {
|
||||
const conversations = [
|
||||
{ role: 'user', content: '帮我审查代码' },
|
||||
];
|
||||
|
||||
const suggestions = await engine.suggestSkills(conversations, 'agent-1');
|
||||
if (suggestions.length > 0) {
|
||||
expect(suggestions[0].confidence).toBeGreaterThan(0);
|
||||
expect(suggestions[0].confidence).toBeLessThanOrEqual(1);
|
||||
expect(suggestions[0].reason.length).toBeGreaterThan(0);
|
||||
expect(suggestions[0].matchedPatterns.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('skill management', () => {
|
||||
it('gets all skills', () => {
|
||||
const skills = engine.getAllSkills();
|
||||
expect(skills.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
it('filters by category', () => {
|
||||
const devSkills = engine.getSkillsByCategory('development');
|
||||
expect(devSkills.length).toBeGreaterThan(0);
|
||||
expect(devSkills.every(s => s.category === 'development')).toBe(true);
|
||||
});
|
||||
|
||||
it('lists categories', () => {
|
||||
const categories = engine.getCategories();
|
||||
expect(categories.length).toBeGreaterThan(0);
|
||||
expect(categories).toContain('development');
|
||||
});
|
||||
|
||||
it('registers a new skill', () => {
|
||||
const countBefore = engine.getAllSkills().length;
|
||||
engine.registerSkill({
|
||||
id: 'custom-skill',
|
||||
name: 'Custom Skill',
|
||||
description: 'A custom skill',
|
||||
triggers: ['custom'],
|
||||
capabilities: ['custom-work'],
|
||||
toolDeps: [],
|
||||
installed: false,
|
||||
category: 'custom',
|
||||
});
|
||||
expect(engine.getAllSkills().length).toBe(countBefore + 1);
|
||||
});
|
||||
|
||||
it('updates existing skill on re-register', () => {
|
||||
engine.registerSkill({
|
||||
id: 'code-review',
|
||||
name: 'Code Review Pro',
|
||||
description: 'Enhanced code review',
|
||||
triggers: ['审查代码'],
|
||||
capabilities: ['深度分析'],
|
||||
toolDeps: ['read'],
|
||||
installed: true,
|
||||
category: 'development',
|
||||
});
|
||||
const skill = engine.getAllSkills().find(s => s.id === 'code-review');
|
||||
expect(skill!.name).toBe('Code Review Pro');
|
||||
});
|
||||
|
||||
it('toggles install status', () => {
|
||||
engine.setSkillInstalled('code-review', false);
|
||||
const skill = engine.getAllSkills().find(s => s.id === 'code-review');
|
||||
expect(skill!.installed).toBe(false);
|
||||
|
||||
engine.setSkillInstalled('code-review', true);
|
||||
const skill2 = engine.getAllSkills().find(s => s.id === 'code-review');
|
||||
expect(skill2!.installed).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('persistence', () => {
|
||||
it('persists skills to localStorage', () => {
|
||||
engine.registerSkill({
|
||||
id: 'persist-test',
|
||||
name: 'Persist Test',
|
||||
description: 'test',
|
||||
triggers: [],
|
||||
capabilities: [],
|
||||
toolDeps: [],
|
||||
installed: false,
|
||||
});
|
||||
|
||||
const engine2 = new SkillDiscoveryEngine();
|
||||
const skill = engine2.getAllSkills().find(s => s.id === 'persist-test');
|
||||
expect(skill).toBeDefined();
|
||||
});
|
||||
|
||||
it('caches suggestions', async () => {
|
||||
const conversations = [
|
||||
{ role: 'user', content: '帮我审查代码' },
|
||||
];
|
||||
await engine.suggestSkills(conversations, 'agent-1');
|
||||
|
||||
const cached = engine.getLastSuggestions();
|
||||
expect(Array.isArray(cached)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('singleton', () => {
|
||||
it('returns same instance', () => {
|
||||
const a = getSkillDiscovery();
|
||||
const b = getSkillDiscovery();
|
||||
expect(a).toBe(b);
|
||||
});
|
||||
|
||||
it('resets singleton', () => {
|
||||
const a = getSkillDiscovery();
|
||||
resetSkillDiscovery();
|
||||
const b = getSkillDiscovery();
|
||||
expect(a).not.toBe(b);
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user