Fix multi-step handoff message ordering in non-streaming RunAsync

HandoffAgentExecutor synthesizes a 'Transferred.' tool-result update for
each handoff function call. That update was created without setting
ResponseId, so MessageMerger routed it to the global dangling bucket and
flushed all such tool results at the very end of the merged
AgentResponse, breaking per-step grouping for multi-step handoffs (see
#4544). Streaming output already preserved order because updates are
yielded directly without going through the merger.

Fix: stamp the synthesized update with the same ResponseId as the
preceding agent stream updates so it groups with the agent's other
messages in MessageMerger.

Also adds a regression test that drives the real workflow through
WorkflowHostAgent.RunAsync over a 3-agent handoff chain and asserts the
per-step message ordering of the merged response.
This commit is contained in:
copilot-swe-agent[bot]
2026-05-28 11:48:24 +00:00
committed by GitHub
Unverified
parent 4e7a46b3f1
commit 56b8307589
2 changed files with 98 additions and 0 deletions
@@ -433,6 +433,18 @@ internal sealed class HandoffAgentExecutor :
FunctionCallContent handoffRequest = candidateRequests[candidateRequests.Count - 1];
requestedHandoff = handoffRequest.Name;
// Stamp the synthetic "Transferred." tool-result update with the same
// ResponseId as the agent's preceding updates so it groups with the
// rest of this agent's step in MessageMerger (and therefore in
// RunAsync's merged AgentResponse and in chat history). Without this,
// the synthetic update goes to MessageMerger's null-ResponseId
// "dangling" bucket and surfaces after every keyed response, which
// re-orders multi-step handoff transcripts versus streaming output
// (issue #4544).
string? syntheticResponseId = updates
.Select(u => u.ResponseId)
.LastOrDefault(id => id is not null);
await AddUpdateAsync(
new AgentResponseUpdate
{
@@ -441,6 +453,7 @@ internal sealed class HandoffAgentExecutor :
Contents = [CreateHandoffResult(handoffRequest.CallId)],
CreatedAt = DateTimeOffset.UtcNow,
MessageId = Guid.NewGuid().ToString("N"),
ResponseId = syntheticResponseId,
Role = ChatRole.Tool,
},
cancellationToken
@@ -304,6 +304,91 @@ public class HandoffOrchestrationTests
Assert.DoesNotContain(capturedThirdAgentMessages, m => m.Role == ChatRole.Tool && m.Contents.Any(c => c is FunctionResultContent));
}
[Fact]
public async Task Handoffs_MultipleTransfers_MergedMessagesPreserveStepOrderAsync()
{
// Regression test for https://github.com/microsoft/agent-framework/issues/4544
//
// Scenario: a multi-step handoff (A -> B -> C) is run through a workflow
// exposed as an AIAgent. When invoked via the non-streaming RunAsync
// entry-point, the merged AgentResponse.Messages must keep each step's
// messages contiguous — in particular, the "Transferred." tool result
// synthesized for a handoff function call must appear immediately after
// the function call that triggered it, before any messages from later
// steps. Streaming output already preserves this order; the bug
// manifested only after merging, where the synthesized tool results were
// bunched at the end of the response, far from their originating function
// calls (and consequently corrupted ChatHistory order).
//
// Each underlying agent response sets a real ResponseId — that is what
// causes MessageMerger to group its updates under that key. If the
// synthesized Tool update is emitted without that ResponseId, the merger
// routes it to the global "dangling" bucket and flushes it last,
// breaking per-step grouping.
var initialAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
{
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
Assert.NotNull(transferFuncName);
return new(new ChatMessage(ChatRole.Assistant, [new TextContent("Routing to second agent"), new FunctionCallContent("call1", transferFuncName)]) { MessageId = "msg-initial" }) { ResponseId = "resp-initial" };
}), name: "initialAgent");
var secondAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
{
string? transferFuncName = options?.Tools?.FirstOrDefault(t => t.Name.StartsWith("handoff_to_", StringComparison.Ordinal))?.Name;
Assert.NotNull(transferFuncName);
return new(new ChatMessage(ChatRole.Assistant, [new TextContent("Routing to third agent"), new FunctionCallContent("call2", transferFuncName)]) { MessageId = "msg-second" }) { ResponseId = "resp-second" };
}), name: "secondAgent", description: "The second agent");
var thirdAgent = new ChatClientAgent(new MockChatClient((messages, options) =>
new(new ChatMessage(ChatRole.Assistant, "Hello from agent3") { MessageId = "msg-third" }) { ResponseId = "resp-third" }),
name: "thirdAgent",
description: "The third / final agent");
var workflow =
AgentWorkflowBuilder.CreateHandoffBuilderWith(initialAgent)
.WithHandoff(initialAgent, secondAgent)
.WithHandoff(secondAgent, thirdAgent)
.Build();
AIAgent hostAgent = workflow.AsAIAgent(name: "HandoffWorkflow");
AgentResponse response = await hostAgent.RunAsync("abc");
List<ChatMessage> result = response.Messages.ToList();
// Expected merged sequence keeps each step contiguous:
// [0] Assistant (initialAgent): text + FunctionCall(call1)
// [1] Tool (initialAgent): FunctionResult(call1, "Transferred.")
// [2] Assistant (secondAgent): text + FunctionCall(call2)
// [3] Tool (secondAgent): FunctionResult(call2, "Transferred.")
// [4] Assistant (thirdAgent): "Hello from agent3"
//
// The bug surfaced as the two Tool messages being moved to the end of the
// list — after thirdAgent's reply — which broke chat-history ordering.
Assert.Equal(5, result.Count);
Assert.Equal(ChatRole.Assistant, result[0].Role);
Assert.Contains("initialAgent", result[0].AuthorName);
Assert.Contains(result[0].Contents, c => c is FunctionCallContent fcc && fcc.CallId == "call1");
Assert.Equal(ChatRole.Tool, result[1].Role);
Assert.Contains("initialAgent", result[1].AuthorName);
Assert.Contains(result[1].Contents, c => c is FunctionResultContent frc && frc.CallId == "call1");
Assert.Equal(ChatRole.Assistant, result[2].Role);
Assert.Contains("secondAgent", result[2].AuthorName);
Assert.Contains(result[2].Contents, c => c is FunctionCallContent fcc && fcc.CallId == "call2");
Assert.Equal(ChatRole.Tool, result[3].Role);
Assert.Contains("secondAgent", result[3].AuthorName);
Assert.Contains(result[3].Contents, c => c is FunctionResultContent frc && frc.CallId == "call2");
Assert.Equal(ChatRole.Assistant, result[4].Role);
Assert.Contains("thirdAgent", result[4].AuthorName);
Assert.Equal("Hello from agent3", result[4].Text);
}
[Fact]
public async Task Handoffs_FilteringNone_HandoffTargetReceivesAllMessagesIncludingToolCallsAsync()
{