Files
stack/apps/orchestrator/src/api/agents/agents.controller.spec.ts
Jason Woltje 9642cd41d4
All checks were successful
ci/woodpecker/push/ci Pipeline was successful
feat(orchestrator): add subagent session tree endpoint
2026-03-07 11:53:39 -06:00

546 lines
16 KiB
TypeScript

import { AgentsController } from "./agents.controller";
import { QueueService } from "../../queue/queue.service";
import { AgentSpawnerService } from "../../spawner/agent-spawner.service";
import { AgentLifecycleService } from "../../spawner/agent-lifecycle.service";
import { KillswitchService } from "../../killswitch/killswitch.service";
import { AgentEventsService } from "./agent-events.service";
import { AgentMessagesService } from "./agent-messages.service";
import { AgentControlService } from "./agent-control.service";
import { AgentTreeService } from "./agent-tree.service";
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
describe("AgentsController", () => {
let controller: AgentsController;
let queueService: {
addTask: ReturnType<typeof vi.fn>;
};
let spawnerService: {
spawnAgent: ReturnType<typeof vi.fn>;
listAgentSessions: ReturnType<typeof vi.fn>;
getAgentSession: ReturnType<typeof vi.fn>;
};
let lifecycleService: {
getAgentLifecycleState: ReturnType<typeof vi.fn>;
registerSpawnedAgent: ReturnType<typeof vi.fn>;
};
let killswitchService: {
killAgent: ReturnType<typeof vi.fn>;
killAllAgents: ReturnType<typeof vi.fn>;
};
let eventsService: {
subscribe: ReturnType<typeof vi.fn>;
getInitialSnapshot: ReturnType<typeof vi.fn>;
createHeartbeat: ReturnType<typeof vi.fn>;
getRecentEvents: ReturnType<typeof vi.fn>;
};
let messagesService: {
getMessages: ReturnType<typeof vi.fn>;
getReplayMessages: ReturnType<typeof vi.fn>;
getMessagesAfter: ReturnType<typeof vi.fn>;
};
let controlService: {
injectMessage: ReturnType<typeof vi.fn>;
pauseAgent: ReturnType<typeof vi.fn>;
resumeAgent: ReturnType<typeof vi.fn>;
};
let treeService: {
getTree: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
// Create mock services
queueService = {
addTask: vi.fn().mockResolvedValue(undefined),
};
spawnerService = {
spawnAgent: vi.fn(),
listAgentSessions: vi.fn(),
getAgentSession: vi.fn(),
};
lifecycleService = {
getAgentLifecycleState: vi.fn(),
registerSpawnedAgent: vi.fn().mockResolvedValue(undefined),
};
killswitchService = {
killAgent: vi.fn(),
killAllAgents: vi.fn(),
};
eventsService = {
subscribe: vi.fn().mockReturnValue(() => {}),
getInitialSnapshot: vi.fn().mockResolvedValue({
type: "stream.snapshot",
timestamp: new Date().toISOString(),
agents: 0,
tasks: 0,
}),
createHeartbeat: vi.fn().mockReturnValue({
type: "task.processing",
timestamp: new Date().toISOString(),
data: { heartbeat: true },
}),
getRecentEvents: vi.fn().mockReturnValue([]),
};
messagesService = {
getMessages: vi.fn(),
getReplayMessages: vi.fn().mockResolvedValue([]),
getMessagesAfter: vi.fn().mockResolvedValue([]),
};
controlService = {
injectMessage: vi.fn().mockResolvedValue(undefined),
pauseAgent: vi.fn().mockResolvedValue(undefined),
resumeAgent: vi.fn().mockResolvedValue(undefined),
};
treeService = {
getTree: vi.fn().mockResolvedValue([]),
};
// Create controller with mocked services
controller = new AgentsController(
queueService as unknown as QueueService,
spawnerService as unknown as AgentSpawnerService,
lifecycleService as unknown as AgentLifecycleService,
killswitchService as unknown as KillswitchService,
eventsService as unknown as AgentEventsService,
messagesService as unknown as AgentMessagesService,
controlService as unknown as AgentControlService,
treeService as unknown as AgentTreeService
);
});
afterEach(() => {
vi.clearAllMocks();
});
it("should be defined", () => {
expect(controller).toBeDefined();
});
describe("getAgentTree", () => {
it("should return tree entries", async () => {
const entries = [
{
sessionId: "agent-1",
parentSessionId: null,
status: "running",
agentType: "worker",
taskSource: "internal",
spawnedAt: "2026-03-07T00:00:00.000Z",
completedAt: null,
},
];
treeService.getTree.mockResolvedValue(entries);
await expect(controller.getAgentTree()).resolves.toEqual(entries);
expect(treeService.getTree).toHaveBeenCalledTimes(1);
});
});
describe("listAgents", () => {
it("should return empty array when no agents exist", () => {
// Arrange
spawnerService.listAgentSessions.mockReturnValue([]);
// Act
const result = controller.listAgents();
// Assert
expect(spawnerService.listAgentSessions).toHaveBeenCalled();
expect(result).toEqual([]);
});
it("should return all agent sessions with mapped status", () => {
// Arrange
const sessions = [
{
agentId: "agent-1",
taskId: "task-1",
agentType: "worker" as const,
state: "running" as const,
context: {
repository: "repo",
branch: "main",
workItems: [],
},
spawnedAt: new Date("2026-02-05T12:00:00Z"),
},
{
agentId: "agent-2",
taskId: "task-2",
agentType: "reviewer" as const,
state: "completed" as const,
context: {
repository: "repo",
branch: "main",
workItems: [],
},
spawnedAt: new Date("2026-02-05T11:00:00Z"),
completedAt: new Date("2026-02-05T11:30:00Z"),
},
{
agentId: "agent-3",
taskId: "task-3",
agentType: "tester" as const,
state: "failed" as const,
context: {
repository: "repo",
branch: "main",
workItems: [],
},
spawnedAt: new Date("2026-02-05T10:00:00Z"),
error: "Test execution failed",
},
];
spawnerService.listAgentSessions.mockReturnValue(sessions);
// Act
const result = controller.listAgents();
// Assert
expect(spawnerService.listAgentSessions).toHaveBeenCalled();
expect(result).toHaveLength(3);
expect(result[0]).toEqual({
agentId: "agent-1",
taskId: "task-1",
status: "running",
agentType: "worker",
spawnedAt: "2026-02-05T12:00:00.000Z",
completedAt: undefined,
error: undefined,
});
expect(result[1]).toEqual({
agentId: "agent-2",
taskId: "task-2",
status: "completed",
agentType: "reviewer",
spawnedAt: "2026-02-05T11:00:00.000Z",
completedAt: "2026-02-05T11:30:00.000Z",
error: undefined,
});
expect(result[2]).toEqual({
agentId: "agent-3",
taskId: "task-3",
status: "failed",
agentType: "tester",
spawnedAt: "2026-02-05T10:00:00.000Z",
completedAt: undefined,
error: "Test execution failed",
});
});
it("should handle errors gracefully", () => {
// Arrange
spawnerService.listAgentSessions.mockImplementation(() => {
throw new Error("Service unavailable");
});
// Act & Assert
expect(() => controller.listAgents()).toThrow("Failed to list agents: Service unavailable");
});
});
describe("spawn", () => {
const validRequest = {
taskId: "task-123",
agentType: "worker" as const,
context: {
repository: "https://github.com/org/repo.git",
branch: "main",
workItems: ["US-001", "US-002"],
skills: ["typescript", "nestjs"],
},
};
it("should spawn agent and queue task successfully", async () => {
// Arrange
const agentId = "agent-abc-123";
const spawnedAt = new Date();
spawnerService.spawnAgent.mockReturnValue({
agentId,
state: "spawning",
spawnedAt,
});
queueService.addTask.mockResolvedValue(undefined);
// Act
const result = await controller.spawn(validRequest);
// Assert
expect(spawnerService.spawnAgent).toHaveBeenCalledWith(validRequest);
expect(queueService.addTask).toHaveBeenCalledWith(validRequest.taskId, validRequest.context, {
priority: 5,
});
expect(lifecycleService.registerSpawnedAgent).toHaveBeenCalledWith(
agentId,
validRequest.taskId
);
expect(result).toEqual({
agentId,
status: "spawning",
});
});
it("should return queued status when agent is queued", async () => {
// Arrange
const agentId = "agent-abc-123";
spawnerService.spawnAgent.mockReturnValue({
agentId,
state: "spawning",
spawnedAt: new Date(),
});
queueService.addTask.mockResolvedValue(undefined);
// Act
const result = await controller.spawn(validRequest);
// Assert
expect(result.status).toBe("spawning");
});
it("should handle reviewer agent type", async () => {
// Arrange
const reviewerRequest = {
...validRequest,
agentType: "reviewer" as const,
};
const agentId = "agent-reviewer-123";
spawnerService.spawnAgent.mockReturnValue({
agentId,
state: "spawning",
spawnedAt: new Date(),
});
queueService.addTask.mockResolvedValue(undefined);
// Act
const result = await controller.spawn(reviewerRequest);
// Assert
expect(spawnerService.spawnAgent).toHaveBeenCalledWith(reviewerRequest);
expect(result.agentId).toBe(agentId);
});
it("should handle tester agent type", async () => {
// Arrange
const testerRequest = {
...validRequest,
agentType: "tester" as const,
};
const agentId = "agent-tester-123";
spawnerService.spawnAgent.mockReturnValue({
agentId,
state: "spawning",
spawnedAt: new Date(),
});
queueService.addTask.mockResolvedValue(undefined);
// Act
const result = await controller.spawn(testerRequest);
// Assert
expect(spawnerService.spawnAgent).toHaveBeenCalledWith(testerRequest);
expect(result.agentId).toBe(agentId);
});
it("should handle missing optional skills", async () => {
// Arrange
const requestWithoutSkills = {
taskId: "task-123",
agentType: "worker" as const,
context: {
repository: "https://github.com/org/repo.git",
branch: "main",
workItems: ["US-001"],
},
};
const agentId = "agent-abc-123";
spawnerService.spawnAgent.mockReturnValue({
agentId,
state: "spawning",
spawnedAt: new Date(),
});
queueService.addTask.mockResolvedValue(undefined);
// Act
const result = await controller.spawn(requestWithoutSkills);
// Assert
expect(result.agentId).toBe(agentId);
});
it("should propagate errors from spawner service", async () => {
// Arrange
const error = new Error("Spawner failed");
spawnerService.spawnAgent.mockImplementation(() => {
throw error;
});
// Act & Assert
await expect(controller.spawn(validRequest)).rejects.toThrow("Spawner failed");
expect(queueService.addTask).not.toHaveBeenCalled();
});
it("should propagate errors from queue service", async () => {
// Arrange
const agentId = "agent-abc-123";
spawnerService.spawnAgent.mockReturnValue({
agentId,
state: "spawning",
spawnedAt: new Date(),
});
const error = new Error("Queue failed");
queueService.addTask.mockRejectedValue(error);
// Act & Assert
await expect(controller.spawn(validRequest)).rejects.toThrow("Queue failed");
});
it("should use default priority of 5", async () => {
// Arrange
const agentId = "agent-abc-123";
spawnerService.spawnAgent.mockReturnValue({
agentId,
state: "spawning",
spawnedAt: new Date(),
});
queueService.addTask.mockResolvedValue(undefined);
// Act
await controller.spawn(validRequest);
// Assert
expect(queueService.addTask).toHaveBeenCalledWith(validRequest.taskId, validRequest.context, {
priority: 5,
});
});
});
describe("agent control endpoints", () => {
const agentId = "0b64079f-4487-42b9-92eb-cf8ea0042a64";
it("should inject an operator message", async () => {
const req = { apiKey: "control-key" };
const result = await controller.injectAgentMessage(
agentId,
{ message: "pause and summarize" },
req
);
expect(controlService.injectMessage).toHaveBeenCalledWith(
agentId,
"control-key",
"pause and summarize"
);
expect(result).toEqual({ message: `Message injected into agent ${agentId}` });
});
it("should default operator id when request api key is missing", async () => {
await controller.injectAgentMessage(agentId, { message: "continue" }, {});
expect(controlService.injectMessage).toHaveBeenCalledWith(agentId, "operator", "continue");
});
it("should pause an agent", async () => {
const result = await controller.pauseAgent(agentId, {}, { apiKey: "ops-user" });
expect(controlService.pauseAgent).toHaveBeenCalledWith(agentId, "ops-user");
expect(result).toEqual({ message: `Agent ${agentId} paused` });
});
it("should resume an agent", async () => {
const result = await controller.resumeAgent(agentId, {}, { apiKey: "ops-user" });
expect(controlService.resumeAgent).toHaveBeenCalledWith(agentId, "ops-user");
expect(result).toEqual({ message: `Agent ${agentId} resumed` });
});
});
describe("getAgentMessages", () => {
it("should return paginated message history", async () => {
const agentId = "0b64079f-4487-42b9-92eb-cf8ea0042a64";
const query = {
limit: 25,
skip: 10,
};
const response = {
messages: [
{
id: "msg-1",
sessionId: agentId,
role: "agent",
content: "hello",
provider: "internal",
timestamp: new Date("2026-03-07T03:00:00.000Z"),
metadata: {},
},
],
total: 101,
};
messagesService.getMessages.mockResolvedValue(response);
const result = await controller.getAgentMessages(agentId, query);
expect(messagesService.getMessages).toHaveBeenCalledWith(agentId, 25, 10);
expect(result).toEqual(response);
});
it("should use default pagination values", async () => {
const agentId = "0b64079f-4487-42b9-92eb-cf8ea0042a64";
const query = {
limit: 50,
skip: 0,
};
messagesService.getMessages.mockResolvedValue({ messages: [], total: 0 });
await controller.getAgentMessages(agentId, query);
expect(messagesService.getMessages).toHaveBeenCalledWith(agentId, 50, 0);
});
});
describe("getRecentEvents", () => {
it("should return recent events with default limit", () => {
eventsService.getRecentEvents.mockReturnValue([
{
type: "task.completed",
timestamp: "2026-02-17T15:00:00.000Z",
taskId: "task-123",
},
]);
const result = controller.getRecentEvents();
expect(eventsService.getRecentEvents).toHaveBeenCalledWith(100);
expect(result).toEqual({
events: [
{
type: "task.completed",
timestamp: "2026-02-17T15:00:00.000Z",
taskId: "task-123",
},
],
});
});
it("should parse and pass custom limit", () => {
controller.getRecentEvents("25");
expect(eventsService.getRecentEvents).toHaveBeenCalledWith(25);
});
it("should fallback to default when limit is invalid", () => {
controller.getRecentEvents("invalid");
expect(eventsService.getRecentEvents).toHaveBeenCalledWith(100);
});
});
});