@@ -0,0 +1,173 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Real pg-boss claims for Agent tests, including local engine/provider tests.
|
||||
import { buildRegistry, Container, defineModule, type JobContext, newId, type ProjectScope } from "@conusai-kit/core"
|
||||
import { systemActor } from "@conusai-kit/core/worker"
|
||||
import { type DataSource, entitiesOf } from "@conusai-kit/data"
|
||||
import { connectKyselyDataSourceFromEnv, type KyselyDataSource } from "@conusai-kit/data-kysely"
|
||||
import {
|
||||
createPgBoss,
|
||||
createPgBossClaimGuard,
|
||||
createPgBossJobQueue,
|
||||
createWorker,
|
||||
fromKyselyTransaction,
|
||||
withJobTransaction,
|
||||
} from "@conusai-kit/jobs-pgboss"
|
||||
import { Pool, type PoolClient } from "pg"
|
||||
import { afterAll, beforeAll } from "vitest"
|
||||
import { z } from "zod"
|
||||
import { runJob } from "../adapters/procedures.js"
|
||||
import { RUN_JOB } from "../contract/index.js"
|
||||
import { AGENT_SCOPE } from "../core/ports.js"
|
||||
import { grantPgBoss, migrate, type Postgres, startPostgres } from "./claimed-postgres.js"
|
||||
import { workerRegistry } from "./worker-fixture.js"
|
||||
|
||||
let postgres: Postgres
|
||||
let boss: ReturnType<typeof createPgBoss>
|
||||
let guard: ReturnType<typeof createPgBossClaimGuard>
|
||||
let sql: Pool
|
||||
let ownedClient: PoolClient
|
||||
/** Terminate only the client acquired from this test's dedicated guard pool. */
|
||||
export async function terminateClaimSession() {
|
||||
const {
|
||||
rows: [{ pid }],
|
||||
} = await ownedClient.query("select pg_backend_pid() as pid")
|
||||
await sql.query("select pg_terminate_backend($1)", [pid])
|
||||
}
|
||||
export type ClaimChange = "cancel" | "reclaim" | "delete" | "scope" | "actor" | "owner"
|
||||
const workers: ReturnType<typeof createWorker>[] = []
|
||||
const pending = new Map<
|
||||
string,
|
||||
{
|
||||
container: Container
|
||||
extra: Partial<JobContext>
|
||||
change?: ClaimChange
|
||||
resolve: () => void
|
||||
reject: (error: unknown) => void
|
||||
}
|
||||
>()
|
||||
let domainData: KyselyDataSource | undefined
|
||||
/** Queue and domain tables share the actual database, as in the supported host. */
|
||||
export async function claimedDataSource(): Promise<KyselyDataSource> {
|
||||
if (!domainData) {
|
||||
const entities = entitiesOf(workerRegistry, "postgres")
|
||||
await migrate(postgres, entities)
|
||||
// Migrations grant access to public tables; queue objects remain owned by pg-boss.
|
||||
domainData = await connectKyselyDataSourceFromEnv(
|
||||
{ POSTGRES_URL: postgres.runtimeUrl, POSTGRES_READER_URL: postgres.readerUrl },
|
||||
{ entities, transactionContext: (trx, run) => withJobTransaction(fromKyselyTransaction(trx), run) },
|
||||
)
|
||||
}
|
||||
return domainData
|
||||
}
|
||||
let currentData: DataSource
|
||||
const registry = buildRegistry([
|
||||
defineModule({
|
||||
id: "agent",
|
||||
version: "1.0.0",
|
||||
kit: "^0.1.0",
|
||||
jobs: {
|
||||
[RUN_JOB]: {
|
||||
payload: z.object({ runId: z.string(), delivery: z.string() }),
|
||||
async handler(payload, ctx) {
|
||||
const target = pending.get(payload.delivery)
|
||||
if (!target) throw new Error("unregistered test delivery")
|
||||
try {
|
||||
if (!ctx.execution) throw new Error("supported adapter did not supply execution")
|
||||
const id = ctx.execution.id
|
||||
if (target.change === "delete") await sql.query("delete from pgboss.job where id = $1", [id])
|
||||
if (target.change === "cancel")
|
||||
await sql.query("update pgboss.job set state = 'cancelled' where id = $1", [id])
|
||||
if (target.change === "reclaim")
|
||||
await sql.query("update pgboss.job set retry_count = retry_count + 1 where id = $1", [id])
|
||||
if (target.change === "scope")
|
||||
await sql.query(
|
||||
"update pgboss.job set data = jsonb_set(data, '{scope,projectId}', to_jsonb($2::text)) where id = $1",
|
||||
[id, newId()],
|
||||
)
|
||||
if (target.change === "actor")
|
||||
await sql.query(
|
||||
"update pgboss.job set data = jsonb_set(data, '{onBehalfOf}', to_jsonb($2::text)) where id = $1",
|
||||
[id, newId()],
|
||||
)
|
||||
await runJob.handler(
|
||||
{ runId: payload.runId },
|
||||
{ ...ctx, ...target.extra, execution: ctx.execution, container: target.container },
|
||||
)
|
||||
target.resolve()
|
||||
} catch (error) {
|
||||
target.reject(error)
|
||||
} finally {
|
||||
pending.delete(payload.delivery)
|
||||
}
|
||||
},
|
||||
},
|
||||
},
|
||||
}),
|
||||
])
|
||||
beforeAll(async () => {
|
||||
postgres = await startPostgres()
|
||||
boss = createPgBoss({ connectionString: postgres.ownerUrl })
|
||||
await boss.start()
|
||||
await boss.createQueue(RUN_JOB)
|
||||
await grantPgBoss(postgres)
|
||||
sql = new Pool({ connectionString: postgres.runtimeUrl, max: 2 })
|
||||
const guardPool = new Pool({ connectionString: postgres.runtimeUrl, max: 2 })
|
||||
guardPool.on("acquire", (client) => {
|
||||
ownedClient = client
|
||||
})
|
||||
guard = createPgBossClaimGuard(guardPool)
|
||||
}, 300_000)
|
||||
afterAll(async () => {
|
||||
for (const worker of workers) await worker.stop()
|
||||
await guard?.close()
|
||||
await sql?.end()
|
||||
await boss?.stop({ graceful: true })
|
||||
await domainData?.close()
|
||||
await postgres?.stop()
|
||||
})
|
||||
let started = false
|
||||
export async function claimedDelivery(
|
||||
data: DataSource,
|
||||
container: Container,
|
||||
scope: ProjectScope,
|
||||
runId: string,
|
||||
extra: Partial<JobContext> = {},
|
||||
change?: ClaimChange,
|
||||
) {
|
||||
currentData = data
|
||||
if (!started) {
|
||||
// Source forwarding is needed because a file exercises several independent tenancy fixtures.
|
||||
const source = new Proxy({} as DataSource, {
|
||||
get(_target, property) {
|
||||
const value = Reflect.get(currentData, property)
|
||||
return typeof value === "function" ? value.bind(currentData) : value
|
||||
},
|
||||
})
|
||||
workers.length = 0
|
||||
const worker = createWorker({
|
||||
registry,
|
||||
boss,
|
||||
claimGuard: guard,
|
||||
dataSource: source,
|
||||
container: new Container(),
|
||||
relay: { enabled: false },
|
||||
})
|
||||
workers.push(worker)
|
||||
await worker.start()
|
||||
started = true
|
||||
}
|
||||
// Mirror the actual adapter scope into the app's independently bound scope port.
|
||||
const base = extra.container ?? container
|
||||
const child = base.child()
|
||||
child.value(AGENT_SCOPE, { ...base.resolve(AGENT_SCOPE), actor: systemActor(RUN_JOB) })
|
||||
const delivery = newId()
|
||||
const settled = new Promise<void>((resolve, reject) =>
|
||||
pending.set(delivery, { container: child, extra, ...(change ? { change } : {}), resolve, reject }),
|
||||
)
|
||||
await createPgBossJobQueue({ boss }).enqueue(
|
||||
change === "owner" ? { ...scope, actor: { userId: newId() } } : scope,
|
||||
RUN_JOB,
|
||||
{ runId, delivery },
|
||||
)
|
||||
return settled
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Disposable local PostgreSQL for the app's actual claim tests. No SDK-private test code.
|
||||
import { execFileSync } from "node:child_process"
|
||||
import type { AnyEntityDef } from "@conusai-kit/data"
|
||||
import { readerMigration, runMigrationsFromEnv, schemaMigration } from "@conusai-kit/data-kysely"
|
||||
import { Pool } from "pg"
|
||||
|
||||
export interface Postgres {
|
||||
ownerUrl: string
|
||||
runtimeUrl: string
|
||||
readerUrl: string
|
||||
stop(): Promise<void>
|
||||
}
|
||||
const docker = (...args: string[]) =>
|
||||
execFileSync("docker", args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }).trim()
|
||||
|
||||
export async function startPostgres(): Promise<Postgres> {
|
||||
const name = `agent-claim-${crypto.randomUUID()}`
|
||||
const container = docker(
|
||||
"run",
|
||||
"--detach",
|
||||
"--rm",
|
||||
"--name",
|
||||
name,
|
||||
"--label",
|
||||
"conusai.test=app-agent-claim",
|
||||
"--publish",
|
||||
"127.0.0.1::5432",
|
||||
"--env",
|
||||
"POSTGRES_USER=owner",
|
||||
"--env",
|
||||
"POSTGRES_PASSWORD=owner",
|
||||
"--env",
|
||||
"POSTGRES_DB=agent",
|
||||
"pgvector/pgvector:pg18",
|
||||
)
|
||||
const stop = async () => {
|
||||
docker("rm", "--force", "--volumes", container)
|
||||
}
|
||||
let owner: Pool | undefined
|
||||
try {
|
||||
// Docker Desktop can return before the random published port is visible. An empty
|
||||
// port makes pg silently use 5432, so wait for the actual mapping before connecting.
|
||||
const mappingDeadline = Date.now() + 10_000
|
||||
let port = ""
|
||||
while (!/^[1-9][0-9]*$/.test(port)) {
|
||||
port = docker("port", container, "5432/tcp").split(":").at(-1) ?? ""
|
||||
if (/^[1-9][0-9]*$/.test(port)) break
|
||||
if (Date.now() >= mappingDeadline) throw new Error("Postgres test port was not published")
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
const ownerUrl = `postgresql://owner:owner@127.0.0.1:${port}/agent`
|
||||
owner = new Pool({ connectionString: ownerUrl, max: 1, connectionTimeoutMillis: 1000 })
|
||||
const deadline = Date.now() + 60_000
|
||||
while (true) {
|
||||
try {
|
||||
await owner.query("select 1")
|
||||
break
|
||||
} catch (error) {
|
||||
if (Date.now() >= deadline) throw error
|
||||
await new Promise((resolve) => setTimeout(resolve, 100))
|
||||
}
|
||||
}
|
||||
await owner.query("create role app_runtime login password 'runtime' nosuperuser nobypassrls")
|
||||
await owner.query("create role app_reader login password 'reader' nosuperuser nobypassrls")
|
||||
return {
|
||||
ownerUrl,
|
||||
runtimeUrl: ownerUrl.replace("owner:owner@", "app_runtime:runtime@"),
|
||||
readerUrl: ownerUrl.replace("owner:owner@", "app_reader:reader@"),
|
||||
stop,
|
||||
}
|
||||
} catch (error) {
|
||||
await stop()
|
||||
throw error
|
||||
} finally {
|
||||
await owner?.end()
|
||||
}
|
||||
}
|
||||
|
||||
export async function migrate(postgres: Postgres, entities: readonly AnyEntityDef[]) {
|
||||
await runMigrationsFromEnv({ POSTGRES_MIGRATE_URL: postgres.ownerUrl }, [
|
||||
schemaMigration(entities),
|
||||
readerMigration("app_reader", []),
|
||||
])
|
||||
const owner = new Pool({ connectionString: postgres.ownerUrl, max: 1 })
|
||||
try {
|
||||
await owner.query("grant usage on schema public to app_runtime, app_reader")
|
||||
await owner.query("grant select, insert, update, delete on all tables in schema public to app_runtime")
|
||||
} finally {
|
||||
await owner.end()
|
||||
}
|
||||
}
|
||||
|
||||
export async function grantPgBoss(postgres: Postgres) {
|
||||
const owner = new Pool({ connectionString: postgres.ownerUrl, max: 1 })
|
||||
try {
|
||||
await owner.query("grant usage on schema pgboss to app_runtime")
|
||||
await owner.query("grant select, insert, update, delete on all tables in schema pgboss to app_runtime")
|
||||
await owner.query("grant execute on all functions in schema pgboss to app_runtime")
|
||||
} finally {
|
||||
await owner.end()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// The module's messages: en and lt declare the same keys, or a page in one language breaks.
|
||||
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { tenancyModule } from "@conusai-kit/tenancy"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { agentModule } from "../index.js"
|
||||
|
||||
describe("the agent module's messages", () => {
|
||||
const catalogues = buildRegistry([tenancyModule, agentModule]).catalogues
|
||||
|
||||
it("declares en and lt, and every en key resolves in lt", () => {
|
||||
const en = catalogues.en?.agent ?? {}
|
||||
const lt = catalogues.lt?.agent ?? {}
|
||||
const flatten = (tree: Record<string, unknown>, prefix = ""): string[] =>
|
||||
Object.entries(tree).flatMap(([key, value]) =>
|
||||
typeof value === "string" ? [`${prefix}${key}`] : flatten(value as Record<string, unknown>, `${prefix}${key}.`),
|
||||
)
|
||||
const enKeys = flatten(en)
|
||||
const ltKeys = new Set(flatten(lt))
|
||||
expect(enKeys.length).toBeGreaterThan(0)
|
||||
for (const key of enKeys) expect(ltKeys.has(key), `agent.${key} has no Lithuanian message`).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,51 @@
|
||||
// What the generated tenant-isolation suite needs of this module: seeds and one fixture per
|
||||
// tenant-scoped procedure, built from the OTHER tenant's ids.
|
||||
|
||||
import type { TransportCaller } from "@conusai-kit/core"
|
||||
import type { IsolationFixture, IsolationTenant } from "@conusai-kit/testing"
|
||||
|
||||
export interface ModuleIsolation {
|
||||
seed(
|
||||
call: TransportCaller,
|
||||
inProject: { org: string; workspace: string; project: string },
|
||||
): Promise<Record<string, string>>
|
||||
fixtures: Record<string, IsolationFixture>
|
||||
}
|
||||
|
||||
const NOWHERE = "00000000-0000-0000-0000-000000000000"
|
||||
const inWorkspaceOf = (t: IsolationTenant) => ({ org: t.orgSlug, workspace: t.workspaceSlug })
|
||||
const inProjectOf = (t: IsolationTenant) => ({ ...inWorkspaceOf(t), project: t.projectSlug })
|
||||
const runOf = (t: IsolationTenant) => t.extra.agentRunId ?? NOWHERE
|
||||
|
||||
const PLAN = [{ key: "read", label: "Read", tool: "documents.read", args: {} }]
|
||||
|
||||
export const isolation: ModuleIsolation = {
|
||||
async seed(call, inProject) {
|
||||
const started = (await call.callByName("agent.run.start", { ...inProject, goal: "Seed run", plan: PLAN })) as {
|
||||
runId: string
|
||||
}
|
||||
return { agentRunId: started.runId }
|
||||
},
|
||||
fixtures: {
|
||||
"agent.run.start": { input: (t) => ({ ...inProjectOf(t), goal: "Intruder", plan: PLAN }) },
|
||||
"agent.run.status": { input: (t) => ({ ...inProjectOf(t), runId: runOf(t) }) },
|
||||
"agent.run.events": { input: (t) => ({ ...inProjectOf(t), runId: runOf(t) }) },
|
||||
"agent.run.cancel": { input: (t) => ({ ...inProjectOf(t), runId: runOf(t) }) },
|
||||
"agent.run.list": { input: (t) => inProjectOf(t) },
|
||||
// The live collection's read-back: another tenant's run id answers nothing.
|
||||
"agent.run.getMany": { input: (t) => ({ ...inProjectOf(t), ids: [runOf(t)] }) },
|
||||
"agent.approval.list": { input: (t) => inProjectOf(t) },
|
||||
"agent.approval.review": {
|
||||
input: (t) => ({ ...inProjectOf(t), runId: runOf(t), stepKey: "read", decision: "approve" }),
|
||||
},
|
||||
// The conversation behind the Agent in the right panel (kit-agent's assistant module, mounted by this one).
|
||||
"assistant.turn.send": { input: (t) => ({ ...inProjectOf(t), turnId: NOWHERE, message: "Intruder" }) },
|
||||
"assistant.conversation.list": { input: (t) => inProjectOf(t) },
|
||||
"assistant.rootAction.reconcile": { input: (t) => ({ ...inProjectOf(t), conversationId: NOWHERE }) },
|
||||
"assistant.conversation.get": { input: (t) => ({ ...inProjectOf(t), conversationId: NOWHERE }) },
|
||||
"assistant.receipt.list": { input: (t) => inProjectOf(t) },
|
||||
// The Agent's own workspace reads, offered to a turn with no project open.
|
||||
"assistant.workspace.projects": { input: (t) => inWorkspaceOf(t) },
|
||||
"assistant.workspace.openProject": { input: (t) => ({ ...inWorkspaceOf(t), slug: t.projectSlug }) },
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,215 @@
|
||||
// The run page's live island: what it shows from the server's seed before the first event, and
|
||||
// from the run's AG-UI events (the projection `agent.run.events` serves, folded by the TanStack AI
|
||||
// client's reducer) after it — step states, a parked approval, the completed run's answer with its
|
||||
// Art. 50 disclosure, and a lost stream. Server-rendered, the island is exactly the seed.
|
||||
|
||||
import type { AgentRunRecord, AgentStepRecord } from "@conusai-kit/agent"
|
||||
import { runEventsOf } from "@conusai-kit/agent/agui"
|
||||
import { type AgUiChunk, initialAgentRunView, reduceAgentRunEvent } from "@conusai-kit/agent/client"
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { createI18n } from "@conusai-kit/i18n"
|
||||
import { createDataClient, DataProvider } from "@conusai-kit/ui/data"
|
||||
import { renderToStaticMarkup } from "react-dom/server"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { appModules } from "../../index.js"
|
||||
import type { RunView } from "../contract/index.js"
|
||||
import { LiveRun } from "../ui/live-run.js"
|
||||
import { liveRunState } from "../ui/live-run-state.js"
|
||||
|
||||
const RUN = "01a19940-0000-7000-0000-000000000001"
|
||||
const PLAN = [
|
||||
{ key: "read", label: "Read", tool: "documents.read", args: {} },
|
||||
{ key: "order", label: "Order", tool: "orders.create", args: { total: 4200 }, needsApproval: true },
|
||||
]
|
||||
|
||||
const seed: RunView = {
|
||||
runId: RUN,
|
||||
goal: "Order the parts",
|
||||
status: "queued",
|
||||
failureCode: null,
|
||||
failureDetail: null,
|
||||
plan: PLAN.map((step) => ({
|
||||
...step,
|
||||
needsApproval: step.needsApproval === true,
|
||||
status: "pending" as const,
|
||||
attempts: 0,
|
||||
error: null,
|
||||
})),
|
||||
usageCostMicrocents: 0,
|
||||
usageSteps: 0,
|
||||
percent: 0,
|
||||
cancelRequested: false,
|
||||
answer: null,
|
||||
answerProvenance: null,
|
||||
actorId: "user-1",
|
||||
createdAt: "2026-11-02T10:00:00.000Z",
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
}
|
||||
|
||||
const record = (status: AgentRunRecord["status"]): AgentRunRecord => ({
|
||||
id: RUN,
|
||||
version: 1,
|
||||
status,
|
||||
failureCode: null,
|
||||
failureDetail: null,
|
||||
goal: seed.goal,
|
||||
plan: PLAN,
|
||||
actorId: "user-1",
|
||||
budget: { maxCostMicrocents: 1_000_000, maxSteps: 10, maxDurationSeconds: 600 },
|
||||
usageCostMicrocents: 0,
|
||||
usageSteps: 0,
|
||||
cancelRequestedAt: null,
|
||||
startedAt: "2026-11-02T10:00:01.000Z",
|
||||
finishedAt: null,
|
||||
createdAt: seed.createdAt,
|
||||
answerProvenance: null,
|
||||
})
|
||||
|
||||
const step = (
|
||||
index: number,
|
||||
status: AgentStepRecord["status"],
|
||||
approval: AgentStepRecord["approval"] = null,
|
||||
): AgentStepRecord => ({
|
||||
id: `s-${index}`,
|
||||
runId: RUN,
|
||||
key: PLAN[index]?.key ?? "missing",
|
||||
index,
|
||||
label: PLAN[index]?.label ?? "missing",
|
||||
status,
|
||||
attempts: 1,
|
||||
usageCostMicrocents: 0,
|
||||
usageTokens: 0,
|
||||
error: null,
|
||||
externalEffect: false,
|
||||
artifacts: [],
|
||||
result: null,
|
||||
approval,
|
||||
startedAt: null,
|
||||
finishedAt: null,
|
||||
})
|
||||
|
||||
const parked = {
|
||||
actionId: null,
|
||||
tool: "orders.create",
|
||||
arguments: { total: 4200 },
|
||||
reviewUrl: null,
|
||||
interrupt: { id: "approval_c-1", runId: `${RUN}.chat` },
|
||||
decision: null,
|
||||
}
|
||||
|
||||
const fold = (events: readonly unknown[]) =>
|
||||
(events as AgUiChunk[]).reduce(reduceAgentRunEvent, initialAgentRunView(RUN))
|
||||
|
||||
describe("the live run state", () => {
|
||||
it("is the seed until the first event", () => {
|
||||
const state = liveRunState(seed, initialAgentRunView(RUN))
|
||||
expect(state.status).toBe("queued")
|
||||
expect(state.steps.map((s) => s.status)).toEqual(["pending", "pending"])
|
||||
expect(state.answer).toBeNull()
|
||||
})
|
||||
|
||||
it("follows the run's events: a committed step, then a parked approval", () => {
|
||||
const view = fold(runEventsOf(record("awaiting_approval"), [step(0, "committed"), step(1, "pending", parked)]))
|
||||
const state = liveRunState(seed, view)
|
||||
expect(state.status).toBe("awaiting_approval")
|
||||
expect(state.steps.map((s) => s.status)).toEqual(["committed", "pending"])
|
||||
expect(state.percent).toBe(0.5)
|
||||
})
|
||||
|
||||
it("shows a completed run's answer and a failed run's code", () => {
|
||||
const done = fold(
|
||||
runEventsOf(
|
||||
record("completed"),
|
||||
[step(0, "committed"), step(1, "committed", { ...parked, decision: "approved" })],
|
||||
"Ordered.",
|
||||
),
|
||||
)
|
||||
expect(liveRunState(seed, done)).toMatchObject({ status: "completed", percent: 1, answer: "Ordered." })
|
||||
const failed = fold(
|
||||
runEventsOf(
|
||||
{ ...record("failed"), failureCode: "budget_exhausted", failureDetail: "the run reached its budget" },
|
||||
[step(0, "committed")],
|
||||
),
|
||||
)
|
||||
expect(liveRunState(seed, failed)).toMatchObject({
|
||||
status: "failed",
|
||||
failureCode: "budget_exhausted",
|
||||
answer: null,
|
||||
})
|
||||
})
|
||||
|
||||
it("names a lost stream and keeps the last state it knew", () => {
|
||||
const view = {
|
||||
...fold(runEventsOf(record("running"), [step(0, "committed")])),
|
||||
error: { source: "transport" as const, code: "reconnect_limit", message: "gone", status: null },
|
||||
}
|
||||
const state = liveRunState(seed, view)
|
||||
expect(state.streamError).toBe("reconnect_limit")
|
||||
expect(state.steps[0]?.status).toBe("committed")
|
||||
})
|
||||
})
|
||||
|
||||
describe("the live run island", () => {
|
||||
const catalogues = buildRegistry([...appModules]).catalogues
|
||||
const i18n = createI18n("en", { en: { agent: catalogues.en?.agent ?? {} } })
|
||||
const t = (key: string) => i18n.message(`agent.${key}`)
|
||||
const labels = {
|
||||
status: t("runs.status"),
|
||||
progress: t("runs.progress"),
|
||||
plan: t("runs.plan"),
|
||||
step: t("runs.step"),
|
||||
tool: t("runs.tool"),
|
||||
needsApproval: t("runs.needsApproval"),
|
||||
answer: t("runs.answer"),
|
||||
answerDisclosure: t("runs.answerDisclosure"),
|
||||
streamLost: t("runs.streamLost"),
|
||||
statuses: {
|
||||
queued: t("runs.statusQueued"),
|
||||
running: t("runs.statusRunning"),
|
||||
awaiting_approval: t("runs.statusAwaitingApproval"),
|
||||
completed: t("runs.statusCompleted"),
|
||||
failed: t("runs.statusFailed"),
|
||||
cancelled: t("runs.statusCancelled"),
|
||||
uncertain: t("runs.statusUncertain"),
|
||||
},
|
||||
stepStatuses: {
|
||||
pending: t("runs.stepPending"),
|
||||
committed: t("runs.stepCommitted"),
|
||||
failed: t("runs.stepFailed"),
|
||||
cancelled: t("runs.stepCancelled"),
|
||||
uncertain: t("runs.stepUncertain"),
|
||||
},
|
||||
controls: {
|
||||
cancel: t("runs.cancel"),
|
||||
cancelling: t("runs.cancelling"),
|
||||
failedHint: t("recovery.failedHint"),
|
||||
cancelledHint: t("recovery.cancelledHint"),
|
||||
uncertainHint: t("recovery.uncertainHint"),
|
||||
budgetExhausted: t("recovery.budgetExhausted"),
|
||||
approvalUnavailable: t("recovery.approvalUnavailable"),
|
||||
toolError: t("recovery.toolError"),
|
||||
planRefused: t("recovery.planRefused"),
|
||||
internal: t("recovery.internal"),
|
||||
},
|
||||
}
|
||||
const render = (run: RunView) =>
|
||||
renderToStaticMarkup(
|
||||
<DataProvider client={createDataClient({ fetch: async () => new Response(null, { status: 501 }) })}>
|
||||
<LiveRun slugs={{ org: "acme", workspace: "ws", project: "p" }} seed={run} labels={labels} />
|
||||
</DataProvider>,
|
||||
)
|
||||
|
||||
it("renders the seed on the server, the approval mark on a gated step, and no answer yet", () => {
|
||||
const html = render(seed)
|
||||
expect(html).toContain(labels.statuses.queued)
|
||||
expect(html).toContain(labels.needsApproval)
|
||||
expect(html).not.toContain(labels.answer)
|
||||
})
|
||||
|
||||
it("renders a completed run's answer with the AI disclosure", () => {
|
||||
const html = render({ ...seed, status: "completed", answer: "Ordered the parts." })
|
||||
expect(html).toContain("Ordered the parts.")
|
||||
expect(html).toContain(labels.answerDisclosure.replaceAll("'", "'"))
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,59 @@
|
||||
// The module's migrations are its own files, each exactly `schemaStatements()` over the entities it
|
||||
// added (decision AT-D20: the entity definition is the one source of a column). Forward-only: the
|
||||
// first file stays the run and step tables as first shipped; the second adds the step's recorded
|
||||
// tool result and the run's conversation table (`agent_thread`, TanStack AI's message store); the
|
||||
// third adds the run answer's provenance (EU AI Act Art. 50(2)).
|
||||
|
||||
import { readFileSync } from "node:fs"
|
||||
import { AGENT_ENTITIES, agentRuns, agentSteps, agentThreads } from "@conusai-kit/agent"
|
||||
import { schemaStatements } from "@conusai-kit/data-kysely"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { agentModule } from "../index.js"
|
||||
|
||||
function tablesOf(entities: Parameters<typeof schemaStatements>[0]): string {
|
||||
const kernel = schemaStatements([]).length
|
||||
return `${schemaStatements(entities)
|
||||
.slice(kernel)
|
||||
.map((statement) => `${statement};`)
|
||||
.join("\n\n")}\n`
|
||||
}
|
||||
|
||||
const migration = (name: string) => readFileSync(new URL(`../migrations/${name}`, import.meta.url), "utf8")
|
||||
const HEADER = "-- SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0\n"
|
||||
|
||||
describe("the migrations of the agent module", () => {
|
||||
it("are the run and step tables as first shipped, then the step's result column", () => {
|
||||
const added = ',\n "result" jsonb\n'
|
||||
const provenance = ',\n "answer_provenance" jsonb\n'
|
||||
const generated = tablesOf([agentRuns, agentSteps])
|
||||
expect(generated).toContain(added)
|
||||
expect(generated).toContain(provenance)
|
||||
expect(`${HEADER}${generated.replace(added, "\n").replace(provenance, "\n")}`).toBe(migration("0001_agent.sql"))
|
||||
expect(migration("0002_agent_thread.sql")).toContain(
|
||||
`${HEADER}alter table "agent_step" add column "result" jsonb;\n\n`,
|
||||
)
|
||||
})
|
||||
|
||||
it("add the run's conversation table in the second file, exactly its table", async () => {
|
||||
await expect(
|
||||
`${HEADER}alter table "agent_step" add column "result" jsonb;\n\n${tablesOf([agentThreads])}`,
|
||||
).toMatchFileSnapshot("../migrations/0002_agent_thread.sql")
|
||||
expect(AGENT_ENTITIES).toEqual([agentRuns, agentSteps, agentThreads])
|
||||
})
|
||||
|
||||
it("add the run answer's provenance column in the third file, exactly that column", () => {
|
||||
expect(tablesOf([agentRuns])).toContain(',\n "answer_provenance" jsonb\n')
|
||||
expect(migration("0003_agent_answer_provenance.sql")).toBe(
|
||||
`${HEADER}alter table "agent_run" add column "answer_provenance" jsonb;\n`,
|
||||
)
|
||||
})
|
||||
|
||||
it("are declared by the manifest, and every table is row-level secured", () => {
|
||||
expect(agentModule.migrations).toEqual({ postgres: "migrations" })
|
||||
const sql = migration("0001_agent.sql") + migration("0002_agent_thread.sql")
|
||||
for (const table of ["agent_run", "agent_step", "agent_thread"]) {
|
||||
expect(sql).toContain(`create table "${table}"`)
|
||||
expect(sql).toContain(`alter table "${table}" force row level security`)
|
||||
}
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,156 @@
|
||||
// The agent page on F10: its loader reads through the caller and returns plain data with every label
|
||||
// translated, and its view renders what reached the browser, the run page's first paint being the seed of
|
||||
// the live island. A path that is not one run is the kernel's 404, which is what the shell answers.
|
||||
|
||||
import { AppError, buildRegistry, Container, forbidden, type TransportCaller } from "@conusai-kit/core"
|
||||
import { builtInCatalogues, createI18n, uiLabels } from "@conusai-kit/i18n"
|
||||
import { createDataClient, DataProvider } from "@conusai-kit/ui/data"
|
||||
import { UiLabelsProvider } from "@conusai-kit/ui/primitives"
|
||||
import { renderToStaticMarkup as renderMarkup } from "react-dom/server"
|
||||
import { describe, expect, it, vi } from "vitest"
|
||||
import { appModules } from "../../index.js"
|
||||
import { modulePages } from "../../pages.js"
|
||||
import type { ApprovalView, RunView } from "../contract/index.js"
|
||||
import { agentPage } from "../ui/pages.js"
|
||||
|
||||
const kitStrings = uiLabels(createI18n("en", builtInCatalogues))
|
||||
const renderToStaticMarkup = (node: React.ReactNode) =>
|
||||
renderMarkup(<UiLabelsProvider labels={kitStrings}>{node}</UiLabelsProvider>)
|
||||
|
||||
const catalogues = buildRegistry([...appModules]).catalogues
|
||||
const i18n = createI18n("en", {
|
||||
en: { agent: catalogues.en?.agent ?? {}, app: { failed: "That did not work: {detail}" } },
|
||||
})
|
||||
const slugs = { org: "acme", workspace: "ws", project: "p" }
|
||||
|
||||
const run: RunView = {
|
||||
runId: "01a19940-0000-7000-0000-000000000001",
|
||||
goal: "Order the parts",
|
||||
status: "running",
|
||||
failureCode: null,
|
||||
failureDetail: null,
|
||||
plan: [
|
||||
{
|
||||
key: "read",
|
||||
label: "Read",
|
||||
tool: "documents.read",
|
||||
args: {},
|
||||
needsApproval: false,
|
||||
status: "pending",
|
||||
attempts: 0,
|
||||
error: null,
|
||||
},
|
||||
],
|
||||
usageCostMicrocents: 120,
|
||||
usageSteps: 3,
|
||||
percent: 0.5,
|
||||
cancelRequested: false,
|
||||
answer: null,
|
||||
answerProvenance: null,
|
||||
actorId: "user-1",
|
||||
createdAt: "2026-11-02T10:00:00.000Z",
|
||||
startedAt: "2026-11-02T10:00:01.000Z",
|
||||
finishedAt: null,
|
||||
}
|
||||
|
||||
const approval: ApprovalView = {
|
||||
runId: run.runId,
|
||||
stepKey: "read",
|
||||
goal: run.goal,
|
||||
tool: "orders.create",
|
||||
arguments: { total: 4200 },
|
||||
reviewUrl: null,
|
||||
createdAt: "2026-11-02T10:00:00.000Z",
|
||||
}
|
||||
|
||||
const callerOf = (answers: Record<string, unknown>, asked: string[] = []): TransportCaller =>
|
||||
({
|
||||
call: async (contract: { name: string }) => {
|
||||
asked.push(contract.name)
|
||||
const answer = answers[contract.name]
|
||||
if (answer instanceof Error) throw answer
|
||||
return answer
|
||||
},
|
||||
}) as unknown as TransportCaller
|
||||
|
||||
const load = (segments: string[], answers: Record<string, unknown>, asked?: string[]) =>
|
||||
agentPage.load({ slugs, segments, search: {}, caller: callerOf(answers, asked), i18n, container: new Container() })
|
||||
|
||||
/** The page as the shell renders it: the loader's data through JSON, as the browser receives it, then the view. */
|
||||
const render = async (segments: string[], answers: Record<string, unknown>) => {
|
||||
const data = await load(segments, answers)
|
||||
expect(JSON.parse(JSON.stringify(data))).toStrictEqual(data)
|
||||
const { View } = agentPage
|
||||
return renderToStaticMarkup(
|
||||
<DataProvider client={createDataClient({ fetch: async () => new Response(null, { status: 501 }) })}>
|
||||
<View slugs={slugs} segments={segments} locale="en" data={JSON.parse(JSON.stringify(data))} />
|
||||
</DataProvider>,
|
||||
)
|
||||
}
|
||||
|
||||
const lists = { "agent.run.list": { runs: [run] }, "agent.approval.list": { approvals: [approval] } }
|
||||
|
||||
describe("the agent page", () => {
|
||||
it("is listed for the shell", async () => {
|
||||
await expect(modulePages.agent?.()).resolves.toBe(agentPage)
|
||||
})
|
||||
|
||||
it("lists the runs, each with its status in words, its progress and a link to it", async () => {
|
||||
const html = await render([], lists)
|
||||
const link = html.match(/<a\b[^>]*>Order the parts<\/a>/)?.[0] ?? ""
|
||||
expect(link.match(/class="([^"]*)"/)?.[1]?.split(/\s+/)).toContain("kit-row-link")
|
||||
expect(link).toContain(`href="/acme/ws/p/agent/${run.runId}"`)
|
||||
expect(link).toContain('data-slot="row-link"')
|
||||
expect(html).toContain('<progress value="0.5" max="1">50%</progress>')
|
||||
expect(html).toContain("user-1")
|
||||
expect(html).toContain("orders.create")
|
||||
})
|
||||
|
||||
it("says when there is no run", async () => {
|
||||
const data = await load([], { "agent.run.list": { runs: [] }, "agent.approval.list": { approvals: [] } })
|
||||
if (data.kind !== "list") throw new Error("not the list")
|
||||
expect(data.runs).toEqual([])
|
||||
expect(await render([], { ...lists, "agent.run.list": { runs: [] } })).toContain(data.runLabels.empty)
|
||||
})
|
||||
|
||||
it("renders one run from its status, the seed of the live island", async () => {
|
||||
const asked: string[] = []
|
||||
const html = await render([run.runId], { "agent.run.status": run })
|
||||
await load([run.runId], { "agent.run.status": run }, asked)
|
||||
expect(asked).toEqual(["agent.run.status"])
|
||||
expect(html).toContain(">Order the parts</h1>")
|
||||
expect(html).toContain('href="/acme/ws/p/agent"')
|
||||
expect(html).toContain("<dt>Usage</dt><dd>3 steps, 120 microcents</dd>")
|
||||
expect(html).toContain('data-live="agent.run"')
|
||||
})
|
||||
|
||||
it("answers a path that is not one run with the kernel's 404, without reading", async () => {
|
||||
const asked: string[] = []
|
||||
const refused = load([run.runId, "extra"], lists, asked)
|
||||
await expect(refused).rejects.toBeInstanceOf(AppError)
|
||||
await expect(refused).rejects.toMatchObject({ status: 404 })
|
||||
expect(asked).toEqual([])
|
||||
})
|
||||
|
||||
it("hands a procedure's refusal on, as the AppError the shell answers", async () => {
|
||||
const call = vi.fn(async () => {
|
||||
throw forbidden("this page needs agent.run.read")
|
||||
})
|
||||
await expect(
|
||||
agentPage.load({
|
||||
slugs,
|
||||
segments: [],
|
||||
search: {},
|
||||
caller: { call } as unknown as TransportCaller,
|
||||
i18n,
|
||||
container: new Container(),
|
||||
}),
|
||||
).rejects.toMatchObject({ status: 403 })
|
||||
})
|
||||
})
|
||||
|
||||
// This journey requires explicit optional-module admission.
|
||||
vi.mock("../../inclusion.js", async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import("../../inclusion.js")>()),
|
||||
...(await import("../../../test/support/native-readiness.js")).selectedModules(["agent"]),
|
||||
}))
|
||||
@@ -0,0 +1,35 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Follow the offered step using a public gateway adapter with an in-process transport.
|
||||
// No network/provider call and no SDK-private or transitive TanStack testing import.
|
||||
import { conusaiText } from "@conusai-sdk/ai/tanstack"
|
||||
|
||||
export function planFollower() {
|
||||
return conusaiText("scripted-agent-test", {
|
||||
apiUrl: "https://gateway.example.invalid/api",
|
||||
token: "synthetic-test-token",
|
||||
maxRetries: 0,
|
||||
fetch: async (_input, init) => {
|
||||
const body = JSON.parse(String(init?.body ?? "{}")) as { tool_choice?: { function?: { name?: string } } }
|
||||
const step = body.tool_choice?.function?.name
|
||||
const delta =
|
||||
step === undefined
|
||||
? { role: "assistant", content: "Done." }
|
||||
: {
|
||||
role: "assistant",
|
||||
tool_calls: [
|
||||
{ index: 0, id: `call-${step}`, type: "function", function: { name: step, arguments: "{}" } },
|
||||
],
|
||||
}
|
||||
const chunk = {
|
||||
id: "synthetic-completion",
|
||||
object: "chat.completion.chunk",
|
||||
created: 1,
|
||||
model: "scripted-agent-test",
|
||||
choices: [{ index: 0, delta, finish_reason: step === undefined ? "stop" : "tool_calls" }],
|
||||
}
|
||||
return new Response(`data: ${JSON.stringify(chunk)}\n\ndata: [DONE]\n\n`, {
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
// 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",
|
||||
},
|
||||
])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,313 @@
|
||||
// The run job over the real tools adapter, MCP client and gateway adapter; only the platform is
|
||||
// answered, at `fetch`. What it pins is the line `StepOutcome` draws: a call a retry cannot fix ends
|
||||
// the run with a typed failure and the job completes, so the queue does not run it again, while a
|
||||
// transient failure rejects, so the queue does, and the step resumes on its idempotency key. A tool
|
||||
// that answers an error is never a committed step: the model would otherwise read the error as the
|
||||
// tool's result and the run would end `completed`.
|
||||
|
||||
import { AGENT_MODEL, AGENT_TOOLS, dataRunStore } from "@conusai-kit/agent"
|
||||
import type { JobContext } from "@conusai-kit/core"
|
||||
import { createMemoryDataSource, DATA_SOURCE } from "@conusai-kit/data"
|
||||
import { afterEach, describe, expect, it, vi } from "vitest"
|
||||
import { gatewayAgentModel, registryAgentTools, runJob } from "../adapters/procedures.js"
|
||||
import { AGENT_SCOPE } from "../core/ports.js"
|
||||
import { cancelRun, startRun } from "../core/runs.js"
|
||||
import { claimedDataSource, claimedDelivery } from "./claimed-job.js"
|
||||
import { workerFixture } from "./worker-fixture.js"
|
||||
|
||||
const API_URL = "https://cloud.example.test/api"
|
||||
const MCP_URL = "https://cloud.example.test/v1/orgs/7/mcp"
|
||||
const GATEWAY_URL = `${API_URL}/ai/v1/chat/completions`
|
||||
const TOKEN = "dt_synthetic_fixture"
|
||||
const TOOL_TEXT = "the backing service said something only its tenant may read"
|
||||
|
||||
interface ToolCall {
|
||||
readonly name: string
|
||||
readonly idempotencyKey: string
|
||||
readonly authorization: string | null
|
||||
}
|
||||
type Mcp = (id: number) => Response
|
||||
|
||||
const rpc =
|
||||
(result: unknown): Mcp =>
|
||||
(id) =>
|
||||
Response.json({ jsonrpc: "2.0", id, result })
|
||||
const answered = rpc({
|
||||
resultType: "complete",
|
||||
isError: false,
|
||||
structuredContent: { rows: 3 },
|
||||
content: [{ type: "text", text: '{"rows":3}' }],
|
||||
})
|
||||
const denied = (reason: string) =>
|
||||
rpc({
|
||||
resultType: "complete",
|
||||
isError: true,
|
||||
structuredContent: { status: "denied", reason },
|
||||
content: [{ type: "text", text: `refused: ${reason}` }],
|
||||
})
|
||||
const status =
|
||||
(code: number): Mcp =>
|
||||
() =>
|
||||
new Response(null, { status: code })
|
||||
|
||||
/** The gateway as a model that follows the tool choice: it calls the step offered, then answers. */
|
||||
function gatewayReply(body: { tool_choice?: unknown }): Response {
|
||||
const choice = body.tool_choice
|
||||
const step =
|
||||
typeof choice === "object" && choice !== null
|
||||
? (choice as { function?: { name?: string } }).function?.name
|
||||
: undefined
|
||||
const delta =
|
||||
step === undefined
|
||||
? { role: "assistant", content: "Done." }
|
||||
: {
|
||||
role: "assistant",
|
||||
tool_calls: [{ index: 0, id: `call-${step}`, type: "function", function: { name: step, arguments: "{}" } }],
|
||||
}
|
||||
const chunk = {
|
||||
id: "chatcmpl-1",
|
||||
object: "chat.completion.chunk",
|
||||
created: 1,
|
||||
model: "gateway-model",
|
||||
choices: [{ index: 0, delta, finish_reason: step === undefined ? "stop" : "tool_calls" }],
|
||||
}
|
||||
return new Response(`data: ${JSON.stringify(chunk)}\n\ndata: [DONE]\n\n`, {
|
||||
status: 200,
|
||||
headers: { "content-type": "text/event-stream" },
|
||||
})
|
||||
}
|
||||
|
||||
/** A queued run of one step, and a platform whose tool endpoint answers with `tool.mcp`. */
|
||||
async function harness(toolName = "records__read") {
|
||||
const tool: { mcp: Mcp; calls: ToolCall[] } = { mcp: answered, calls: [] }
|
||||
vi.stubGlobal(
|
||||
"fetch",
|
||||
vi.fn(async (input: string | URL | Request, init?: RequestInit) => {
|
||||
const url = String(input)
|
||||
const body = JSON.parse(String(init?.body ?? "{}"))
|
||||
if (url === GATEWAY_URL) return gatewayReply(body)
|
||||
if (url !== MCP_URL) throw new Error(`no such stand-in: ${url}`)
|
||||
tool.calls.push({
|
||||
name: body.params.name,
|
||||
idempotencyKey: body.params._meta["com.conusai/idempotencyKey"],
|
||||
authorization: new Headers(init?.headers).get("authorization"),
|
||||
})
|
||||
return tool.mcp(body.id)
|
||||
}),
|
||||
)
|
||||
const data = await claimedDataSource()
|
||||
const { container, scope } = await workerFixture(data)
|
||||
container.value(AGENT_SCOPE, scope)
|
||||
container.value(AGENT_MODEL, gatewayAgentModel({ apiUrl: API_URL, token: TOKEN, model: "gateway-model" }))
|
||||
container.value(
|
||||
AGENT_TOOLS,
|
||||
registryAgentTools({
|
||||
apiUrl: API_URL,
|
||||
token: TOKEN,
|
||||
orgId: "7",
|
||||
scope: { orgSlug: "acme", workspaceSlug: "main", projectSlug: "p" },
|
||||
}),
|
||||
)
|
||||
const started = await startRun({ data }, scope, {
|
||||
goal: "Read the records",
|
||||
plan: [{ key: "read", label: "Read the records", tool: toolName, args: { limit: 1 } }],
|
||||
})
|
||||
const store = dataRunStore(data, scope)
|
||||
return {
|
||||
tool,
|
||||
data,
|
||||
scope,
|
||||
container,
|
||||
runId: started.runId,
|
||||
/** One delivery of the job: a rejection is what makes the queue deliver it again. */
|
||||
deliver: (extra: Partial<JobContext> = {}) => claimedDelivery(data, container, scope, started.runId, extra),
|
||||
cancel: () => cancelRun({ data }, scope, started.runId),
|
||||
run: () => store.get(started.runId),
|
||||
step: async () => (await store.listSteps(started.runId))[0],
|
||||
}
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
})
|
||||
|
||||
describe("the agent run job's tool calls", () => {
|
||||
it("calls the tool with the deployment token and completes the run", async () => {
|
||||
const job = await harness()
|
||||
await job.deliver()
|
||||
expect(job.tool.calls).toEqual([
|
||||
{ name: "records__read", idempotencyKey: `${job.runId}:read`, authorization: `Bearer ${TOKEN}` },
|
||||
])
|
||||
expect(await job.run()).toMatchObject({ status: "completed", failureCode: null })
|
||||
expect(await job.step()).toMatchObject({ status: "committed", attempts: 1, result: { rows: 3 } })
|
||||
})
|
||||
|
||||
it.each<[string, Mcp, string]>([
|
||||
["a token without the grant (403)", status(403), "forbidden"],
|
||||
["a token the endpoint does not accept (401)", status(401), "unauthenticated"],
|
||||
["a policy denial", denied("forbidden"), "forbidden"],
|
||||
["a plan denial", denied("plan-required"), "plan-required"],
|
||||
])("ends the run failed/tool_error on %s, and the queue does not run it again", async (_case, mcp, reason) => {
|
||||
const job = await harness()
|
||||
job.tool.mcp = mcp
|
||||
await expect(job.deliver()).resolves.toBeUndefined()
|
||||
const run = await job.run()
|
||||
expect(run).toMatchObject({ status: "failed", failureCode: "tool_error" })
|
||||
expect(run?.failureDetail).toContain(reason)
|
||||
expect(run?.finishedAt).not.toBeNull()
|
||||
expect(await job.step()).toMatchObject({ status: "failed", attempts: 1, result: null })
|
||||
// A redelivery reaches a terminal run: no second call.
|
||||
await job.deliver()
|
||||
expect(job.tool.calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("does not resume a refused run when the grant is approved later", async () => {
|
||||
const job = await harness()
|
||||
job.tool.mcp = status(403)
|
||||
await job.deliver()
|
||||
job.tool.mcp = answered
|
||||
await job.deliver()
|
||||
expect(await job.run()).toMatchObject({ status: "failed", failureCode: "tool_error" })
|
||||
expect(job.tool.calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("does not resume a cancelled run: a run stuck on a transient failure can be ended by a person", async () => {
|
||||
const job = await harness()
|
||||
job.tool.mcp = status(503)
|
||||
await expect(job.deliver()).rejects.toThrow()
|
||||
await job.cancel()
|
||||
job.tool.mcp = answered
|
||||
await job.deliver()
|
||||
expect(await job.run()).toMatchObject({ status: "cancelled", failureCode: null })
|
||||
await job.deliver()
|
||||
expect(job.tool.calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("does not commit a step whose tool answered an error, and keeps the tool's text out of the run", async () => {
|
||||
const job = await harness()
|
||||
job.tool.mcp = rpc({ resultType: "complete", isError: true, content: [{ type: "text", text: TOOL_TEXT }] })
|
||||
await expect(job.deliver()).resolves.toBeUndefined()
|
||||
const run = await job.run()
|
||||
expect(run).toMatchObject({ status: "failed", failureCode: "tool_error", usageSteps: 0 })
|
||||
expect(run?.failureDetail).toContain("records__read")
|
||||
expect(run?.failureDetail).not.toContain(TOOL_TEXT)
|
||||
const step = await job.step()
|
||||
expect(step).toMatchObject({ status: "failed", result: null })
|
||||
expect(step?.error).not.toContain(TOOL_TEXT)
|
||||
await job.deliver()
|
||||
expect(job.tool.calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it.each<[string, Mcp, string?]>([
|
||||
["a 503", status(503)],
|
||||
["a 429", status(429)],
|
||||
["a 500", status(500)],
|
||||
[
|
||||
"busy local compute",
|
||||
rpc({
|
||||
resultType: "complete",
|
||||
isError: true,
|
||||
structuredContent: { status: "compute_busy", retryAfterSeconds: 5 },
|
||||
}),
|
||||
],
|
||||
[
|
||||
"a catalog that is still indexing",
|
||||
rpc({ resultType: "complete", isError: true, structuredContent: { status: "indexing", retryAfterSeconds: 5 } }),
|
||||
"find_tools",
|
||||
],
|
||||
["a runtime the platform could not reach", denied("runtime-unavailable")],
|
||||
])("leaves the run to the queue on %s, and the next delivery resumes the step", async (_case, mcp, toolName) => {
|
||||
const job = await harness(toolName)
|
||||
job.tool.mcp = mcp
|
||||
await expect(job.deliver()).rejects.toThrow()
|
||||
expect(await job.run()).toMatchObject({ status: "running", failureCode: null })
|
||||
expect(await job.step()).toMatchObject({ status: "pending", attempts: 1 })
|
||||
|
||||
job.tool.mcp = answered
|
||||
await job.deliver()
|
||||
expect(await job.run()).toMatchObject({ status: "completed", failureCode: null })
|
||||
expect(await job.step()).toMatchObject({ status: "committed", attempts: 2 })
|
||||
// The same key both times: an effect the first call made is not made twice.
|
||||
expect(new Set(job.tool.calls.map((call) => call.idempotencyKey))).toEqual(new Set([`${job.runId}:read`]))
|
||||
})
|
||||
|
||||
it("leaves the run to the queue when the network fails", async () => {
|
||||
const job = await harness()
|
||||
job.tool.mcp = () => {
|
||||
throw new TypeError("fetch failed")
|
||||
}
|
||||
await expect(job.deliver()).rejects.toThrow("fetch failed")
|
||||
expect(await job.run()).toMatchObject({ status: "running", failureCode: null })
|
||||
})
|
||||
})
|
||||
|
||||
it("persists sanitized exhaustion on the actual final attempt, with no replay calls", async () => {
|
||||
const job = await harness()
|
||||
job.tool.mcp = status(503)
|
||||
await job.deliver({ finalAttempt: true })
|
||||
expect(await job.run()).toMatchObject({ status: "failed", failureCode: "internal" })
|
||||
expect((await job.run())?.failureDetail).toBe("the durable job exhausted its retry attempts")
|
||||
await job.deliver({ finalAttempt: true })
|
||||
expect(job.tool.calls).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("a revoked initiating member is refused before any provider call despite the warm memo", async () => {
|
||||
const job = await harness()
|
||||
const { orgMemberships } = await import("@conusai-kit/tenancy")
|
||||
const repos = job.data.forScope(job.scope)
|
||||
const membership = (await repos.of(orgMemberships).list({ limit: 10 })).items[0]
|
||||
if (!membership) throw new Error("expected membership")
|
||||
await repos.of(orgMemberships).delete(membership.id)
|
||||
await job.deliver()
|
||||
expect(await job.run()).toMatchObject({ status: "failed", failureCode: "internal" })
|
||||
expect(vi.mocked(fetch)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("revocation after a tool prevents the next model call", async () => {
|
||||
const job = await harness()
|
||||
const { orgMemberships } = await import("@conusai-kit/tenancy")
|
||||
const original = job.container.resolve(AGENT_TOOLS)
|
||||
const child = job.container.child()
|
||||
child.value(AGENT_TOOLS, {
|
||||
async call(...args) {
|
||||
const result = await original.call(...args)
|
||||
const repos = job.data.forScope(job.scope)
|
||||
const member = (await repos.of(orgMemberships).list({ limit: 10 })).items[0]
|
||||
if (!member) throw new Error("expected membership")
|
||||
await repos.of(orgMemberships).delete(member.id)
|
||||
return result
|
||||
},
|
||||
})
|
||||
await job.deliver({ container: child })
|
||||
expect(await job.run()).toMatchObject({ status: "failed", usageSteps: 1 })
|
||||
expect(job.tool.calls).toHaveLength(1)
|
||||
expect(vi.mocked(fetch).mock.calls.filter(([url]) => String(url) === GATEWAY_URL)).toHaveLength(1)
|
||||
})
|
||||
|
||||
it("missing adapter execution refuses before durable or provider work even at exhaustion", async () => {
|
||||
const job = await harness()
|
||||
await expect(
|
||||
runJob.handler(
|
||||
{ runId: job.runId },
|
||||
{
|
||||
container: job.container,
|
||||
correlationId: "unsupported-adapter",
|
||||
attempt: 1,
|
||||
finalAttempt: true,
|
||||
},
|
||||
),
|
||||
).rejects.toMatchObject({ code: "job_claim_lost" })
|
||||
expect((await job.run())?.status).toBe("queued")
|
||||
expect(job.tool.calls).toHaveLength(0)
|
||||
expect(vi.mocked(fetch)).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it("an actual claim with an unsupported data source hook refuses before Agent effects", async () => {
|
||||
const job = await harness()
|
||||
const child = job.container.child()
|
||||
child.value(DATA_SOURCE, createMemoryDataSource({ entities: [] }))
|
||||
await expect(job.deliver({ container: child, finalAttempt: true })).rejects.toMatchObject({ code: "job_claim_lost" })
|
||||
expect((await job.run())?.status).toBe("queued")
|
||||
expect(job.tool.calls).toHaveLength(0)
|
||||
expect(vi.mocked(fetch)).not.toHaveBeenCalled()
|
||||
})
|
||||
@@ -0,0 +1,201 @@
|
||||
// The agent module's use cases on the in-memory adapter: starting lists and statuses, cancel's
|
||||
// resume event, and the approval queue — which must surface the EXACT stored proposed arguments.
|
||||
|
||||
import { AGENT_ENTITIES, dataAgentPersistence, dataRunStore, threadIdOf } from "@conusai-kit/agent"
|
||||
import type { TenantScope } from "@conusai-kit/core"
|
||||
import { createMemoryDataSource } from "@conusai-kit/data"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { cancelRun, getManyRuns, getRun, listApprovals, listRuns, reviewApproval, startRun } from "../core/runs.js"
|
||||
|
||||
const USER = "01900000-0000-7000-0000-000000000009"
|
||||
const scope: TenantScope = {
|
||||
level: "project",
|
||||
actor: { userId: USER },
|
||||
orgId: "01900000-0000-7000-0000-000000000001",
|
||||
workspaceId: "01900000-0000-7000-0000-000000000002",
|
||||
projectId: "01900000-0000-7000-0000-000000000003",
|
||||
}
|
||||
const makeDeps = () => ({ data: createMemoryDataSource({ entities: [...AGENT_ENTITIES] }) })
|
||||
|
||||
const PLAN = [
|
||||
{ key: "read", label: "Read the document", tool: "documents.read", args: { fileId: "f-1" } },
|
||||
{ key: "propose", label: "Propose lines", tool: "documents.extract", args: { fileId: "f-1", schema: "line" } },
|
||||
]
|
||||
|
||||
describe("the agent module's run use cases", () => {
|
||||
const deps = makeDeps()
|
||||
it("starts a run queued, with the plan visible, and lists it", async () => {
|
||||
const run = await startRun(deps, scope, { goal: "Review the contract", plan: PLAN })
|
||||
expect(run.status).toBe("queued")
|
||||
expect(run.goal).toBe("Review the contract")
|
||||
expect(run.plan).toEqual([
|
||||
{
|
||||
key: "read",
|
||||
label: "Read the document",
|
||||
tool: "documents.read",
|
||||
args: { fileId: "f-1" },
|
||||
needsApproval: false,
|
||||
status: "pending",
|
||||
attempts: 0,
|
||||
error: null,
|
||||
},
|
||||
{
|
||||
key: "propose",
|
||||
label: "Propose lines",
|
||||
tool: "documents.extract",
|
||||
args: { fileId: "f-1", schema: "line" },
|
||||
needsApproval: false,
|
||||
status: "pending",
|
||||
attempts: 0,
|
||||
error: null,
|
||||
},
|
||||
])
|
||||
expect(run.percent).toBe(0)
|
||||
expect(run.answer).toBeNull()
|
||||
const listed = await listRuns(deps, scope, {})
|
||||
expect(listed.runs).toHaveLength(1)
|
||||
expect((await getRun(deps, scope, run.runId)).runId).toBe(run.runId)
|
||||
// The outbox event that enqueues the run job in the worker.
|
||||
const pending = await deps.data.outbox.pending(10)
|
||||
expect(pending.some((record) => record.name === "agent.runResumeRequested")).toBe(true)
|
||||
})
|
||||
|
||||
it("reads runs back by id for the live list, and answers nothing for another project's or no run", async () => {
|
||||
const own = makeDeps()
|
||||
const run = await startRun(own, scope, { goal: "Read back", plan: PLAN })
|
||||
const listed = (await listRuns(own, scope, {})).runs
|
||||
expect(await getManyRuns(own, scope, [run.runId, run.runId])).toEqual(listed)
|
||||
const elsewhere: TenantScope = { ...scope, projectId: "01900000-0000-7000-0000-000000000004" }
|
||||
expect(await getManyRuns(own, elsewhere, [run.runId])).toEqual([])
|
||||
expect(await getManyRuns(own, scope, ["01900000-0000-7000-0000-0000000000ff"])).toEqual([])
|
||||
})
|
||||
|
||||
it("answers a completed run's answer with the provenance it is stored with (AI Act Art. 50(2))", async () => {
|
||||
const fresh = makeDeps()
|
||||
const run = await startRun(fresh, scope, { goal: "Review the contract", plan: PLAN })
|
||||
expect(run.answerProvenance).toBeNull()
|
||||
const provenance = {
|
||||
ai_generated: true as const,
|
||||
mode: "machine-generated" as const,
|
||||
digital_source_type: "http://cv.iptc.org/newscodes/digitalsourcetype/trainedAlgorithmicMedia",
|
||||
generator: "conusai.ai-gateway",
|
||||
provider: "openai",
|
||||
model: "gpt-4o-mini-2024-07-18",
|
||||
generated_at: "2026-11-02T10:30:00Z",
|
||||
}
|
||||
const store = dataRunStore(fresh.data, scope)
|
||||
const record = await store.get(run.runId)
|
||||
await store.transition(run.runId, record?.version ?? 0, {
|
||||
status: "completed",
|
||||
finishedAt: "2026-11-02T10:30:01.000Z",
|
||||
answerProvenance: provenance,
|
||||
})
|
||||
await dataAgentPersistence(fresh.data, scope).stores.messages.saveThread(threadIdOf(run.runId), [
|
||||
{ role: "user", content: "Review the contract" },
|
||||
{ role: "assistant", content: "The contract was reviewed." },
|
||||
])
|
||||
const completed = await getRun(fresh, scope, run.runId)
|
||||
expect(completed.answer).toBe("The contract was reviewed.")
|
||||
expect(completed.answerProvenance).toEqual(provenance)
|
||||
})
|
||||
|
||||
it("refuses an over-long or repeated plan before anything is written", async () => {
|
||||
const fresh = makeDeps()
|
||||
const tooLong = await startRun(fresh, scope, {
|
||||
goal: "x",
|
||||
plan: Array.from({ length: 26 }, (_, at) => ({ key: `s${at}`, label: `Step ${at}`, tool: "t", args: {} })),
|
||||
}).catch((error: unknown) => error)
|
||||
expect((tooLong as { status?: number }).status).toBe(422)
|
||||
const repeated = await startRun(fresh, scope, {
|
||||
goal: "x",
|
||||
plan: [
|
||||
{ key: "same", label: "One", tool: "t", args: {} },
|
||||
{ key: "same", label: "Two", tool: "t", args: {} },
|
||||
],
|
||||
}).catch((error: unknown) => error)
|
||||
expect((repeated as { status?: number }).status).toBe(422)
|
||||
expect((await listRuns(fresh, scope, {})).runs).toHaveLength(0)
|
||||
})
|
||||
|
||||
it("cancel persists the request and re-enqueues the resume; a parked approval surfaces its exact arguments", async () => {
|
||||
const fresh = makeDeps()
|
||||
const run = await startRun(fresh, scope, { goal: "Approve-aware run", plan: PLAN })
|
||||
// Simulate the engine parking an approval by writing the step row as the engine would.
|
||||
const { dataRunStore } = await import("@conusai-kit/agent")
|
||||
const store = dataRunStore(fresh.data, scope)
|
||||
|
||||
await store.parkStepApproval(
|
||||
run.runId,
|
||||
{ key: "read", index: 0, label: "Read the document" },
|
||||
{
|
||||
actionId: "act-7",
|
||||
tool: "documents.read",
|
||||
arguments: { fileId: "f-1", dryRun: false },
|
||||
reviewUrl: "https://console/review?action=act-7",
|
||||
interrupt: null,
|
||||
},
|
||||
new Date().toISOString(),
|
||||
)
|
||||
await fresh.data
|
||||
.forScope(scope)
|
||||
// biome-ignore lint/suspicious/noExplicitAny: test adjusts the run's status as the engine would.
|
||||
.of(AGENT_ENTITIES[0] as any)
|
||||
.update(run.runId, { status: "awaiting_approval" }, 1)
|
||||
|
||||
const queue = await listApprovals(fresh, scope)
|
||||
expect(queue.approvals).toHaveLength(1)
|
||||
expect(queue.approvals[0]).toMatchObject({
|
||||
runId: run.runId,
|
||||
stepKey: "read",
|
||||
tool: "documents.read",
|
||||
arguments: { fileId: "f-1", dryRun: false },
|
||||
reviewUrl: "https://console/review?action=act-7",
|
||||
})
|
||||
|
||||
const cancelled = await cancelRun(fresh, scope, run.runId)
|
||||
expect(cancelled.cancelled).toBe(true)
|
||||
expect((await getRun(fresh, scope, run.runId)).cancelRequested).toBe(true)
|
||||
// The cancel re-published the resume event: a parked run notices the request only when resumed.
|
||||
const pending = await fresh.data.outbox.pending(10)
|
||||
expect(pending.filter((record) => record.name === "agent.runResumeRequested")).toHaveLength(2)
|
||||
|
||||
const review = await reviewApproval(fresh, scope, { runId: run.runId, stepKey: "read", decision: "approve" })
|
||||
expect(["queued", "running", "awaiting_approval", "cancelled"]).toContain(review.status)
|
||||
const bad = await reviewApproval(fresh, scope, { runId: run.runId, stepKey: "propose", decision: "approve" }).catch(
|
||||
(error: unknown) => error,
|
||||
)
|
||||
expect((bad as { status?: number }).status).toBe(422)
|
||||
// The decision is recorded on the step: the queue no longer offers it, and it is not decided twice.
|
||||
expect((await listApprovals(fresh, scope)).approvals).toHaveLength(0)
|
||||
const again = await reviewApproval(fresh, scope, { runId: run.runId, stepKey: "read", decision: "reject" }).catch(
|
||||
(error: unknown) => error,
|
||||
)
|
||||
expect((again as { status?: number }).status).toBe(409)
|
||||
expect((await store.listSteps(run.runId))[0]?.approval?.decision).toBe("approved")
|
||||
})
|
||||
})
|
||||
|
||||
it("does not offer or accept a pending approval after its run failed", async () => {
|
||||
const fresh = makeDeps()
|
||||
const run = await startRun(fresh, scope, { goal: "Synthetic approval", plan: PLAN })
|
||||
const store = dataRunStore(fresh.data, scope)
|
||||
await store.parkStepApproval(
|
||||
run.runId,
|
||||
{ key: "read", index: 0, label: "Read" },
|
||||
{
|
||||
actionId: null,
|
||||
tool: "records.write",
|
||||
arguments: {},
|
||||
reviewUrl: null,
|
||||
interrupt: null,
|
||||
},
|
||||
new Date().toISOString(),
|
||||
)
|
||||
const record = await store.get(run.runId)
|
||||
if (!record) throw new Error("expected a run")
|
||||
await store.transition(run.runId, record.version, { status: "failed", failureCode: "budget_exhausted" })
|
||||
expect((await listApprovals(fresh, scope)).approvals).toEqual([])
|
||||
await expect(
|
||||
reviewApproval(fresh, scope, { runId: run.runId, stepKey: "read", decision: "approve" }),
|
||||
).rejects.toMatchObject({ status: 409 })
|
||||
})
|
||||
@@ -0,0 +1,61 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// The real procedure/kernel/tenancy path over memory repositories. No live Cloud/provider claim.
|
||||
import { type Actor, buildRegistry, Container, createCaller } from "@conusai-kit/core"
|
||||
import { createMemoryDataSource, DATA_SOURCE, entitiesOf, SYSTEM_DATA } from "@conusai-kit/data"
|
||||
import { createTenancyHooks, orgMemberships, TENANCY, tenancyModule, users } from "@conusai-kit/tenancy"
|
||||
import { expect, it } from "vitest"
|
||||
import { AGENT_MODULE_BASE } from "../module.shared.js"
|
||||
|
||||
it("an open stream observes membership revocation and reconnect is refused", async () => {
|
||||
const registry = buildRegistry([tenancyModule, AGENT_MODULE_BASE])
|
||||
const data = createMemoryDataSource({ entities: entitiesOf(registry, "postgres") })
|
||||
const root = new Container()
|
||||
root.value(DATA_SOURCE, data)
|
||||
root.value(SYSTEM_DATA, data)
|
||||
const caller = (actor: Actor) => {
|
||||
const tenancy = createTenancyHooks({ data, registry })
|
||||
const container = root.child()
|
||||
container.value(TENANCY, tenancy)
|
||||
return createCaller({ registry, container, tenancy }, { actor })
|
||||
}
|
||||
const user = async (subject: string) => {
|
||||
const row = await data.global(users).insert({
|
||||
issuer: "https://id.example.test",
|
||||
subject,
|
||||
email: `${subject}@example.test`,
|
||||
displayName: subject,
|
||||
locale: "en",
|
||||
})
|
||||
return { userId: row.id }
|
||||
}
|
||||
const owner = await user("owner")
|
||||
const member = await user("reader")
|
||||
const call = caller(owner)
|
||||
const { orgId } = (await call.callByName("tenancy.org.create", { slug: "alpha", name: "Alpha" })) as { orgId: string }
|
||||
await call.callByName("tenancy.workspace.create", { org: "alpha", slug: "work", name: "Work", visibility: "org" })
|
||||
await call.callByName("tenancy.project.create", {
|
||||
org: "alpha",
|
||||
workspace: "work",
|
||||
slug: "project",
|
||||
name: "Project",
|
||||
moduleKey: "agent",
|
||||
})
|
||||
await data.unitOfWork.run({ level: "org", actor: owner, orgId }, (tx) =>
|
||||
tx.of(orgMemberships).insert({ userId: member.userId, role: "viewer" }),
|
||||
)
|
||||
const slugs = { org: "alpha", workspace: "work", project: "project" }
|
||||
const run = (await call.callByName("agent.run.start", {
|
||||
...slugs,
|
||||
goal: "Synthetic run",
|
||||
plan: [{ key: "read", label: "Read", tool: "records.read", args: {} }],
|
||||
})) as { runId: string }
|
||||
const response = (await caller(member).callByName("agent.run.events", { ...slugs, runId: run.runId })) as Response
|
||||
if (!response.body) throw new Error("expected a streamed response")
|
||||
const reader = response.body.getReader()
|
||||
expect(new TextDecoder().decode((await reader.read()).value)).toContain("RUN_STARTED")
|
||||
await caller(owner).callByName("tenancy.member.remove", { org: "alpha", userId: member.userId })
|
||||
expect(await reader.read()).toEqual({ done: true, value: undefined })
|
||||
await expect(caller(member).callByName("agent.run.events", { ...slugs, runId: run.runId })).rejects.toMatchObject({
|
||||
status: 404,
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,104 @@
|
||||
// The approval queue's markup: the EXACT stored proposed arguments are rendered (never a
|
||||
// narration), the Art. 50 disclosure rides with them, and the controls are named for a keyboard.
|
||||
|
||||
import { buildRegistry } from "@conusai-kit/core"
|
||||
import { createI18n } from "@conusai-kit/i18n"
|
||||
import { createDataClient, DataProvider } from "@conusai-kit/ui/data"
|
||||
import { renderToStaticMarkup } from "react-dom/server"
|
||||
import { describe, expect, it } from "vitest"
|
||||
import { appModules } from "../../index.js"
|
||||
import type { ApprovalView } from "../contract/index.js"
|
||||
import { ApprovalQueue } from "../ui/approval-queue.js"
|
||||
import { RunControls } from "../ui/run-controls.js"
|
||||
|
||||
const catalogues = buildRegistry([...appModules]).catalogues
|
||||
const i18n = createI18n("en", { en: { agent: catalogues.en?.agent ?? {} } })
|
||||
const t = (key: string, values?: Record<string, string | number>) => i18n.message(`agent.${key}`, values)
|
||||
const failed = "That did not work: {detail}"
|
||||
const slugs = { org: "acme", workspace: "ws", project: "p" }
|
||||
|
||||
const inProvider = (node: React.ReactNode) =>
|
||||
renderToStaticMarkup(
|
||||
<DataProvider client={createDataClient({ fetch: async () => new Response(null, { status: 501 }) })}>
|
||||
{node}
|
||||
</DataProvider>,
|
||||
)
|
||||
|
||||
const approval: ApprovalView = {
|
||||
runId: "r1",
|
||||
stepKey: "read",
|
||||
goal: "Review the contract",
|
||||
tool: "orders.create",
|
||||
arguments: { total: 4200, currency: "EUR" },
|
||||
reviewUrl: "https://console/tool-approvals?action=act-1",
|
||||
createdAt: "2026-10-23T00:00:00.000Z",
|
||||
}
|
||||
|
||||
const labels = {
|
||||
heading: t("approvals.heading"),
|
||||
empty: t("approvals.empty"),
|
||||
proposed: t("approvals.proposed"),
|
||||
review: t("approvals.review"),
|
||||
approve: t("approvals.approve"),
|
||||
reject: t("approvals.reject"),
|
||||
decided: t("approvals.decided"),
|
||||
disclosure: t("approvals.disclosure"),
|
||||
failed,
|
||||
}
|
||||
|
||||
describe("the approval queue", () => {
|
||||
const html = inProvider(<ApprovalQueue slugs={slugs} seed={[approval]} labels={labels} />)
|
||||
|
||||
it("renders the exact stored proposed arguments, pretty-printed, with the disclosure", () => {
|
||||
// The exact stored arguments, HTML-escaped as any rendered text is.
|
||||
expect(html).toContain(""total": 4200")
|
||||
expect(html).toContain(""currency": "EUR"")
|
||||
expect(html).toContain(t("approvals.disclosure"))
|
||||
expect(html).toContain(t("approvals.proposed"))
|
||||
})
|
||||
|
||||
it("names the decision controls and links the console review", () => {
|
||||
expect(html).toContain(`>${t("approvals.approve")}<`)
|
||||
expect(html).toContain(`>${t("approvals.reject")}<`)
|
||||
expect(html).toContain('href="https://console/tool-approvals?action=act-1"')
|
||||
})
|
||||
|
||||
it("announces an empty queue", () => {
|
||||
const empty = inProvider(<ApprovalQueue slugs={slugs} seed={[]} labels={labels} />)
|
||||
expect(empty).toContain(t("approvals.empty"))
|
||||
})
|
||||
})
|
||||
|
||||
describe("the run controls", () => {
|
||||
const labels = {
|
||||
cancel: t("runs.cancel"),
|
||||
cancelling: t("runs.cancelling"),
|
||||
failedHint: t("recovery.failedHint"),
|
||||
cancelledHint: t("recovery.cancelledHint"),
|
||||
uncertainHint: t("recovery.uncertainHint"),
|
||||
budgetExhausted: t("recovery.budgetExhausted"),
|
||||
approvalUnavailable: t("recovery.approvalUnavailable"),
|
||||
toolError: t("recovery.toolError"),
|
||||
planRefused: t("recovery.planRefused"),
|
||||
internal: t("recovery.internal"),
|
||||
}
|
||||
|
||||
it("offers cancel on an active run and recovery copy on an uncertain one", () => {
|
||||
const active = inProvider(
|
||||
<RunControls slugs={slugs} run={{ runId: "r1", status: "running", failureCode: null }} labels={labels} />,
|
||||
)
|
||||
expect(active).toContain(`>${t("runs.cancel")}<`)
|
||||
const uncertain = inProvider(
|
||||
<RunControls slugs={slugs} run={{ runId: "r1", status: "uncertain", failureCode: null }} labels={labels} />,
|
||||
)
|
||||
expect(uncertain).toContain(
|
||||
t("recovery.uncertainHint")
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">")
|
||||
.replaceAll(String.fromCharCode(34), """)
|
||||
.replaceAll(String.fromCharCode(39), "'"),
|
||||
)
|
||||
expect(uncertain).not.toContain(`>${t("runs.cancel")}<`)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,45 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
import { buildRegistry, Container, createCaller } from "@conusai-kit/core"
|
||||
import { DATA_SOURCE, type DataSource, SYSTEM_DATA, type SystemDataSource } from "@conusai-kit/data"
|
||||
import { createTenancyHooks, TENANCY, tenancyModule, users } from "@conusai-kit/tenancy"
|
||||
import type { AgentScope } from "../core/ports.js"
|
||||
import { AGENT_MODULE_BASE } from "../module.shared.js"
|
||||
|
||||
export const workerRegistry = buildRegistry([tenancyModule, AGENT_MODULE_BASE])
|
||||
|
||||
/** Real app membership/permission resolution, including a retained memo to expose stale use. */
|
||||
export async function workerFixture(data: DataSource & SystemDataSource) {
|
||||
const container = new Container()
|
||||
container.value(DATA_SOURCE, data)
|
||||
container.value(SYSTEM_DATA, data)
|
||||
const tenancy = createTenancyHooks({ data, registry: workerRegistry })
|
||||
container.value(TENANCY, tenancy)
|
||||
const user = await data.global(users).insert({
|
||||
issuer: "https://id.example.test",
|
||||
subject: crypto.randomUUID(),
|
||||
email: "worker@example.test",
|
||||
displayName: "Worker fixture",
|
||||
locale: "en",
|
||||
})
|
||||
const actor = { userId: user.id }
|
||||
const caller = createCaller({ registry: workerRegistry, container, tenancy }, { actor })
|
||||
const org = `worker-${crypto.randomUUID()}`
|
||||
const { orgId } = (await caller.callByName("tenancy.org.create", { slug: org, name: "Worker" })) as { orgId: string }
|
||||
const { workspaceId } = (await caller.callByName("tenancy.workspace.create", {
|
||||
org,
|
||||
slug: "work",
|
||||
name: "Work",
|
||||
visibility: "org",
|
||||
})) as { workspaceId: string }
|
||||
const { projectId } = (await caller.callByName("tenancy.project.create", {
|
||||
org,
|
||||
workspace: "work",
|
||||
slug: "project",
|
||||
name: "Project",
|
||||
moduleKey: "agent",
|
||||
})) as { projectId: string }
|
||||
const scope: AgentScope = { level: "project", actor, orgId, workspaceId, projectId }
|
||||
// Warm the request memo: worker admission must read past this after a revocation.
|
||||
await tenancy.authorize(scope, "agent.run.start", { name: "agent.run.start", kind: "effect" })
|
||||
return { container, scope, tenancy }
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
|
||||
// Actual nonowner PostgreSQL + app tenancy + Agent job. Scripted model/tools, no live provider.
|
||||
import { AGENT_MODEL, AGENT_TOOLS, dataRunStore } from "@conusai-kit/agent"
|
||||
import { newId } from "@conusai-kit/core"
|
||||
import type { KyselyDataSource } from "@conusai-kit/data-kysely"
|
||||
import { orgMemberships, TENANCY } from "@conusai-kit/tenancy"
|
||||
import { beforeAll, expect, it, vi } from "vitest"
|
||||
import { AGENT_SCOPE } from "../core/ports.js"
|
||||
import { startRun } from "../core/runs.js"
|
||||
import { type ClaimChange, claimedDataSource, claimedDelivery, terminateClaimSession } from "./claimed-job.js"
|
||||
import { planFollower } from "./plan-model.js"
|
||||
import { workerFixture } from "./worker-fixture.js"
|
||||
|
||||
let data: KyselyDataSource
|
||||
beforeAll(async () => {
|
||||
data = await claimedDataSource()
|
||||
}, 300_000)
|
||||
|
||||
async function fixture(needsApproval = false) {
|
||||
const { container, scope } = await workerFixture(data)
|
||||
// The actual worker binding uses a system actor; stored actorId alone supplies member authority.
|
||||
container.value(AGENT_SCOPE, { ...scope, actor: { userId: "system:agent", system: true } })
|
||||
let models = 0
|
||||
let revokeDuringModel = false
|
||||
let loseDuringModel = false
|
||||
let tools = 0
|
||||
const adapter = planFollower()
|
||||
const stream = adapter.chatStream.bind(adapter)
|
||||
adapter.chatStream = async function* (...args) {
|
||||
models++
|
||||
if (revokeDuringModel) await removeMember()
|
||||
if (loseDuringModel) await terminateClaimSession()
|
||||
yield* stream(...args)
|
||||
}
|
||||
container.value(AGENT_MODEL, adapter)
|
||||
let revoke = false
|
||||
let transient = false
|
||||
const removeMember = async () => {
|
||||
const repos = data.forScope(scope)
|
||||
const member = (await repos.of(orgMemberships).list({ limit: 10 })).items[0]
|
||||
if (!member) throw new Error("expected member")
|
||||
await repos.of(orgMemberships).delete(member.id)
|
||||
}
|
||||
container.value(AGENT_TOOLS, {
|
||||
async call() {
|
||||
tools++
|
||||
if (transient) throw new Error("private upstream failure")
|
||||
if (revoke) await removeMember()
|
||||
return { status: "completed", content: [], usage: { costMicrocents: 0, steps: 1, tokens: 0 } }
|
||||
},
|
||||
})
|
||||
const { runId } = await startRun({ data }, scope, {
|
||||
goal: "Synthetic worker verification",
|
||||
plan: [
|
||||
{ key: "read", label: "Read", tool: "records.read", args: {}, needsApproval },
|
||||
{ key: "next", label: "Next", tool: "records.next", args: {} },
|
||||
],
|
||||
})
|
||||
const store = dataRunStore(data, scope)
|
||||
return {
|
||||
container,
|
||||
scope,
|
||||
runId,
|
||||
store,
|
||||
removeMember,
|
||||
counts: () => ({ models, tools }),
|
||||
loseDuringModel: () => {
|
||||
loseDuringModel = true
|
||||
},
|
||||
revokeDuringModel: () => {
|
||||
revokeDuringModel = true
|
||||
},
|
||||
revoke: () => {
|
||||
revoke = true
|
||||
},
|
||||
fail: () => {
|
||||
transient = true
|
||||
},
|
||||
deliver: (finalAttempt = false, change?: ClaimChange) =>
|
||||
claimedDelivery(data, container, scope, runId, { finalAttempt }, change),
|
||||
}
|
||||
}
|
||||
|
||||
it("executes and replays without repeating providers through the nonowner runtime", async () => {
|
||||
const job = await fixture()
|
||||
await job.deliver()
|
||||
expect((await job.store.get(job.runId))?.status).toBe("completed")
|
||||
const counts = job.counts()
|
||||
expect(counts.tools).toBe(2)
|
||||
await job.deliver()
|
||||
expect(job.counts()).toEqual(counts)
|
||||
})
|
||||
|
||||
it("refuses revoked membership before the first model and after a committed step", async () => {
|
||||
const before = await fixture()
|
||||
await before.removeMember()
|
||||
await before.deliver()
|
||||
expect(before.counts()).toEqual({ models: 0, tools: 0 })
|
||||
expect((await before.store.get(before.runId))?.status).toBe("failed")
|
||||
const between = await fixture()
|
||||
between.revoke()
|
||||
await between.deliver()
|
||||
expect(between.counts()).toEqual({ models: 1, tools: 1 })
|
||||
expect(await between.store.get(between.runId)).toMatchObject({ status: "failed", usageSteps: 1 })
|
||||
})
|
||||
|
||||
it("wrong org, workspace or project cannot execute or alter the immutable stored run", async () => {
|
||||
const job = await fixture()
|
||||
for (const field of ["orgId", "workspaceId", "projectId"] as const) {
|
||||
const container = job.container.child()
|
||||
container.value(AGENT_SCOPE, { ...job.scope, [field]: newId() })
|
||||
await expect(claimedDelivery(data, container, job.scope, job.runId, { finalAttempt: true })).rejects.toThrow()
|
||||
}
|
||||
expect(job.counts()).toEqual({ models: 0, tools: 0 })
|
||||
expect((await job.store.get(job.runId))?.status).toBe("queued")
|
||||
})
|
||||
|
||||
it("bounded exhaustion persists terminal failure, preserving committed/replay semantics", async () => {
|
||||
const job = await fixture()
|
||||
job.fail()
|
||||
await expect(job.deliver()).rejects.toThrow()
|
||||
expect((await job.store.get(job.runId))?.status).toBe("running")
|
||||
await job.deliver(true)
|
||||
expect(await job.store.get(job.runId)).toMatchObject({
|
||||
status: "failed",
|
||||
failureDetail: "the durable job exhausted its retry attempts",
|
||||
})
|
||||
expect((await job.store.listSteps(job.runId))[0]?.attempts).toBe(2)
|
||||
const counts = job.counts()
|
||||
await job.deliver(true)
|
||||
expect(job.counts()).toEqual(counts)
|
||||
})
|
||||
|
||||
it("revocation between model and tool refuses the effect before any tool call", async () => {
|
||||
const job = await fixture()
|
||||
job.revokeDuringModel()
|
||||
await job.deliver()
|
||||
expect(job.counts()).toEqual({ models: 1, tools: 0 })
|
||||
expect((await job.store.get(job.runId))?.status).toBe("failed")
|
||||
})
|
||||
|
||||
it("approval resumes the exact call, while revocation prevents approved resumption", async () => {
|
||||
const job = await fixture(true)
|
||||
await job.deliver(true)
|
||||
expect((await job.store.get(job.runId))?.status).toBe("awaiting_approval")
|
||||
expect(job.counts().tools).toBe(0)
|
||||
await job.store.decideApproval(job.runId, "read", "approved")
|
||||
await job.deliver()
|
||||
expect((await job.store.get(job.runId))?.status).toBe("completed")
|
||||
expect(job.counts().tools).toBe(2)
|
||||
await job.deliver()
|
||||
expect(job.counts().tools).toBe(2)
|
||||
const revoked = await fixture(true)
|
||||
await revoked.deliver()
|
||||
await revoked.store.decideApproval(revoked.runId, "read", "approved")
|
||||
await revoked.removeMember()
|
||||
const counts = revoked.counts()
|
||||
await revoked.deliver()
|
||||
expect(revoked.counts()).toEqual(counts)
|
||||
expect((await revoked.store.get(revoked.runId))?.status).toBe("failed")
|
||||
})
|
||||
|
||||
it("a current role demotion removes execution permission despite the retained memo", async () => {
|
||||
const job = await fixture()
|
||||
const repos = data.forScope(job.scope)
|
||||
const member = (await repos.of(orgMemberships).list({ limit: 10 })).items[0]
|
||||
if (!member) throw new Error("expected membership")
|
||||
await repos.of(orgMemberships).update(member.id, { role: "viewer" }, member.version)
|
||||
await job.deliver()
|
||||
expect(job.counts()).toEqual({ models: 0, tools: 0 })
|
||||
expect((await job.store.get(job.runId))?.status).toBe("failed")
|
||||
})
|
||||
|
||||
it.each(["cancel", "reclaim", "delete", "scope", "actor"] as const)(
|
||||
"actual adapter refuses %s before provider or durable run mutation",
|
||||
async (change) => {
|
||||
const job = await fixture()
|
||||
const before = await job.store.get(job.runId)
|
||||
const storeCalls = vi.spyOn(data, "forScope")
|
||||
await expect(job.deliver(true, change)).rejects.toMatchObject({ code: "job_claim_lost" })
|
||||
expect(job.counts()).toEqual({ models: 0, tools: 0 })
|
||||
expect(storeCalls).not.toHaveBeenCalled()
|
||||
storeCalls.mockRestore()
|
||||
expect(await job.store.get(job.runId)).toEqual(before)
|
||||
expect(await job.store.listSteps(job.runId)).toEqual([])
|
||||
},
|
||||
)
|
||||
|
||||
it("actual original attribution must match the immutable run owner", async () => {
|
||||
const job = await fixture()
|
||||
const before = await job.store.get(job.runId)
|
||||
await expect(job.deliver(true, "owner")).rejects.toMatchObject({ code: "job_claim_lost" })
|
||||
expect(job.counts()).toEqual({ models: 0, tools: 0 })
|
||||
expect(await job.store.get(job.runId)).toEqual(before)
|
||||
expect(await job.store.listSteps(job.runId)).toEqual([])
|
||||
})
|
||||
|
||||
it("actual owned guard backend loss during model admission prevents every later tool and terminal failure", async () => {
|
||||
const job = await fixture()
|
||||
job.loseDuringModel()
|
||||
await expect(job.deliver(true)).rejects.toMatchObject({ code: "job_claim_lost" })
|
||||
expect(job.counts()).toEqual({ models: 1, tools: 0 })
|
||||
expect((await job.store.get(job.runId))?.status).not.toBe("failed")
|
||||
})
|
||||
|
||||
it("actual guard loss during authorization refuses model admission before any provider call", async () => {
|
||||
const job = await fixture()
|
||||
const tenancy = job.container.resolve(TENANCY)
|
||||
const child = job.container.child()
|
||||
child.value(TENANCY, {
|
||||
...tenancy,
|
||||
async authorizeIn(...args) {
|
||||
const result = await tenancy.authorizeIn(...args)
|
||||
await terminateClaimSession()
|
||||
return result
|
||||
},
|
||||
})
|
||||
await expect(claimedDelivery(data, child, job.scope, job.runId, { finalAttempt: true })).rejects.toMatchObject({
|
||||
code: "job_claim_lost",
|
||||
})
|
||||
expect(job.counts()).toEqual({ models: 0, tools: 0 })
|
||||
expect((await job.store.get(job.runId))?.status).toBe("queued")
|
||||
})
|
||||
Reference in New Issue
Block a user