/** * Tests for interactive mode */ import { describe, it, expect, vi, beforeEach } from 'vitest'; vi.mock('../infra/config/global/globalConfig.js', () => ({ loadGlobalConfig: vi.fn(() => ({ provider: 'mock', language: 'en' })), getBuiltinWorkflowsEnabled: 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>()), loadAgentSessions: vi.fn(() => ({})), updateAgentSession: vi.fn(), getProjectConfigDir: vi.fn(() => '/tmp'), })); 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(), })); // Mock readline to simulate user input vi.mock('node:readline', () => ({ createInterface: vi.fn(), })); import { createInterface } from 'node:readline'; import { getProvider } from '../infra/providers/index.js'; import { interactiveMode } from '../features/interactive/index.js'; import { selectOption } from '../shared/prompt/index.js'; const mockGetProvider = vi.mocked(getProvider); const mockCreateInterface = vi.mocked(createInterface); const mockSelectOption = vi.mocked(selectOption); /** Helper to set up a sequence of readline inputs */ function setupInputSequence(inputs: (string | null)[]): void { let callIndex = 0; mockCreateInterface.mockImplementation(() => { const input = callIndex < inputs.length ? inputs[callIndex] : null; callIndex++; const listeners: Record void)[]> = {}; const rlMock = { question: vi.fn((_prompt: string, callback: (answer: string) => void) => { if (input === null) { // Simulate EOF (Ctrl+D) — emit close event asynchronously // so that the on('close') listener is registered first queueMicrotask(() => { const closeListeners = listeners['close'] || []; for (const listener of closeListeners) { listener(); } }); } else { callback(input); } }), close: vi.fn(), on: vi.fn((event: string, listener: (...args: unknown[]) => void) => { if (!listeners[event]) { listeners[event] = []; } listeners[event]!.push(listener); return rlMock; }), } as unknown as ReturnType; return rlMock; }); } /** Create a mock provider that returns given responses */ function setupMockProvider(responses: string[]): void { let callIndex = 0; const mockProvider = { call: vi.fn(async () => { const content = callIndex < responses.length ? responses[callIndex] : 'AI response'; callIndex++; return { agent: 'interactive', status: 'done' as const, content: content!, timestamp: new Date(), }; }), callCustom: vi.fn(), }; mockGetProvider.mockReturnValue(mockProvider); } beforeEach(() => { vi.clearAllMocks(); mockSelectOption.mockResolvedValue('yes'); }); describe('interactiveMode', () => { it('should return confirmed=false when user types /cancel', async () => { // Given setupInputSequence(['/cancel']); setupMockProvider([]); // When const result = await interactiveMode('/project'); // Then expect(result.confirmed).toBe(false); expect(result.task).toBe(''); }); it('should return confirmed=false on EOF (Ctrl+D)', async () => { // Given setupInputSequence([null]); setupMockProvider([]); // When const result = await interactiveMode('/project'); // Then expect(result.confirmed).toBe(false); }); it('should call provider with allowed tools for codebase exploration', async () => { // Given setupInputSequence(['fix the login bug', '/go']); setupMockProvider(['What kind of login bug?']); // When await interactiveMode('/project'); // Then const mockProvider = mockGetProvider.mock.results[0]!.value as { call: ReturnType }; expect(mockProvider.call).toHaveBeenCalledWith( 'interactive', expect.any(String), expect.objectContaining({ cwd: '/project', allowedTools: ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch'], }), ); }); it('should return confirmed=true with task on /go after conversation', async () => { // Given setupInputSequence(['add auth feature', '/go']); setupMockProvider(['What kind of authentication?', 'Implement auth feature with chosen method.']); // When const result = await interactiveMode('/project'); // Then expect(result.confirmed).toBe(true); expect(result.task).toBe('Implement auth feature with chosen method.'); }); it('should reject /go with no prior conversation', async () => { // Given: /go immediately, then /cancel to exit setupInputSequence(['/go', '/cancel']); setupMockProvider([]); // When const result = await interactiveMode('/project'); // Then: should not confirm (fell through to /cancel) expect(result.confirmed).toBe(false); }); it('should skip empty input', async () => { // Given: empty line, then actual input, then /go setupInputSequence(['', 'do something', '/go']); setupMockProvider(['Sure, what exactly?', 'Do something with the clarified scope.']); // When const result = await interactiveMode('/project'); // Then expect(result.confirmed).toBe(true); const mockProvider = mockGetProvider.mock.results[0]!.value as { call: ReturnType }; expect(mockProvider.call).toHaveBeenCalledTimes(2); }); it('should accumulate conversation history across multiple turns', async () => { // Given: two user messages before /go setupInputSequence(['first message', 'second message', '/go']); setupMockProvider(['response to first', 'response to second', 'Summarized task.']); // When const result = await interactiveMode('/project'); // Then: task should be a summary and prompt should include full history expect(result.confirmed).toBe(true); expect(result.task).toBe('Summarized task.'); const mockProvider = mockGetProvider.mock.results[0]!.value as { call: ReturnType }; const summaryPrompt = mockProvider.call.mock.calls[2]?.[1] as string; expect(summaryPrompt).toContain('Conversation:'); expect(summaryPrompt).toContain('User: first message'); expect(summaryPrompt).toContain('Assistant: response to first'); expect(summaryPrompt).toContain('User: second message'); expect(summaryPrompt).toContain('Assistant: response to second'); }); it('should send only current input per turn (session handles history)', async () => { // Given setupInputSequence(['first msg', 'second msg', '/go']); setupMockProvider(['AI reply 1', 'AI reply 2']); // When await interactiveMode('/project'); // Then: each call receives only the current user input (session maintains context) const mockProvider = mockGetProvider.mock.results[0]!.value as { call: ReturnType }; expect(mockProvider.call.mock.calls[0]?.[1]).toBe('first msg'); expect(mockProvider.call.mock.calls[1]?.[1]).toBe('second msg'); }); it('should process initialInput as first message before entering loop', async () => { // Given: initialInput provided, then user types /go setupInputSequence(['/go']); setupMockProvider(['What do you mean by "a"?', 'Clarify task for "a".']); // When const result = await interactiveMode('/project', 'a'); // Then: AI should have been called with initialInput const mockProvider = mockGetProvider.mock.results[0]!.value as { call: ReturnType }; expect(mockProvider.call).toHaveBeenCalledTimes(2); expect(mockProvider.call.mock.calls[0]?.[1]).toBe('a'); // /go should work because initialInput already started conversation expect(result.confirmed).toBe(true); expect(result.task).toBe('Clarify task for "a".'); }); it('should send only current input for subsequent turns after initialInput', async () => { // Given: initialInput, then follow-up, then /go setupInputSequence(['fix the login page', '/go']); setupMockProvider(['What about "a"?', 'Got it, fixing login page.', 'Fix login page with clarified scope.']); // When const result = await interactiveMode('/project', 'a'); // Then: each call receives only its own input (session handles history) const mockProvider = mockGetProvider.mock.results[0]!.value as { call: ReturnType }; expect(mockProvider.call).toHaveBeenCalledTimes(3); expect(mockProvider.call.mock.calls[0]?.[1]).toBe('a'); expect(mockProvider.call.mock.calls[1]?.[1]).toBe('fix the login page'); // Task still contains all history for downstream use expect(result.confirmed).toBe(true); expect(result.task).toBe('Fix login page with clarified scope.'); }); });