diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MagenticOrchestratorParticipantResponseBug6173Tests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MagenticOrchestratorParticipantResponseBug6173Tests.cs new file mode 100644 index 0000000000..dfce621e15 --- /dev/null +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MagenticOrchestratorParticipantResponseBug6173Tests.cs @@ -0,0 +1,204 @@ +// 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(); + } +}