Files
fusion/packages/core/src/agent-store.test.ts
gsxdsm 1c4a3f4e4d feat(FN-1452): make structured run records the source of truth for agent run state
- Update AGENTS.md with authoritative run lifecycle semantics documentation
- Add API-level regression test for repeated manual run prevention
- Update run lifecycle tests to match corrected behavior
- Refactor AgentStore to use structured run records as the authoritative source for run state
- Improve run status queries with better filtering and ordering
2026-04-09 18:37:48 -07:00

2390 lines
90 KiB
TypeScript

/**
* Tests for AgentStore — filesystem-based agent lifecycle management.
*
* Covers every public method: init, createAgent, getAgent, getAgentDetail,
* updateAgent, updateAgentState, assignTask, listAgents, deleteAgent,
* recordHeartbeat, getHeartbeatHistory, startHeartbeatRun, endHeartbeatRun,
* getActiveHeartbeatRun, getCompletedHeartbeatRuns.
*
* Also tests event emissions (agent:created, agent:updated, agent:deleted,
* agent:heartbeat, agent:stateChanged), error paths, state transition
* validation, concurrency locking, and filesystem persistence.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { AgentStore } from "./agent-store.js";
import { TaskStore } from "./store.js";
import { rm } from "node:fs/promises";
import { join } from "node:path";
import { mkdtempSync, existsSync, writeFileSync, readFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { createHash } from "node:crypto";
import { CheckoutConflictError, type AgentCapability, type AgentState } from "./types.js";
function makeTmpDir(): string {
return mkdtempSync(join(tmpdir(), "kb-agent-store-test-"));
}
describe("AgentStore", () => {
let rootDir: string;
let store: AgentStore;
beforeEach(async () => {
rootDir = makeTmpDir();
store = new AgentStore({ rootDir });
await store.init();
});
afterEach(async () => {
await rm(rootDir, { recursive: true, force: true });
});
// ── init ──────────────────────────────────────────────────────────
describe("init", () => {
it("creates the agents/ directory inside rootDir", async () => {
const agentsDir = join(rootDir, "agents");
expect(existsSync(agentsDir)).toBe(true);
});
it("is idempotent (calling twice doesn't error)", async () => {
await store.init();
await store.init();
const agentsDir = join(rootDir, "agents");
expect(existsSync(agentsDir)).toBe(true);
});
});
// ── createAgent ───────────────────────────────────────────────────
describe("createAgent", () => {
it("returns an agent with correct fields", async () => {
const agent = await store.createAgent({
name: " Test Agent ",
role: "executor",
});
expect(agent.id).toMatch(/^agent-/);
expect(agent.name).toBe("Test Agent"); // trimmed
expect(agent.role).toBe("executor");
expect(agent.state).toBe("idle");
expect(agent.metadata).toEqual({});
expect(new Date(agent.createdAt).getTime()).not.toBeNaN();
expect(new Date(agent.updatedAt).getTime()).not.toBeNaN();
});
it("preserves custom metadata", async () => {
const agent = await store.createAgent({
name: "With Meta",
role: "reviewer",
metadata: { version: 2, tags: ["test"] },
});
expect(agent.metadata).toEqual({ version: 2, tags: ["test"] });
});
it("persists soul and memory fields on create", async () => {
const agent = await store.createAgent({
name: "With Soul",
role: "executor",
soul: "Calm and precise.",
memory: "Prefers concise code examples.",
});
expect(agent.soul).toBe("Calm and precise.");
expect(agent.memory).toBe("Prefers concise code examples.");
const persisted = await store.getAgent(agent.id);
expect(persisted?.soul).toBe("Calm and precise.");
expect(persisted?.memory).toBe("Prefers concise code examples.");
});
it("throws when name is empty", async () => {
await expect(
store.createAgent({ name: "", role: "executor" })
).rejects.toThrow("Agent name is required");
});
it("throws when name is whitespace-only", async () => {
await expect(
store.createAgent({ name: " ", role: "executor" })
).rejects.toThrow("Agent name is required");
});
it("throws when role is missing", async () => {
await expect(
store.createAgent({ name: "No Role", role: "" as AgentCapability })
).rejects.toThrow("Agent role is required");
});
it("emits 'agent:created' event with the created agent", async () => {
const handler = vi.fn();
store.on("agent:created", handler);
const agent = await store.createAgent({
name: "Event Agent",
role: "triage",
});
expect(handler).toHaveBeenCalledOnce();
expect(handler).toHaveBeenCalledWith(agent);
});
});
// ── getAgent ──────────────────────────────────────────────────────
describe("getAgent", () => {
it("returns the agent after creation", async () => {
const created = await store.createAgent({
name: "Lookup Agent",
role: "executor",
});
const found = await store.getAgent(created.id);
expect(found).not.toBeNull();
expect(found!.id).toBe(created.id);
expect(found!.name).toBe("Lookup Agent");
expect(found!.role).toBe("executor");
expect(found!.state).toBe("idle");
});
it("returns null for a non-existent ID", async () => {
const result = await store.getAgent("agent-nonexistent");
expect(result).toBeNull();
});
});
// ── getAccessState ────────────────────────────────────────────────
describe("getAccessState", () => {
it("returns computed state for an executor agent", async () => {
const created = await store.createAgent({
name: "Executor",
role: "executor",
});
const state = await store.getAccessState(created.id);
expect(state).not.toBeNull();
expect(state?.agentId).toBe(created.id);
expect(state?.canExecuteTasks).toBe(true);
expect(state?.canAssignTasks).toBe(false);
expect(state?.taskAssignSource).toBe("denied");
});
it("returns null for non-existent agent", async () => {
const state = await store.getAccessState("agent-missing");
expect(state).toBeNull();
});
it("reflects explicit permissions when set", async () => {
const created = await store.createAgent({
name: "Explicit",
role: "executor",
permissions: { "tasks:assign": true },
});
const state = await store.getAccessState(created.id);
expect(state).not.toBeNull();
expect(state?.canAssignTasks).toBe(true);
expect(state?.taskAssignSource).toBe("explicit_grant");
expect(state?.explicitPermissions.has("tasks:assign")).toBe(true);
});
});
// ── Budget Management ─────────────────────────────────────────────
describe("Budget Management", () => {
describe("getBudgetStatus", () => {
it("throws if agent not found", async () => {
await expect(store.getBudgetStatus("nonexistent")).rejects.toThrow("not found");
});
it("returns no-limit status when agent has no budgetConfig", async () => {
const agent = await store.createAgent({
name: "No Budget Config",
role: "executor",
});
const status = await store.getBudgetStatus(agent.id);
expect(status.currentUsage).toBe(0);
expect(status.budgetLimit).toBeNull();
expect(status.usagePercent).toBeNull();
expect(status.thresholdPercent).toBeNull();
expect(status.isOverBudget).toBe(false);
expect(status.isOverThreshold).toBe(false);
expect(status.lastResetAt).toBeNull();
expect(status.nextResetAt).toBeNull();
});
it("returns no-limit status when budgetConfig has no tokenBudget", async () => {
const agent = await store.createAgent({
name: "Threshold Only",
role: "executor",
runtimeConfig: {
budgetConfig: {
usageThreshold: 0.9,
},
},
});
const status = await store.getBudgetStatus(agent.id);
expect(status.budgetLimit).toBeNull();
expect(status.usagePercent).toBeNull();
expect(status.thresholdPercent).toBeNull();
});
it("computes usage from totalInputTokens + totalOutputTokens", async () => {
const agent = await store.createAgent({
name: "Usage Counter",
role: "executor",
runtimeConfig: {
budgetConfig: {
tokenBudget: 20000,
},
},
});
await store.updateAgent(agent.id, {
totalInputTokens: 5000,
totalOutputTokens: 3000,
});
const status = await store.getBudgetStatus(agent.id);
expect(status.currentUsage).toBe(8000);
});
it("detects over-budget when usage >= tokenBudget", async () => {
const agent = await store.createAgent({
name: "Over Budget",
role: "executor",
runtimeConfig: {
budgetConfig: {
tokenBudget: 1000,
},
},
});
await store.updateAgent(agent.id, {
totalInputTokens: 800,
totalOutputTokens: 300,
});
const status = await store.getBudgetStatus(agent.id);
expect(status.isOverBudget).toBe(true);
});
it("detects over-threshold when usagePercent >= thresholdPercent", async () => {
const agent = await store.createAgent({
name: "Threshold Hit",
role: "executor",
runtimeConfig: {
budgetConfig: {
tokenBudget: 10000,
usageThreshold: 0.5,
},
},
});
await store.updateAgent(agent.id, {
totalInputTokens: 3000,
totalOutputTokens: 2500,
});
const status = await store.getBudgetStatus(agent.id);
expect(status.usagePercent).toBeCloseTo(55, 10);
expect(status.thresholdPercent).toBe(50);
expect(status.isOverThreshold).toBe(true);
});
it("is not over-threshold when below threshold", async () => {
const agent = await store.createAgent({
name: "Threshold Safe",
role: "executor",
runtimeConfig: {
budgetConfig: {
tokenBudget: 10000,
usageThreshold: 0.8,
},
},
});
await store.updateAgent(agent.id, {
totalInputTokens: 2500,
totalOutputTokens: 2500,
});
const status = await store.getBudgetStatus(agent.id);
expect(status.usagePercent).toBe(50);
expect(status.isOverThreshold).toBe(false);
});
it("clamps usagePercent to 100 when over budget", async () => {
const agent = await store.createAgent({
name: "Clamp Usage",
role: "executor",
runtimeConfig: {
budgetConfig: {
tokenBudget: 100,
},
},
});
await store.updateAgent(agent.id, {
totalInputTokens: 400,
totalOutputTokens: 100,
});
const status = await store.getBudgetStatus(agent.id);
expect(status.usagePercent).toBe(100);
});
it("returns lastResetAt from runtimeConfig.budgetResetAt", async () => {
const budgetResetAt = "2026-01-01T00:00:00.000Z";
const agent = await store.createAgent({
name: "Has Reset Timestamp",
role: "executor",
runtimeConfig: {
budgetResetAt,
},
});
const status = await store.getBudgetStatus(agent.id);
expect(status.lastResetAt).toBe(budgetResetAt);
});
it("returns null nextResetAt for lifetime budget period", async () => {
const agent = await store.createAgent({
name: "Lifetime Budget",
role: "executor",
runtimeConfig: {
budgetConfig: {
tokenBudget: 1000,
budgetPeriod: "lifetime",
},
},
});
const status = await store.getBudgetStatus(agent.id);
expect(status.nextResetAt).toBeNull();
});
it("computes nextResetAt for daily period as next midnight", async () => {
const agent = await store.createAgent({
name: "Daily Budget",
role: "executor",
runtimeConfig: {
budgetConfig: {
tokenBudget: 1000,
budgetPeriod: "daily",
},
},
});
const now = Date.now();
const status = await store.getBudgetStatus(agent.id);
expect(status.nextResetAt).not.toBeNull();
const nextResetAt = new Date(status.nextResetAt!);
expect(nextResetAt.getTime()).toBeGreaterThan(now);
expect(nextResetAt.getHours()).toBe(0);
expect(nextResetAt.getMinutes()).toBe(0);
expect(nextResetAt.getSeconds()).toBe(0);
expect(nextResetAt.getMilliseconds()).toBe(0);
});
it("computes nextResetAt for weekly period using resetDay", async () => {
const agent = await store.createAgent({
name: "Weekly Budget",
role: "executor",
runtimeConfig: {
budgetConfig: {
tokenBudget: 1000,
budgetPeriod: "weekly",
resetDay: 1,
},
},
});
const now = Date.now();
const status = await store.getBudgetStatus(agent.id);
expect(status.nextResetAt).not.toBeNull();
const nextResetAt = new Date(status.nextResetAt!);
expect(nextResetAt.getTime()).toBeGreaterThan(now);
expect(nextResetAt.getDay()).toBe(1);
expect(nextResetAt.getHours()).toBe(0);
expect(nextResetAt.getMinutes()).toBe(0);
expect(nextResetAt.getSeconds()).toBe(0);
expect(nextResetAt.getMilliseconds()).toBe(0);
expect(nextResetAt.getTime() - now).toBeLessThanOrEqual(8 * 24 * 60 * 60 * 1000);
});
it("clamps resetDay to month length for monthly period", async () => {
const agent = await store.createAgent({
name: "Monthly Budget",
role: "executor",
runtimeConfig: {
budgetConfig: {
tokenBudget: 1000,
budgetPeriod: "monthly",
resetDay: 31,
},
},
});
const now = Date.now();
const status = await store.getBudgetStatus(agent.id);
expect(status.nextResetAt).not.toBeNull();
const nextResetAt = new Date(status.nextResetAt!);
const lastDayOfMonth = new Date(nextResetAt.getFullYear(), nextResetAt.getMonth() + 1, 0).getDate();
expect(nextResetAt.getTime()).toBeGreaterThan(now);
expect(nextResetAt.getDate()).toBe(Math.min(31, lastDayOfMonth));
expect(nextResetAt.getHours()).toBe(0);
expect(nextResetAt.getMinutes()).toBe(0);
expect(nextResetAt.getSeconds()).toBe(0);
expect(nextResetAt.getMilliseconds()).toBe(0);
});
});
describe("resetBudgetUsage", () => {
it("throws if agent not found", async () => {
await expect(store.resetBudgetUsage("nonexistent")).rejects.toThrow("not found");
});
it("resets totalInputTokens and totalOutputTokens to 0", async () => {
const agent = await store.createAgent({
name: "Reset Usage",
role: "executor",
});
await store.updateAgent(agent.id, {
totalInputTokens: 1200,
totalOutputTokens: 800,
});
await store.resetBudgetUsage(agent.id);
const updated = await store.getAgent(agent.id);
expect(updated).not.toBeNull();
expect(updated?.totalInputTokens).toBe(0);
expect(updated?.totalOutputTokens).toBe(0);
});
it("sets budgetResetAt to current timestamp", async () => {
const agent = await store.createAgent({
name: "Reset Timestamp",
role: "executor",
});
const beforeReset = Date.now();
await store.resetBudgetUsage(agent.id);
const updated = await store.getAgent(agent.id);
const rawBudgetResetAt = (updated?.runtimeConfig as Record<string, unknown> | undefined)?.budgetResetAt;
expect(typeof rawBudgetResetAt).toBe("string");
const parsedResetAt = new Date(rawBudgetResetAt as string).getTime();
expect(parsedResetAt).toBeGreaterThanOrEqual(beforeReset - 5000);
expect(parsedResetAt).toBeGreaterThan(Date.now() - 5000);
});
it("preserves other runtimeConfig values", async () => {
const agent = await store.createAgent({
name: "Preserve Config",
role: "executor",
runtimeConfig: {
heartbeatIntervalMs: 30000,
budgetConfig: {
tokenBudget: 1000,
},
},
});
await store.resetBudgetUsage(agent.id);
const updated = await store.getAgent(agent.id);
const runtimeConfig = updated?.runtimeConfig as Record<string, unknown>;
expect(runtimeConfig.heartbeatIntervalMs).toBe(30000);
expect(runtimeConfig.budgetConfig).toEqual({ tokenBudget: 1000 });
expect(typeof runtimeConfig.budgetResetAt).toBe("string");
});
});
});
// ── updateAgent ───────────────────────────────────────────────────
describe("updateAgent", () => {
it("updates name, role, and metadata fields", async () => {
const created = await store.createAgent({
name: "Before",
role: "executor",
});
const updated = await store.updateAgent(created.id, {
name: "After",
role: "reviewer",
metadata: { key: "value" },
});
expect(updated.name).toBe("After");
expect(updated.role).toBe("reviewer");
expect(updated.metadata).toEqual({ key: "value" });
expect(new Date(updated.updatedAt).getTime()).toBeGreaterThanOrEqual(
new Date(created.updatedAt).getTime()
);
});
it("preserves fields not included in the update input", async () => {
const created = await store.createAgent({
name: "Original",
role: "executor",
metadata: { preserved: true },
});
const updated = await store.updateAgent(created.id, {
name: "Changed Name",
});
expect(updated.name).toBe("Changed Name");
expect(updated.role).toBe("executor"); // preserved
expect(updated.metadata).toEqual({ preserved: true }); // preserved
});
it("updates soul and memory fields", async () => {
const created = await store.createAgent({
name: "Knowledge Agent",
role: "executor",
});
const updated = await store.updateAgent(created.id, {
soul: "Collaborative, practical mentor",
memory: "Avoids broad rewrites; prefers incremental changes.",
});
expect(updated.soul).toBe("Collaborative, practical mentor");
expect(updated.memory).toBe("Avoids broad rewrites; prefers incremental changes.");
const persisted = await store.getAgent(created.id);
expect(persisted?.soul).toBe("Collaborative, practical mentor");
expect(persisted?.memory).toBe("Avoids broad rewrites; prefers incremental changes.");
});
it("does not clear soul when updates.soul is undefined", async () => {
const created = await store.createAgent({
name: "Stable Soul",
role: "executor",
soul: "Patient reviewer",
});
const updated = await store.updateAgent(created.id, {
soul: undefined,
memory: "Remembers coding preferences",
});
expect(updated.soul).toBe("Patient reviewer");
expect(updated.memory).toBe("Remembers coding preferences");
});
it("allows clearing optional fields via explicit undefined", async () => {
const created = await store.createAgent({
name: "Clearable",
role: "executor",
title: "Worker",
instructionsText: "Initial instructions",
});
const withTransientState = await store.updateAgent(created.id, {
pauseReason: "manual",
lastError: "oops",
});
expect(withTransientState.pauseReason).toBe("manual");
expect(withTransientState.lastError).toBe("oops");
const cleared = await store.updateAgent(created.id, {
title: undefined,
instructionsText: undefined,
pauseReason: undefined,
lastError: undefined,
});
expect(cleared.title).toBeUndefined();
expect(cleared.instructionsText).toBeUndefined();
expect(cleared.pauseReason).toBeUndefined();
expect(cleared.lastError).toBeUndefined();
});
it("rejects whitespace-only names", async () => {
const created = await store.createAgent({
name: "Rename Me",
role: "executor",
});
await expect(store.updateAgent(created.id, { name: " " })).rejects.toThrow("Agent name cannot be empty");
});
it("throws for non-existent agent ID", async () => {
await expect(
store.updateAgent("agent-missing", { name: "Nope" })
).rejects.toThrow("Agent agent-missing not found");
});
it("emits 'agent:updated' event", async () => {
const created = await store.createAgent({
name: "Update Event",
role: "executor",
});
const handler = vi.fn();
store.on("agent:updated", handler);
const updated = await store.updateAgent(created.id, { name: "New Name" });
expect(handler).toHaveBeenCalledWith(updated);
});
});
// ── config revisions ───────────────────────────────────────────────
describe("config revisions", () => {
it("records revision when name changes", async () => {
const created = await store.createAgent({ name: "Original", role: "executor" });
await store.updateAgent(created.id, { name: "Renamed" });
const revisions = await store.getConfigRevisions(created.id);
expect(revisions).toHaveLength(1);
expect(revisions[0].source).toBe("user");
expect(revisions[0].diffs).toEqual(
expect.arrayContaining([
expect.objectContaining({ field: "name", oldValue: "Original", newValue: "Renamed" }),
]),
);
expect(revisions[0].summary).toContain("name");
expect(revisions[0].before.name).toBe("Original");
expect(revisions[0].after.name).toBe("Renamed");
});
it("records revisions for runtimeConfig, permissions, instructions, soul, and memory changes", async () => {
const created = await store.createAgent({
name: "Configurable",
role: "executor",
runtimeConfig: { heartbeatIntervalMs: 30000 },
permissions: { canReview: false },
});
await store.updateAgent(created.id, { runtimeConfig: { heartbeatIntervalMs: 10000 } });
await store.updateAgent(created.id, { permissions: { canReview: true, canExecute: true } });
await store.updateAgent(created.id, { instructionsPath: "docs/agent.md" });
await store.updateAgent(created.id, { instructionsText: "Follow safety checks." });
await store.updateAgent(created.id, { soul: "Thoughtful collaborator" });
await store.updateAgent(created.id, { memory: "Knows the repository architecture" });
const revisions = await store.getConfigRevisions(created.id);
const changedFields = revisions.flatMap((revision) => revision.diffs.map((diff) => diff.field));
expect(changedFields).toContain("runtimeConfig");
expect(changedFields).toContain("permissions");
expect(changedFields).toContain("instructionsPath");
expect(changedFields).toContain("instructionsText");
expect(changedFields).toContain("soul");
expect(changedFields).toContain("memory");
});
it("does not create a revision when only non-config fields change", async () => {
const created = await store.createAgent({ name: "No Diff", role: "executor" });
await store.updateAgent(created.id, {
totalInputTokens: 120,
totalOutputTokens: 80,
pauseReason: "manual",
lastError: "temporary",
});
const revisions = await store.getConfigRevisions(created.id);
expect(revisions).toEqual([]);
});
it("returns revisions in reverse chronological order and respects limit", async () => {
const created = await store.createAgent({ name: "Chrono", role: "executor" });
await store.updateAgent(created.id, { name: "Chrono-1" });
await store.updateAgent(created.id, { name: "Chrono-2" });
await store.updateAgent(created.id, { name: "Chrono-3" });
const revisions = await store.getConfigRevisions(created.id);
expect(revisions).toHaveLength(3);
expect(revisions[0].after.name).toBe("Chrono-3");
expect(revisions[1].after.name).toBe("Chrono-2");
expect(revisions[2].after.name).toBe("Chrono-1");
const limited = await store.getConfigRevisions(created.id, 2);
expect(limited).toHaveLength(2);
expect(limited.map((revision) => revision.after.name)).toEqual(["Chrono-3", "Chrono-2"]);
});
it("getConfigRevisions returns empty for agents with no revisions and non-existent agents", async () => {
const created = await store.createAgent({ name: "No Revisions", role: "executor" });
expect(await store.getConfigRevisions(created.id)).toEqual([]);
expect(await store.getConfigRevisions("agent-missing")).toEqual([]);
});
it("getConfigRevision returns matching revision and null when missing", async () => {
const created = await store.createAgent({ name: "Find Revision", role: "executor" });
await store.updateAgent(created.id, { name: "Find Revision v2" });
const [revision] = await store.getConfigRevisions(created.id);
const found = await store.getConfigRevision(created.id, revision.id);
expect(found).not.toBeNull();
expect(found!.id).toBe(revision.id);
expect(await store.getConfigRevision(created.id, "revision-missing")).toBeNull();
expect(await store.getConfigRevision("agent-missing", revision.id)).toBeNull();
});
it("rollbackConfig restores previous config and records rollback revision", async () => {
const created = await store.createAgent({
name: "Rollback Me",
role: "executor",
runtimeConfig: { heartbeatTimeoutMs: 60000 },
});
await store.updateAgent(created.id, {
name: "Rollback Me v2",
runtimeConfig: { heartbeatTimeoutMs: 90000 },
});
const [targetRevision] = await store.getConfigRevisions(created.id);
const result = await store.rollbackConfig(created.id, targetRevision.id);
expect(result.agent.name).toBe("Rollback Me");
expect(result.agent.runtimeConfig).toEqual({ heartbeatTimeoutMs: 60000 });
expect(result.revision.source).toBe("rollback");
expect(result.revision.rollbackToRevisionId).toBe(targetRevision.id);
const revisions = await store.getConfigRevisions(created.id);
expect(revisions[0].id).toBe(result.revision.id);
expect(revisions[0].source).toBe("rollback");
});
it("rollbackConfig supports chained rollbacks", async () => {
const created = await store.createAgent({ name: "Version 1", role: "executor" });
await store.updateAgent(created.id, { name: "Version 2" });
await store.updateAgent(created.id, { name: "Version 3" });
const revisions = await store.getConfigRevisions(created.id);
const revToV2 = revisions.find((revision) => revision.after.name === "Version 3");
const revToV1 = revisions.find((revision) => revision.after.name === "Version 2");
expect(revToV2).toBeDefined();
expect(revToV1).toBeDefined();
await store.rollbackConfig(created.id, revToV2!.id);
const afterFirstRollback = await store.getAgent(created.id);
expect(afterFirstRollback!.name).toBe("Version 2");
await store.rollbackConfig(created.id, revToV1!.id);
const afterSecondRollback = await store.getAgent(created.id);
expect(afterSecondRollback!.name).toBe("Version 1");
});
it("rollbackConfig throws for missing revision", async () => {
const created = await store.createAgent({ name: "Rollback Missing", role: "executor" });
await expect(store.rollbackConfig(created.id, "revision-missing")).rejects.toThrow(
`Config revision revision-missing not found for agent ${created.id}`,
);
});
it("rollbackConfig throws when revision belongs to a different agent", async () => {
const agentA = await store.createAgent({ name: "Agent A", role: "executor" });
const agentB = await store.createAgent({ name: "Agent B", role: "reviewer" });
await store.updateAgent(agentA.id, { name: "Agent A v2" });
const [revisionA] = await store.getConfigRevisions(agentA.id);
await expect(store.rollbackConfig(agentB.id, revisionA.id)).rejects.toThrow(
`Config revision ${revisionA.id} belongs to agent ${agentA.id}`,
);
});
it("emits agent:configRevision on config updates and rollback, but not updateAgentState", async () => {
const created = await store.createAgent({ name: "Events", role: "executor" });
const handler = vi.fn();
store.on("agent:configRevision", handler);
await store.updateAgent(created.id, { name: "Events v2" });
expect(handler).toHaveBeenCalledTimes(1);
expect(handler).toHaveBeenLastCalledWith(
created.id,
expect.objectContaining({ agentId: created.id, source: "user" }),
);
await store.updateAgentState(created.id, "idle");
expect(handler).toHaveBeenCalledTimes(1);
const [firstRevision] = await store.getConfigRevisions(created.id);
await store.rollbackConfig(created.id, firstRevision.id);
expect(handler).toHaveBeenCalledTimes(2);
expect(handler).toHaveBeenLastCalledWith(
created.id,
expect.objectContaining({ source: "rollback", rollbackToRevisionId: firstRevision.id }),
);
});
it("persists revisions to an append-only JSONL file without affecting heartbeat files", async () => {
const created = await store.createAgent({ name: "Persisted", role: "executor" });
await store.updateAgent(created.id, { name: "Persisted v2" });
await store.updateAgent(created.id, { name: "Persisted v3" });
await store.recordHeartbeat(created.id, "ok");
const revisionsPath = join(rootDir, "agents", `${created.id}-revisions.jsonl`);
const heartbeatsPath = join(rootDir, "agents", `${created.id}-heartbeats.jsonl`);
expect(existsSync(revisionsPath)).toBe(true);
expect(existsSync(heartbeatsPath)).toBe(true);
const revisionLines = readFileSync(revisionsPath, "utf-8").trim().split("\n").filter(Boolean);
expect(revisionLines).toHaveLength(2);
const parsedRevisions = revisionLines.map((line) => JSON.parse(line) as { agentId: string });
expect(parsedRevisions.every((line) => line.agentId === created.id)).toBe(true);
const heartbeatLines = readFileSync(heartbeatsPath, "utf-8").trim().split("\n").filter(Boolean);
expect(heartbeatLines.length).toBeGreaterThan(0);
const parsedHeartbeat = JSON.parse(heartbeatLines[0]) as { status: string };
expect(parsedHeartbeat.status).toBe("ok");
});
});
// ── deleteAgent ───────────────────────────────────────────────────
describe("deleteAgent", () => {
it("removes the agent so getAgent returns null", async () => {
const created = await store.createAgent({
name: "To Delete",
role: "executor",
});
await store.deleteAgent(created.id);
const found = await store.getAgent(created.id);
expect(found).toBeNull();
});
it("also removes heartbeat file if present", async () => {
const created = await store.createAgent({
name: "With HB",
role: "executor",
});
// Record a heartbeat to create the file
await store.recordHeartbeat(created.id, "ok");
const hbPath = join(rootDir, "agents", `${created.id}-heartbeats.jsonl`);
expect(existsSync(hbPath)).toBe(true);
await store.deleteAgent(created.id);
expect(existsSync(hbPath)).toBe(false);
});
it("throws for non-existent agent ID", async () => {
await expect(store.deleteAgent("agent-missing")).rejects.toThrow(
"Agent agent-missing not found"
);
});
it("emits 'agent:deleted' event with the agent ID", async () => {
const created = await store.createAgent({
name: "Delete Event",
role: "executor",
});
const handler = vi.fn();
store.on("agent:deleted", handler);
await store.deleteAgent(created.id);
expect(handler).toHaveBeenCalledOnce();
expect(handler).toHaveBeenCalledWith(created.id);
});
});
// ── listAgents ────────────────────────────────────────────────────
describe("listAgents", () => {
it("returns empty array when no agents exist", async () => {
const agents = await store.listAgents();
expect(agents).toEqual([]);
});
it("returns all created agents sorted by createdAt descending", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const a1 = await store.createAgent({ name: "First", role: "executor" });
vi.setSystemTime(new Date("2026-01-02T00:00:00Z"));
const a2 = await store.createAgent({ name: "Second", role: "reviewer" });
vi.setSystemTime(new Date("2026-01-03T00:00:00Z"));
const a3 = await store.createAgent({ name: "Third", role: "triage" });
const agents = await store.listAgents();
expect(agents).toHaveLength(3);
// Newest first
expect(agents[0].id).toBe(a3.id);
expect(agents[1].id).toBe(a2.id);
expect(agents[2].id).toBe(a1.id);
} finally {
vi.useRealTimers();
}
});
it("filters by state", async () => {
const a1 = await store.createAgent({ name: "Idle", role: "executor" });
const a2 = await store.createAgent({ name: "Active", role: "executor" });
// Record a heartbeat first so that updateAgentState(→active) doesn't
// trigger startHeartbeatRun internally (which would re-enter withLock).
await store.recordHeartbeat(a2.id, "ok");
await store.updateAgentState(a2.id, "active");
const idle = await store.listAgents({ state: "idle" });
expect(idle).toHaveLength(1);
expect(idle[0].id).toBe(a1.id);
const active = await store.listAgents({ state: "active" });
expect(active).toHaveLength(1);
expect(active[0].id).toBe(a2.id);
});
it("filters by role", async () => {
await store.createAgent({ name: "Exec", role: "executor" });
await store.createAgent({ name: "Review", role: "reviewer" });
const executors = await store.listAgents({ role: "executor" });
expect(executors).toHaveLength(1);
expect(executors[0].name).toBe("Exec");
});
it("filters by both state and role", async () => {
const a1 = await store.createAgent({ name: "ActiveExec", role: "executor" });
await store.recordHeartbeat(a1.id, "ok");
await store.updateAgentState(a1.id, "active");
await store.createAgent({ name: "IdleExec", role: "executor" });
const a3 = await store.createAgent({ name: "ActiveReview", role: "reviewer" });
await store.recordHeartbeat(a3.id, "ok");
await store.updateAgentState(a3.id, "active");
const result = await store.listAgents({ state: "active", role: "executor" });
expect(result).toHaveLength(1);
expect(result[0].name).toBe("ActiveExec");
});
it("skips corrupted JSON files without throwing", async () => {
await store.createAgent({ name: "Valid", role: "executor" });
// Write a corrupted file
const corruptPath = join(rootDir, "agents", "agent-corrupt.json");
writeFileSync(corruptPath, "not-valid-json{{{");
const agents = await store.listAgents();
expect(agents).toHaveLength(1);
expect(agents[0].name).toBe("Valid");
});
});
// ── Org Hierarchy ────────────────────────────────────────────────
describe("getChainOfCommand", () => {
it("returns empty array for nonexistent agent", async () => {
const chain = await store.getChainOfCommand("agent-missing");
expect(chain).toEqual([]);
});
it("returns only self when agent has no manager", async () => {
const solo = await store.createAgent({ name: "Solo", role: "executor" });
const chain = await store.getChainOfCommand(solo.id);
expect(chain.map((agent) => agent.id)).toEqual([solo.id]);
});
it("returns self → manager → grand-manager", async () => {
const grandManager = await store.createAgent({ name: "Grand", role: "executor" });
const manager = await store.createAgent({
name: "Manager",
role: "executor",
reportsTo: grandManager.id,
});
const agent = await store.createAgent({
name: "Worker",
role: "executor",
reportsTo: manager.id,
});
const chain = await store.getChainOfCommand(agent.id);
expect(chain.map((item) => item.id)).toEqual([agent.id, manager.id, grandManager.id]);
});
it("stops traversal when a cycle is detected", async () => {
const a = await store.createAgent({ name: "Cycle A", role: "executor" });
const b = await store.createAgent({
name: "Cycle B",
role: "executor",
reportsTo: a.id,
});
await store.updateAgent(a.id, { reportsTo: b.id });
const chain = await store.getChainOfCommand(a.id);
expect(chain.map((agent) => agent.id)).toEqual([a.id, b.id]);
expect(chain.length).toBeLessThanOrEqual(20);
});
});
describe("getOrgTree", () => {
it("returns empty array when no agents exist", async () => {
const tree = await store.getOrgTree();
expect(tree).toEqual([]);
});
it("returns all agents as roots when no one has reportsTo", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const first = await store.createAgent({ name: "First", role: "executor" });
vi.setSystemTime(new Date("2026-01-02T00:00:00Z"));
const second = await store.createAgent({ name: "Second", role: "executor" });
const tree = await store.getOrgTree();
expect(tree).toHaveLength(2);
expect(tree.map((node) => node.agent.id)).toEqual([first.id, second.id]);
expect(tree[0].children).toEqual([]);
expect(tree[1].children).toEqual([]);
} finally {
vi.useRealTimers();
}
});
it("builds a nested hierarchy and sorts children by createdAt ascending", async () => {
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-02-01T00:00:00Z"));
const root = await store.createAgent({ name: "Root", role: "executor" });
vi.setSystemTime(new Date("2026-02-02T00:00:00Z"));
const childOlder = await store.createAgent({
name: "Child Older",
role: "executor",
reportsTo: root.id,
});
vi.setSystemTime(new Date("2026-02-03T00:00:00Z"));
const childYounger = await store.createAgent({
name: "Child Younger",
role: "executor",
reportsTo: root.id,
});
vi.setSystemTime(new Date("2026-02-04T00:00:00Z"));
const grandChild = await store.createAgent({
name: "Grand Child",
role: "executor",
reportsTo: childOlder.id,
});
const tree = await store.getOrgTree();
expect(tree).toHaveLength(1);
expect(tree[0].agent.id).toBe(root.id);
expect(tree[0].children.map((node) => node.agent.id)).toEqual([
childOlder.id,
childYounger.id,
]);
expect(tree[0].children[0].children.map((node) => node.agent.id)).toEqual([
grandChild.id,
]);
} finally {
vi.useRealTimers();
}
});
it("treats agents with missing managers as root nodes", async () => {
const root = await store.createAgent({ name: "Root", role: "executor" });
const orphan = await store.createAgent({
name: "Orphan",
role: "executor",
reportsTo: "agent-nonexistent",
});
const tree = await store.getOrgTree();
expect(tree.map((node) => node.agent.id).sort()).toEqual([root.id, orphan.id].sort());
});
});
describe("resolveAgent", () => {
it("resolves by exact agent ID", async () => {
const created = await store.createAgent({ name: "ID Match", role: "executor" });
const resolved = await store.resolveAgent(created.id);
expect(resolved?.id).toBe(created.id);
});
it("resolves by normalized name", async () => {
const created = await store.createAgent({ name: "My Agent", role: "executor" });
const resolved = await store.resolveAgent("my-agent");
expect(resolved?.id).toBe(created.id);
});
it("returns null when multiple agents share the same normalized shortname", async () => {
await store.createAgent({ name: "My Agent", role: "executor" });
await store.createAgent({ name: "my-agent", role: "reviewer" });
const resolved = await store.resolveAgent("my-agent");
expect(resolved).toBeNull();
});
it("returns null for unknown shortnames", async () => {
await store.createAgent({ name: "Known Agent", role: "executor" });
const resolved = await store.resolveAgent("not-found");
expect(resolved).toBeNull();
});
it("matches shortnames case-insensitively", async () => {
const created = await store.createAgent({ name: "My Agent", role: "executor" });
const resolved = await store.resolveAgent("MY-AGENT");
expect(resolved?.id).toBe(created.id);
});
it("normalizes special characters in names", async () => {
const created = await store.createAgent({ name: "Test Agent v2!", role: "executor" });
const resolved = await store.resolveAgent("test-agent-v2");
expect(resolved?.id).toBe(created.id);
});
});
// ── updateAgentState ──────────────────────────────────────────────
describe("updateAgentState", () => {
// Helper: create an agent and set lastHeartbeatAt so that
// idle→active transitions don't trigger the re-entrant
// startHeartbeatRun path (see FN-711 for the deadlock bug).
// Also records a "missed" heartbeat to close any active run,
// preventing the terminated-transition deadlock path too.
async function createReadyAgent(s: AgentStore, name: string) {
const agent = await s.createAgent({ name, role: "executor" });
await s.recordHeartbeat(agent.id, "ok");
// Close the active run so transitioning to terminated
// won't trigger endHeartbeatRun inside withLock.
await s.recordHeartbeat(agent.id, "missed");
return agent;
}
it("idle → active transition succeeds", async () => {
const agent = await createReadyAgent(store, "IdleToActive");
const updated = await store.updateAgentState(agent.id, "active");
expect(updated.state).toBe("active");
});
it("active → paused transition succeeds", async () => {
const agent = await createReadyAgent(store, "ActiveToPaused");
await store.updateAgentState(agent.id, "active");
const updated = await store.updateAgentState(agent.id, "paused");
expect(updated.state).toBe("paused");
});
it("active → terminated transition succeeds", async () => {
const agent = await createReadyAgent(store, "ActiveToTerminated");
await store.updateAgentState(agent.id, "active");
const updated = await store.updateAgentState(agent.id, "terminated");
expect(updated.state).toBe("terminated");
});
it("paused → active transition succeeds", async () => {
const agent = await createReadyAgent(store, "PausedToActive");
await store.updateAgentState(agent.id, "active");
await store.updateAgentState(agent.id, "paused");
const updated = await store.updateAgentState(agent.id, "active");
expect(updated.state).toBe("active");
});
it("paused → terminated transition succeeds", async () => {
const agent = await createReadyAgent(store, "PausedToTerminated");
await store.updateAgentState(agent.id, "active");
await store.updateAgentState(agent.id, "paused");
const updated = await store.updateAgentState(agent.id, "terminated");
expect(updated.state).toBe("terminated");
});
it("same-state transition returns agent unchanged (no-op)", async () => {
const agent = await store.createAgent({ name: "SameState", role: "executor" });
const unchanged = await store.updateAgentState(agent.id, "idle");
expect(unchanged.state).toBe("idle");
expect(unchanged.updatedAt).toBe(agent.updatedAt);
});
it("idle → paused throws with descriptive error message", async () => {
const agent = await store.createAgent({ name: "BadTransition", role: "executor" });
await expect(
store.updateAgentState(agent.id, "paused")
).rejects.toThrow("Invalid state transition: idle -> paused");
});
it("idle → terminated throws", async () => {
const agent = await store.createAgent({ name: "BadTerminate", role: "executor" });
await expect(
store.updateAgentState(agent.id, "terminated")
).rejects.toThrow("Invalid state transition: idle -> terminated");
});
it("transition from terminated to paused still throws", async () => {
const agent = await createReadyAgent(store, "Terminated");
await store.updateAgentState(agent.id, "active");
await store.updateAgentState(agent.id, "terminated");
await expect(
store.updateAgentState(agent.id, "paused")
).rejects.toThrow("Invalid state transition: terminated -> paused");
});
it("terminated → active transition succeeds", async () => {
const agent = await createReadyAgent(store, "RestartActive");
await store.updateAgentState(agent.id, "active");
await store.updateAgentState(agent.id, "terminated");
const updated = await store.updateAgentState(agent.id, "active");
expect(updated.state).toBe("active");
});
it("terminated → running transition succeeds", async () => {
const agent = await createReadyAgent(store, "RestartRunning");
await store.updateAgentState(agent.id, "active");
await store.updateAgentState(agent.id, "terminated");
const updated = await store.updateAgentState(agent.id, "running");
expect(updated.state).toBe("running");
});
it("terminated → idle transition succeeds", async () => {
const agent = await createReadyAgent(store, "RestartIdle");
await store.updateAgentState(agent.id, "active");
await store.updateAgentState(agent.id, "terminated");
const updated = await store.updateAgentState(agent.id, "idle");
expect(updated.state).toBe("idle");
});
it("transitioning from terminated clears lastError", async () => {
const agent = await createReadyAgent(store, "ClearError");
await store.updateAgentState(agent.id, "active");
await store.updateAgent(agent.id, { lastError: "something broke" });
await store.updateAgentState(agent.id, "terminated");
const restarted = await store.updateAgentState(agent.id, "active");
expect(restarted.state).toBe("active");
expect(restarted.lastError).toBeUndefined();
});
it("emits both 'agent:stateChanged' and 'agent:updated' events", async () => {
const agent = await createReadyAgent(store, "StateEvents");
const stateHandler = vi.fn();
const updateHandler = vi.fn();
store.on("agent:stateChanged", stateHandler);
store.on("agent:updated", updateHandler);
await store.updateAgentState(agent.id, "active");
expect(stateHandler).toHaveBeenCalledOnce();
expect(stateHandler).toHaveBeenCalledWith(agent.id, "idle", "active");
// agent:updated is called with updated agent and previousState
expect(updateHandler).toHaveBeenCalled();
const [updatedAgent, previousState] = updateHandler.mock.calls[0];
expect(updatedAgent.state).toBe("active");
expect(previousState).toBe("idle");
});
it("throws for non-existent agent", async () => {
await expect(
store.updateAgentState("agent-nope", "active")
).rejects.toThrow("Agent agent-nope not found");
});
});
// ── assignTask ────────────────────────────────────────────────────
describe("assignTask", () => {
it("sets taskId on the agent", async () => {
const agent = await store.createAgent({ name: "Assignee", role: "executor" });
const updated = await store.assignTask(agent.id, "KB-001");
expect(updated.taskId).toBe("KB-001");
const fetched = await store.getAgent(agent.id);
expect(fetched!.taskId).toBe("KB-001");
});
it("clears taskId with undefined", async () => {
const agent = await store.createAgent({ name: "Unassign", role: "executor" });
await store.assignTask(agent.id, "KB-001");
const updated = await store.assignTask(agent.id, undefined);
expect(updated.taskId).toBeUndefined();
});
it("emits 'agent:updated' event", async () => {
const agent = await store.createAgent({ name: "AssignEvent", role: "executor" });
const handler = vi.fn();
store.on("agent:updated", handler);
await store.assignTask(agent.id, "KB-002");
expect(handler).toHaveBeenCalledOnce();
const [updatedAgent] = handler.mock.calls[0];
expect(updatedAgent.taskId).toBe("KB-002");
});
it("emits 'agent:assigned' event when assigning a task", async () => {
const agent = await store.createAgent({ name: "AssignEvent", role: "executor" });
const handler = vi.fn();
store.on("agent:assigned", handler);
await store.assignTask(agent.id, "KB-003");
expect(handler).toHaveBeenCalledOnce();
const [updatedAgent, taskId] = handler.mock.calls[0];
expect(updatedAgent.id).toBe(agent.id);
expect(updatedAgent.taskId).toBe("KB-003");
expect(taskId).toBe("KB-003");
});
it("does NOT emit 'agent:assigned' when clearing taskId", async () => {
const agent = await store.createAgent({ name: "UnassignEvent", role: "executor" });
await store.assignTask(agent.id, "KB-004");
const handler = vi.fn();
store.on("agent:assigned", handler);
await store.assignTask(agent.id, undefined);
expect(handler).not.toHaveBeenCalled();
});
it("throws for non-existent agent", async () => {
await expect(
store.assignTask("agent-missing", "KB-001")
).rejects.toThrow("Agent agent-missing not found");
});
});
describe("checkout leasing", () => {
let taskStore: TaskStore;
let holderId: string;
let otherAgentId: string;
let taskId: string;
beforeEach(async () => {
taskStore = new TaskStore(rootDir);
await taskStore.init();
store = new AgentStore({ rootDir, taskStore });
await store.init();
const holder = await store.createAgent({ name: "Checkout Holder", role: "executor" });
const other = await store.createAgent({ name: "Checkout Other", role: "executor" });
const task = await taskStore.createTask({ description: "Task for checkout leasing tests" });
holderId = holder.id;
otherAgentId = other.id;
taskId = task.id;
});
it("checkoutTask acquires a lease and stamps checkedOutAt", async () => {
const updated = await store.checkoutTask(holderId, taskId);
expect(updated.checkedOutBy).toBe(holderId);
expect(updated.checkedOutAt).toBeDefined();
const persisted = await taskStore.getTask(taskId);
expect(persisted?.checkedOutBy).toBe(holderId);
expect(persisted?.checkedOutAt).toBeDefined();
});
it("checkoutTask is idempotent when the same agent re-checks out", async () => {
const first = await store.checkoutTask(holderId, taskId);
const second = await store.checkoutTask(holderId, taskId);
expect(second.checkedOutBy).toBe(holderId);
expect(second.checkedOutAt).toBe(first.checkedOutAt);
});
it("checkoutTask throws CheckoutConflictError when already held by another agent", async () => {
await store.checkoutTask(holderId, taskId);
try {
await store.checkoutTask(otherAgentId, taskId);
throw new Error("Expected checkout conflict");
} catch (error) {
expect(error).toBeInstanceOf(CheckoutConflictError);
const conflict = error as CheckoutConflictError;
expect(conflict.taskId).toBe(taskId);
expect(conflict.currentHolderId).toBe(holderId);
expect(conflict.requestedById).toBe(otherAgentId);
}
});
it("checkoutTask throws when agent is missing", async () => {
await expect(store.checkoutTask("agent-missing", taskId)).rejects.toThrow("Agent agent-missing not found");
});
it("checkoutTask throws when task is missing", async () => {
await expect(store.checkoutTask(holderId, "FN-404")).rejects.toThrow("Task FN-404 not found");
});
it("releaseTask clears checkedOutBy and checkedOutAt for the holder", async () => {
await store.checkoutTask(holderId, taskId);
const released = await store.releaseTask(holderId, taskId);
expect(released.checkedOutBy).toBeUndefined();
expect(released.checkedOutAt).toBeUndefined();
const persisted = await taskStore.getTask(taskId);
expect(persisted?.checkedOutBy).toBeUndefined();
expect(persisted?.checkedOutAt).toBeUndefined();
});
it("releaseTask throws for a non-holder agent", async () => {
await store.checkoutTask(holderId, taskId);
await expect(store.releaseTask(otherAgentId, taskId)).rejects.toThrow("Cannot release: not the checkout holder");
});
it("releaseTask is idempotent when task is already released", async () => {
const released = await store.releaseTask(holderId, taskId);
expect(released.checkedOutBy).toBeUndefined();
expect(released.checkedOutAt).toBeUndefined();
});
it("forceReleaseTask clears checkout regardless of holder", async () => {
await store.checkoutTask(holderId, taskId);
const released = await store.forceReleaseTask(taskId);
expect(released.checkedOutBy).toBeUndefined();
expect(released.checkedOutAt).toBeUndefined();
});
it("getCheckedOutBy returns holder ID when checked out and undefined otherwise", async () => {
expect(await store.getCheckedOutBy(taskId)).toBeUndefined();
await store.checkoutTask(holderId, taskId);
expect(await store.getCheckedOutBy(taskId)).toBe(holderId);
});
});
// ── resetAgent ────────────────────────────────────────────────────
describe("resetAgent", () => {
// Helper: create an agent and transition it to terminated with error/task
async function createTerminatedAgent(s: AgentStore, name: string) {
const agent = await s.createAgent({ name, role: "executor" });
await s.recordHeartbeat(agent.id, "ok");
await s.recordHeartbeat(agent.id, "missed");
await s.updateAgentState(agent.id, "active");
await s.assignTask(agent.id, "KB-999");
await s.updateAgent(agent.id, {
pauseReason: "manual",
lastError: "something broke",
});
await s.updateAgentState(agent.id, "terminated");
return agent;
}
it("transitions terminated agent to idle", async () => {
const agent = await createTerminatedAgent(store, "ResetToIdle");
const reset = await store.resetAgent(agent.id);
expect(reset.state).toBe("idle");
});
it("can reset directly from running", async () => {
const agent = await store.createAgent({ name: "RunningReset", role: "executor" });
await store.recordHeartbeat(agent.id, "ok");
await store.updateAgentState(agent.id, "active");
await store.updateAgentState(agent.id, "running");
await store.assignTask(agent.id, "KB-123");
await store.updateAgent(agent.id, {
pauseReason: "stalled",
lastError: "runner failed",
});
const reset = await store.resetAgent(agent.id);
expect(reset.state).toBe("idle");
expect(reset.taskId).toBeUndefined();
expect(reset.pauseReason).toBeUndefined();
expect(reset.lastError).toBeUndefined();
});
it("clears lastError", async () => {
const agent = await createTerminatedAgent(store, "ResetClearsError");
const reset = await store.resetAgent(agent.id);
expect(reset.lastError).toBeUndefined();
});
it("clears pauseReason", async () => {
const agent = await createTerminatedAgent(store, "ResetClearsPause");
const reset = await store.resetAgent(agent.id);
expect(reset.pauseReason).toBeUndefined();
});
it("clears taskId", async () => {
const agent = await createTerminatedAgent(store, "ResetClearsTask");
const reset = await store.resetAgent(agent.id);
expect(reset.taskId).toBeUndefined();
});
it("starts fresh heartbeat tracking on subsequent active transition", async () => {
const agent = await createTerminatedAgent(store, "ResetHeartbeat");
await store.resetAgent(agent.id);
// After reset, explicitly start a heartbeat run (as the caller would)
const run = await store.startHeartbeatRun(agent.id);
const activeRun = await store.getActiveHeartbeatRun(agent.id);
expect(activeRun).not.toBeNull();
expect(activeRun!.id).toBe(run.id);
});
it("throws for non-existent agent", async () => {
await expect(
store.resetAgent("agent-ghost")
).rejects.toThrow("Agent agent-ghost not found");
});
});
// ── recordHeartbeat ───────────────────────────────────────────────
describe("recordHeartbeat", () => {
it("appends to the heartbeats JSONL file", async () => {
const agent = await store.createAgent({ name: "HB Agent", role: "executor" });
await store.recordHeartbeat(agent.id, "ok");
await store.recordHeartbeat(agent.id, "ok");
const history = await store.getHeartbeatHistory(agent.id);
expect(history).toHaveLength(2);
});
it("with status 'ok' updates agent's lastHeartbeatAt", async () => {
const agent = await store.createAgent({ name: "OK HB", role: "executor" });
expect(agent.lastHeartbeatAt).toBeUndefined();
await store.recordHeartbeat(agent.id, "ok");
const updated = await store.getAgent(agent.id);
expect(updated!.lastHeartbeatAt).toBeDefined();
expect(new Date(updated!.lastHeartbeatAt!).getTime()).not.toBeNaN();
});
it("with status 'missed' does NOT update lastHeartbeatAt", async () => {
const agent = await store.createAgent({ name: "Missed HB", role: "executor" });
// Record an OK heartbeat first to set lastHeartbeatAt
await store.recordHeartbeat(agent.id, "ok");
const afterOk = await store.getAgent(agent.id);
const okTimestamp = afterOk!.lastHeartbeatAt;
// Record a missed heartbeat — lastHeartbeatAt should stay the same
await store.recordHeartbeat(agent.id, "missed");
const afterMissed = await store.getAgent(agent.id);
expect(afterMissed!.lastHeartbeatAt).toBe(okTimestamp);
});
it("emits 'agent:heartbeat' event", async () => {
const agent = await store.createAgent({ name: "HB Event", role: "executor" });
const handler = vi.fn();
store.on("agent:heartbeat", handler);
await store.recordHeartbeat(agent.id, "ok");
expect(handler).toHaveBeenCalledOnce();
const [id, event] = handler.mock.calls[0];
expect(id).toBe(agent.id);
expect(event.status).toBe("ok");
expect(event.runId).toBeDefined();
});
it("throws for non-existent agent", async () => {
await expect(
store.recordHeartbeat("agent-ghost", "ok")
).rejects.toThrow("Agent agent-ghost not found");
});
});
// ── getHeartbeatHistory ───────────────────────────────────────────
describe("getHeartbeatHistory", () => {
it("returns events newest-first", async () => {
vi.useFakeTimers();
try {
const agent = await store.createAgent({ name: "History", role: "executor" });
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
await store.recordHeartbeat(agent.id, "ok");
vi.setSystemTime(new Date("2026-01-02T00:00:00Z"));
await store.recordHeartbeat(agent.id, "ok");
vi.setSystemTime(new Date("2026-01-03T00:00:00Z"));
await store.recordHeartbeat(agent.id, "ok");
const history = await store.getHeartbeatHistory(agent.id);
expect(history).toHaveLength(3);
// Newest first
expect(history[0].timestamp).toBe("2026-01-03T00:00:00.000Z");
expect(history[1].timestamp).toBe("2026-01-02T00:00:00.000Z");
expect(history[2].timestamp).toBe("2026-01-01T00:00:00.000Z");
} finally {
vi.useRealTimers();
}
});
it("respects limit parameter", async () => {
const agent = await store.createAgent({ name: "Limited", role: "executor" });
for (let i = 0; i < 10; i++) {
await store.recordHeartbeat(agent.id, "ok");
}
const limited = await store.getHeartbeatHistory(agent.id, 3);
expect(limited).toHaveLength(3);
});
it("returns empty array when no heartbeats exist", async () => {
const agent = await store.createAgent({ name: "NoHB", role: "executor" });
const history = await store.getHeartbeatHistory(agent.id);
expect(history).toEqual([]);
});
});
// ── heartbeat runs ────────────────────────────────────────────────
describe("heartbeat runs", () => {
it("startHeartbeatRun returns a run with status 'active' and valid fields", async () => {
const agent = await store.createAgent({ name: "RunAgent", role: "executor" });
const run = await store.startHeartbeatRun(agent.id);
expect(run.id).toMatch(/^run-/);
expect(run.agentId).toBe(agent.id);
expect(run.status).toBe("active");
expect(run.endedAt).toBeNull();
expect(new Date(run.startedAt).getTime()).not.toBeNaN();
});
it("getActiveHeartbeatRun returns the active run after starting one", async () => {
const agent = await store.createAgent({ name: "ActiveRunAgent", role: "executor" });
const run = await store.startHeartbeatRun(agent.id);
const active = await store.getActiveHeartbeatRun(agent.id);
expect(active).not.toBeNull();
expect(active!.id).toBe(run.id);
expect(active!.status).toBe("active");
});
it("getActiveHeartbeatRun returns null when no runs exist", async () => {
const agent = await store.createAgent({ name: "NoRuns", role: "executor" });
const active = await store.getActiveHeartbeatRun(agent.id);
expect(active).toBeNull();
});
it("endHeartbeatRun with 'terminated' marks the run as ended", async () => {
const agent = await store.createAgent({ name: "TermRun", role: "executor" });
const run = await store.startHeartbeatRun(agent.id);
await store.endHeartbeatRun(run.id, "terminated");
const completed = await store.getCompletedHeartbeatRuns(agent.id);
expect(completed).toHaveLength(1);
expect(completed[0].id).toBe(run.id);
expect(completed[0].status).toBe("terminated");
expect(completed[0].endedAt).toBeDefined();
});
it("endHeartbeatRun with 'completed' removes from active and adds to completed", async () => {
const agent = await store.createAgent({ name: "CompleteRun", role: "executor" });
const run = await store.startHeartbeatRun(agent.id);
await store.endHeartbeatRun(run.id, "completed");
// A completed run should NOT appear in active runs
const active = await store.getActiveHeartbeatRun(agent.id);
expect(active).toBeNull();
// A completed run should appear in completed runs with terminal status
const completed = await store.getCompletedHeartbeatRuns(agent.id);
expect(completed).toHaveLength(1);
expect(completed[0].id).toBe(run.id);
expect(completed[0].status).toBe("completed");
expect(completed[0].endedAt).toBeDefined();
});
it("getCompletedHeartbeatRuns returns only non-active runs", async () => {
const agent = await store.createAgent({ name: "MultiRun", role: "executor" });
const run1 = await store.startHeartbeatRun(agent.id);
await store.endHeartbeatRun(run1.id, "terminated");
const run2 = await store.startHeartbeatRun(agent.id);
// run2 is still active
const completed = await store.getCompletedHeartbeatRuns(agent.id);
expect(completed).toHaveLength(1);
expect(completed[0].id).toBe(run1.id);
// Active run should not appear in completed
const active = await store.getActiveHeartbeatRun(agent.id);
expect(active).not.toBeNull();
expect(active!.id).toBe(run2.id);
});
it("after completion, a new run can start without stale active-run blockage", async () => {
const agent = await store.createAgent({ name: "RestartRun", role: "executor" });
// Start and complete first run
const run1 = await store.startHeartbeatRun(agent.id);
await store.endHeartbeatRun(run1.id, "completed");
// Verify first run is not active
const active1 = await store.getActiveHeartbeatRun(agent.id);
expect(active1).toBeNull();
// Start second run - should succeed without conflict
const run2 = await store.startHeartbeatRun(agent.id);
expect(run2.id).not.toBe(run1.id);
expect(run2.status).toBe("active");
// Verify second run is now the active run
const active2 = await store.getActiveHeartbeatRun(agent.id);
expect(active2).not.toBeNull();
expect(active2!.id).toBe(run2.id);
});
it("startHeartbeatRun persists the run to structured storage", async () => {
const agent = await store.createAgent({ name: "PersistRun", role: "executor" });
const run = await store.startHeartbeatRun(agent.id);
// Verify run is persisted
const detail = await store.getRunDetail(agent.id, run.id);
expect(detail).not.toBeNull();
expect(detail!.id).toBe(run.id);
expect(detail!.agentId).toBe(agent.id);
expect(detail!.status).toBe("active");
expect(detail!.endedAt).toBeNull();
// Verify run appears in recent runs
const recent = await store.getRecentRuns(agent.id);
expect(recent.some((r) => r.id === run.id)).toBe(true);
});
it("endHeartbeatRun updates the persisted run with terminal state", async () => {
const agent = await store.createAgent({ name: "UpdateRun", role: "executor" });
const run = await store.startHeartbeatRun(agent.id);
// Complete the run
await store.endHeartbeatRun(run.id, "completed");
// Verify persisted run is updated
const detail = await store.getRunDetail(agent.id, run.id);
expect(detail).not.toBeNull();
expect(detail!.status).toBe("completed");
expect(detail!.endedAt).toBeDefined();
});
it("getCompletedHeartbeatRuns returns terminal runs in newest-first order", async () => {
const agent = await store.createAgent({ name: "OrderRuns", role: "executor" });
vi.useFakeTimers();
try {
vi.setSystemTime(new Date("2026-01-01T00:00:00Z"));
const run1 = await store.startHeartbeatRun(agent.id);
await store.endHeartbeatRun(run1.id, "completed");
vi.setSystemTime(new Date("2026-01-02T00:00:00Z"));
const run2 = await store.startHeartbeatRun(agent.id);
await store.endHeartbeatRun(run2.id, "completed");
const completed = await store.getCompletedHeartbeatRuns(agent.id);
expect(completed).toHaveLength(2);
expect(completed[0].id).toBe(run2.id); // Newest first
expect(completed[1].id).toBe(run1.id);
} finally {
vi.useRealTimers();
}
});
it("handles mixed structured and legacy run data", async () => {
const agent = await store.createAgent({ name: "MixedRuns", role: "executor" });
// Structured run (new behavior)
const structuredRun = await store.startHeartbeatRun(agent.id);
await store.endHeartbeatRun(structuredRun.id, "completed");
// Legacy completed run (simulated by checking legacy fallback)
// The fallback still reconstructs runs from heartbeat events
const completed = await store.getCompletedHeartbeatRuns(agent.id);
expect(completed.some((r) => r.id === structuredRun.id)).toBe(true);
});
});
// ── blocked state persistence ─────────────────────────────────────
describe("blocked state persistence", () => {
it("roundtrips last blocked state via set/get", async () => {
const agent = await store.createAgent({ name: "BlockedState", role: "executor" });
const snapshot = {
taskId: "FN-123",
blockedBy: "FN-122",
recordedAt: new Date().toISOString(),
contextHash: "abc123hash",
};
await store.setLastBlockedState(agent.id, snapshot);
const loaded = await store.getLastBlockedState(agent.id);
expect(loaded).toEqual(snapshot);
});
it("returns null when no blocked-state file exists", async () => {
const agent = await store.createAgent({ name: "NoBlockedState", role: "executor" });
const loaded = await store.getLastBlockedState(agent.id);
expect(loaded).toBeNull();
});
it("clearLastBlockedState removes persisted snapshot", async () => {
const agent = await store.createAgent({ name: "ClearBlockedState", role: "executor" });
await store.setLastBlockedState(agent.id, {
taskId: "FN-999",
blockedBy: "FN-998",
recordedAt: new Date().toISOString(),
contextHash: "will-clear",
});
await store.clearLastBlockedState(agent.id);
const loaded = await store.getLastBlockedState(agent.id);
expect(loaded).toBeNull();
expect(existsSync(join(rootDir, "agents", `${agent.id}-last-blocked.json`))).toBe(false);
});
});
// ── getAgentDetail ────────────────────────────────────────────────
describe("getAgentDetail", () => {
it("returns agent data plus heartbeat info", async () => {
const agent = await store.createAgent({ name: "DetailAgent", role: "executor" });
await store.recordHeartbeat(agent.id, "ok");
const detail = await store.getAgentDetail(agent.id);
expect(detail).not.toBeNull();
expect(detail!.id).toBe(agent.id);
expect(detail!.name).toBe("DetailAgent");
expect(detail!.heartbeatHistory).toHaveLength(1);
expect(detail!.completedRuns).toBeDefined();
expect(Array.isArray(detail!.completedRuns)).toBe(true);
});
it("returns null for non-existent agent", async () => {
const detail = await store.getAgentDetail("agent-nope");
expect(detail).toBeNull();
});
it("respects heartbeatLimit parameter", async () => {
const agent = await store.createAgent({ name: "LimitDetail", role: "executor" });
for (let i = 0; i < 10; i++) {
await store.recordHeartbeat(agent.id, "ok");
}
const detail = await store.getAgentDetail(agent.id, 3);
expect(detail!.heartbeatHistory).toHaveLength(3);
});
it("includes active and completed runs", async () => {
const agent = await store.createAgent({ name: "RunsDetail", role: "executor" });
const run1 = await store.startHeartbeatRun(agent.id);
await store.endHeartbeatRun(run1.id, "terminated");
const run2 = await store.startHeartbeatRun(agent.id);
const detail = await store.getAgentDetail(agent.id);
expect(detail!.activeRun).toBeDefined();
expect(detail!.activeRun!.id).toBe(run2.id);
expect(detail!.completedRuns).toHaveLength(1);
expect(detail!.completedRuns[0].id).toBe(run1.id);
});
});
describe("rating methods", () => {
const addSequencedRatings = async (
agentId: string,
scores: number[],
inputOverrides?: Partial<{ category: string; comment: string; runId: string; taskId: string; raterId: string }>,
) => {
vi.useFakeTimers();
const base = new Date("2026-01-01T00:00:00.000Z").getTime();
try {
const ratings = [];
for (let i = 0; i < scores.length; i++) {
vi.setSystemTime(new Date(base + i * 1000));
ratings.push(
await store.addRating(agentId, {
raterType: "user",
score: scores[i],
...inputOverrides,
}),
);
}
return ratings;
} finally {
vi.useRealTimers();
}
};
it("addRating creates a rating and emits rating:added", async () => {
const agent = await store.createAgent({ name: "Rated Agent", role: "executor" });
const handler = vi.fn();
store.on("rating:added", handler);
const rating = await store.addRating(agent.id, {
raterType: "user",
score: 5,
comment: "Great run",
});
expect(rating.id).toMatch(/^rating-[a-f0-9]{8}$/);
expect(rating.agentId).toBe(agent.id);
expect(rating.raterType).toBe("user");
expect(rating.score).toBe(5);
expect(rating.comment).toBe("Great run");
expect(new Date(rating.createdAt).getTime()).not.toBeNaN();
expect(handler).toHaveBeenCalledOnce();
expect(handler).toHaveBeenCalledWith(rating);
});
it("addRating rejects scores outside 1..5", async () => {
const agent = await store.createAgent({ name: "Validator", role: "reviewer" });
await expect(
store.addRating(agent.id, { raterType: "system", score: 0 }),
).rejects.toThrow("Rating score must be between 1 and 5");
await expect(
store.addRating(agent.id, { raterType: "system", score: 6 }),
).rejects.toThrow("Rating score must be between 1 and 5");
});
it("addRating stores all optional fields", async () => {
const agent = await store.createAgent({ name: "Optional Fields", role: "executor" });
const rating = await store.addRating(agent.id, {
raterType: "agent",
raterId: "agent-rater",
score: 4,
category: "quality",
comment: "Strong implementation",
runId: "run-123",
taskId: "FN-1000",
});
expect(rating.raterId).toBe("agent-rater");
expect(rating.category).toBe("quality");
expect(rating.comment).toBe("Strong implementation");
expect(rating.runId).toBe("run-123");
expect(rating.taskId).toBe("FN-1000");
});
it("getRatings returns ratings ordered by createdAt desc", async () => {
const agent = await store.createAgent({ name: "Order Agent", role: "executor" });
const created = await addSequencedRatings(agent.id, [2, 3, 5]);
const ratings = await store.getRatings(agent.id);
expect(ratings.map((rating) => rating.id)).toEqual([
created[2].id,
created[1].id,
created[0].id,
]);
});
it("getRatings applies category filter", async () => {
const agent = await store.createAgent({ name: "Category Agent", role: "executor" });
await store.addRating(agent.id, { raterType: "user", score: 4, category: "quality" });
await store.addRating(agent.id, { raterType: "user", score: 2, category: "speed" });
await store.addRating(agent.id, { raterType: "user", score: 5, category: "quality" });
const ratings = await store.getRatings(agent.id, { category: "quality" });
expect(ratings).toHaveLength(2);
expect(ratings.every((rating) => rating.category === "quality")).toBe(true);
});
it("getRatings respects the limit option", async () => {
const agent = await store.createAgent({ name: "Limit Agent", role: "executor" });
await addSequencedRatings(agent.id, [1, 2, 3, 4]);
const ratings = await store.getRatings(agent.id, { limit: 2 });
expect(ratings).toHaveLength(2);
expect(ratings[0].score).toBe(4);
expect(ratings[1].score).toBe(3);
});
it("getRatingSummary returns an empty summary when no ratings exist", async () => {
const agent = await store.createAgent({ name: "Empty Summary", role: "executor" });
const summary = await store.getRatingSummary(agent.id);
expect(summary).toEqual({
agentId: agent.id,
averageScore: 0,
totalRatings: 0,
categoryAverages: {},
recentRatings: [],
trend: "insufficient-data",
});
});
it("getRatingSummary computes averages and categoryAverages", async () => {
const agent = await store.createAgent({ name: "Summary Agent", role: "executor" });
await addSequencedRatings(agent.id, [5], { category: "quality" });
await addSequencedRatings(agent.id, [3], { category: "quality" });
await addSequencedRatings(agent.id, [4], { category: "speed" });
await addSequencedRatings(agent.id, [2]);
const summary = await store.getRatingSummary(agent.id);
expect(summary.averageScore).toBe(3.5);
expect(summary.totalRatings).toBe(4);
expect(summary.categoryAverages).toEqual({
quality: 4,
speed: 4,
});
expect(summary.recentRatings).toHaveLength(4);
expect(summary.trend).toBe("insufficient-data");
});
it("getRatingSummary trend is improving when recent average is higher", async () => {
const agent = await store.createAgent({ name: "Improving Agent", role: "executor" });
await addSequencedRatings(agent.id, [1, 1, 2, 2, 2, 4, 4, 5, 5, 5]);
const summary = await store.getRatingSummary(agent.id);
expect(summary.trend).toBe("improving");
});
it("getRatingSummary trend is declining when recent average is lower", async () => {
const agent = await store.createAgent({ name: "Declining Agent", role: "executor" });
await addSequencedRatings(agent.id, [5, 5, 4, 4, 4, 2, 2, 1, 1, 1]);
const summary = await store.getRatingSummary(agent.id);
expect(summary.trend).toBe("declining");
});
it("getRatingSummary trend is stable when windows are approximately equal", async () => {
const agent = await store.createAgent({ name: "Stable Agent", role: "executor" });
await addSequencedRatings(agent.id, [3, 3, 3, 3, 3, 3, 3, 3, 3, 3]);
const summary = await store.getRatingSummary(agent.id);
expect(summary.trend).toBe("stable");
});
it("deleteRating removes the rating", async () => {
const agent = await store.createAgent({ name: "Delete Agent", role: "executor" });
const first = await store.addRating(agent.id, { raterType: "user", score: 4 });
await store.addRating(agent.id, { raterType: "user", score: 5 });
await store.deleteRating(first.id);
const ratings = await store.getRatings(agent.id);
expect(ratings).toHaveLength(1);
expect(ratings[0].id).not.toBe(first.id);
});
});
// ── heartbeat lifecycle via updateAgentState ──────────────────────
describe("heartbeat lifecycle via updateAgentState", () => {
// NOTE: updateAgentState has a re-entrant withLock deadlock bug
// (see FN-711). These tests exercise the *intended behavior*
// via direct method calls rather than through the deadlock-prone
// updateAgentState path.
it("idle → active intended to start heartbeat run (tested via direct call)", async () => {
const agent = await store.createAgent({ name: "HBLifecycle", role: "executor" });
// Directly call startHeartbeatRun (what updateAgentState intends to do)
const run = await store.startHeartbeatRun(agent.id);
expect(run.status).toBe("active");
const active = await store.getActiveHeartbeatRun(agent.id);
expect(active).not.toBeNull();
expect(active!.id).toBe(run.id);
});
it("terminated transition intended to end heartbeat run (tested via direct call)", async () => {
const agent = await store.createAgent({ name: "HBEnd", role: "executor" });
const run = await store.startHeartbeatRun(agent.id);
// Directly call endHeartbeatRun (what updateAgentState intends to do)
await store.endHeartbeatRun(run.id, "terminated");
const active = await store.getActiveHeartbeatRun(agent.id);
expect(active).toBeNull();
const completed = await store.getCompletedHeartbeatRuns(agent.id);
expect(completed).toHaveLength(1);
expect(completed[0].status).toBe("terminated");
});
});
// ── API Keys ──────────────────────────────────────────────────────
describe("API Keys", () => {
it("createApiKey returns key metadata and one-time plaintext token", async () => {
const agent = await store.createAgent({ name: "KeyAgent", role: "executor" });
const result = await store.createApiKey(agent.id);
expect(result.key.id).toMatch(/^key-[a-f0-9]{8}$/);
expect(result.key.agentId).toBe(agent.id);
expect(result.key.tokenHash).toMatch(/^[a-f0-9]{64}$/);
expect(result.token).toMatch(/^[a-f0-9]{64}$/);
expect(new Date(result.key.createdAt).getTime()).not.toBeNaN();
expect(result.key.revokedAt).toBeUndefined();
const expectedHash = createHash("sha256").update(result.token).digest("hex");
expect(result.key.tokenHash).toBe(expectedHash);
const keyPath = join(rootDir, "agents", `${agent.id}-keys.jsonl`);
expect(existsSync(keyPath)).toBe(true);
const persisted = readFileSync(keyPath, "utf-8");
expect(persisted).not.toContain(result.token);
});
it("createApiKey with label persists the label", async () => {
const agent = await store.createAgent({ name: "LabeledKeyAgent", role: "executor" });
const { key } = await store.createApiKey(agent.id, { label: "CI Key" });
const keys = await store.listApiKeys(agent.id);
expect(key.label).toBe("CI Key");
expect(keys).toHaveLength(1);
expect(keys[0].label).toBe("CI Key");
});
it("createApiKey omits empty labels", async () => {
const agent = await store.createAgent({ name: "NoLabelKeyAgent", role: "executor" });
const { key } = await store.createApiKey(agent.id, { label: " " });
expect(key.label).toBeUndefined();
});
it("createApiKey throws when agent is not found", async () => {
await expect(store.createApiKey("agent-missing")).rejects.toThrow(
"Agent agent-missing not found"
);
});
it("listApiKeys returns keys for one agent and empty array for an agent with no keys", async () => {
const withKeys = await store.createAgent({ name: "WithKeys", role: "executor" });
const noKeys = await store.createAgent({ name: "NoKeys", role: "executor" });
const other = await store.createAgent({ name: "Other", role: "reviewer" });
const first = await store.createApiKey(withKeys.id);
const second = await store.createApiKey(withKeys.id);
await store.createApiKey(other.id);
const withKeysList = await store.listApiKeys(withKeys.id);
expect(withKeysList).toHaveLength(2);
expect(withKeysList.map((key) => key.id)).toEqual([first.key.id, second.key.id]);
const noKeysList = await store.listApiKeys(noKeys.id);
expect(noKeysList).toEqual([]);
});
it("listApiKeys throws when agent is not found", async () => {
await expect(store.listApiKeys("agent-missing")).rejects.toThrow(
"Agent agent-missing not found"
);
});
it("revokeApiKey sets revokedAt and revoked key remains in list", async () => {
const agent = await store.createAgent({ name: "RevokeKeyAgent", role: "executor" });
const { key } = await store.createApiKey(agent.id);
const revoked = await store.revokeApiKey(agent.id, key.id);
expect(revoked.id).toBe(key.id);
expect(revoked.revokedAt).toBeDefined();
const keys = await store.listApiKeys(agent.id);
expect(keys).toHaveLength(1);
expect(keys[0].id).toBe(key.id);
expect(keys[0].revokedAt).toBe(revoked.revokedAt);
});
it("revokeApiKey already revoked is a no-op", async () => {
const agent = await store.createAgent({ name: "RevokeTwiceAgent", role: "executor" });
const { key } = await store.createApiKey(agent.id);
const firstRevocation = await store.revokeApiKey(agent.id, key.id);
const secondRevocation = await store.revokeApiKey(agent.id, key.id);
expect(firstRevocation.revokedAt).toBeDefined();
expect(secondRevocation.revokedAt).toBe(firstRevocation.revokedAt);
});
it("revokeApiKey throws when key is not found", async () => {
const agent = await store.createAgent({ name: "MissingKeyAgent", role: "executor" });
await expect(store.revokeApiKey(agent.id, "key-missing")).rejects.toThrow(
`API key key-missing not found for agent ${agent.id}`
);
});
it("revokeApiKey throws when agent is not found", async () => {
await expect(store.revokeApiKey("agent-missing", "key-1234")).rejects.toThrow(
"Agent agent-missing not found"
);
});
it("multiple keys can be listed and revoking one does not affect others", async () => {
const agent = await store.createAgent({ name: "MultiKeyAgent", role: "executor" });
const key1 = await store.createApiKey(agent.id, { label: "key-1" });
const key2 = await store.createApiKey(agent.id, { label: "key-2" });
const key3 = await store.createApiKey(agent.id, { label: "key-3" });
const revoked = await store.revokeApiKey(agent.id, key2.key.id);
const keys = await store.listApiKeys(agent.id);
expect(keys).toHaveLength(3);
const byId = new Map(keys.map((key) => [key.id, key]));
expect(byId.get(key1.key.id)?.revokedAt).toBeUndefined();
expect(byId.get(key2.key.id)?.revokedAt).toBe(revoked.revokedAt);
expect(byId.get(key3.key.id)?.revokedAt).toBeUndefined();
});
it("API keys survive store reinitialization", async () => {
const agent = await store.createAgent({ name: "KeyPersistence", role: "executor" });
const { key } = await store.createApiKey(agent.id, { label: "persist" });
const store2 = new AgentStore({ rootDir });
await store2.init();
const keys = await store2.listApiKeys(agent.id);
expect(keys).toHaveLength(1);
expect(keys[0].id).toBe(key.id);
expect(keys[0].label).toBe("persist");
});
});
// ── concurrency (withLock) ────────────────────────────────────────
describe("concurrency", () => {
it("concurrent updateAgent calls on the same agent serialize correctly", async () => {
const agent = await store.createAgent({ name: "ConcAgent", role: "executor" });
// Fire multiple updates concurrently
const [r1, r2, r3] = await Promise.all([
store.updateAgent(agent.id, { name: "Name-1" }),
store.updateAgent(agent.id, { name: "Name-2" }),
store.updateAgent(agent.id, { name: "Name-3" }),
]);
// The last write wins since they're serialized
const final = await store.getAgent(agent.id);
expect(final!.name).toBe("Name-3");
// All three should have returned valid agents (no corruption)
expect(r1.name).toBe("Name-1");
expect(r2.name).toBe("Name-2");
expect(r3.name).toBe("Name-3");
});
it("concurrent recordHeartbeat calls don't corrupt the JSONL file", async () => {
const agent = await store.createAgent({ name: "ConcHB", role: "executor" });
// Fire 10 heartbeats concurrently
await Promise.all(
Array.from({ length: 10 }, () => store.recordHeartbeat(agent.id, "ok"))
);
const history = await store.getHeartbeatHistory(agent.id, 100);
expect(history).toHaveLength(10);
// Each event should be parseable (no corruption)
for (const event of history) {
expect(event.status).toBe("ok");
expect(event.runId).toBeDefined();
expect(new Date(event.timestamp).getTime()).not.toBeNaN();
}
});
it("concurrent createApiKey calls don't corrupt the JSONL file", async () => {
const agent = await store.createAgent({ name: "ConcKeys", role: "executor" });
const results = await Promise.all(
Array.from({ length: 10 }, () => store.createApiKey(agent.id))
);
const keys = await store.listApiKeys(agent.id);
expect(keys).toHaveLength(10);
const ids = new Set(results.map(({ key }) => key.id));
expect(ids.size).toBe(10);
});
});
// ── filesystem persistence ────────────────────────────────────────
describe("filesystem persistence", () => {
it("agent data survives store reinitialization", async () => {
const agent = await store.createAgent({
name: "Persistent",
role: "reviewer",
metadata: { key: "val" },
});
await store.recordHeartbeat(agent.id, "ok");
// Create a new store instance pointing to the same rootDir
const store2 = new AgentStore({ rootDir });
await store2.init();
const found = await store2.getAgent(agent.id);
expect(found).not.toBeNull();
expect(found!.id).toBe(agent.id);
expect(found!.name).toBe("Persistent");
expect(found!.role).toBe("reviewer");
expect(found!.metadata).toEqual({ key: "val" });
expect(found!.lastHeartbeatAt).toBeDefined();
// Heartbeat history persists too
const history = await store2.getHeartbeatHistory(agent.id);
expect(history).toHaveLength(1);
});
});
});