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:
iven
2026-03-15 22:44:18 +08:00
parent 04ddf94123
commit 137f1a32fa
5 changed files with 1555 additions and 7 deletions

View 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);
});
});
});