78 lines
3.4 KiB
TypeScript
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
|
|
}
|
|
}
|