diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs
index bd0b3114f1..568aded9bc 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/HandoffsWorkflowBuilder.cs
@@ -13,10 +13,18 @@ namespace Microsoft.Agents.AI.Workflows;
///
public sealed class HandoffsWorkflowBuilder
{
- internal const string FunctionPrefix = "handoff_to_";
+ ///
+ /// The prefix for function calls that trigger handoffs to other agents; the full name is then `{FunctionPrefix}<agent_id>`,
+ /// where `<agent_id>` is the ID of the target agent to hand off to.
+ ///
+ public const string FunctionPrefix = "handoff_to_";
+
private readonly AIAgent _initialAgent;
private readonly Dictionary> _targets = [];
private readonly HashSet _allAgents = new(AIAgentIDEqualityComparer.Instance);
+
+ private bool _emitAgentResponseEvents;
+ private bool _emitAgentResponseUpdateEvents;
private HandoffToolCallFilteringBehavior _toolCallFilteringBehavior = HandoffToolCallFilteringBehavior.HandoffOnly;
///
@@ -47,9 +55,13 @@ in your conversation with the user.
""";
///
- /// Sets additional instructions to provide to an agent that has handoffs about how and when to
- /// perform them.
+ /// Sets instructions to provide to each agent that has handoffs about how and when perform them.
///
+ ///
+ /// In the vast majority of cases, the will be sufficient, and there will be no need to customize.
+ /// If you do provide alternate instructions, remember to explain the mechanics of the handoff function tool call, using see
+ /// constant.
+ ///
/// The instructions to provide, or to restore the default instructions.
public HandoffsWorkflowBuilder WithHandoffInstructions(string? instructions)
{
@@ -57,6 +69,29 @@ public HandoffsWorkflowBuilder WithHandoffInstructions(string? instructions)
return this;
}
+ ///
+ /// Sets a value indicating whether agent streaming update events should be emitted during execution.
+ /// If , the value will be taken from the
+ ///
+ ///
+ ///
+ public HandoffsWorkflowBuilder EmitAgentResponseUpdateEvents(bool emitAgentResponseUpdateEvents)
+ {
+ this._emitAgentResponseUpdateEvents = true;
+ return this;
+ }
+
+ ///
+ /// Sets a value indicating whether aggregated agent response events should be emitted during execution.
+ ///
+ ///
+ ///
+ public HandoffsWorkflowBuilder EmitAgentResponseEvents(bool emitAgentResponseEvents)
+ {
+ this._emitAgentResponseEvents = true;
+ return this;
+ }
+
///
/// Sets the behavior for filtering and contents from
/// s flowing through the handoff workflow. Defaults to .
@@ -175,7 +210,10 @@ public Workflow Build()
HandoffsEndExecutor end = new();
WorkflowBuilder builder = new(start);
- HandoffAgentExecutorOptions options = new(this.HandoffInstructions, this._toolCallFilteringBehavior);
+ HandoffAgentExecutorOptions options = new(this.HandoffInstructions,
+ this._emitAgentResponseEvents,
+ this._emitAgentResponseUpdateEvents,
+ this._toolCallFilteringBehavior);
// Create an AgentExecutor for each again.
Dictionary executors = this._allAgents.ToDictionary(a => a.Id, a => new HandoffAgentExecutor(a, options));
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs
index 9cc72d7310..ee5d4c818c 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/AIAgentHostExecutor.cs
@@ -12,6 +12,15 @@ namespace Microsoft.Agents.AI.Workflows.Specialized;
internal record AIAgentHostState(JsonElement? ThreadState, bool? CurrentTurnEmitEvents);
+internal static class TurnExtensions
+{
+ public static bool ShouldEmitStreamingEvents(this TurnToken token, bool? agentSetting)
+ => token.EmitEvents ?? agentSetting ?? false;
+
+ public static bool ShouldEmitStreamingEvents(bool? turnTokenSetting, bool? agentSetting)
+ => turnTokenSetting ?? agentSetting ?? false;
+}
+
internal sealed class AIAgentHostExecutor : ChatProtocolExecutor
{
private readonly AIAgent _agent;
@@ -88,9 +97,6 @@ private ValueTask HandleFunctionResultAsync(
return this.ContinueTurnAsync(implicitTurnMessages, context, this._currentTurnEmitEvents ?? false, cancellationToken);
}
- public bool ShouldEmitStreamingEvents(bool? emitEvents)
- => emitEvents ?? this._options.EmitAgentUpdateEvents ?? false;
-
private async ValueTask EnsureSessionAsync(IWorkflowContext context, CancellationToken cancellationToken) =>
this._session ??= await this._agent.CreateSessionAsync(cancellationToken).ConfigureAwait(false);
@@ -159,7 +165,10 @@ await context.SendMessageAsync(response.Messages is List list ? lis
}
protected override ValueTask TakeTurnAsync(List messages, IWorkflowContext context, bool? emitEvents, CancellationToken cancellationToken = default)
- => this.ContinueTurnAsync(messages, context, this.ShouldEmitStreamingEvents(emitEvents), cancellationToken);
+ => this.ContinueTurnAsync(messages,
+ context,
+ TurnExtensions.ShouldEmitStreamingEvents(turnTokenSetting: emitEvents, this._options.EmitAgentUpdateEvents),
+ cancellationToken);
private async ValueTask InvokeAgentAsync(IEnumerable messages, IWorkflowContext context, bool emitEvents, CancellationToken cancellationToken = default)
{
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs
index d1367b83ad..21519e07ee 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/Specialized/HandoffAgentExecutor.cs
@@ -14,14 +14,20 @@ namespace Microsoft.Agents.AI.Workflows.Specialized;
internal sealed class HandoffAgentExecutorOptions
{
- public HandoffAgentExecutorOptions(string? handoffInstructions, HandoffToolCallFilteringBehavior toolCallFilteringBehavior)
+ public HandoffAgentExecutorOptions(string? handoffInstructions, bool emitAgentResponseEvents, bool? emitAgentResponseUpdateEvents, HandoffToolCallFilteringBehavior toolCallFilteringBehavior)
{
this.HandoffInstructions = handoffInstructions;
+ this.EmitAgentResponseEvents = emitAgentResponseEvents;
+ this.EmitAgentResponseUpdateEvents = emitAgentResponseUpdateEvents;
this.ToolCallFilteringBehavior = toolCallFilteringBehavior;
}
public string? HandoffInstructions { get; set; }
+ public bool EmitAgentResponseEvents { get; set; }
+
+ public bool? EmitAgentResponseUpdateEvents { get; set; }
+
public HandoffToolCallFilteringBehavior ToolCallFilteringBehavior { get; set; } = HandoffToolCallFilteringBehavior.HandoffOnly;
}
@@ -250,7 +256,14 @@ await AddUpdateAsync(
}
}
- allMessages.AddRange(updates.ToAgentResponse().Messages);
+ AgentResponse agentResponse = updates.ToAgentResponse();
+
+ if (options.EmitAgentResponseEvents)
+ {
+ await context.YieldOutputAsync(agentResponse, cancellationToken).ConfigureAwait(false);
+ }
+
+ allMessages.AddRange(agentResponse.Messages);
roleChanges.ResetUserToAssistantForChangedRoles();
@@ -259,7 +272,7 @@ await AddUpdateAsync(
async Task AddUpdateAsync(AgentResponseUpdate update, CancellationToken cancellationToken)
{
updates.Add(update);
- if (message.TurnToken.EmitEvents is true)
+ if (message.TurnToken.ShouldEmitStreamingEvents(options.EmitAgentResponseUpdateEvents))
{
await context.YieldOutputAsync(update, cancellationToken).ConfigureAwait(false);
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowChatHistoryProvider.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowChatHistoryProvider.cs
index 2815ed99f0..69c09abb27 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowChatHistoryProvider.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowChatHistoryProvider.cs
@@ -43,7 +43,7 @@ internal void AddMessages(AgentSession session, params IEnumerable
=> this._sessionState.GetOrInitializeState(session).Messages.AddRange(messages);
protected override ValueTask> ProvideChatHistoryAsync(InvokingContext context, CancellationToken cancellationToken = default)
- => new(this._sessionState.GetOrInitializeState(context.Session).Messages);
+ => new(this._sessionState.GetOrInitializeState(context.Session).Messages.AsReadOnly());
protected override ValueTask StoreChatHistoryAsync(InvokedContext context, CancellationToken cancellationToken = default)
{
@@ -62,6 +62,12 @@ public IEnumerable GetFromBookmark(AgentSession session)
}
}
+ public IEnumerable GetAllMessages(AgentSession session)
+ {
+ var state = this._sessionState.GetOrInitializeState(session);
+ return state.Messages.AsReadOnly();
+ }
+
public void UpdateBookmark(AgentSession session)
{
var state = this._sessionState.GetOrInitializeState(session);
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs
index 7679123970..295c08ceeb 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowHostAgent.cs
@@ -119,13 +119,17 @@ Task RunCoreAsync(
MessageMerger merger = new();
await foreach (AgentResponseUpdate update in workflowSession.InvokeStageAsync(cancellationToken)
- .ConfigureAwait(false)
- .WithCancellation(cancellationToken))
+ .ConfigureAwait(false)
+ .WithCancellation(cancellationToken))
{
merger.AddUpdate(update);
}
- return merger.ComputeMerged(workflowSession.LastResponseId!, this.Id, this.Name);
+ AgentResponse response = merger.ComputeMerged(workflowSession.LastResponseId!, this.Id, this.Name);
+ workflowSession.ChatHistoryProvider.AddMessages(workflowSession, response.Messages);
+ workflowSession.ChatHistoryProvider.UpdateBookmark(workflowSession);
+
+ return response;
}
protected override async
@@ -138,11 +142,18 @@ IAsyncEnumerable RunCoreStreamingAsync(
await this.ValidateWorkflowAsync().ConfigureAwait(false);
WorkflowSession workflowSession = await this.UpdateSessionAsync(messages, session, cancellationToken).ConfigureAwait(false);
+ MessageMerger merger = new();
+
await foreach (AgentResponseUpdate update in workflowSession.InvokeStageAsync(cancellationToken)
.ConfigureAwait(false)
.WithCancellation(cancellationToken))
{
+ merger.AddUpdate(update);
yield return update;
}
+
+ AgentResponse response = merger.ComputeMerged(workflowSession.LastResponseId!, this.Id, this.Name);
+ workflowSession.ChatHistoryProvider.AddMessages(workflowSession, response.Messages);
+ workflowSession.ChatHistoryProvider.UpdateBookmark(workflowSession);
}
}
diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs
index 40a18dbadb..a01bd68e4b 100644
--- a/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs
+++ b/dotnet/src/Microsoft.Agents.AI.Workflows/WorkflowSession.cs
@@ -110,7 +110,7 @@ public AgentResponseUpdate CreateUpdate(string responseId, object raw, params AI
{
Throw.IfNullOrEmpty(parts);
- AgentResponseUpdate update = new(ChatRole.Assistant, parts)
+ return new(ChatRole.Assistant, parts)
{
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
@@ -118,27 +118,19 @@ public AgentResponseUpdate CreateUpdate(string responseId, object raw, params AI
ResponseId = responseId,
RawRepresentation = raw
};
-
- this.ChatHistoryProvider.AddMessages(this, update.ToChatMessage());
-
- return update;
}
public AgentResponseUpdate CreateUpdate(string responseId, object raw, ChatMessage message)
{
Throw.IfNull(message);
- AgentResponseUpdate update = new(message.Role, message.Contents)
+ return new(message.Role, message.Contents)
{
CreatedAt = message.CreatedAt ?? DateTimeOffset.UtcNow,
MessageId = message.MessageId ?? Guid.NewGuid().ToString("N"),
ResponseId = responseId,
RawRepresentation = raw
};
-
- this.ChatHistoryProvider.AddMessages(this, update.ToChatMessage());
-
- return update;
}
private async ValueTask CreateOrResumeRunAsync(List messages, CancellationToken cancellationToken = default)
@@ -170,94 +162,86 @@ internal async
IAsyncEnumerable InvokeStageAsync(
[EnumeratorCancellation] CancellationToken cancellationToken = default)
{
- try
- {
- this.LastResponseId = Guid.NewGuid().ToString("N");
- List messages = this.ChatHistoryProvider.GetFromBookmark(this).ToList();
+ this.LastResponseId = Guid.NewGuid().ToString("N");
+ List messages = this.ChatHistoryProvider.GetFromBookmark(this).ToList();
#pragma warning disable CA2007 // Analyzer misfiring and not seeing .ConfigureAwait(false) below.
- await using StreamingRun run =
- await this.CreateOrResumeRunAsync(messages, cancellationToken).ConfigureAwait(false);
+ await using StreamingRun run =
+ await this.CreateOrResumeRunAsync(messages, cancellationToken).ConfigureAwait(false);
#pragma warning restore CA2007
- await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false);
- await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false, cancellationToken)
- .ConfigureAwait(false)
- .WithCancellation(cancellationToken))
+ await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false);
+ await foreach (WorkflowEvent evt in run.WatchStreamAsync(blockOnPendingRequest: false, cancellationToken)
+ .ConfigureAwait(false)
+ .WithCancellation(cancellationToken))
+ {
+ switch (evt)
{
- switch (evt)
- {
- case AgentResponseUpdateEvent agentUpdate:
- yield return agentUpdate.Update;
- break;
-
- case RequestInfoEvent requestInfo:
- FunctionCallContent fcContent = requestInfo.Request.ToFunctionCall();
- AgentResponseUpdate update = this.CreateUpdate(this.LastResponseId, evt, fcContent);
- yield return update;
- break;
-
- case WorkflowErrorEvent workflowError:
- Exception? exception = workflowError.Exception;
- if (exception is TargetInvocationException tie && tie.InnerException != null)
- {
- exception = tie.InnerException;
- }
-
- if (exception != null)
- {
- string message = this._includeExceptionDetails
- ? exception.Message
- : "An error occurred while executing the workflow.";
-
- ErrorContent errorContent = new(message);
- yield return this.CreateUpdate(this.LastResponseId, evt, errorContent);
- }
-
- break;
-
- case SuperStepCompletedEvent stepCompleted:
- this.LastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint;
+ case AgentResponseUpdateEvent agentUpdate:
+ yield return agentUpdate.Update;
+ break;
+
+ case RequestInfoEvent requestInfo:
+ FunctionCallContent fcContent = requestInfo.Request.ToFunctionCall();
+ AgentResponseUpdate update = this.CreateUpdate(this.LastResponseId, evt, fcContent);
+ yield return update;
+ break;
+
+ case WorkflowErrorEvent workflowError:
+ Exception? exception = workflowError.Exception;
+ if (exception is TargetInvocationException tie && tie.InnerException != null)
+ {
+ exception = tie.InnerException;
+ }
+
+ if (exception != null)
+ {
+ string message = this._includeExceptionDetails
+ ? exception.Message
+ : "An error occurred while executing the workflow.";
+
+ ErrorContent errorContent = new(message);
+ yield return this.CreateUpdate(this.LastResponseId, evt, errorContent);
+ }
+
+ break;
+
+ case SuperStepCompletedEvent stepCompleted:
+ this.LastCheckpoint = stepCompleted.CompletionInfo?.Checkpoint;
+ goto default;
+
+ case WorkflowOutputEvent output:
+ IEnumerable? updateMessages = output.Data switch
+ {
+ IEnumerable chatMessages => chatMessages,
+ ChatMessage chatMessage => [chatMessage],
+ _ => null
+ };
+
+ if (!this._includeWorkflowOutputsInResponse || updateMessages == null)
+ {
goto default;
-
- case WorkflowOutputEvent output:
- IEnumerable? updateMessages = output.Data switch
- {
- IEnumerable chatMessages => chatMessages,
- ChatMessage chatMessage => [chatMessage],
- _ => null
- };
-
- if (!this._includeWorkflowOutputsInResponse || updateMessages == null)
- {
- goto default;
- }
-
- foreach (ChatMessage message in updateMessages)
- {
- yield return this.CreateUpdate(this.LastResponseId, evt, message);
- }
- break;
-
- default:
- // Emit all other workflow events for observability (DevUI, logging, etc.)
- yield return new AgentResponseUpdate(ChatRole.Assistant, [])
- {
- CreatedAt = DateTimeOffset.UtcNow,
- MessageId = Guid.NewGuid().ToString("N"),
- Role = ChatRole.Assistant,
- ResponseId = this.LastResponseId,
- RawRepresentation = evt
- };
- break;
- }
+ }
+
+ foreach (ChatMessage message in updateMessages)
+ {
+ yield return this.CreateUpdate(this.LastResponseId, evt, message);
+ }
+ break;
+
+ default:
+ // Emit all other workflow events for observability (DevUI, logging, etc.)
+ yield return new AgentResponseUpdate(ChatRole.Assistant, [])
+ {
+ CreatedAt = DateTimeOffset.UtcNow,
+ MessageId = Guid.NewGuid().ToString("N"),
+ Role = ChatRole.Assistant,
+ ResponseId = this.LastResponseId,
+ RawRepresentation = evt
+ };
+ break;
}
}
- finally
- {
- // Do we want to try to undo the step, and not update the bookmark?
- this.ChatHistoryProvider.UpdateBookmark(this);
- }
}
public string? LastResponseId { get; set; }
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs
index 063bd77cda..9cd9eb45e6 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostExecutorTests.cs
@@ -10,20 +10,8 @@
namespace Microsoft.Agents.AI.Workflows.UnitTests;
-public class AIAgentHostExecutorTests
+public class AIAgentHostExecutorTests : AIAgentHostingExecutorTestsBase
{
- private const string TestAgentId = nameof(TestAgentId);
- private const string TestAgentName = nameof(TestAgentName);
-
- private static readonly string[] s_messageStrings = [
- "",
- "Hello world!",
- "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
- "Quisque dignissim ante odio, at facilisis orci porta a. Duis mi augue, fringilla eu egestas a, pellentesque sed lacus."
- ];
-
- private static List TestMessages => TestReplayAgent.ToChatMessages(s_messageStrings);
-
[Theory]
[InlineData(null, null)]
[InlineData(null, true)]
@@ -50,30 +38,7 @@ public async Task Test_AgentHostExecutor_EmitsStreamingUpdatesIFFConfiguredAsync
bool expectingEvents = turnSetting ?? executorSetting ?? false;
AgentResponseUpdateEvent[] updates = testContext.Events.OfType().ToArray();
- if (expectingEvents)
- {
- // The way TestReplayAgent is set up, it will emit one update per non-empty AIContent
- List expectedUpdateContents = TestMessages.SelectMany(message => message.Contents).ToList();
-
- updates.Should().HaveCount(expectedUpdateContents.Count);
- for (int i = 0; i < updates.Length; i++)
- {
- AgentResponseUpdateEvent updateEvent = updates[i];
- AIContent expectedUpdateContent = expectedUpdateContents[i];
-
- updateEvent.ExecutorId.Should().Be(agent.GetDescriptiveId());
-
- AgentResponseUpdate update = updateEvent.Update;
- update.AuthorName.Should().Be(TestAgentName);
- update.AgentId.Should().Be(TestAgentId);
- update.Contents.Should().HaveCount(1);
- update.Contents[0].Should().BeEquivalentTo(expectedUpdateContent);
- }
- }
- else
- {
- updates.Should().BeEmpty();
- }
+ CheckResponseUpdateEventsAgainstTestMessages(updates, expectingEvents, agent.GetDescriptiveId());
}
[Theory]
@@ -92,30 +57,7 @@ public async Task Test_AgentHostExecutor_EmitsResponseIFFConfiguredAsync(bool ex
// Assert
AgentResponseEvent[] updates = testContext.Events.OfType().ToArray();
- if (executorSetting)
- {
- updates.Should().HaveCount(1);
-
- AgentResponseEvent responseEvent = updates[0];
- responseEvent.ExecutorId.Should().Be(agent.GetDescriptiveId());
-
- AgentResponse response = responseEvent.Response;
- response.AgentId.Should().Be(TestAgentId);
- response.Messages.Should().HaveCount(TestMessages.Count - 1);
-
- for (int i = 0; i < response.Messages.Count; i++)
- {
- ChatMessage responseMessage = response.Messages[i];
- ChatMessage expectedMessage = TestMessages[i + 1]; // Skip the first empty message
-
- responseMessage.AuthorName.Should().Be(TestAgentName);
- responseMessage.Text.Should().Be(expectedMessage.Text);
- }
- }
- else
- {
- updates.Should().BeEmpty();
- }
+ CheckResponseEventsAgainstTestMessages(updates, expectingResponse: executorSetting, agent.GetDescriptiveId());
}
private static ChatMessage UserMessage => new(ChatRole.User, "Hello from User!") { AuthorName = "User" };
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostingExecutorTestsBase.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostingExecutorTestsBase.cs
new file mode 100644
index 0000000000..2285074ce3
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/AIAgentHostingExecutorTestsBase.cs
@@ -0,0 +1,79 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Collections.Generic;
+using System.Linq;
+using FluentAssertions;
+using Microsoft.Extensions.AI;
+
+namespace Microsoft.Agents.AI.Workflows.UnitTests;
+
+public abstract class AIAgentHostingExecutorTestsBase
+{
+ protected const string TestAgentId = nameof(TestAgentId);
+ protected const string TestAgentName = nameof(TestAgentName);
+
+ private static readonly string[] s_messageStrings = [
+ "",
+ "Hello world!",
+ "Lorem ipsum dolor sit amet, consectetur adipiscing elit.",
+ "Quisque dignissim ante odio, at facilisis orci porta a. Duis mi augue, fringilla eu egestas a, pellentesque sed lacus."
+ ];
+
+ protected static List TestMessages => TestReplayAgent.ToChatMessages(s_messageStrings);
+
+ protected static void CheckResponseUpdateEventsAgainstTestMessages(AgentResponseUpdateEvent[] updates, bool expectingEvents, string expectedExecutorId)
+ {
+ if (expectingEvents)
+ {
+ // The way TestReplayAgent is set up, it will emit one update per non-empty AIContent
+ List expectedUpdateContents = TestMessages.SelectMany(message => message.Contents).ToList();
+
+ updates.Should().HaveCount(expectedUpdateContents.Count);
+ for (int i = 0; i < updates.Length; i++)
+ {
+ AgentResponseUpdateEvent updateEvent = updates[i];
+ AIContent expectedUpdateContent = expectedUpdateContents[i];
+
+ updateEvent.ExecutorId.Should().Be(expectedExecutorId);
+
+ AgentResponseUpdate update = updateEvent.Update;
+ update.AuthorName.Should().Be(TestAgentName);
+ update.AgentId.Should().Be(TestAgentId);
+ update.Contents.Should().HaveCount(1);
+ update.Contents[0].Should().BeEquivalentTo(expectedUpdateContent);
+ }
+ }
+ else
+ {
+ updates.Should().BeEmpty();
+ }
+ }
+
+ protected static void CheckResponseEventsAgainstTestMessages(AgentResponseEvent[] updates, bool expectingResponse, string expectedExecutorId)
+ {
+ if (expectingResponse)
+ {
+ updates.Should().HaveCount(1);
+
+ AgentResponseEvent responseEvent = updates[0];
+ responseEvent.ExecutorId.Should().Be(expectedExecutorId);
+
+ AgentResponse response = responseEvent.Response;
+ response.AgentId.Should().Be(TestAgentId);
+ response.Messages.Should().HaveCount(TestMessages.Count - 1);
+
+ for (int i = 0; i < response.Messages.Count; i++)
+ {
+ ChatMessage responseMessage = response.Messages[i];
+ ChatMessage expectedMessage = TestMessages[i + 1]; // Skip the first empty message
+
+ responseMessage.AuthorName.Should().Be(TestAgentName);
+ responseMessage.Text.Should().Be(expectedMessage.Text);
+ }
+ }
+ else
+ {
+ updates.Should().BeEmpty();
+ }
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs
new file mode 100644
index 0000000000..8bdbe23c5f
--- /dev/null
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/HandoffAgentExecutorTests.cs
@@ -0,0 +1,71 @@
+// Copyright (c) Microsoft. All rights reserved.
+
+using System.Linq;
+using System.Threading.Tasks;
+using Microsoft.Agents.AI.Workflows.Specialized;
+
+namespace Microsoft.Agents.AI.Workflows.UnitTests;
+
+public class HandoffAgentExecutorTests : AIAgentHostingExecutorTestsBase
+{
+ [Theory]
+ [InlineData(null, null)]
+ [InlineData(null, true)]
+ [InlineData(null, false)]
+ [InlineData(true, null)]
+ [InlineData(true, true)]
+ [InlineData(true, false)]
+ [InlineData(false, null)]
+ [InlineData(false, true)]
+ [InlineData(false, false)]
+ public async Task Test_HandoffAgentExecutor_EmitsStreamingUpdatesIFFConfiguredAsync(bool? executorSetting, bool? turnSetting)
+ {
+ // Arrange
+ TestRunContext testContext = new();
+ TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName);
+
+ HandoffAgentExecutorOptions options = new("",
+ emitAgentResponseEvents: false,
+ emitAgentResponseUpdateEvents: executorSetting,
+ HandoffToolCallFilteringBehavior.None);
+
+ HandoffAgentExecutor executor = new(agent, options);
+ testContext.ConfigureExecutor(executor);
+
+ // Act
+ HandoffState message = new(new(turnSetting), null, []);
+ await executor.HandleAsync(message, testContext.BindWorkflowContext(executor.Id));
+
+ // Assert
+ bool expectingStreamingUpdates = turnSetting ?? executorSetting ?? false;
+
+ AgentResponseUpdateEvent[] updates = testContext.Events.OfType().ToArray();
+ CheckResponseUpdateEventsAgainstTestMessages(updates, expectingStreamingUpdates, agent.GetDescriptiveId());
+ }
+
+ [Theory]
+ [InlineData(true)]
+ [InlineData(false)]
+ public async Task Test_HandoffAgentExecutor_EmitsResponseIFFConfiguredAsync(bool executorSetting)
+ {
+ // Arrange
+ TestRunContext testContext = new();
+ TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName);
+
+ HandoffAgentExecutorOptions options = new("",
+ emitAgentResponseEvents: executorSetting,
+ emitAgentResponseUpdateEvents: false,
+ HandoffToolCallFilteringBehavior.None);
+
+ HandoffAgentExecutor executor = new(agent, options);
+ testContext.ConfigureExecutor(executor);
+
+ // Act
+ HandoffState message = new(new(false), null, []);
+ await executor.HandleAsync(message, testContext.BindWorkflowContext(executor.Id));
+
+ // Assert
+ AgentResponseEvent[] updates = testContext.Events.OfType().ToArray();
+ CheckResponseEventsAgainstTestMessages(updates, expectingResponse: executorSetting, agent.GetDescriptiveId());
+ }
+}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs
index 40e4f2098f..55f0cb10bc 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/WorkflowHostSmokeTests.cs
@@ -28,7 +28,7 @@ public ExpectedException(string? message, Exception? innerException) : base(mess
}
}
-public class WorkflowHostSmokeTests
+public class WorkflowHostSmokeTests : AIAgentHostingExecutorTestsBase
{
private sealed class AlwaysFailsAIAgent(bool failByThrowing) : AIAgent
{
@@ -112,4 +112,70 @@ public async Task Test_AsAgent_ErrorContentStreamedOutAsync(bool includeExceptio
hadErrorContent.Should().BeTrue();
}
+
+ private async Task Run_AsAgent_OutgoingMessagesInHistoryAsync(Workflow workflow, bool runAsync)
+ {
+ // Arrange
+ AIAgent workflowAgent = workflow.AsAIAgent();
+
+ // Act
+ AgentSession session = await workflowAgent.CreateSessionAsync();
+ AgentResponse response;
+ if (runAsync)
+ {
+ List updates = [];
+ await foreach (AgentResponseUpdate update in workflowAgent.RunStreamingAsync(session))
+ {
+ // Skip WorkflowEvent updates, which do not get persisted in ChatHistory; we cannot skip
+ // them after because of a deleterious interaction with .ToAgentResponse() due to the
+ // empty initial message (which is created without a MessageId). When running through the
+ // message merger, it does the right thing internally.
+ if (!string.IsNullOrEmpty(update.Text))
+ {
+ updates.Add(update);
+ }
+ }
+
+ response = updates.ToAgentResponse();
+ }
+ else
+ {
+ response = await workflowAgent.RunAsync(session);
+ }
+
+ // Assert
+ WorkflowSession workflowSession = session.Should().BeOfType().Subject;
+
+ ChatMessage[] responseMessages = response.Messages.Where(message => message.Contents.Any())
+ .ToArray();
+
+ ChatMessage[] sessionMessages = workflowSession.ChatHistoryProvider.GetAllMessages(workflowSession)
+ .ToArray();
+
+ // Since we never sent an incoming message, the expectation is that there should be nothing in the session
+ // except the response
+ responseMessages.Should().BeEquivalentTo(sessionMessages, options => options.WithStrictOrdering());
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public Task Test_SingleAgent_AsAgent_OutgoingMessagesInHistoryAsync(bool runAsync)
+ {
+ // Arrange
+ TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName);
+ Workflow singleAgentWorkflow = new WorkflowBuilder(agent).Build();
+ return this.Run_AsAgent_OutgoingMessagesInHistoryAsync(singleAgentWorkflow, runAsync);
+ }
+
+ [Theory]
+ [InlineData(false)]
+ [InlineData(true)]
+ public Task Test_Handoffs_AsAgent_OutgoingMessagesInHistoryAsync(bool runAsync)
+ {
+ // Arrange
+ TestReplayAgent agent = new(TestMessages, TestAgentId, TestAgentName);
+ Workflow handoffWorkflow = new HandoffsWorkflowBuilder(agent).Build();
+ return this.Run_AsAgent_OutgoingMessagesInHistoryAsync(handoffWorkflow, runAsync);
+ }
}