Menu
popagent
publicLatest change 6edc7e928c57dab23c0973ed3f7ee70b3ebb72d5 - Initial popagent baseline by AkurAI Build
import { afterAll, beforeAll, describe, expect, test } from "bun:test";
import type { AgentRuntimeSettings, AgentTask } from "./api-types";
import { createTaskLifecycleHooks } from "./hook-lifecycle";
import { HookRuntime, type HookEvent } from "./hooks";
import { AgentTaskRuntime, AgentTaskStore } from "./tasks";
const TEST_MODEL = "test/model";
const runtimeSettings = (
overrides: Partial<AgentRuntimeSettings> = {},
) => ({
get: async (): Promise<AgentRuntimeSettings> => ({
defaultModel: TEST_MODEL,
supervisorMaxSteps: 24,
specialistMaxSteps: 12,
toolConcurrency: 3,
delegationContextMessages: 12,
delegationResultCharacters: 16_000,
maxProcessorRetries: 3,
finalResponseFeedback: "Return a final response",
delegationFailureFeedback: "Delegate {{agentId}} failed: {{error}}",
delegationResultTruncationMarker: "\n[truncated]",
taskConcurrency: 1,
taskPollIntervalMs: 60_000,
taskTimeoutMs: 900_000,
taskStaleAfterMs: 86_400_000,
updatedAt: "",
...overrides,
}),
});
const store = new AgentTaskStore();
const taskIds: string[] = [];
const scheduleIds: string[] = [];
beforeAll(() => store.init());
afterAll(async () => {
for (const id of taskIds) await store.storage.db.none("DELETE FROM popagent_agent_tasks WHERE id = $1", [id]);
for (const id of scheduleIds) await store.deleteSchedule(id);
});
describe("AgentTaskStore", () => {
test("persists and transitions a background task", async () => {
const task = await store.createTask({ prompt: "Persistence test", model: "cx/gpt-5.6-sol" });
taskIds.push(task.id);
expect(task.status).toBe("queued");
expect(await store.claim(task.id)).toBe(true);
await store.updateProgress(task.id, { stepsCompleted: 3, progress: "Using webSearch" });
await store.complete(task.id, "TASK_COMPLETE");
expect(await store.listTasks()).toContainEqual(expect.objectContaining({
id: task.id,
status: "completed",
output: "TASK_COMPLETE",
stepsCompleted: 3,
recoveryCount: 0,
progress: "Completed",
}));
});
test("keeps active cancellation observable until execution stops", async () => {
const task = await store.createTask({ prompt: "Cancellation test", model: TEST_MODEL });
taskIds.push(task.id);
expect(await store.claim(task.id)).toBe(true);
expect(await store.cancel(task.id)).toBe("cancelling");
expect(await store.listTasks()).toContainEqual(expect.objectContaining({
id: task.id,
status: "cancelling",
progress: "Cancelling",
completedAt: null,
}));
await store.finishCancellation(task.id);
expect(await store.listTasks()).toContainEqual(expect.objectContaining({
id: task.id,
status: "cancelled",
progress: "Cancelled",
completedAt: expect.any(String),
}));
});
test("increments recovery counts when interrupted work is requeued", async () => {
await store.recoverInterruptedTasks(86_400_000);
const task = await store.createTask({ prompt: "Recovery test", model: TEST_MODEL });
taskIds.push(task.id);
expect(task.recoveryCount).toBe(0);
expect(await store.claim(task.id)).toBe(true);
expect(await store.recoverInterruptedTasks(86_400_000)).toBe(1);
expect(await store.listTasks()).toContainEqual(expect.objectContaining({
id: task.id,
status: "queued",
progress: "Queued after restart",
recoveryCount: 1,
startedAt: null,
}));
});
test("recovers a freshly started task even when it was queued long ago", async () => {
const task = await store.createTask({ prompt: "Old queue, fresh execution", model: TEST_MODEL });
taskIds.push(task.id);
await store.storage.db.none(
"UPDATE popagent_agent_tasks SET created_at = NOW() - INTERVAL '2 days' WHERE id = $1",
[task.id],
);
expect(await store.claim(task.id)).toBe(true);
expect(await store.recoverInterruptedTasks(60_000)).toBe(1);
expect(await store.listTasks()).toContainEqual(expect.objectContaining({
id: task.id,
status: "queued",
progress: "Queued after restart",
}));
});
test("persists, pauses, resumes, and deletes a recurring schedule", async () => {
const schedule = await store.createSchedule({ name: "Persistence schedule", prompt: "Scheduled test", model: "cx/gpt-5.6-sol", cron: "0 9 * * *", enabled: true });
scheduleIds.push(schedule.id);
expect(schedule.nextRunAt).not.toBeNull();
const paused = await store.updateSchedule(schedule.id, { ...schedule, enabled: false });
expect(paused).toEqual(expect.objectContaining({ enabled: false, nextRunAt: null }));
const resumed = await store.updateSchedule(schedule.id, { ...schedule, enabled: true });
expect(resumed).toEqual(expect.objectContaining({ enabled: true }));
expect(await store.deleteSchedule(schedule.id)).toBe(true);
scheduleIds.splice(scheduleIds.indexOf(schedule.id), 1);
});
test("scopes tasks and schedules to their repository workspace", async () => {
const workspaceId = `workspace-${crypto.randomUUID()}`;
const task = await store.createTask({
workspaceId,
prompt: "Workspace task",
model: TEST_MODEL,
});
const schedule = await store.createSchedule({
workspaceId,
name: "Workspace schedule",
prompt: "Workspace schedule task",
model: TEST_MODEL,
cron: "0 9 * * *",
enabled: true,
});
taskIds.push(task.id);
scheduleIds.push(schedule.id);
expect(await store.listTasks(workspaceId)).toEqual([
expect.objectContaining({ id: task.id, workspaceId }),
]);
expect(await store.listSchedules(workspaceId)).toEqual([
expect.objectContaining({ id: schedule.id, workspaceId }),
]);
expect((await store.listTasks("another-workspace")).some((item) => item.id === task.id)).toBe(false);
});
test("materializes one task per due schedule occurrence", async () => {
const schedule = await store.createSchedule({ name: "Due schedule", prompt: "Due test", model: "cx/gpt-5.6-sol", cron: "* * * * *", enabled: true });
scheduleIds.push(schedule.id);
const contender = new AgentTaskStore();
await contender.init();
await store.storage.db.none("UPDATE popagent_agent_schedules SET next_run_at = NOW() - INTERVAL '1 second' WHERE id = $1", [schedule.id]);
const batches = await Promise.all([store.materializeDueTasks(), contender.materializeDueTasks()]);
const materialized = batches.flat().filter((task) => task.scheduleId === schedule.id);
taskIds.push(...materialized.map((task) => task.id));
expect(materialized).toHaveLength(1);
expect((await store.listSchedules()).find((item) => item.id === schedule.id)).toEqual(
expect.objectContaining({ lastRunAt: expect.any(String), nextRunAt: expect.any(String) }),
);
});
});
describe("AgentTaskRuntime", () => {
test("recovers every queued task with bounded execution concurrency", async () => {
const queued = Array.from({ length: 105 }, (_, index): AgentTask => ({
id: `queued-${index}`,
sessionId: null,
scheduleId: null,
workspaceId: "default",
prompt: `Task ${index}`,
model: TEST_MODEL,
status: "queued",
output: null,
error: null,
stepsCompleted: 0,
progress: null,
recoveryCount: 0,
createdAt: new Date(index).toISOString(),
startedAt: null,
completedAt: null,
}));
const completed: string[] = [];
const firstBatchStarted = Promise.withResolvers<void>();
const release = Promise.withResolvers<void>();
const allCompleted = Promise.withResolvers<void>();
let active = 0;
let peak = 0;
let started = 0;
let recovered = false;
const repository = {
init: async () => undefined,
recoverInterruptedTasks: async () => {
recovered = true;
return 0;
},
listQueuedTasks: async () => queued,
claim: async () => true,
updateProgress: async () => undefined,
complete: async (id: string) => {
completed.push(id);
if (completed.length === queued.length) allCompleted.resolve();
return "completed" as const;
},
fail: async () => undefined,
cancel: async () => "cancelled" as const,
finishCancellation: async () => undefined,
materializeDueTasks: async () => [],
};
const runtime = new AgentTaskRuntime(repository, async (task) => {
active++;
peak = Math.max(peak, active);
started++;
if (started === 3) firstBatchStarted.resolve();
await release.promise;
active--;
return `completed ${task.prompt}`;
}, undefined, runtimeSettings({ taskConcurrency: 3 }));
for (const task of queued) runtime.enqueue(task);
try {
await runtime.start();
await firstBatchStarted.promise;
expect(recovered).toBe(true);
expect(started).toBe(3);
expect(peak).toBe(3);
release.resolve();
await allCompleted.promise;
expect(completed).toHaveLength(queued.length);
expect(new Set(completed).size).toBe(queued.length);
expect(peak).toBe(3);
} finally {
release.resolve();
await runtime.stop();
}
});
test("emits a correlated lifecycle for autonomous task execution", async () => {
const task: AgentTask = {
id: "lifecycle-task",
sessionId: "session-1",
scheduleId: null,
workspaceId: "default",
prompt: "Complete lifecycle",
model: TEST_MODEL,
status: "queued",
output: null,
error: null,
stepsCompleted: 0,
progress: null,
recoveryCount: 0,
createdAt: "",
startedAt: null,
completedAt: null,
};
const finished = Promise.withResolvers<void>();
const events: HookEvent[] = [];
const hookRuntime = new HookRuntime({
config: {
version: 1,
hooks: {
TaskStart: [{ id: "start", type: "http", url: "https://hooks.example/task-start" }],
TaskStop: [{ id: "stop", type: "http", url: "https://hooks.example/task-stop" }],
},
},
transport: async (_handler, event) => {
events.push(event);
if (event.eventName === "TaskStop") finished.resolve();
return { schemaVersion: 1, outcome: "pass" };
},
});
const repository = {
init: async () => undefined,
recoverInterruptedTasks: async () => 0,
listQueuedTasks: async () => [],
claim: async () => true,
updateProgress: async () => undefined,
complete: async () => "completed" as const,
fail: async () => undefined,
cancel: async () => "cancelled" as const,
finishCancellation: async () => undefined,
materializeDueTasks: async () => [],
};
const runtime = new AgentTaskRuntime(
repository,
async () => "TASK_COMPLETE",
createTaskLifecycleHooks(hookRuntime),
runtimeSettings(),
);
runtime.enqueue(task);
try {
await runtime.start();
await finished.promise;
expect(events.map((event) => event.eventName)).toEqual(["TaskStart", "TaskStop"]);
expect(events.map((event) => event.detail.status)).toEqual(["running", "completed"]);
expect(new Set(events.map((event) => event.turnId)).size).toBe(1);
} finally {
await runtime.stop();
}
});
test("finalizes cancellation after the active executor acknowledges abort", async () => {
const task: AgentTask = {
id: "cancellation-task",
sessionId: null,
scheduleId: null,
workspaceId: "default",
prompt: "Cancel active work",
model: TEST_MODEL,
status: "queued",
output: null,
error: null,
stepsCompleted: 0,
progress: null,
recoveryCount: 0,
createdAt: "",
startedAt: null,
completedAt: null,
};
const started = Promise.withResolvers<void>();
const allowFinalization = Promise.withResolvers<void>();
const stopped = Promise.withResolvers<void>();
let status: AgentTask["status"] = "queued";
const currentStatus = (): AgentTask["status"] => status;
const repository = {
init: async () => undefined,
recoverInterruptedTasks: async () => 0,
listQueuedTasks: async () => [],
claim: async () => {
status = "running";
return true;
},
updateProgress: async () => undefined,
complete: async () => undefined,
fail: async () => undefined,
cancel: async () => {
status = "cancelling";
return "cancelling" as const;
},
finishCancellation: async () => {
await allowFinalization.promise;
status = "cancelled";
},
materializeDueTasks: async () => [],
};
const runtime = new AgentTaskRuntime(
repository,
async () => {
started.resolve();
return new Promise<string>(() => undefined);
},
async (event) => {
if (event.status === "cancelled") stopped.resolve();
},
runtimeSettings(),
);
runtime.enqueue(task);
try {
await runtime.start();
await started.promise;
expect(await runtime.cancel(task.id)).toBe(true);
expect(currentStatus()).toBe("cancelling");
allowFinalization.resolve();
await stopped.promise;
expect(currentStatus()).toBe("cancelled");
} finally {
allowFinalization.resolve();
await runtime.stop();
}
});
test("fails tasks that exceed their runtime deadline", async () => {
const failure = Promise.withResolvers<string>();
let aborted = false;
const task: AgentTask = {
id: "timeout-task",
sessionId: null,
scheduleId: null,
workspaceId: "default",
prompt: "Never completes",
model: TEST_MODEL,
status: "queued",
output: null,
error: null,
stepsCompleted: 0,
progress: null,
recoveryCount: 0,
createdAt: "",
startedAt: null,
completedAt: null,
};
const repository = {
init: async () => undefined,
recoverInterruptedTasks: async () => 0,
listQueuedTasks: async () => [],
claim: async () => true,
updateProgress: async () => undefined,
complete: async () => undefined,
fail: async (_id: string, error: string) => { failure.resolve(error); },
cancel: async () => "cancelled" as const,
finishCancellation: async () => undefined,
materializeDueTasks: async () => [],
};
const runtime = new AgentTaskRuntime(repository, async (_task, signal) => {
signal.addEventListener("abort", () => { aborted = true; }, { once: true });
return new Promise<string>(() => undefined);
}, undefined, runtimeSettings({ taskTimeoutMs: 10 }));
runtime.enqueue(task);
try {
await runtime.start();
expect(await failure.promise).toBe("Background task timed out after 10ms");
expect(aborted).toBe(true);
} finally {
await runtime.stop();
}
});
});