/** * E2E Test: Full Agent Lifecycle * * Tests the complete lifecycle of an agent from spawn to completion/failure. * Uses mocked services to simulate the full flow without external dependencies. * * Lifecycle: spawn → running → completed/failed/killed * * Covers issue #226 (ORCH-125) */ import { describe, it, expect, beforeEach, vi } from "vitest"; import { ConfigService } from "@nestjs/config"; import { AgentSpawnerService } from "../../src/spawner/agent-spawner.service"; import { AgentLifecycleService } from "../../src/spawner/agent-lifecycle.service"; import { QueueService } from "../../src/queue/queue.service"; import { KillswitchService } from "../../src/killswitch/killswitch.service"; import { AgentsController } from "../../src/api/agents/agents.controller"; import type { AgentState } from "../../src/valkey/types"; describe("E2E: Full Agent Lifecycle", () => { let controller: AgentsController; let spawnerService: AgentSpawnerService; let lifecycleService: AgentLifecycleService; let queueService: QueueService; const mockConfigService = { get: vi.fn((key: string, defaultValue?: unknown) => { const config: Record = { "orchestrator.claude.apiKey": "test-api-key", "orchestrator.queue.name": "test-queue", "orchestrator.queue.maxRetries": 3, "orchestrator.queue.baseDelay": 100, "orchestrator.queue.maxDelay": 1000, "orchestrator.valkey.host": "localhost", "orchestrator.valkey.port": 6379, }; return config[key] ?? defaultValue; }), }; beforeEach(async () => { vi.clearAllMocks(); // Create real spawner service with mock config spawnerService = new AgentSpawnerService(mockConfigService as unknown as ConfigService); // Create mock lifecycle service lifecycleService = { transitionToRunning: vi.fn(), transitionToCompleted: vi.fn(), transitionToFailed: vi.fn(), getAgentLifecycleState: vi.fn(), } as unknown as AgentLifecycleService; // Create mock queue service queueService = { addTask: vi.fn().mockResolvedValue(undefined), getStats: vi.fn(), } as unknown as QueueService; const killswitchService = { killAgent: vi.fn(), killAllAgents: vi.fn(), } as unknown as KillswitchService; controller = new AgentsController( queueService, spawnerService, lifecycleService, killswitchService ); }); describe("Happy path: spawn → queue → track", () => { it("should spawn an agent, register it, and queue the task", async () => { // Step 1: Spawn agent const spawnResult = await controller.spawn({ taskId: "e2e-task-001", agentType: "worker", context: { repository: "https://git.example.com/repo.git", branch: "main", workItems: ["US-001"], skills: ["typescript"], }, }); expect(spawnResult.agentId).toBeDefined(); expect(spawnResult.status).toBe("spawning"); // Step 2: Verify agent appears in list const agents = spawnerService.listAgentSessions(); expect(agents).toHaveLength(1); expect(agents[0].state).toBe("spawning"); expect(agents[0].taskId).toBe("e2e-task-001"); // Step 3: Verify agent status const session = spawnerService.getAgentSession(spawnResult.agentId); expect(session).toBeDefined(); expect(session?.state).toBe("spawning"); expect(session?.agentType).toBe("worker"); // Step 4: Verify task was queued expect(queueService.addTask).toHaveBeenCalledWith( "e2e-task-001", expect.objectContaining({ repository: "https://git.example.com/repo.git", branch: "main", }), { priority: 5 } ); }); it("should track multiple agents spawned sequentially", async () => { // Spawn 3 agents const agents = []; for (let i = 0; i < 3; i++) { const result = await controller.spawn({ taskId: `e2e-task-${String(i).padStart(3, "0")}`, agentType: "worker", context: { repository: "https://git.example.com/repo.git", branch: "main", workItems: [`US-${String(i).padStart(3, "0")}`], }, }); agents.push(result); } // Verify all 3 agents are listed const listedAgents = spawnerService.listAgentSessions(); expect(listedAgents).toHaveLength(3); // Verify each agent has unique ID const agentIds = listedAgents.map((a) => a.agentId); const uniqueIds = new Set(agentIds); expect(uniqueIds.size).toBe(3); }); }); describe("Failure path: spawn → running → failed", () => { it("should handle agent spawn with invalid parameters", async () => { await expect( controller.spawn({ taskId: "", agentType: "worker", context: { repository: "https://git.example.com/repo.git", branch: "main", workItems: ["US-001"], }, }) ).rejects.toThrow("taskId is required"); }); it("should reject invalid agent types", async () => { await expect( controller.spawn({ taskId: "e2e-task-001", agentType: "invalid" as "worker", context: { repository: "https://git.example.com/repo.git", branch: "main", workItems: ["US-001"], }, }) ).rejects.toThrow("agentType must be one of"); }); }); describe("Multi-type agents", () => { it("should support worker, reviewer, and tester agent types", async () => { const types = ["worker", "reviewer", "tester"] as const; for (const agentType of types) { const result = await controller.spawn({ taskId: `e2e-task-${agentType}`, agentType, context: { repository: "https://git.example.com/repo.git", branch: "main", workItems: ["US-001"], }, }); expect(result.agentId).toBeDefined(); expect(result.status).toBe("spawning"); } const agents = spawnerService.listAgentSessions(); expect(agents).toHaveLength(3); const agentTypes = agents.map((a) => a.agentType); expect(agentTypes).toContain("worker"); expect(agentTypes).toContain("reviewer"); expect(agentTypes).toContain("tester"); }); }); describe("Agent status tracking", () => { it("should track spawn timestamp", async () => { const before = new Date(); const result = await controller.spawn({ taskId: "e2e-task-time", agentType: "worker", context: { repository: "https://git.example.com/repo.git", branch: "main", workItems: ["US-001"], }, }); const after = new Date(); const agents = spawnerService.listAgentSessions(); const agent = agents.find((a) => a.agentId === result.agentId); expect(agent).toBeDefined(); const spawnedAt = new Date(agent!.spawnedAt); expect(spawnedAt.getTime()).toBeGreaterThanOrEqual(before.getTime()); expect(spawnedAt.getTime()).toBeLessThanOrEqual(after.getTime()); }); it("should return correct status for each agent", async () => { // Mock lifecycle to return specific states const mockState: AgentState = { agentId: "mock-agent-1", taskId: "e2e-task-001", status: "running", startedAt: new Date().toISOString(), }; (lifecycleService.getAgentLifecycleState as ReturnType).mockResolvedValue( mockState ); const status = await controller.getAgentStatus("mock-agent-1"); expect(status.status).toBe("running"); expect(status.taskId).toBe("e2e-task-001"); }); }); });