Skip to content

refactor(agent)!: Align AG-UI events with spec and bump @ag-ui/client#159

Open
mattbrailsford wants to merge 9 commits into
devfrom
feature/bump-ag-ui-client
Open

refactor(agent)!: Align AG-UI events with spec and bump @ag-ui/client#159
mattbrailsford wants to merge 9 commits into
devfrom
feature/bump-ag-ui-client

Conversation

@mattbrailsford
Copy link
Copy Markdown
Contributor

@mattbrailsford mattbrailsford commented May 7, 2026

Summary

Stacked on top of #157. Resolves #158.

Started as a @ag-ui/client@0.0.42 → 0.0.53 dependency bump. Expanded once we discovered our event shapes had drifted from the published AG-UI spec — and a subsequent audit found further drift across most of the protocol surface. Going aggressive with one PR because we control both ends of the protocol, so the usual external-coordination cost of breaking wire changes doesn't apply.

Spec alignment landing in this PR

Topic What changed
@ag-ui/client SDK bump 0.0.42 → 0.0.53. Re-export AG-UI's typed events from transport/types.ts instead of duplicating; #handleEvent now narrows naturally on event.type.
RUN_FINISHED outcome Flat outcome: string + sibling interrupt/error fields → spec-shaped discriminated union `outcome: { type: "success" }
STATE_SNAPSHOT / STATE_DELTA Frontend now reads spec field snapshot (was state); delta enforced as IReadOnlyList<JsonElement> (spec types as JSON Patch ops array).
ActivitySnapshotEvent.Content stringJsonElement (spec types as Record<string, any>).
AGUIMessage Id is now required (spec mandates id on every message variant). Added optional EncryptedValue (zero-data-retention envelope) and tool-only Error field.
AGUIToolCall.EncryptedValue? Added optional spec field.
Role narrowing New AGUITextMessageRole enum restricts TextMessageStart/Chunk roles to `developer
AGUIMessageRole.Reasoning Added enum value (inert until reasoning emits, but accepts inbound spec-shaped reasoning messages).
Tool.metadata cleanup Eliminated the forwardedProps.toolMetadata side-channel where metadata was sent in a parallel array and rejoined by name on the server. Tool metadata (scope, isDestructive) now travels inline via the spec's Tool.metadata field. Loosened Parameters from a constrained AGUIToolParameters class to JsonElement per AG-UI's z.ZodAny — supports the full JSON Schema vocabulary (oneOf, enum, $ref).
Resume protocol Single-object Resume { interruptId, payload: { toolResults[] } } → spec-shaped IReadOnlyList<AGUIResumeEntry> { interruptId, status, payload? }. Server uses toolCallId as the interruptId to recover tool call without extra state.
Multimodal content Dropped legacy AGUIBinaryInputContent. New typed variants: AGUIImage/Audio/Video/DocumentInputContent with nested source: { type, value, mimeType } discriminator. document is the catch-all per the SDK's official mime-type classifier (image/*/audio/*/video/*/else document). Filenames live in metadata.filename. Server AGUIFileProcessor and AGUIMessageConverter operate on the typed shapes. Frontend input.element.ts (uploads) and message.element.ts (rendering) updated.

Documented deviations we intentionally keep

Deviation Why Removal condition
Frontend local types RunFinishedAGUIEvent, AGUIInterrupt, AGUIRunOutcome Spec docs include outcome discriminated union on RUN_FINISHED, but @ag-ui/client@0.0.53's Zod schema doesn't model it yet — we travel via Zod passthrough. Drop these once the SDK adds outcome to RunFinishedEventSchema. The C# field stays — it's spec-correct.

Out of scope / follow-up

The audit also surfaced these items, which are net-new feature work or larger surface design and are deliberately deferred:

  • REASONING_* events (7 events) + ReasoningMessage model — net-new feature work tied to reasoning support landing.
  • AgentCapabilities model + /info advertisement endpoint — net-new public API surface; deserves dedicated design.
  • ActivityMessage model — distinct from ActivitySnapshotEvent; not currently used.

