fix: align orchestration behavior with agent framework docs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-24 22:09:52 +01:00
co-authored by Copilot
parent cf6a25a9da
commit 5ab0b22d15
7 changed files with 235 additions and 17 deletions
@@ -47,8 +47,12 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
await foreach (WorkflowEvent evt in run.WatchStreamAsync(cancellationToken).ConfigureAwait(false))
{
await HandleWorkflowEventAsync(command, evt, inputMessages, state, onDelta, onActivity)
bool shouldEndTurn = await HandleWorkflowEventAsync(command, evt, inputMessages, state, onDelta, onActivity)
.ConfigureAwait(false);
if (shouldEndTurn)
{
break;
}
}
return state.FinalizeCompletedMessages();
@@ -61,7 +65,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
return _approvalCoordinator.ResolveApprovalAsync(command, cancellationToken);
}
private static async Task HandleWorkflowEventAsync(
private static async Task<bool> HandleWorkflowEventAsync(
RunTurnCommandDto command,
WorkflowEvent evt,
IReadOnlyList<ChatMessage> inputMessages,
@@ -76,7 +80,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
out AgentIdentity invokedAgent))
{
await state.EmitThinkingIfNeeded(invokedAgent, onActivity).ConfigureAwait(false);
return;
return false;
}
if (evt is RequestInfoEvent requestInfo)
@@ -89,18 +93,18 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
if (activity is null)
{
return;
return WorkflowRequestInfoInterpreter.RequiresUserInputTurnBoundary(command, requestInfo);
}
state.ApplyActivity(activity);
await onActivity(activity).ConfigureAwait(false);
return;
return false;
}
if (evt is AgentResponseUpdateEvent update)
{
await HandleAgentResponseUpdateAsync(command, update, state, onDelta, onActivity).ConfigureAwait(false);
return;
return false;
}
if (evt is ExecutorCompletedEvent completed
@@ -111,7 +115,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
out AgentIdentity completedAgent))
{
state.ClearActiveAgentIfMatching(completedAgent);
return;
return false;
}
if (evt is WorkflowOutputEvent outputEvent)
@@ -119,6 +123,8 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
List<ChatMessage> allMessages = outputEvent.As<List<ChatMessage>>() ?? [];
state.UpdateCompletedMessages(allMessages, inputMessages);
}
return false;
}
private static async Task HandleAgentResponseUpdateAsync(
@@ -52,6 +52,23 @@ internal static class WorkflowRequestInfoInterpreter
};
}
public static bool RequiresUserInputTurnBoundary(
RunTurnCommandDto command,
RequestInfoEvent requestInfo)
{
if (!string.Equals(command.Pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase))
{
return false;
}
if (TryGetHandoffTarget(command.Pattern, requestInfo, out _))
{
return false;
}
return !TryGetToolRequestInfo(requestInfo, out _, out _);
}
private static bool TryGetHandoffTarget(
PatternDefinitionDto pattern,
RequestInfoEvent requestInfo,
@@ -156,6 +156,143 @@ public sealed class CopilotWorkflowRunnerTests
Assert.Equal("Hello", message.Content);
}
[Fact]
public void ProjectCompletedMessages_PreservesSequentialConversationHistory()
{
RunTurnCommandDto command = new()
{
RequestId = "turn-1",
SessionId = "session-1",
Pattern = new PatternDefinitionDto
{
Id = "pattern-sequential",
Name = "Sequential Trio Review",
Mode = "sequential",
Availability = "available",
Agents =
[
CreateAgent(id: "agent-sequential-analyst", name: "Analyst"),
CreateAgent(id: "agent-sequential-builder", name: "Builder"),
CreateAgent(id: "agent-sequential-reviewer", name: "Reviewer"),
],
},
};
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
command,
[
new ChatMessage(ChatRole.Assistant, "Plan the approach.") { AuthorName = "Analyst" },
new ChatMessage(ChatRole.Assistant, "Implement the change.") { AuthorName = "Builder" },
new ChatMessage(ChatRole.Assistant, "Review the implementation.") { AuthorName = "Reviewer" },
],
[]);
Assert.Collection(
messages,
analyst =>
{
Assert.Equal("Analyst", analyst.AuthorName);
Assert.Equal("Plan the approach.", analyst.Content);
},
builder =>
{
Assert.Equal("Builder", builder.AuthorName);
Assert.Equal("Implement the change.", builder.Content);
},
reviewer =>
{
Assert.Equal("Reviewer", reviewer.AuthorName);
Assert.Equal("Review the implementation.", reviewer.Content);
});
}
[Fact]
public void ProjectCompletedMessages_PreservesConcurrentAggregatedResponses()
{
RunTurnCommandDto command = new()
{
RequestId = "turn-1",
SessionId = "session-1",
Pattern = new PatternDefinitionDto
{
Id = "pattern-concurrent",
Name = "Concurrent Brainstorm",
Mode = "concurrent",
Availability = "available",
Agents =
[
CreateAgent(id: "agent-concurrent-architect", name: "Architect"),
CreateAgent(id: "agent-concurrent-product", name: "Product"),
CreateAgent(id: "agent-concurrent-implementer", name: "Implementer"),
],
},
};
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
command,
[
new ChatMessage(ChatRole.Assistant, "Architecture concerns.") { AuthorName = "Architect" },
new ChatMessage(ChatRole.Assistant, "Product trade-offs.") { AuthorName = "Product" },
new ChatMessage(ChatRole.Assistant, "Implementation details.") { AuthorName = "Implementer" },
],
[]);
Assert.Collection(
messages,
architect => Assert.Equal("Architect", architect.AuthorName),
product => Assert.Equal("Product", product.AuthorName),
implementer => Assert.Equal("Implementer", implementer.AuthorName));
}
[Fact]
public void ProjectCompletedMessages_PreservesGroupChatConversationHistory()
{
RunTurnCommandDto command = new()
{
RequestId = "turn-1",
SessionId = "session-1",
Pattern = new PatternDefinitionDto
{
Id = "pattern-group-chat",
Name = "Collaborative Group Chat",
Mode = "group-chat",
Availability = "available",
Agents =
[
CreateAgent(id: "agent-group-writer", name: "Writer"),
CreateAgent(id: "agent-group-reviewer", name: "Reviewer"),
],
},
};
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
command,
[
new ChatMessage(ChatRole.Assistant, "Initial draft.") { AuthorName = "Writer" },
new ChatMessage(ChatRole.Assistant, "Needs clearer examples.") { AuthorName = "Reviewer" },
new ChatMessage(ChatRole.Assistant, "Revised draft with examples.") { AuthorName = "Writer" },
],
[]);
Assert.Collection(
messages,
writerDraft =>
{
Assert.Equal("Writer", writerDraft.AuthorName);
Assert.Equal("Initial draft.", writerDraft.Content);
},
reviewer =>
{
Assert.Equal("Reviewer", reviewer.AuthorName);
Assert.Equal("Needs clearer examples.", reviewer.Content);
},
writerRevision =>
{
Assert.Equal("Writer", writerRevision.AuthorName);
Assert.Equal("Revised draft with examples.", writerRevision.Content);
});
}
[Fact]
public void ProjectCompletedMessages_UsesFallbackAgentForGenericAssistantOutput()
{
@@ -109,6 +109,62 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.Empty(toolNamesByCallId);
}
[Fact]
public void RequiresUserInputTurnBoundary_ReturnsTrueForUnhandledHandoffRequests()
{
RequestInfoEvent requestInfo = CreateRequestInfoEvent(new
{
Prompt = "Please provide more detail.",
});
bool requiresBoundary = WorkflowRequestInfoInterpreter.RequiresUserInputTurnBoundary(
CreateHandoffCommand(),
requestInfo);
Assert.True(requiresBoundary);
}
[Fact]
public void RequiresUserInputTurnBoundary_ReturnsFalseForExplicitHandoffs()
{
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
CreateHandoffTarget("agent-handoff-ux", "UX Specialist"));
bool requiresBoundary = WorkflowRequestInfoInterpreter.RequiresUserInputTurnBoundary(
CreateHandoffCommand(),
requestInfo);
Assert.False(requiresBoundary);
}
[Fact]
public void RequiresUserInputTurnBoundary_ReturnsFalseForToolRequests()
{
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>()));
bool requiresBoundary = WorkflowRequestInfoInterpreter.RequiresUserInputTurnBoundary(
CreateHandoffCommand(),
requestInfo);
Assert.False(requiresBoundary);
}
[Fact]
public void RequiresUserInputTurnBoundary_ReturnsFalseOutsideHandoffMode()
{
RequestInfoEvent requestInfo = CreateRequestInfoEvent(new
{
Prompt = "Please provide more detail.",
});
bool requiresBoundary = WorkflowRequestInfoInterpreter.RequiresUserInputTurnBoundary(
CreateSingleAgentCommand(),
requestInfo);
Assert.False(requiresBoundary);
}
private static RunTurnCommandDto CreateSingleAgentCommand()
{
return new RunTurnCommandDto