diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/AzureAgentProvider.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/AzureAgentProvider.cs
index 0a673e2aa5d..ac5e7056ecc 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/AzureAgentProvider.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative.Foundry/AzureAgentProvider.cs
@@ -4,6 +4,7 @@
using System.ClientModel.Primitives;
using System.Collections.Generic;
using System.Collections.ObjectModel;
+using System.Diagnostics.CodeAnalysis;
using System.Net.Http;
using System.Runtime.CompilerServices;
using System.Text.Json.Nodes;
@@ -132,13 +133,80 @@ messages is not null ?
agent.RunStreamingAsync([.. messages], null, runOptions, cancellationToken) :
agent.RunStreamingAsync([], null, runOptions, cancellationToken);
- await foreach (AgentResponseUpdate update in agentResponse.ConfigureAwait(false))
+ await foreach (AgentResponseUpdate update in WithFailureDetectionAsync(agentResponse, agentVersionResult.Name, cancellationToken).ConfigureAwait(false))
{
- update.AuthorName = agentVersionResult.Name;
yield return update;
}
}
+ ///
+ /// Surfaces a failed Responses API run as .
+ ///
+ ///
+ ///
+ /// Microsoft.Extensions.AI.OpenAI maps the response.failed event onto a
+ /// contentless update, leaving a failed run indistinguishable from an empty successful one.
+ ///
+ ///
+ /// The failed update is replaced rather than supplemented: it carries the provider's error text
+ /// in its raw representation, and updates reach clients verbatim regardless of the host's
+ /// exception-detail policy.
+ ///
+ ///
+ internal static async IAsyncEnumerable WithFailureDetectionAsync(
+ IAsyncEnumerable updates,
+ string? authorName,
+ [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ await foreach (AgentResponseUpdate update in updates.WithCancellation(cancellationToken).ConfigureAwait(false))
+ {
+ update.AuthorName = authorName;
+
+ yield return TryCreateFailureUpdate(update, authorName, out AgentResponseUpdate? failureUpdate)
+ ? failureUpdate
+ : update;
+ }
+ }
+
+ ///
+ /// Builds an update when represents a failed run.
+ ///
+ private static bool TryCreateFailureUpdate(
+ AgentResponseUpdate update,
+ string? authorName,
+ [NotNullWhen(true)] out AgentResponseUpdate? failureUpdate)
+ {
+ failureUpdate = null;
+
+ if (update.RawRepresentation is not ChatResponseUpdate chatUpdate ||
+ chatUpdate.RawRepresentation is not StreamingResponseFailedUpdate failedUpdate)
+ {
+ return false;
+ }
+
+ ResponseError? error = failedUpdate.Response?.Error;
+
+ // A failure with no detail must still explain itself to the client.
+ ErrorContent errorContent =
+ new(string.IsNullOrWhiteSpace(error?.Message) ? DefaultFailureMessage : error!.Message)
+ {
+ ErrorCode = error?.Code.ToString() is { Length: > 0 } code ? code : DefaultFailureCode,
+ };
+
+ failureUpdate =
+ new(ChatRole.Assistant, [errorContent])
+ {
+ AuthorName = authorName,
+ ResponseId = update.ResponseId ?? failedUpdate.Response?.Id,
+ CreatedAt = update.CreatedAt,
+ };
+
+ return true;
+ }
+
+ private const string DefaultFailureMessage = "The agent run failed.";
+ private const string DefaultFailureCode = "failed";
+
private async Task QueryAgentAsync(string agentName, string? agentVersion, CancellationToken cancellationToken = default)
{
string agentKey = $"{agentName}:{agentVersion}";
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/AgentProviderExtensions.cs b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/AgentProviderExtensions.cs
index d7a1d5f7157..bd9d590987e 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/AgentProviderExtensions.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows.Declarative/Extensions/AgentProviderExtensions.cs
@@ -77,7 +77,9 @@ public static async ValueTask InvokeAgentAsync(
updates.Add(update);
- if (autoSend)
+ // Error updates are withheld: they reach the client verbatim, bypassing the host's
+ // exception-detail policy. The detail still arrives via the thrown exception.
+ if (autoSend && !HasError(update))
{
await context.AddEventAsync(new AgentResponseUpdateEvent(executorId, update), cancellationToken).ConfigureAwait(false);
}
@@ -85,6 +87,9 @@ public static async ValueTask InvokeAgentAsync(
AgentResponse response = updates.ToAgentResponse();
+ // Fail before the response is announced as completed or copied to the conversation.
+ ThrowIfFailed(response, agentName);
+
if (autoSend)
{
await context.AddEventAsync(new AgentResponseEvent(executorId, response), cancellationToken).ConfigureAwait(false);
@@ -111,4 +116,41 @@ async ValueTask AssignConversationIdAsync(string? assignValue)
}
}
}
+
+ ///
+ /// Indicates whether an update carries an agent error rather than usable content.
+ ///
+ private static bool HasError(AgentResponseUpdate update) =>
+ update.Contents.Any(content => content is ErrorContent);
+
+ ///
+ /// Fails the action when the agent reported an error rather than a usable response.
+ ///
+ ///
+ /// Any top-level is a failure, covering both a failed run and a
+ /// refusal, and excluding incomplete, which carries partial content instead. The detail
+ /// is folded into the exception so one error path stays under the host's exception-detail policy.
+ ///
+ private static void ThrowIfFailed(AgentResponse response, string agentName)
+ {
+ // The last error wins: a run that fails without detail yields a generic placeholder first,
+ // and the specific cause follows as its own error.
+ ErrorContent? error =
+ response.Messages
+ .SelectMany(message => message.Contents)
+ .OfType()
+ .LastOrDefault();
+
+ if (error is null)
+ {
+ return;
+ }
+
+ string errorCode = string.IsNullOrWhiteSpace(error.ErrorCode) ? "unknown" : error.ErrorCode!;
+ string errorMessage = string.IsNullOrWhiteSpace(error.Message) ? "No error message was provided." : error.Message!;
+
+ // No inner exception: DeclarativeActionException is unwrapped to its inner exception when
+ // reported, which would discard this message.
+ throw new DeclarativeActionException($"Agent '{agentName}' failed [{errorCode}]: {errorMessage}");
+ }
}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/AgentUpdateTestHelpers.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/AgentUpdateTestHelpers.cs
new file mode 100644
index 00000000000..a1bb3a44fa6
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/AgentUpdateTestHelpers.cs
@@ -0,0 +1,98 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.ClientModel.Primitives;
+using System.Collections.Generic;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using OpenAI.Responses;
+
+namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests;
+
+///
+/// Helpers for building and driving sequences in tests.
+///
+internal static class AgentUpdateTestHelpers
+{
+ ///
+ /// The response identifier carried by updates built with .
+ ///
+ private const string FailedResponseId = "resp_test";
+
+ ///
+ /// Presents updates as the asynchronous sequence a returns.
+ ///
+ public static async IAsyncEnumerable ToAsyncEnumerableAsync(IEnumerable updates)
+ {
+ foreach (AgentResponseUpdate update in updates)
+ {
+ yield return update;
+ }
+
+ await Task.CompletedTask;
+ }
+
+ ///
+ /// Runs updates through the provider's failure-detection transform, reproducing what
+ /// emits when streaming an agent run.
+ ///
+ public static async Task> ApplyFailureDetectionAsync(
+ string agentName,
+ params AgentResponseUpdate[] updates)
+ {
+ List results = [];
+
+ await foreach (AgentResponseUpdate update in
+ AzureAgentProvider.WithFailureDetectionAsync(ToAsyncEnumerableAsync(updates), agentName))
+ {
+ results.Add(update);
+ }
+
+ return results;
+ }
+
+ ///
+ /// Builds the update shape Microsoft.Extensions.AI.OpenAI produces for a Responses
+ /// response.failed event. Pass a null to model a failure
+ /// that carries no error detail.
+ ///
+ public static AgentResponseUpdate CreateFailedUpdate(string? errorCode, string? errorMessage)
+ {
+ string errorJson =
+ errorCode is null
+ ? "null"
+ : $$"""{"code":"{{errorCode}}","message":"{{errorMessage}}"}""";
+
+ string payload =
+ $$"""
+ {
+ "type": "response.failed",
+ "sequence_number": 1,
+ "response": {
+ "id": "{{FailedResponseId}}",
+ "object": "response",
+ "created_at": 1700000000,
+ "status": "failed",
+ "model": "gpt-test",
+ "output": [],
+ "error": {{errorJson}}
+ }
+ }
+ """;
+
+ StreamingResponseUpdate streamingUpdate =
+ ModelReaderWriter.Read(BinaryData.FromString(payload))!;
+
+ // Guards the assumption the production unwrap depends on.
+ Assert.IsType(streamingUpdate);
+
+ ChatResponseUpdate chatUpdate =
+ new(ChatRole.Assistant, (IList?)null)
+ {
+ ResponseId = FailedResponseId,
+ RawRepresentation = streamingUpdate,
+ };
+
+ return new AgentResponseUpdate(chatUpdate);
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/AzureAgentProviderFailureTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/AzureAgentProviderFailureTest.cs
new file mode 100644
index 00000000000..2575051d061
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/AzureAgentProviderFailureTest.cs
@@ -0,0 +1,81 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using OpenAI.Responses;
+
+namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests;
+
+///
+/// Verifies that restores the failure signal that
+/// Microsoft.Extensions.AI.OpenAI drops when it maps the Responses response.failed
+/// event onto a contentless update.
+///
+public sealed class AzureAgentProviderFailureTest(ITestOutputHelper output) : WorkflowTest(output)
+{
+ private const string AgentName = "TestAgent";
+
+ [Fact]
+ public async Task FailedResponseYieldsErrorContentAsync()
+ {
+ // Arrange
+ AgentResponseUpdate failed = FailedUpdate("server_error", "Something went wrong.");
+
+ // Act
+ AgentResponseUpdate[] results = await CollectAsync(failed);
+
+ // Assert
+ // The failed update is replaced, not supplemented: its raw representation carries provider
+ // error text that must not reach clients.
+ AgentResponseUpdate result = Assert.Single(results);
+ ErrorContent error = Assert.Single(result.Contents.OfType());
+ Assert.Equal("Something went wrong.", error.Message);
+ Assert.Equal("server_error", error.ErrorCode);
+ Assert.Null((result.RawRepresentation as ChatResponseUpdate)?.RawRepresentation as StreamingResponseFailedUpdate);
+
+ // Correlation is carried onto the replacement.
+ Assert.Equal(AgentName, result.AuthorName);
+ Assert.Equal(failed.ResponseId, result.ResponseId);
+ }
+
+ [Fact]
+ public async Task FailedResponseWithoutErrorDetailUsesFallbackAsync()
+ {
+ // Arrange - a failed response that carries no error object.
+ AgentResponseUpdate failed = FailedUpdate(errorCode: null, errorMessage: null);
+
+ // Act
+ AgentResponseUpdate[] results = await CollectAsync(failed);
+
+ // Assert - a failure with no detail must still explain itself.
+ ErrorContent error = Assert.Single(results.SelectMany(update => update.Contents).OfType());
+ Assert.False(string.IsNullOrWhiteSpace(error.Message));
+ Assert.False(string.IsNullOrWhiteSpace(error.ErrorCode));
+ }
+
+ [Fact]
+ public async Task SuccessfulUpdatesPassThroughUntouchedAsync()
+ {
+ // Arrange
+ AgentResponseUpdate text = new(ChatRole.Assistant, [new TextContent("All good.")]);
+ AgentResponseUpdate contentless = new(ChatRole.Assistant, []);
+
+ // Act
+ AgentResponseUpdate[] results = await CollectAsync(text, contentless);
+
+ // Assert - no synthesized failure is introduced.
+ Assert.Equal(2, results.Length);
+ Assert.Empty(results.SelectMany(update => update.Contents).OfType());
+ Assert.All(results, update => Assert.Equal(AgentName, update.AuthorName));
+ }
+
+ private static async Task CollectAsync(params AgentResponseUpdate[] updates) =>
+ [.. await AgentUpdateTestHelpers.ApplyFailureDetectionAsync(AgentName, updates)];
+
+ ///
+ /// Builds the update shape produced for a response.failed event.
+ ///
+ private static AgentResponseUpdate FailedUpdate(string? errorCode, string? errorMessage) =>
+ AgentUpdateTestHelpers.CreateFailedUpdate(errorCode, errorMessage);
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/InvokeAgentFailureTest.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/InvokeAgentFailureTest.cs
new file mode 100644
index 00000000000..ecc7ada7214
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/InvokeAgentFailureTest.cs
@@ -0,0 +1,332 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System;
+using System.Collections.Generic;
+using System.IO;
+using System.Linq;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.Extensions.AI;
+using Moq;
+using OpenAI.Responses;
+
+namespace Microsoft.Agents.AI.Workflows.Declarative.UnitTests;
+
+///
+/// Verifies that an agent which reports a failure aborts the workflow instead of silently
+/// advancing to the next action and finalizing as a successful, empty response.
+///
+public sealed class InvokeAgentFailureTest(ITestOutputHelper output) : WorkflowTest(output)
+{
+ private const string FollowupWorkflow = "AgentFailureFollowup.yaml";
+ private const string NoAutoSendWorkflow = "AgentFailureNoAutoSend.yaml";
+ private const string DownstreamActionId = "after_agent";
+ private const string AgentActionId = "invoke_agent";
+
+ ///
+ /// Matches the agent name declared by the test workflow YAML.
+ ///
+ private const string AgentName = "TestAgent";
+
+ ///
+ /// A response carrying fails the action. Refusals surface through the
+ /// same content type, so they are covered by the same rule.
+ ///
+ [Theory]
+ [InlineData("Agent run failed.", "server_error")]
+ [InlineData("I cannot help with that.", "Refusal")]
+ public async Task ErrorContentResponseFailsWorkflowAsync(string message, string errorCode)
+ {
+ // Arrange
+ List updates = [ErrorUpdate(message, errorCode)];
+
+ // Act
+ WorkflowEvent[] events = await this.RunWorkflowAsync(FollowupWorkflow, updates);
+
+ // Assert
+ Assert.Contains(events, e => e is ExecutorFailedEvent);
+ this.AssertNotExecuted(events, DownstreamActionId);
+ }
+
+ ///
+ /// The failure is detected on the aggregated response, so it is independent of whether the
+ /// response was forwarded as workflow output.
+ ///
+ [Fact]
+ public async Task ErrorContentResponseFailsWorkflowWhenAutoSendDisabledAsync()
+ {
+ // Arrange
+ List updates = [ErrorUpdate("Agent run failed.", "server_error")];
+
+ // Act
+ WorkflowEvent[] events = await this.RunWorkflowAsync(NoAutoSendWorkflow, updates);
+
+ // Assert
+ Assert.Contains(events, e => e is ExecutorFailedEvent);
+
+ // Response updates are emitted only on the autoSend path, confirming autoSend is off.
+ Assert.DoesNotContain(events, e => e is AgentResponseUpdateEvent);
+ }
+
+ ///
+ /// A contentless update is not a failure signal to the engine. Translating a failed Responses
+ /// run into is the provider's job, so a mocked provider cannot
+ /// produce that signal. See AzureAgentProviderFailureTest.
+ ///
+ [Fact]
+ public async Task ContentlessResponseDoesNotFailWorkflowAsync()
+ {
+ // Arrange
+ List updates = [new(ChatRole.Assistant, []) { ResponseId = "resp_empty" }];
+
+ // Act
+ WorkflowEvent[] events = await this.RunWorkflowAsync(FollowupWorkflow, updates);
+
+ // Assert
+ Assert.DoesNotContain(events, e => e is ExecutorFailedEvent);
+ this.AssertExecuted(events, DownstreamActionId);
+ }
+
+ ///
+ /// Control: a transport failure aborts the workflow.
+ ///
+ [Fact]
+ public async Task ThrownExceptionFailsWorkflowAsync()
+ {
+ // Arrange & Act
+ WorkflowEvent[] events = await this.RunWorkflowAsync(FollowupWorkflow, updates: null, throwOnInvoke: true);
+
+ // Assert
+ Assert.Contains(events, e => e is ExecutorFailedEvent);
+ this.AssertNotExecuted(events, DownstreamActionId);
+ }
+
+ ///
+ /// A successful response still completes the workflow, so the failure rule does not capture
+ /// ordinary responses.
+ ///
+ [Fact]
+ public async Task SuccessfulResponseCompletesWorkflowAsync()
+ {
+ // Arrange
+ List updates = [new(ChatRole.Assistant, [new TextContent("All good.")])];
+
+ // Act
+ WorkflowEvent[] events = await this.RunWorkflowAsync(FollowupWorkflow, updates);
+
+ // Assert
+ Assert.DoesNotContain(events, e => e is ExecutorFailedEvent);
+ this.AssertExecuted(events, AgentActionId);
+ this.AssertExecuted(events, DownstreamActionId);
+ }
+
+ ///
+ /// A failed run reaching the hosting boundary surfaces an error rather than an empty response.
+ /// Exercises both halves of the fix: the failure arrives with no content and must be translated
+ /// before the engine can act on it.
+ ///
+ [Fact]
+ public async Task FailedResponseSurfacesErrorToHostedAgentAsync()
+ {
+ // Arrange
+ List updates =
+ await AgentUpdateTestHelpers.ApplyFailureDetectionAsync(
+ AgentName,
+ AgentUpdateTestHelpers.CreateFailedUpdate("server_error", "Something went wrong."));
+ AIAgent hostAgent =
+ CreateWorkflow(FollowupWorkflow, updates, throwOnInvoke: false)
+ .AsAIAgent(id: "host", name: "host", includeExceptionDetails: true);
+
+ // Act
+ AgentResponse response = await hostAgent.RunAsync("Test input message");
+
+ // Assert
+ ErrorContent[] errors = [.. response.Messages.SelectMany(message => message.Contents).OfType()];
+ Assert.NotEmpty(errors);
+ Assert.Contains(
+ errors,
+ error =>
+ error.Message.Contains(AgentName, StringComparison.Ordinal) &&
+ error.Message.Contains("server_error", StringComparison.Ordinal) &&
+ error.Message.Contains("Something went wrong.", StringComparison.Ordinal));
+ }
+
+ ///
+ /// The agent's raw error text must not reach the client unless the host opted into exception
+ /// detail. The failure is reported either way; only the detail is withheld.
+ ///
+ [Fact]
+ public async Task FailedResponseIsRedactedFromHostedAgentByDefaultAsync()
+ {
+ // Arrange
+ const string ProviderDetail = "Deployment 'internal-gpt-x' quota exceeded.";
+ List updates =
+ await AgentUpdateTestHelpers.ApplyFailureDetectionAsync(
+ AgentName,
+ AgentUpdateTestHelpers.CreateFailedUpdate("server_error", ProviderDetail));
+ AIAgent hostAgent =
+ CreateWorkflow(FollowupWorkflow, updates, throwOnInvoke: false)
+ .AsAIAgent(id: "host", name: "host");
+
+ // Act
+ AgentResponse response = await hostAgent.RunAsync("Test input message");
+
+ // Assert
+ ErrorContent[] errors = [.. response.Messages.SelectMany(message => message.Contents).OfType()];
+ Assert.NotEmpty(errors);
+ Assert.DoesNotContain(errors, error => error.Message.Contains(ProviderDetail, StringComparison.Ordinal));
+ Assert.DoesNotContain(errors, error => error.Message.Contains(AgentName, StringComparison.Ordinal));
+ }
+
+ ///
+ /// Raw provider detail must not reach streaming callers either. Updates are forwarded verbatim,
+ /// so detail carried in a raw representation would bypass the exception-detail policy even
+ /// though the visible content is redacted.
+ ///
+ [Fact]
+ public async Task FailedResponseIsRedactedFromHostedStreamByDefaultAsync()
+ {
+ // Arrange
+ const string ProviderDetail = "Deployment 'internal-gpt-x' quota exceeded.";
+ List updates =
+ await AgentUpdateTestHelpers.ApplyFailureDetectionAsync(
+ AgentName,
+ AgentUpdateTestHelpers.CreateFailedUpdate("server_error", ProviderDetail));
+ AIAgent hostAgent =
+ CreateWorkflow(FollowupWorkflow, updates, throwOnInvoke: false)
+ .AsAIAgent(id: "host", name: "host");
+
+ // Act
+ List streamed = [];
+ await foreach (AgentResponseUpdate update in hostAgent.RunStreamingAsync("Test input message"))
+ {
+ streamed.Add(update);
+ }
+
+ // Assert - the provider's own failure object never reaches the client.
+ Assert.DoesNotContain(
+ streamed,
+ update => (update.RawRepresentation as ChatResponseUpdate)?.RawRepresentation is StreamingResponseFailedUpdate);
+ Assert.DoesNotContain(
+ streamed.SelectMany(update => update.Contents).OfType(),
+ error => error.Message.Contains(ProviderDetail, StringComparison.Ordinal));
+ }
+
+ ///
+ /// Both halves composed: a response.failed event arrives contentless, the provider
+ /// translates it into , and the engine aborts rather than advancing
+ /// to the next action.
+ ///
+ [Fact]
+ public async Task FailedResponseFromProviderFailsWorkflowAsync()
+ {
+ // Arrange - the provider emits what AzureAgentProvider produces for a failed run.
+ List updates =
+ await AgentUpdateTestHelpers.ApplyFailureDetectionAsync(
+ AgentName,
+ AgentUpdateTestHelpers.CreateFailedUpdate("server_error", "Something went wrong."));
+
+ // Act
+ WorkflowEvent[] events = await this.RunWorkflowAsync(FollowupWorkflow, updates);
+
+ // Assert
+ Assert.Contains(events, e => e is ExecutorFailedEvent);
+ this.AssertNotExecuted(events, DownstreamActionId);
+ }
+
+ ///
+ /// A failed run that carries no error detail is reported by the provider as a generic
+ /// placeholder, and the specific cause follows as its own error. The specific cause must win.
+ ///
+ [Fact]
+ public async Task SpecificErrorTakesPrecedenceOverGenericFallbackAsync()
+ {
+ // Arrange - response.failed with a null error, then the follow-up carrying the real cause.
+ const string SpecificDetail = "Rate limit exceeded for deployment.";
+ List updates =
+ await AgentUpdateTestHelpers.ApplyFailureDetectionAsync(
+ AgentName,
+ AgentUpdateTestHelpers.CreateFailedUpdate(errorCode: null, errorMessage: null));
+ updates.Add(ErrorUpdate(SpecificDetail, "rate_limit"));
+
+ AIAgent hostAgent =
+ CreateWorkflow(FollowupWorkflow, updates, throwOnInvoke: false)
+ .AsAIAgent(id: "host", name: "host", includeExceptionDetails: true);
+
+ // Act
+ AgentResponse response = await hostAgent.RunAsync("Test input message");
+
+ // Assert
+ ErrorContent[] errors = [.. response.Messages.SelectMany(message => message.Contents).OfType()];
+ Assert.Contains(errors, error => error.Message.Contains(SpecificDetail, StringComparison.Ordinal));
+ Assert.DoesNotContain(errors, error => error.Message.Contains("The agent run failed.", StringComparison.Ordinal));
+ }
+
+ private static AgentResponseUpdate ErrorUpdate(string message, string errorCode) =>
+ new(ChatRole.Assistant, [new ErrorContent(message) { ErrorCode = errorCode }]) { ResponseId = "resp_error" };
+
+ private void AssertExecuted(IEnumerable events, string executorId) =>
+ Assert.Contains(events.OfType(), e => e.ExecutorId == executorId);
+
+ private void AssertNotExecuted(IEnumerable events, string executorId) =>
+ Assert.DoesNotContain(events.OfType(), e => e.ExecutorId == executorId);
+
+ private async Task RunWorkflowAsync(
+ string workflowFile,
+ List? updates,
+ bool throwOnInvoke = false)
+ {
+ List events = [];
+
+ Workflow workflow = CreateWorkflow(workflowFile, updates, throwOnInvoke);
+ await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, "Test input message");
+
+ await foreach (WorkflowEvent workflowEvent in run.WatchStreamAsync())
+ {
+ events.Add(workflowEvent);
+ this.Output.WriteLine($"EVENT: {workflowEvent.GetType().Name} {Describe(workflowEvent)}");
+ }
+
+ return [.. events];
+ }
+
+ private static string Describe(WorkflowEvent workflowEvent) =>
+ workflowEvent switch
+ {
+ ExecutorCompletedEvent e => e.ExecutorId,
+ ExecutorFailedEvent e => $"{e.ExecutorId}: {e.Data?.Message}",
+ AgentResponseEvent e => $"messages={e.Response.Messages.Count}",
+ _ => string.Empty,
+ };
+
+ private static Workflow CreateWorkflow(string workflowFile, List? updates, bool throwOnInvoke)
+ {
+ Mock provider = new(MockBehavior.Strict);
+ provider.Setup(p => p.CreateConversationAsync(It.IsAny()))
+ .Returns(() => Task.FromResult(Guid.NewGuid().ToString("N")));
+ provider.Setup(p => p.CreateMessageAsync(It.IsAny(), It.IsAny(), It.IsAny()))
+ .Returns((_, message, _) => Task.FromResult(message));
+ provider.Setup(
+ p => p.InvokeAgentAsync(
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny(),
+ It.IsAny?>(),
+ It.IsAny?>(),
+ It.IsAny()))
+ .Returns(() => throwOnInvoke ? ThrowAsync() : AgentUpdateTestHelpers.ToAsyncEnumerableAsync(updates ?? []));
+
+ using StreamReader yamlReader = File.OpenText(Path.Combine("Workflows", workflowFile));
+ DeclarativeWorkflowOptions options = new(provider.Object);
+ return DeclarativeWorkflowBuilder.Build(yamlReader, options);
+ }
+
+ private static async IAsyncEnumerable ThrowAsync()
+ {
+ await Task.CompletedTask;
+ throw new InvalidOperationException("Simulated transport failure");
+#pragma warning disable CS0162 // Unreachable code detected
+ yield break;
+#pragma warning restore CS0162
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests.csproj b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests.csproj
index c95266dd6e4..57d4858dbbe 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests.csproj
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests.csproj
@@ -2,10 +2,12 @@
true
+ true
+
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/AgentFailureFollowup.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/AgentFailureFollowup.yaml
new file mode 100644
index 00000000000..29bc7119ee3
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/AgentFailureFollowup.yaml
@@ -0,0 +1,20 @@
+kind: Workflow
+trigger:
+
+ kind: OnConversationStart
+ id: my_workflow
+ actions:
+
+ - kind: InvokeAzureAgent
+ id: invoke_agent
+ conversationId: =System.ConversationId
+ agent:
+ name: TestAgent
+ input:
+ messages: =[UserMessage(System.LastMessageText)]
+ output:
+ messages: Local.AgentResponse
+
+ - kind: SendActivity
+ id: after_agent
+ activity: AFTER_AGENT
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/AgentFailureNoAutoSend.yaml b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/AgentFailureNoAutoSend.yaml
new file mode 100644
index 00000000000..4878f41e32f
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.Declarative.UnitTests/Workflows/AgentFailureNoAutoSend.yaml
@@ -0,0 +1,16 @@
+kind: Workflow
+trigger:
+
+ kind: OnConversationStart
+ id: my_workflow
+ actions:
+
+ - kind: InvokeAzureAgent
+ id: invoke_agent
+ agent:
+ name: TestAgent
+ input:
+ messages: =[UserMessage(System.LastMessageText)]
+ output:
+ autoSend: false
+ messages: Local.AgentResponse