Files
template-app/modules/agent/__tests__/retention.test.ts
T
2026-11-14 09:56:45 +00:00

136 lines
5.4 KiB
TypeScript

// The run retention sweep of the agent module (G3j): the hourly job deletes a terminal run past the
// retention with its steps and conversation, in each project the app serves, and keeps a run still going,
// a fresher one and another organization's. The job is the module's own (adapters/procedures.ts), so it runs
// on whatever data source the worker binds; this test uses the in-memory one.
import { AGENT_ENTITIES, agentRuns, agentSteps, agentThreads, threadIdOf } from "@conusai-kit/agent"
import { Container, type TenantScope } from "@conusai-kit/core"
import { createMemoryDataSource, DATA_SOURCE, type DataSource } from "@conusai-kit/data"
import { afterEach, describe, expect, it, vi } from "vitest"
import { RUN_EXPIRE_JOB } from "../contract/constants.js"
import { AGENT_SCOPE } from "../core/ports.js"
import { AGENT_JOBS, agentSchedules } from "../index.js"
const DAY = 86_400_000
const NOW = Date.parse("2026-11-02T12:00:00.000Z")
const USER = "01900000-0000-7000-0000-000000000009"
const ORG_A = "01900000-0000-7000-0000-000000000001"
const ORG_B = "01900000-0000-7000-0000-00000000000b"
const WORKSPACE = "01900000-0000-7000-0000-000000000002"
const PROJECT = "01900000-0000-7000-0000-000000000003"
const scopeOf = (orgId: string): TenantScope & { workspaceId: string; projectId: string } => ({
level: "project",
actor: { userId: USER as never, system: true },
orgId,
workspaceId: WORKSPACE,
projectId: PROJECT,
})
const daysAgo = (days: number): string => new Date(NOW - days * DAY).toISOString()
async function seed(data: DataSource, orgId: string, status: string, finishedDaysAgo: number | null): Promise<string> {
const repos = data.forScope(scopeOf(orgId))
const run = await repos.of(agentRuns).insert({
goal: "Review the contract",
plan: [{ key: "read", label: "Read", tool: "documents.read", args: {} }],
status,
failureCode: null,
failureDetail: null,
actorId: USER,
budgetCostMicrocents: 1_000_000,
budgetSteps: 10,
budgetSeconds: 300,
usageCostMicrocents: 0,
usageSteps: 0,
cancelRequestedAt: null,
startedAt: daysAgo((finishedDaysAgo ?? 1) + 1),
finishedAt: finishedDaysAgo === null ? null : daysAgo(finishedDaysAgo),
} as never)
await repos.of(agentSteps).insert({
runId: run.id,
key: "read",
index: 0,
label: "Read",
status: "committed",
attempts: 1,
usageCostMicrocents: 0,
usageTokens: 0,
error: null,
externalEffect: false,
artifacts: [],
approval: null,
startedAt: null,
finishedAt: null,
result: null,
} as never)
await repos.of(agentThreads).insert({ threadId: threadIdOf(run.id), messages: [] } as never)
return run.id
}
/** The retention job, as the module registers it. */
function expireJob() {
const job = AGENT_JOBS[RUN_EXPIRE_JOB]
if (!job) throw new Error("the agent module registers no run retention job")
return job
}
const runIdsOf = async (data: DataSource, orgId: string): Promise<string[]> =>
(await data.forScope(scopeOf(orgId)).of(agentRuns).list({})).items.map((run) => run.id).sort()
/** The job as the worker runs it: its container binds the data source and the project scope it runs in. */
function containerFor(data: DataSource, orgId: string): Container {
const container = new Container()
container.value(DATA_SOURCE, data)
container.value(AGENT_SCOPE, scopeOf(orgId))
return container
}
afterEach(() => vi.restoreAllMocks())
describe("the agent run retention job", () => {
it("deletes a terminal run past the retention with its steps and conversation, and keeps the rest", async () => {
vi.spyOn(Date, "now").mockReturnValue(NOW)
const data = createMemoryDataSource({ entities: [...AGENT_ENTITIES] })
await seed(data, ORG_A, "completed", 100)
await seed(data, ORG_A, "failed", 91)
const fresh = await seed(data, ORG_A, "completed", 89)
const running = await seed(data, ORG_A, "running", null)
const otherOrg = await seed(data, ORG_B, "completed", 400)
await expireJob().handler(
{ retentionDays: 90 },
{ container: containerFor(data, ORG_A), correlationId: "t", attempt: 1 },
)
expect(await runIdsOf(data, ORG_A)).toEqual([fresh, running].sort())
expect((await data.forScope(scopeOf(ORG_A)).of(agentSteps).list({})).items.map((s) => s.runId).sort()).toEqual(
[fresh, running].sort(),
)
expect((await data.forScope(scopeOf(ORG_A)).of(agentThreads).list({})).items.map((t) => t.threadId).sort()).toEqual(
[fresh, running].map(threadIdOf).sort(),
)
// Negative control: another organization's run, past the same retention, is untouched in its own project.
expect(await runIdsOf(data, ORG_B)).toEqual([otherOrg])
})
it("takes its retention from the schedule's payload, which accepts only whole days from 1 to 3650", () => {
const payload = expireJob().payload
expect(payload.safeParse({ retentionDays: 90 }).success).toBe(true)
expect(payload.safeParse({ retentionDays: 0 }).success).toBe(false)
expect(payload.safeParse({ retentionDays: 3651 }).success).toBe(false)
expect(payload.safeParse({ retentionDays: 2.5 }).success).toBe(false)
})
it("is scheduled hourly, once per project, with the retention it was given", () => {
expect(agentSchedules(90)).toEqual([
{
job: RUN_EXPIRE_JOB,
cron: "0 * * * *",
scope: { level: "each-project" },
payload: { retentionDays: 90 },
key: "agent.runs.expire",
missed: "skip",
},
])
})
})