From 4377806ee576d28e698ad171c091b2cb334508f7 Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Thu, 7 May 2026 13:42:46 -0400 Subject: [PATCH 1/3] test: Split out Handoff Orchestration tests --- .../AgentWorkflowBuilderTests.cs | 892 --------------- .../HandoffOrchestrationTests.cs | 1001 +++++++++++++++++ 2 files changed, 1001 insertions(+), 892 deletions(-) create mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffOrchestrationTests.cs diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs index fc984a9963..9dcd928314 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs @@ -3,18 +3,14 @@ using System; using System.Collections.Generic; using System.Linq; -using System.Reflection; using System.Runtime.CompilerServices; using System.Text; using System.Text.Json; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; -using FluentAssertions; using Microsoft.Agents.AI.Workflows.InProc; -using Microsoft.Agents.AI.Workflows.Specialized; using Microsoft.Extensions.AI; -using Microsoft.Extensions.Logging; #pragma warning disable SYSLIB1045 // Use GeneratedRegex #pragma warning disable RCS1186 // Use Regex instance instead of static method @@ -36,72 +32,6 @@ public void BuildConcurrent_InvalidArguments_Throws() Assert.Throws("agents", () => AgentWorkflowBuilder.BuildConcurrent(null!)); } - [Fact] - public void BuildHandoffs_InvalidArguments_Throws() - { - Assert.Throws("initialAgent", () => AgentWorkflowBuilder.CreateHandoffBuilderWith(null!)); - - var agent = new DoubleEchoAgent("agent"); - var handoffs = AgentWorkflowBuilder.CreateHandoffBuilderWith(agent); - Assert.NotNull(handoffs); - - Assert.Throws("from", () => handoffs.WithHandoff(null!, new DoubleEchoAgent("a2"))); - Assert.Throws("to", () => handoffs.WithHandoff(new DoubleEchoAgent("a2"), null!)); - - Assert.Throws("from", () => handoffs.WithHandoffs(null!, new DoubleEchoAgent("a2"))); - Assert.Throws("from", () => handoffs.WithHandoffs([null!], new DoubleEchoAgent("a2"))); - Assert.Throws("to", () => handoffs.WithHandoffs(new DoubleEchoAgent("a2"), null!)); - Assert.Throws("to", () => handoffs.WithHandoffs(new DoubleEchoAgent("a2"), [null!])); - - var noDescriptionAgent = new ChatClientAgent(new MockChatClient(delegate { return new(); })); - Assert.Throws("to", () => handoffs.WithHandoff(agent, noDescriptionAgent)); - - var emptyDescriptionAgent = new MockChatClient(delegate { return new(); }).AsAIAgent(description: ""); - Assert.Throws("to", () => handoffs.WithHandoff(agent, emptyDescriptionAgent)); - - var emptyNameAgent = new MockChatClient(delegate { return new(); }).AsAIAgent(name: ""); - Assert.Throws("to", () => handoffs.WithHandoff(agent, emptyNameAgent)); - } - - private sealed class NullLogger : ILogger - { - public IDisposable? BeginScope(TState state) where TState : notnull - { - return null; - } - - public bool IsEnabled(LogLevel logLevel) - { - return false; - } - - public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) - { - } - } - - [Fact] - public void BuildHandoffs_DelegatingAIAgent_DoesNotThrow() - { - DoubleEchoAgent agent = new("agent"); - HandoffWorkflowBuilder handoffs = AgentWorkflowBuilder.CreateHandoffBuilderWith(agent); - Assert.NotNull(handoffs); - - ChatClientAgent instructionsOnlyAgent = new MockChatClient(delegate { return new(); }).AsAIAgent(instructions: "instructions"); - LoggingAgent delegatingAgent = new(instructionsOnlyAgent, new NullLogger()); - - handoffs.WithHandoff(agent, delegatingAgent); - - // get the _targets field from the HandoffWorkflowBuilder (need to use the base type) - FieldInfo field = typeof(HandoffWorkflowBuilder).BaseType!.GetField("_targets", BindingFlags.Instance | BindingFlags.NonPublic)!; - Dictionary>? targets = field.GetValue(handoffs) as Dictionary>; - - targets.Should().NotBeNull(); - - HandoffTarget target = targets[agent].Single(); - target.Reason.Should().Be("instructions"); - } - [Fact] public void BuildGroupChat_InvalidArguments_Throws() { @@ -287,628 +217,6 @@ public async Task BuildConcurrent_AgentsRunInParallelAsync() } } - [Fact] - public async Task Handoffs_NoTransfers_ResponseServedByOriginalAgentAsync() - { - var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) => - { - ChatMessage message = Assert.Single(messages); - Assert.Equal("abc", Assert.IsType(Assert.Single(message.Contents)).Text); - - return new(new ChatMessage(ChatRole.Assistant, "Hello from agent1")); - })); - - var workflow = - AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent) - .WithHandoff(initialAgent, new ChatClientAgent(new MockChatClient(delegate - { - Assert.Fail("Should never be invoked."); - return new(); - }), description: "nop")) - .Build(); - - (string updateText, List? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); - - Assert.Equal("Hello from agent1", updateText); - Assert.NotNull(result); - - Assert.Equal(2, result.Count); - - Assert.Equal(ChatRole.User, result[0].Role); - Assert.Equal("abc", result[0].Text); - - Assert.Equal(ChatRole.Assistant, result[1].Role); - Assert.Equal("Hello from agent1", result[1].Text); - } - - [Fact] - public async Task Handoffs_OneTransfer_ResponseServedBySecondAgentAsync() - { - var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) => - { - ChatMessage message = Assert.Single(messages); - Assert.Equal("abc", Assert.IsType(Assert.Single(message.Contents)).Text); - - string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; - Assert.NotNull(transferFuncName); - - return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); - }), name: "initialAgent"); - - var nextAgent = new ChatClientAgent(new MockChatClient((messages, options) => - new(new ChatMessage(ChatRole.Assistant, "Hello from agent2"))), - name: "nextAgent", - description: "The second agent"); - - var workflow = - AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent) - .WithHandoff(initialAgent, nextAgent) - .Build(); - - (string updateText, List? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); - - Assert.Equal("Hello from agent2", updateText); - Assert.NotNull(result); - - Assert.Equal(4, result.Count); - - Assert.Equal(ChatRole.User, result[0].Role); - Assert.Equal("abc", result[0].Text); - - Assert.Equal(ChatRole.Assistant, result[1].Role); - Assert.Equal("", result[1].Text); - Assert.Contains("initialAgent", result[1].AuthorName); - - Assert.Equal(ChatRole.Tool, result[2].Role); - Assert.Contains("initialAgent", result[2].AuthorName); - - Assert.Equal(ChatRole.Assistant, result[3].Role); - Assert.Equal("Hello from agent2", result[3].Text); - Assert.Contains("nextAgent", result[3].AuthorName); - } - - [Fact] - public async Task Handoffs_OneTransfer_HandoffTargetDoesNotReceiveHandoffFunctionMessagesAsync() - { - // Regression test for https://github.com/microsoft/agent-framework/issues/3161 - // When a handoff occurs, the target agent should receive the original user message - // but should NOT receive the handoff function call or tool result messages from the - // source agent, as these confuse the target LLM into ignoring the user's question. - - List? capturedNextAgentMessages = null; - - var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) => - { - string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; - Assert.NotNull(transferFuncName); - - return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); - }), name: "initialAgent"); - - var nextAgent = new ChatClientAgent(new MockChatClient((messages, options) => - { - capturedNextAgentMessages = messages.ToList(); - return new(new ChatMessage(ChatRole.Assistant, "The derivative of x^2 is 2x.")); - }), - name: "nextAgent", - description: "The second agent"); - - var workflow = - AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent) - .WithHandoff(initialAgent, nextAgent) - .Build(); - - _ = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "What is the derivative of x^2?")]); - - Assert.NotNull(capturedNextAgentMessages); - - // The target agent should see the original user message - Assert.Contains(capturedNextAgentMessages, m => m.Role == ChatRole.User && m.Text == "What is the derivative of x^2?"); - - // The target agent should NOT see the handoff function call or tool result from the source agent - Assert.DoesNotContain(capturedNextAgentMessages, m => m.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name.StartsWith("handoff_to_", StringComparison.Ordinal))); - Assert.DoesNotContain(capturedNextAgentMessages, m => m.Role == ChatRole.Tool && m.Contents.Any(c => c is FunctionResultContent frc && frc.Result?.ToString() == "Transferred.")); - } - - [Fact] - public async Task Handoffs_TwoTransfers_HandoffTargetsDoNotReceiveHandoffFunctionMessagesAsync() - { - // Regression test for https://github.com/microsoft/agent-framework/issues/3161 - // With two hops (initial -> second -> third), each target agent should receive the - // original user message and text responses from prior agents (as User role), but - // NOT any handoff function call or tool result messages. - - List? capturedSecondAgentMessages = null; - List? capturedThirdAgentMessages = null; - - var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) => - { - string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; - Assert.NotNull(transferFuncName); - - // Return both a text message and a handoff function call - return new(new ChatMessage(ChatRole.Assistant, [new TextContent("Routing to second agent"), new FunctionCallContent("call1", transferFuncName)])); - }), name: "initialAgent"); - - var secondAgent = new ChatClientAgent(new MockChatClient((messages, options) => - { - capturedSecondAgentMessages = messages.ToList(); - - string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; - Assert.NotNull(transferFuncName); - - // Return both a text message and a handoff function call - return new(new ChatMessage(ChatRole.Assistant, [new TextContent("Routing to third agent"), new FunctionCallContent("call2", transferFuncName)])); - }), name: "secondAgent", description: "The second agent"); - - var thirdAgent = new ChatClientAgent(new MockChatClient((messages, options) => - { - capturedThirdAgentMessages = messages.ToList(); - return new(new ChatMessage(ChatRole.Assistant, "Hello from agent3")); - }), - name: "thirdAgent", - description: "The third / final agent"); - - var workflow = - AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent) - .WithHandoff(initialAgent, secondAgent) - .WithHandoff(secondAgent, thirdAgent) - .Build(); - - (string updateText, _, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); - - Assert.Contains("Hello from agent3", updateText); - - // Second agent should see the original user message and initialAgent's text as context - Assert.NotNull(capturedSecondAgentMessages); - Assert.Contains(capturedSecondAgentMessages, m => m.Text == "abc"); - Assert.Contains(capturedSecondAgentMessages, m => m.Text!.Contains("Routing to second agent")); - Assert.DoesNotContain(capturedSecondAgentMessages, m => m.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name.StartsWith("handoff_to_", StringComparison.Ordinal))); - Assert.DoesNotContain(capturedSecondAgentMessages, m => m.Role == ChatRole.Tool && m.Contents.Any(c => c is FunctionResultContent)); - - // Third agent should see the original user message and both prior agents' text as context - Assert.NotNull(capturedThirdAgentMessages); - Assert.Contains(capturedThirdAgentMessages, m => m.Text == "abc"); - Assert.Contains(capturedThirdAgentMessages, m => m.Text!.Contains("Routing to second agent")); - Assert.Contains(capturedThirdAgentMessages, m => m.Text!.Contains("Routing to third agent")); - Assert.DoesNotContain(capturedThirdAgentMessages, m => m.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name.StartsWith("handoff_to_", StringComparison.Ordinal))); - Assert.DoesNotContain(capturedThirdAgentMessages, m => m.Role == ChatRole.Tool && m.Contents.Any(c => c is FunctionResultContent)); - } - - [Fact] - public async Task Handoffs_FilteringNone_HandoffTargetReceivesAllMessagesIncludingToolCallsAsync() - { - // With filtering set to None, the target agent should see everything including - // handoff function calls and tool results. - - List? capturedNextAgentMessages = null; - - var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) => - { - string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; - Assert.NotNull(transferFuncName); - - return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); - }), name: "initialAgent"); - - var nextAgent = new ChatClientAgent(new MockChatClient((messages, options) => - { - capturedNextAgentMessages = messages.ToList(); - return new(new ChatMessage(ChatRole.Assistant, "response")); - }), - name: "nextAgent", - description: "The second agent"); - - var workflow = - AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent) - .WithHandoff(initialAgent, nextAgent) - .WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior.None) - .Build(); - - _ = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "hello")]); - - Assert.NotNull(capturedNextAgentMessages); - Assert.Contains(capturedNextAgentMessages, m => m.Text == "hello"); - - // With None filtering, handoff function calls and tool results should be visible - Assert.Contains(capturedNextAgentMessages, m => m.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name.StartsWith("handoff_to_", StringComparison.Ordinal))); - Assert.Contains(capturedNextAgentMessages, m => m.Contents.Any(c => c is FunctionResultContent)); - } - - [Fact] - public async Task Handoffs_FilteringAll_HandoffTargetDoesNotReceiveAnyToolCallsAsync() - { - // With filtering set to All, the target agent should see no function calls or tool - // results at all — not even non-handoff ones from prior conversation history. - - List? capturedNextAgentMessages = null; - - var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) => - { - string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; - Assert.NotNull(transferFuncName); - - return new(new ChatMessage(ChatRole.Assistant, [new TextContent("Routing you now"), new FunctionCallContent("call1", transferFuncName)])); - }), name: "initialAgent"); - - var nextAgent = new ChatClientAgent(new MockChatClient((messages, options) => - { - capturedNextAgentMessages = messages.ToList(); - return new(new ChatMessage(ChatRole.Assistant, "response")); - }), - name: "nextAgent", - description: "The second agent"); - - var workflow = - AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent) - .WithHandoff(initialAgent, nextAgent) - .WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior.All) - .Build(); - - // Input includes a pre-existing non-handoff tool call in the conversation history - List input = - [ - new(ChatRole.User, "What's the weather? Also help me with math."), - new(ChatRole.Assistant, [new FunctionCallContent("toolcall1", "get_weather")]) { AuthorName = "initialAgent" }, - new(ChatRole.Tool, [new FunctionResultContent("toolcall1", "sunny")]), - new(ChatRole.Assistant, "The weather is sunny. Now let me route your math question.") { AuthorName = "initialAgent" }, - ]; - - _ = await RunWorkflowAsync(workflow, input); - - Assert.NotNull(capturedNextAgentMessages); - - // With All filtering, NO function calls or tool results should be visible - Assert.DoesNotContain(capturedNextAgentMessages, m => m.Contents.Any(c => c is FunctionCallContent)); - Assert.DoesNotContain(capturedNextAgentMessages, m => m.Role == ChatRole.Tool); - - // But text content should still be visible - Assert.Contains(capturedNextAgentMessages, m => m.Text!.Contains("What's the weather")); - Assert.Contains(capturedNextAgentMessages, m => m.Text!.Contains("Routing you now")); - } - - [Fact] - public async Task Handoffs_FilteringHandoffOnly_PreservesNonHandoffToolCallsAsync() - { - // With HandoffOnly filtering (the default), non-handoff function calls and tool - // results should be preserved while handoff ones are stripped. - - List? capturedNextAgentMessages = null; - - var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) => - { - string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; - Assert.NotNull(transferFuncName); - - return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); - }), name: "initialAgent"); - - var nextAgent = new ChatClientAgent(new MockChatClient((messages, options) => - { - capturedNextAgentMessages = messages.ToList(); - return new(new ChatMessage(ChatRole.Assistant, "response")); - }), - name: "nextAgent", - description: "The second agent"); - - var workflow = - AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent) - .WithHandoff(initialAgent, nextAgent) - .WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior.HandoffOnly) - .Build(); - - // Input includes a pre-existing non-handoff tool call in the conversation history - List input = - [ - new(ChatRole.User, "What's the weather? Also help me with math."), - new(ChatRole.Assistant, [new FunctionCallContent("toolcall1", "get_weather")]) { AuthorName = "initialAgent" }, - new(ChatRole.Tool, [new FunctionResultContent("toolcall1", "sunny")]), - new(ChatRole.Assistant, "The weather is sunny. Now let me route your math question.") { AuthorName = "initialAgent" }, - ]; - - _ = await RunWorkflowAsync(workflow, input); - - Assert.NotNull(capturedNextAgentMessages); - - // Handoff function calls and their tool results should be filtered - Assert.DoesNotContain(capturedNextAgentMessages, m => m.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name.StartsWith("handoff_to_", StringComparison.Ordinal))); - - // Non-handoff function calls and their tool results should be preserved - Assert.Contains(capturedNextAgentMessages, m => m.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name == "get_weather")); - Assert.Contains(capturedNextAgentMessages, m => m.Role == ChatRole.Tool && m.Contents.Any(c => c is FunctionResultContent frc && frc.CallId == "toolcall1")); - } - - [Fact] - public async Task Handoffs_TwoTransfers_ResponseServedByThirdAgentAsync() - { - var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) => - { - ChatMessage message = Assert.Single(messages); - Assert.Equal("abc", Assert.IsType(Assert.Single(message.Contents)).Text); - - string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; - Assert.NotNull(transferFuncName); - - // Only a handoff function call. - return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); - }), name: "initialAgent"); - - var secondAgent = new ChatClientAgent(new MockChatClient((messages, options) => - { - // Second agent should receive the conversation so far (including previous assistant + tool messages eventually). - string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; - Assert.NotNull(transferFuncName); - - return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call2", transferFuncName)])); - }), name: "secondAgent", description: "The second agent"); - - var thirdAgent = new ChatClientAgent(new MockChatClient((messages, options) => - new(new ChatMessage(ChatRole.Assistant, "Hello from agent3"))), - name: "thirdAgent", - description: "The third / final agent"); - - var workflow = - AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent) - .WithHandoff(initialAgent, secondAgent) - .WithHandoff(secondAgent, thirdAgent) - .Build(); - - (string updateText, List? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); - - Assert.Equal("Hello from agent3", updateText); - Assert.NotNull(result); - - // User + (assistant empty + tool) for each of first two agents + final assistant with text. - Assert.Equal(6, result.Count); - - Assert.Equal(ChatRole.User, result[0].Role); - Assert.Equal("abc", result[0].Text); - - Assert.Equal(ChatRole.Assistant, result[1].Role); - Assert.Equal("", result[1].Text); - Assert.Contains("initialAgent", result[1].AuthorName); - - Assert.Equal(ChatRole.Tool, result[2].Role); - Assert.Contains("initialAgent", result[2].AuthorName); - - Assert.Equal(ChatRole.Assistant, result[3].Role); - Assert.Equal("", result[3].Text); - Assert.Contains("secondAgent", result[3].AuthorName); - - Assert.Equal(ChatRole.Tool, result[4].Role); - Assert.Contains("secondAgent", result[4].AuthorName); - - Assert.Equal(ChatRole.Assistant, result[5].Role); - Assert.Equal("Hello from agent3", result[5].Text); - Assert.Contains("thirdAgent", result[5].AuthorName); - } - - [Fact] - public async Task Handoffs_TwoTransfers_SecondAgentUserApproval_ResponseServedByThirdAgentAsync() - { - var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) => - { - ChatMessage message = Assert.Single(messages); - Assert.Equal("abc", Assert.IsType(Assert.Single(message.Contents)).Text); - - string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; - Assert.NotNull(transferFuncName); - - // Only a handoff function call. - return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); - }), name: "initialAgent"); - - bool secondAgentInvoked = false; - - const string SomeOtherFunctionCallId = "call2first"; - - AIFunction someOtherFunction = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(SomeOtherFunction)); - - var secondAgent = new ChatClientAgent(new MockChatClient((messages, options) => - { - if (!secondAgentInvoked) - { - secondAgentInvoked = true; - return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent(SomeOtherFunctionCallId, someOtherFunction.Name)])); - } - - // Second agent should receive the conversation so far (including previous assistant + tool messages eventually). - string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; - Assert.NotNull(transferFuncName); - - return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call2", transferFuncName)])); - }), name: "secondAgent", description: "The second agent", tools: [someOtherFunction]); - - var thirdAgent = new ChatClientAgent(new MockChatClient((messages, options) => - new(new ChatMessage(ChatRole.Assistant, "Hello from agent3"))), - name: "thirdAgent", - description: "The third / final agent"); - - var workflow = - AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent) - .WithHandoff(initialAgent, secondAgent) - .WithHandoff(secondAgent, thirdAgent) - .Build(); - - CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); - const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep; - - (string updateText, List? result, CheckpointInfo? lastCheckpoint, List requests) = - await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "abc")], Environment, checkpointManager); - - Assert.Null(result); - Assert.NotNull(requests); - - requests.Should().HaveCount(1); - ExternalRequest request = requests[0].Request; - - ToolApprovalRequestContent approvalRequest = - request.Data.As().Should().NotBeNull() - .And.Subject.As(); - - approvalRequest.ToolCall.CallId.Should().Be(SomeOtherFunctionCallId); - - ExternalResponse response = request.CreateResponse(approvalRequest.CreateResponse(false, "Denied")); - - (updateText, result, _, requests) = - await RunWorkflowCheckpointedAsync(workflow, response, Environment, checkpointManager, lastCheckpoint); - - Assert.Equal("Hello from agent3", updateText); - Assert.NotNull(result); - - // User + (assistant empty + tool) for each of first two agents + final assistant with text. - Assert.Equal(10, result.Count); - - Assert.Equal(ChatRole.User, result[0].Role); - Assert.Equal("abc", result[0].Text); - - Assert.Equal(ChatRole.Assistant, result[1].Role); - Assert.Equal("", result[1].Text); - Assert.Contains("initialAgent", result[1].AuthorName); - - Assert.Equal(ChatRole.Tool, result[2].Role); - Assert.Contains("initialAgent", result[2].AuthorName); - - // Non-handoff tool invocation (and user denial) - Assert.Equal(ChatRole.Assistant, result[3].Role); - Assert.Equal("", result[3].Text); - Assert.Contains("secondAgent", result[3].AuthorName); - - Assert.Equal(ChatRole.User, result[4].Role); - Assert.Equal("", result[4].Text); - - // Rejected tool call - Assert.Equal(ChatRole.Assistant, result[5].Role); - Assert.Equal("", result[5].Text); - Assert.Contains("secondAgent", result[5].AuthorName); - - Assert.Equal(ChatRole.Tool, result[6].Role); - Assert.Contains("secondAgent", result[6].AuthorName); - - // Handoff invocation - Assert.Equal(ChatRole.Assistant, result[7].Role); - Assert.Equal("", result[7].Text); - Assert.Contains("secondAgent", result[7].AuthorName); - - Assert.Equal(ChatRole.Tool, result[8].Role); - Assert.Contains("secondAgent", result[8].AuthorName); - - Assert.Equal(ChatRole.Assistant, result[9].Role); - Assert.Equal("Hello from agent3", result[9].Text); - Assert.Contains("thirdAgent", result[9].AuthorName); - - static bool SomeOtherFunction() => true; - } - - [Fact] - public async Task Handoffs_TwoTransfers_SecondAgentToolCall_ResponseServedByThirdAgentAsync() - { - var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) => - { - ChatMessage message = Assert.Single(messages); - Assert.Equal("abc", Assert.IsType(Assert.Single(message.Contents)).Text); - - string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; - Assert.NotNull(transferFuncName); - - // Only a handoff function call. - return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); - }), name: "initialAgent"); - - bool secondAgentInvoked = false; - - const string SomeOtherFunctionName = "SomeOtherFunction"; - const string SomeOtherFunctionCallId = "call2first"; - - JsonElement otherFunctionSchema = AIFunctionFactory.Create(() => true).JsonSchema; - AIFunctionDeclaration someOtherFunction = AIFunctionFactory.CreateDeclaration(SomeOtherFunctionName, "Another function", otherFunctionSchema); - - var secondAgent = new ChatClientAgent(new MockChatClient((messages, options) => - { - if (!secondAgentInvoked) - { - secondAgentInvoked = true; - return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent(SomeOtherFunctionCallId, SomeOtherFunctionName)])); - } - - // Second agent should receive the conversation so far (including previous assistant + tool messages eventually). - string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; - Assert.NotNull(transferFuncName); - - return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call2", transferFuncName)])); - }), name: "secondAgent", description: "The second agent", tools: [someOtherFunction]); - - var thirdAgent = new ChatClientAgent(new MockChatClient((messages, options) => - new(new ChatMessage(ChatRole.Assistant, "Hello from agent3"))), - name: "thirdAgent", - description: "The third / final agent"); - - var workflow = - AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent) - .WithHandoff(initialAgent, secondAgent) - .WithHandoff(secondAgent, thirdAgent) - .Build(); - - CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); - const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep; - - (string updateText, List? result, CheckpointInfo? lastCheckpoint, List requests) = - await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "abc")], Environment, checkpointManager); - - Assert.Null(result); - Assert.NotNull(requests); - - requests.Should().HaveCount(1); - ExternalRequest request = requests[0].Request; - - FunctionCallContent functionCall = request.Data.As().Should().NotBeNull() - .And.Subject.As(); - - functionCall.CallId.Should().Be(SomeOtherFunctionCallId); - functionCall.Name.Should().Be(SomeOtherFunctionName); - - ExternalResponse response = request.CreateResponse(new FunctionResultContent(functionCall.CallId, true)); - - (updateText, result, _, requests) = - await RunWorkflowCheckpointedAsync(workflow, response, Environment, checkpointManager, lastCheckpoint); - - Assert.Equal("Hello from agent3", updateText); - Assert.NotNull(result); - - // User + (assistant empty + tool) for each of first two agents + final assistant with text. - Assert.Equal(8, result.Count); - - Assert.Equal(ChatRole.User, result[0].Role); - Assert.Equal("abc", result[0].Text); - - Assert.Equal(ChatRole.Assistant, result[1].Role); - Assert.Equal("", result[1].Text); - Assert.Contains("initialAgent", result[1].AuthorName); - - Assert.Equal(ChatRole.Tool, result[2].Role); - Assert.Contains("initialAgent", result[2].AuthorName); - - // Non-handoff tool invocation - Assert.Equal(ChatRole.Assistant, result[3].Role); - Assert.Equal("", result[3].Text); - Assert.Contains("secondAgent", result[3].AuthorName); - - Assert.Equal(ChatRole.Tool, result[4].Role); - Assert.Contains("secondAgent", result[4].AuthorName); - - // Handoff invocation - Assert.Equal(ChatRole.Assistant, result[5].Role); - Assert.Equal("", result[5].Text); - Assert.Contains("secondAgent", result[5].AuthorName); - - Assert.Equal(ChatRole.Tool, result[6].Role); - Assert.Contains("secondAgent", result[6].AuthorName); - - Assert.Equal(ChatRole.Assistant, result[7].Role); - Assert.Equal("Hello from agent3", result[7].Text); - Assert.Contains("thirdAgent", result[7].AuthorName); - } - [Theory] [InlineData(1)] [InlineData(2)] @@ -955,178 +263,8 @@ public async Task BuildGroupChat_AgentsRunInOrderAsync(int maxIterations) } } - [Fact] - public async Task Handoffs_ReturnToPrevious_DisabledByDefault_SecondTurnRoutesViaCoordinatorAsync() - { - int coordinatorCallCount = 0; - - var coordinator = new ChatClientAgent(new MockChatClient((messages, options) => - { - coordinatorCallCount++; - if (coordinatorCallCount == 1) - { - string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; - Assert.NotNull(transferFuncName); - return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); - } - return new(new ChatMessage(ChatRole.Assistant, "coordinator responded on turn 2")); - }), name: "coordinator"); - - var specialist = new ChatClientAgent(new MockChatClient((messages, options) => - new(new ChatMessage(ChatRole.Assistant, "specialist responded"))), - name: "specialist", description: "The specialist agent"); - - var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator) - .WithHandoff(coordinator, specialist) - .Build(); - - CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); - const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep; - - // Turn 1: coordinator hands off to specialist - WorkflowRunResult result = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "book an appointment")], Environment, checkpointManager); - Assert.Equal(1, coordinatorCallCount); - - // Turn 2: without ReturnToPrevious, coordinator should be invoked again - _ = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "my id is 12345")], Environment, checkpointManager, result.LastCheckpoint); - Assert.Equal(2, coordinatorCallCount); - } - - [Fact] - public async Task Handoffs_ReturnToPrevious_Enabled_SecondTurnRoutesDirectlyToSpecialistAsync() - { - int coordinatorCallCount = 0; - int specialistCallCount = 0; - - var coordinator = new ChatClientAgent(new MockChatClient((messages, options) => - { - coordinatorCallCount++; - string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; - Assert.NotNull(transferFuncName); - return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); - }), name: "coordinator"); - - var specialist = new ChatClientAgent(new MockChatClient((messages, options) => - { - specialistCallCount++; - return new(new ChatMessage(ChatRole.Assistant, "specialist responded")); - }), name: "specialist", description: "The specialist agent"); - - var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator) - .WithHandoff(coordinator, specialist) - .EnableReturnToPrevious() - .Build(); - - CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); - const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep; - - // Turn 1: coordinator hands off to specialist - WorkflowRunResult result = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "book an appointment")], Environment, checkpointManager); - Assert.Equal(1, coordinatorCallCount); - Assert.Equal(1, specialistCallCount); - - // Turn 2: with ReturnToPrevious, specialist should be invoked directly, coordinator should NOT be called again - _ = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "my id is 12345")], Environment, checkpointManager, result.LastCheckpoint); - Assert.Equal(1, coordinatorCallCount); // coordinator NOT called again - Assert.Equal(2, specialistCallCount); // specialist called again - } - - [Fact] - public async Task Handoffs_ReturnToPrevious_Enabled_BeforeAnyHandoff_RoutesViaInitialAgentAsync() - { - int coordinatorCallCount = 0; - - var coordinator = new ChatClientAgent(new MockChatClient((messages, options) => - { - coordinatorCallCount++; - return new(new ChatMessage(ChatRole.Assistant, "coordinator responded")); - }), name: "coordinator"); - - var specialist = new ChatClientAgent(new MockChatClient((messages, options) => - { - Assert.Fail("Specialist should not be invoked."); - return new(); - }), name: "specialist", description: "The specialist agent"); - - var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator) - .WithHandoff(coordinator, specialist) - .EnableReturnToPrevious() - .Build(); - - // First turn with no prior handoff: should route to initial (coordinator) agent - _ = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "hello")]); - Assert.Equal(1, coordinatorCallCount); - } - - [Fact] - public async Task Handoffs_ReturnToPrevious_Enabled_AfterHandoffBackToCoordinator_NextTurnRoutesViaCoordinatorAsync() - { - int coordinatorCallCount = 0; - int specialistCallCount = 0; - - var coordinator = new ChatClientAgent(new MockChatClient((messages, options) => - { - coordinatorCallCount++; - if (coordinatorCallCount == 1) - { - // First call: hand off to specialist - string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; - Assert.NotNull(transferFuncName); - return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); - } - // Subsequent calls: respond without handoff - return new(new ChatMessage(ChatRole.Assistant, "coordinator responded")); - }), name: "coordinator"); - - var specialist = new ChatClientAgent(new MockChatClient((messages, options) => - { - specialistCallCount++; - // Specialist hands back to coordinator - string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; - Assert.NotNull(transferFuncName); - return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call2", transferFuncName)])); - }), name: "specialist", description: "The specialist agent"); - - var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator) - .WithHandoff(coordinator, specialist) - .WithHandoff(specialist, coordinator) - .EnableReturnToPrevious() - .Build(); - - CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); - const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep; - - // Turn 1: coordinator → specialist → coordinator (specialist hands back) - WorkflowRunResult result = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "book an appointment")], Environment, checkpointManager); - Assert.Equal(2, coordinatorCallCount); // called twice: initial handoff + receiving handback - Assert.Equal(1, specialistCallCount); // specialist called once, then handed back - - // Turn 2: after handoff back to coordinator, should route to coordinator (not specialist) - _ = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "never mind")], Environment, checkpointManager, result.LastCheckpoint); - Assert.Equal(3, coordinatorCallCount); // coordinator called again on turn 2 - Assert.Equal(1, specialistCallCount); // specialist NOT called - } - private sealed record WorkflowRunResult(string UpdateText, List? Result, CheckpointInfo? LastCheckpoint, List PendingRequests); - private static Task RunWorkflowCheckpointedAsync( - Workflow workflow, List input, ExecutionEnvironment executionEnvironment, CheckpointManager checkpointManager, CheckpointInfo? fromCheckpoint = null) - { - InProcessExecutionEnvironment environment = executionEnvironment.ToWorkflowExecutionEnvironment() - .WithCheckpointing(checkpointManager); - - return RunWorkflowCheckpointedAsync(workflow, input, environment, fromCheckpoint); - } - - private static Task RunWorkflowCheckpointedAsync( - Workflow workflow, ExternalResponse response, ExecutionEnvironment executionEnvironment, CheckpointManager checkpointManager, CheckpointInfo? fromCheckpoint = null) - { - InProcessExecutionEnvironment environment = executionEnvironment.ToWorkflowExecutionEnvironment() - .WithCheckpointing(checkpointManager); - - return RunWorkflowCheckpointedAsync(workflow, response, environment, fromCheckpoint); - } - private static async Task RunWorkflowCheckpointedAsync( Workflow workflow, List input, InProcessExecutionEnvironment environment, CheckpointInfo? fromCheckpoint = null) { @@ -1140,18 +278,6 @@ private static async Task RunWorkflowCheckpointedAsync( return await ProcessWorkflowRunAsync(run); } - private static async Task RunWorkflowCheckpointedAsync( - Workflow workflow, ExternalResponse response, InProcessExecutionEnvironment environment, CheckpointInfo? fromCheckpoint = null) - { - await using StreamingRun run = - fromCheckpoint != null ? await environment.ResumeStreamingAsync(workflow, fromCheckpoint) - : await environment.OpenStreamingAsync(workflow); - - await run.SendResponseAsync(response); - - return await ProcessWorkflowRunAsync(run); - } - private static async Task ProcessWorkflowRunAsync(StreamingRun run) { StringBuilder sb = new(); @@ -1212,22 +338,4 @@ protected override async IAsyncEnumerable RunCoreStreamingA } } } - - private sealed class MockChatClient(Func, ChatOptions?, ChatResponse> responseFactory) : IChatClient - { - public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) => - Task.FromResult(responseFactory(messages, options)); - - public async IAsyncEnumerable GetStreamingResponseAsync( - IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) - { - foreach (var update in (await this.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)).ToChatResponseUpdates()) - { - yield return update; - } - } - - public object? GetService(Type serviceType, object? serviceKey = null) => null; - public void Dispose() { } - } } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffOrchestrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffOrchestrationTests.cs new file mode 100644 index 0000000000..89cb52a89a --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffOrchestrationTests.cs @@ -0,0 +1,1001 @@ +// Copyright (c) Microsoft. All rights reserved. + +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Runtime.CompilerServices; +using System.Text; +using System.Text.Json; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using Microsoft.Agents.AI.Workflows.InProc; +using Microsoft.Agents.AI.Workflows.Specialized; +using Microsoft.Extensions.AI; +using Microsoft.Extensions.Logging; + +namespace Microsoft.Agents.AI.Workflows.UnitTests; + +public class HandoffOrchestrationTests +{ + [Fact] + public void BuildHandoffs_InvalidArguments_Throws() + { + Assert.Throws("initialAgent", () => AgentWorkflowBuilder.CreateHandoffBuilderWith(null!)); + + var agent = new DoubleEchoAgent("agent"); + var handoffs = AgentWorkflowBuilder.CreateHandoffBuilderWith(agent); + Assert.NotNull(handoffs); + + Assert.Throws("from", () => handoffs.WithHandoff(null!, new DoubleEchoAgent("a2"))); + Assert.Throws("to", () => handoffs.WithHandoff(new DoubleEchoAgent("a2"), null!)); + + Assert.Throws("from", () => handoffs.WithHandoffs(null!, new DoubleEchoAgent("a2"))); + Assert.Throws("from", () => handoffs.WithHandoffs([null!], new DoubleEchoAgent("a2"))); + Assert.Throws("to", () => handoffs.WithHandoffs(new DoubleEchoAgent("a2"), null!)); + Assert.Throws("to", () => handoffs.WithHandoffs(new DoubleEchoAgent("a2"), [null!])); + + var noDescriptionAgent = new ChatClientAgent(new MockChatClient(delegate { return new(); })); + Assert.Throws("to", () => handoffs.WithHandoff(agent, noDescriptionAgent)); + + var emptyDescriptionAgent = new MockChatClient(delegate { return new(); }).AsAIAgent(description: ""); + Assert.Throws("to", () => handoffs.WithHandoff(agent, emptyDescriptionAgent)); + + var emptyNameAgent = new MockChatClient(delegate { return new(); }).AsAIAgent(name: ""); + Assert.Throws("to", () => handoffs.WithHandoff(agent, emptyNameAgent)); + } + + private sealed class NullLogger : ILogger + { + public IDisposable? BeginScope(TState state) where TState : notnull + { + return null; + } + + public bool IsEnabled(LogLevel logLevel) + { + return false; + } + + public void Log(LogLevel logLevel, EventId eventId, TState state, Exception? exception, Func formatter) + { + } + } + + [Fact] + public void BuildHandoffs_DelegatingAIAgent_DoesNotThrow() + { + DoubleEchoAgent agent = new("agent"); + HandoffWorkflowBuilder handoffs = AgentWorkflowBuilder.CreateHandoffBuilderWith(agent); + Assert.NotNull(handoffs); + + ChatClientAgent instructionsOnlyAgent = new MockChatClient(delegate { return new(); }).AsAIAgent(instructions: "instructions"); + LoggingAgent delegatingAgent = new(instructionsOnlyAgent, new NullLogger()); + + handoffs.WithHandoff(agent, delegatingAgent); + + // get the _targets field from the HandoffWorkflowBuilder (need to use the base type) + FieldInfo field = typeof(HandoffWorkflowBuilder).BaseType!.GetField("_targets", BindingFlags.Instance | BindingFlags.NonPublic)!; + Dictionary>? targets = field.GetValue(handoffs) as Dictionary>; + + targets.Should().NotBeNull(); + + HandoffTarget target = targets[agent].Single(); + target.Reason.Should().Be("instructions"); + } + + [Fact] + public async Task Handoffs_NoTransfers_ResponseServedByOriginalAgentAsync() + { + var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + ChatMessage message = Assert.Single(messages); + Assert.Equal("abc", Assert.IsType(Assert.Single(message.Contents)).Text); + + return new(new ChatMessage(ChatRole.Assistant, "Hello from agent1")); + })); + + var workflow = + AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent) + .WithHandoff(initialAgent, new ChatClientAgent(new MockChatClient(delegate + { + Assert.Fail("Should never be invoked."); + return new(); + }), description: "nop")) + .Build(); + + (string updateText, List? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + + Assert.Equal("Hello from agent1", updateText); + Assert.NotNull(result); + + Assert.Equal(2, result.Count); + + Assert.Equal(ChatRole.User, result[0].Role); + Assert.Equal("abc", result[0].Text); + + Assert.Equal(ChatRole.Assistant, result[1].Role); + Assert.Equal("Hello from agent1", result[1].Text); + } + + [Fact] + public async Task Handoffs_OneTransfer_ResponseServedBySecondAgentAsync() + { + var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + ChatMessage message = Assert.Single(messages); + Assert.Equal("abc", Assert.IsType(Assert.Single(message.Contents)).Text); + + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); + }), name: "initialAgent"); + + var nextAgent = new ChatClientAgent(new MockChatClient((messages, options) => + new(new ChatMessage(ChatRole.Assistant, "Hello from agent2"))), + name: "nextAgent", + description: "The second agent"); + + var workflow = + AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent) + .WithHandoff(initialAgent, nextAgent) + .Build(); + + (string updateText, List? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + + Assert.Equal("Hello from agent2", updateText); + Assert.NotNull(result); + + Assert.Equal(4, result.Count); + + Assert.Equal(ChatRole.User, result[0].Role); + Assert.Equal("abc", result[0].Text); + + Assert.Equal(ChatRole.Assistant, result[1].Role); + Assert.Equal("", result[1].Text); + Assert.Contains("initialAgent", result[1].AuthorName); + + Assert.Equal(ChatRole.Tool, result[2].Role); + Assert.Contains("initialAgent", result[2].AuthorName); + + Assert.Equal(ChatRole.Assistant, result[3].Role); + Assert.Equal("Hello from agent2", result[3].Text); + Assert.Contains("nextAgent", result[3].AuthorName); + } + + [Fact] + public async Task Handoffs_OneTransfer_HandoffTargetDoesNotReceiveHandoffFunctionMessagesAsync() + { + // Regression test for https://github.com/microsoft/agent-framework/issues/3161 + // When a handoff occurs, the target agent should receive the original user message + // but should NOT receive the handoff function call or tool result messages from the + // source agent, as these confuse the target LLM into ignoring the user's question. + + List? capturedNextAgentMessages = null; + + var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); + }), name: "initialAgent"); + + var nextAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + capturedNextAgentMessages = messages.ToList(); + return new(new ChatMessage(ChatRole.Assistant, "The derivative of x^2 is 2x.")); + }), + name: "nextAgent", + description: "The second agent"); + + var workflow = + AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent) + .WithHandoff(initialAgent, nextAgent) + .Build(); + + _ = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "What is the derivative of x^2?")]); + + Assert.NotNull(capturedNextAgentMessages); + + // The target agent should see the original user message + Assert.Contains(capturedNextAgentMessages, m => m.Role == ChatRole.User && m.Text == "What is the derivative of x^2?"); + + // The target agent should NOT see the handoff function call or tool result from the source agent + Assert.DoesNotContain(capturedNextAgentMessages, m => m.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name.StartsWith("handoff_to_", StringComparison.Ordinal))); + Assert.DoesNotContain(capturedNextAgentMessages, m => m.Role == ChatRole.Tool && m.Contents.Any(c => c is FunctionResultContent frc && frc.Result?.ToString() == "Transferred.")); + } + + [Fact] + public async Task Handoffs_TwoTransfers_HandoffTargetsDoNotReceiveHandoffFunctionMessagesAsync() + { + // Regression test for https://github.com/microsoft/agent-framework/issues/3161 + // With two hops (initial -> second -> third), each target agent should receive the + // original user message and text responses from prior agents (as User role), but + // NOT any handoff function call or tool result messages. + + List? capturedSecondAgentMessages = null; + List? capturedThirdAgentMessages = null; + + var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + + // Return both a text message and a handoff function call + return new(new ChatMessage(ChatRole.Assistant, [new TextContent("Routing to second agent"), new FunctionCallContent("call1", transferFuncName)])); + }), name: "initialAgent"); + + var secondAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + capturedSecondAgentMessages = messages.ToList(); + + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + + // Return both a text message and a handoff function call + return new(new ChatMessage(ChatRole.Assistant, [new TextContent("Routing to third agent"), new FunctionCallContent("call2", transferFuncName)])); + }), name: "secondAgent", description: "The second agent"); + + var thirdAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + capturedThirdAgentMessages = messages.ToList(); + return new(new ChatMessage(ChatRole.Assistant, "Hello from agent3")); + }), + name: "thirdAgent", + description: "The third / final agent"); + + var workflow = + AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent) + .WithHandoff(initialAgent, secondAgent) + .WithHandoff(secondAgent, thirdAgent) + .Build(); + + (string updateText, _, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + + Assert.Contains("Hello from agent3", updateText); + + // Second agent should see the original user message and initialAgent's text as context + Assert.NotNull(capturedSecondAgentMessages); + Assert.Contains(capturedSecondAgentMessages, m => m.Text == "abc"); + Assert.Contains(capturedSecondAgentMessages, m => m.Text!.Contains("Routing to second agent")); + Assert.DoesNotContain(capturedSecondAgentMessages, m => m.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name.StartsWith("handoff_to_", StringComparison.Ordinal))); + Assert.DoesNotContain(capturedSecondAgentMessages, m => m.Role == ChatRole.Tool && m.Contents.Any(c => c is FunctionResultContent)); + + // Third agent should see the original user message and both prior agents' text as context + Assert.NotNull(capturedThirdAgentMessages); + Assert.Contains(capturedThirdAgentMessages, m => m.Text == "abc"); + Assert.Contains(capturedThirdAgentMessages, m => m.Text!.Contains("Routing to second agent")); + Assert.Contains(capturedThirdAgentMessages, m => m.Text!.Contains("Routing to third agent")); + Assert.DoesNotContain(capturedThirdAgentMessages, m => m.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name.StartsWith("handoff_to_", StringComparison.Ordinal))); + Assert.DoesNotContain(capturedThirdAgentMessages, m => m.Role == ChatRole.Tool && m.Contents.Any(c => c is FunctionResultContent)); + } + + [Fact] + public async Task Handoffs_FilteringNone_HandoffTargetReceivesAllMessagesIncludingToolCallsAsync() + { + // With filtering set to None, the target agent should see everything including + // handoff function calls and tool results. + + List? capturedNextAgentMessages = null; + + var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); + }), name: "initialAgent"); + + var nextAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + capturedNextAgentMessages = messages.ToList(); + return new(new ChatMessage(ChatRole.Assistant, "response")); + }), + name: "nextAgent", + description: "The second agent"); + + var workflow = + AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent) + .WithHandoff(initialAgent, nextAgent) + .WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior.None) + .Build(); + + _ = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "hello")]); + + Assert.NotNull(capturedNextAgentMessages); + Assert.Contains(capturedNextAgentMessages, m => m.Text == "hello"); + + // With None filtering, handoff function calls and tool results should be visible + Assert.Contains(capturedNextAgentMessages, m => m.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name.StartsWith("handoff_to_", StringComparison.Ordinal))); + Assert.Contains(capturedNextAgentMessages, m => m.Contents.Any(c => c is FunctionResultContent)); + } + + [Fact] + public async Task Handoffs_FilteringAll_HandoffTargetDoesNotReceiveAnyToolCallsAsync() + { + // With filtering set to All, the target agent should see no function calls or tool + // results at all — not even non-handoff ones from prior conversation history. + + List? capturedNextAgentMessages = null; + + var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + + return new(new ChatMessage(ChatRole.Assistant, [new TextContent("Routing you now"), new FunctionCallContent("call1", transferFuncName)])); + }), name: "initialAgent"); + + var nextAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + capturedNextAgentMessages = messages.ToList(); + return new(new ChatMessage(ChatRole.Assistant, "response")); + }), + name: "nextAgent", + description: "The second agent"); + + var workflow = + AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent) + .WithHandoff(initialAgent, nextAgent) + .WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior.All) + .Build(); + + // Input includes a pre-existing non-handoff tool call in the conversation history + List input = + [ + new(ChatRole.User, "What's the weather? Also help me with math."), + new(ChatRole.Assistant, [new FunctionCallContent("toolcall1", "get_weather")]) { AuthorName = "initialAgent" }, + new(ChatRole.Tool, [new FunctionResultContent("toolcall1", "sunny")]), + new(ChatRole.Assistant, "The weather is sunny. Now let me route your math question.") { AuthorName = "initialAgent" }, + ]; + + _ = await RunWorkflowAsync(workflow, input); + + Assert.NotNull(capturedNextAgentMessages); + + // With All filtering, NO function calls or tool results should be visible + Assert.DoesNotContain(capturedNextAgentMessages, m => m.Contents.Any(c => c is FunctionCallContent)); + Assert.DoesNotContain(capturedNextAgentMessages, m => m.Role == ChatRole.Tool); + + // But text content should still be visible + Assert.Contains(capturedNextAgentMessages, m => m.Text!.Contains("What's the weather")); + Assert.Contains(capturedNextAgentMessages, m => m.Text!.Contains("Routing you now")); + } + + [Fact] + public async Task Handoffs_FilteringHandoffOnly_PreservesNonHandoffToolCallsAsync() + { + // With HandoffOnly filtering (the default), non-handoff function calls and tool + // results should be preserved while handoff ones are stripped. + + List? capturedNextAgentMessages = null; + + var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); + }), name: "initialAgent"); + + var nextAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + capturedNextAgentMessages = messages.ToList(); + return new(new ChatMessage(ChatRole.Assistant, "response")); + }), + name: "nextAgent", + description: "The second agent"); + + var workflow = + AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent) + .WithHandoff(initialAgent, nextAgent) + .WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior.HandoffOnly) + .Build(); + + // Input includes a pre-existing non-handoff tool call in the conversation history + List input = + [ + new(ChatRole.User, "What's the weather? Also help me with math."), + new(ChatRole.Assistant, [new FunctionCallContent("toolcall1", "get_weather")]) { AuthorName = "initialAgent" }, + new(ChatRole.Tool, [new FunctionResultContent("toolcall1", "sunny")]), + new(ChatRole.Assistant, "The weather is sunny. Now let me route your math question.") { AuthorName = "initialAgent" }, + ]; + + _ = await RunWorkflowAsync(workflow, input); + + Assert.NotNull(capturedNextAgentMessages); + + // Handoff function calls and their tool results should be filtered + Assert.DoesNotContain(capturedNextAgentMessages, m => m.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name.StartsWith("handoff_to_", StringComparison.Ordinal))); + + // Non-handoff function calls and their tool results should be preserved + Assert.Contains(capturedNextAgentMessages, m => m.Contents.Any(c => c is FunctionCallContent fcc && fcc.Name == "get_weather")); + Assert.Contains(capturedNextAgentMessages, m => m.Role == ChatRole.Tool && m.Contents.Any(c => c is FunctionResultContent frc && frc.CallId == "toolcall1")); + } + + [Fact] + public async Task Handoffs_TwoTransfers_ResponseServedByThirdAgentAsync() + { + var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + ChatMessage message = Assert.Single(messages); + Assert.Equal("abc", Assert.IsType(Assert.Single(message.Contents)).Text); + + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + + // Only a handoff function call. + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); + }), name: "initialAgent"); + + var secondAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + // Second agent should receive the conversation so far (including previous assistant + tool messages eventually). + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call2", transferFuncName)])); + }), name: "secondAgent", description: "The second agent"); + + var thirdAgent = new ChatClientAgent(new MockChatClient((messages, options) => + new(new ChatMessage(ChatRole.Assistant, "Hello from agent3"))), + name: "thirdAgent", + description: "The third / final agent"); + + var workflow = + AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent) + .WithHandoff(initialAgent, secondAgent) + .WithHandoff(secondAgent, thirdAgent) + .Build(); + + (string updateText, List? result, _, _) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "abc")]); + + Assert.Equal("Hello from agent3", updateText); + Assert.NotNull(result); + + // User + (assistant empty + tool) for each of first two agents + final assistant with text. + Assert.Equal(6, result.Count); + + Assert.Equal(ChatRole.User, result[0].Role); + Assert.Equal("abc", result[0].Text); + + Assert.Equal(ChatRole.Assistant, result[1].Role); + Assert.Equal("", result[1].Text); + Assert.Contains("initialAgent", result[1].AuthorName); + + Assert.Equal(ChatRole.Tool, result[2].Role); + Assert.Contains("initialAgent", result[2].AuthorName); + + Assert.Equal(ChatRole.Assistant, result[3].Role); + Assert.Equal("", result[3].Text); + Assert.Contains("secondAgent", result[3].AuthorName); + + Assert.Equal(ChatRole.Tool, result[4].Role); + Assert.Contains("secondAgent", result[4].AuthorName); + + Assert.Equal(ChatRole.Assistant, result[5].Role); + Assert.Equal("Hello from agent3", result[5].Text); + Assert.Contains("thirdAgent", result[5].AuthorName); + } + + [Fact] + public async Task Handoffs_TwoTransfers_SecondAgentUserApproval_ResponseServedByThirdAgentAsync() + { + var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + ChatMessage message = Assert.Single(messages); + Assert.Equal("abc", Assert.IsType(Assert.Single(message.Contents)).Text); + + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + + // Only a handoff function call. + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); + }), name: "initialAgent"); + + bool secondAgentInvoked = false; + + const string SomeOtherFunctionCallId = "call2first"; + + AIFunction someOtherFunction = new ApprovalRequiredAIFunction(AIFunctionFactory.Create(SomeOtherFunction)); + + var secondAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + if (!secondAgentInvoked) + { + secondAgentInvoked = true; + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent(SomeOtherFunctionCallId, someOtherFunction.Name)])); + } + + // Second agent should receive the conversation so far (including previous assistant + tool messages eventually). + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call2", transferFuncName)])); + }), name: "secondAgent", description: "The second agent", tools: [someOtherFunction]); + + var thirdAgent = new ChatClientAgent(new MockChatClient((messages, options) => + new(new ChatMessage(ChatRole.Assistant, "Hello from agent3"))), + name: "thirdAgent", + description: "The third / final agent"); + + var workflow = + AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent) + .WithHandoff(initialAgent, secondAgent) + .WithHandoff(secondAgent, thirdAgent) + .Build(); + + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep; + + (string updateText, List? result, CheckpointInfo? lastCheckpoint, List requests) = + await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "abc")], Environment, checkpointManager); + + Assert.Null(result); + Assert.NotNull(requests); + + requests.Should().HaveCount(1); + ExternalRequest request = requests[0].Request; + + ToolApprovalRequestContent approvalRequest = + request.Data.As().Should().NotBeNull() + .And.Subject.As(); + + approvalRequest.ToolCall.CallId.Should().Be(SomeOtherFunctionCallId); + + ExternalResponse response = request.CreateResponse(approvalRequest.CreateResponse(false, "Denied")); + + (updateText, result, _, requests) = + await RunWorkflowCheckpointedAsync(workflow, response, Environment, checkpointManager, lastCheckpoint); + + Assert.Equal("Hello from agent3", updateText); + Assert.NotNull(result); + + // User + (assistant empty + tool) for each of first two agents + final assistant with text. + Assert.Equal(10, result.Count); + + Assert.Equal(ChatRole.User, result[0].Role); + Assert.Equal("abc", result[0].Text); + + Assert.Equal(ChatRole.Assistant, result[1].Role); + Assert.Equal("", result[1].Text); + Assert.Contains("initialAgent", result[1].AuthorName); + + Assert.Equal(ChatRole.Tool, result[2].Role); + Assert.Contains("initialAgent", result[2].AuthorName); + + // Non-handoff tool invocation (and user denial) + Assert.Equal(ChatRole.Assistant, result[3].Role); + Assert.Equal("", result[3].Text); + Assert.Contains("secondAgent", result[3].AuthorName); + + Assert.Equal(ChatRole.User, result[4].Role); + Assert.Equal("", result[4].Text); + + // Rejected tool call + Assert.Equal(ChatRole.Assistant, result[5].Role); + Assert.Equal("", result[5].Text); + Assert.Contains("secondAgent", result[5].AuthorName); + + Assert.Equal(ChatRole.Tool, result[6].Role); + Assert.Contains("secondAgent", result[6].AuthorName); + + // Handoff invocation + Assert.Equal(ChatRole.Assistant, result[7].Role); + Assert.Equal("", result[7].Text); + Assert.Contains("secondAgent", result[7].AuthorName); + + Assert.Equal(ChatRole.Tool, result[8].Role); + Assert.Contains("secondAgent", result[8].AuthorName); + + Assert.Equal(ChatRole.Assistant, result[9].Role); + Assert.Equal("Hello from agent3", result[9].Text); + Assert.Contains("thirdAgent", result[9].AuthorName); + + static bool SomeOtherFunction() => true; + } + + [Fact] + public async Task Handoffs_TwoTransfers_SecondAgentToolCall_ResponseServedByThirdAgentAsync() + { + var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + ChatMessage message = Assert.Single(messages); + Assert.Equal("abc", Assert.IsType(Assert.Single(message.Contents)).Text); + + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + + // Only a handoff function call. + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); + }), name: "initialAgent"); + + bool secondAgentInvoked = false; + + const string SomeOtherFunctionName = "SomeOtherFunction"; + const string SomeOtherFunctionCallId = "call2first"; + + JsonElement otherFunctionSchema = AIFunctionFactory.Create(() => true).JsonSchema; + AIFunctionDeclaration someOtherFunction = AIFunctionFactory.CreateDeclaration(SomeOtherFunctionName, "Another function", otherFunctionSchema); + + var secondAgent = new ChatClientAgent(new MockChatClient((messages, options) => + { + if (!secondAgentInvoked) + { + secondAgentInvoked = true; + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent(SomeOtherFunctionCallId, SomeOtherFunctionName)])); + } + + // Second agent should receive the conversation so far (including previous assistant + tool messages eventually). + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call2", transferFuncName)])); + }), name: "secondAgent", description: "The second agent", tools: [someOtherFunction]); + + var thirdAgent = new ChatClientAgent(new MockChatClient((messages, options) => + new(new ChatMessage(ChatRole.Assistant, "Hello from agent3"))), + name: "thirdAgent", + description: "The third / final agent"); + + var workflow = + AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent) + .WithHandoff(initialAgent, secondAgent) + .WithHandoff(secondAgent, thirdAgent) + .Build(); + + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep; + + (string updateText, List? result, CheckpointInfo? lastCheckpoint, List requests) = + await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "abc")], Environment, checkpointManager); + + Assert.Null(result); + Assert.NotNull(requests); + + requests.Should().HaveCount(1); + ExternalRequest request = requests[0].Request; + + FunctionCallContent functionCall = request.Data.As().Should().NotBeNull() + .And.Subject.As(); + + functionCall.CallId.Should().Be(SomeOtherFunctionCallId); + functionCall.Name.Should().Be(SomeOtherFunctionName); + + ExternalResponse response = request.CreateResponse(new FunctionResultContent(functionCall.CallId, true)); + + (updateText, result, _, requests) = + await RunWorkflowCheckpointedAsync(workflow, response, Environment, checkpointManager, lastCheckpoint); + + Assert.Equal("Hello from agent3", updateText); + Assert.NotNull(result); + + // User + (assistant empty + tool) for each of first two agents + final assistant with text. + Assert.Equal(8, result.Count); + + Assert.Equal(ChatRole.User, result[0].Role); + Assert.Equal("abc", result[0].Text); + + Assert.Equal(ChatRole.Assistant, result[1].Role); + Assert.Equal("", result[1].Text); + Assert.Contains("initialAgent", result[1].AuthorName); + + Assert.Equal(ChatRole.Tool, result[2].Role); + Assert.Contains("initialAgent", result[2].AuthorName); + + // Non-handoff tool invocation + Assert.Equal(ChatRole.Assistant, result[3].Role); + Assert.Equal("", result[3].Text); + Assert.Contains("secondAgent", result[3].AuthorName); + + Assert.Equal(ChatRole.Tool, result[4].Role); + Assert.Contains("secondAgent", result[4].AuthorName); + + // Handoff invocation + Assert.Equal(ChatRole.Assistant, result[5].Role); + Assert.Equal("", result[5].Text); + Assert.Contains("secondAgent", result[5].AuthorName); + + Assert.Equal(ChatRole.Tool, result[6].Role); + Assert.Contains("secondAgent", result[6].AuthorName); + + Assert.Equal(ChatRole.Assistant, result[7].Role); + Assert.Equal("Hello from agent3", result[7].Text); + Assert.Contains("thirdAgent", result[7].AuthorName); + } + + [Fact] + public async Task Handoffs_ReturnToPrevious_DisabledByDefault_SecondTurnRoutesViaCoordinatorAsync() + { + int coordinatorCallCount = 0; + + var coordinator = new ChatClientAgent(new MockChatClient((messages, options) => + { + coordinatorCallCount++; + if (coordinatorCallCount == 1) + { + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); + } + return new(new ChatMessage(ChatRole.Assistant, "coordinator responded on turn 2")); + }), name: "coordinator"); + + var specialist = new ChatClientAgent(new MockChatClient((messages, options) => + new(new ChatMessage(ChatRole.Assistant, "specialist responded"))), + name: "specialist", description: "The specialist agent"); + + var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator) + .WithHandoff(coordinator, specialist) + .Build(); + + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep; + + // Turn 1: coordinator hands off to specialist + WorkflowRunResult result = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "book an appointment")], Environment, checkpointManager); + Assert.Equal(1, coordinatorCallCount); + + // Turn 2: without ReturnToPrevious, coordinator should be invoked again + _ = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "my id is 12345")], Environment, checkpointManager, result.LastCheckpoint); + Assert.Equal(2, coordinatorCallCount); + } + + [Fact] + public async Task Handoffs_ReturnToPrevious_Enabled_SecondTurnRoutesDirectlyToSpecialistAsync() + { + int coordinatorCallCount = 0; + int specialistCallCount = 0; + + var coordinator = new ChatClientAgent(new MockChatClient((messages, options) => + { + coordinatorCallCount++; + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); + }), name: "coordinator"); + + var specialist = new ChatClientAgent(new MockChatClient((messages, options) => + { + specialistCallCount++; + return new(new ChatMessage(ChatRole.Assistant, "specialist responded")); + }), name: "specialist", description: "The specialist agent"); + + var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator) + .WithHandoff(coordinator, specialist) + .EnableReturnToPrevious() + .Build(); + + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep; + + // Turn 1: coordinator hands off to specialist + WorkflowRunResult result = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "book an appointment")], Environment, checkpointManager); + Assert.Equal(1, coordinatorCallCount); + Assert.Equal(1, specialistCallCount); + + // Turn 2: with ReturnToPrevious, specialist should be invoked directly, coordinator should NOT be called again + _ = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "my id is 12345")], Environment, checkpointManager, result.LastCheckpoint); + Assert.Equal(1, coordinatorCallCount); // coordinator NOT called again + Assert.Equal(2, specialistCallCount); // specialist called again + } + + [Fact] + public async Task Handoffs_ReturnToPrevious_Enabled_BeforeAnyHandoff_RoutesViaInitialAgentAsync() + { + int coordinatorCallCount = 0; + + var coordinator = new ChatClientAgent(new MockChatClient((messages, options) => + { + coordinatorCallCount++; + return new(new ChatMessage(ChatRole.Assistant, "coordinator responded")); + }), name: "coordinator"); + + var specialist = new ChatClientAgent(new MockChatClient((messages, options) => + { + Assert.Fail("Specialist should not be invoked."); + return new(); + }), name: "specialist", description: "The specialist agent"); + + var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator) + .WithHandoff(coordinator, specialist) + .EnableReturnToPrevious() + .Build(); + + // First turn with no prior handoff: should route to initial (coordinator) agent + _ = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "hello")]); + Assert.Equal(1, coordinatorCallCount); + } + + [Fact] + public async Task Handoffs_ReturnToPrevious_Enabled_AfterHandoffBackToCoordinator_NextTurnRoutesViaCoordinatorAsync() + { + int coordinatorCallCount = 0; + int specialistCallCount = 0; + + var coordinator = new ChatClientAgent(new MockChatClient((messages, options) => + { + coordinatorCallCount++; + if (coordinatorCallCount == 1) + { + // First call: hand off to specialist + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call1", transferFuncName)])); + } + // Subsequent calls: respond without handoff + return new(new ChatMessage(ChatRole.Assistant, "coordinator responded")); + }), name: "coordinator"); + + var specialist = new ChatClientAgent(new MockChatClient((messages, options) => + { + specialistCallCount++; + // Specialist hands back to coordinator + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("call2", transferFuncName)])); + }), name: "specialist", description: "The specialist agent"); + + var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator) + .WithHandoff(coordinator, specialist) + .WithHandoff(specialist, coordinator) + .EnableReturnToPrevious() + .Build(); + + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep; + + // Turn 1: coordinator → specialist → coordinator (specialist hands back) + WorkflowRunResult result = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "book an appointment")], Environment, checkpointManager); + Assert.Equal(2, coordinatorCallCount); // called twice: initial handoff + receiving handback + Assert.Equal(1, specialistCallCount); // specialist called once, then handed back + + // Turn 2: after handoff back to coordinator, should route to coordinator (not specialist) + _ = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "never mind")], Environment, checkpointManager, result.LastCheckpoint); + Assert.Equal(3, coordinatorCallCount); // coordinator called again on turn 2 + Assert.Equal(1, specialistCallCount); // specialist NOT called + } + + #region Helper Types and Methods + + private sealed record WorkflowRunResult(string UpdateText, List? Result, CheckpointInfo? LastCheckpoint, List PendingRequests); + + private static Task RunWorkflowCheckpointedAsync( + Workflow workflow, List input, ExecutionEnvironment executionEnvironment, CheckpointManager checkpointManager, CheckpointInfo? fromCheckpoint = null) + { + InProcessExecutionEnvironment environment = executionEnvironment.ToWorkflowExecutionEnvironment() + .WithCheckpointing(checkpointManager); + + return RunWorkflowCheckpointedAsync(workflow, input, environment, fromCheckpoint); + } + + private static Task RunWorkflowCheckpointedAsync( + Workflow workflow, ExternalResponse response, ExecutionEnvironment executionEnvironment, CheckpointManager checkpointManager, CheckpointInfo? fromCheckpoint = null) + { + InProcessExecutionEnvironment environment = executionEnvironment.ToWorkflowExecutionEnvironment() + .WithCheckpointing(checkpointManager); + + return RunWorkflowCheckpointedAsync(workflow, response, environment, fromCheckpoint); + } + + private static async Task RunWorkflowCheckpointedAsync( + Workflow workflow, List input, InProcessExecutionEnvironment environment, CheckpointInfo? fromCheckpoint = null) + { + await using StreamingRun run = + fromCheckpoint != null ? await environment.ResumeStreamingAsync(workflow, fromCheckpoint) + : await environment.OpenStreamingAsync(workflow); + + await run.TrySendMessageAsync(input); + await run.TrySendMessageAsync(new TurnToken(emitEvents: true)); + + return await ProcessWorkflowRunAsync(run); + } + + private static async Task RunWorkflowCheckpointedAsync( + Workflow workflow, ExternalResponse response, InProcessExecutionEnvironment environment, CheckpointInfo? fromCheckpoint = null) + { + await using StreamingRun run = + fromCheckpoint != null ? await environment.ResumeStreamingAsync(workflow, fromCheckpoint) + : await environment.OpenStreamingAsync(workflow); + + await run.SendResponseAsync(response); + + return await ProcessWorkflowRunAsync(run); + } + + private static async Task ProcessWorkflowRunAsync(StreamingRun run) + { + StringBuilder sb = new(); + WorkflowOutputEvent? output = null; + CheckpointInfo? lastCheckpoint = null; + + List pendingRequests = []; + + await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false).ConfigureAwait(false)) + { + switch (evt) + { + case AgentResponseUpdateEvent responseUpdate: + sb.Append(responseUpdate.Data); + break; + + case RequestInfoEvent requestInfo: + pendingRequests.Add(requestInfo); + break; + + case WorkflowOutputEvent e: + output = e; + break; + + case WorkflowErrorEvent errorEvent: + Assert.Fail($"Workflow execution failed with error: {errorEvent.Exception}"); + break; + + case SuperStepCompletedEvent stepCompleted: + lastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint; + break; + } + } + + return new(sb.ToString(), output?.As>(), lastCheckpoint, pendingRequests); + } + + private static Task RunWorkflowAsync( + Workflow workflow, List input, ExecutionEnvironment executionEnvironment = ExecutionEnvironment.InProcess_Lockstep) + => RunWorkflowCheckpointedAsync(workflow, input, executionEnvironment.ToWorkflowExecutionEnvironment()); + + private sealed class DoubleEchoAgent(string name) : AIAgent + { + public override string Name => name; + + protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default) + => new(new DoubleEchoAgentSession()); + + protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => new(new DoubleEchoAgentSession()); + + protected override ValueTask SerializeSessionCoreAsync(AgentSession session, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default) + => default; + + protected override Task RunCoreAsync( + IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, CancellationToken cancellationToken = default) => + throw new NotImplementedException(); + + protected override async IAsyncEnumerable RunCoreStreamingAsync( + IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + await Task.Yield(); + + var contents = messages.SelectMany(m => m.Contents).ToList(); + string id = Guid.NewGuid().ToString("N"); + yield return new AgentResponseUpdate(ChatRole.Assistant, this.Name) { AuthorName = this.Name, MessageId = id }; + yield return new AgentResponseUpdate(ChatRole.Assistant, contents) { AuthorName = this.Name, MessageId = id }; + yield return new AgentResponseUpdate(ChatRole.Assistant, contents) { AuthorName = this.Name, MessageId = id }; + } + } + + private sealed class DoubleEchoAgentSession() : AgentSession(); + + private sealed class MockChatClient(Func, ChatOptions?, ChatResponse> responseFactory) : IChatClient + { + public Task GetResponseAsync(IEnumerable messages, ChatOptions? options = null, CancellationToken cancellationToken = default) => + Task.FromResult(responseFactory(messages, options)); + + public async IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, ChatOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default) + { + foreach (var update in (await this.GetResponseAsync(messages, options, cancellationToken).ConfigureAwait(false)).ToChatResponseUpdates()) + { + yield return update; + } + } + + public object? GetService(Type serviceType, object? serviceKey = null) => null; + public void Dispose() { } + } + + #endregion +} From f8c6320cb9001f3da43651a3f1f5848b8cc6ea1c Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Fri, 8 May 2026 13:13:35 -0400 Subject: [PATCH 2/3] fix: Synthesized Handoff FunctionResult is never sent to agent When we receive a handoff request from the agent, we need to service it outside of the Agent Loop to terminate the loop. What this means is that we take ownership of terminating the call by feeding the result back into the agent on a subsequent invocation. When we refactored Handoff to support HITL and make use of AgentSession, we inadvertantly removed this step, causing subsequent invocations to the Handoff agent to fail (first works, but breaks the state). The fix is to be more precise about the agent's bookmark when concatenating the result of agent invocation to the shared conversation history. --- .../Specialized/HandoffAgentExecutor.cs | 71 +++++++++++-------- 1 file changed, 43 insertions(+), 28 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs index 576c749a90..6ee4c9c098 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs @@ -266,7 +266,33 @@ await this._sharedStateRef.InvokeWithStateAsync( sharedState.Conversation.AddMessages(incomingMessages); } - newConversationBookmark = sharedState.Conversation.AddMessages(result.Response.Messages); + if (result.IsHandoffRequested) + { + int preHandoffMessageCount = result.Response.Messages.Count - 1; + newConversationBookmark = sharedState.Conversation.AddMessages(result.Response.Messages.Take(preHandoffMessageCount)); + + // The following message contains the Handoff FunctionCallResult which should be added to the conversation history with + // the caveat that we need to get it back next time _this_ agent is invoked because we need to feed the FunctionCallResult + // back to the agent. So ignore the bookmark update. + ChatMessage handoffCallResultMessage = result.Response.Messages[preHandoffMessageCount]; + + if (handoffCallResultMessage.Role != ChatRole.Tool) + { + throw new InvalidOperationException("The last message in a handoff response must be a Tool message containing the Handoff FunctionCallResult."); + } + + if (handoffCallResultMessage.Contents.Count != 1 || + handoffCallResultMessage.Contents[0] is not FunctionResultContent) + { + throw new InvalidOperationException("The Tool message in a handoff response must contain exactly one content item of type FunctionResultContent."); + } + + _ = sharedState.Conversation.AddMessage(handoffCallResultMessage); + } + else + { + newConversationBookmark = sharedState.Conversation.AddMessages(result.Response.Messages); + } return new ValueTask(); }, @@ -376,39 +402,28 @@ private async ValueTask InvokeAgentAsync(IEnumerable updates = []; List candidateRequests = []; - await this.InvokeWithStateAsync( - async (state, ctx, ct) => - { - this._session ??= await this._agent.CreateSessionAsync(ct).ConfigureAwait(false); - - IAsyncEnumerable agentStream = - this._agent.RunStreamingAsync(messages, - this._session, - options: this._agentOptions, - cancellationToken: ct); + this._session ??= await this._agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false); - await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false)) - { - await AddUpdateAsync(update, ct).ConfigureAwait(false); + IAsyncEnumerable agentStream = + this._agent.RunStreamingAsync(messages, this._session, this._agentOptions, cancellationToken); - collector.ProcessAgentResponseUpdate(update, CollectHandoffRequestsFilter); + await foreach (AgentResponseUpdate update in agentStream.ConfigureAwait(false)) + { + await AddUpdateAsync(update, cancellationToken).ConfigureAwait(false); - bool CollectHandoffRequestsFilter(FunctionCallContent candidateHandoffRequest) - { - bool isHandoffRequest = this._handoffFunctionNames.Contains(candidateHandoffRequest.Name); - if (isHandoffRequest) - { - candidateRequests.Add(candidateHandoffRequest); - } + collector.ProcessAgentResponseUpdate(update, CollectHandoffRequestsFilter); - return !isHandoffRequest; - } + bool CollectHandoffRequestsFilter(FunctionCallContent candidateHandoffRequest) + { + bool isHandoffRequest = this._handoffFunctionNames.Contains(candidateHandoffRequest.Name); + if (isHandoffRequest) + { + candidateRequests.Add(candidateHandoffRequest); } - return state; - }, - context, - cancellationToken: cancellationToken).ConfigureAwait(false); + return !isHandoffRequest; + } + } if (candidateRequests.Count > 1) { From aeb2e124c7730dd635d28cea63794fc9f63b0f5a Mon Sep 17 00:00:00 2001 From: Jacob Alber Date: Fri, 8 May 2026 14:33:50 -0400 Subject: [PATCH 3/3] test: Add unit tests for Handoff FunctionCall/Result matching fix --- .../StreamingToolCallResultPairMatcher.cs | 11 +- .../HandoffOrchestrationTests.cs | 251 ++++++++++++++++++ 2 files changed, 260 insertions(+), 2 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/Magentic/StreamingToolCallResultPairMatcher.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/Magentic/StreamingToolCallResultPairMatcher.cs index bb72b8390a..80fa89b0a2 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/Magentic/StreamingToolCallResultPairMatcher.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/Magentic/StreamingToolCallResultPairMatcher.cs @@ -3,13 +3,14 @@ using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; +using System.Linq; using Microsoft.Extensions.AI; namespace Microsoft.Agents.AI.Workflows.Specialized.Magentic; internal sealed class StreamingToolCallResultPairMatcher { - private enum CallType + internal enum CallType { Function, McpServerTool @@ -17,7 +18,7 @@ private enum CallType private record CallSummaryKey(CallType Type, string CallId); - private struct ToolCallSummary(CallType callType, string callId, string name) + internal struct ToolCallSummary(CallType callType, string callId, string name) { public CallType CallType => callType; @@ -28,6 +29,12 @@ private struct ToolCallSummary(CallType callType, string callId, string name) private readonly Dictionary _callSummaries = new(); + public bool HasUnmatchedCalls => this._callSummaries.Count > 0; + + public IEnumerable UnmatchedCalls => this.HasUnmatchedCalls + ? this._callSummaries.Values.ToList() + : []; + private void Collect(CallType callType, string callId, string name, string callContentTypeName, string resultContentTypeName) { CallSummaryKey key = new(callType, callId); diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffOrchestrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffOrchestrationTests.cs index 89cb52a89a..73c34cae53 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffOrchestrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffOrchestrationTests.cs @@ -12,6 +12,7 @@ using FluentAssertions; using Microsoft.Agents.AI.Workflows.InProc; using Microsoft.Agents.AI.Workflows.Specialized; +using Microsoft.Agents.AI.Workflows.Specialized.Magentic; using Microsoft.Extensions.AI; using Microsoft.Extensions.Logging; @@ -859,6 +860,256 @@ public async Task Handoffs_ReturnToPrevious_Enabled_AfterHandoffBackToCoordinato Assert.Equal(1, specialistCallCount); // specialist NOT called } + private static MockChatClient CreateFunctionCallResultValidatingClient(Func, ChatOptions?, ChatResponse> innerResponseFactory) + { + return new MockChatClient(InvokeResponseFactory); + + ChatResponse InvokeResponseFactory(IEnumerable chatMessages, ChatOptions? options) + { + // We do not need to keep the callResolver around because ChatClientAgent owns making sure that the function call is properly + // resent to the underlying agent. + StreamingToolCallResultPairMatcher callResolver = new(); + List incomingMessages = chatMessages.ToList(); + foreach (ChatMessage message in incomingMessages) + { + foreach (AIContent content in message.Contents) + { + switch (content) + { + case FunctionCallContent functionCallContent: + { + callResolver.CollectFunctionCall(functionCallContent); + break; + } + + case FunctionResultContent functionResultContent: + { + if (!callResolver.TryResolveFunctionCall(functionResultContent, out _)) + { + throw new InvalidOperationException($"Received unexpected function result: {functionResultContent.CallId}"); + } + break; + } + + case McpServerToolCallContent mcpServerToolCallContent: + { + callResolver.CollectMcpServerToolCall(mcpServerToolCallContent); + break; + } + + case McpServerToolResultContent mcpServerToolResultContent: + { + if (!callResolver.TryResolveMcpServerToolCall(mcpServerToolResultContent, out _)) + { + throw new InvalidOperationException($"Received unexpected tool result: {mcpServerToolResultContent.CallId}"); + } + break; + } + } + } + } + + // If there are still unmatched calls, we have an error + callResolver.UnmatchedCalls.Should().BeEmpty(); + + // Now we can invoke the inner response factory to generate the response + ChatResponse response = innerResponseFactory(incomingMessages, options); + + foreach (ChatMessage message in response.Messages) + { + foreach (AIContent content in message.Contents) + { + switch (content) + { + case FunctionCallContent functionCallContent: + callResolver.CollectFunctionCall(functionCallContent); + break; + case McpServerToolCallContent mcpServerToolCallContent: + callResolver.CollectMcpServerToolCall(mcpServerToolCallContent); + break; + case FunctionResultContent functionResultContent: + { + if (!callResolver.TryResolveFunctionCall(functionResultContent, out string? name)) + { + throw new InvalidOperationException($"Produced unexpected function result: {functionResultContent.CallId}"); + } + break; + } + case McpServerToolResultContent mcpServerToolResultContent: + { + if (!callResolver.TryResolveMcpServerToolCall(mcpServerToolResultContent, out string? name)) + { + throw new InvalidOperationException($"Produced unexpected tool result: {mcpServerToolResultContent.CallId}"); + } + break; + } + } + } + } + + return response; + } + } + + [Fact] + public async Task Handoffs_ReentrantHandoff_FunctionResultSentToAgentOnSubsequentInvocationAsync() + { + // Regression test: When an agent requests a handoff, the synthesized FunctionResult for the handoff + // must be sent back to the agent on subsequent invocations. If this doesn't happen, the agent's + // conversation state will be broken because the LLM will receive a FunctionCall without a + // corresponding FunctionResult. + + List>? specialistInvocations = []; + int coordinatorCallCount = 0; + int specialistCallCount = 0; + + var coordinator = new ChatClientAgent(CreateFunctionCallResultValidatingClient((messages, options) => + { + coordinatorCallCount++; + // Always hand off to specialist + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent($"coordinator_handoff_call_{coordinatorCallCount}", transferFuncName)])); + }), name: "coordinator"); + + var specialist = new ChatClientAgent(CreateFunctionCallResultValidatingClient((messages, options) => + { + specialistCallCount++; + specialistInvocations.Add(messages.ToList()); + + if (specialistCallCount == 1) + { + // First call: hand back to coordinator + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent("specialist_handoff_call", transferFuncName)])); + } + + // Subsequent calls: respond normally + return new(new ChatMessage(ChatRole.Assistant, "specialist final response")); + }), name: "specialist", description: "The specialist agent"); + + var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator) + .WithHandoff(coordinator, specialist) + .WithHandoff(specialist, coordinator) + .EnableReturnToPrevious() + .Build(); + + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep; + + // Turn 1: coordinator -> specialist -> coordinator -> specialist (specialist responds on 2nd call) + // Flow: coordinator(1) hands off -> specialist(1) hands off -> coordinator(2) hands off -> specialist(2) responds + WorkflowRunResult result = await RunWorkflowCheckpointedAsync(workflow, [new ChatMessage(ChatRole.User, "start")], Environment, checkpointManager); + Assert.Equal(2, coordinatorCallCount); // initial + receiving handback from specialist + Assert.Equal(2, specialistCallCount); // specialist invoked twice (once handed off, once responded) + + Assert.Equal(2, specialistInvocations.Count); + } + + [Fact] + public async Task Handoffs_MultiTurnWithHandoffAndReturn_AllFunctionCallsHaveMatchingResultsAsync() + { + // This test verifies that across multiple turns with handoffs going back and forth, + // the FunctionCall/FunctionResult pairing rule is always maintained for any agent + // that is re-invoked after previously requesting a handoff. + + List> coordinatorInvocations = []; + List> specialistInvocations = []; + + var coordinator = new ChatClientAgent(CreateFunctionCallResultValidatingClient((messages, options) => + { + coordinatorInvocations.Add(messages.ToList()); + int callCount = coordinatorInvocations.Count; + + // Coordinator always hands off to specialist + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent($"coord_call_{callCount}", transferFuncName)])); + }), name: "coordinator"); + + var specialist = new ChatClientAgent(CreateFunctionCallResultValidatingClient((messages, options) => + { + specialistInvocations.Add(messages.ToList()); + int callCount = specialistInvocations.Count; + + if (callCount % 2 == 1) + { + // Odd invocations: hand back to coordinator + string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name; + Assert.NotNull(transferFuncName); + return new(new ChatMessage(ChatRole.Assistant, [new FunctionCallContent($"spec_call_{callCount}", transferFuncName)])); + } + + // Even invocations: respond normally + return new(new ChatMessage(ChatRole.Assistant, $"specialist response {callCount}")); + }), name: "specialist", description: "The specialist agent"); + + var workflow = AgentWorkflowBuilder.CreateHandoffBuilderWith(coordinator) + .WithHandoff(coordinator, specialist) + .WithHandoff(specialist, coordinator) + .EnableReturnToPrevious() + .Build(); + + CheckpointManager checkpointManager = CheckpointManager.CreateInMemory(); + const ExecutionEnvironment Environment = ExecutionEnvironment.InProcess_Lockstep; + + // Turn 1: coordinator -> specialist -> coordinator -> specialist (ends with response) + WorkflowRunResult result = await RunWorkflowCheckpointedAsync( + workflow, + [new ChatMessage(ChatRole.User, "turn 1")], + Environment, + checkpointManager); + + // Verify FunctionCall/FunctionResult pairing for all invocations + VerifyFunctionCallResultPairing(coordinatorInvocations, "coordinator"); + VerifyFunctionCallResultPairing(specialistInvocations, "specialist"); + + // Turn 2: conversation continues + _ = await RunWorkflowCheckpointedAsync( + workflow, + [new ChatMessage(ChatRole.User, "turn 2")], + Environment, + checkpointManager, + result.LastCheckpoint); + + // Verify pairing again after second turn + VerifyFunctionCallResultPairing(coordinatorInvocations, "coordinator"); + VerifyFunctionCallResultPairing(specialistInvocations, "specialist"); + } + + /// + /// Verifies that for each invocation of an agent, all FunctionCallContent items + /// that appear in the message history have corresponding FunctionResultContent items. + /// + private static void VerifyFunctionCallResultPairing(List> invocations, string agentName) + { + for (int i = 0; i < invocations.Count; i++) + { + List messages = invocations[i]; + + // Get all FunctionCallContent and FunctionResultContent items from the messages + var functionCalls = messages + .SelectMany(m => m.Contents.OfType()) + .ToList(); + + var functionResults = messages + .SelectMany(m => m.Contents.OfType()) + .ToList(); + + // Create lookup of call IDs that have results + var resultCallIds = new HashSet(functionResults.Select(r => r.CallId)); + + // Verify each function call has a matching result + foreach (var call in functionCalls) + { + Assert.True(resultCallIds.Contains(call.CallId), + $"Agent '{agentName}' invocation {i + 1}: FunctionCallContent with CallId '{call.CallId}' (Name: '{call.Name}') " + + "has no matching FunctionResultContent. This violates the LLM's requirement that all FunctionCalls have results."); + } + } + } + #region Helper Types and Methods private sealed record WorkflowRunResult(string UpdateText, List? Result, CheckpointInfo? LastCheckpoint, List PendingRequests);