These are captured here in the PR description rather than as separate issues so reviewers see the full audit findings, but no separate tracking is being filed.

Test results

  • dotnet build Umbraco.AI.Agent/Umbraco.AI.Agent.slnx — 0 errors
  • dotnet test Umbraco.AI.Agent.Tests.Unit — 135/135 passing (was 137; 2 tests verifying the now-deleted AIFrontendToolFunction.BuildJsonSchema helper were removed)
  • ✅ Frontend npx tsc -p tsconfig.api.json — 0 errors in src/transport/

(Pre-existing local frontend errors against @umbraco-cms/backoffice from a parent-dir node_modules shadowing this dev env's worktree are unrelated; CI runs in a clean env.)

Frontend OpenAPI client regeneration

Umbraco.AI.Agent.Web.StaticAssets/Client/src/api/types.gen.ts is auto-generated from the server's OpenAPI spec and still references the legacy multimodal/resume shapes. It should be regenerated via npm run generate-client against the demo site so the committed types match the new server contract.

Test plan

  • CI build
  • Smoke-test agent streaming end-to-end in demo site:
    • Success path → RUN_FINISHED with outcome.type === "success"
    • Interrupt path → frontend tool surfaces approval, resume array round-trips, tool result injects on next turn
    • Error path → RUN_ERROR, no trailing RUN_FINISHED
    • Tool metadata flow → scope/isDestructive arrive via tool.metadata (not forwardedProps)
    • File upload → typed image/document content with nested source
  • Regenerate frontend OpenAPI client (npm run generate-client) and commit types.gen.ts updates

🤖 Generated with Claude Code

The 0.0.53 release moved BaseEvent to a Zod-passthrough type and
introduced a proper AGUIEvent discriminated union. Drop the local
event-type duplicates in transport/types.ts and re-export AG-UI's
types instead. The discriminated union now narrows naturally on
event.type without a per-case cast.

Server-specific extensions (RUN_FINISHED.outcome/interrupt/error and
STATE_SNAPSHOT.state) are kept as locally-extended interfaces to
document their non-spec nature.

Resolves #158.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mattbrailsford mattbrailsford force-pushed the feature/bump-ag-ui-client branch from 1e5e1a2 to fc1fda6 Compare May 7, 2026 11:46
… AG-UI spec

The AG-UI 0.0.53 bump exposed long-standing drift between our event shapes
and the published AG-UI protocol. Bring both server and frontend in line
with the spec at https://docs.ag-ui.com/concepts so we can drop the local
extension types as the SDK Zod schemas catch up.

Server (Umbraco.AI.AGUI):
- Replace AGUIRunOutcome enum with a polymorphic record carrying success
  and interrupt variants. The wire shape is now
  outcome: { type, interrupts? }.
- Update AGUIInterruptInfo to model the spec fields (id, reason, message,
  toolCallId, responseSchema, expiresAt, metadata).
- RunFinishedEvent loses the flat outcome/interrupt/error fields and gains
  a required Outcome property. Errors are emitted exclusively as
  RunErrorEvent — never as a RUN_FINISHED with outcome=error.
- AGUIStreamingService and AIAgentService.EmitAGUIError no longer emit a
  RUN_FINISHED after a RUN_ERROR.

Frontend (Umbraco.AI.Agent.Web.StaticAssets):
- Drop the local StateSnapshotEvent extension; STATE_SNAPSHOT now reads
  the spec snapshot field directly.
- Replace the prior local RunFinishedAGUIEvent (flat outcome string) with
  one modelling the spec outcome discriminated union. Marked with the
  conditions for removal once @ag-ui/client publishes a Zod schema that
  models outcome natively.
- New AGUIInterrupt and AGUIRunOutcome local types document the spec
  shape until the SDK catches up. parseInterrupt now reads UI-render
  hints (type/title/options/inputConfig) from the AG-UI metadata bag
  instead of expecting them at the top level.

Tests updated to assert the new outcome variants and the spec invariant
that a run terminates with either RUN_FINISHED or RUN_ERROR — never both.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@mattbrailsford mattbrailsford changed the title chore(deps): Bump @ag-ui/client 0.0.42 -> 0.0.53 refactor(agent)!: Align AG-UI events with spec and bump @ag-ui/client May 7, 2026
mattbrailsford and others added 7 commits May 7, 2026 14:12
…ot, deltas, role enum

Small mechanical alignments surfaced by the AG-UI spec audit:

- AGUIMessage.Id is now required (spec mandates id on every message variant).
  Add encryptedValue and tool-only error fields. Update converter, callers,
  and JSON deserializer to enforce required id.
- AGUIToolCall: add optional encryptedValue (spec field).
- ActivitySnapshotEvent.Content: string -> JsonElement (spec types as
  Record<string, any>).
- StateDeltaEvent.Delta and ActivityDeltaEvent.Patch:
  JsonElement -> IReadOnlyList<JsonElement> (spec types as any[] JSON Patch ops).
- AGUIMessageRole: add Reasoning enum value (inert until reasoning emits;
  enables inbound spec-shaped reasoning messages to deserialize).
- AGUIConstants.MessageRoles: add Activity and Reasoning string constants.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…lt events

Per AG-UI spec, TEXT_MESSAGE_START/CHUNK only allow developer/system/assistant/user
(no tool/activity/reasoning), and TOOL_CALL_RESULT only allows literal "tool".
Our events were exposing the full AGUIMessageRole enum, making it possible to
emit non-spec values from C#.

- New AGUITextMessageRole enum with the 4 spec-allowed values; used by
  TextMessageStartEvent and TextMessageChunkEvent.
- TextMessageStart / TextMessageChunk gain optional Name field (spec).
- ToolCallResultEvent.Role becomes string? defaulted to "tool" (spec literal).
- AGUIEventEmitter callers updated to use the new types.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…etadata side-channel

The frontend used to split each tool into the AG-UI Tool definition + a
parallel toolMetadata array shoved into forwardedProps. The server then
re-joined by tool name with a dictionary lookup. The metadata was severed
from the tool definition over the wire and reassembled at request time.

Use the spec's Tool.metadata field instead — vendor-specific data (scope,
isDestructive) now travels inline with each tool, eliminating the split,
the forwardedProps namespace pollution, and the lookup machinery.

