diff --git a/docs/sdk/dotnet/client/transport.mdx b/docs/sdk/dotnet/client/transport.mdx index 5c5ddd30e6..90cfa4aaf7 100644 --- a/docs/sdk/dotnet/client/transport.mdx +++ b/docs/sdk/dotnet/client/transport.mdx @@ -102,7 +102,9 @@ to `ChatResponseUpdate` values: - Tool call events become MEAI function call content. - Interrupt outcomes become `ToolApprovalRequestContent` or `InterruptRequestContent`. -- `RUN_ERROR` is surfaced as an exception. +- `RUN_ERROR` becomes an `ErrorContent` update. Its `message` maps to + `ErrorContent.Message`, and its optional `code` maps to + `ErrorContent.ErrorCode`. `ResponseId` on each update corresponds to the AG-UI run id. The client clears `ConversationId` before yielding updates so callers continue sending full diff --git a/sdks/dotnet/docs/architecture.md b/sdks/dotnet/docs/architecture.md index 918fa2e68e..ddd0b82ca7 100644 --- a/sdks/dotnet/docs/architecture.md +++ b/sdks/dotnet/docs/architecture.md @@ -175,6 +175,8 @@ The AG-UI specification does not prescribe how capabilities are exposed over the When the server sends a `RunFinishedEvent` with outcome `"interrupt"` and a tool approval payload, `AGUIChatClient` surfaces it as a `ToolApprovalRequestContent`. For other interrupts, it surfaces an `InterruptRequestContent`. The calling code handles the interrupt and supplies the response, which gets sent as `RunAgentInput.Resume` on the next request. +When the server sends a `RunErrorEvent`, `AGUIChatClient` surfaces it as an `ErrorContent` update. The event's `message` maps to `ErrorContent.Message`, and its optional `code` maps to `ErrorContent.ErrorCode`, so callers can handle protocol error codes without parsing the raw event. + `AGUIChatClient` is **stateless**: it sends the full message history on every turn. It therefore never surfaces a `ConversationId` on returned updates — a non-null `ConversationId` signals a service-managed conversation in MEAI, which would make agent wrappers (e.g. `AsAIAgent`) send only deltas on the next turn and truncate history against a stateless server. Updates are correlated by `ResponseId` (the AG-UI run id), and the AG-UI thread id is available via `ChatResponseUpdate.AdditionalProperties["agui_thread_id"]`. To keep a stable thread across turns, reuse the same `ChatOptions` instance (the client pins the thread id onto it) or set `RunAgentInput.ThreadId`/`ParentRunId` explicitly through `ChatOptions.RawRepresentationFactory` — the AG-UI-native way to drive wire-level fields, as shown in the Step 11 sample. `IAGUITransport` abstracts the wire protocol. The built-in `AGUIHttpTransport` uses HTTP and negotiates the response format (SSE by default, or protobuf when accepted), but you can implement the interface for in-memory testing or alternative transports. diff --git a/sdks/dotnet/src/AGUI.Client/Internal/EventStreamConverter.cs b/sdks/dotnet/src/AGUI.Client/Internal/EventStreamConverter.cs index df7007aa2a..d5d69bf659 100644 --- a/sdks/dotnet/src/AGUI.Client/Internal/EventStreamConverter.cs +++ b/sdks/dotnet/src/AGUI.Client/Internal/EventStreamConverter.cs @@ -164,7 +164,14 @@ internal static async IAsyncEnumerable AsChatResponseUpdates case RunErrorEvent errorEvent: runError = true; - throw new System.InvalidOperationException(errorEvent.Message); + yield return new ChatResponseUpdate(ChatRole.Assistant, + [new ErrorContent(errorEvent.Message) { ErrorCode = errorEvent.Code }]) + { + ConversationId = conversationId, + ResponseId = responseId, + RawRepresentation = errorEvent + }; + break; case TextMessageStartEvent textStart: textMessageBuilder.AddTextStart(textStart); diff --git a/sdks/dotnet/tests/AGUI.Client.UnitTests/ProtocolRuleTest.cs b/sdks/dotnet/tests/AGUI.Client.UnitTests/ProtocolRuleTest.cs index 519c291c85..368ea5fc5d 100644 --- a/sdks/dotnet/tests/AGUI.Client.UnitTests/ProtocolRuleTest.cs +++ b/sdks/dotnet/tests/AGUI.Client.UnitTests/ProtocolRuleTest.cs @@ -44,7 +44,7 @@ public async Task ValidCompleteSequence_ProducesAllEvents() } [Fact] - public async Task RunError_ThrowsInvalidOperationException() + public async Task RunError_ProducesErrorContentUpdate() { var events = new BaseEvent[] { @@ -52,22 +52,28 @@ public async Task RunError_ThrowsInvalidOperationException() new RunErrorEvent { Message = "Something failed", Code = "ERR01" } }; - var ex = await Assert.ThrowsAsync( - () => ProcessEventsAsync(events)); - Assert.Contains("Something failed", ex.Message); + var result = await ProcessEventsAsync(events); + var update = result[1]; + var error = Assert.IsType(Assert.Single(update.Contents)); + Assert.Equal("Something failed", error.Message); + Assert.Equal("ERR01", error.ErrorCode); + Assert.IsType(update.RawRepresentation); + Assert.Equal("t1", update.ConversationId); + Assert.Equal("r1", update.ResponseId); } [Fact] - public async Task RunErrorAsFirstEvent_ThrowsInvalidOperationException() + public async Task RunErrorAsFirstEvent_ProducesErrorContentUpdate() { var events = new BaseEvent[] { new RunErrorEvent { Message = "Immediate failure" } }; - var ex = await Assert.ThrowsAsync( - () => ProcessEventsAsync(events)); - Assert.Contains("Immediate failure", ex.Message); + var result = await ProcessEventsAsync(events); + var error = Assert.IsType(Assert.Single(Assert.Single(result).Contents)); + Assert.Equal("Immediate failure", error.Message); + Assert.Null(error.ErrorCode); } [Fact] @@ -694,11 +700,7 @@ public async Task Lifecycle_RunErrorAsFirstEvent_IsAllowed() new RunErrorEvent { Message = "Immediate failure" } }; - // RunErrorEvent as first event is allowed by the verifier; our ProcessEventsAsync - // then throws as part of handling, which is the expected application behavior. - var ex = await Assert.ThrowsAsync( - () => ProcessEventsAsync(events)); - Assert.Contains("Immediate failure", ex.Message); + Assert.Single(await ProcessEventsAsync(events)); } [Fact] @@ -719,7 +721,7 @@ public async Task Lifecycle_NoEventsAfterRunFinished() [Fact] public async Task Lifecycle_NoEventsAfterRunError() { - // RunError terminates the stream — subsequent events are never processed. + // RunError emits an ErrorContent update and terminates the run; subsequent events are rejected. var events = new BaseEvent[] { new RunStartedEvent { ThreadId = "t1", RunId = "r1" }, @@ -729,7 +731,7 @@ public async Task Lifecycle_NoEventsAfterRunError() var ex = await Assert.ThrowsAsync( () => ProcessEventsAsync(events)); - Assert.Contains("boom", ex.Message); + Assert.Contains("already errored", ex.Message); } [Fact] @@ -782,7 +784,7 @@ public async Task Lifecycle_RunFinishedWithActiveToolCalls_Throws() [Fact] public async Task Lifecycle_RunErrorAfterRunFinished_StillTerminal() { - // After RUN_FINISHED, RUN_ERROR is allowed but makes run terminal + // RUN_ERROR remains allowed after RUN_FINISHED, emits an ErrorContent update, and makes the stream terminal. var events = new BaseEvent[] { new RunStartedEvent { ThreadId = "t1", RunId = "r1" }, @@ -793,7 +795,7 @@ public async Task Lifecycle_RunErrorAfterRunFinished_StillTerminal() var ex = await Assert.ThrowsAsync( () => ProcessEventsAsync(events)); - Assert.Contains("late error", ex.Message); + Assert.Contains("already errored", ex.Message); } // ──────────────────────────────────────────────── @@ -914,9 +916,9 @@ public async Task MultiRun_ThreeSequentialRuns_Succeeds() } [Fact] - public async Task MultiRun_RunErrorBlocksSubsequentEventsInSameRun() + public async Task MultiRun_RunErrorBlocksAnotherRunInSameStream() { - // RunError terminates the stream — the second RunStarted is never reached. + // RunError emits an ErrorContent update and prevents another run from starting in the same stream. var events = new BaseEvent[] { new RunStartedEvent { ThreadId = "t1", RunId = "r1" }, @@ -926,7 +928,7 @@ public async Task MultiRun_RunErrorBlocksSubsequentEventsInSameRun() var ex = await Assert.ThrowsAsync( () => ProcessEventsAsync(events)); - Assert.Contains("boom", ex.Message); + Assert.Contains("already errored", ex.Message); } // ──────────────────────────────────────────────── diff --git a/sdks/dotnet/tests/AGUI.CrossLanguage.IntegrationTests/PassthroughEventTests.cs b/sdks/dotnet/tests/AGUI.CrossLanguage.IntegrationTests/PassthroughEventTests.cs index 145d36b069..ad09cb331d 100644 --- a/sdks/dotnet/tests/AGUI.CrossLanguage.IntegrationTests/PassthroughEventTests.cs +++ b/sdks/dotnet/tests/AGUI.CrossLanguage.IntegrationTests/PassthroughEventTests.cs @@ -74,26 +74,28 @@ public async Task RawEvent_SurfacesViaRawRepresentation() } [Fact] - public async Task RunError_ThrowsInvalidOperationException() + public async Task RunError_SurfacesErrorContent() { - // Per ProtocolRuleTest.RunError_ThrowsInvalidOperationException - // the C# client surfaces RUN_ERROR as InvalidOperationException - // with the message verbatim. Same contract over the wire. + // RUN_ERROR crosses the language boundary as ErrorContent so callers + // can inspect both the error message and the protocol error code. using HttpClient http = new() { Timeout = TimeSpan.FromSeconds(10) }; AGUIChatClient client = new(new(http, $"{_fixture.BaseUrl}/run_error")); using CancellationTokenSource cts = new(TimeSpan.FromSeconds(20)); - InvalidOperationException ex = await Assert.ThrowsAsync(async () => + List updates = []; + await foreach (ChatResponseUpdate update in client + .GetStreamingResponseAsync( + [new(ChatRole.User, "fail")], + cancellationToken: cts.Token) + .ConfigureAwait(false)) { - await foreach (ChatResponseUpdate _ in client - .GetStreamingResponseAsync( - [new(ChatRole.User, "fail")], - cancellationToken: cts.Token) - .ConfigureAwait(false)) - { - } - }); + updates.Add(update); + } - Assert.Contains("fake agent: simulated upstream failure", ex.Message); + ChatResponseUpdate errorUpdate = Assert.Single(updates, u => u.Contents.Any(c => c is ErrorContent)); + ErrorContent error = Assert.IsType(Assert.Single(errorUpdate.Contents)); + Assert.Equal("fake agent: simulated upstream failure", error.Message); + Assert.Equal("FAKE_AGENT_FAILURE", error.ErrorCode); + Assert.IsType(errorUpdate.RawRepresentation); } } diff --git a/sdks/dotnet/tests/CrossLanguage.Vitest/server/fakeAgents.ts b/sdks/dotnet/tests/CrossLanguage.Vitest/server/fakeAgents.ts index f73c35b26f..3edc778b08 100644 --- a/sdks/dotnet/tests/CrossLanguage.Vitest/server/fakeAgents.ts +++ b/sdks/dotnet/tests/CrossLanguage.Vitest/server/fakeAgents.ts @@ -321,8 +321,7 @@ export function rawEventRun(input: RunAgentInput): BaseEvent[] { /** * Server starts the run normally then emits RUN_ERROR. The C# client - * surfaces this as an InvalidOperationException with the error message - * (per ProtocolRuleTest.RunError_ThrowsInvalidOperationException). + * surfaces this as ErrorContent with the event's message and error code. */ export function runErrorScenario(input: RunAgentInput): BaseEvent[] { const events: BaseEvent[] = [runStarted(input)];