import { ConfigService } from "@nestjs/config"; import { HttpException, HttpStatus } from "@nestjs/common"; import { describe, it, expect, beforeEach, vi } from "vitest"; import { AgentSpawnerService } from "./agent-spawner.service"; import { SpawnAgentRequest } from "./types/agent-spawner.types"; describe("AgentSpawnerService", () => { let service: AgentSpawnerService; let mockConfigService: ConfigService; beforeEach(() => { // Create mock ConfigService mockConfigService = { get: vi.fn((key: string) => { if (key === "orchestrator.aiProvider") { return "ollama"; } if (key === "orchestrator.claude.apiKey") { return "test-api-key"; } if (key === "orchestrator.spawner.maxConcurrentAgents") { return 20; } return undefined; }), } as unknown as ConfigService; // Create service with mock service = new AgentSpawnerService(mockConfigService); }); describe("constructor", () => { it("should be defined", () => { expect(service).toBeDefined(); }); it("should initialize with default AI provider when API key is omitted", () => { const noClaudeConfigService = { get: vi.fn((key: string) => { if (key === "orchestrator.aiProvider") { return "ollama"; } if (key === "orchestrator.spawner.maxConcurrentAgents") { return 20; } if (key === "orchestrator.spawner.sessionCleanupDelayMs") { return 30000; } return undefined; }), } as unknown as ConfigService; const serviceNoKey = new AgentSpawnerService(noClaudeConfigService); expect(serviceNoKey).toBeDefined(); }); it("should initialize with Claude provider when key is present", () => { expect(mockConfigService.get).toHaveBeenCalledWith("orchestrator.claude.apiKey"); }); it("should initialize with CLAUDE provider when API key is present", () => { const claudeConfigService = { get: vi.fn((key: string) => { if (key === "orchestrator.aiProvider") { return "claude"; } if (key === "orchestrator.claude.apiKey") { return "test-api-key"; } if (key === "orchestrator.spawner.maxConcurrentAgents") { return 20; } return undefined; }), } as unknown as ConfigService; const claudeService = new AgentSpawnerService(claudeConfigService); expect(claudeService).toBeDefined(); }); it("should throw error if Claude API key is missing when provider is claude", () => { const badConfigService = { get: vi.fn((key: string) => { if (key === "orchestrator.aiProvider") { return "claude"; } return undefined; }), } as unknown as ConfigService; expect(() => new AgentSpawnerService(badConfigService)).toThrow( "CLAUDE_API_KEY is required when AI_PROVIDER is set to 'claude'" ); }); it("should still initialize when CLAUDE_API_KEY is missing for non-Claude provider", () => { const nonClaudeConfigService = { get: vi.fn((key: string) => { if (key === "orchestrator.aiProvider") { return "ollama"; } if (key === "orchestrator.spawner.maxConcurrentAgents") { return 20; } return undefined; }), } as unknown as ConfigService; expect(() => new AgentSpawnerService(nonClaudeConfigService)).not.toThrow(); }); }); describe("spawnAgent", () => { const validRequest: SpawnAgentRequest = { taskId: "task-123", agentType: "worker", context: { repository: "https://github.com/test/repo.git", branch: "main", workItems: ["Implement feature X"], }, }; it("should spawn an agent and return agentId", () => { const response = service.spawnAgent(validRequest); expect(response).toBeDefined(); expect(response.agentId).toBeDefined(); expect(typeof response.agentId).toBe("string"); expect(response.state).toBe("spawning"); expect(response.spawnedAt).toBeInstanceOf(Date); }); it("should generate unique agentId for each spawn", () => { const response1 = service.spawnAgent(validRequest); const response2 = service.spawnAgent(validRequest); expect(response1.agentId).not.toBe(response2.agentId); }); it("should track agent session", () => { const response = service.spawnAgent(validRequest); const session = service.getAgentSession(response.agentId); expect(session).toBeDefined(); expect(session?.agentId).toBe(response.agentId); expect(session?.taskId).toBe(validRequest.taskId); expect(session?.agentType).toBe(validRequest.agentType); expect(session?.state).toBe("spawning"); }); it("should validate taskId is provided", () => { const invalidRequest = { ...validRequest, taskId: "", }; expect(() => service.spawnAgent(invalidRequest)).toThrow("taskId is required"); }); it("should validate agentType is valid", () => { const invalidRequest = { ...validRequest, agentType: "invalid" as unknown as "worker", }; expect(() => service.spawnAgent(invalidRequest)).toThrow( "agentType must be one of: worker, reviewer, tester" ); }); it("should validate context.repository is provided", () => { const invalidRequest = { ...validRequest, context: { ...validRequest.context, repository: "", }, }; expect(() => service.spawnAgent(invalidRequest)).toThrow("context.repository is required"); }); it("should validate context.branch is provided", () => { const invalidRequest = { ...validRequest, context: { ...validRequest.context, branch: "", }, }; expect(() => service.spawnAgent(invalidRequest)).toThrow("context.branch is required"); }); it("should validate context.workItems is not empty", () => { const invalidRequest = { ...validRequest, context: { ...validRequest.context, workItems: [], }, }; expect(() => service.spawnAgent(invalidRequest)).toThrow( "context.workItems must not be empty" ); }); it("should accept optional skills in context", () => { const requestWithSkills: SpawnAgentRequest = { ...validRequest, context: { ...validRequest.context, skills: ["typescript", "nestjs"], }, }; const response = service.spawnAgent(requestWithSkills); const session = service.getAgentSession(response.agentId); expect(session?.context.skills).toEqual(["typescript", "nestjs"]); }); it("should accept optional options", () => { const requestWithOptions: SpawnAgentRequest = { ...validRequest, options: { sandbox: true, timeout: 3600000, maxRetries: 3, }, }; const response = service.spawnAgent(requestWithOptions); const session = service.getAgentSession(response.agentId); expect(session?.options).toEqual({ sandbox: true, timeout: 3600000, maxRetries: 3, }); }); it("should handle spawn errors gracefully", () => { // Mock Claude SDK to throw error const errorRequest = { ...validRequest, context: { ...validRequest.context, repository: "invalid-repo-that-will-fail", }, }; // For now, this should not throw but handle gracefully // We'll implement error handling in the service const response = service.spawnAgent(errorRequest); expect(response.agentId).toBeDefined(); }); }); describe("getAgentSession", () => { it("should return undefined for non-existent agentId", () => { const session = service.getAgentSession("non-existent-id"); expect(session).toBeUndefined(); }); it("should return session for existing agentId", () => { const request: SpawnAgentRequest = { taskId: "task-123", agentType: "worker", context: { repository: "https://github.com/test/repo.git", branch: "main", workItems: ["Implement feature X"], }, }; const response = service.spawnAgent(request); const session = service.getAgentSession(response.agentId); expect(session).toBeDefined(); expect(session?.agentId).toBe(response.agentId); }); }); describe("listAgentSessions", () => { it("should return empty array when no agents spawned", () => { const sessions = service.listAgentSessions(); expect(sessions).toEqual([]); }); it("should return all spawned agent sessions", () => { const request1: SpawnAgentRequest = { taskId: "task-1", agentType: "worker", context: { repository: "https://github.com/test/repo1.git", branch: "main", workItems: ["Task 1"], }, }; const request2: SpawnAgentRequest = { taskId: "task-2", agentType: "reviewer", context: { repository: "https://github.com/test/repo2.git", branch: "develop", workItems: ["Task 2"], }, }; service.spawnAgent(request1); service.spawnAgent(request2); const sessions = service.listAgentSessions(); expect(sessions).toHaveLength(2); expect(sessions[0].agentType).toBe("worker"); expect(sessions[1].agentType).toBe("reviewer"); }); }); describe("max concurrent agents limit", () => { const createValidRequest = (taskId: string): SpawnAgentRequest => ({ taskId, agentType: "worker", context: { repository: "https://github.com/test/repo.git", branch: "main", workItems: ["Implement feature X"], }, }); it("should allow spawning when under the limit", () => { // Default limit is 20, spawn 5 agents for (let i = 0; i < 5; i++) { const response = service.spawnAgent(createValidRequest(`task-${i}`)); expect(response.agentId).toBeDefined(); } expect(service.listAgentSessions()).toHaveLength(5); }); it("should reject spawn when at the limit", () => { // Create service with low limit for testing const limitedConfigService = { get: vi.fn((key: string) => { if (key === "orchestrator.claude.apiKey") { return "test-api-key"; } if (key === "orchestrator.spawner.maxConcurrentAgents") { return 3; } return undefined; }), } as unknown as ConfigService; const limitedService = new AgentSpawnerService(limitedConfigService); // Spawn up to the limit limitedService.spawnAgent(createValidRequest("task-1")); limitedService.spawnAgent(createValidRequest("task-2")); limitedService.spawnAgent(createValidRequest("task-3")); // Next spawn should throw 429 Too Many Requests expect(() => limitedService.spawnAgent(createValidRequest("task-4"))).toThrow(HttpException); try { limitedService.spawnAgent(createValidRequest("task-5")); } catch (error) { expect(error).toBeInstanceOf(HttpException); expect((error as HttpException).getStatus()).toBe(HttpStatus.TOO_MANY_REQUESTS); expect((error as HttpException).message).toContain("Maximum concurrent agents limit"); } }); it("should provide appropriate error message when limit reached", () => { const limitedConfigService = { get: vi.fn((key: string) => { if (key === "orchestrator.claude.apiKey") { return "test-api-key"; } if (key === "orchestrator.spawner.maxConcurrentAgents") { return 2; } return undefined; }), } as unknown as ConfigService; const limitedService = new AgentSpawnerService(limitedConfigService); // Spawn up to the limit limitedService.spawnAgent(createValidRequest("task-1")); limitedService.spawnAgent(createValidRequest("task-2")); // Next spawn should throw with appropriate message try { limitedService.spawnAgent(createValidRequest("task-3")); expect.fail("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(HttpException); const httpError = error as HttpException; expect(httpError.getStatus()).toBe(HttpStatus.TOO_MANY_REQUESTS); expect(httpError.message).toContain("2"); } }); it("should use default limit of 20 when not configured", () => { const defaultConfigService = { get: vi.fn((key: string) => { if (key === "orchestrator.claude.apiKey") { return "test-api-key"; } // Return undefined for maxConcurrentAgents to test default return undefined; }), } as unknown as ConfigService; const defaultService = new AgentSpawnerService(defaultConfigService); // Should be able to spawn 20 agents for (let i = 0; i < 20; i++) { const response = defaultService.spawnAgent(createValidRequest(`task-${i}`)); expect(response.agentId).toBeDefined(); } // 21st should fail expect(() => defaultService.spawnAgent(createValidRequest("task-21"))).toThrow(HttpException); }); it("should return current and max count in error response", () => { const limitedConfigService = { get: vi.fn((key: string) => { if (key === "orchestrator.claude.apiKey") { return "test-api-key"; } if (key === "orchestrator.spawner.maxConcurrentAgents") { return 5; } return undefined; }), } as unknown as ConfigService; const limitedService = new AgentSpawnerService(limitedConfigService); // Spawn 5 agents for (let i = 0; i < 5; i++) { limitedService.spawnAgent(createValidRequest(`task-${i}`)); } try { limitedService.spawnAgent(createValidRequest("task-6")); expect.fail("Should have thrown"); } catch (error) { expect(error).toBeInstanceOf(HttpException); const httpError = error as HttpException; const response = httpError.getResponse() as { message: string; currentCount: number; maxLimit: number; }; expect(response.currentCount).toBe(5); expect(response.maxLimit).toBe(5); } }); }); describe("session cleanup", () => { const createValidRequest = (taskId: string): SpawnAgentRequest => ({ taskId, agentType: "worker", context: { repository: "https://github.com/test/repo.git", branch: "main", workItems: ["Implement feature X"], }, }); it("should remove session immediately", () => { const response = service.spawnAgent(createValidRequest("task-1")); expect(service.getAgentSession(response.agentId)).toBeDefined(); const removed = service.removeSession(response.agentId); expect(removed).toBe(true); expect(service.getAgentSession(response.agentId)).toBeUndefined(); }); it("should return false when removing non-existent session", () => { const removed = service.removeSession("non-existent-id"); expect(removed).toBe(false); }); it("should schedule session cleanup with delay", async () => { vi.useFakeTimers(); const response = service.spawnAgent(createValidRequest("task-1")); expect(service.getAgentSession(response.agentId)).toBeDefined(); // Schedule cleanup with short delay service.scheduleSessionCleanup(response.agentId, 100); // Session should still exist before delay expect(service.getAgentSession(response.agentId)).toBeDefined(); expect(service.getPendingCleanupCount()).toBe(1); // Advance timer past the delay vi.advanceTimersByTime(150); // Session should be cleaned up expect(service.getAgentSession(response.agentId)).toBeUndefined(); expect(service.getPendingCleanupCount()).toBe(0); vi.useRealTimers(); }); it("should replace existing cleanup timer when rescheduled", async () => { vi.useFakeTimers(); const response = service.spawnAgent(createValidRequest("task-1")); // Schedule cleanup with 100ms delay service.scheduleSessionCleanup(response.agentId, 100); expect(service.getPendingCleanupCount()).toBe(1); // Advance by 50ms (halfway) vi.advanceTimersByTime(50); expect(service.getAgentSession(response.agentId)).toBeDefined(); // Reschedule with 100ms delay (should reset the timer) service.scheduleSessionCleanup(response.agentId, 100); expect(service.getPendingCleanupCount()).toBe(1); // Advance by 75ms (past original but not new) vi.advanceTimersByTime(75); expect(service.getAgentSession(response.agentId)).toBeDefined(); // Advance by remaining 25ms vi.advanceTimersByTime(50); expect(service.getAgentSession(response.agentId)).toBeUndefined(); vi.useRealTimers(); }); it("should clear cleanup timer when session is removed directly", () => { vi.useFakeTimers(); const response = service.spawnAgent(createValidRequest("task-1")); // Schedule cleanup service.scheduleSessionCleanup(response.agentId, 1000); expect(service.getPendingCleanupCount()).toBe(1); // Remove session directly service.removeSession(response.agentId); // Timer should be cleared expect(service.getPendingCleanupCount()).toBe(0); vi.useRealTimers(); }); it("should decrease session count after cleanup", async () => { vi.useFakeTimers(); // Create service with low limit for testing const limitedConfigService = { get: vi.fn((key: string) => { if (key === "orchestrator.claude.apiKey") { return "test-api-key"; } if (key === "orchestrator.spawner.maxConcurrentAgents") { return 2; } return undefined; }), } as unknown as ConfigService; const limitedService = new AgentSpawnerService(limitedConfigService); // Spawn up to the limit const response1 = limitedService.spawnAgent(createValidRequest("task-1")); limitedService.spawnAgent(createValidRequest("task-2")); // Should be at limit expect(limitedService.listAgentSessions()).toHaveLength(2); expect(() => limitedService.spawnAgent(createValidRequest("task-3"))).toThrow(HttpException); // Schedule cleanup for first agent limitedService.scheduleSessionCleanup(response1.agentId, 100); vi.advanceTimersByTime(150); // Should have freed a slot expect(limitedService.listAgentSessions()).toHaveLength(1); // Should be able to spawn another agent now const response3 = limitedService.spawnAgent(createValidRequest("task-3")); expect(response3.agentId).toBeDefined(); vi.useRealTimers(); }); it("should clear all timers on module destroy", () => { vi.useFakeTimers(); const response1 = service.spawnAgent(createValidRequest("task-1")); const response2 = service.spawnAgent(createValidRequest("task-2")); service.scheduleSessionCleanup(response1.agentId, 1000); service.scheduleSessionCleanup(response2.agentId, 1000); expect(service.getPendingCleanupCount()).toBe(2); // Call module destroy service.onModuleDestroy(); expect(service.getPendingCleanupCount()).toBe(0); vi.useRealTimers(); }); }); });