// What the run page shows, from the server's first render (`agent.run.status`) and the run's AG-UI // events as `@conusai-kit/agent/client` folds them. Until the first event arrives the seed is the // answer, so the hydrated page matches the server's; from then on the events are: the stream replays // the run from its start, so the view holds every step that began. import type { AgentRunView } from "@conusai-kit/agent/client" import type { RunView } from "../contract/index.js" const FAILURE_CODES = ["tool_error", "budget_exhausted", "plan_refused", "approval_unavailable", "internal"] as const type FailureCode = (typeof FAILURE_CODES)[number] const isFailureCode = (code: string): code is FailureCode => (FAILURE_CODES as readonly string[]).includes(code) export interface LiveRunState { readonly status: RunView["status"] readonly failureCode: RunView["failureCode"] readonly failureDetail: string | null readonly steps: readonly { readonly key: string readonly status: RunView["plan"][number]["status"] readonly error: string | null }[] readonly percent: number /** The AI-generated answer of a completed run. */ readonly answer: string | null /** * The answer's provenance (AI Act Art. 50(2)), from the server's render: a run that completes * while the page is open shows its answer marked without it until the page is loaded again. */ readonly answerProvenance: RunView["answerProvenance"] /** The stream could not be read: the page still shows the last state it knew. */ readonly streamError: string | null } export function liveRunState(seed: RunView, view: AgentRunView): LiveRunState { const streamError = view.error?.source === "transport" ? view.error.code : null if (view.events === 0) { return { status: seed.status, failureCode: seed.failureCode, failureDetail: seed.failureDetail, steps: seed.plan.map((step) => ({ key: step.key, status: step.status, error: step.error })), percent: seed.percent, answer: seed.answer, answerProvenance: seed.answerProvenance, streamError, } } const steps = seed.plan.map((planned) => { const step = view.steps.find((candidate) => candidate.key === planned.key) if (step === undefined || !step.finished) return { key: planned.key, status: "pending" as const, error: null } return { key: planned.key, status: step.outcome ?? ("committed" as const), error: step.error } }) const committed = steps.filter((step) => step.status === "committed").length const runError = view.error?.source === "run" ? view.error : null return { status: statusOf(seed, view), failureCode: runError !== null && isFailureCode(runError.code) ? runError.code : null, failureDetail: runError?.message ?? null, steps, percent: seed.plan.length === 0 ? 0 : committed / seed.plan.length, answer: view.phase === "completed" && view.text !== "" ? view.text : null, answerProvenance: view.phase === "completed" ? seed.answerProvenance : null, streamError, } } function statusOf(seed: RunView, view: AgentRunView): RunView["status"] { switch (view.phase) { case "connecting": return seed.status // The stream opens every run with RUN_STARTED; a run the worker has not claimed is still queued. case "running": return seed.status === "queued" && view.steps.length === 0 ? "queued" : "running" default: return view.phase } }