diff --git a/src/test/e2e/decompose-workflow.test.ts b/src/test/e2e/decompose-workflow.test.ts new file mode 100644 index 0000000..c7dc6e1 --- /dev/null +++ b/src/test/e2e/decompose-workflow.test.ts @@ -0,0 +1,301 @@ +/** + * E2E Tests for Decompose Workflow + * + * Tests the complete decomposition workflow from plan creation through task creation: + * - Decompose mode: Break plan into executable tasks + * - Q&A flow: Handle clarifying questions during decomposition + * - Task persistence: Save tasks from decomposition output + * + * Uses TestHarness from src/test/ for full system wiring. + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { createTestHarness, type TestHarness } from '../index.js'; +import type { AgentStoppedEvent, AgentWaitingEvent } from '../../events/types.js'; + +describe('Decompose Workflow E2E', () => { + let harness: TestHarness; + + beforeEach(() => { + harness = createTestHarness(); + }); + + afterEach(() => { + harness.cleanup(); + vi.useRealTimers(); + }); + + describe('spawn decompose agent', () => { + it('should spawn agent in decompose mode and complete with tasks', async () => { + vi.useFakeTimers(); + + // Setup: Create initiative -> phase -> plan + const initiative = await harness.createInitiative('Test Project', 'Test project description'); + const phases = await harness.createPhasesFromBreakdown(initiative.id, [ + { number: 1, name: 'Phase 1', description: 'First phase' }, + ]); + const plan = await harness.createPlan(phases[0].id, 'Auth Plan', 'Implement authentication'); + + // Set decompose scenario + harness.setArchitectDecomposeComplete('decomposer', [ + { number: 1, name: 'Create schema', description: 'User table', type: 'auto', dependencies: [] }, + { number: 2, name: 'Create endpoint', description: 'Login API', type: 'auto', dependencies: [1] }, + ]); + + // Spawn decompose agent + const agent = await harness.caller.spawnArchitectDecompose({ + name: 'decomposer', + planId: plan.id, + }); + + expect(agent.mode).toBe('decompose'); + + // Advance timers for async completion + await harness.advanceTimers(); + + // Verify agent completed + const events = harness.getEmittedEvents('agent:stopped') as AgentStoppedEvent[]; + expect(events).toHaveLength(1); + expect(events[0].payload.name).toBe('decomposer'); + expect(events[0].payload.reason).toBe('decompose_complete'); + }); + + it('should pause on questions and resume', async () => { + vi.useFakeTimers(); + + const initiative = await harness.createInitiative('Test Project'); + const phases = await harness.createPhasesFromBreakdown(initiative.id, [ + { number: 1, name: 'Phase 1', description: 'First phase' }, + ]); + const plan = await harness.createPlan(phases[0].id, 'Complex Plan'); + + // Set questions scenario + harness.setArchitectDecomposeQuestions('decomposer', [ + { id: 'q1', question: 'How granular should tasks be?' }, + ]); + + const agent = await harness.caller.spawnArchitectDecompose({ + name: 'decomposer', + planId: plan.id, + }); + + await harness.advanceTimers(); + + // Verify agent is waiting for input + const waitingAgent = await harness.caller.getAgent({ name: 'decomposer' }); + expect(waitingAgent?.status).toBe('waiting_for_input'); + + // Verify paused on questions (emits agent:waiting, not agent:stopped) + const waitingEvents = harness.getEmittedEvents('agent:waiting') as AgentWaitingEvent[]; + expect(waitingEvents).toHaveLength(1); + expect(waitingEvents[0].payload.questions).toHaveLength(1); + + // Get pending questions + const pending = await harness.mockAgentManager.getPendingQuestions(agent.id); + expect(pending?.questions).toHaveLength(1); + expect(pending?.questions[0].question).toBe('How granular should tasks be?'); + + // Set completion scenario for resume + harness.setArchitectDecomposeComplete('decomposer', [ + { number: 1, name: 'Task 1', description: 'Single task', type: 'auto', dependencies: [] }, + ]); + + // Resume with answer + await harness.caller.resumeAgent({ + name: 'decomposer', + answers: { q1: 'Very granular' }, + }); + await harness.advanceTimers(); + + // Verify completed after resume + const finalAgent = await harness.caller.getAgent({ name: 'decomposer' }); + expect(finalAgent?.status).toBe('idle'); + }); + + it('should handle multiple questions', async () => { + vi.useFakeTimers(); + + const initiative = await harness.createInitiative('Multi-Q Project'); + const phases = await harness.createPhasesFromBreakdown(initiative.id, [ + { number: 1, name: 'Phase 1', description: 'First phase' }, + ]); + const plan = await harness.createPlan(phases[0].id, 'Complex Plan'); + + // Set multiple questions scenario + harness.setArchitectDecomposeQuestions('decomposer', [ + { id: 'q1', question: 'What task granularity?', options: [{ label: 'Fine' }, { label: 'Coarse' }] }, + { id: 'q2', question: 'Include checkpoints?' }, + { id: 'q3', question: 'Any blocking dependencies?' }, + ]); + + const agent = await harness.caller.spawnArchitectDecompose({ + name: 'decomposer', + planId: plan.id, + }); + + await harness.advanceTimers(); + + // Verify all questions received + const pending = await harness.mockAgentManager.getPendingQuestions(agent.id); + expect(pending?.questions).toHaveLength(3); + + // Set completion scenario for resume + harness.setArchitectDecomposeComplete('decomposer', [ + { number: 1, name: 'Task 1', description: 'First task', type: 'auto', dependencies: [] }, + { number: 2, name: 'Task 2', description: 'Second task', type: 'auto', dependencies: [1] }, + { number: 3, name: 'Verify', description: 'Verify all', type: 'checkpoint:human-verify', dependencies: [2] }, + ]); + + // Resume with all answers + await harness.caller.resumeAgent({ + name: 'decomposer', + answers: { + q1: 'Fine', + q2: 'Yes, add human verification', + q3: 'Tasks 1 and 2 are sequential', + }, + }); + await harness.advanceTimers(); + + // Verify completed + const finalAgent = await harness.caller.getAgent({ name: 'decomposer' }); + expect(finalAgent?.status).toBe('idle'); + }); + }); + + describe('task persistence', () => { + it('should create tasks from decomposition output', async () => { + const initiative = await harness.createInitiative('Test Project'); + const phases = await harness.createPhasesFromBreakdown(initiative.id, [ + { number: 1, name: 'Phase 1', description: 'First phase' }, + ]); + const plan = await harness.createPlan(phases[0].id, 'Auth Plan'); + + // Create tasks from decomposition + await harness.caller.createTasksFromDecomposition({ + planId: plan.id, + tasks: [ + { number: 1, name: 'Schema', description: 'Create tables', type: 'auto', dependencies: [] }, + { number: 2, name: 'API', description: 'Create endpoints', type: 'auto', dependencies: [1] }, + { number: 3, name: 'Verify', description: 'Test flow', type: 'checkpoint:human-verify', dependencies: [2] }, + ], + }); + + // Verify tasks created + const tasks = await harness.getTasksForPlan(plan.id); + expect(tasks).toHaveLength(3); + expect(tasks[0].name).toBe('Schema'); + expect(tasks[1].name).toBe('API'); + expect(tasks[2].name).toBe('Verify'); + expect(tasks[2].type).toBe('checkpoint:human-verify'); + }); + + it('should handle all task types', async () => { + const initiative = await harness.createInitiative('Task Types Test'); + const phases = await harness.createPhasesFromBreakdown(initiative.id, [ + { number: 1, name: 'Phase 1', description: 'First phase' }, + ]); + const plan = await harness.createPlan(phases[0].id, 'Mixed Tasks'); + + // Create tasks with all types + await harness.caller.createTasksFromDecomposition({ + planId: plan.id, + tasks: [ + { number: 1, name: 'Auto Task', description: 'Automated work', type: 'auto' }, + { number: 2, name: 'Human Verify', description: 'Visual check', type: 'checkpoint:human-verify', dependencies: [1] }, + { number: 3, name: 'Decision', description: 'Choose approach', type: 'checkpoint:decision', dependencies: [2] }, + { number: 4, name: 'Human Action', description: 'Manual step', type: 'checkpoint:human-action', dependencies: [3] }, + ], + }); + + const tasks = await harness.getTasksForPlan(plan.id); + expect(tasks).toHaveLength(4); + expect(tasks[0].type).toBe('auto'); + expect(tasks[1].type).toBe('checkpoint:human-verify'); + expect(tasks[2].type).toBe('checkpoint:decision'); + expect(tasks[3].type).toBe('checkpoint:human-action'); + }); + + it('should create task dependencies', async () => { + const initiative = await harness.createInitiative('Dependencies Test'); + const phases = await harness.createPhasesFromBreakdown(initiative.id, [ + { number: 1, name: 'Phase 1', description: 'First phase' }, + ]); + const plan = await harness.createPlan(phases[0].id, 'Dependent Tasks'); + + // Create tasks with complex dependencies + await harness.caller.createTasksFromDecomposition({ + planId: plan.id, + tasks: [ + { number: 1, name: 'Task A', description: 'No deps', type: 'auto' }, + { number: 2, name: 'Task B', description: 'Depends on A', type: 'auto', dependencies: [1] }, + { number: 3, name: 'Task C', description: 'Depends on A', type: 'auto', dependencies: [1] }, + { number: 4, name: 'Task D', description: 'Depends on B and C', type: 'auto', dependencies: [2, 3] }, + ], + }); + + const tasks = await harness.getTasksForPlan(plan.id); + expect(tasks).toHaveLength(4); + + // All tasks should be created with correct names + expect(tasks.map(t => t.name)).toEqual(['Task A', 'Task B', 'Task C', 'Task D']); + }); + }); + + describe('full decompose workflow', () => { + it('should complete initiative -> phase -> plan -> decompose -> tasks workflow', async () => { + vi.useFakeTimers(); + + // 1. Create initiative + const initiative = await harness.createInitiative('Full Workflow Test', 'Complete workflow'); + + // 2. Create phase + const phases = await harness.createPhasesFromBreakdown(initiative.id, [ + { number: 1, name: 'Auth Phase', description: 'Authentication implementation' }, + ]); + + // 3. Create plan + const plan = await harness.createPlan(phases[0].id, 'Auth Plan', 'Implement JWT auth'); + + // 4. Spawn decompose agent + harness.setArchitectDecomposeComplete('decomposer', [ + { number: 1, name: 'Create user schema', description: 'Define User model', type: 'auto', dependencies: [] }, + { number: 2, name: 'Implement JWT', description: 'Token generation', type: 'auto', dependencies: [1] }, + { number: 3, name: 'Protected routes', description: 'Middleware', type: 'auto', dependencies: [2] }, + { number: 4, name: 'Verify auth', description: 'Test login flow', type: 'checkpoint:human-verify', dependencies: [3] }, + ]); + + await harness.caller.spawnArchitectDecompose({ + name: 'decomposer', + planId: plan.id, + }); + await harness.advanceTimers(); + + // 5. Verify agent completed + const events = harness.getEmittedEvents('agent:stopped') as AgentStoppedEvent[]; + expect(events).toHaveLength(1); + expect(events[0].payload.reason).toBe('decompose_complete'); + + // 6. Persist tasks (simulating what orchestrator would do after decompose) + await harness.caller.createTasksFromDecomposition({ + planId: plan.id, + tasks: [ + { number: 1, name: 'Create user schema', description: 'Define User model', type: 'auto', dependencies: [] }, + { number: 2, name: 'Implement JWT', description: 'Token generation', type: 'auto', dependencies: [1] }, + { number: 3, name: 'Protected routes', description: 'Middleware', type: 'auto', dependencies: [2] }, + { number: 4, name: 'Verify auth', description: 'Test login flow', type: 'checkpoint:human-verify', dependencies: [3] }, + ], + }); + + // 7. Verify final state + const tasks = await harness.getTasksForPlan(plan.id); + expect(tasks).toHaveLength(4); + expect(tasks[0].name).toBe('Create user schema'); + expect(tasks[3].type).toBe('checkpoint:human-verify'); + + // Agent should be idle + const finalAgent = await harness.caller.getAgent({ name: 'decomposer' }); + expect(finalAgent?.status).toBe('idle'); + }); + }); +});