takt/src/__tests__/pieceExecution-ask-user-question.test.ts

201 lines
6.0 KiB
TypeScript

/**
* Tests: executePiece() wires a deny handler for AskUserQuestion
* to PieceEngine during piece execution.
*
* This ensures that the agent cannot prompt the user interactively
* during automated piece runs — AskUserQuestion is always blocked.
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import type { PieceConfig } from '../core/models/index.js';
import { AskUserQuestionDeniedError } from '../core/piece/ask-user-question-error.js';
const { MockPieceEngine } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { EventEmitter: EE } = require('node:events') as typeof import('node:events');
class MockPieceEngine extends EE {
static lastInstance: MockPieceEngine;
readonly receivedOptions: Record<string, unknown>;
private readonly config: PieceConfig;
constructor(config: PieceConfig, _cwd: string, _task: string, options: Record<string, unknown>) {
super();
this.config = config;
this.receivedOptions = options;
MockPieceEngine.lastInstance = this;
}
abort(): void {}
async run(): Promise<{ status: string; iteration: number }> {
const firstStep = this.config.movements[0];
if (firstStep) {
this.emit('movement:start', firstStep, 1, firstStep.instructionTemplate, { provider: undefined, model: undefined });
}
this.emit('piece:complete', { status: 'completed', iteration: 1 });
return { status: 'completed', iteration: 1 };
}
}
return { MockPieceEngine };
});
vi.mock('../core/piece/index.js', async () => {
const errorModule = await import('../core/piece/ask-user-question-error.js');
return {
PieceEngine: MockPieceEngine,
createDenyAskUserQuestionHandler: errorModule.createDenyAskUserQuestionHandler,
};
});
vi.mock('../infra/claude/query-manager.js', () => ({
interruptAllQueries: vi.fn(),
}));
vi.mock('../agents/ai-judge.js', () => ({
callAiJudge: vi.fn(),
}));
vi.mock('../infra/config/index.js', () => ({
loadPersonaSessions: vi.fn().mockReturnValue({}),
updatePersonaSession: vi.fn(),
loadWorktreeSessions: vi.fn().mockReturnValue({}),
updateWorktreeSession: vi.fn(),
resolvePieceConfigValues: vi.fn().mockReturnValue({
notificationSound: true,
notificationSoundEvents: {},
provider: 'claude',
runtime: undefined,
preventSleep: false,
model: undefined,
observability: undefined,
}),
saveSessionState: vi.fn(),
ensureDir: vi.fn(),
writeFileAtomic: vi.fn(),
}));
vi.mock('../shared/context.js', () => ({
isQuietMode: vi.fn().mockReturnValue(true),
}));
vi.mock('../shared/ui/index.js', () => ({
header: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
success: vi.fn(),
status: vi.fn(),
blankLine: vi.fn(),
StreamDisplay: vi.fn().mockImplementation(() => ({
createHandler: vi.fn().mockReturnValue(vi.fn()),
flush: vi.fn(),
})),
}));
vi.mock('../infra/fs/index.js', () => ({
generateSessionId: vi.fn().mockReturnValue('test-session-id'),
createSessionLog: vi.fn().mockReturnValue({
startTime: new Date().toISOString(),
iterations: 0,
}),
finalizeSessionLog: vi.fn().mockImplementation((log, status) => ({
...log,
status,
endTime: new Date().toISOString(),
})),
initNdjsonLog: vi.fn().mockReturnValue('/tmp/test-log.jsonl'),
appendNdjsonLine: vi.fn(),
}));
vi.mock('../shared/utils/index.js', () => ({
createLogger: vi.fn().mockReturnValue({
debug: vi.fn(),
info: vi.fn(),
warn: vi.fn(),
error: vi.fn(),
}),
notifySuccess: vi.fn(),
notifyError: vi.fn(),
preventSleep: vi.fn(),
isDebugEnabled: vi.fn().mockReturnValue(false),
writePromptLog: vi.fn(),
generateReportDir: vi.fn().mockReturnValue('test-report-dir'),
isValidReportDirName: vi.fn().mockReturnValue(true),
playWarningSound: vi.fn(),
}));
vi.mock('../shared/prompt/index.js', () => ({
selectOption: vi.fn(),
promptInput: vi.fn(),
}));
vi.mock('../shared/i18n/index.js', () => ({
getLabel: vi.fn().mockImplementation((key: string) => key),
}));
vi.mock('../shared/exitCodes.js', () => ({
EXIT_SIGINT: 130,
}));
import { executePiece } from '../features/tasks/execute/pieceExecution.js';
function makeConfig(): PieceConfig {
return {
name: 'test-piece',
maxMovements: 5,
initialMovement: 'implement',
movements: [
{
name: 'implement',
persona: '../agents/coder.md',
personaDisplayName: 'coder',
instructionTemplate: 'Implement task',
passPreviousResponse: true,
rules: [{ condition: 'done', next: 'COMPLETE' }],
},
],
};
}
describe('executePiece AskUserQuestion deny handler wiring', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('should pass onAskUserQuestion handler to PieceEngine', async () => {
// Given: normal piece execution
await executePiece(makeConfig(), 'task', '/tmp/project', {
projectCwd: '/tmp/project',
});
// Then: PieceEngine receives an onAskUserQuestion handler
const handler = MockPieceEngine.lastInstance.receivedOptions.onAskUserQuestion;
expect(typeof handler).toBe('function');
});
it('should provide a handler that throws AskUserQuestionDeniedError', async () => {
// Given: piece execution completed
await executePiece(makeConfig(), 'task', '/tmp/project', {
projectCwd: '/tmp/project',
});
// When: the handler is invoked (as PieceEngine would when agent calls AskUserQuestion)
const handler = MockPieceEngine.lastInstance.receivedOptions.onAskUserQuestion as () => never;
// Then: it throws AskUserQuestionDeniedError
expect(() => handler()).toThrow(AskUserQuestionDeniedError);
});
it('should complete successfully despite deny handler being present', async () => {
// Given/When: normal piece execution with deny handler wired
const result = await executePiece(makeConfig(), 'task', '/tmp/project', {
projectCwd: '/tmp/project',
});
// Then: piece completes successfully
expect(result.success).toBe(true);
});
});