Files
template-app/test/agent-events.test.ts
2026-11-14 09:56:45 +00:00

142 lines
6.1 KiB
TypeScript

// SPDX-License-Identifier: LicenseRef-ConusAI-Template-1.0
// `agent.run.events` through the kernel's caller, the way the HTTP mount calls it: a project's
// reader gets the run's AG-UI events as Server-Sent Events, resumes strictly after an offset, and
// the stream closes after the run's terminal event; another organization's run is not found, as
// `agent.run.status` answers, and an offset of another run is refused.
import { AppError, type TransportCaller } from "@conusai-kit/core"
import { describe, expect, it, vi } from "vitest"
import { makeNotesApp, type NotesApp, type SeededProject } from "./support/notes-app.js"
const PLAN = [{ key: "read", label: "Read", tool: "documents.read", args: { fileId: "f-1" } }]
const refusal = async (promise: Promise<unknown>): Promise<AppError> => {
try {
await promise
} catch (error) {
if (error instanceof AppError) return error
throw error
}
throw new Error("expected a refusal")
}
interface Frame {
id: string | undefined
data: { type: string; [field: string]: unknown }
}
const framesOf = (text: string): Frame[] =>
text
.split("\n\n")
.filter((frame) => frame.trim() !== "" && !frame.startsWith(":"))
.map((frame) => ({
id: /^id: (.*)$/m.exec(frame)?.[1],
data: JSON.parse(/^data: (.*)$/m.exec(frame)?.[1] ?? "null") as Frame["data"],
}))
/** The first chunk of a stream that is still open (a run that has not ended), then the stream is let go. */
async function firstChunk(response: Response): Promise<string> {
const reader = (response.body as ReadableStream<Uint8Array>).getReader()
const { value } = await reader.read()
await reader.cancel()
return new TextDecoder().decode(value)
}
async function startRun(app: NotesApp, project: SeededProject) {
const started = (await project.call.callByName("agent.run.start", {
...project.slugs,
goal: "Review",
plan: PLAN,
})) as {
runId: string
}
// The resume event names the run's project by id: what the engine would write under.
const record = (await app.data.outbox.pending(50)).find(
(entry) =>
entry.name === "agent.runResumeRequested" && (entry.payload as { runId: string }).runId === started.runId,
)
const ids = record?.payload as { orgId: string; workspaceId: string; projectId: string }
const scope = { level: "project" as const, actor: project.owner, ...ids }
/** Ends the run as the engine would. */
const finish = async () => {
const entity = app.entities.find((candidate) => candidate.table === "agent_run")
if (entity?.tenancy !== "project") throw new Error("the agent module declares no project run table")
const scoped = app.data.forScope(scope)
const table = scoped.of(entity as Parameters<typeof scoped.of>[0])
const row = (await table.get(started.runId)) as { version: number } | null
if (!row) throw new Error("the run is gone")
await table.update(started.runId, { status: "cancelled" }, row.version)
}
return { runId: started.runId, finish }
}
const events = (call: TransportCaller, input: Record<string, unknown>) =>
call.callByName("agent.run.events", input) as Promise<Response>
describe("agent.run.events", () => {
it("streams a reader the run's events, resumes after an offset and closes after the terminal event", async () => {
const app = makeNotesApp()
const project = await app.seedProject("acme", "owner@acme.test")
const run = await startRun(app, project)
const viewer = await app.addMember(project, "viewer@acme.test", "viewer")
const open = await events(viewer.call, { ...project.slugs, runId: run.runId })
expect(open.headers.get("content-type")).toContain("text/event-stream")
expect(open.headers.get("cache-control")).toBe("no-store")
expect(framesOf(await firstChunk(open))).toEqual([
{
id: `agk1:${run.runId}:0`,
data: { type: "RUN_STARTED", threadId: `agent-run:${run.runId}`, runId: run.runId },
},
])
await run.finish()
const rest = framesOf(
await (await events(viewer.call, { ...project.slugs, runId: run.runId, after: `agk1:${run.runId}:0` })).text(),
)
expect(rest).toEqual([
{
id: `agk1:${run.runId}:1`,
data: {
type: "RUN_FINISHED",
threadId: `agent-run:${run.runId}`,
runId: run.runId,
outcome: { type: "success" },
result: { status: "cancelled" },
},
},
])
expect(await (await events(viewer.call, { ...project.slugs, runId: run.runId, after: "1" })).text()).toBe("")
})
it("answers another organization's run as not found, and refuses another run's offset", async () => {
const app = makeNotesApp()
const acme = await app.seedProject("acme", "owner@acme.test")
const globex = await app.seedProject("globex", "owner@globex.test")
const theirs = await startRun(app, acme)
const ours = await startRun(app, globex)
// Globex's member names Acme's run in Globex's own project: not found, as agent.run.status says.
const asStatus = await refusal(globex.call.callByName("agent.run.status", { ...globex.slugs, runId: theirs.runId }))
const asEvents = await refusal(events(globex.call, { ...globex.slugs, runId: theirs.runId }))
expect(asEvents.status).toBe(404)
expect(asEvents.code).toBe(asStatus.code)
// ... and in Acme's project, of which they are no member: refused before anything is read.
const outside = await refusal(events(globex.call, { ...acme.slugs, runId: theirs.runId }))
expect([403, 404]).toContain(outside.status)
const foreignOffset = await refusal(
events(globex.call, { ...globex.slugs, runId: ours.runId, after: `agk1:${theirs.runId}:0` }),
)
expect(foreignOffset.status).toBe(422)
const malformed = await refusal(events(globex.call, { ...globex.slugs, runId: ours.runId, after: "../0" }))
expect(malformed.status).toBe(422)
})
})
// This journey requires explicit optional-module admission.
vi.mock("../modules/inclusion.js", async (importOriginal) => ({
...(await importOriginal<typeof import("../modules/inclusion.js")>()),
...(await import("./support/native-readiness.js")).selectedModules(["agent"]),
}))