From 932c4530dfb2a6b55caffed57f895c8716daf9e2 Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 29 Sep 2026 17:10:28 +0100 Subject: [PATCH 1/3] .NET: Validate handoff request lifecycle before routing --- .../Specialized/HandoffAgentExecutor.cs | 27 +- .../HandoffAgentExecutorTests.cs | 273 ++++++++++++++++++ .../05_Simple_Workflow_Checkpointing.cs | 7 + 3 files changed, 305 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs index 50a6871aa2c..71738ff3888 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs @@ -430,6 +430,7 @@ private async ValueTask InvokeAgentAsync(IEnumerable updates = []; List<(FunctionCallContent Request, string? ResponseId)> candidateRequests = []; + HashSet completedRequestIds = new(StringComparer.Ordinal); this._session ??= await this._agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); @@ -440,11 +441,33 @@ private async ValueTask InvokeAgentAsync(IEnumerable string.Equals(candidate.Request.CallId, functionResult.CallId, 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 = + update.Role == ChatRole.Assistant + && update.AgentId is not null + && string.Equals(update.AgentId, this._agent.Id, StringComparison.Ordinal) + && !string.IsNullOrWhiteSpace(candidateHandoffRequest.CallId) + && !completedRequestIds.Contains(candidateHandoffRequest.CallId) + && this._handoffFunctionNames.Contains(candidateHandoffRequest.Name); if (isHandoffRequest) { candidateRequests.Add((candidateHandoffRequest, update.ResponseId)); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs index 6b691f9eebb..f9ce48a0d3f 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs @@ -17,6 +17,66 @@ 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 static async ValueTask PrepareHandoffSharedStateAsync(TestRunContext? runContext = null, IEnumerable? messages = null) { runContext ??= new(); @@ -200,6 +260,219 @@ 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_DoesNotRouteHandoffRequestWithoutAgentProvenanceAsync() + { + // 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 + 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_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_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); + } } 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..3d8474f4629 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 @@ -35,6 +35,13 @@ await environment.WithCheckpointing(checkpointManager) Assert.Null(result); Assert.Equal(6, checkpoints.Count); + // Cancelling the event consumer does not cancel the workflow. Wait for the current + // superstep batch to halt before importing checkpoint state, or an off-thread runner + // can still be publishing queued updates when the restore begins. + await foreach (WorkflowEvent _ in handle.WatchStreamAsync(blockOnPendingRequest: false).ConfigureAwait(false)) + { + } + CheckpointInfo targetCheckpoint = checkpoints[2]; Console.WriteLine($"Restoring to checkpoint {targetCheckpoint} from session {targetCheckpoint.SessionId}"); From faa1caa7dbb5a73d12a9dd6f2aef97131ad8a2db Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Tue, 29 Sep 2026 18:04:56 +0100 Subject: [PATCH 2/3] .NET: Preserve handoff provenance across streaming updates --- .../Specialized/HandoffAgentExecutor.cs | 70 ++++- .../HandoffAgentExecutorTests.cs | 254 +++++++++++++++++- .../05_Simple_Workflow_Checkpointing.cs | 1 + 3 files changed, 309 insertions(+), 16 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs index 71738ff3888..d171810c0da 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs @@ -429,8 +429,13 @@ private async ValueTask InvokeAgentAsync(IEnumerable updates = []; - List<(FunctionCallContent Request, string? ResponseId)> candidateRequests = []; - HashSet completedRequestIds = new(StringComparer.Ordinal); + 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); @@ -441,16 +446,53 @@ private async ValueTask InvokeAgentAsync(IEnumerable string.Equals(candidate.Request.CallId, functionResult.CallId, StringComparison.Ordinal)); + candidate => 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); @@ -462,15 +504,14 @@ bool CollectHandoffRequestsFilter(FunctionCallContent candidateHandoffRequest) // executor. Other roles, other agents, malformed IDs, and already-completed calls // are provider lifecycle events rather than application routing decisions. bool isHandoffRequest = - update.Role == ChatRole.Assistant - && update.AgentId is not null - && string.Equals(update.AgentId, this._agent.Id, StringComparison.Ordinal) + effectiveRole == ChatRole.Assistant + && string.Equals(effectiveAgentId, this._agent.Id, StringComparison.Ordinal) && !string.IsNullOrWhiteSpace(candidateHandoffRequest.CallId) - && !completedRequestIds.Contains(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; @@ -485,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( @@ -526,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 f9ce48a0d3f..551516413c7 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs @@ -77,6 +77,25 @@ [new FunctionCallContent("cancelled-call", $"{HandoffWorkflowBuilder.FunctionPre } } + 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(); @@ -395,7 +414,7 @@ [new ChatMessage(assistantRole ? ChatRole.Assistant : ChatRole.Tool, [ineligible } [Fact] - public async Task Test_HandoffAgentExecutor_DoesNotRouteHandoffRequestWithoutAgentProvenanceAsync() + public async Task Test_HandoffAgentExecutor_RoutesHandoffRequestWithoutAgentProvenanceAsync() { // Arrange TestEchoAgent targetAgent = new("target-agent", "Target Agent"); @@ -414,9 +433,9 @@ public async Task Test_HandoffAgentExecutor_DoesNotRouteHandoffRequestWithoutAge 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); + HandoffState sentState = Assert.IsType(Assert.Single(testContext.QueuedMessages[executor.Id]).Message); + Assert.Equal(targetAgent.Id, sentState.RequestedHandoffTargetAgentId); + Assert.Empty(testContext.ExternalRequests); } [Fact] @@ -444,6 +463,202 @@ public async Task Test_HandoffAgentExecutor_DoesNotRouteHandoffRequestFromDiffer 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() { @@ -473,6 +688,37 @@ public async Task Test_HandoffAgentExecutor_CancellationDoesNotRetainHandoffCand 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 3d8474f4629..cd0cafd2bb4 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 @@ -40,6 +40,7 @@ await environment.WithCheckpointing(checkpointManager) // can still be publishing queued updates when the restore begins. await foreach (WorkflowEvent _ in handle.WatchStreamAsync(blockOnPendingRequest: false).ConfigureAwait(false)) { + // Intentionally drain the stream until the current workflow run has halted. } CheckpointInfo targetCheckpoint = checkpoints[2]; From 844c9a1b2b0b4325150eb409471813b0b6befead Mon Sep 17 00:00:00 2001 From: Roger Barreto <19890735+RogerBarreto@users.noreply.github.com> Date: Fri, 2 Oct 2026 13:43:49 +0100 Subject: [PATCH 3/3] .NET: Keep checkpoint drain with bounded stream --- .../Sample/05_Simple_Workflow_Checkpointing.cs | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) 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 cd0cafd2bb4..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 @@ -35,14 +35,6 @@ await environment.WithCheckpointing(checkpointManager) Assert.Null(result); Assert.Equal(6, checkpoints.Count); - // Cancelling the event consumer does not cancel the workflow. Wait for the current - // superstep batch to halt before importing checkpoint state, or an off-thread runner - // can still be publishing queued updates when the restore begins. - await foreach (WorkflowEvent _ in handle.WatchStreamAsync(blockOnPendingRequest: false).ConfigureAwait(false)) - { - // Intentionally drain the stream until the current workflow run has halted. - } - CheckpointInfo targetCheckpoint = checkpoints[2]; Console.WriteLine($"Restoring to checkpoint {targetCheckpoint} from session {targetCheckpoint.SessionId}"); @@ -151,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; }