refactor(agent)!: Align AG-UI events with spec and bump @ag-ui/client#159
Open
mattbrailsford wants to merge 9 commits into
Open
refactor(agent)!: Align AG-UI events with spec and bump @ag-ui/client#159mattbrailsford wants to merge 9 commits into
mattbrailsford wants to merge 9 commits into
Conversation
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>
1e5e1a2 to
fc1fda6
Compare
… 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>
…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>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Stacked on top of #157. Resolves #158.
Started as a
@ag-ui/client@0.0.42 → 0.0.53dependency 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
@ag-ui/clientSDK bumptransport/types.tsinstead of duplicating;#handleEventnow narrows naturally onevent.type.RUN_FINISHEDoutcomeoutcome: string+ siblinginterrupt/errorfields → spec-shaped discriminated union `outcome: { type: "success" }STATE_SNAPSHOT/STATE_DELTAsnapshot(wasstate); delta enforced asIReadOnlyList<JsonElement>(spec types as JSON Patch ops array).ActivitySnapshotEvent.Contentstring→JsonElement(spec types asRecord<string, any>).AGUIMessageIdis now required (spec mandates id on every message variant). Added optionalEncryptedValue(zero-data-retention envelope) and tool-onlyErrorfield.AGUIToolCall.EncryptedValue?AGUITextMessageRoleenum restrictsTextMessageStart/Chunkroles to `developerAGUIMessageRole.ReasoningTool.metadatacleanupforwardedProps.toolMetadataside-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'sTool.metadatafield. LoosenedParametersfrom a constrainedAGUIToolParametersclass toJsonElementper AG-UI'sz.ZodAny— supports the full JSON Schema vocabulary (oneOf,enum,$ref).Resume { interruptId, payload: { toolResults[] } }→ spec-shapedIReadOnlyList<AGUIResumeEntry> { interruptId, status, payload? }. Server usestoolCallIdas theinterruptIdto recover tool call without extra state.AGUIBinaryInputContent. New typed variants:AGUIImage/Audio/Video/DocumentInputContentwith nestedsource: { type, value, mimeType }discriminator.documentis the catch-all per the SDK's official mime-type classifier (image/*/audio/*/video/*/elsedocument). Filenames live inmetadata.filename. ServerAGUIFileProcessorandAGUIMessageConverteroperate on the typed shapes. Frontendinput.element.ts(uploads) andmessage.element.ts(rendering) updated.Documented deviations we intentionally keep
RunFinishedAGUIEvent,AGUIInterrupt,AGUIRunOutcomeoutcomediscriminated union onRUN_FINISHED, but@ag-ui/client@0.0.53's Zod schema doesn't model it yet — we travel via Zodpassthrough.outcometoRunFinishedEventSchema. 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) +ReasoningMessagemodel — net-new feature work tied to reasoning support landing.AgentCapabilitiesmodel +/infoadvertisement endpoint — net-new public API surface; deserves dedicated design.ActivityMessagemodel — distinct fromActivitySnapshotEvent; 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 errorsdotnet test Umbraco.AI.Agent.Tests.Unit— 135/135 passing (was 137; 2 tests verifying the now-deletedAIFrontendToolFunction.BuildJsonSchemahelper were removed)npx tsc -p tsconfig.api.json— 0 errors insrc/transport/(Pre-existing local frontend errors against
@umbraco-cms/backofficefrom a parent-dirnode_modulesshadowing 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.tsis auto-generated from the server's OpenAPI spec and still references the legacy multimodal/resume shapes. It should be regenerated vianpm run generate-clientagainst the demo site so the committed types match the new server contract.Test plan
RUN_FINISHEDwithoutcome.type === "success"RUN_ERROR, no trailingRUN_FINISHEDtool.metadata(notforwardedProps)image/documentcontent with nestedsourcenpm run generate-client) and committypes.gen.tsupdates🤖 Generated with Claude Code