From d8c6944c349764ac44256ea553bb9b9205d3becf Mon Sep 17 00:00:00 2001 From: Marco Minerva Date: Tue, 30 Jun 2026 14:40:53 +0200 Subject: [PATCH 1/7] Refactor MessageMerger to preserve message order Refactored MessageMerger to delegate update grouping and merging to M.E.AI, preserving the correct order and structure of assistant messages, especially for reasoning content without message IDs. Removed per-message bucketing and CreatedAt-based sorting. Added tests to verify message order and correct merging of reasoning and text updates. --- .../MessageMerger.cs | 140 +++--------------- .../MessageMergerTests.cs | 118 +++++++++++++++ 2 files changed, 140 insertions(+), 118 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs index 4b702034cee..2348f45d9aa 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs @@ -4,7 +4,6 @@ using System.Collections.Generic; using System.Linq; using Microsoft.Extensions.AI; -using Microsoft.Shared.Diagnostics; namespace Microsoft.Agents.AI.Workflows; @@ -14,67 +13,21 @@ private sealed class ResponseMergeState(string? responseId) { public string? ResponseId { get; } = responseId; - public Dictionary> UpdatesByMessageId { get; } = []; - public List DanglingUpdates { get; } = []; + // All updates for this response in streamed/arrival order. Grouping updates into messages is + // delegated to M.E.AI (ToAgentResponse), which coalesces contiguous updates by message id + // exactly like a directly-invoked agent - including merging an id-less update (e.g. a reasoning + // summary that streams without a message id) with the text that follows it. Doing the grouping + // here (bucketing per message id and appending id-less updates last) split a single assistant + // message into two and reordered its content. + private readonly List _updates = []; - public void AddUpdate(AgentResponseUpdate update) - { - if (update.MessageId is null) - { - this.DanglingUpdates.Add(update); - } - else - { - if (!this.UpdatesByMessageId.TryGetValue(update.MessageId, out List? updates)) - { - this.UpdatesByMessageId[update.MessageId] = updates = []; - } - - updates.Add(update); - } - } - - public AgentResponse ComputeMerged(string messageId) - { - if (this.UpdatesByMessageId.TryGetValue(Throw.IfNull(messageId), out List? updates)) - { - return updates.ToAgentResponse(); - } - - throw new KeyNotFoundException($"No updates found for message ID '{messageId}' in response '{this.ResponseId}'."); - } - - public AgentResponse ComputeDangling() - { - if (this.DanglingUpdates.Count == 0) - { - throw new InvalidOperationException("No dangling updates to compute a response from."); - } - - return this.DanglingUpdates.ToAgentResponse(); - } - - public List ComputeFlattened() - { - List result = this.UpdatesByMessageId.Keys.SelectMany(AggregateUpdatesToMessage).ToList(); - if (this.DanglingUpdates.Count > 0) - { - result.AddRange(this.ComputeDangling().Messages); - } + public bool HasUpdates => this._updates.Count > 0; - return result; + public void AddUpdate(AgentResponseUpdate update) => this._updates.Add(update); - IList AggregateUpdatesToMessage(string messageId) - { - List updates = this.UpdatesByMessageId[messageId]; - if (updates.Count == 0) - { - throw new InvalidOperationException($"No updates found for message ID '{messageId}' in response '{this.ResponseId}'."); - } + public AgentResponse ComputeResponse() => this._updates.ToAgentResponse(); - return updates.Select(oldUpdate => oldUpdate.AsChatResponseUpdate()).ToChatResponse().Messages; - } - } + public List ComputeFlattened() => this._updates.ToAgentResponse().Messages.ToList(); } private readonly Dictionary _mergeStates = []; @@ -84,7 +37,7 @@ public void AddUpdate(AgentResponseUpdate update) { if (update.ResponseId is null) { - this._danglingState.DanglingUpdates.Add(update); + this._danglingState.AddUpdate(update); } else { @@ -97,28 +50,6 @@ public void AddUpdate(AgentResponseUpdate update) } } - private int CompareByDateTimeOffset(AgentResponse left, AgentResponse right) - { - const int LESS = -1, EQ = 0, GREATER = 1; - - if (left.CreatedAt == right.CreatedAt) - { - return EQ; - } - - if (!left.CreatedAt.HasValue) - { - return GREATER; - } - - if (!right.CreatedAt.HasValue) - { - return LESS; - } - - return left.CreatedAt.Value.CompareTo(right.CreatedAt.Value); - } - public AgentResponse ComputeMerged(string primaryResponseId, string? primaryAgentId = null, string? primaryAgentName = null) { List messages = []; @@ -130,14 +61,11 @@ public AgentResponse ComputeMerged(string primaryResponseId, string? primaryAgen { ResponseMergeState mergeState = this._mergeStates[responseId]; - List responseList = mergeState.UpdatesByMessageId.Keys.Select(mergeState.ComputeMerged).ToList(); - if (mergeState.DanglingUpdates.Count > 0) - { - responseList.Add(mergeState.ComputeDangling()); - } - - responseList.Sort(this.CompareByDateTimeOffset); - responses[responseId] = responseList.Aggregate(MergeResponses); + // Delegate update-to-message grouping to M.E.AI, which coalesces contiguous updates by + // message id exactly like a directly-invoked agent. This keeps reasoning content that + // streams without a message id in the same assistant message as the text that follows + // it, preserving both content order and the single-message structure. + responses[responseId] = mergeState.ComputeResponse(); messages.AddRange(GetMessagesWithCreatedAt(responses[responseId])); } @@ -166,7 +94,10 @@ public AgentResponse ComputeMerged(string primaryResponseId, string? primaryAgen additionalProperties = MergeProperties(additionalProperties, response.AdditionalProperties); } - messages.AddRange(this._danglingState.ComputeFlattened()); + if (this._danglingState.HasUpdates) + { + messages.AddRange(this._danglingState.ComputeFlattened()); + } // Remove any empty text contents or messages that are now empty. foreach (var m in messages) @@ -180,6 +111,7 @@ public AgentResponse ComputeMerged(string primaryResponseId, string? primaryAgen } } } + messages.RemoveAll(m => m.Contents.Count == 0); return new AgentResponse(messages) @@ -194,34 +126,6 @@ public AgentResponse ComputeMerged(string primaryResponseId, string? primaryAgen AdditionalProperties = additionalProperties }; - static AgentResponse MergeResponses(AgentResponse? current, AgentResponse incoming) - { - if (current is null) - { - return incoming; - } - - if (current.ResponseId != incoming.ResponseId) - { - throw new InvalidOperationException($"Cannot merge responses with different IDs: '{current.ResponseId}' and '{incoming.ResponseId}'."); - } - - List rawRepresentation = current.RawRepresentation as List ?? []; - rawRepresentation.Add(incoming.RawRepresentation); - - return new() - { - AgentId = incoming.AgentId ?? current.AgentId, - AdditionalProperties = MergeProperties(current.AdditionalProperties, incoming.AdditionalProperties), - CreatedAt = incoming.CreatedAt ?? current.CreatedAt, - FinishReason = incoming.FinishReason ?? current.FinishReason, - Messages = current.Messages.Concat(incoming.Messages).ToList(), - ResponseId = current.ResponseId, - RawRepresentation = rawRepresentation, - Usage = MergeUsage(current.Usage, incoming.Usage), - }; - } - static IEnumerable GetMessagesWithCreatedAt(AgentResponse response) { if (response.Messages.Count == 0) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs index 09d83966aa6..a6db4825fa2 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs @@ -71,4 +71,122 @@ public void Test_MessageMerger_PropagatesFinishReasonFromUpdates() // Assert - FinishReason from the update should propagate through response.FinishReason.Should().Be(ChatFinishReason.ContentFilter); } + + [Fact] + public void Test_MessageMerger_PreservesMessageOrderWhenReasoningLacksCreatedAt() + { + // Arrange: a reasoning model streams its reasoning summary first (without a CreatedAt + // timestamp) followed by the textual answer (with one). Both share a response id and carry + // distinct, explicit message ids, so they are legitimately two messages. This guards against + // ordering by CreatedAt, which would otherwise push the timestamp-less reasoning message + // after the text message. + string responseId = Guid.NewGuid().ToString("N"); + string reasoningMessageId = Guid.NewGuid().ToString("N"); + string textMessageId = Guid.NewGuid().ToString("N"); + + MessageMerger merger = new(); + + merger.AddUpdate(new AgentResponseUpdate + { + Role = ChatRole.Assistant, + ResponseId = responseId, + MessageId = reasoningMessageId, + Contents = [new TextReasoningContent("Thinking about the question")], + CreatedAt = null, + }); + + merger.AddUpdate(new AgentResponseUpdate + { + Role = ChatRole.Assistant, + ResponseId = responseId, + MessageId = textMessageId, + Contents = [new TextContent("Here is the answer.")], + CreatedAt = DateTimeOffset.UtcNow, + }); + + // Act + AgentResponse response = merger.ComputeMerged(responseId); + + // Assert - the reasoning message must remain first, matching a directly-invoked agent. + response.Messages.Should().HaveCount(2); + + response.Messages[0].Contents.Should().ContainSingle() + .Which.Should().BeOfType() + .Which.Text.Should().Be("Thinking about the question"); + + response.Messages[1].Contents.Should().ContainSingle() + .Which.Should().BeOfType() + .Which.Text.Should().Be("Here is the answer."); + } + + [Fact] + public void Test_MessageMerger_MergesReasoningAndTextIntoSingleMessageWhenReasoningLacksMessageId() + { + // Arrange: this mirrors the exact streaming shape captured from the workflow-as-agent repro + // in https://github.com/microsoft/agent-framework/issues/6329. A reasoning model (e.g. Azure + // OpenAI Responses) streams its reasoning summary first as several id-less updates (the + // Responses API emits reasoning updates with a null MessageId and no CreatedAt), followed by + // the textual answer carrying a real message id. All updates share the same response id. + // + // Previously the merger bucketed updates per MessageId and appended the id-less reasoning + // updates last, splitting one assistant message into two ([text], [reasoning]) in reversed + // order. Grouping is now delegated to M.E.AI, which keeps the reasoning in the same message + // as the text that follows it - exactly as a directly-invoked agent produces. + string responseId = "resp_" + Guid.NewGuid().ToString("N"); + string textMessageId = "msg_" + Guid.NewGuid().ToString("N"); + + MessageMerger merger = new(); + + // Reasoning summary: id-less updates without a CreatedAt timestamp. + merger.AddUpdate(new AgentResponseUpdate + { + Role = ChatRole.Assistant, + ResponseId = responseId, + MessageId = null, + Contents = [new TextReasoningContent("Thinking ")], + CreatedAt = null, + }); + merger.AddUpdate(new AgentResponseUpdate + { + Role = ChatRole.Assistant, + ResponseId = responseId, + MessageId = null, + Contents = [new TextReasoningContent("about the question")], + CreatedAt = null, + }); + + // Final answer: text updates carrying a real message id. + merger.AddUpdate(new AgentResponseUpdate + { + Role = ChatRole.Assistant, + ResponseId = responseId, + MessageId = textMessageId, + Contents = [new TextContent("Here is ")], + CreatedAt = DateTimeOffset.UtcNow, + }); + merger.AddUpdate(new AgentResponseUpdate + { + Role = ChatRole.Assistant, + ResponseId = responseId, + MessageId = textMessageId, + Contents = [new TextContent("the answer.")], + CreatedAt = DateTimeOffset.UtcNow, + }); + + // Act + AgentResponse response = merger.ComputeMerged(responseId); + + // Assert - a single assistant message with reasoning first, then the answer text. + response.Messages.Should().ContainSingle(); + + ChatMessage message = response.Messages[0]; + message.Role.Should().Be(ChatRole.Assistant); + message.Contents.Should().HaveCount(2); + + message.Contents[0].Should().BeOfType() + .Which.Text.Should().Be("Thinking about the question"); + + message.Contents[1].Should().BeOfType() + .Which.Text.Should().Be("Here is the answer."); + } } From 5724147b358fef2b1e167fa670baf19eb74128e9 Mon Sep 17 00:00:00 2001 From: Marco Minerva Date: Tue, 30 Jun 2026 15:07:06 +0200 Subject: [PATCH 2/7] Set CreatedAt from merged responses preservation of original message timestamps during merging. --- dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs index 2348f45d9aa..56d5708d3fe 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs @@ -121,7 +121,7 @@ public AgentResponse ComputeMerged(string primaryResponseId, string? primaryAgen ?? primaryAgentName ?? (agentIds.Count == 1 ? agentIds.First() : null), FinishReason = finishReasons.Count == 1 ? finishReasons.First() : null, - CreatedAt = DateTimeOffset.UtcNow, + CreatedAt = createdTimes.Count == 1 ? createdTimes.First() : null, Usage = usage, AdditionalProperties = additionalProperties }; From 13b29e6390fcdd1abf4fb6d00b7d9e341565dd5a Mon Sep 17 00:00:00 2001 From: Marco Minerva Date: Tue, 30 Jun 2026 15:16:24 +0200 Subject: [PATCH 3/7] Set merged message CreatedAt to current UTC time Removed logic for tracking unique creation times and now always assign DateTimeOffset.UtcNow to the merged response's CreatedAt property. This simplifies timestamp handling during message merging. --- dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs | 8 +------- 1 file changed, 1 insertion(+), 7 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs index 56d5708d3fe..5c3af623340 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs @@ -71,7 +71,6 @@ public AgentResponse ComputeMerged(string primaryResponseId, string? primaryAgen UsageDetails? usage = null; AdditionalPropertiesDictionary? additionalProperties = null; - HashSet createdTimes = []; foreach (AgentResponse response in responses.Values) { @@ -80,11 +79,6 @@ public AgentResponse ComputeMerged(string primaryResponseId, string? primaryAgen agentIds.Add(response.AgentId); } - if (response.CreatedAt.HasValue) - { - createdTimes.Add(response.CreatedAt.Value); - } - if (response.FinishReason.HasValue) { finishReasons.Add(response.FinishReason.Value); @@ -121,7 +115,7 @@ public AgentResponse ComputeMerged(string primaryResponseId, string? primaryAgen ?? primaryAgentName ?? (agentIds.Count == 1 ? agentIds.First() : null), FinishReason = finishReasons.Count == 1 ? finishReasons.First() : null, - CreatedAt = createdTimes.Count == 1 ? createdTimes.First() : null, + CreatedAt = DateTimeOffset.UtcNow, Usage = usage, AdditionalProperties = additionalProperties }; From 232b9f72fb15b860da80aea14e1b6f543d41d250 Mon Sep 17 00:00:00 2001 From: Marco Minerva Date: Thu, 16 Jul 2026 12:15:36 +0200 Subject: [PATCH 4/7] Refactor MessageMerger id-less folding logic Refactored MessageMerger to fold identifierless reasoning segments into the following id'd message at the flattened-message level, ensuring correct merging across response buckets (fixes #6329). Updated ComputeMerged to merge id-less messages with the next message of the same role. Removed redundant per-bucket folding logic. Added unit tests to verify correct folding behavior and role matching. --- .../MessageMerger.cs | 62 +++++---- .../MessageMergerTests.cs | 120 ++++++++++++++++++ 2 files changed, 156 insertions(+), 26 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs index 1cb56d6fae2..9ad4ccabb5a 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs @@ -61,36 +61,18 @@ private MessageMergeState GetOrCreateMessageState(string? messageId) public List ComputeMerged() { - // Message buckets keep their first-seen order. Grouping updates into messages is otherwise - // delegated to M.E.AI (ToAgentResponse), which coalesces contiguous updates by message id - // exactly like a directly-invoked agent. In addition, an id-less segment that is immediately - // followed by an id'd message of the same role is folded into that message. This keeps a - // reasoning summary that streams without a message id in the same assistant message as the - // text that follows it (see https://github.com/microsoft/agent-framework/issues/6329), - // while still separating id-less segments that belong to a different role. + // Message buckets keep their first-seen order. Grouping updates into messages is delegated + // to M.E.AI (ToAgentResponse), which coalesces contiguous updates by message id exactly like + // a directly-invoked agent. Folding an id-less segment (e.g. a streamed reasoning summary) + // into the following id'd message of the same role is handled once, at the flattened-message + // level in MessageMerger.ComputeMerged, so it works both within a single response bucket and + // across buckets (see https://github.com/microsoft/agent-framework/issues/6329). List ordered = this._messageStatesInOrder; List responses = new(ordered.Count); - int index = 0; - while (index < ordered.Count) + foreach (MessageMergeState current in ordered) { - MessageMergeState current = ordered[index]; - - if (current.MessageId is null && - index + 1 < ordered.Count && - ordered[index + 1].MessageId is not null && - current.Role == ordered[index + 1].Role) - { - MessageMergeState next = ordered[index + 1]; - List merged = [.. current.Updates, .. next.Updates]; - responses.Add(merged.ToAgentResponse()); - index += 2; - } - else - { - responses.Add(current.Updates.ToAgentResponse()); - index += 1; - } + responses.Add(current.Updates.ToAgentResponse()); } return responses; @@ -160,6 +142,34 @@ public AgentResponse ComputeMerged(string primaryResponseId, string? primaryAgen messages.AddRange(this._danglingState.ComputeFlattened()); + // Fold an id-less message that is immediately followed by an id'd message of the same role + // into that message. A streamed reasoning summary often arrives without a message id and, when + // an agent is hosted inside a workflow, can land in a different response bucket than the answer + // text that follows it. The per-response fold cannot merge across buckets, so we also fold here + // at the flattened-message level to keep the reasoning and the answer in a single assistant + // message (see https://github.com/microsoft/agent-framework/issues/6329). + for (int i = 0; i < messages.Count - 1; i++) + { + ChatMessage current = messages[i]; + ChatMessage next = messages[i + 1]; + + if (current.MessageId is null && next.MessageId is not null && current.Role == next.Role) + { + messages[i + 1] = new ChatMessage + { + Role = next.Role, + AuthorName = next.AuthorName ?? current.AuthorName, + Contents = [.. current.Contents, .. next.Contents], + MessageId = next.MessageId, + CreatedAt = current.CreatedAt ?? next.CreatedAt, + RawRepresentation = next.RawRepresentation, + AdditionalProperties = next.AdditionalProperties, + }; + messages.RemoveAt(i); + i--; + } + } + // Remove any empty text contents or messages that are now empty. foreach (var m in messages) { diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs index eabac3bb902..416086ac28b 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs @@ -202,6 +202,79 @@ public void Test_MessageMerger_SeparatesIdentifierlessSegments() response.Messages.Select(message => message.Text).Should().Equal("AB", "X", "Y"); } + [Fact] + public void Test_MessageMerger_FoldsIdentifierlessReasoningIntoFollowingMessage() + { + // Arrange - a streamed reasoning summary arrives without a message id, immediately + // followed by the actual answer that carries a message id (same assistant role). + // See https://github.com/microsoft/agent-framework/issues/6329. + const string ResponseId = "response"; + const string MessageId = "msg_answer"; + MessageMerger merger = new(); + + merger.AddUpdate(new AgentResponseUpdate + { + ResponseId = ResponseId, + Role = ChatRole.Assistant, + Contents = [new TextReasoningContent("thinking about the question")], + }); + merger.AddUpdate(new AgentResponseUpdate + { + ResponseId = ResponseId, + MessageId = MessageId, + Role = ChatRole.Assistant, + Contents = [new TextContent("The reformulated question.")], + }); + + // Act + AgentResponse response = merger.ComputeMerged(ResponseId); + + // Assert - reasoning and answer should be folded into a single message with two contents, + // adopting the following message's id. + response.Messages.Should().HaveCount(1); + ChatMessage message = response.Messages[0]; + message.Role.Should().Be(ChatRole.Assistant); + message.MessageId.Should().Be(MessageId); + message.Contents.Should().HaveCount(2); + message.Contents[0].Should().BeOfType() + .Which.Text.Should().Be("thinking about the question"); + message.Contents[1].Should().BeOfType() + .Which.Text.Should().Be("The reformulated question."); + message.Text.Should().Be("The reformulated question."); + } + + [Fact] + public void Test_MessageMerger_DoesNotFoldIdentifierlessReasoningIntoDifferentRole() + { + // Arrange - an id-less segment is only folded when the following message shares its role. + const string ResponseId = "response"; + const string MessageId = "msg_tool"; + MessageMerger merger = new(); + + merger.AddUpdate(new AgentResponseUpdate + { + ResponseId = ResponseId, + Role = ChatRole.Assistant, + Contents = [new TextReasoningContent("thinking")], + }); + merger.AddUpdate(new AgentResponseUpdate + { + ResponseId = ResponseId, + MessageId = MessageId, + Role = ChatRole.Tool, + Contents = [new FunctionResultContent("call", "done")], + }); + + // Act + AgentResponse response = merger.ComputeMerged(ResponseId); + + // Assert - different roles must remain separate messages. + response.Messages.Should().HaveCount(2); + response.Messages[0].Role.Should().Be(ChatRole.Assistant); + response.Messages[0].Contents.Should().ContainSingle().Which.Should().BeOfType(); + response.Messages[1].Role.Should().Be(ChatRole.Tool); + } + private static void AddTextMessage(MessageMerger merger, string responseId, string text, DateTimeOffset? createdAt = null) { merger.AddUpdate(new AgentResponseUpdate @@ -331,4 +404,51 @@ public void Test_MessageMerger_MergesReasoningAndTextIntoSingleMessageWhenReason message.Contents[1].Should().BeOfType() .Which.Text.Should().Be("Here is the answer."); } + + [Fact] + public void Test_MessageMerger_FoldsIdentifierlessReasoningIntoFollowingMessageAcrossResponseBuckets() + { + // Arrange: this reproduces the workflow-as-agent repro where a reasoning summary and the + // answer text end up in DIFFERENT response buckets (distinct response ids). The per-response + // fold cannot merge across buckets, so this exercises the flattened-message fold in the outer + // ComputeMerged. See https://github.com/microsoft/agent-framework/issues/6329. + const string ReasoningResponseId = "resp_reasoning"; + const string TextResponseId = "resp_text"; + const string TextMessageId = "msg_answer"; + + MessageMerger merger = new(); + + // Reasoning summary: id-less update in its own response bucket, seen first. + merger.AddUpdate(new AgentResponseUpdate + { + Role = ChatRole.Assistant, + ResponseId = ReasoningResponseId, + MessageId = null, + Contents = [new TextReasoningContent("thinking about the question")], + }); + + // Final answer: text update carrying a real message id in a different response bucket. + merger.AddUpdate(new AgentResponseUpdate + { + Role = ChatRole.Assistant, + ResponseId = TextResponseId, + MessageId = TextMessageId, + Contents = [new TextContent("The reformulated question.")], + }); + + // Act + AgentResponse response = merger.ComputeMerged(TextResponseId); + + // Assert - a single assistant message adopting the answer's id, reasoning first then text. + response.Messages.Should().ContainSingle(); + ChatMessage message = response.Messages[0]; + message.Role.Should().Be(ChatRole.Assistant); + message.MessageId.Should().Be(TextMessageId); + message.Contents.Should().HaveCount(2); + message.Contents[0].Should().BeOfType() + .Which.Text.Should().Be("thinking about the question"); + message.Contents[1].Should().BeOfType() + .Which.Text.Should().Be("The reformulated question."); + message.Text.Should().Be("The reformulated question."); + } } From e0fddc580542563b695bb58024cde3207bbaf9af Mon Sep 17 00:00:00 2001 From: Marco Minerva Date: Fri, 17 Jul 2026 09:30:48 +0200 Subject: [PATCH 5/7] Remove unused property Removed the unused Role property from MessageMergeState for code cleanliness. --- .../Microsoft.Agents.AI.Workflows/MessageMerger.cs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs index 9ad4ccabb5a..0289d934a1b 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs @@ -14,10 +14,6 @@ private sealed class MessageMergeState(string? messageId) public string? MessageId { get; } = messageId; public List Updates { get; } = []; - - // The role of the message is taken from its first update. Buckets always contain at least - // one update by the time they are read. - public ChatRole? Role => this.Updates.Count > 0 ? this.Updates[0].Role : null; } private sealed class ResponseMergeState(string? responseId) @@ -128,12 +124,12 @@ public AgentResponse ComputeMerged(string primaryResponseId, string? primaryAgen { if (response.AgentId is not null) { - agentIds.Add(response.AgentId); + _ = agentIds.Add(response.AgentId); } if (response.FinishReason.HasValue) { - finishReasons.Add(response.FinishReason.Value); + _ = finishReasons.Add(response.FinishReason.Value); } usage = MergeUsage(usage, response.Usage); @@ -182,7 +178,8 @@ public AgentResponse ComputeMerged(string primaryResponseId, string? primaryAgen } } } - messages.RemoveAll(m => m.Contents.Count == 0); + + _ = messages.RemoveAll(m => m.Contents.Count == 0); return new AgentResponse(messages) { From 8fdbcd17d7997314a1c1c529a748b90d8764df51 Mon Sep 17 00:00:00 2001 From: Marco Minerva Date: Fri, 17 Jul 2026 10:37:48 +0200 Subject: [PATCH 6/7] Refactor MessageMerger to iterate backward for merging Changed MessageMerger to iterate messages in reverse order, ensuring all consecutive messages without IDs preceding a message with an ID are merged correctly. Updated merging logic, index handling, and comments to reflect this new approach. --- .../Microsoft.Agents.AI.Workflows/MessageMerger.cs | 14 ++++++++------ 1 file changed, 8 insertions(+), 6 deletions(-) diff --git a/dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs b/dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs index 0289d934a1b..f656b1aa068 100644 --- a/dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs +++ b/dotnet/src/Microsoft.Agents.AI.Workflows/MessageMerger.cs @@ -144,14 +144,17 @@ public AgentResponse ComputeMerged(string primaryResponseId, string? primaryAgen // text that follows it. The per-response fold cannot merge across buckets, so we also fold here // at the flattened-message level to keep the reasoning and the answer in a single assistant // message (see https://github.com/microsoft/agent-framework/issues/6329). - for (int i = 0; i < messages.Count - 1; i++) + // We iterate backward so that a run of consecutive id-less messages preceding an id'd message + // all cascade into that message: once folded, the merged message adopts next.MessageId, so a + // forward pass would never re-examine the preceding id-less entry. + for (int i = messages.Count - 1; i > 0; i--) { - ChatMessage current = messages[i]; - ChatMessage next = messages[i + 1]; + ChatMessage current = messages[i - 1]; + ChatMessage next = messages[i]; if (current.MessageId is null && next.MessageId is not null && current.Role == next.Role) { - messages[i + 1] = new ChatMessage + messages[i] = new ChatMessage { Role = next.Role, AuthorName = next.AuthorName ?? current.AuthorName, @@ -161,8 +164,7 @@ public AgentResponse ComputeMerged(string primaryResponseId, string? primaryAgen RawRepresentation = next.RawRepresentation, AdditionalProperties = next.AdditionalProperties, }; - messages.RemoveAt(i); - i--; + messages.RemoveAt(i - 1); } } From 941f0a73c1f93cd12f9156c54e816354df0179d7 Mon Sep 17 00:00:00 2001 From: Marco Minerva Date: Fri, 17 Jul 2026 10:51:44 +0200 Subject: [PATCH 7/7] Update code comment to better reflect its behavior. --- .../MessageMergerTests.cs | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs index 416086ac28b..30c8a6941cd 100644 --- a/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs +++ b/dotnet/tests/Microsoft.Agents.AI.Workflows.UnitTests/MessageMergerTests.cs @@ -345,8 +345,10 @@ public void Test_MessageMerger_MergesReasoningAndTextIntoSingleMessageWhenReason // // Previously the merger bucketed updates per MessageId and appended the id-less reasoning // updates last, splitting one assistant message into two ([text], [reasoning]) in reversed - // order. Grouping is now delegated to M.E.AI, which keeps the reasoning in the same message - // as the text that follows it - exactly as a directly-invoked agent produces. + // order. Now M.E.AI (using ToAgentResponse) only groups contiguous updates sharing a MessageId, + // while the explicit fold loop in ComputeMerged folds the id-less reasoning into the id'd + // text message that follows it - keeping them in a single assistant message, exactly as a + // directly-invoked agent produces. string responseId = "resp_" + Guid.NewGuid().ToString("N"); string textMessageId = "msg_" + Guid.NewGuid().ToString("N");