diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs
index ccb993b188..8e5cee7ed5 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs
@@ -1,5 +1,6 @@
// Copyright (c) Microsoft. All rights reserved.
+using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.Agents.AI.Workflows.Specialized;
@@ -8,10 +9,21 @@ using Microsoft.Shared.Diagnostics;
namespace Microsoft.Agents.AI.Workflows;
+///
+[Obsolete("Prefer HandoffWorkflowBuilder (no 's') instead, which has the same API but the preferred name. This will be removed in a future release before GA.")]
+public sealed class HandoffsWorkflowBuilder(AIAgent initialAgent) : HandoffWorkflowBuilderCore(initialAgent)
+{
+}
+
+///
+public sealed class HandoffWorkflowBuilder(AIAgent initialAgent) : HandoffWorkflowBuilderCore(initialAgent)
+{
+}
+
///
/// Provides a builder for specifying the handoff relationships between agents and building the resulting workflow.
///
-public sealed class HandoffsWorkflowBuilder
+public class HandoffWorkflowBuilderCore where TBuilder : HandoffWorkflowBuilderCore
{
///
/// The prefix for function calls that trigger handoffs to other agents; the full name is then `{FunctionPrefix}<agent_id>`,
@@ -26,12 +38,13 @@ public sealed class HandoffsWorkflowBuilder
private bool _emitAgentResponseEvents;
private bool _emitAgentResponseUpdateEvents;
private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly;
+ private bool _returnToPrevious;
///
/// Initializes a new instance of the class with no handoff relationships.
///
/// The first agent to be invoked (prior to any handoff).
- internal HandoffsWorkflowBuilder(AIAgent initialAgent)
+ internal HandoffWorkflowBuilderCore(AIAgent initialAgent)
{
this._initialAgent = initialAgent;
this._allAgents.Add(initialAgent);
@@ -63,10 +76,10 @@ public sealed class HandoffsWorkflowBuilder
/// constant.
///
/// The instructions to provide, or to restore the default instructions.
- public HandoffsWorkflowBuilder WithHandoffInstructions(string? instructions)
+ public TBuilder WithHandoffInstructions(string? instructions)
{
this.HandoffInstructions = instructions ?? DefaultHandoffInstructions;
- return this;
+ return (TBuilder)this;
}
///
@@ -75,10 +88,10 @@ public sealed class HandoffsWorkflowBuilder
///
///
///
- public HandoffsWorkflowBuilder EmitAgentResponseUpdateEvents(bool emitAgentResponseUpdateEvents = true)
+ public TBuilder EmitAgentResponseUpdateEvents(bool emitAgentResponseUpdateEvents = true)
{
this._emitAgentResponseUpdateEvents = emitAgentResponseUpdateEvents;
- return this;
+ return (TBuilder)this;
}
///
@@ -86,10 +99,10 @@ public sealed class HandoffsWorkflowBuilder
///
///
///
- public HandoffsWorkflowBuilder EmitAgentResponseEvents(bool emitAgentResponseEvents = true)
+ public TBuilder EmitAgentResponseEvents(bool emitAgentResponseEvents = true)
{
this._emitAgentResponseEvents = emitAgentResponseEvents;
- return this;
+ return (TBuilder)this;
}
///
@@ -97,10 +110,21 @@ public sealed class HandoffsWorkflowBuilder
/// s flowing through the handoff workflow. Defaults to .
///
/// The filtering behavior to apply.
- public HandoffsWorkflowBuilder WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior behavior)
+ public TBuilder WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior behavior)
{
this._toolCallFilteringBehavior = behavior;
- return this;
+ return (TBuilder)this;
+ }
+
+ ///
+ /// Configures the workflow so that subsequent user turns route directly back to the specialist agent
+ /// that handled the previous turn, rather than always routing through the initial (coordinator) agent.
+ ///
+ /// The updated instance.
+ public TBuilder EnableReturnToPrevious()
+ {
+ this._returnToPrevious = true;
+ return (TBuilder)this;
}
///
@@ -110,7 +134,7 @@ public sealed class HandoffsWorkflowBuilder
/// The target agents to add as handoff targets for the source agent.
/// The updated instance.
/// The handoff reason for each target in is derived from that agent's description or name.
- public HandoffsWorkflowBuilder WithHandoffs(AIAgent from, IEnumerable to)
+ public TBuilder WithHandoffs(AIAgent from, IEnumerable to)
{
Throw.IfNull(from);
Throw.IfNull(to);
@@ -125,7 +149,7 @@ public sealed class HandoffsWorkflowBuilder
this.WithHandoff(from, target);
}
- return this;
+ return (TBuilder)this;
}
///
@@ -138,7 +162,7 @@ public sealed class HandoffsWorkflowBuilder
/// If , the reason is derived from 's description or name.
///
/// The updated instance.
- public HandoffsWorkflowBuilder WithHandoffs(IEnumerable from, AIAgent to, string? handoffReason = null)
+ public TBuilder WithHandoffs(IEnumerable from, AIAgent to, string? handoffReason = null)
{
Throw.IfNull(from);
Throw.IfNull(to);
@@ -153,7 +177,7 @@ public sealed class HandoffsWorkflowBuilder
this.WithHandoff(source, to, handoffReason);
}
- return this;
+ return (TBuilder)this;
}
///
@@ -166,7 +190,7 @@ public sealed class HandoffsWorkflowBuilder
/// If , the reason is derived from 's description or name.
///
/// The updated instance.
- public HandoffsWorkflowBuilder WithHandoff(AIAgent from, AIAgent to, string? handoffReason = null)
+ public TBuilder WithHandoff(AIAgent from, AIAgent to, string? handoffReason = null)
{
Throw.IfNull(from);
Throw.IfNull(to);
@@ -196,7 +220,7 @@ public sealed class HandoffsWorkflowBuilder
Throw.InvalidOperationException($"A handoff from agent '{from.Name ?? from.Id}' to agent '{to.Name ?? to.Id}' has already been registered.");
}
- return this;
+ return (TBuilder)this;
}
///
@@ -206,8 +230,8 @@ public sealed class HandoffsWorkflowBuilder
/// The workflow built based on the handoffs in the builder.
public Workflow Build()
{
- HandoffsStartExecutor start = new();
- HandoffsEndExecutor end = new();
+ HandoffsStartExecutor start = new(this._returnToPrevious);
+ HandoffsEndExecutor end = new(this._returnToPrevious);
WorkflowBuilder builder = new(start);
HandoffAgentExecutorOptions options = new(this.HandoffInstructions,
@@ -215,11 +239,31 @@ public sealed class HandoffsWorkflowBuilder
this._emitAgentResponseUpdateEvents,
this._toolCallFilteringBehavior);
- // Create an AgentExecutor for each again.
+ // Create an AgentExecutor for each agent.
Dictionary executors = this._allAgents.ToDictionary(a => a.Id, a => new HandoffAgentExecutor(a, options));
- // Connect the start executor to the initial agent.
- builder.AddEdge(start, executors[this._initialAgent.Id]);
+ // Connect the start executor to the initial agent (or use dynamic routing when ReturnToPrevious is enabled).
+ if (this._returnToPrevious)
+ {
+ string initialAgentId = this._initialAgent.Id;
+ builder.AddSwitch(start, sb =>
+ {
+ foreach (var agent in this._allAgents)
+ {
+ if (agent.Id != initialAgentId)
+ {
+ string agentId = agent.Id;
+ sb.AddCase(state => state?.CurrentAgentId == agentId, executors[agentId]);
+ }
+ }
+
+ sb.WithDefault(executors[initialAgentId]);
+ });
+ }
+ else
+ {
+ builder.AddEdge(start, executors[this._initialAgent.Id]);
+ }
// Initialize each executor with its handoff targets to the other executors.
foreach (var agent in this._allAgents)
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs
index 21519e07ee..98205a40c6 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs
@@ -42,7 +42,7 @@ internal sealed class HandoffMessagesFilter
internal static bool IsHandoffFunctionName(string name)
{
- return name.StartsWith(HandoffsWorkflowBuilder.FunctionPrefix, StringComparison.Ordinal);
+ return name.StartsWith(HandoffWorkflowBuilder.FunctionPrefix, StringComparison.Ordinal);
}
public IEnumerable FilterMessages(List messages)
@@ -173,6 +173,7 @@ internal sealed class HandoffAgentExecutor(
private readonly AIAgent _agent = agent;
private readonly HashSet _handoffFunctionNames = [];
+ private readonly Dictionary _handoffFunctionToAgentId = [];
private ChatClientAgentRunOptions? _agentOptions;
public void Initialize(
@@ -199,9 +200,10 @@ internal sealed class HandoffAgentExecutor(
foreach (HandoffTarget handoff in handoffs)
{
index++;
- var handoffFunc = AIFunctionFactory.CreateDeclaration($"{HandoffsWorkflowBuilder.FunctionPrefix}{index}", handoff.Reason, s_handoffSchema);
+ var handoffFunc = AIFunctionFactory.CreateDeclaration($"{HandoffWorkflowBuilder.FunctionPrefix}{index}", handoff.Reason, s_handoffSchema);
this._handoffFunctionNames.Add(handoffFunc.Name);
+ this._handoffFunctionToAgentId[handoffFunc.Name] = handoff.Target.Id;
this._agentOptions.ChatOptions.Tools.Add(handoffFunc);
@@ -267,7 +269,11 @@ internal sealed class HandoffAgentExecutor(
roleChanges.ResetUserToAssistantForChangedRoles();
- return new(message.TurnToken, requestedHandoff, allMessages);
+ string currentAgentId = requestedHandoff is not null && this._handoffFunctionToAgentId.TryGetValue(requestedHandoff, out string? targetAgentId)
+ ? targetAgentId
+ : this._agent.Id;
+
+ return new(message.TurnToken, requestedHandoff, allMessages, currentAgentId);
async Task AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancellationToken)
{
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs
index cc4d87d21a..56e2fef9df 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffState.cs
@@ -8,4 +8,5 @@ namespace Microsoft.Agents.AI.Workflows.Specialized;
internal sealed record class HandoffState(
TurnToken TurnToken,
string? InvokedHandoff,
- List Messages);
+ List Messages,
+ string? CurrentAgentId = null);
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsCurrentAgentTracker.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsCurrentAgentTracker.cs
new file mode 100644
index 0000000000..51e3fd9475
--- /dev/null
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsCurrentAgentTracker.cs
@@ -0,0 +1,9 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+namespace Microsoft.Agents.AI.Workflows.Specialized;
+
+/// Tracks the current agent ID across turns when return-to-previous routing is enabled.
+internal sealed class HandoffsCurrentAgentTracker
+{
+ public string? CurrentAgentId { get; set; }
+}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs
index 69f81376be..c892112453 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsEndExecutor.cs
@@ -1,20 +1,31 @@
// Copyright (c) Microsoft. All rights reserved.
using System.Collections.Generic;
+using System.Threading;
using System.Threading.Tasks;
using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
/// Executor used at the end of a handoff workflow to raise a final completed event.
-internal sealed class HandoffsEndExecutor() : Executor(ExecutorId, declareCrossRunShareable: true), IResettableExecutor
+internal sealed class HandoffsEndExecutor(HandoffsCurrentAgentTracker? tracker = null) : Executor(ExecutorId, declareCrossRunShareable: true), IResettableExecutor
{
public const string ExecutorId = "HandoffEnd";
protected override ProtocolBuilder ConfigureProtocol(ProtocolBuilder protocolBuilder) =>
protocolBuilder.ConfigureRoutes(routeBuilder => routeBuilder.AddHandler((handoff, context, cancellationToken) =>
- context.YieldOutputAsync(handoff.Messages, cancellationToken)))
+ this.HandleAsync(handoff, context, cancellationToken)))
.YieldsOutput>();
+ private async ValueTask HandleAsync(HandoffState handoff, IWorkflowContext context, CancellationToken cancellationToken)
+ {
+ if (tracker is not null && handoff.CurrentAgentId is not null)
+ {
+ tracker.CurrentAgentId = handoff.CurrentAgentId;
+ }
+
+ await context.YieldOutputAsync(handoff.Messages, cancellationToken).ConfigureAwait(false);
+ }
+
public ValueTask ResetAsync() => default;
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs
index 9039e86f5b..119a0a778d 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffsStartExecutor.cs
@@ -8,7 +8,7 @@ using Microsoft.Extensions.AI;
namespace Microsoft.Agents.AI.Workflows.Specialized;
/// Executor used at the start of a handoffs workflow to accumulate messages and emit them as HandoffState upon receiving a turn token.
-internal sealed class HandoffsStartExecutor() : ChatProtocolExecutor(ExecutorId, DefaultOptions, declareCrossRunShareable: true), IResettableExecutor
+internal sealed class HandoffsStartExecutor(HandoffsCurrentAgentTracker? tracker = null) : ChatProtocolExecutor(ExecutorId, DefaultOptions, declareCrossRunShareable: true), IResettableExecutor
{
internal const string ExecutorId = "HandoffStart";
@@ -22,7 +22,7 @@ internal sealed class HandoffsStartExecutor() : ChatProtocolExecutor(ExecutorId,
base.ConfigureProtocol(protocolBuilder).SendsMessage();
protected override ValueTask TakeTurnAsync(List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
- => context.SendMessageAsync(new HandoffState(new(emitEvents), null, messages), cancellationToken: cancellationToken);
+ => context.SendMessageAsync(new HandoffState(new(emitEvents), null, messages, tracker?.CurrentAgentId), cancellationToken: cancellationToken);
public new ValueTask ResetAsync() => base.ResetAsync();
}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs
index 77d8d0a88d..78c9a29baf 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AgentWorkflowBuilderTests.cs
@@ -680,6 +680,196 @@ public class AgentWorkflowBuilderTests
}
}
+ [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();
+
+ var environment = InProcessExecution.Lockstep;
+ string sessionId = Guid.NewGuid().ToString("N");
+
+ // Turn 1: coordinator hands off to specialist
+ (_, List? turn1Result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "book an appointment")], environment, sessionId);
+ Assert.Equal(1, coordinatorCallCount);
+
+ // Turn 2: without ReturnToPrevious, coordinator should be invoked again
+ Assert.NotNull(turn1Result);
+ turn1Result.Add(new ChatMessage(ChatRole.User, "my id is 12345"));
+ _ = await RunWorkflowAsync(workflow, turn1Result, environment, sessionId);
+ 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();
+
+ var environment = InProcessExecution.Lockstep;
+ string sessionId = Guid.NewGuid().ToString("N");
+
+ // Turn 1: coordinator hands off to specialist
+ (_, List? turn1Result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "book an appointment")], environment, sessionId);
+ Assert.Equal(1, coordinatorCallCount);
+ Assert.Equal(1, specialistCallCount);
+
+ // Turn 2: with ReturnToPrevious, specialist should be invoked directly, coordinator should NOT be called again
+ Assert.NotNull(turn1Result);
+ turn1Result.Add(new ChatMessage(ChatRole.User, "my id is 12345"));
+ _ = await RunWorkflowAsync(workflow, turn1Result, environment, sessionId);
+ 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();
+
+ var environment = InProcessExecution.Lockstep;
+ string sessionId = Guid.NewGuid().ToString("N");
+
+ // First turn with no prior handoff: should route to initial (coordinator) agent
+ _ = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "hello")], environment, sessionId);
+ 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();
+
+ var environment = InProcessExecution.Lockstep;
+ string sessionId = Guid.NewGuid().ToString("N");
+
+ // Turn 1: coordinator → specialist → coordinator (specialist hands back)
+ (_, List? turn1Result) = await RunWorkflowAsync(workflow, [new ChatMessage(ChatRole.User, "book an appointment")], environment, sessionId);
+ 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)
+ Assert.NotNull(turn1Result);
+ turn1Result.Add(new ChatMessage(ChatRole.User, "never mind"));
+ _ = await RunWorkflowAsync(workflow, turn1Result, environment, sessionId);
+ Assert.Equal(3, coordinatorCallCount); // coordinator called again on turn 2
+ Assert.Equal(1, specialistCallCount); // specialist NOT called
+ }
+
+ private static async Task<(string UpdateText, List? Result)> RunWorkflowAsync(
+ Workflow workflow, List input, InProcessExecutionEnvironment environment, string? sessionId = null)
+ {
+ StringBuilder sb = new();
+
+ await using StreamingRun run = await environment.RunStreamingAsync(workflow, input, sessionId);
+ await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
+
+ WorkflowOutputEvent? output = null;
+ await foreach (WorkflowEvent evt in run.WatchStreamAsync().ConfigureAwait(false))
+ {
+ if (evt is AgentResponseUpdateEvent executorComplete)
+ {
+ sb.Append(executorComplete.Data);
+ }
+ else if (evt is WorkflowOutputEvent e)
+ {
+ output = e;
+ break;
+ }
+ else if (evt is WorkflowErrorEvent errorEvent)
+ {
+ Assert.Fail($"Workflow execution failed with error: {errorEvent.Exception}");
+ }
+ }
+
+ return (sb.ToString(), output?.As>());
+ }
+
private static async Task<(string UpdateText, List? Result)> RunWorkflowAsync(
Workflow workflow, List input, ExecutionEnvironment executionEnvironment = ExecutionEnvironment.InProcess_Lockstep)
{
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs
index 7bf00c4fb9..3fa17a34bb 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs
@@ -794,7 +794,7 @@ public class WorkflowHostSmokeTests : AIAgentHostingExecutorTestsBase
{
// Arrange
TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName);
- Workflow handoffWorkflow = new HandoffsWorkflowBuilder(agent).Build();
+ Workflow handoffWorkflow = new HandoffWorkflowBuilder(agent).Build();
return this.Run_AsAgent_OutgoingMessagesInHistoryAsync(handoffWorkflow, runAsync);
}
}