diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MagenticOrchestratorParticipantResponseBug6173Tests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MagenticOrchestratorParticipantResponseBug6173Tests.cs
deleted file mode 100644
index dfce621e15..0000000000
--- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MagenticOrchestratorParticipantResponseBug6173Tests.cs
+++ /dev/null
@@ -1,204 +0,0 @@
-// Copyright (c) Microsoft. All rights reserved.
-
-using System;
-using System.Collections.Generic;
-using System.Linq;
-using System.Runtime.CompilerServices;
-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.Magentic;
-using Microsoft.Extensions.AI;
-
-namespace Microsoft.Agents.AI.Workflows.UnitTests;
-
-///
-/// Regression test for https://github.com/microsoft/agent-framework/issues/6173.
-///
-/// On subsequent turns of (i.e., when an
-/// agent participant returns control to the orchestrator), the inbound messages
-/// parameter — which carries the participant's response — must be appended to the
-/// task context's chat history before the next coordination round is computed.
-/// Otherwise, the manager's progress ledger never sees the participant's output and the
-/// workflow loops until it exhausts MaxRoundCount.
-///
-public class MagenticOrchestratorParticipantResponseBug6173Tests
-{
- [Fact]
- public async Task ParticipantResponse_IsAppendedTo_ChatHistory_BetweenRoundsAsync()
- {
- // A marker that only appears in the worker's echo response, NOT in any orchestrator
- // instruction or manager prompt. Used to prove the participant response made it
- // back into the chat history between rounds.
- const string WorkerEchoMarker = "WORKER_ECHO_MARKER::";
-
- List factsResponse = CreatePlanResponse("Initial facts");
- List planResponse = CreatePlanResponse("Step 1: Delegate to worker");
-
- // Round 1 ledger: not satisfied, delegate to Worker.
- List round1Ledger = CreateProgressLedgerResponse(
- isRequestSatisfied: false,
- isInLoop: false,
- isProgressBeingMade: true,
- nextSpeaker: "Worker",
- instructionOrQuestion: "Please work on the task");
-
- // Round 2 ledger: satisfied — drives the workflow to the final answer.
- List round2Ledger = CreateProgressLedgerResponse(
- isRequestSatisfied: true,
- isInLoop: false,
- isProgressBeingMade: true,
- nextSpeaker: "Worker",
- instructionOrQuestion: "Done");
-
- List finalAnswerResponse = CreateFinalAnswerResponse("All done");
-
- RecordingReplayAgent manager = new(
- [factsResponse, planResponse, round1Ledger, round2Ledger, finalAnswerResponse],
- name: "Manager");
-
- TestEchoAgent worker = new(name: "Worker", prefix: WorkerEchoMarker);
-
- Workflow workflow = new MagenticWorkflowBuilder(manager)
- .AddParticipants(worker)
- .RequirePlanSignoff(false)
- .WithMaxRounds(2)
- .Build();
-
- InProcessExecutionEnvironment environment = ExecutionEnvironment.InProcess_Lockstep
- .ToWorkflowExecutionEnvironment()
- .WithCheckpointing(CheckpointManager.CreateInMemory());
-
- await using StreamingRun run = await environment.OpenStreamingAsync(workflow);
-
- await run.TrySendMessageAsync(new List { new(ChatRole.User, "Do the task") });
- await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
-
- await foreach (WorkflowEvent _ in run.WatchStreamAsync(blockOnPendingRequest: false).ConfigureAwait(false))
- {
- // Drain to completion.
- }
-
- // The manager is invoked five times: facts, plan, round-1 ledger, round-2 ledger, final answer.
- // The round-2 progress-ledger call (index 3) is the one that proves the worker's response
- // made it into chat history between rounds.
- manager.Invocations.Should().HaveCountGreaterThanOrEqualTo(4,
- "the manager should have been invoked at least four times (facts, plan, ledger1, ledger2)");
-
- IReadOnlyList round2LedgerInputs = manager.Invocations[3];
-
- round2LedgerInputs.Should().Contain(
- m => m.Text != null && m.Text.Contains(WorkerEchoMarker, StringComparison.Ordinal),
- "the worker's response from round 1 must be included in the messages provided to the manager "
- + "when computing the round-2 progress ledger — otherwise the orchestrator cannot detect task completion "
- + "and the workflow will exhaust MaxRoundCount (see issue #6173).");
- }
-
- private static List CreatePlanResponse(string plan) =>
- [
- new ChatMessage(ChatRole.Assistant, plan)
- {
- MessageId = Guid.NewGuid().ToString("N"),
- CreatedAt = DateTimeOffset.UtcNow
- }
- ];
-
- private static List CreateProgressLedgerResponse(
- bool isRequestSatisfied,
- bool isInLoop,
- bool isProgressBeingMade,
- string nextSpeaker,
- string instructionOrQuestion)
- {
- string isRequestSatisfiedStr = isRequestSatisfied ? "true" : "false";
- string isInLoopStr = isInLoop ? "true" : "false";
- string isProgressBeingMadeStr = isProgressBeingMade ? "true" : "false";
- string nextSpeakerJson = JsonSerializer.Serialize(nextSpeaker);
- string instructionJson = JsonSerializer.Serialize(instructionOrQuestion);
-
- string ledgerJson = $$"""
- {
- "is_request_satisfied": { "answer": {{isRequestSatisfiedStr}}, "reason": "test reason" },
- "is_in_loop": { "answer": {{isInLoopStr}}, "reason": "test reason" },
- "is_progress_being_made": { "answer": {{isProgressBeingMadeStr}}, "reason": "test reason" },
- "next_speaker": { "answer": {{nextSpeakerJson}}, "reason": "test reason" },
- "instruction_or_question": { "answer": {{instructionJson}}, "reason": "test reason" }
- }
- """;
-
- return
- [
- new ChatMessage(ChatRole.Assistant, ledgerJson)
- {
- MessageId = Guid.NewGuid().ToString("N"),
- CreatedAt = DateTimeOffset.UtcNow
- }
- ];
- }
-
- private static List CreateFinalAnswerResponse(string answer) =>
- [
- new ChatMessage(ChatRole.Assistant, answer)
- {
- MessageId = Guid.NewGuid().ToString("N"),
- CreatedAt = DateTimeOffset.UtcNow
- }
- ];
-
- ///
- /// A scripted manager agent that records the messages it receives on each invocation,
- /// so tests can inspect what context was passed to the manager at each step.
- ///
- private sealed class RecordingReplayAgent(List> scriptedResponses, string? name = null) : AIAgent
- {
- public override string? Name => name;
-
- public List> Invocations { get; } = [];
-
- private int _turn;
-
- protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default)
- => new(new RecordingAgentSession());
-
- protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
- => new(new RecordingAgentSession());
-
- 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)
- => this.RunStreamingAsync(messages, session, options, cancellationToken).ToAgentResponseAsync(cancellationToken);
-
- protected override async IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
- {
- this.Invocations.Add(messages.ToList());
-
- string responseId = Guid.NewGuid().ToString("N");
-
- if (this._turn < scriptedResponses.Count)
- {
- foreach (ChatMessage message in scriptedResponses[this._turn++])
- {
- foreach (AIContent content in message.Contents)
- {
- yield return new AgentResponseUpdate
- {
- AgentId = this.Id,
- AuthorName = this.Name,
- MessageId = message.MessageId,
- ResponseId = responseId,
- Contents = [content],
- Role = message.Role,
- };
- }
- }
- }
-
- await Task.CompletedTask.ConfigureAwait(false);
- }
-
- private sealed class RecordingAgentSession() : AgentSession();
- }
-}
diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MagenticOrchestratorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MagenticOrchestratorTests.cs
index db091c9a95..14a3c899b2 100644
--- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MagenticOrchestratorTests.cs
+++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MagenticOrchestratorTests.cs
@@ -1,7 +1,14 @@
// Copyright (c) Microsoft. All rights reserved.
+using System;
using System.Collections.Generic;
+using System.Linq;
+using System.Runtime.CompilerServices;
+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.Magentic;
using Microsoft.Extensions.AI;
@@ -23,4 +30,194 @@ public class MagenticOrchestratorTests
protocol.Sends.Should().Contain(typeof(TurnToken));
protocol.Sends.Should().Contain(typeof(ResetChatSignal));
}
+
+ ///
+ /// Regression test for https://github.com/microsoft/agent-framework/issues/6173.
+ ///
+ /// On subsequent turns of (i.e., when an
+ /// agent participant returns control to the orchestrator), the inbound messages
+ /// parameter — which carries the participant's response — must be appended to the
+ /// task context's chat history before the next coordination round is computed.
+ /// Otherwise, the manager's progress ledger never sees the participant's output and the
+ /// workflow loops until it exhausts MaxRoundCount.
+ ///
+ [Fact]
+ public async Task ChatHistory_Includes_ParticipantResponse_On_Subsequent_TurnsAsync()
+ {
+ // A marker that only appears in the worker's echo response, NOT in any orchestrator
+ // instruction or manager prompt. Used to prove the participant response made it
+ // back into the chat history between rounds.
+ const string WorkerEchoMarker = "WORKER_ECHO_MARKER::";
+
+ List factsResponse = CreatePlanResponse("Initial facts");
+ List planResponse = CreatePlanResponse("Step 1: Delegate to worker");
+
+ // Round 1 ledger: not satisfied, delegate to Worker.
+ List round1Ledger = CreateProgressLedgerResponse(
+ isRequestSatisfied: false,
+ isInLoop: false,
+ isProgressBeingMade: true,
+ nextSpeaker: "Worker",
+ instructionOrQuestion: "Please work on the task");
+
+ // Round 2 ledger: satisfied — drives the workflow to the final answer.
+ List round2Ledger = CreateProgressLedgerResponse(
+ isRequestSatisfied: true,
+ isInLoop: false,
+ isProgressBeingMade: true,
+ nextSpeaker: "Worker",
+ instructionOrQuestion: "Done");
+
+ List finalAnswerResponse = CreateFinalAnswerResponse("All done");
+
+ RecordingReplayAgent manager = new(
+ [factsResponse, planResponse, round1Ledger, round2Ledger, finalAnswerResponse],
+ name: "Manager");
+
+ TestEchoAgent worker = new(name: "Worker", prefix: WorkerEchoMarker);
+
+ Workflow workflow = new MagenticWorkflowBuilder(manager)
+ .AddParticipants(worker)
+ .RequirePlanSignoff(false)
+ .WithMaxRounds(2)
+ .Build();
+
+ InProcessExecutionEnvironment environment = ExecutionEnvironment.InProcess_Lockstep
+ .ToWorkflowExecutionEnvironment()
+ .WithCheckpointing(CheckpointManager.CreateInMemory());
+
+ await using StreamingRun run = await environment.OpenStreamingAsync(workflow);
+
+ await run.TrySendMessageAsync(new List { new(ChatRole.User, "Do the task") });
+ await run.TrySendMessageAsync(new TurnToken(emitEvents: true));
+
+ await foreach (WorkflowEvent _ in run.WatchStreamAsync(blockOnPendingRequest: false).ConfigureAwait(false))
+ {
+ // Drain to completion.
+ }
+
+ // The manager is invoked five times: facts, plan, round-1 ledger, round-2 ledger, final answer.
+ // The round-2 progress-ledger call (index 3) is the one that proves the worker's response
+ // made it into chat history between rounds.
+ manager.Invocations.Should().HaveCountGreaterThanOrEqualTo(4,
+ "the manager should have been invoked at least four times (facts, plan, ledger1, ledger2)");
+
+ IReadOnlyList round2LedgerInputs = manager.Invocations[3];
+
+ round2LedgerInputs.Should().Contain(
+ m => m.Text != null && m.Text.Contains(WorkerEchoMarker, StringComparison.Ordinal),
+ "the worker's response from round 1 must be included in the messages provided to the manager "
+ + "when computing the round-2 progress ledger — otherwise the orchestrator cannot detect task completion "
+ + "and the workflow will exhaust MaxRoundCount (see issue #6173).");
+ }
+
+ #region Helper Methods
+
+ private static List CreatePlanResponse(string plan) =>
+ [
+ new ChatMessage(ChatRole.Assistant, plan)
+ {
+ MessageId = Guid.NewGuid().ToString("N"),
+ CreatedAt = DateTimeOffset.UtcNow
+ }
+ ];
+
+ private static List CreateProgressLedgerResponse(
+ bool isRequestSatisfied,
+ bool isInLoop,
+ bool isProgressBeingMade,
+ string nextSpeaker,
+ string instructionOrQuestion)
+ {
+ string isRequestSatisfiedStr = isRequestSatisfied ? "true" : "false";
+ string isInLoopStr = isInLoop ? "true" : "false";
+ string isProgressBeingMadeStr = isProgressBeingMade ? "true" : "false";
+ string nextSpeakerJson = JsonSerializer.Serialize(nextSpeaker);
+ string instructionJson = JsonSerializer.Serialize(instructionOrQuestion);
+
+ string ledgerJson = $$"""
+ {
+ "is_request_satisfied": { "answer": {{isRequestSatisfiedStr}}, "reason": "test reason" },
+ "is_in_loop": { "answer": {{isInLoopStr}}, "reason": "test reason" },
+ "is_progress_being_made": { "answer": {{isProgressBeingMadeStr}}, "reason": "test reason" },
+ "next_speaker": { "answer": {{nextSpeakerJson}}, "reason": "test reason" },
+ "instruction_or_question": { "answer": {{instructionJson}}, "reason": "test reason" }
+ }
+ """;
+
+ return
+ [
+ new ChatMessage(ChatRole.Assistant, ledgerJson)
+ {
+ MessageId = Guid.NewGuid().ToString("N"),
+ CreatedAt = DateTimeOffset.UtcNow
+ }
+ ];
+ }
+
+ private static List CreateFinalAnswerResponse(string answer) =>
+ [
+ new ChatMessage(ChatRole.Assistant, answer)
+ {
+ MessageId = Guid.NewGuid().ToString("N"),
+ CreatedAt = DateTimeOffset.UtcNow
+ }
+ ];
+
+ ///
+ /// A scripted manager agent that records the messages it receives on each invocation,
+ /// so tests can inspect what context was passed to the manager at each step.
+ ///
+ private sealed class RecordingReplayAgent(List> scriptedResponses, string? name = null) : AIAgent
+ {
+ public override string? Name => name;
+
+ public List> Invocations { get; } = [];
+
+ private int _turn;
+
+ protected override ValueTask CreateSessionCoreAsync(CancellationToken cancellationToken = default)
+ => new(new RecordingAgentSession());
+
+ protected override ValueTask DeserializeSessionCoreAsync(JsonElement serializedState, JsonSerializerOptions? jsonSerializerOptions = null, CancellationToken cancellationToken = default)
+ => new(new RecordingAgentSession());
+
+ 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)
+ => this.RunStreamingAsync(messages, session, options, cancellationToken).ToAgentResponseAsync(cancellationToken);
+
+ protected override async IAsyncEnumerable RunCoreStreamingAsync(IEnumerable messages, AgentSession? session = null, AgentRunOptions? options = null, [EnumeratorCancellation] CancellationToken cancellationToken = default)
+ {
+ this.Invocations.Add(messages.ToList());
+
+ string responseId = Guid.NewGuid().ToString("N");
+
+ if (this._turn < scriptedResponses.Count)
+ {
+ foreach (ChatMessage message in scriptedResponses[this._turn++])
+ {
+ foreach (AIContent content in message.Contents)
+ {
+ yield return new AgentResponseUpdate
+ {
+ AgentId = this.Id,
+ AuthorName = this.Name,
+ MessageId = message.MessageId,
+ ResponseId = responseId,
+ Contents = [content],
+ Role = message.Role,
+ };
+ }
+ }
+ }
+
+ await Task.CompletedTask.ConfigureAwait(false);
+ }
+
+ private sealed class RecordingAgentSession() : AgentSession();
+ }
+
+ #endregion
}