Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 3 additions & 1 deletion docs/sdk/dotnet/client/transport.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 2 additions & 0 deletions sdks/dotnet/docs/architecture.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -164,7 +164,14 @@ internal static async IAsyncEnumerable<ChatResponseUpdate> 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);
Expand Down
42 changes: 22 additions & 20 deletions sdks/dotnet/tests/AGUI.Client.UnitTests/ProtocolRuleTest.cs
Original file line number Diff line number Diff line change
Expand Up @@ -44,30 +44,36 @@ public async Task ValidCompleteSequence_ProducesAllEvents()
}

[Fact]
public async Task RunError_ThrowsInvalidOperationException()
public async Task RunError_ProducesErrorContentUpdate()
{
var events = new BaseEvent[]
{
new RunStartedEvent { ThreadId = "t1", RunId = "r1" },
new RunErrorEvent { Message = "Something failed", Code = "ERR01" }
};

var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => ProcessEventsAsync(events));
Assert.Contains("Something failed", ex.Message);
var result = await ProcessEventsAsync(events);
var update = result[1];
var error = Assert.IsType<ErrorContent>(Assert.Single(update.Contents));
Assert.Equal("Something failed", error.Message);
Assert.Equal("ERR01", error.ErrorCode);
Assert.IsType<RunErrorEvent>(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<InvalidOperationException>(
() => ProcessEventsAsync(events));
Assert.Contains("Immediate failure", ex.Message);
var result = await ProcessEventsAsync(events);
var error = Assert.IsType<ErrorContent>(Assert.Single(Assert.Single(result).Contents));
Assert.Equal("Immediate failure", error.Message);
Assert.Null(error.ErrorCode);
}

[Fact]
Expand Down Expand Up @@ -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<InvalidOperationException>(
() => ProcessEventsAsync(events));
Assert.Contains("Immediate failure", ex.Message);
Assert.Single(await ProcessEventsAsync(events));
}

[Fact]
Expand All @@ -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" },
Expand All @@ -729,7 +731,7 @@ public async Task Lifecycle_NoEventsAfterRunError()

var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => ProcessEventsAsync(events));
Assert.Contains("boom", ex.Message);
Assert.Contains("already errored", ex.Message);
}

[Fact]
Expand Down Expand Up @@ -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" },
Expand All @@ -793,7 +795,7 @@ public async Task Lifecycle_RunErrorAfterRunFinished_StillTerminal()

var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => ProcessEventsAsync(events));
Assert.Contains("late error", ex.Message);
Assert.Contains("already errored", ex.Message);
}

// ────────────────────────────────────────────────
Expand Down Expand Up @@ -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" },
Expand All @@ -926,7 +928,7 @@ public async Task MultiRun_RunErrorBlocksSubsequentEventsInSameRun()

var ex = await Assert.ThrowsAsync<InvalidOperationException>(
() => ProcessEventsAsync(events));
Assert.Contains("boom", ex.Message);
Assert.Contains("already errored", ex.Message);
}

// ────────────────────────────────────────────────
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<InvalidOperationException>(async () =>
List<ChatResponseUpdate> 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<ErrorContent>(Assert.Single(errorUpdate.Contents));
Assert.Equal("fake agent: simulated upstream failure", error.Message);
Assert.Equal("FAKE_AGENT_FAILURE", error.ErrorCode);
Assert.IsType<RunErrorEvent>(errorUpdate.RawRepresentation);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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)];
Expand Down
Loading