288 lines
10 KiB
TypeScript
288 lines
10 KiB
TypeScript
/**
|
|
* PieceEngine integration tests: Report phase (Phase 2) blocked handling.
|
|
*
|
|
* Covers:
|
|
* - Report phase blocked propagates to PieceEngine's handleBlocked flow
|
|
* - User input triggers full movement retry (Phase 1 → 2 → 3)
|
|
* - Null user input aborts the piece
|
|
*/
|
|
|
|
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
|
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
import { join } from 'node:path';
|
|
|
|
// --- Mock setup (must be before imports that use these modules) ---
|
|
|
|
vi.mock('../agents/runner.js', () => ({
|
|
runAgent: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../core/piece/evaluation/index.js', () => ({
|
|
detectMatchedRule: vi.fn(),
|
|
}));
|
|
|
|
vi.mock('../core/piece/phase-runner.js', () => ({
|
|
needsStatusJudgmentPhase: vi.fn().mockReturnValue(false),
|
|
runReportPhase: vi.fn().mockResolvedValue(undefined),
|
|
runStatusJudgmentPhase: vi.fn().mockResolvedValue({ tag: '', ruleIndex: 0, method: 'auto_select' }),
|
|
}));
|
|
|
|
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
|
|
...(await importOriginal<Record<string, unknown>>()),
|
|
generateReportDir: vi.fn().mockReturnValue('test-report-dir'),
|
|
}));
|
|
|
|
// --- Imports (after mocks) ---
|
|
|
|
import { PieceEngine } from '../core/piece/index.js';
|
|
import { runReportPhase } from '../core/piece/phase-runner.js';
|
|
import {
|
|
makeResponse,
|
|
makeMovement,
|
|
buildDefaultPieceConfig,
|
|
mockRunAgentSequence,
|
|
mockDetectMatchedRuleSequence,
|
|
createTestTmpDir,
|
|
applyDefaultMocks,
|
|
} from './engine-test-helpers.js';
|
|
import type { PieceConfig, OutputContractItem } from '../core/models/index.js';
|
|
|
|
/**
|
|
* Build a piece config where a movement has outputContracts (triggering report phase).
|
|
* plan → implement (with report) → supervise
|
|
*/
|
|
function buildConfigWithReport(): PieceConfig {
|
|
const reportContract: OutputContractItem = {
|
|
name: '02-coder-scope.md',
|
|
label: 'Scope',
|
|
description: 'Scope report',
|
|
};
|
|
|
|
return buildDefaultPieceConfig({
|
|
movements: [
|
|
makeMovement('plan', {
|
|
rules: [
|
|
{ condition: 'Requirements are clear', next: 'implement' },
|
|
{ condition: 'Requirements unclear', next: 'ABORT' },
|
|
],
|
|
}),
|
|
makeMovement('implement', {
|
|
outputContracts: [reportContract],
|
|
rules: [
|
|
{ condition: 'Implementation complete', next: 'supervise' },
|
|
{ condition: 'Cannot proceed', next: 'plan' },
|
|
],
|
|
}),
|
|
makeMovement('supervise', {
|
|
rules: [
|
|
{ condition: 'All checks passed', next: 'COMPLETE' },
|
|
{ condition: 'Requirements unmet', next: 'plan' },
|
|
],
|
|
}),
|
|
],
|
|
});
|
|
}
|
|
|
|
describe('PieceEngine Integration: Report Phase Blocked Handling', () => {
|
|
let tmpDir: string;
|
|
|
|
beforeEach(() => {
|
|
vi.resetAllMocks();
|
|
applyDefaultMocks();
|
|
tmpDir = createTestTmpDir();
|
|
});
|
|
|
|
afterEach(() => {
|
|
if (existsSync(tmpDir)) {
|
|
rmSync(tmpDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('should abort when report phase is blocked and no onUserInput callback', async () => {
|
|
const config = buildConfigWithReport();
|
|
const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
|
|
|
|
// Phase 1 succeeds for plan, then implement
|
|
mockRunAgentSequence([
|
|
makeResponse({ persona: 'plan', content: 'Plan done' }),
|
|
makeResponse({ persona: 'implement', content: 'Impl done' }),
|
|
]);
|
|
|
|
// plan → implement, then implement's report phase blocks
|
|
mockDetectMatchedRuleSequence([
|
|
{ index: 0, method: 'phase1_tag' },
|
|
]);
|
|
|
|
// Report phase returns blocked (only implement has outputContracts, so only one call)
|
|
const blockedResponse = makeResponse({ persona: 'implement', status: 'blocked', content: 'Need clarification for report' });
|
|
vi.mocked(runReportPhase).mockResolvedValueOnce({ blocked: true, response: blockedResponse });
|
|
|
|
const blockedFn = vi.fn();
|
|
const abortFn = vi.fn();
|
|
engine.on('movement:blocked', blockedFn);
|
|
engine.on('piece:abort', abortFn);
|
|
|
|
const state = await engine.run();
|
|
|
|
expect(state.status).toBe('aborted');
|
|
expect(blockedFn).toHaveBeenCalledOnce();
|
|
expect(abortFn).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('should abort when report phase is blocked and onUserInput returns null', async () => {
|
|
const config = buildConfigWithReport();
|
|
const onUserInput = vi.fn().mockResolvedValue(null);
|
|
const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir, onUserInput });
|
|
|
|
mockRunAgentSequence([
|
|
makeResponse({ persona: 'plan', content: 'Plan done' }),
|
|
makeResponse({ persona: 'implement', content: 'Impl done' }),
|
|
]);
|
|
|
|
mockDetectMatchedRuleSequence([
|
|
{ index: 0, method: 'phase1_tag' },
|
|
]);
|
|
|
|
const blockedResponse = makeResponse({ persona: 'implement', status: 'blocked', content: 'Need info for report' });
|
|
vi.mocked(runReportPhase).mockResolvedValueOnce({ blocked: true, response: blockedResponse });
|
|
|
|
const state = await engine.run();
|
|
|
|
expect(state.status).toBe('aborted');
|
|
expect(onUserInput).toHaveBeenCalledOnce();
|
|
});
|
|
|
|
it('should retry full movement when report phase is blocked and user provides input', async () => {
|
|
const config = buildConfigWithReport();
|
|
const onUserInput = vi.fn().mockResolvedValueOnce('User provided report clarification');
|
|
const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir, onUserInput });
|
|
|
|
mockRunAgentSequence([
|
|
// First: plan succeeds
|
|
makeResponse({ persona: 'plan', content: 'Plan done' }),
|
|
// Second: implement Phase 1 succeeds, but Phase 2 will block
|
|
makeResponse({ persona: 'implement', content: 'Impl done' }),
|
|
// Third: implement retried after user input (Phase 1 re-executes)
|
|
makeResponse({ persona: 'implement', content: 'Impl done with clarification' }),
|
|
// Fourth: supervise
|
|
makeResponse({ persona: 'supervise', content: 'All passed' }),
|
|
]);
|
|
|
|
mockDetectMatchedRuleSequence([
|
|
// plan → implement
|
|
{ index: 0, method: 'phase1_tag' },
|
|
// implement (blocked, no rule eval happens)
|
|
// implement retry → supervise
|
|
{ index: 0, method: 'phase1_tag' },
|
|
// supervise → COMPLETE
|
|
{ index: 0, method: 'phase1_tag' },
|
|
]);
|
|
|
|
// Report phase: only implement has outputContracts; blocks first, succeeds on retry
|
|
const blockedResponse = makeResponse({ persona: 'implement', status: 'blocked', content: 'Need report clarification' });
|
|
vi.mocked(runReportPhase).mockResolvedValueOnce({ blocked: true, response: blockedResponse }); // implement (first attempt)
|
|
vi.mocked(runReportPhase).mockResolvedValueOnce(undefined); // implement (retry, succeeds)
|
|
|
|
const userInputFn = vi.fn();
|
|
engine.on('movement:user_input', userInputFn);
|
|
|
|
const state = await engine.run();
|
|
|
|
expect(state.status).toBe('completed');
|
|
expect(onUserInput).toHaveBeenCalledOnce();
|
|
expect(userInputFn).toHaveBeenCalledOnce();
|
|
expect(state.userInputs).toContain('User provided report clarification');
|
|
});
|
|
|
|
it('should propagate blocked content from report phase to engine response', async () => {
|
|
const config = buildConfigWithReport();
|
|
const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
|
|
|
|
mockRunAgentSequence([
|
|
makeResponse({ persona: 'plan', content: 'Plan done' }),
|
|
makeResponse({ persona: 'implement', content: 'Original impl content' }),
|
|
]);
|
|
|
|
mockDetectMatchedRuleSequence([
|
|
{ index: 0, method: 'phase1_tag' },
|
|
]);
|
|
|
|
const blockedContent = 'Blocked: need specific file path for report';
|
|
const blockedResponse = makeResponse({ persona: 'implement', status: 'blocked', content: blockedContent });
|
|
vi.mocked(runReportPhase).mockResolvedValueOnce({ blocked: true, response: blockedResponse });
|
|
|
|
const blockedFn = vi.fn();
|
|
engine.on('movement:blocked', blockedFn);
|
|
|
|
const state = await engine.run();
|
|
|
|
expect(state.status).toBe('aborted');
|
|
expect(blockedFn).toHaveBeenCalledWith(
|
|
expect.objectContaining({ name: 'implement' }),
|
|
expect.objectContaining({ status: 'blocked', content: blockedContent }),
|
|
);
|
|
});
|
|
|
|
it('should skip report phase when phase 1 returns error', async () => {
|
|
const config = buildConfigWithReport();
|
|
const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir });
|
|
|
|
mockRunAgentSequence([
|
|
makeResponse({ persona: 'plan', content: 'Plan done' }),
|
|
makeResponse({
|
|
persona: 'implement',
|
|
status: 'error',
|
|
content: 'Cursor Agent CLI exited with code 1: Workspace Trust Required',
|
|
error: 'Cursor Agent CLI exited with code 1: Workspace Trust Required',
|
|
}),
|
|
]);
|
|
|
|
mockDetectMatchedRuleSequence([
|
|
{ index: 0, method: 'phase1_tag' },
|
|
]);
|
|
|
|
const abortFn = vi.fn();
|
|
engine.on('piece:abort', abortFn);
|
|
|
|
const state = await engine.run();
|
|
|
|
expect(state.status).toBe('aborted');
|
|
expect(runReportPhase).not.toHaveBeenCalled();
|
|
expect(abortFn).toHaveBeenCalledOnce();
|
|
const reason = abortFn.mock.calls[0]?.[1] as string;
|
|
expect(reason).toContain('Movement "implement" failed');
|
|
expect(reason).toContain('Workspace Trust Required');
|
|
});
|
|
|
|
it('should skip report phase when phase 1 returns blocked and persist blocked snapshot', async () => {
|
|
const config = buildConfigWithReport();
|
|
const onUserInput = vi.fn().mockResolvedValueOnce(null);
|
|
const engine = new PieceEngine(config, tmpDir, 'test task', { projectCwd: tmpDir, onUserInput });
|
|
|
|
mockRunAgentSequence([
|
|
makeResponse({ persona: 'plan', content: 'Plan done' }),
|
|
makeResponse({
|
|
persona: 'implement',
|
|
status: 'blocked',
|
|
content: 'Need clarification before report',
|
|
}),
|
|
]);
|
|
|
|
mockDetectMatchedRuleSequence([
|
|
{ index: 0, method: 'phase1_tag' }, // plan -> implement
|
|
]);
|
|
|
|
const state = await engine.run();
|
|
|
|
expect(state.status).toBe('aborted');
|
|
expect(onUserInput).toHaveBeenCalledOnce();
|
|
expect(runReportPhase).not.toHaveBeenCalled();
|
|
expect(state.previousResponseSourcePath).toMatch(
|
|
/^\.takt\/runs\/test-report-dir\/context\/previous_responses\/implement\.1\.\d{8}T\d{6}Z\.md$/,
|
|
);
|
|
|
|
const snapshotPath = join(tmpDir, state.previousResponseSourcePath!);
|
|
expect(readFileSync(snapshotPath, 'utf-8')).toBe('Need clarification before report');
|
|
});
|
|
});
|