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