From 58b06ffb391408dabbc0c6910632bf4a5990fc10 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Fri, 29 May 2026 16:42:49 +0000 Subject: [PATCH] Merge MagenticOrchestratorTests into MagenticOrchestrationTests --- .../MagenticOrchestrationTests.cs | 152 ++++++++++++ .../MagenticOrchestratorTests.cs | 223 ------------------ 2 files changed, 152 insertions(+), 223 deletions(-) delete mode 100644 dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MagenticOrchestratorTests.cs diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MagenticOrchestrationTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MagenticOrchestrationTests.cs index 937e047886..9d7af5bcc0 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MagenticOrchestrationTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MagenticOrchestrationTests.cs @@ -3,8 +3,10 @@ using System; using System.Collections.Generic; using System.Linq; +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; @@ -18,6 +20,101 @@ namespace Microsoft.Agents.AI.Workflows.UnitTests; /// public class MagenticOrchestrationTests { + [Fact] + public void Test_MagenticOrchestrator_Protocol_Declares_SentMessages() + { + TestReplayAgent manager = new(name: nameof(MagenticOrchestrator)); + TestEchoAgent participant = new(name: "Echo"); + MagenticOrchestrator orchestrator = new(manager, [participant], new(), requirePlanSignoff: false); + + ProtocolDescriptor protocol = orchestrator.DescribeProtocol(); + + protocol.Sends.Should().Contain(typeof(List)); + protocol.Sends.Should().Contain(typeof(ChatMessage)); + 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)."); + } + [Fact] public async Task Task_Completes_When_RequestSatisfiedAsync() { @@ -1403,5 +1500,60 @@ public class MagenticOrchestrationTests return new(sb.ToString(), output?.As>(), lastCheckpoint, pendingRequests); } + /// + /// 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 } diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MagenticOrchestratorTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MagenticOrchestratorTests.cs deleted file mode 100644 index 14a3c899b2..0000000000 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MagenticOrchestratorTests.cs +++ /dev/null @@ -1,223 +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; - -public class MagenticOrchestratorTests -{ - [Fact] - public void Test_MagenticOrchestrator_Protocol_Declares_SentMessages() - { - TestReplayAgent manager = new(name: nameof(MagenticOrchestrator)); - TestEchoAgent participant = new(name: "Echo"); - MagenticOrchestrator orchestrator = new(manager, [participant], new(), requirePlanSignoff: false); - - ProtocolDescriptor protocol = orchestrator.DescribeProtocol(); - - protocol.Sends.Should().Contain(typeof(List)); - protocol.Sends.Should().Contain(typeof(ChatMessage)); - 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 -}