| import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; |
| import { runServerGeneration, awaitBuildResult } from '../server-orchestrator-runner'; |
| import { TaskManager } from '../task-manager'; |
| import { SSEEventBus } from '../sse-event-bus'; |
| import type { StartGenerationRequest, BuildResult } from '../types'; |
| import type { VirtualFileSystem } from '@/lib/vfs'; |
|
|
| const mockExecute = vi.fn().mockResolvedValue({ success: true }); |
| const mockStop = vi.fn(); |
| const mockImportConversation = vi.fn(); |
|
|
| vi.mock('@/lib/llm/multi-agent-orchestrator', () => ({ |
| MultiAgentOrchestrator: vi.fn().mockImplementation(() => ({ |
| execute: mockExecute, |
| stop: mockStop, |
| importConversation: mockImportConversation, |
| })), |
| })); |
|
|
| vi.mock('../vfs-context', () => ({ |
| runWithVFS: (_vfs: unknown, fn: () => Promise<unknown>) => fn(), |
| })); |
|
|
| vi.mock('@/lib/vfs', () => ({ |
| VirtualFileSystem: vi.fn(), |
| registerContextVFSProvider: vi.fn(), |
| })); |
|
|
| function createMockVFS(): VirtualFileSystem { |
| return { |
| createFile: vi.fn(), |
| updateFile: vi.fn(), |
| deleteFile: vi.fn(), |
| renameFile: vi.fn(), |
| moveFile: vi.fn(), |
| deleteDirectory: vi.fn(), |
| createDirectory: vi.fn(), |
| getAllFilesAndDirectories: vi.fn().mockResolvedValue([]), |
| } as unknown as VirtualFileSystem; |
| } |
|
|
| function makeRequest(overrides?: Partial<StartGenerationRequest>): StartGenerationRequest { |
| return { |
| projectId: 'proj-1', |
| prompt: 'add a button', |
| model: 'gpt-4', |
| apiKey: 'sk-test', |
| conversationHistory: [], |
| generationParams: {}, |
| ...overrides, |
| }; |
| } |
|
|
| describe('runServerGeneration', () => { |
| let tm: TaskManager; |
| let bus: SSEEventBus; |
| let mockVFS: VirtualFileSystem; |
|
|
| beforeEach(() => { |
| tm = new TaskManager({ maxConcurrentPerScope: 5, keyTTLMs: 30 * 60 * 1000 }); |
| bus = new SSEEventBus({ maxBufferSize: 500 }); |
| mockVFS = createMockVFS(); |
| vi.clearAllMocks(); |
| }); |
|
|
| afterEach(() => { |
| tm.dispose(); |
| }); |
|
|
| function makeDeps() { |
| return { |
| taskManager: tm, |
| eventBus: bus, |
| createVFS: vi.fn().mockResolvedValue(mockVFS), |
| apiBaseUrl: 'http://localhost:3000', |
| }; |
| } |
|
|
| it('emits task_complete with result=success on happy path', async () => { |
| const taskId = tm.createTask('proj-1', 'sess-1', 'sk-test'); |
| const emitted: Array<{ event: string; data: Record<string, unknown> }> = []; |
| bus.addListener('sess-1', (e) => emitted.push(e)); |
|
|
| await runServerGeneration(taskId, makeRequest(), makeDeps()); |
|
|
| const complete = emitted.find((e) => e.event === 'task_complete'); |
| expect(complete).toBeDefined(); |
| expect(complete!.data.result).toBe('success'); |
| expect(tm.getTask(taskId)?.status).toBe('completed'); |
| }); |
|
|
| it('emits task_complete with result=failed when the loop reports failure without throwing', async () => { |
| mockExecute.mockResolvedValueOnce({ |
| success: false, |
| summary: 'Reached maximum iterations (40)', |
| exitReason: 'max_iterations', |
| }); |
| const taskId = tm.createTask('proj-1', 'sess-1', 'sk-test'); |
| const emitted: Array<{ event: string; data: Record<string, unknown> }> = []; |
| bus.addListener('sess-1', (e) => emitted.push(e)); |
|
|
| await runServerGeneration(taskId, makeRequest(), makeDeps()); |
|
|
| const complete = emitted.find((e) => e.event === 'task_complete'); |
| expect(complete!.data.result).toBe('failed'); |
| expect(complete!.data.error).toBe('Reached maximum iterations (40)'); |
| expect(tm.getTask(taskId)?.status).toBe('failed'); |
| }); |
|
|
| it('emits task_complete with result=stopped when the loop reports a user stop', async () => { |
| mockExecute.mockResolvedValueOnce({ |
| success: false, |
| summary: 'Stopped by user', |
| exitReason: 'stopped', |
| }); |
| const taskId = tm.createTask('proj-1', 'sess-1', 'sk-test'); |
| const emitted: Array<{ event: string; data: Record<string, unknown> }> = []; |
| bus.addListener('sess-1', (e) => emitted.push(e)); |
|
|
| await runServerGeneration(taskId, makeRequest(), makeDeps()); |
|
|
| const complete = emitted.find((e) => e.event === 'task_complete'); |
| expect(complete!.data.result).toBe('stopped'); |
| expect(tm.getTask(taskId)?.status).toBe('cancelled'); |
| }); |
|
|
| it('emits task_complete with result=failed when orchestrator throws', async () => { |
| mockExecute.mockRejectedValueOnce(new Error('LLM API failed')); |
| const taskId = tm.createTask('proj-1', 'sess-1', 'sk-test'); |
| const emitted: Array<{ event: string; data: Record<string, unknown> }> = []; |
| bus.addListener('sess-1', (e) => emitted.push(e)); |
|
|
| await runServerGeneration(taskId, makeRequest(), makeDeps()); |
|
|
| const errorEvt = emitted.find((e) => e.event === 'error'); |
| expect(errorEvt).toBeDefined(); |
| expect(errorEvt!.data.message).toBe('LLM API failed'); |
|
|
| const complete = emitted.find((e) => e.event === 'task_complete'); |
| expect(complete!.data.result).toBe('failed'); |
| expect(tm.getTask(taskId)?.status).toBe('failed'); |
| }); |
|
|
| it('emits task_complete with result=stopped when task is cancelled', async () => { |
| const taskId = tm.createTask('proj-1', 'sess-1', 'sk-test'); |
| mockExecute.mockImplementationOnce(async () => { |
| tm.getTask(taskId)!.status = 'cancelled'; |
| throw new Error('Stopped'); |
| }); |
| const emitted: Array<{ event: string; data: Record<string, unknown> }> = []; |
| bus.addListener('sess-1', (e) => emitted.push(e)); |
|
|
| await runServerGeneration(taskId, makeRequest(), makeDeps()); |
|
|
| const complete = emitted.find((e) => e.event === 'task_complete'); |
| expect(complete!.data.result).toBe('stopped'); |
| expect(tm.getTask(taskId)?.status).toBe('cancelled'); |
| }); |
|
|
| it('emits task_complete with result=stopped when task is paused', async () => { |
| const taskId = tm.createTask('proj-1', 'sess-1', 'sk-test'); |
| mockExecute.mockImplementationOnce(async () => { |
| tm.getTask(taskId)!.status = 'paused'; |
| throw new Error('Paused'); |
| }); |
| const emitted: Array<{ event: string; data: Record<string, unknown> }> = []; |
| bus.addListener('sess-1', (e) => emitted.push(e)); |
|
|
| await runServerGeneration(taskId, makeRequest(), makeDeps()); |
|
|
| const complete = emitted.find((e) => e.event === 'task_complete'); |
| expect(complete!.data.result).toBe('stopped'); |
| expect(tm.getTask(taskId)?.status).toBe('cancelled'); |
| }); |
|
|
| it('reports stopped (not success) when task paused without throw', async () => { |
| const taskId = tm.createTask('proj-1', 'sess-1', 'sk-test'); |
| mockExecute.mockImplementationOnce(async () => { |
| tm.getTask(taskId)!.status = 'paused'; |
| return { success: true }; |
| }); |
| const emitted: Array<{ event: string; data: Record<string, unknown> }> = []; |
| bus.addListener('sess-1', (e) => emitted.push(e)); |
|
|
| await runServerGeneration(taskId, makeRequest(), makeDeps()); |
|
|
| const complete = emitted.find((e) => e.event === 'task_complete'); |
| expect(complete!.data.result).toBe('stopped'); |
| }); |
|
|
| it('throws when task does not exist', async () => { |
| await expect(runServerGeneration('nonexistent', makeRequest(), makeDeps())) |
| .rejects.toThrow('Task nonexistent not found'); |
| }); |
|
|
| it('calls importConversation when conversationHistory is provided', async () => { |
| const taskId = tm.createTask('proj-1', 'sess-1', 'sk-test'); |
| const history = [{ role: 'user', content: 'hello' }]; |
|
|
| await runServerGeneration(taskId, makeRequest({ conversationHistory: history }), makeDeps()); |
|
|
| expect(mockImportConversation).toHaveBeenCalledWith(history); |
| }); |
|
|
| it('does not call importConversation when history is empty', async () => { |
| const taskId = tm.createTask('proj-1', 'sess-1', 'sk-test'); |
|
|
| await runServerGeneration(taskId, makeRequest({ conversationHistory: [] }), makeDeps()); |
|
|
| expect(mockImportConversation).not.toHaveBeenCalled(); |
| }); |
|
|
| it('flushes dirty paths as files_changed on tool_status completed', async () => { |
| const taskId = tm.createTask('proj-1', 'sess-1', 'sk-test'); |
| const { MultiAgentOrchestrator } = await import('@/lib/llm/multi-agent-orchestrator'); |
| (MultiAgentOrchestrator as any).mockImplementationOnce( |
| (_projId: string, _role: string, progressCb: (event: string, data?: unknown) => void) => ({ |
| execute: vi.fn().mockImplementation(async () => { |
| |
| |
| await mockVFS.createFile('proj-1', '/new-file.html', '<h1>hi</h1>'); |
| progressCb('tool_status', { status: 'completed' }); |
| return { success: true }; |
| }), |
| stop: vi.fn(), |
| importConversation: vi.fn(), |
| }), |
| ); |
|
|
| const emitted: Array<{ event: string; data: Record<string, unknown> }> = []; |
| bus.addListener('sess-1', (e) => emitted.push(e)); |
|
|
| await runServerGeneration(taskId, makeRequest(), makeDeps()); |
|
|
| const filesChanged = emitted.find((e) => e.event === 'files_changed'); |
| expect(filesChanged).toBeDefined(); |
| expect((filesChanged!.data.paths as string[])).toContain('/new-file.html'); |
| }); |
| }); |
|
|
| describe('awaitBuildResult', () => { |
| let tm: TaskManager; |
| let bus: SSEEventBus; |
|
|
| beforeEach(() => { |
| tm = new TaskManager({ maxConcurrentPerScope: 5, keyTTLMs: 30 * 60 * 1000 }); |
| bus = new SSEEventBus({ maxBufferSize: 500 }); |
| vi.clearAllMocks(); |
| }); |
|
|
| afterEach(() => { |
| tm.dispose(); |
| }); |
|
|
| function makeDeps() { |
| const mockVFS = createMockVFS(); |
| return { |
| taskManager: tm, |
| eventBus: bus, |
| createVFS: vi.fn().mockResolvedValue(mockVFS), |
| apiBaseUrl: 'http://localhost:3000', |
| }; |
| } |
|
|
| it('returns task-not-found error for nonexistent task', async () => { |
| const result = await awaitBuildResult('nonexistent', makeDeps()); |
| expect(result).toEqual({ success: false, errors: ['Task not found'] }); |
| }); |
|
|
| it('resolves when pendingBuildResolve is called', async () => { |
| const taskId = tm.createTask('proj-1', 'sess-1', 'sk-test'); |
| const deps = makeDeps(); |
|
|
| const resultPromise = awaitBuildResult(taskId, deps); |
|
|
| |
| const task = tm.getTask(taskId)!; |
| await vi.waitFor(() => expect(task.pendingBuildResolve).not.toBeNull()); |
| task.pendingBuildResolve!({ success: true }); |
|
|
| const result = await resultPromise; |
| expect(result).toEqual({ success: true }); |
| }); |
|
|
| it('resolves with deferred result after 30s timeout', async () => { |
| vi.useFakeTimers(); |
| try { |
| const taskId = tm.createTask('proj-1', 'sess-1', 'sk-test'); |
| const deps = makeDeps(); |
|
|
| const resultPromise = awaitBuildResult(taskId, deps); |
|
|
| |
| await vi.advanceTimersByTimeAsync(30_000); |
|
|
| const result = await resultPromise; |
| expect(result.success).toBe(true); |
| expect(result.errors).toContain('Build deferred — client disconnected'); |
|
|
| const task = tm.getTask(taskId)!; |
| expect(task.buildDeferred).toBe(true); |
| expect(task.pendingBuildResolve).toBeNull(); |
| } finally { |
| vi.useRealTimers(); |
| } |
| }); |
|
|
| it('emits build_requested event with file manifest', async () => { |
| const taskId = tm.createTask('proj-1', 'sess-1', 'sk-test'); |
| const deps = makeDeps(); |
| const mockVFS = await deps.createVFS('proj-1'); |
| (mockVFS.getAllFilesAndDirectories as any).mockResolvedValue([ |
| { id: 'f1', path: '/index.html', name: 'index.html', updatedAt: new Date('2026-01-01') }, |
| { path: '/src', name: 'src', type: 'directory' }, |
| ]); |
|
|
| const emitted: Array<{ event: string; data: Record<string, unknown> }> = []; |
| bus.addListener('sess-1', (e) => emitted.push(e)); |
|
|
| const resultPromise = awaitBuildResult(taskId, deps); |
|
|
| |
| const task = tm.getTask(taskId)!; |
| await vi.waitFor(() => expect(task.pendingBuildResolve).not.toBeNull()); |
| task.pendingBuildResolve!({ success: true }); |
| await resultPromise; |
|
|
| const buildReq = emitted.find((e) => e.event === 'build_requested'); |
| expect(buildReq).toBeDefined(); |
| expect(buildReq!.data.fileManifest).toBeDefined(); |
| const manifest = buildReq!.data.fileManifest as Record<string, number>; |
| expect(manifest['/index.html']).toBeDefined(); |
| expect(manifest['/src']).toBeUndefined(); |
| }); |
|
|
| it('clears timeout when build resolves before 30s', async () => { |
| vi.useFakeTimers(); |
| try { |
| const taskId = tm.createTask('proj-1', 'sess-1', 'sk-test'); |
| const deps = makeDeps(); |
|
|
| const resultPromise = awaitBuildResult(taskId, deps); |
|
|
| |
| await vi.advanceTimersByTimeAsync(0); |
|
|
| const task = tm.getTask(taskId)!; |
| expect(task.pendingBuildResolve).not.toBeNull(); |
| task.pendingBuildResolve!({ success: true }); |
| await resultPromise; |
|
|
| |
| await vi.advanceTimersByTimeAsync(30_000); |
| expect(task.buildDeferred).toBe(false); |
| } finally { |
| vi.useRealTimers(); |
| } |
| }); |
| }); |
|
|