Server:
- AGUITool: add Metadata (IReadOnlyDictionary<string, object?>?). Loosen
  Parameters from a constrained AGUIToolParameters class to JsonElement?
  per AG-UI docs (the spec's parameters field carries a JSON Schema, types
  any on the wire, and is optional).
- DELETE AGUIToolParameters helper class.
- StreamAgentAGUIController: drop ExtractToolMetadataLookup,
  CombineToolsWithMetadata, ToolMetadataDto. Read tool.Metadata directly.
- AIFrontendToolFunction: pass tool.Parameters straight through as the JSON
  schema; default to a minimal { type: "object" } when callers omit it.
- Tests rewritten to construct Parameters as JsonElement directly.

Frontend:
- uai-agent-client: drop #splitFrontendTools. New #toAGUITool maps each
  UaiFrontendTool to a spec-shaped Tool with metadata inline. The
  forwardedProps payload no longer carries toolMetadata.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Per AG-UI spec, RunAgentInput.resume is an Array<{ interruptId, status, payload? }>
where each entry maps to one open interrupt from the previous run. Our previous
shape (single object with payload.toolResults wrapper) was a server-only extension.

- DELETE AGUIResumeInfo.
- NEW AGUIResumeEntry { InterruptId, Status, Payload? }.
- NEW AGUIResumeStatus enum (Resolved | Cancelled).
- AGUIRunRequest.Resume becomes IReadOnlyList<AGUIResumeEntry>?.
- AGUIEventEmitter: when emitting interrupts use the toolCallId as the interruptId
  so the server can recover the tool call from a resume entry without tracking
  extra state.
- AGUIStreamingService.ExtractToolResultsFromResume: iterate entries, skip
  cancelled, build a tool-result message per resolved entry.

Tests updated for the new shape. The frontend OpenAPI client (types.gen.ts)
will pick up the new model shape when next regenerated via npm run generate-client.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ideo/document)

