From ccbb9762f0b8f8cd07143f67572cf8212cff8552 Mon Sep 17 00:00:00 2001 From: Jefferson Youashi <119521983+clintjeff2@users.noreply.github.com> Date: Tue, 30 Jun 2026 16:29:35 +0100 Subject: [PATCH] Add Agent SDK lifecycle tests --- app/api/agents/[id]/task/route.ts | 34 ++++-- lib/agent-registry.ts | 4 +- lib/agent-runtime/__tests__/agent.test.ts | 134 ++++++++++++++++++++++ lib/agent-runtime/agent.ts | 8 +- lib/agents/agent-health-store.ts | 2 +- lib/types.ts | 4 +- 6 files changed, 170 insertions(+), 16 deletions(-) create mode 100644 lib/agent-runtime/__tests__/agent.test.ts diff --git a/app/api/agents/[id]/task/route.ts b/app/api/agents/[id]/task/route.ts index feefa43e..6488fe99 100644 --- a/app/api/agents/[id]/task/route.ts +++ b/app/api/agents/[id]/task/route.ts @@ -9,14 +9,22 @@ interface RouteContext { function getRuntimeAgent(id: string) { const displayAgent = findAgentByLookup(id) + if (!displayAgent) { + if (!/^bot-\d+$/.test(id)) return null + return getOrCreateAgent({ + id, + name: id, + model: "claude/runtime-delegated", + }) + } return getOrCreateAgent({ - id: displayAgent?.id ?? id, - name: displayAgent?.name ?? id, - model: displayAgent?.model ?? "claude/runtime-delegated", - district: displayAgent?.district, - cpu: displayAgent?.cpu, - memory: displayAgent?.memory, - autoRestart: displayAgent?.autoRestart, + id: displayAgent.id, + name: displayAgent.name, + model: displayAgent.model, + district: displayAgent.district, + cpu: displayAgent.cpu, + memory: displayAgent.memory, + autoRestart: displayAgent.autoRestart, }) } @@ -24,6 +32,12 @@ export async function GET(_req: Request, context: RouteContext) { const { id } = await context.params const agentId = decodeURIComponent(id) const agent = getRuntimeAgent(agentId) + if (!agent) { + return NextResponse.json( + { ok: false, error: "agent_not_found" }, + { status: 404, headers: { "Cache-Control": "no-store" } }, + ) + } return NextResponse.json( { ok: true, tasks: listAgentTaskRecords(agent.id) }, @@ -36,6 +50,12 @@ export async function POST(req: Request, context: RouteContext) { const agentId = decodeURIComponent(id) const body = await req.json().catch(() => ({})) const agent = getRuntimeAgent(agentId) + if (!agent) { + return NextResponse.json( + { ok: false, error: "agent_not_found" }, + { status: 404, headers: { "Cache-Control": "no-store" } }, + ) + } const rateLimit = checkRateLimit(agentId) if (!rateLimit.allowed) { diff --git a/lib/agent-registry.ts b/lib/agent-registry.ts index 71ce95b2..a36551b3 100644 --- a/lib/agent-registry.ts +++ b/lib/agent-registry.ts @@ -41,7 +41,7 @@ export interface CapabilityCount { export type AgentRegistryChangeAction = "registered" | "updated" | "deregistered" const DISTRICTS: DistrictId[] = ["data-center", "comm-hub", "processing", "defense", "research"] -const STATUSES: AgentStatus[] = ["active", "idle", "working", "error", "offline"] +const STATUSES: AgentStatus[] = ["active", "idle", "running", "working", "error", "offline", "stopped"] interface AgentRegistryState { agents: Map @@ -276,4 +276,4 @@ export function deregisterAgent(agentId: string): AgentCapabilityManifest | null export function resetAgentRegistryForTests(): void { registry.agents.clear() -} \ No newline at end of file +} diff --git a/lib/agent-runtime/__tests__/agent.test.ts b/lib/agent-runtime/__tests__/agent.test.ts new file mode 100644 index 00000000..8827fb7d --- /dev/null +++ b/lib/agent-runtime/__tests__/agent.test.ts @@ -0,0 +1,134 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest" +import { Agent } from "@/lib/agent-runtime/agent" +import type { AgentConfig, Task } from "@/lib/agent-runtime/types" +import { POST } from "@/app/api/agents/[id]/task/route" + +const jest = vi + +function createConfig(id: string, overrides: Partial = {}): AgentConfig { + return { + id, + name: `Agent ${id}`, + model: "claude/test", + heartbeatIntervalMs: 1_000, + offlineAfterMs: 60_000, + ...overrides, + } +} + +function createTask(id = "task-1"): Task { + return { + id, + title: "Test task", + payload: { ok: true }, + createdAt: new Date(Date.now()).toISOString(), + } +} + +describe("Agent SDK lifecycle", () => { + beforeEach(() => { + jest.useFakeTimers() + jest.setSystemTime(new Date("2026-06-30T00:00:00.000Z")) + }) + + afterEach(() => { + jest.runOnlyPendingTimers() + jest.useRealTimers() + vi.restoreAllMocks() + }) + + it("start() transitions agent status from idle to running and starts the heartbeat", async () => { + const agent = new Agent(createConfig("agent-start")) + + expect(agent.getStatus()).toBe("idle") + + await agent.start() + const firstHeartbeat = agent.getMetrics().lastHeartbeat + + expect(agent.getStatus()).toBe("running") + expect(firstHeartbeat).toBe("2026-06-30T00:00:00.000Z") + + jest.advanceTimersByTime(1_000) + + expect(agent.getMetrics().lastHeartbeat).toBe("2026-06-30T00:00:01.000Z") + }) + + it("stop() transitions to stopped and clears the heartbeat interval", async () => { + const agent = new Agent(createConfig("agent-stop")) + await agent.start() + + jest.advanceTimersByTime(1_000) + await agent.stop() + const stoppedHeartbeat = agent.getMetrics().lastHeartbeat + + expect(agent.getStatus()).toBe("stopped") + + jest.advanceTimersByTime(5_000) + + expect(agent.getMetrics().lastHeartbeat).toBe(stoppedHeartbeat) + }) + + it("restart() calls stop() then start()", async () => { + const agent = new Agent(createConfig("agent-restart")) + const calls: string[] = [] + const stopSpy = vi.spyOn(agent, "stop").mockImplementation(async () => { calls.push("stop") }) + const startSpy = vi.spyOn(agent, "start").mockImplementation(async () => { calls.push("start") }) + + await agent.restart() + + expect(stopSpy).toHaveBeenCalledTimes(1) + expect(startSpy).toHaveBeenCalledTimes(1) + expect(calls).toEqual(["stop", "start"]) + }) + + it("executeTask() sets status to working, calls the executor, and returns to running", async () => { + const agent = new Agent(createConfig("agent-task")) + await agent.start() + const task = createTask() + const handler = vi.fn(() => { + expect(agent.getStatus()).toBe("working") + return "executor completed" + }) + agent.onTask(handler) + + const result = await agent.executeTask(task) + + expect(handler).toHaveBeenCalledWith(expect.objectContaining({ id: task.id }), agent) + expect(result).toMatchObject({ taskId: task.id, agentId: agent.id, status: "completed", summary: "executor completed" }) + expect(agent.getStatus()).toBe("running") + }) + + it("executeTask() on a stopped agent throws an error", async () => { + const agent = new Agent(createConfig("agent-stopped-task")) + await agent.start() + await agent.stop() + + await expect(agent.executeTask(createTask())).rejects.toThrow("Cannot execute task on a stopped agent") + }) + + it("heartbeat updates lastSeen timestamp every interval", async () => { + const agent = new Agent(createConfig("agent-heartbeat", { heartbeatIntervalMs: 2_500 })) + await agent.start() + + expect(agent.getMetrics().lastHeartbeat).toBe("2026-06-30T00:00:00.000Z") + + jest.advanceTimersByTime(2_500) + expect(agent.getMetrics().lastHeartbeat).toBe("2026-06-30T00:00:02.500Z") + + jest.advanceTimersByTime(2_500) + expect(agent.getMetrics().lastHeartbeat).toBe("2026-06-30T00:00:05.000Z") + }) + + it("POST /api/agents/[id]/task returns 404 for unknown agent IDs", async () => { + const response = await POST( + new Request("http://localhost/api/agents/not-a-real-agent/task", { + method: "POST", + body: JSON.stringify(createTask("missing-agent-task")), + }), + { params: Promise.resolve({ id: "not-a-real-agent" }) }, + ) + + await expect(response.json()).resolves.toMatchObject({ ok: false, error: "agent_not_found" }) + expect(response.status).toBe(404) + }) +}) diff --git a/lib/agent-runtime/agent.ts b/lib/agent-runtime/agent.ts index cbb896a5..a7b94854 100644 --- a/lib/agent-runtime/agent.ts +++ b/lib/agent-runtime/agent.ts @@ -81,7 +81,7 @@ export class Agent implements AgentRuntimeContext { async start(): Promise { this.startedAtMs = this.startedAtMs ?? Date.now() this.stoppedAtMs = null - this.status = "active" + this.status = "running" this.recordHeartbeat() this.heartbeatTimer ??= setInterval(() => this.recordHeartbeat(), this.config.heartbeatIntervalMs ?? DEFAULT_HEARTBEAT_INTERVAL_MS) publishSystemEvent({ type: "agent.status", agentId: this.id, status: this.status }) @@ -91,7 +91,7 @@ export class Agent implements AgentRuntimeContext { if (this.heartbeatTimer) clearInterval(this.heartbeatTimer) this.heartbeatTimer = null this.stoppedAtMs = Date.now() - this.status = "offline" + this.status = "stopped" this.recordHeartbeat() publishSystemEvent({ type: "agent.status", agentId: this.id, status: this.status }) } @@ -110,7 +110,7 @@ export class Agent implements AgentRuntimeContext { } async executeTask(taskInput: Task): Promise { - if (this.status === "offline") await this.start() + if (this.status === "stopped" || this.status === "offline") throw new Error("Cannot execute task on a stopped agent") const task = normalizeTask(taskInput) const startedAt = isoNow() const startedMs = Date.now() @@ -136,7 +136,7 @@ export class Agent implements AgentRuntimeContext { } this.metrics.tasksCompleted += 1 this.taskDurations.push(durationMs) - this.status = "idle" + this.status = "running" this.recordHeartbeat() writeTaskRecord(this.id, { task, result, status: "completed", updatedAt: completedAt }) publishSystemEvent({ type: "task.completed", agentId: this.id, taskId: task.id, result: { summary: result.summary, durationMs } }) diff --git a/lib/agents/agent-health-store.ts b/lib/agents/agent-health-store.ts index 083dc6af..821fbaa4 100644 --- a/lib/agents/agent-health-store.ts +++ b/lib/agents/agent-health-store.ts @@ -7,7 +7,7 @@ export const HEARTBEAT_INTERVAL_MS = 15_000 export const OFFLINE_AFTER_MS = 30_000 export const ALERT_AFTER_MS = 5 * 60_000 -const VALID_AGENT_STATUSES: AgentStatus[] = ["active", "idle", "working", "error", "offline"] +const VALID_AGENT_STATUSES: AgentStatus[] = ["active", "idle", "running", "working", "error", "offline", "stopped"] export interface AgentHeartbeatInput { status?: unknown diff --git a/lib/types.ts b/lib/types.ts index 6c5e895a..2b7d90d7 100644 --- a/lib/types.ts +++ b/lib/types.ts @@ -1,4 +1,4 @@ -export type AgentStatus = "active" | "idle" | "working" | "error" | "offline" +export type AgentStatus = "active" | "idle" | "running" | "working" | "error" | "offline" | "stopped" export type DistrictId = "data-center" | "comm-hub" | "processing" | "defense" | "research" @@ -105,4 +105,4 @@ export interface AgentTask { createdAt: number startedAt?: number completedAt?: number -} \ No newline at end of file +}