/** * Phase 6 — Capability Truth integration tests. * * Proves the cross-system success criteria for Waves O/P/Q/R: * Capability Router, Starter Skills, Plugin Runtime, MCP Runtime, * Command Registry, Hooks, Workflow Templates, Sub-agent Orchestrator. * * All tests are self-contained — no server, no Docker, no network. */ import { describe, it, expect, afterEach } from 'vitest'; import * as fs from 'node:fs'; import * as path from 'node:path'; import * as os from 'node:os'; import { CapabilityRouter, type CapabilityRouterDeps } from '../../src/capability-router.js'; import { listStarterSkills, installStarterSkills, PluginRuntimeManager, type PluginManifestWithTools, } from '@waggle/sdk'; import { CommandRegistry } from '../../src/commands/command-registry.js'; import { registerWorkflowCommands } from '../../src/commands/workflow-commands.js'; import { HookRegistry, type HookEvent } from '../../src/hooks.js'; import { listWorkflowTemplates, WORKFLOW_TEMPLATES } from '../../src/workflow-templates.js'; // ── Helpers ────────────────────────────────────────────────────────────── function makeDeps(overrides: Partial = {}): CapabilityRouterDeps { return { toolNames: [], skills: [], plugins: [], mcpServers: [], subAgentRoles: [], ...overrides, }; } function makeTestPlugin(name: string, tools: string[]): PluginManifestWithTools { return { name, version: '1.0.0', description: `Test plugin: ${name}`, tools: tools.map((t) => ({ name: t, description: `Tool ${t}`, parameters: { type: 'object', properties: {} }, })), }; } // ── Temp dir management ───────────────────────────────────────────────── const tmpDirs: string[] = []; function makeTmpDir(): string { const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'waggle-p6-')); tmpDirs.push(dir); return dir; } afterEach(() => { for (const d of tmpDirs) { try { fs.rmSync(d, { recursive: true, force: true }); } catch { // best-effort cleanup } } tmpDirs.length = 0; }); // ═════════════════════════════════════════════════════════════════════════ // Tests // ═════════════════════════════════════════════════════════════════════════ describe('Phase 6 — Capability Truth integration', () => { // ── 1. Capability Router Fallback Chain ────────────────────────────── it('resolves native -> skill -> plugin -> MCP -> subagent fallback chain', () => { const router = new CapabilityRouter( makeDeps({ toolNames: ['research_tool'], skills: [{ name: 'research-deep', content: 'Deep research into any topic' }], plugins: [{ name: 'research-plugin', description: 'Research helper' }], mcpServers: ['research-mcp'], subAgentRoles: ['researcher'], }), ); // Full resolution: every source type present, sorted by confidence const routes = router.resolve('research'); expect(routes.length).toBe(5); const sources = routes.map((r) => r.source); expect(sources).toEqual(['native', 'skill', 'plugin', 'mcp', 'subagent']); // Native comes first with highest confidence expect(routes[0].confidence).toBeGreaterThan(routes[1].confidence); expect(routes[routes.length - 1].source).toBe('subagent'); // Every route is available expect(routes.every((r) => r.available)).toBe(true); // Exact native match → confidence 1.0 const exactRouter = new CapabilityRouter(makeDeps({ toolNames: ['save_memory'] })); const exact = exactRouter.resolve('save_memory'); expect(exact[0]).toMatchObject({ source: 'native', confidence: 1.0 }); // Nothing matches → missing with suggestion const emptyRouter = new CapabilityRouter(makeDeps()); const missing = emptyRouter.resolve('quantum_teleport'); expect(missing).toHaveLength(1); expect(missing[0]).toMatchObject({ source: 'missing', available: false }); expect(missing[0].suggestion).toBeTruthy(); }); // ── 2. Starter Skills Auto-Install ────────────────────────────────── it('starter skills: listStarterSkills returns 18, installStarterSkills copies to target', () => { const skills = listStarterSkills(); expect(skills).toHaveLength(18); // Spot-check well-known skill names expect(skills).toContain('catch-up'); expect(skills).toContain('research-synthesis'); expect(skills).toContain('decision-matrix'); // Install to temp dir const targetDir = makeTmpDir(); const installed = installStarterSkills(targetDir); expect(installed).toHaveLength(18); // Verify files physically exist const files = fs.readdirSync(targetDir).filter((f) => f.endsWith('.md')); expect(files).toHaveLength(18); // Idempotent: second install returns 0 (no overwrite) const secondRun = installStarterSkills(targetDir); expect(secondRun).toHaveLength(0); }); // ── 3. Plugin Lifecycle ───────────────────────────────────────────── it('plugin lifecycle: register -> enable -> tools visible -> disable -> tools removed', async () => { const mgr = new PluginRuntimeManager(); const manifest = makeTestPlugin('test-plugin', ['tool_a', 'tool_b']); // Register — state is installed, no tools yet mgr.register(manifest); expect(mgr.getPluginStates()).toEqual({ 'test-plugin': 'installed' }); expect(mgr.getAllTools()).toHaveLength(0); // Enable — transitions to active, tools appear await mgr.enable('test-plugin'); expect(mgr.getPluginStates()).toEqual({ 'test-plugin': 'active' }); const tools = mgr.getAllTools(); expect(tools).toHaveLength(2); expect(tools.map((t) => t.name).sort()).toEqual(['tool_a', 'tool_b']); // Tools are executable (default executor returns JSON) const result = await tools[0].execute({ input: 'test' }); expect(JSON.parse(result)).toMatchObject({ tool: tools[0].name, status: 'executed' }); // Disable — tools removed mgr.disable('test-plugin'); expect(mgr.getPluginStates()).toEqual({ 'test-plugin': 'disabled' }); expect(mgr.getAllTools()).toHaveLength(0); }); // ── 4. Command Registry End-to-End ────────────────────────────────── it('command registry: registerWorkflowCommands -> execute /catchup returns markdown', async () => { const registry = new CommandRegistry(); registerWorkflowCommands(registry); // 22 commands registered (13 original + plugins, export, import, settings, cli + search-all, connectors, workflow + pr) expect(registry.list()).toHaveLength(22); // Execute /catchup with mock context const ctx = { workspaceId: 'ws-test', sessionId: 's-test', getWorkspaceState: async () => 'Session count: 5. Recent: architecture review.', }; const catchupResult = await registry.execute('/catchup', ctx); expect(catchupResult).toContain('Catch-Up Briefing'); // Execute /help — lists all commands const helpResult = await registry.execute('/help', ctx); expect(helpResult).toContain('Available Commands'); expect(helpResult).toContain('/catchup'); expect(helpResult).toContain('/research'); expect(helpResult).toContain('/spawn'); // Search partial match const searchResults = registry.search('res'); const names = searchResults.map((c) => c.name); expect(names).toContain('research'); // Alias resolution: /catch-up resolves to catchup const aliasResult = await registry.execute('/catch-up', ctx); expect(aliasResult).toContain('Catch-Up Briefing'); // Unknown command returns helpful message const unknownResult = await registry.execute('/foobar', ctx); expect(unknownResult).toContain('Unknown command'); }); // ── 5. Hook Scoping ───────────────────────────────────────────────── it('workspace-scoped hook fires only for matching workspace', async () => { const hooks = new HookRegistry(); const calls: string[] = []; hooks.onScoped( 'pre:tool', (ctx) => { calls.push(`ws1:${ctx.toolName}`); }, { workspaceId: 'ws-1' }, ); // Fire for ws-1 — handler called await hooks.fire('pre:tool', { workspaceId: 'ws-1', toolName: 'save_memory' }); expect(calls).toEqual(['ws1:save_memory']); // Fire for ws-2 — handler NOT called await hooks.fire('pre:tool', { workspaceId: 'ws-2', toolName: 'read_file' }); expect(calls).toEqual(['ws1:save_memory']); // unchanged // Fire for ws-1 again await hooks.fire('pre:tool', { workspaceId: 'ws-1', toolName: 'search_memory' }); expect(calls).toEqual(['ws1:save_memory', 'ws1:search_memory']); }); // ── 6. Hook Activity Log ──────────────────────────────────────────── it('hook activity log records fires and caps at 50', async () => { const hooks = new HookRegistry(); hooks.on('session:start', () => { /* no-op */ }); // Fire 55 times for (let i = 0; i < 55; i++) { await hooks.fire('session:start', { sessionId: `s-${i}` }); } const log = hooks.getActivityLog(); expect(log).toHaveLength(50); // Most recent entry should be from the last fire (i=54) expect(log[log.length - 1].event).toBe('session:start'); // Oldest surviving entry should be from i=5 (first 5 were evicted) // (entries 0-4 evicted, 5-54 remain = 50 entries) expect(log[0].event).toBe('session:start'); }); // ── 7. Memory-Write Hook Events ───────────────────────────────────── it('pre:memory-write and post:memory-write events can be registered and fired', async () => { const hooks = new HookRegistry(); const captured: { event: string; content: string | undefined }[] = []; hooks.on('pre:memory-write', (ctx) => { captured.push({ event: 'pre:memory-write', content: ctx.memoryContent }); }); hooks.on('post:memory-write', (ctx) => { captured.push({ event: 'post:memory-write', content: ctx.memoryContent }); }); await hooks.fire('pre:memory-write', { memoryContent: 'Architecture decision: use SQLite', memoryType: 'decision', }); await hooks.fire('post:memory-write', { memoryContent: 'Architecture decision: use SQLite', memoryType: 'decision', }); expect(captured).toHaveLength(2); expect(captured[0]).toMatchObject({ event: 'pre:memory-write', content: 'Architecture decision: use SQLite', }); expect(captured[1]).toMatchObject({ event: 'post:memory-write', content: 'Architecture decision: use SQLite', }); }); // ── 8. Workflow Templates ─────────────────────────────────────────── it('workflow templates: 5 available, each has description and steps', () => { const templateNames = listWorkflowTemplates(); expect(templateNames).toHaveLength(5); expect(templateNames).toContain('research-team'); expect(templateNames).toContain('review-pair'); expect(templateNames).toContain('plan-execute'); // Each factory produces a template with description and steps for (const name of templateNames) { const factory = WORKFLOW_TEMPLATES[name]; expect(factory).toBeDefined(); const template = factory('test task'); expect(template.name).toBe(name); expect(template.description).toBeTruthy(); expect(template.steps.length).toBeGreaterThanOrEqual(2); expect(template.aggregation).toBeTruthy(); // Each step has name, role, task for (const step of template.steps) { expect(step.name).toBeTruthy(); expect(step.role).toBeTruthy(); expect(step.task).toContain('test task'); } } // research-team specifically has 3 steps with dependency chain const research = WORKFLOW_TEMPLATES['research-team']('topic'); expect(research.steps).toHaveLength(3); expect(research.steps[1].dependsOn).toContain('Researcher'); expect(research.steps[2].dependsOn).toContain('Synthesizer'); expect(research.steps[2].contextFrom).toContain('Researcher'); expect(research.steps[2].contextFrom).toContain('Synthesizer'); }); // ── 9. Workflow Lifecycle Hooks ───────────────────────────────────── it('workflow:start and workflow:end hooks fire correctly', async () => { const hooks = new HookRegistry(); const events: { event: string; name?: string; task?: string }[] = []; hooks.on('workflow:start', (ctx) => { events.push({ event: 'workflow:start', name: ctx.workflowName, task: ctx.workflowTask }); }); hooks.on('workflow:end', (ctx) => { events.push({ event: 'workflow:end', name: ctx.workflowName, task: ctx.workflowTask }); }); await hooks.fire('workflow:start', { workflowName: 'research-team', workflowTask: 'Investigate quantum computing', }); await hooks.fire('workflow:end', { workflowName: 'research-team', workflowTask: 'Investigate quantum computing', }); expect(events).toHaveLength(2); expect(events[0]).toMatchObject({ event: 'workflow:start', name: 'research-team', task: 'Investigate quantum computing', }); expect(events[1]).toMatchObject({ event: 'workflow:end', name: 'research-team', task: 'Investigate quantum computing', }); // Activity log captures both fires const log = hooks.getActivityLog(); expect(log.filter((e) => e.event === 'workflow:start')).toHaveLength(1); expect(log.filter((e) => e.event === 'workflow:end')).toHaveLength(1); }); // ── 10. Cross-System Integration ──────────────────────────────────── it('capability router + commands + hooks work together', async () => { // -- Set up all three systems -- const hooks = new HookRegistry(); const registry = new CommandRegistry(); registerWorkflowCommands(registry); const hookLog: string[] = []; // Hook tracks tool usage hooks.on('pre:tool', (ctx) => { hookLog.push(`pre:tool:${ctx.toolName}`); }); // Hook tracks command execution via workflow events hooks.on('workflow:start', (ctx) => { hookLog.push(`workflow:start:${ctx.workflowName}`); }); // Router has native tools, skills matching commands, and plugin const router = new CapabilityRouter( makeDeps({ toolNames: ['save_memory', 'search_memory', 'read_file'], skills: [ { name: 'catch-up', content: 'Workspace restart summary' }, { name: 'research-synthesis', content: 'Deep research into topic' }, ], plugins: [ { name: 'web-research', description: 'Web research tools for scraping', skills: ['web-research'] }, ], mcpServers: ['github-mcp'], subAgentRoles: ['researcher', 'writer', 'coder'], }), ); // 1. Router resolves a known native tool const memRoutes = router.resolve('save_memory'); expect(memRoutes[0]).toMatchObject({ source: 'native', confidence: 1.0 }); // 2. Fire pre:tool hook as agent would before executing the tool await hooks.fire('pre:tool', { toolName: 'save_memory', workspaceId: 'ws-1' }); expect(hookLog).toContain('pre:tool:save_memory'); // 3. Router resolves something that needs a skill const catchupRoutes = router.resolve('catch-up'); expect(catchupRoutes.some((r) => r.source === 'skill' && r.name === 'catch-up')).toBe(true); // 4. Execute the corresponding command const ctx = { workspaceId: 'ws-1', sessionId: 's-1', getWorkspaceState: async () => '3 sessions. Last: code review.', }; const result = await registry.execute('/catchup', ctx); expect(result).toContain('Catch-Up Briefing'); // 5. Fire workflow hook as orchestrator would await hooks.fire('workflow:start', { workflowName: 'research-team', workflowTask: 'test' }); expect(hookLog).toContain('workflow:start:research-team'); // 6. Router resolves web scraping → plugin const webRoutes = router.resolve('scraping'); expect(webRoutes.some((r) => r.source === 'plugin' && r.name === 'web-research')).toBe(true); // 7. Router resolves coding → subagent const codeRoutes = router.resolve('code'); expect(codeRoutes.some((r) => r.source === 'subagent' && r.name === 'coder')).toBe(true); // 8. Activity log has all fires const activityLog = hooks.getActivityLog(); expect(activityLog).toHaveLength(2); // pre:tool + workflow:start expect(activityLog.map((e) => e.event)).toEqual(['pre:tool', 'workflow:start']); }); });