diff --git a/src/__tests__/engine-happy-path.test.ts b/src/__tests__/engine-happy-path.test.ts index 8292b69..2e49022 100644 --- a/src/__tests__/engine-happy-path.test.ts +++ b/src/__tests__/engine-happy-path.test.ts @@ -256,6 +256,76 @@ describe('WorkflowEngine Integration: Happy Path', () => { expect(startedSteps).toEqual(['plan', 'implement', 'ai_review', 'reviewers', 'supervise']); }); + it('should pass instruction to step:start for normal steps', async () => { + const simpleConfig: WorkflowConfig = { + name: 'test', + maxIterations: 10, + initialStep: 'plan', + steps: [ + makeStep('plan', { + rules: [makeRule('done', 'COMPLETE')], + }), + ], + }; + const engine = new WorkflowEngine(simpleConfig, tmpDir, 'test task'); + + mockRunAgentSequence([ + makeResponse({ agent: 'plan', content: 'Plan done' }), + ]); + mockDetectMatchedRuleSequence([ + { index: 0, method: 'phase1_tag' }, + ]); + + const startFn = vi.fn(); + engine.on('step:start', startFn); + + await engine.run(); + + expect(startFn).toHaveBeenCalledTimes(1); + // step:start should receive (step, iteration, instruction) + const [_step, _iteration, instruction] = startFn.mock.calls[0]; + expect(typeof instruction).toBe('string'); + expect(instruction.length).toBeGreaterThan(0); + }); + + it('should pass empty instruction to step:start for parallel steps', async () => { + const config = buildDefaultWorkflowConfig(); + const engine = new WorkflowEngine(config, tmpDir, 'test task'); + + mockRunAgentSequence([ + makeResponse({ agent: 'plan', content: 'Plan' }), + makeResponse({ agent: 'implement', content: 'Impl' }), + makeResponse({ agent: 'ai_review', content: 'OK' }), + makeResponse({ agent: 'arch-review', content: 'OK' }), + makeResponse({ agent: 'security-review', content: 'OK' }), + makeResponse({ agent: 'supervise', content: 'Pass' }), + ]); + + mockDetectMatchedRuleSequence([ + { index: 0, method: 'phase1_tag' }, + { index: 0, method: 'phase1_tag' }, + { index: 0, method: 'phase1_tag' }, + { index: 0, method: 'phase1_tag' }, + { index: 0, method: 'phase1_tag' }, + { index: 0, method: 'aggregate' }, + { index: 0, method: 'phase1_tag' }, + ]); + + const startFn = vi.fn(); + engine.on('step:start', startFn); + + await engine.run(); + + // Find the "reviewers" step:start call (parallel step) + const reviewersCall = startFn.mock.calls.find( + (call) => (call[0] as WorkflowStep).name === 'reviewers' + ); + expect(reviewersCall).toBeDefined(); + // Parallel steps emit empty string for instruction + const [, , instruction] = reviewersCall!; + expect(instruction).toBe(''); + }); + it('should emit iteration:limit when max iterations reached', async () => { const config = buildDefaultWorkflowConfig({ maxIterations: 1 }); const engine = new WorkflowEngine(config, tmpDir, 'test task'); diff --git a/src/commands/workflowExecution.ts b/src/commands/workflowExecution.ts index dd82cb4..48e37b2 100644 --- a/src/commands/workflowExecution.ts +++ b/src/commands/workflowExecution.ts @@ -201,9 +201,15 @@ export async function executeWorkflow( let abortReason: string | undefined; - engine.on('step:start', (step, iteration) => { + engine.on('step:start', (step, iteration, instruction) => { log.debug('Step starting', { step: step.name, agent: step.agentDisplayName, iteration }); info(`[${iteration}/${workflowConfig.maxIterations}] ${step.name} (${step.agentDisplayName})`); + + // Log prompt content for debugging + if (instruction) { + log.debug('Step instruction', instruction); + } + displayRef.current = new StreamDisplay(step.agentDisplayName); stepRef.current = step.name; @@ -214,6 +220,7 @@ export async function executeWorkflow( agent: step.agentDisplayName, iteration, timestamp: new Date().toISOString(), + ...(instruction ? { instruction } : {}), }; appendNdjsonLine(ndjsonLogPath, record); }); diff --git a/src/utils/session.ts b/src/utils/session.ts index 1917561..e287d68 100644 --- a/src/utils/session.ts +++ b/src/utils/session.ts @@ -48,6 +48,8 @@ export interface NdjsonStepStart { agent: string; iteration: number; timestamp: string; + /** Instruction (prompt) sent to the agent. Empty for parallel parent steps. */ + instruction?: string; } /** NDJSON record: streaming chunk received */ diff --git a/src/workflow/engine.ts b/src/workflow/engine.ts index 59151b8..db133c0 100644 --- a/src/workflow/engine.ts +++ b/src/workflow/engine.ts @@ -199,11 +199,11 @@ export class WorkflowEngine extends EventEmitter { } /** Run a single step (delegates to runParallelStep if step has parallel sub-steps) */ - private async runStep(step: WorkflowStep): Promise<{ response: AgentResponse; instruction: string }> { + private async runStep(step: WorkflowStep, prebuiltInstruction?: string): Promise<{ response: AgentResponse; instruction: string }> { if (step.parallel && step.parallel.length > 0) { return this.runParallelStep(step); } - return this.runNormalStep(step); + return this.runNormalStep(step, prebuiltInstruction); } /** Build common RunAgentOptions shared by all phases */ @@ -272,9 +272,11 @@ export class WorkflowEngine extends EventEmitter { } /** Run a normal (non-parallel) step */ - private async runNormalStep(step: WorkflowStep): Promise<{ response: AgentResponse; instruction: string }> { - const stepIteration = incrementStepIteration(this.state, step.name); - const instruction = this.buildInstruction(step, stepIteration); + private async runNormalStep(step: WorkflowStep, prebuiltInstruction?: string): Promise<{ response: AgentResponse; instruction: string }> { + const stepIteration = prebuiltInstruction + ? this.state.stepIterations.get(step.name) ?? 1 + : incrementStepIteration(this.state, step.name); + const instruction = prebuiltInstruction ?? this.buildInstruction(step, stepIteration); log.debug('Running step', { step: step.name, agent: step.agent, @@ -475,10 +477,18 @@ export class WorkflowEngine extends EventEmitter { } this.state.iteration++; - this.emit('step:start', step, this.state.iteration); + + // Build instruction before emitting step:start so listeners can log it + const isParallel = step.parallel && step.parallel.length > 0; + let prebuiltInstruction: string | undefined; + if (!isParallel) { + const stepIteration = incrementStepIteration(this.state, step.name); + prebuiltInstruction = this.buildInstruction(step, stepIteration); + } + this.emit('step:start', step, this.state.iteration, prebuiltInstruction ?? ''); try { - const { response, instruction } = await this.runStep(step); + const { response, instruction } = await this.runStep(step, prebuiltInstruction); this.emit('step:complete', step, response, instruction); if (response.status === 'blocked') { diff --git a/src/workflow/types.ts b/src/workflow/types.ts index d41f82e..5a78fd6 100644 --- a/src/workflow/types.ts +++ b/src/workflow/types.ts @@ -11,7 +11,7 @@ import type { PermissionHandler, AskUserQuestionHandler } from '../claude/proces /** Events emitted by workflow engine */ export interface WorkflowEvents { - 'step:start': (step: WorkflowStep, iteration: number) => void; + 'step:start': (step: WorkflowStep, iteration: number, instruction: string) => void; 'step:complete': (step: WorkflowStep, response: AgentResponse, instruction: string) => void; 'step:report': (step: WorkflowStep, filePath: string, fileName: string) => void; 'step:blocked': (step: WorkflowStep, response: AgentResponse) => void;