216 lines
7.3 KiB
TypeScript
216 lines
7.3 KiB
TypeScript
// 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("'", "'"))
|
|
})
|
|
})
|