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 }