Drop the legacy AGUIBinaryInputContent shape in favour of the four spec-defined
typed variants with nested source: { type, value, mimeType } discriminator.
Document is the catch-all for non-media MIME types (PDFs, ZIPs, plain text, etc.)
mirroring the SDK's official classifier.

Server (Umbraco.AI.AGUI):
- DELETE AGUIBinaryInputContent.
- NEW AGUIInputContentSource (abstract polymorphic) with AGUIInputContentDataSource
  and AGUIInputContentUrlSource.
- NEW AGUIImage/Audio/Video/DocumentInputContent — each with Source + Metadata.
- NEW AGUIInputContentFactory.Classify / FromSource — mime-type to variant mapping.
- AGUIInputContent updated to register the four new variants.

Server (Umbraco.AI.Agent.Core):
- AGUIFileProcessor refactored to operate on the typed variants. Filenames now live
  in metadata.filename per spec; the file-store fileId is round-tripped via
  metadata.fileId. Resolved bytes are attached to a part's metadata bag (instead
  of a non-spec ResolvedData property) and read via AGUIFileProcessor.GetResolvedBytes.
  Storage rewrites a DataSource to a UrlSource (server file URL).
- AGUIMessageConverter handles the four typed variants both directions
  (M.E.AI DataContent <-> typed AG-UI parts).

Frontend (Umbraco.AI.Agent.Web.StaticAssets transport):
- transport/types.ts replaces UaiBinaryInputContent with UaiImage/Audio/Video/
  DocumentInputContent + UaiInputContentSource discriminated union.
- New classifyContentKind(mimeType) helper mirrors the server-side classifier.

Frontend (Umbraco.AI.Agent.UI):
- chat/types/index.ts and exports.ts re-export the new type names.
- input.element.ts emits typed parts on file upload using classifyContentKind.
- message.element.ts renders typed parts (inline image render for image/*,
  file chip with filename from metadata for everything else).

Tests across AGUIFileProcessor, AGUIMessageConverter, AGUIMessageMultimodal updated
to construct and assert the new shapes. Filename and fileId now read from metadata
bags. 135/135 unit tests pass.

The frontend OpenAPI client (types.gen.ts) still references the legacy binary
shape and will pick up the new shape when next regenerated via
npm run generate-client.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
…ToolCallRole enum

AG-UI spec restricts TOOL_CALL_RESULT.role to the literal "tool". Replace the
string?-defaulted-to-"tool" field with a single-value enum so the C# type system
enforces the constraint — callers can't accidentally assign a non-spec string.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Apply findings from a code-review pass on the spec-alignment work:

- Introduce AGUIMediaInputContent abstract base. The four typed content
  classes (image / audio / video / document) collapse to sealed leaves.
  AGUIFileProcessor and AGUIMessageConverter no longer need their own
  per-variant switches to read Source / Metadata.
- Add Source.GetMimeType() helper on AGUIInputContentSource so the
  AGUIInputContentDataSource / AGUIInputContentUrlSource pattern doesn't
  need a separate `MimeOf` helper in each consumer.
- Collapse the double WithMetadata clone in AGUIFileProcessor.StoreAndRewriteAsync
  by adding a 2-key overload — one Dictionary allocation per stored part
  instead of two.
- Simplify WithMetadata to a copy-constructor + collection-expression form.
- Make file-processor metadata key constants `internal` so test assertions
  can reference them instead of hard-coding the same strings.
- Add AGUIConstants.ToolMetadataKeys.Scope / IsDestructive — replaces raw
  "scope" / "isDestructive" strings in StreamAgentAGUIController.
- Fold AGUIMessageConverter.ConvertFromChatMessage's four .OfType<>
  enumerations into a single switch foreach.
- Drop dead `using` imports (AGUIStreamingService, StreamAgentAGUIController).
- Trim narrative comments that re-explained the SDK lag and spec invariants
  already documented at the type definitions.

135/135 unit tests still pass; frontend transport still compiles clean.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Base automatically changed from feature/bump-3rd-party-deps to dev May 7, 2026 14:10
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant