/** * E2E test: Retry mode with failure context and run session injection. * * Simulates the retry assistant flow: * 1. Create .takt/runs/ fixtures (logs, reports) * 2. Build RetryContext with failure info + run session * 3. Run retry mode with stdin simulation (user types message → /go) * 4. Mock provider captures the system prompt sent to AI * 5. Verify failure info AND run session data appear in the system prompt * * Real: buildRetryTemplateVars, loadTemplate, runConversationLoop, * loadRunSessionContext, formatRunSessionForPrompt, getRunPaths * Mocked: provider (captures system prompt), config, UI, session persistence */ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { mkdirSync, writeFileSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; import { setupRawStdin, restoreStdin, toRawInputs, createMockProvider, type MockProviderCapture, } from './helpers/stdinSimulator.js'; // --- Mocks (infrastructure only) --- vi.mock('../infra/fs/session.js', () => ({ loadNdjsonLog: vi.fn(), })); vi.mock('../infra/config/global/globalConfig.js', () => ({ loadGlobalConfig: vi.fn(() => ({ provider: 'mock', language: 'en' })), getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true), })); vi.mock('../infra/providers/index.js', () => ({ getProvider: vi.fn(), })); vi.mock('../shared/utils/index.js', async (importOriginal) => ({ ...(await importOriginal>()), createLogger: () => ({ info: vi.fn(), debug: vi.fn(), error: vi.fn(), }), })); vi.mock('../shared/context.js', () => ({ isQuietMode: vi.fn(() => false), })); vi.mock('../infra/config/paths.js', async (importOriginal) => ({ ...(await importOriginal>()), loadPersonaSessions: vi.fn(() => ({})), updatePersonaSession: vi.fn(), getProjectConfigDir: vi.fn(() => '/tmp'), loadSessionState: vi.fn(() => null), clearSessionState: vi.fn(), })); vi.mock('../shared/ui/index.js', () => ({ info: vi.fn(), error: vi.fn(), blankLine: vi.fn(), StreamDisplay: vi.fn().mockImplementation(() => ({ createHandler: vi.fn(() => vi.fn()), flush: vi.fn(), })), })); vi.mock('../shared/prompt/index.js', () => ({ selectOption: vi.fn().mockResolvedValue('execute'), })); vi.mock('../shared/i18n/index.js', () => ({ getLabel: vi.fn((_key: string, _lang: string) => 'Mock label'), getLabelObject: vi.fn(() => ({ intro: 'Retry intro', resume: 'Resume', noConversation: 'No conversation', summarizeFailed: 'Summarize failed', continuePrompt: 'Continue?', proposed: 'Proposed:', actionPrompt: 'What next?', playNoTask: 'No task', cancelled: 'Cancelled', actions: { execute: 'Execute', saveTask: 'Save', continue: 'Continue' }, })), })); // --- Imports (after mocks) --- import { getProvider } from '../infra/providers/index.js'; import { loadNdjsonLog } from '../infra/fs/session.js'; import { loadRunSessionContext, formatRunSessionForPrompt, getRunPaths, } from '../features/interactive/runSessionReader.js'; import { runRetryMode, type RetryContext } from '../features/interactive/retryMode.js'; const mockGetProvider = vi.mocked(getProvider); const mockLoadNdjsonLog = vi.mocked(loadNdjsonLog); // --- Fixture helpers --- function createTmpDir(): string { const dir = join(tmpdir(), `takt-retry-e2e-${Date.now()}-${Math.random().toString(36).slice(2)}`); mkdirSync(dir, { recursive: true }); return dir; } function createRunFixture( cwd: string, slug: string, overrides?: { meta?: Record; reports?: Array<{ name: string; content: string }>; }, ): void { const runDir = join(cwd, '.takt', 'runs', slug); mkdirSync(join(runDir, 'logs'), { recursive: true }); mkdirSync(join(runDir, 'reports'), { recursive: true }); const meta = { task: `Task for ${slug}`, piece: 'default', status: 'completed', startTime: '2026-02-01T00:00:00.000Z', logsDirectory: `.takt/runs/${slug}/logs`, reportDirectory: `.takt/runs/${slug}/reports`, runSlug: slug, ...overrides?.meta, }; writeFileSync(join(runDir, 'meta.json'), JSON.stringify(meta), 'utf-8'); writeFileSync(join(runDir, 'logs', 'session-001.jsonl'), '{}', 'utf-8'); for (const report of overrides?.reports ?? []) { writeFileSync(join(runDir, 'reports', report.name), report.content, 'utf-8'); } } function setupMockNdjsonLog(history: Array<{ step: string; persona: string; status: string; content: string }>): void { mockLoadNdjsonLog.mockReturnValue({ task: 'mock', projectDir: '', pieceName: 'default', iterations: history.length, startTime: '2026-02-01T00:00:00.000Z', status: 'completed', history: history.map((h) => ({ ...h, instruction: '', timestamp: '2026-02-01T00:00:00.000Z', })), }); } function setupProvider(responses: string[]): MockProviderCapture { const { provider, capture } = createMockProvider(responses); mockGetProvider.mockReturnValue(provider); return capture; } // --- Tests --- describe('E2E: Retry mode with failure context injection', () => { let tmpDir: string; beforeEach(() => { tmpDir = createTmpDir(); vi.clearAllMocks(); }); afterEach(() => { restoreStdin(); rmSync(tmpDir, { recursive: true, force: true }); }); it('should inject failure info into system prompt', async () => { setupRawStdin(toRawInputs(['what went wrong?', '/go'])); const capture = setupProvider([ 'The review step failed due to a timeout.', 'Fix review timeout by increasing the limit.', ]); const retryContext: RetryContext = { failure: { taskName: 'implement-auth', taskContent: 'Implement authentication feature', createdAt: '2026-02-15T10:00:00Z', failedMovement: 'review', error: 'Timeout after 300s', lastMessage: 'Agent stopped responding', retryNote: '', }, branchName: 'takt/implement-auth', pieceContext: { name: 'default', description: '', pieceStructure: '', movementPreviews: [], }, run: null, previousOrderContent: null, }; const result = await runRetryMode(tmpDir, retryContext, null); // Verify: system prompt contains failure information expect(capture.systemPrompts.length).toBeGreaterThan(0); const systemPrompt = capture.systemPrompts[0]!; expect(systemPrompt).toContain('Retry Assistant'); expect(systemPrompt).toContain('implement-auth'); expect(systemPrompt).toContain('takt/implement-auth'); expect(systemPrompt).toContain('review'); expect(systemPrompt).toContain('Timeout after 300s'); expect(systemPrompt).toContain('Agent stopped responding'); // Verify: flow completed expect(result.action).toBe('execute'); expect(result.task).toBe('Fix review timeout by increasing the limit.'); expect(capture.callCount).toBe(2); }); it('should inject failure info AND run session data into system prompt', async () => { // Create run fixture with logs and reports createRunFixture(tmpDir, 'run-failed', { meta: { task: 'Build login page', status: 'failed' }, reports: [ { name: '00-plan.md', content: '# Plan\n\nLogin form with OAuth2.' }, ], }); setupMockNdjsonLog([ { step: 'plan', persona: 'architect', status: 'completed', content: 'Planned OAuth2 login flow' }, { step: 'implement', persona: 'coder', status: 'failed', content: 'Failed at CSS compilation' }, ]); // Load real run session data const sessionContext = loadRunSessionContext(tmpDir, 'run-failed'); const formatted = formatRunSessionForPrompt(sessionContext); const paths = getRunPaths(tmpDir, 'run-failed'); setupRawStdin(toRawInputs(['fix the CSS issue', '/go'])); const capture = setupProvider([ 'The CSS compilation error is likely due to missing imports.', 'Fix CSS imports in login component.', ]); const retryContext: RetryContext = { failure: { taskName: 'build-login', taskContent: 'Build login page with OAuth2', createdAt: '2026-02-15T14:00:00Z', failedMovement: 'implement', error: 'CSS compilation failed', lastMessage: 'PostCSS error: unknown property', retryNote: '', }, branchName: 'takt/build-login', pieceContext: { name: 'default', description: '', pieceStructure: '', movementPreviews: [], }, run: { logsDir: paths.logsDir, reportsDir: paths.reportsDir, task: formatted.runTask, piece: formatted.runPiece, status: formatted.runStatus, movementLogs: formatted.runMovementLogs, reports: formatted.runReports, }, previousOrderContent: null, }; const result = await runRetryMode(tmpDir, retryContext, null); // Verify: system prompt contains BOTH failure info and run session data const systemPrompt = capture.systemPrompts[0]!; // Failure info expect(systemPrompt).toContain('build-login'); expect(systemPrompt).toContain('CSS compilation failed'); expect(systemPrompt).toContain('PostCSS error: unknown property'); expect(systemPrompt).toContain('implement'); // Run session data expect(systemPrompt).toContain('Previous Run Data'); expect(systemPrompt).toContain('Build login page'); expect(systemPrompt).toContain('Planned OAuth2 login flow'); expect(systemPrompt).toContain('Failed at CSS compilation'); expect(systemPrompt).toContain('00-plan.md'); expect(systemPrompt).toContain('Login form with OAuth2'); // Run paths (AI can use Read tool) expect(systemPrompt).toContain(paths.logsDir); expect(systemPrompt).toContain(paths.reportsDir); // Flow completed expect(result.action).toBe('execute'); expect(result.task).toBe('Fix CSS imports in login component.'); }); it('should include existing retry note in system prompt', async () => { setupRawStdin(toRawInputs(['what should I do?', '/go'])); const capture = setupProvider([ 'Based on the previous attempt, the mocks are still incomplete.', 'Add complete mocks for all API endpoints.', ]); const retryContext: RetryContext = { failure: { taskName: 'fix-tests', taskContent: 'Fix failing test suite', createdAt: '2026-02-15T16:00:00Z', failedMovement: '', error: 'Test suite failed', lastMessage: '', retryNote: 'Previous attempt: added missing mocks but still failing', }, branchName: 'takt/fix-tests', pieceContext: { name: 'default', description: '', pieceStructure: '', movementPreviews: [], }, run: null, previousOrderContent: null, }; await runRetryMode(tmpDir, retryContext, null); const systemPrompt = capture.systemPrompts[0]!; expect(systemPrompt).toContain('Existing Retry Note'); expect(systemPrompt).toContain('Previous attempt: added missing mocks but still failing'); // absent fields should NOT appear as sections expect(systemPrompt).not.toContain('Failed movement'); expect(systemPrompt).not.toContain('Last Message'); }); it('should cancel cleanly and not crash', async () => { setupRawStdin(toRawInputs(['/cancel'])); setupProvider([]); const retryContext: RetryContext = { failure: { taskName: 'some-task', taskContent: 'Complete some task', createdAt: '2026-02-15T12:00:00Z', failedMovement: 'plan', error: 'Unknown error', lastMessage: '', retryNote: '', }, branchName: 'takt/some-task', pieceContext: { name: 'default', description: '', pieceStructure: '', movementPreviews: [], }, run: null, previousOrderContent: null, }; const result = await runRetryMode(tmpDir, retryContext, null); expect(result.action).toBe('cancel'); expect(result.task).toBe(''); }); it('should handle conversation before /go with failure context', async () => { setupRawStdin(toRawInputs([ 'what was the error?', 'can you suggest a fix?', '/go', ])); const capture = setupProvider([ 'The error was a timeout in the review step.', 'You could increase the timeout limit or optimize the review.', 'Increase review timeout to 600s and add retry logic.', ]); const retryContext: RetryContext = { failure: { taskName: 'optimize-review', taskContent: 'Optimize the review step', createdAt: '2026-02-15T18:00:00Z', failedMovement: 'review', error: 'Timeout', lastMessage: '', retryNote: '', }, branchName: 'takt/optimize-review', pieceContext: { name: 'default', description: '', pieceStructure: '', movementPreviews: [], }, run: null, previousOrderContent: null, }; const result = await runRetryMode(tmpDir, retryContext, null); expect(result.action).toBe('execute'); expect(result.task).toBe('Increase review timeout to 600s and add retry logic.'); expect(capture.callCount).toBe(3); }); });