diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs index 50a6871aa2c..d171810c0da 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs @@ -429,7 +429,13 @@ private async ValueTask InvokeAgentAsync(IEnumerable updates = []; - List<(FunctionCallContent Request, string? ResponseId)> candidateRequests = []; + List<(FunctionCallContent Request, string ProducerId, string? ResponseId)> candidateRequests = []; + HashSet<(string CallId, string ProducerId, string? ResponseId)> completedRequests = []; + bool hasMessageScope = false; + string? scopedMessageId = null; + string? scopedResponseId = null; + ChatRole? scopedRole = null; + string? scopedAgentId = null; this._session ??= await this._agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); @@ -440,14 +446,72 @@ private async ValueTask InvokeAgentAsync(IEnumerable string.Equals(candidate.Request.CallId, completion.CallId, StringComparison.Ordinal) + && string.Equals(candidate.ProducerId, completion.ProducerId, StringComparison.Ordinal) + && string.Equals(candidate.ResponseId, completion.ResponseId, StringComparison.Ordinal)); + } + + collector.ProcessAIContents([content], CollectHandoffRequestsFilter); + } bool CollectHandoffRequestsFilter(FunctionCallContent candidateHandoffRequest) { - bool isHandoffRequest = this._handoffFunctionNames.Contains(candidateHandoffRequest.Name); + // A handoff is an unresolved assistant request for a declaration owned by this + // executor. Other roles, other agents, malformed IDs, and already-completed calls + // are provider lifecycle events rather than application routing decisions. + bool isHandoffRequest = + effectiveRole == ChatRole.Assistant + && string.Equals(effectiveAgentId, this._agent.Id, StringComparison.Ordinal) + && !string.IsNullOrWhiteSpace(candidateHandoffRequest.CallId) + && !completedRequests.Contains((candidateHandoffRequest.CallId, effectiveAgentId, effectiveResponseId)) + && this._handoffFunctionNames.Contains(candidateHandoffRequest.Name); if (isHandoffRequest) { - candidateRequests.Add((candidateHandoffRequest, update.ResponseId)); + candidateRequests.Add((candidateHandoffRequest, effectiveAgentId, effectiveResponseId)); } return !isHandoffRequest; @@ -462,7 +526,7 @@ bool CollectHandoffRequestsFilter(FunctionCallContent candidateHandoffRequest) if (candidateRequests.Count > 0) { - (FunctionCallContent handoffRequest, string? handoffResponseId) = candidateRequests[candidateRequests.Count - 1]; + (FunctionCallContent handoffRequest, _, string? handoffResponseId) = candidateRequests[candidateRequests.Count - 1]; requestedHandoff = handoffRequest.Name; await AddUpdateAsync( @@ -503,6 +567,11 @@ ValueTask AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancellat => requestedHandoff != null ? this._handoffFunctionToAgentId.TryGetValue(requestedHandoff, out string? targetId) ? targetId : null : null; + + static bool AreCompatibleMessageIds(string? establishedId, string? updateId) + => establishedId is null + ? updateId is null + : updateId is null || string.Equals(establishedId, updateId, StringComparison.Ordinal); } internal static FunctionResultContent CreateHandoffResult(string requestCallId) => new(requestCallId, "Transferred."); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs index 6b691f9eebb..551516413c7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs @@ -17,6 +17,85 @@ namespace Microsoft.Agents.AI.Workflows.UnitTests; public class HandoffAgentExecutorTests : AIAgentHostingExecutorTestsBase { + private sealed class AgentIdOverrideReplayAgent( + FunctionCallContent content, + string? updateAgentId, + string? id = null, + string? name = null) : TestReplayAgent(id, name) + { + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + yield return new AgentResponseUpdate(ChatRole.Assistant, [content]) + { + AgentId = updateAgentId, + MessageId = "nested-message", + ResponseId = "nested-response", + }; + } + } + + private sealed class CancellableHandoffReplayAgent(string? id = null, string? name = null) : TestReplayAgent(id, name) + { + private int _invocation; + + public TaskCompletionSource FirstRequestObserved { get; } = new(TaskCreationOptions.RunContinuationsAsynchronously); + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + if (this._invocation++ == 0) + { + yield return new AgentResponseUpdate( + ChatRole.Assistant, + [new FunctionCallContent("cancelled-call", $"{HandoffWorkflowBuilder.FunctionPrefix}1")]) + { + AgentId = this.Id, + MessageId = "cancelled-message", + ResponseId = "cancelled-response", + }; + + this.FirstRequestObserved.SetResult(true); + await Task.Delay(Timeout.Infinite, cancellationToken); + } + else + { + yield return new AgentResponseUpdate(ChatRole.Assistant, "Completed without handoff.") + { + AgentId = this.Id, + MessageId = "completed-message", + ResponseId = "completed-response", + }; + } + } + } + + private sealed class StreamingUpdatesReplayAgent( + IReadOnlyList updates, + string? id = null, + string? name = null) : TestReplayAgent(id, name) + { + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, + AgentSession? session = null, + AgentRunOptions? options = null, + [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + foreach (AgentResponseUpdate update in updates) + { + await Task.Yield(); + yield return update; + } + } + } + private static async ValueTask PrepareHandoffSharedStateAsync(TestRunContext? runContext = null, IEnumerable? messages = null) { runContext ??= new(); @@ -200,6 +279,446 @@ public async Task Test_HandoffAgentExecutor_PreservesExistingInstructionsAndTool async Task runStreamingAsync() => await executor.HandleAsync(state, testContext); Assert.Null(await Record.ExceptionAsync(runStreamingAsync)); } + + [Fact] + public async Task Test_HandoffAgentExecutor_DoesNotRouteCompletedHandoffNamedToolEventAsync() + { + // Arrange + const string CallId = "provider-tool-call"; + TestEchoAgent targetAgent = new("target-agent", "Target Agent"); + FunctionCallContent toolStart = new(CallId, $"{HandoffWorkflowBuilder.FunctionPrefix}1") + { + RawRepresentation = new object(), + }; + FunctionResultContent toolComplete = new(CallId, "Tool execution failed") + { + RawRepresentation = new object(), + }; + TestReplayAgent agent = new( + [ + new ChatMessage(ChatRole.Assistant, [toolStart]) { MessageId = "provider-tool-start" }, + new ChatMessage(ChatRole.Tool, [toolComplete]) { MessageId = "provider-tool-complete" }, + ], TestAgentId, TestAgentName); + + HandoffAgentExecutorOptions options = new("", + emitAgentResponseEvents: false, + emitAgentResponseUpdateEvents: false, + HandoffToolCallFilteringBehavior.None); + HandoffAgentExecutor executor = new(agent, [new HandoffTarget(targetAgent)], options); + TestRunContext testContext = await PrepareHandoffSharedStateAsync(); + testContext.ConfigureExecutor(executor); + + // Act + HandoffState message = new(new(false), null, null); + await executor.HandleAsync(message, testContext.BindWorkflowContext(executor.Id)); + + // Assert + HandoffState sentState = Assert.IsType(Assert.Single(testContext.QueuedMessages[executor.Id]).Message); + Assert.Null(sentState.RequestedHandoffTargetAgentId); + + HandoffSharedState? sharedState = await testContext + .BindWorkflowContext(nameof(HandoffStartExecutor)) + .ReadStateAsync( + HandoffConstants.HandoffSharedStateKey, + HandoffConstants.HandoffSharedStateScope); + Assert.NotNull(sharedState); + Assert.DoesNotContain( + sharedState.Conversation.History.SelectMany(history => history.Contents).OfType(), + result => result.CallId == CallId && string.Equals(result.Result?.ToString(), "Transferred.", StringComparison.Ordinal)); + } + + [Fact] + public async Task Test_HandoffAgentExecutor_RoutesUnresolvedDeclaredHandoffRequestAsync() + { + // Arrange + TestEchoAgent targetAgent = new("target-agent", "Target Agent"); + FunctionCallContent handoffRequest = new("handoff-call", $"{HandoffWorkflowBuilder.FunctionPrefix}1"); + TestReplayAgent agent = new( + [new ChatMessage(ChatRole.Assistant, [handoffRequest]) { MessageId = "handoff-message" }], + TestAgentId, + TestAgentName); + + HandoffAgentExecutorOptions options = new("", + emitAgentResponseEvents: false, + emitAgentResponseUpdateEvents: false, + HandoffToolCallFilteringBehavior.None); + HandoffAgentExecutor executor = new(agent, [new HandoffTarget(targetAgent)], options); + TestRunContext testContext = await PrepareHandoffSharedStateAsync(); + testContext.ConfigureExecutor(executor); + + // Act + await executor.HandleAsync(new HandoffState(new(false), null, null), testContext.BindWorkflowContext(executor.Id)); + + // Assert + HandoffState sentState = Assert.IsType(Assert.Single(testContext.QueuedMessages[executor.Id]).Message); + Assert.Equal(targetAgent.Id, sentState.RequestedHandoffTargetAgentId); + } + + [Fact] + public async Task Test_HandoffAgentExecutor_DoesNotCancelHandoffRequestForDifferentCompletedCallAsync() + { + // Arrange + TestEchoAgent targetAgent = new("target-agent", "Target Agent"); + FunctionCallContent handoffRequest = new("handoff-call", $"{HandoffWorkflowBuilder.FunctionPrefix}1"); + FunctionResultContent unrelatedCompletion = new("different-call", "Completed"); + TestReplayAgent agent = new( + [ + new ChatMessage(ChatRole.Assistant, [handoffRequest]) { MessageId = "handoff-message" }, + new ChatMessage(ChatRole.Tool, [unrelatedCompletion]) { MessageId = "unrelated-completion" }, + ], TestAgentId, TestAgentName); + + HandoffAgentExecutorOptions options = new("", + emitAgentResponseEvents: false, + emitAgentResponseUpdateEvents: false, + HandoffToolCallFilteringBehavior.None); + HandoffAgentExecutor executor = new(agent, [new HandoffTarget(targetAgent)], options); + TestRunContext testContext = await PrepareHandoffSharedStateAsync(); + testContext.ConfigureExecutor(executor); + + // Act + await executor.HandleAsync(new HandoffState(new(false), null, null), testContext.BindWorkflowContext(executor.Id)); + + // Assert + HandoffState sentState = Assert.IsType(Assert.Single(testContext.QueuedMessages[executor.Id]).Message); + Assert.Equal(targetAgent.Id, sentState.RequestedHandoffTargetAgentId); + } + + [Theory] + [InlineData("", true)] + [InlineData("provider-tool-call", false)] + public async Task Test_HandoffAgentExecutor_DoesNotRouteIneligibleHandoffNamedContentAsync(string callId, bool assistantRole) + { + // Arrange + TestEchoAgent targetAgent = new("target-agent", "Target Agent"); + FunctionCallContent ineligibleRequest = new(callId, $"{HandoffWorkflowBuilder.FunctionPrefix}1"); + TestReplayAgent agent = new( + [new ChatMessage(assistantRole ? ChatRole.Assistant : ChatRole.Tool, [ineligibleRequest]) { MessageId = "ineligible-message" }], + TestAgentId, + TestAgentName); + + HandoffAgentExecutorOptions options = new("", + emitAgentResponseEvents: false, + emitAgentResponseUpdateEvents: false, + HandoffToolCallFilteringBehavior.None); + HandoffAgentExecutor executor = new(agent, [new HandoffTarget(targetAgent)], options); + TestRunContext testContext = await PrepareHandoffSharedStateAsync(); + testContext.ConfigureExecutor(executor); + + // Act + await executor.HandleAsync(new HandoffState(new(false), null, null), testContext.BindWorkflowContext(executor.Id)); + + // Assert + Assert.False(testContext.QueuedMessages.ContainsKey(executor.Id)); + FunctionCallContent externalRequest = Assert.IsType(Assert.Single(testContext.ExternalRequests).Data.As()); + Assert.Same(ineligibleRequest, externalRequest); + } + + [Fact] + public async Task Test_HandoffAgentExecutor_RoutesHandoffRequestWithoutAgentProvenanceAsync() + { + // Arrange + TestEchoAgent targetAgent = new("target-agent", "Target Agent"); + FunctionCallContent nestedRequest = new("nested-call", $"{HandoffWorkflowBuilder.FunctionPrefix}1"); + AgentIdOverrideReplayAgent agent = new(nestedRequest, null, TestAgentId, TestAgentName); + + HandoffAgentExecutorOptions options = new("", + emitAgentResponseEvents: false, + emitAgentResponseUpdateEvents: false, + HandoffToolCallFilteringBehavior.None); + HandoffAgentExecutor executor = new(agent, [new HandoffTarget(targetAgent)], options); + TestRunContext testContext = await PrepareHandoffSharedStateAsync(); + testContext.ConfigureExecutor(executor); + + // Act + await executor.HandleAsync(new HandoffState(new(false), null, null), testContext.BindWorkflowContext(executor.Id)); + + // Assert + HandoffState sentState = Assert.IsType(Assert.Single(testContext.QueuedMessages[executor.Id]).Message); + Assert.Equal(targetAgent.Id, sentState.RequestedHandoffTargetAgentId); + Assert.Empty(testContext.ExternalRequests); + } + + [Fact] + public async Task Test_HandoffAgentExecutor_DoesNotRouteHandoffRequestFromDifferentAgentAsync() + { + // Arrange + TestEchoAgent targetAgent = new("target-agent", "Target Agent"); + FunctionCallContent nestedRequest = new("nested-call", $"{HandoffWorkflowBuilder.FunctionPrefix}1"); + AgentIdOverrideReplayAgent agent = new(nestedRequest, "different-agent", TestAgentId, TestAgentName); + + HandoffAgentExecutorOptions options = new("", + emitAgentResponseEvents: false, + emitAgentResponseUpdateEvents: false, + HandoffToolCallFilteringBehavior.None); + HandoffAgentExecutor executor = new(agent, [new HandoffTarget(targetAgent)], options); + TestRunContext testContext = await PrepareHandoffSharedStateAsync(); + testContext.ConfigureExecutor(executor); + + // Act + await executor.HandleAsync(new HandoffState(new(false), null, null), testContext.BindWorkflowContext(executor.Id)); + + // Assert + Assert.False(testContext.QueuedMessages.ContainsKey(executor.Id)); + FunctionCallContent externalRequest = Assert.IsType(Assert.Single(testContext.ExternalRequests).Data.As()); + Assert.Same(nestedRequest, externalRequest); + } + + [Fact] + public async Task Test_HandoffAgentExecutor_InheritsRoleWithinMessageAsync() + { + // Arrange + FunctionCallContent handoffRequest = new("handoff-call", $"{HandoffWorkflowBuilder.FunctionPrefix}1"); + StreamingUpdatesReplayAgent agent = new( + [ + CreateUpdate(ChatRole.Assistant, TestAgentId, "message", "response", new TextContent("Thinking")), + CreateUpdate(null, TestAgentId, "message", "response", handoffRequest), + ], TestAgentId, TestAgentName); + + // Act + (TestRunContext testContext, HandoffAgentExecutor executor, TestEchoAgent targetAgent) = + await RunHandoffAgentAsync(agent); + + // Assert + HandoffState sentState = Assert.IsType(Assert.Single(testContext.QueuedMessages[executor.Id]).Message); + Assert.Equal(targetAgent.Id, sentState.RequestedHandoffTargetAgentId); + } + + [Fact] + public async Task Test_HandoffAgentExecutor_InheritsForeignAgentWithinMessageAsync() + { + // Arrange + FunctionCallContent handoffRequest = new("handoff-call", $"{HandoffWorkflowBuilder.FunctionPrefix}1"); + StreamingUpdatesReplayAgent agent = new( + [ + CreateUpdate(ChatRole.Assistant, "different-agent", "message", "response", new TextContent("Thinking")), + CreateUpdate(null, null, "message", "response", handoffRequest), + ], TestAgentId, TestAgentName); + + // Act + (TestRunContext testContext, HandoffAgentExecutor executor, _) = await RunHandoffAgentAsync(agent); + + // Assert + Assert.False(testContext.QueuedMessages.ContainsKey(executor.Id)); + FunctionCallContent externalRequest = Assert.IsType(Assert.Single(testContext.ExternalRequests).Data.As()); + Assert.Same(handoffRequest, externalRequest); + } + + [Fact] + public async Task Test_HandoffAgentExecutor_DoesNotInheritForeignAgentAcrossMessagesAsync() + { + // Arrange + FunctionCallContent handoffRequest = new("handoff-call", $"{HandoffWorkflowBuilder.FunctionPrefix}1"); + StreamingUpdatesReplayAgent agent = new( + [ + CreateUpdate(ChatRole.Assistant, "different-agent", "foreign-message", "response", new TextContent("Nested response")), + CreateUpdate(ChatRole.Assistant, null, "direct-message", "response", handoffRequest), + ], TestAgentId, TestAgentName); + + // Act + (TestRunContext testContext, HandoffAgentExecutor executor, TestEchoAgent targetAgent) = + await RunHandoffAgentAsync(agent); + + // Assert + HandoffState sentState = Assert.IsType(Assert.Single(testContext.QueuedMessages[executor.Id]).Message); + Assert.Equal(targetAgent.Id, sentState.RequestedHandoffTargetAgentId); + } + + [Theory] + [InlineData("different-message", "response")] + [InlineData("message", "different-response")] + public async Task Test_HandoffAgentExecutor_DoesNotInheritRoleAcrossMessageOrResponseAsync(string messageId, string responseId) + { + // Arrange + FunctionCallContent handoffRequest = new("handoff-call", $"{HandoffWorkflowBuilder.FunctionPrefix}1"); + StreamingUpdatesReplayAgent agent = new( + [ + CreateUpdate(ChatRole.Assistant, TestAgentId, "message", "response", new TextContent("Thinking")), + CreateUpdate(null, TestAgentId, messageId, responseId, handoffRequest), + ], TestAgentId, TestAgentName); + + // Act + (TestRunContext testContext, HandoffAgentExecutor executor, _) = await RunHandoffAgentAsync(agent); + + // Assert + Assert.False(testContext.QueuedMessages.ContainsKey(executor.Id)); + FunctionCallContent externalRequest = Assert.IsType(Assert.Single(testContext.ExternalRequests).Data.As()); + Assert.Same(handoffRequest, externalRequest); + } + + [Fact] + public async Task Test_HandoffAgentExecutor_DoesNotCancelHandoffForForeignProducerAsync() + { + // Arrange + const string CallId = "handoff-call"; + StreamingUpdatesReplayAgent agent = new( + [ + CreateUpdate(ChatRole.Assistant, TestAgentId, "request-message", "response", new FunctionCallContent(CallId, $"{HandoffWorkflowBuilder.FunctionPrefix}1")), + CreateUpdate(ChatRole.Tool, "different-agent", "result-message", "response", new FunctionResultContent(CallId, "Completed")), + ], TestAgentId, TestAgentName); + + // Act + (TestRunContext testContext, HandoffAgentExecutor executor, TestEchoAgent targetAgent) = + await RunHandoffAgentAsync(agent); + + // Assert + HandoffState sentState = Assert.IsType(Assert.Single(testContext.QueuedMessages[executor.Id]).Message); + Assert.Equal(targetAgent.Id, sentState.RequestedHandoffTargetAgentId); + } + + [Fact] + public async Task Test_HandoffAgentExecutor_DoesNotCancelHandoffForDifferentResponseAsync() + { + // Arrange + const string CallId = "handoff-call"; + StreamingUpdatesReplayAgent agent = new( + [ + CreateUpdate(ChatRole.Assistant, TestAgentId, "request-message", "request-response", new FunctionCallContent(CallId, $"{HandoffWorkflowBuilder.FunctionPrefix}1")), + CreateUpdate(ChatRole.Tool, TestAgentId, "result-message", "different-response", new FunctionResultContent(CallId, "Completed")), + ], TestAgentId, TestAgentName); + + // Act + (TestRunContext testContext, HandoffAgentExecutor executor, TestEchoAgent targetAgent) = + await RunHandoffAgentAsync(agent); + + // Assert + HandoffState sentState = Assert.IsType(Assert.Single(testContext.QueuedMessages[executor.Id]).Message); + Assert.Equal(targetAgent.Id, sentState.RequestedHandoffTargetAgentId); + } + + [Theory] + [InlineData("response")] + [InlineData(null)] + public async Task Test_HandoffAgentExecutor_CancelsHandoffForSameProducerAndResponseAsync(string? responseId) + { + // Arrange + const string CallId = "handoff-call"; + StreamingUpdatesReplayAgent agent = new( + [ + CreateUpdate(ChatRole.Assistant, TestAgentId, responseId is null ? null : "request-message", responseId, new FunctionCallContent(CallId, $"{HandoffWorkflowBuilder.FunctionPrefix}1")), + CreateUpdate(ChatRole.Tool, TestAgentId, null, null, new FunctionResultContent(CallId, "Completed")), + ], TestAgentId, TestAgentName); + + // Act + (TestRunContext testContext, HandoffAgentExecutor executor, _) = await RunHandoffAgentAsync(agent); + + // Assert + HandoffState sentState = Assert.IsType(Assert.Single(testContext.QueuedMessages[executor.Id]).Message); + Assert.Null(sentState.RequestedHandoffTargetAgentId); + } + + [Fact] + public async Task Test_HandoffAgentExecutor_CancelsAnonymousHandoffAfterMetadataOnlyDeltaAsync() + { + // Arrange + const string CallId = "handoff-call"; + StreamingUpdatesReplayAgent agent = new( + [ + CreateUpdate(ChatRole.Assistant, TestAgentId, null, null, new TextContent("Thinking")), + CreateUpdate(null, null, null, null, new FunctionCallContent(CallId, $"{HandoffWorkflowBuilder.FunctionPrefix}1")), + CreateUpdate(ChatRole.Tool, null, null, null, new FunctionResultContent(CallId, "Completed")), + ], TestAgentId, TestAgentName); + + // Act + (TestRunContext testContext, HandoffAgentExecutor executor, _) = await RunHandoffAgentAsync(agent); + + // Assert + HandoffState sentState = Assert.IsType(Assert.Single(testContext.QueuedMessages[executor.Id]).Message); + Assert.Null(sentState.RequestedHandoffTargetAgentId); + } + + [Theory] + [InlineData("different-agent", "response", true)] + [InlineData(TestAgentId, "different-response", true)] + [InlineData(TestAgentId, "response", false)] + public async Task Test_HandoffAgentExecutor_ScopesCompletionBeforeHandoffRequestAsync( + string completionAgentId, + string completionResponseId, + bool expectedHandoff) + { + // Arrange + const string CallId = "handoff-call"; + StreamingUpdatesReplayAgent agent = new( + [ + CreateUpdate(ChatRole.Tool, completionAgentId, "result-message", completionResponseId, new FunctionResultContent(CallId, "Completed")), + CreateUpdate(ChatRole.Assistant, TestAgentId, "request-message", "response", new FunctionCallContent(CallId, $"{HandoffWorkflowBuilder.FunctionPrefix}1")), + ], TestAgentId, TestAgentName); + + // Act + (TestRunContext testContext, HandoffAgentExecutor executor, TestEchoAgent targetAgent) = + await RunHandoffAgentAsync(agent); + + // Assert + if (expectedHandoff) + { + HandoffState sentState = Assert.IsType(Assert.Single(testContext.QueuedMessages[executor.Id]).Message); + Assert.Equal(targetAgent.Id, sentState.RequestedHandoffTargetAgentId); + } + else + { + Assert.False(testContext.QueuedMessages.ContainsKey(executor.Id)); + } + } + + [Fact] + public async Task Test_HandoffAgentExecutor_CancellationDoesNotRetainHandoffCandidateAsync() + { + // Arrange + TestEchoAgent targetAgent = new("target-agent", "Target Agent"); + CancellableHandoffReplayAgent agent = new(TestAgentId, TestAgentName); + HandoffAgentExecutorOptions options = new("", + emitAgentResponseEvents: false, + emitAgentResponseUpdateEvents: false, + HandoffToolCallFilteringBehavior.None); + HandoffAgentExecutor executor = new(agent, [new HandoffTarget(targetAgent)], options); + TestRunContext testContext = await PrepareHandoffSharedStateAsync(); + testContext.ConfigureExecutor(executor); + using CancellationTokenSource cancellationSource = new(); + + // Act + Task cancelledTurn = executor + .HandleAsync(new HandoffState(new(false), null, null), testContext.BindWorkflowContext(executor.Id), cancellationSource.Token) + .AsTask(); + await agent.FirstRequestObserved.Task; + cancellationSource.Cancel(); + + // Assert + await Assert.ThrowsAnyAsync(() => cancelledTurn); + await executor.HandleAsync(new HandoffState(new(false), null, null), testContext.BindWorkflowContext(executor.Id)); + + HandoffState sentState = Assert.IsType(Assert.Single(testContext.QueuedMessages[executor.Id]).Message); + Assert.Null(sentState.RequestedHandoffTargetAgentId); + } + + private static AgentResponseUpdate CreateUpdate( + ChatRole? role, + string? agentId, + string? messageId, + string? responseId, + AIContent content) => + new() + { + Role = role, + AgentId = agentId, + MessageId = messageId, + ResponseId = responseId, + Contents = [content], + }; + + private static async Task<(TestRunContext Context, HandoffAgentExecutor Executor, TestEchoAgent TargetAgent)> RunHandoffAgentAsync(AIAgent agent) + { + TestEchoAgent targetAgent = new("target-agent", "Target Agent"); + HandoffAgentExecutorOptions options = new("", + emitAgentResponseEvents: false, + emitAgentResponseUpdateEvents: false, + HandoffToolCallFilteringBehavior.None); + HandoffAgentExecutor executor = new(agent, [new HandoffTarget(targetAgent)], options); + TestRunContext testContext = await PrepareHandoffSharedStateAsync(); + testContext.ConfigureExecutor(executor); + + await executor.HandleAsync(new HandoffState(new(false), null, null), testContext.BindWorkflowContext(executor.Id)); + + return (testContext, executor, targetAgent); + } } internal sealed record Challenge(string Value); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/05_Simple_Workflow_Checkpointing.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/05_Simple_Workflow_Checkpointing.cs index 9795d668de1..2b43b5a318e 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/05_Simple_Workflow_Checkpointing.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/Sample/05_Simple_Workflow_Checkpointing.cs @@ -143,6 +143,13 @@ await environment.WithCheckpointing(checkpointManager) if (cancellationSource.IsCancellationRequested) { + // Cancelling the event consumer does not cancel the workflow. Drain the stream + // until the current run halts before the caller restores checkpoint state. + await foreach (WorkflowEvent _ in handle.WatchStreamAsync(blockOnPendingRequest: false).ConfigureAwait(false)) + { + // Intentionally ignore events while waiting for the halt boundary. + } + return null; }