This commit is contained in:
nrslib 2026-01-31 17:02:52 +09:00
parent 4b924851a8
commit 1c46a76bbd
5 changed files with 98 additions and 9 deletions

View File

@ -256,6 +256,76 @@ describe('WorkflowEngine Integration: Happy Path', () => {
expect(startedSteps).toEqual(['plan', 'implement', 'ai_review', 'reviewers', 'supervise']); 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 () => { it('should emit iteration:limit when max iterations reached', async () => {
const config = buildDefaultWorkflowConfig({ maxIterations: 1 }); const config = buildDefaultWorkflowConfig({ maxIterations: 1 });
const engine = new WorkflowEngine(config, tmpDir, 'test task'); const engine = new WorkflowEngine(config, tmpDir, 'test task');

View File

@ -201,9 +201,15 @@ export async function executeWorkflow(
let abortReason: string | undefined; 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 }); log.debug('Step starting', { step: step.name, agent: step.agentDisplayName, iteration });
info(`[${iteration}/${workflowConfig.maxIterations}] ${step.name} (${step.agentDisplayName})`); 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); displayRef.current = new StreamDisplay(step.agentDisplayName);
stepRef.current = step.name; stepRef.current = step.name;
@ -214,6 +220,7 @@ export async function executeWorkflow(
agent: step.agentDisplayName, agent: step.agentDisplayName,
iteration, iteration,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
...(instruction ? { instruction } : {}),
}; };
appendNdjsonLine(ndjsonLogPath, record); appendNdjsonLine(ndjsonLogPath, record);
}); });

View File

@ -48,6 +48,8 @@ export interface NdjsonStepStart {
agent: string; agent: string;
iteration: number; iteration: number;
timestamp: string; timestamp: string;
/** Instruction (prompt) sent to the agent. Empty for parallel parent steps. */
instruction?: string;
} }
/** NDJSON record: streaming chunk received */ /** NDJSON record: streaming chunk received */

View File

@ -199,11 +199,11 @@ export class WorkflowEngine extends EventEmitter {
} }
/** Run a single step (delegates to runParallelStep if step has parallel sub-steps) */ /** 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) { if (step.parallel && step.parallel.length > 0) {
return this.runParallelStep(step); return this.runParallelStep(step);
} }
return this.runNormalStep(step); return this.runNormalStep(step, prebuiltInstruction);
} }
/** Build common RunAgentOptions shared by all phases */ /** Build common RunAgentOptions shared by all phases */
@ -272,9 +272,11 @@ export class WorkflowEngine extends EventEmitter {
} }
/** Run a normal (non-parallel) step */ /** Run a normal (non-parallel) step */
private async runNormalStep(step: WorkflowStep): Promise<{ response: AgentResponse; instruction: string }> { private async runNormalStep(step: WorkflowStep, prebuiltInstruction?: string): Promise<{ response: AgentResponse; instruction: string }> {
const stepIteration = incrementStepIteration(this.state, step.name); const stepIteration = prebuiltInstruction
const instruction = this.buildInstruction(step, stepIteration); ? this.state.stepIterations.get(step.name) ?? 1
: incrementStepIteration(this.state, step.name);
const instruction = prebuiltInstruction ?? this.buildInstruction(step, stepIteration);
log.debug('Running step', { log.debug('Running step', {
step: step.name, step: step.name,
agent: step.agent, agent: step.agent,
@ -475,10 +477,18 @@ export class WorkflowEngine extends EventEmitter {
} }
this.state.iteration++; 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 { try {
const { response, instruction } = await this.runStep(step); const { response, instruction } = await this.runStep(step, prebuiltInstruction);
this.emit('step:complete', step, response, instruction); this.emit('step:complete', step, response, instruction);
if (response.status === 'blocked') { if (response.status === 'blocked') {

View File

@ -11,7 +11,7 @@ import type { PermissionHandler, AskUserQuestionHandler } from '../claude/proces
/** Events emitted by workflow engine */ /** Events emitted by workflow engine */
export interface WorkflowEvents { 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:complete': (step: WorkflowStep, response: AgentResponse, instruction: string) => void;
'step:report': (step: WorkflowStep, filePath: string, fileName: string) => void; 'step:report': (step: WorkflowStep, filePath: string, fileName: string) => void;
'step:blocked': (step: WorkflowStep, response: AgentResponse) => void; 'step:blocked': (step: WorkflowStep, response: AgentResponse) => void;