/** * OutputHandler Tests * * Test suite for the OutputHandler class, specifically focusing on * question parsing and agent completion handling. */ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { OutputHandler } from './output-handler.js'; import type { AgentRepository } from '../db/repositories/agent-repository.js'; import type { EventBus, DomainEvent, AgentWaitingEvent } from '../events/types.js'; import { getProvider } from './providers/registry.js'; // ============================================================================= // Test Helpers // ============================================================================= function createMockEventBus(): EventBus & { emittedEvents: DomainEvent[] } { const emittedEvents: DomainEvent[] = []; const mockBus = { emittedEvents, emit: vi.fn().mockImplementation((event: T): void => { emittedEvents.push(event); }), on: vi.fn(), off: vi.fn(), once: vi.fn(), }; return mockBus; } function createMockAgentRepository() { return { findById: vi.fn(), update: vi.fn(), create: vi.fn(), findByName: vi.fn(), findByStatus: vi.fn(), findAll: vi.fn(), delete: vi.fn(), }; } // ============================================================================= // Tests // ============================================================================= describe('OutputHandler', () => { let outputHandler: OutputHandler; let mockAgentRepo: ReturnType; let eventBus: ReturnType; const mockAgent = { id: 'agent-123', name: 'test-agent', taskId: 'task-456', sessionId: 'session-789', provider: 'claude', mode: 'refine', }; beforeEach(() => { mockAgentRepo = createMockAgentRepository(); eventBus = createMockEventBus(); outputHandler = new OutputHandler( mockAgentRepo as any, eventBus, ); // Setup default mock behavior mockAgentRepo.findById.mockResolvedValue(mockAgent); }); describe('processAgentOutput', () => { it('should correctly parse and handle questions from Claude CLI output', async () => { // Arrange: Create realistic Claude CLI output with questions (like fantastic-crane) const questionsResult = { status: "questions", questions: [ { id: "q1", question: "What specific components are in the current admin UI? (e.g., tables, forms, modals, navigation)" }, { id: "q2", question: "What does 'modern look' mean for you? (e.g., dark mode support, specific color scheme, animations)" }, { id: "q3", question: "Are there any specific shadcn components you want to use or prioritize?" } ] }; const claudeOutput = JSON.stringify({ type: "result", subtype: "success", is_error: false, session_id: "test-session-123", result: JSON.stringify(questionsResult), total_cost_usd: 0.05 }); const getAgentWorkdir = vi.fn().mockReturnValue('/test/workdir'); const provider = getProvider('claude')!; // Act await outputHandler.processAgentOutput( mockAgent.id, claudeOutput, provider, getAgentWorkdir ); // Assert: Agent should be updated with questions and waiting_for_input status expect(mockAgentRepo.update).toHaveBeenCalledWith(mockAgent.id, { pendingQuestions: JSON.stringify({ questions: [ { id: 'q1', question: 'What specific components are in the current admin UI? (e.g., tables, forms, modals, navigation)' }, { id: 'q2', question: 'What does \'modern look\' mean for you? (e.g., dark mode support, specific color scheme, animations)' }, { id: 'q3', question: 'Are there any specific shadcn components you want to use or prioritize?' } ] }), status: 'waiting_for_input' }); // Should be called at least once (could be once or twice depending on session ID extraction) expect(mockAgentRepo.update).toHaveBeenCalledTimes(1); // Assert: AgentWaitingEvent should be emitted const waitingEvents = eventBus.emittedEvents.filter(e => e.type === 'agent:waiting') as AgentWaitingEvent[]; expect(waitingEvents).toHaveLength(1); expect(waitingEvents[0].payload.questions).toEqual([ { id: 'q1', question: 'What specific components are in the current admin UI? (e.g., tables, forms, modals, navigation)' }, { id: 'q2', question: 'What does \'modern look\' mean for you? (e.g., dark mode support, specific color scheme, animations)' }, { id: 'q3', question: 'Are there any specific shadcn components you want to use or prioritize?' } ]); }); it('should handle malformed questions gracefully', async () => { // Arrange: Create output with malformed questions JSON const malformedOutput = JSON.stringify({ type: "result", subtype: "success", is_error: false, session_id: "test-session", result: '{"status": "questions", "questions": [malformed json]}', total_cost_usd: 0.05 }); const getAgentWorkdir = vi.fn().mockReturnValue('/test/workdir'); const provider = getProvider('claude')!; // Act & Assert: Should not throw, should handle error gracefully await expect( outputHandler.processAgentOutput( mockAgent.id, malformedOutput, provider, getAgentWorkdir ) ).resolves.not.toThrow(); // Should update status to crashed due to malformed JSON const updateCalls = mockAgentRepo.update.mock.calls; const crashedCall = updateCalls.find(call => call[1]?.status === 'crashed'); expect(crashedCall).toBeDefined(); }); it('should correctly handle "done" status without questions', async () => { // Arrange: Create output with done status const doneOutput = JSON.stringify({ type: "result", subtype: "success", is_error: false, session_id: "test-session", result: JSON.stringify({ status: "done", message: "Task completed successfully" }), total_cost_usd: 0.05 }); const getAgentWorkdir = vi.fn().mockReturnValue('/test/workdir'); const provider = getProvider('claude')!; // Act await outputHandler.processAgentOutput( mockAgent.id, doneOutput, provider, getAgentWorkdir ); // Assert: Should not set waiting_for_input status or pendingQuestions const updateCalls = mockAgentRepo.update.mock.calls; const waitingCall = updateCalls.find(call => call[1]?.status === 'waiting_for_input'); expect(waitingCall).toBeUndefined(); const questionsCall = updateCalls.find(call => call[1]?.pendingQuestions); expect(questionsCall).toBeUndefined(); }); }); describe('getPendingQuestions', () => { it('should retrieve and parse stored pending questions', async () => { // Arrange const questionsPayload = { questions: [ { id: 'q1', question: 'Test question 1?' }, { id: 'q2', question: 'Test question 2?' } ] }; mockAgentRepo.findById.mockResolvedValue({ ...mockAgent, pendingQuestions: JSON.stringify(questionsPayload) }); // Act const result = await outputHandler.getPendingQuestions(mockAgent.id); // Assert expect(result).toEqual(questionsPayload); expect(mockAgentRepo.findById).toHaveBeenCalledWith(mockAgent.id); }); it('should return null when no pending questions exist', async () => { // Arrange mockAgentRepo.findById.mockResolvedValue({ ...mockAgent, pendingQuestions: null }); // Act const result = await outputHandler.getPendingQuestions(mockAgent.id); // Assert expect(result).toBeNull(); }); }); // ============================================================================= // formatAnswersAsPrompt Tests // ============================================================================= describe('formatAnswersAsPrompt', () => { it('should format normal answers correctly', () => { const answers = { 'q1': 'The admin UI has tables and forms', 'q2': 'Modern means dark mode and clean aesthetics' }; const result = outputHandler.formatAnswersAsPrompt(answers); expect(result).toBe( 'Here are my answers to your questions:\n' + '[q1]: The admin UI has tables and forms\n' + '[q2]: Modern means dark mode and clean aesthetics' ); }); it('should handle instruction-enhanced answers for retry scenarios', () => { const answers = { 'q1': 'Fix the authentication bug', '__instruction__': 'IMPORTANT: Create a signal.json file when done' }; const result = outputHandler.formatAnswersAsPrompt(answers); expect(result).toBe( 'IMPORTANT: Create a signal.json file when done\n\n' + 'Here are my answers to your questions:\n' + '[q1]: Fix the authentication bug' ); }); it('should handle instruction with whitespace correctly', () => { const answers = { 'q1': 'Complete the task', '__instruction__': ' \n Some instruction with whitespace \n ' }; const result = outputHandler.formatAnswersAsPrompt(answers); expect(result).toBe( 'Some instruction with whitespace\n\n' + 'Here are my answers to your questions:\n' + '[q1]: Complete the task' ); }); it('should work with only instruction and no real answers', () => { const answers = { '__instruction__': 'Retry with this instruction' }; const result = outputHandler.formatAnswersAsPrompt(answers); expect(result).toBe( 'Retry with this instruction\n\n' + 'Here are my answers to your questions:\n' ); }); it('should work with empty answers object', () => { const answers = {}; const result = outputHandler.formatAnswersAsPrompt(answers); expect(result).toBe( 'Here are my answers to your questions:\n' ); }); }); });