Files
2026-11-14 09:56:45 +00:00

78 lines
3.4 KiB
TypeScript

// 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
}
}