Files
aryx/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs
T

2910 lines
107 KiB
C#

using System.IO;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Text.Json;
using Aryx.AgentHost.Contracts;
using Aryx.AgentHost.Services;
using GitHub.Copilot.SDK;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.InProc;
using Microsoft.Extensions.AI;
namespace Aryx.AgentHost.Tests;
public sealed class CopilotWorkflowRunnerTests
{
[Fact]
public void ConfigureHookLifecycleEventSuppression_SetsStateFromBundle()
{
RunTurnCommandDto command = CreateApprovalCommand();
CopilotTurnExecutionState state = new(command);
CopilotAgentBundle bundle = new(Array.Empty<AIAgent>(), hasConfiguredHooks: false);
CopilotWorkflowRunner.ConfigureHookLifecycleEventSuppression(state, bundle);
Assert.True(state.SuppressHookLifecycleEvents);
}
[Fact]
public void BuildWorkflowForCommand_UsesGroupChatBuilderForGroupChatMode()
{
RunTurnCommandDto command = CreateCommand(
"group-chat",
modeSettings: new OrchestrationModeSettingsDto
{
GroupChat = new GroupChatModeSettingsDto
{
SelectionStrategy = "round-robin",
MaxRounds = 6,
},
},
workflowName: "Collaborative Review",
workflowDescription: "Two agents collaborate under a group chat manager.",
agents:
[
CreateAgent("agent-group-writer", "Writer"),
CreateAgent("agent-group-reviewer", "Reviewer"),
]);
Workflow workflow = CopilotWorkflowRunner.BuildWorkflowForCommand(
command,
[
CreateChatClientAgent("agent-group-writer", "Writer"),
CreateChatClientAgent("agent-group-reviewer", "Reviewer"),
]);
Assert.Equal("Collaborative Review", workflow.Name);
Assert.Equal("Two agents collaborate under a group chat manager.", workflow.Description);
}
[Fact]
public async Task BuildWorkflowForCommand_UsesHandoffBuilderForHandoffMode()
{
RunTurnCommandDto command = CreateCommand(
"handoff",
modeSettings: new OrchestrationModeSettingsDto
{
Handoff = new HandoffModeSettingsDto
{
TriageAgentNodeId = "agent-handoff-triage",
ToolCallFiltering = "handoff-only",
},
},
agents:
[
CreateAgent("agent-handoff-triage", "Triage"),
CreateAgent("agent-handoff-runtime", "Runtime Specialist"),
]);
Workflow workflow = CopilotWorkflowRunner.BuildWorkflowForCommand(
command,
[
CreateChatClientAgent("agent-handoff-triage", "Triage"),
CreateChatClientAgent("agent-handoff-runtime", "Runtime Specialist"),
]);
ProtocolDescriptor descriptor = await workflow.DescribeProtocolAsync();
Assert.Contains(descriptor.Yields, candidate => candidate == typeof(List<ChatMessage>));
}
[Fact]
public async Task BuildWorkflowForCommand_UsesGraphWorkflowRunnerForGraphModes()
{
RunTurnCommandDto command = CreateGraphWorkflowCommand();
Workflow workflow = CopilotWorkflowRunner.BuildWorkflowForCommand(
command,
[
CreateChatClientAgent("agent-primary", "Primary Agent"),
]);
ProtocolDescriptor descriptor = await workflow.DescribeProtocolAsync();
Assert.Contains(descriptor.Yields, candidate => candidate == typeof(List<ChatMessage>));
}
[Fact]
public void SelectNewOutputMessages_SkipsFullTranscriptPrefix()
{
List<ChatMessage> inputMessages =
[
new(ChatRole.User, "Hello"),
];
List<ChatMessage> outputMessages =
[
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi there."),
];
IReadOnlyList<ChatMessage> newMessages = WorkflowTranscriptProjector.SelectNewOutputMessages(
outputMessages,
inputMessages);
ChatMessage message = Assert.Single(newMessages);
Assert.Equal(ChatRole.Assistant, message.Role);
Assert.Equal("Hi there.", message.Text);
}
[Fact]
public void SelectNewOutputMessages_SkipsOnlyTheLatestInputOverlap()
{
List<ChatMessage> inputMessages =
[
new(ChatRole.Assistant, "Earlier answer"),
new(ChatRole.User, "Hello"),
];
List<ChatMessage> outputMessages =
[
new(ChatRole.User, "Hello"),
new(ChatRole.Assistant, "Hi there."),
];
IReadOnlyList<ChatMessage> newMessages = WorkflowTranscriptProjector.SelectNewOutputMessages(
outputMessages,
inputMessages);
ChatMessage message = Assert.Single(newMessages);
Assert.Equal(ChatRole.Assistant, message.Role);
Assert.Equal("Hi there.", message.Text);
}
[Fact]
public void SelectNewOutputMessages_PreservesAssistantOnlyOutput()
{
List<ChatMessage> inputMessages =
[
new(ChatRole.User, "Hello"),
];
List<ChatMessage> outputMessages =
[
new(ChatRole.Assistant, "Hi there."),
];
IReadOnlyList<ChatMessage> newMessages = WorkflowTranscriptProjector.SelectNewOutputMessages(
outputMessages,
inputMessages);
ChatMessage message = Assert.Single(newMessages);
Assert.Equal(ChatRole.Assistant, message.Role);
Assert.Equal("Hi there.", message.Text);
}
[Fact]
public void ProjectCompletedMessages_FallsBackToStreamingSegmentsWhenWorkflowOutputIsMissing()
{
RunTurnCommandDto command = CreateCommand(
"concurrent",
CreateAgent(id: "agent-concurrent-architect", name: "Architect"),
CreateAgent(id: "agent-concurrent-implementer", name: "Implementer"));
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
command,
[],
[
("msg-1", "Architect", "Architecture reply"),
("msg-2", "Implementer", "Implementation reply"),
]);
Assert.Collection(
messages,
architect =>
{
Assert.Equal("msg-1", architect.Id);
Assert.Equal("Architect", architect.AuthorName);
Assert.Equal("Architecture reply", architect.Content);
},
implementer =>
{
Assert.Equal("msg-2", implementer.Id);
Assert.Equal("Implementer", implementer.AuthorName);
Assert.Equal("Implementation reply", implementer.Content);
});
}
[Fact]
public void ProjectCompletedMessages_CanonicalizesWorkflowOutputAuthorNames()
{
RunTurnCommandDto command = CreateCommand(
"single",
CreateAgent(id: "agent-single-primary", name: "Primary Agent"));
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
command,
[
new ChatMessage(ChatRole.Assistant, "Hello")
{
AuthorName = "assistant",
},
],
[
("msg-1", "Primary Agent", "Hello"),
]);
ChatMessageDto message = Assert.Single(messages);
Assert.Equal("msg-1", message.Id);
Assert.Equal("Primary Agent", message.AuthorName);
Assert.Equal("Hello", message.Content);
}
[Fact]
public void ProjectCompletedMessages_UsesFinalAssistantPayloadWhenStreamingTextIsMissing()
{
RunTurnCommandDto command = CreateCommand(
"single",
CreateAgent(id: "agent-single-primary", name: "Primary Agent"));
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
command,
[
new ChatMessage(ChatRole.Assistant, string.Empty)
{
MessageId = "msg-1",
RawRepresentation = new AssistantMessageEvent
{
Data = new AssistantMessageData
{
MessageId = "msg-1",
Content = "Hello from the final assistant payload.",
},
},
},
],
[]);
ChatMessageDto message = Assert.Single(messages);
Assert.Equal("msg-1", message.Id);
Assert.Equal("Primary Agent", message.AuthorName);
Assert.Equal("Hello from the final assistant payload.", message.Content);
}
[Fact]
public void ProjectCompletedMessages_PreservesSequentialConversationHistory()
{
RunTurnCommandDto command = CreateCommand(
"sequential",
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 = CreateCommand(
"concurrent",
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_ConcurrentUsesLastStreamedMessagePerAgentForGenericOutput()
{
RunTurnCommandDto command = CreateCommand(
"concurrent",
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 with cleaner wording.")
{
AuthorName = "assistant",
},
new ChatMessage(ChatRole.Assistant, "Product trade-offs with cleaned bullets.")
{
AuthorName = "assistant",
},
new ChatMessage(ChatRole.Assistant, "Implementation details with cleaned formatting.")
{
AuthorName = "assistant",
},
],
[
("msg-arch-1", "Architect", "Architecture concerns."),
("msg-prod-1", "Product", "Product trade-offs."),
("msg-impl-1", "Implementer", "Implementation details."),
("msg-arch-2", "Architect", "Architecture concerns with extra draft spacing."),
("msg-prod-2", "Product", "Product trade-offs with extra draft bullets."),
("msg-impl-2", "Implementer", "Implementation details with extra draft formatting."),
]);
Assert.Collection(
messages,
architect =>
{
Assert.Equal("msg-arch-2", architect.Id);
Assert.Equal("Architect", architect.AuthorName);
Assert.Equal("Architecture concerns with cleaner wording.", architect.Content);
},
product =>
{
Assert.Equal("msg-prod-2", product.Id);
Assert.Equal("Product", product.AuthorName);
Assert.Equal("Product trade-offs with cleaned bullets.", product.Content);
},
implementer =>
{
Assert.Equal("msg-impl-2", implementer.Id);
Assert.Equal("Implementer", implementer.AuthorName);
Assert.Equal("Implementation details with cleaned formatting.", implementer.Content);
});
}
[Fact]
public void ProjectCompletedMessages_PreservesGroupChatConversationHistory()
{
RunTurnCommandDto command = CreateCommand(
"group-chat",
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_FallsBackToPositionWhenOutputTextDiffers()
{
RunTurnCommandDto command = CreateCommand(
"group-chat",
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 with cleaner wording.")
{
AuthorName = "assistant",
},
new ChatMessage(ChatRole.Assistant, "Review feedback with cleaner wording.")
{
AuthorName = "assistant",
},
],
[
("msg-1", "Writer", "Initial draft with extra draft wording."),
("msg-2", "Reviewer", "Review feedback with extra draft wording."),
]);
Assert.Collection(
messages,
writer =>
{
Assert.Equal("msg-1", writer.Id);
Assert.Equal("Writer", writer.AuthorName);
Assert.Equal("Initial draft with cleaner wording.", writer.Content);
},
reviewer =>
{
Assert.Equal("msg-2", reviewer.Id);
Assert.Equal("Reviewer", reviewer.AuthorName);
Assert.Equal("Review feedback with cleaner wording.", reviewer.Content);
});
}
[Fact]
public void ProjectCompletedMessages_UsesExactMessageIdMatchBeforeFallbackHeuristics()
{
RunTurnCommandDto command = CreateCommand(
"group-chat",
CreateAgent(id: "agent-group-writer", name: "Writer"),
CreateAgent(id: "agent-group-reviewer", name: "Reviewer"));
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessagesFromSegments(
command,
[
new ChatMessage(ChatRole.Assistant, "Revised draft with cleaner wording.")
{
AuthorName = "assistant",
MessageId = "msg-writer-2",
},
new ChatMessage(ChatRole.Assistant, "Review feedback with cleaner wording.")
{
AuthorName = "assistant",
MessageId = "msg-reviewer-1",
},
],
[
new TranscriptSegment("msg-writer-1", "Writer", "Initial draft."),
new TranscriptSegment("msg-reviewer-1", "Reviewer", "Review feedback with draft wording."),
new TranscriptSegment("msg-writer-2", "Writer", "Revised draft with draft wording."),
]);
Assert.Collection(
messages,
writer =>
{
Assert.Equal("msg-writer-2", writer.Id);
Assert.Equal("Writer", writer.AuthorName);
Assert.Equal("Revised draft with cleaner wording.", writer.Content);
},
reviewer =>
{
Assert.Equal("msg-reviewer-1", reviewer.Id);
Assert.Equal("Reviewer", reviewer.AuthorName);
Assert.Equal("Review feedback with cleaner wording.", reviewer.Content);
});
}
[Fact]
public void ProjectCompletedMessages_UsesFallbackAgentForGenericAssistantOutput()
{
RunTurnCommandDto command = CreateCommand(
"handoff",
CreateAgent(id: "agent-handoff-triage", name: "Triage"),
CreateAgent(id: "agent-handoff-ux", name: "UX Specialist"));
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
command,
[
new ChatMessage(ChatRole.Assistant, "The button is in place.")
{
AuthorName = "assistant",
},
],
[],
new AgentIdentity("agent-handoff-ux", "UX Specialist"));
ChatMessageDto message = Assert.Single(messages);
Assert.Equal("UX Specialist", message.AuthorName);
Assert.Equal("The button is in place.", message.Content);
}
[Fact]
public void ProjectCompletedMessages_PrefersContentMatchedStreamingSegmentOverPosition()
{
RunTurnCommandDto command = CreateCommand(
"handoff",
CreateAgent(id: "agent-handoff-triage", name: "Triage"),
CreateAgent(id: "agent-handoff-runtime", name: "Runtime Specialist"));
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
command,
[
new ChatMessage(ChatRole.Assistant, "Done — GoogleClient.cs now contains the requested class.")
{
AuthorName = "assistant",
},
],
[
("msg-1", "Triage", "I'll hand this to a coding specialist."),
("msg-2", "Runtime Specialist", "Done — GoogleClient.cs now contains the requested class."),
],
new AgentIdentity("agent-handoff-runtime", "Runtime Specialist"));
ChatMessageDto message = Assert.Single(messages);
Assert.Equal("msg-2", message.Id);
Assert.Equal("Runtime Specialist", message.AuthorName);
Assert.Equal("Done — GoogleClient.cs now contains the requested class.", message.Content);
}
[Fact]
public void ProjectCompletedMessages_UsesFallbackAgentForSingleGenericAssistantOutputWithMultipleSegments()
{
RunTurnCommandDto command = CreateCommand(
"handoff",
CreateAgent(id: "agent-handoff-triage", name: "Triage"),
CreateAgent(id: "agent-handoff-runtime", name: "Runtime Specialist"));
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
command,
[
new ChatMessage(ChatRole.Assistant, "Done — GoogleClient.cs now contains the requested class with cleaned formatting.")
{
AuthorName = "assistant",
},
],
[
("msg-1", "Triage", "I'll hand this to a coding specialist."),
("msg-2", "Runtime Specialist", "Done — GoogleClient.cs now contains the requested class with draft formatting."),
],
new AgentIdentity("agent-handoff-runtime", "Runtime Specialist"));
ChatMessageDto message = Assert.Single(messages);
Assert.Equal("msg-2", message.Id);
Assert.Equal("Runtime Specialist", message.AuthorName);
Assert.Equal("Done — GoogleClient.cs now contains the requested class with cleaned formatting.", message.Content);
}
[Fact]
public void ProjectCompletedMessages_PrefersFinalizedSegmentContentOverWorkflowOutput()
{
RunTurnCommandDto command = CreateCommand(
"single",
CreateAgent(id: "agent-single-primary", name: "Primary Agent"));
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessagesFromSegments(
command,
[
new ChatMessage(ChatRole.Assistant, "Workflow wording that arrived later.")
{
AuthorName = "assistant",
MessageId = "msg-1",
},
],
[
new TranscriptSegment(
"msg-1",
"Primary Agent",
"Provider final wording that should win.",
IsFinalized: true),
]);
ChatMessageDto message = Assert.Single(messages);
Assert.Equal("msg-1", message.Id);
Assert.Equal("Primary Agent", message.AuthorName);
Assert.Equal("Provider final wording that should win.", message.Content);
}
[Fact]
public void ProjectCompletedMessages_DropsBlankAssistantOutputMessages()
{
RunTurnCommandDto command = CreateCommand(
"handoff",
CreateAgent(id: "agent-handoff-triage", name: "Triage"),
CreateAgent(id: "agent-handoff-ux", name: "UX Specialist"));
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
command,
[
new ChatMessage(ChatRole.Assistant, string.Empty)
{
AuthorName = "assistant",
},
new ChatMessage(ChatRole.Assistant, "Real content")
{
AuthorName = "assistant",
},
],
[],
new AgentIdentity("agent-handoff-ux", "UX Specialist"));
ChatMessageDto message = Assert.Single(messages);
Assert.Equal("UX Specialist", message.AuthorName);
Assert.Equal("Real content", message.Content);
}
[Fact]
public void StreamingTranscriptBuffer_MergesUpdatesPerMessageId()
{
StreamingTranscriptBuffer buffer = new();
buffer.AppendDelta("msg-1", "Architect", "Hello");
(string messageId, string authorName, string content) = buffer.AppendDelta("msg-1", "Architect", " world");
Assert.Equal("msg-1", messageId);
Assert.Equal("Architect", authorName);
Assert.Equal("Hello world", content);
Assert.Collection(
buffer.Snapshot(),
segment =>
{
Assert.Equal("msg-1", segment.MessageId);
Assert.Equal("Architect", segment.AuthorName);
Assert.Equal("Hello world", segment.Content);
});
}
[Fact]
public void StreamingTranscriptBuffer_PreservesInsertionOrderAcrossMessages()
{
StreamingTranscriptBuffer buffer = new();
buffer.AppendDelta("msg-1", "Architect", "A");
buffer.AppendDelta("msg-2", "Implementer", "B");
buffer.AppendDelta("msg-1", "Architect", " plus");
Assert.Collection(
buffer.Snapshot(),
first =>
{
Assert.Equal("msg-1", first.MessageId);
Assert.Equal("Architect", first.AuthorName);
Assert.Equal("A plus", first.Content);
},
second =>
{
Assert.Equal("msg-2", second.MessageId);
Assert.Equal("Implementer", second.AuthorName);
Assert.Equal("B", second.Content);
});
}
[Fact]
public void StreamingTranscriptBuffer_IgnoresLateDeltasAfterFinalSnapshot()
{
StreamingTranscriptBuffer buffer = new();
buffer.AppendDelta("msg-1", "Architect", "Draft");
Assert.True(buffer.TryApplySnapshot("msg-1", "Architect", "Final", out TranscriptSegment finalized));
Assert.True(finalized.IsFinalized);
Assert.Equal("Final", finalized.Content);
Assert.False(buffer.TryAppendDelta("msg-1", "Architect", " late delta", out TranscriptSegment unchanged));
Assert.True(unchanged.IsFinalized);
Assert.Equal("Final", unchanged.Content);
}
[Fact]
public void ObserveSessionEvent_TracksObservedAgentByMessageId()
{
CopilotTurnExecutionState state = new(CreateHandoffCommand());
SessionEvent sessionEvent = SessionEvent.FromJson(
"""
{
"type": "assistant.message_delta",
"data": {
"messageId": "msg-7",
"deltaContent": "Done."
},
"id": "11111111-1111-1111-1111-111111111111",
"timestamp": "2026-03-24T00:00:00Z"
}
""");
state.ObserveSessionEvent(CreateAgent("agent-handoff-ux", "UX Specialist"), sessionEvent);
Assert.True(state.TryResolveObservedAgentForMessage("msg-7", out AgentIdentity observedAgent));
Assert.Equal("agent-handoff-ux", observedAgent.AgentId);
Assert.Equal("UX Specialist", observedAgent.AgentName);
Assert.Equal("agent-handoff-ux", state.ActiveAgent?.AgentId);
AgentActivityEventDto activity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
Assert.Equal("thinking", activity.ActivityType);
Assert.Equal("agent-handoff-ux", activity.AgentId);
}
[Fact]
public void ObserveSessionEvent_UsesReasoningDeltasToTrackActiveAgent()
{
CopilotTurnExecutionState state = new(CreateHandoffCommand());
SessionEvent sessionEvent = SessionEvent.FromJson(
"""
{
"type": "assistant.reasoning_delta",
"data": {
"reasoningId": "reasoning-1",
"deltaContent": "Planning."
},
"id": "22222222-2222-2222-2222-222222222222",
"timestamp": "2026-03-24T00:00:00Z"
}
""");
state.ObserveSessionEvent(CreateAgent("agent-handoff-ux", "UX Specialist"), sessionEvent);
Assert.Equal("agent-handoff-ux", state.ActiveAgent?.AgentId);
Assert.Equal("UX Specialist", state.ActiveAgent?.AgentName);
AgentActivityEventDto activity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
Assert.Equal("thinking", activity.ActivityType);
Assert.Equal("agent-handoff-ux", activity.AgentId);
}
[Fact]
public async Task HandleWorkflowEventAsync_EmitsThinkingForHandoffTargets()
{
RunTurnCommandDto command = CreateHandoffCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
CreateAgent("agent-handoff-triage", "Triage"),
SessionEvent.FromJson(
"""
{
"type": "assistant.reasoning_delta",
"data": {
"reasoningId": "reasoning-1",
"deltaContent": "Delegating."
},
"id": "33333333-3333-3333-3333-333333333333",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
_ = state.DrainPendingEvents();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
CreateHandoffTarget("agent-handoff-ux", "UX Specialist"));
List<AgentActivityEventDto> activities = [];
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
"HandleWorkflowEventAsync",
BindingFlags.NonPublic | BindingFlags.Static)!;
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
null,
[
command,
requestInfo,
Array.Empty<ChatMessage>(),
state,
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
(Func<SidecarEventDto, Task>)(sidecarEvent =>
{
activities.Add(Assert.IsType<AgentActivityEventDto>(sidecarEvent));
return Task.CompletedTask;
}),
])!;
bool shouldEndTurn = await handleTask;
Assert.False(shouldEndTurn);
Assert.Collection(
activities,
handoff =>
{
Assert.Equal("handoff", handoff.ActivityType);
Assert.Equal("agent-handoff-ux", handoff.AgentId);
Assert.Equal("UX Specialist", handoff.AgentName);
Assert.Equal("agent-handoff-triage", handoff.SourceAgentId);
},
thinking =>
{
Assert.Equal("thinking", thinking.ActivityType);
Assert.Equal("agent-handoff-ux", thinking.AgentId);
Assert.Equal("UX Specialist", thinking.AgentName);
});
}
[Fact]
public async Task HandleWorkflowEventAsync_EmitsToolActivityEnrichmentWhenRequestInfoAddsMissingArguments()
{
RunTurnCommandDto command = CreateApprovalCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
CreateAgent("agent-1", "Primary"),
SessionEvent.FromJson(
"""
{
"type": "tool.execution_start",
"data": {
"toolCallId": "tool-call-1",
"toolName": "view"
},
"id": "f61652d1-120e-4a9f-8f0e-1dbf04fb18da",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
_ = state.DrainPendingEvents();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("tool-call-1", "view", new Dictionary<string, object?>
{
["path"] = @"C:\workspace\README.md",
}));
List<AgentActivityEventDto> activities = [];
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
"HandleWorkflowEventAsync",
BindingFlags.NonPublic | BindingFlags.Static)!;
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
null,
[
command,
requestInfo,
Array.Empty<ChatMessage>(),
state,
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
(Func<SidecarEventDto, Task>)(sidecarEvent =>
{
activities.Add(Assert.IsType<AgentActivityEventDto>(sidecarEvent));
return Task.CompletedTask;
}),
])!;
bool shouldEndTurn = await handleTask;
Assert.False(shouldEndTurn);
AgentActivityEventDto activity = Assert.Single(activities);
Assert.Equal("tool-calling", activity.ActivityType);
Assert.Equal("view", activity.ToolName);
Assert.Equal("tool-call-1", activity.ToolCallId);
Assert.NotNull(activity.ToolArguments);
Assert.Equal(@"C:\workspace\README.md", activity.ToolArguments["path"]);
Assert.True(state.ToolCalls.HasTrackedArguments("tool-call-1"));
}
[Fact]
public async Task ObserveSessionEvent_ToolExecutionStart_DoesNotDuplicateTrackedRequestInfoActivity()
{
RunTurnCommandDto command = CreateApprovalCommand();
CopilotTurnExecutionState state = new(command);
WorkflowNodeDto agent = CreateAgent("agent-1", "Primary");
state.ObserveSessionEvent(
agent,
SessionEvent.FromJson(
"""
{
"type": "assistant.message_delta",
"data": {
"messageId": "msg-1",
"deltaContent": "Inspecting"
},
"id": "b61652d1-120e-4a9f-8f0e-1dbf04fb18da",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
_ = state.DrainPendingEvents();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("tool-call-1", "view", new Dictionary<string, object?>
{
["path"] = @"C:\workspace\README.md",
}));
List<AgentActivityEventDto> requestActivities = [];
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
"HandleWorkflowEventAsync",
BindingFlags.NonPublic | BindingFlags.Static)!;
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
null,
[
command,
requestInfo,
Array.Empty<ChatMessage>(),
state,
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
(Func<SidecarEventDto, Task>)(sidecarEvent =>
{
requestActivities.Add(Assert.IsType<AgentActivityEventDto>(sidecarEvent));
return Task.CompletedTask;
}),
])!;
bool shouldEndTurn = await handleTask;
Assert.False(shouldEndTurn);
AgentActivityEventDto requestActivity = Assert.Single(requestActivities);
Assert.Equal("tool-calling", requestActivity.ActivityType);
Assert.True(state.ToolCalls.HasTrackedArguments("tool-call-1"));
state.ObserveSessionEvent(
agent,
SessionEvent.FromJson(
"""
{
"type": "tool.execution_start",
"data": {
"toolCallId": "tool-call-1",
"toolName": "view",
"arguments": {
"path": "C:\\workspace\\README.md"
}
},
"id": "c61652d1-120e-4a9f-8f0e-1dbf04fb18da",
"timestamp": "2026-03-27T00:00:01Z"
}
"""));
IReadOnlyList<SidecarEventDto> pending = state.DrainPendingEvents();
Assert.DoesNotContain(
pending.OfType<AgentActivityEventDto>(),
activity => activity.ActivityType == "tool-calling");
MessageReclassifiedEventDto reclassified = Assert.Single(pending.OfType<MessageReclassifiedEventDto>());
Assert.Equal("msg-1", reclassified.MessageId);
}
[Fact]
public async Task ObserveSessionEvent_ToolExecutionStart_EmitsEnrichmentWhenRequestInfoWasMissingArguments()
{
RunTurnCommandDto command = CreateApprovalCommand();
CopilotTurnExecutionState state = new(command);
WorkflowNodeDto agent = CreateAgent("agent-1", "Primary");
state.ObserveSessionEvent(
agent,
SessionEvent.FromJson(
"""
{
"type": "assistant.message_delta",
"data": {
"messageId": "msg-2",
"deltaContent": "Inspecting"
},
"id": "d61652d1-120e-4a9f-8f0e-1dbf04fb18da",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
_ = state.DrainPendingEvents();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("tool-call-1", "view", new Dictionary<string, object?>()));
List<AgentActivityEventDto> requestActivities = [];
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
"HandleWorkflowEventAsync",
BindingFlags.NonPublic | BindingFlags.Static)!;
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
null,
[
command,
requestInfo,
Array.Empty<ChatMessage>(),
state,
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
(Func<SidecarEventDto, Task>)(sidecarEvent =>
{
requestActivities.Add(Assert.IsType<AgentActivityEventDto>(sidecarEvent));
return Task.CompletedTask;
}),
])!;
bool shouldEndTurn = await handleTask;
Assert.False(shouldEndTurn);
AgentActivityEventDto requestActivity = Assert.Single(requestActivities);
Assert.Null(requestActivity.ToolArguments);
Assert.False(state.ToolCalls.HasTrackedArguments("tool-call-1"));
state.ObserveSessionEvent(
agent,
SessionEvent.FromJson(
"""
{
"type": "tool.execution_start",
"data": {
"toolCallId": "tool-call-1",
"toolName": "view",
"arguments": {
"path": "C:\\workspace\\README.md"
}
},
"id": "e61652d1-120e-4a9f-8f0e-1dbf04fb18da",
"timestamp": "2026-03-27T00:00:01Z"
}
"""));
AgentActivityEventDto enrichment = Assert.Single(
state.DrainPendingEvents().OfType<AgentActivityEventDto>(),
activity => activity.ActivityType == "tool-calling");
Assert.NotNull(enrichment.ToolArguments);
Assert.Equal(@"C:\workspace\README.md", enrichment.ToolArguments["path"]);
Assert.True(state.ToolCalls.HasTrackedArguments("tool-call-1"));
}
[Fact]
public void CreateExecutionEnvironment_UsesLockstepWhenRequested()
{
RunTurnCommandDto command = new()
{
Workflow = new WorkflowDefinitionDto
{
Settings = new WorkflowSettingsDto
{
ExecutionMode = "lockstep",
Checkpointing = new WorkflowCheckpointSettingsDto(),
},
},
};
InProcessExecutionEnvironment environment = CopilotWorkflowRunner.CreateExecutionEnvironment(command, checkpointManager: null);
Assert.Same(InProcessExecution.Lockstep, environment);
}
[Fact]
public void CoerceRequestPortResponse_ParsesSupportedResponseTypes()
{
Assert.Equal("hello", CopilotWorkflowRunner.CoerceRequestPortResponse("string", "hello"));
Assert.Equal(true, CopilotWorkflowRunner.CoerceRequestPortResponse("bool", "yes"));
Assert.Equal(12.5d, CopilotWorkflowRunner.CoerceRequestPortResponse("number", "12.5"));
JsonElement json = Assert.IsType<JsonElement>(CopilotWorkflowRunner.CoerceRequestPortResponse("json", "{\"ok\":true}"));
Assert.True(json.GetProperty("ok").GetBoolean());
}
[Fact]
public async Task RunTurnAsync_RequestPortWorkflowUsesUserInputBridge()
{
CopilotWorkflowRunner runner = new(new WorkflowValidator());
List<UserInputRequestedEventDto> requests = [];
IReadOnlyList<ChatMessageDto> messages = await runner.RunTurnAsync(
CreateRequestPortCommand(),
_ => Task.CompletedTask,
_ => Task.CompletedTask,
_ => Task.CompletedTask,
async request =>
{
requests.Add(request);
await runner.ResolveUserInputAsync(
new ResolveUserInputCommandDto
{
UserInputId = request.UserInputId,
Answer = "approved",
WasFreeform = true,
},
CancellationToken.None);
},
_ => Task.CompletedTask,
_ => Task.CompletedTask,
CancellationToken.None);
UserInputRequestedEventDto requestEvent = Assert.Single(requests);
Assert.Equal("Needs approval?", requestEvent.Question);
Assert.Null(requestEvent.AgentId);
ChatMessageDto message = Assert.Single(messages);
Assert.Equal("Workflow", message.AuthorName);
Assert.Equal("approved", message.Content);
}
[Fact]
public async Task HandleWorkflowEventAsync_FallsBackToActiveAgentForUnresolvedStreamingUpdates()
{
RunTurnCommandDto command = CreateHandoffCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
CreateAgent("agent-handoff-ux", "UX Specialist"),
SessionEvent.FromJson(
"""
{
"type": "assistant.reasoning_delta",
"data": {
"reasoningId": "reasoning-1",
"deltaContent": "Polishing the UI."
},
"id": "77777777-7777-7777-7777-777777777777",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
_ = state.DrainPendingEvents();
List<TurnDeltaEventDto> deltas = [];
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
"HandleWorkflowEventAsync",
BindingFlags.NonPublic | BindingFlags.Static)!;
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
null,
[
command,
new AgentResponseUpdateEvent(
"copilot-executor-ux",
new AgentResponseUpdate(ChatRole.Assistant, "The button is ready.")
{
MessageId = "msg-ux-1",
}),
Array.Empty<ChatMessage>(),
state,
(Func<TurnDeltaEventDto, Task>)(delta =>
{
deltas.Add(delta);
return Task.CompletedTask;
}),
(Func<SidecarEventDto, Task>)(_ => Task.CompletedTask),
])!;
bool shouldEndTurn = await handleTask;
Assert.False(shouldEndTurn);
TurnDeltaEventDto delta = Assert.Single(deltas);
Assert.Equal("msg-ux-1", delta.MessageId);
Assert.Equal("UX Specialist", delta.AuthorName);
Assert.Equal("The button is ready.", delta.ContentDelta);
Assert.Equal("The button is ready.", delta.Content);
}
[Fact]
public async Task HandleWorkflowEventAsync_EmitsWorkflowCheckpointSavedEvent()
{
RunTurnCommandDto command = CreateHandoffCommand();
command = new RunTurnCommandDto
{
RequestId = command.RequestId,
SessionId = command.SessionId,
Workflow = new WorkflowDefinitionDto
{
Id = command.Workflow.Id,
Name = command.Workflow.Name,
Graph = command.Workflow.Graph,
Settings = new WorkflowSettingsDto
{
OrchestrationMode = command.Workflow.Settings.OrchestrationMode,
Checkpointing = new WorkflowCheckpointSettingsDto
{
Enabled = true,
},
},
},
};
CopilotTurnExecutionState state = new(command);
List<WorkflowCheckpointSavedEventDto> checkpoints = [];
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
"HandleWorkflowEventAsync",
BindingFlags.NonPublic | BindingFlags.Static)!;
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
null,
[
command,
new SuperStepCompletedEvent(
3,
new SuperStepCompletionInfo([])
{
Checkpoint = new CheckpointInfo(command.RequestId, "checkpoint-1"),
}),
Array.Empty<ChatMessage>(),
state,
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
(Func<SidecarEventDto, Task>)(sidecarEvent =>
{
checkpoints.Add(Assert.IsType<WorkflowCheckpointSavedEventDto>(sidecarEvent));
return Task.CompletedTask;
}),
])!;
bool shouldEndTurn = await handleTask;
Assert.False(shouldEndTurn);
WorkflowCheckpointSavedEventDto checkpoint = Assert.Single(checkpoints);
Assert.Equal("workflow-checkpoint-saved", checkpoint.Type);
Assert.Equal(command.SessionId, checkpoint.SessionId);
Assert.Equal(command.RequestId, checkpoint.WorkflowSessionId);
Assert.Equal("checkpoint-1", checkpoint.CheckpointId);
Assert.Equal(3, checkpoint.StepNumber);
Assert.EndsWith(
Path.Combine("Aryx", "workflow-checkpoints", command.SessionId, command.RequestId),
checkpoint.StorePath);
}
[Fact]
public async Task HandleWorkflowEventAsync_EmitsExecutorFailedDiagnostic()
{
RunTurnCommandDto command = CreateApprovalCommand();
CopilotTurnExecutionState state = new(command);
List<WorkflowDiagnosticEventDto> diagnostics = [];
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
"HandleWorkflowEventAsync",
BindingFlags.NonPublic | BindingFlags.Static)!;
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
null,
[
command,
new ExecutorFailedEvent("agent-1", new InvalidOperationException("Tool crashed.")),
Array.Empty<ChatMessage>(),
state,
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
(Func<SidecarEventDto, Task>)(sidecarEvent =>
{
diagnostics.Add(Assert.IsType<WorkflowDiagnosticEventDto>(sidecarEvent));
return Task.CompletedTask;
}),
])!;
bool shouldEndTurn = await handleTask;
Assert.False(shouldEndTurn);
WorkflowDiagnosticEventDto diagnostic = Assert.Single(diagnostics);
Assert.Equal("workflow-diagnostic", diagnostic.Type);
Assert.Equal("error", diagnostic.Severity);
Assert.Equal("executor-failed", diagnostic.DiagnosticKind);
Assert.Equal("Tool crashed.", diagnostic.Message);
Assert.Equal("agent-1", diagnostic.AgentId);
Assert.Equal("Primary", diagnostic.AgentName);
Assert.Equal("agent-1", diagnostic.ExecutorId);
Assert.Equal("InvalidOperationException", diagnostic.ExceptionType);
}
[Fact]
public async Task HandleWorkflowEventAsync_QueuesCompletedActivityForCompletedExecutor()
{
RunTurnCommandDto command = CreateApprovalCommand();
CopilotTurnExecutionState state = new(command);
WorkflowNodeDto primaryAgent = command.Workflow.GetAgentNodes()[0];
state.ObserveSessionEvent(
primaryAgent,
SessionEvent.FromJson(
"""
{
"type": "assistant.message_delta",
"data": {
"messageId": "msg-1",
"deltaContent": "Working"
},
"id": "11111111-1111-1111-1111-111111111111",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
_ = state.DrainPendingEvents();
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
"HandleWorkflowEventAsync",
BindingFlags.NonPublic | BindingFlags.Static)!;
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
null,
[
command,
new ExecutorCompletedEvent("agent-1", null),
Array.Empty<ChatMessage>(),
state,
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
(Func<SidecarEventDto, Task>)(_ => Task.CompletedTask),
])!;
bool shouldEndTurn = await handleTask;
Assert.False(shouldEndTurn);
Assert.Null(state.ActiveAgent);
AgentActivityEventDto completed = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
Assert.Equal("completed", completed.ActivityType);
Assert.Equal("agent-1", completed.AgentId);
Assert.Equal("Primary", completed.AgentName);
}
[Fact]
public async Task HandleWorkflowEventAsync_EmitsSubworkflowStartedActivityForSubworkflowExecutor()
{
RunTurnCommandDto command = CreateReferencedSubworkflowCommand();
CopilotTurnExecutionState state = new(command);
List<AgentActivityEventDto> activities = [];
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
"HandleWorkflowEventAsync",
BindingFlags.NonPublic | BindingFlags.Static)!;
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
null,
[
command,
new ExecutorInvokedEvent("subworkflow-review", null!),
Array.Empty<ChatMessage>(),
state,
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
(Func<SidecarEventDto, Task>)(sidecarEvent =>
{
activities.Add(Assert.IsType<AgentActivityEventDto>(sidecarEvent));
return Task.CompletedTask;
}),
])!;
bool shouldEndTurn = await handleTask;
Assert.False(shouldEndTurn);
AgentActivityEventDto activity = Assert.Single(activities);
Assert.Equal("subworkflow-started", activity.ActivityType);
Assert.Null(activity.AgentId);
Assert.Null(activity.AgentName);
Assert.Equal("subworkflow-review", activity.SubworkflowNodeId);
Assert.Equal("Review Lane", activity.SubworkflowName);
}
[Fact]
public async Task HandleWorkflowEventAsync_EmitsSubworkflowCompletedActivityForSubworkflowExecutor()
{
RunTurnCommandDto command = CreateReferencedSubworkflowCommand();
CopilotTurnExecutionState state = new(command);
List<AgentActivityEventDto> activities = [];
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
"HandleWorkflowEventAsync",
BindingFlags.NonPublic | BindingFlags.Static)!;
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
null,
[
command,
new ExecutorCompletedEvent("subworkflow-review", null),
Array.Empty<ChatMessage>(),
state,
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
(Func<SidecarEventDto, Task>)(sidecarEvent =>
{
activities.Add(Assert.IsType<AgentActivityEventDto>(sidecarEvent));
return Task.CompletedTask;
}),
])!;
bool shouldEndTurn = await handleTask;
Assert.False(shouldEndTurn);
AgentActivityEventDto activity = Assert.Single(activities);
Assert.Equal("subworkflow-completed", activity.ActivityType);
Assert.Null(activity.AgentId);
Assert.Null(activity.AgentName);
Assert.Equal("subworkflow-review", activity.SubworkflowNodeId);
Assert.Equal("Review Lane", activity.SubworkflowName);
}
[Fact]
public async Task HandleWorkflowEventAsync_EmitsWorkflowWarningDiagnostic()
{
RunTurnCommandDto command = CreateApprovalCommand();
CopilotTurnExecutionState state = new(command);
List<WorkflowDiagnosticEventDto> diagnostics = [];
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
"HandleWorkflowEventAsync",
BindingFlags.NonPublic | BindingFlags.Static)!;
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
null,
[
command,
new WorkflowWarningEvent("Token budget is nearly exhausted."),
Array.Empty<ChatMessage>(),
state,
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
(Func<SidecarEventDto, Task>)(sidecarEvent =>
{
diagnostics.Add(Assert.IsType<WorkflowDiagnosticEventDto>(sidecarEvent));
return Task.CompletedTask;
}),
])!;
bool shouldEndTurn = await handleTask;
Assert.False(shouldEndTurn);
WorkflowDiagnosticEventDto diagnostic = Assert.Single(diagnostics);
Assert.Equal("warning", diagnostic.Severity);
Assert.Equal("workflow-warning", diagnostic.DiagnosticKind);
Assert.Equal("Token budget is nearly exhausted.", diagnostic.Message);
Assert.Null(diagnostic.SubworkflowId);
Assert.Null(diagnostic.ExceptionType);
}
[Fact]
public async Task HandleWorkflowEventAsync_EmitsSubworkflowErrorDiagnostic()
{
RunTurnCommandDto command = CreateApprovalCommand();
CopilotTurnExecutionState state = new(command);
List<WorkflowDiagnosticEventDto> diagnostics = [];
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
"HandleWorkflowEventAsync",
BindingFlags.NonPublic | BindingFlags.Static)!;
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
null,
[
command,
new SubworkflowErrorEvent("subworkflow-review", new InvalidOperationException("Reviewer agent failed.")),
Array.Empty<ChatMessage>(),
state,
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
(Func<SidecarEventDto, Task>)(sidecarEvent =>
{
diagnostics.Add(Assert.IsType<WorkflowDiagnosticEventDto>(sidecarEvent));
return Task.CompletedTask;
}),
])!;
bool shouldEndTurn = await handleTask;
Assert.False(shouldEndTurn);
WorkflowDiagnosticEventDto diagnostic = Assert.Single(diagnostics);
Assert.Equal("error", diagnostic.Severity);
Assert.Equal("subworkflow-error", diagnostic.DiagnosticKind);
Assert.Equal("Reviewer agent failed.", diagnostic.Message);
Assert.Equal("subworkflow-review", diagnostic.SubworkflowId);
Assert.Equal("InvalidOperationException", diagnostic.ExceptionType);
}
[Fact]
public void RequiresToolCallApproval_HonorsAutoApprovedToolNames()
{
ApprovalPolicyDto policy = new()
{
Rules =
[
new ApprovalCheckpointRuleDto
{
Kind = "tool-call",
AgentIds = ["agent-1"],
},
],
AutoApprovedToolNames = ["lsp_ts_hover", "web_fetch"],
};
Assert.False(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-1", "lsp_ts_hover"));
Assert.False(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-1", "web_fetch"));
Assert.True(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-1", "lsp_ts_definition"));
Assert.True(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-1", null));
Assert.False(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-2", "lsp_ts_definition"));
}
[Fact]
public void RequiresToolCallApproval_HonorsRuntimeApprovalAliases()
{
ApprovalPolicyDto policy = new()
{
Rules =
[
new ApprovalCheckpointRuleDto
{
Kind = "tool-call",
AgentIds = ["agent-1"],
},
],
AutoApprovedToolNames = ["read", "store_memory"],
};
Assert.False(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-1", "view", "read"));
Assert.False(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-1", "remember_fact", "store_memory"));
Assert.True(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-1", "write_file", "write"));
Assert.True(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-1", "git.status"));
}
[Fact]
public void RequiresToolCallApproval_HonorsMcpServerLevelApprovalKey()
{
ApprovalPolicyDto policy = new()
{
Rules =
[
new ApprovalCheckpointRuleDto
{
Kind = "tool-call",
},
],
AutoApprovedToolNames = ["mcp_server:Git MCP"],
};
// Server-level key approves any tool from that server
Assert.False(CopilotApprovalCoordinator.RequiresToolCallApproval(
policy, "agent-1", "git.status", null, "mcp_server:Git MCP"));
Assert.False(CopilotApprovalCoordinator.RequiresToolCallApproval(
policy, "agent-1", "git.diff", null, "mcp_server:Git MCP"));
// Different server still requires approval
Assert.True(CopilotApprovalCoordinator.RequiresToolCallApproval(
policy, "agent-1", "fs.read", null, "mcp_server:Filesystem"));
// Non-MCP tools unaffected
Assert.True(CopilotApprovalCoordinator.RequiresToolCallApproval(
policy, "agent-1", "unknown_tool"));
}
[Fact]
public void TryGetApprovalToolName_ResolvesDirectNamesAndRuntimeFallbacks()
{
Assert.True(
CopilotApprovalCoordinator.TryGetApprovalToolName(
new PermissionRequestMcp
{
Kind = "mcp",
ServerName = "Git MCP",
ToolName = "git.status",
ToolTitle = "Git Status",
ReadOnly = true,
},
out string? mcpToolName));
Assert.Equal("git.status", mcpToolName);
Assert.True(
CopilotApprovalCoordinator.TryGetApprovalToolName(
new PermissionRequestCustomTool
{
Kind = "custom tool",
ToolName = "lsp_ts_hover",
ToolDescription = "Hover information",
},
out string? customToolName));
Assert.Equal("lsp_ts_hover", customToolName);
Assert.True(
CopilotApprovalCoordinator.TryGetApprovalToolName(
new PermissionRequestHook
{
Kind = "hook",
ToolName = "web_fetch",
ToolArgs = """{"url":"https://example.com"}""",
HookMessage = "Review required before fetch",
},
out string? hookToolName));
Assert.Equal("web_fetch", hookToolName);
Assert.True(
CopilotApprovalCoordinator.TryGetApprovalToolName(
new PermissionRequestShell
{
Kind = "shell",
FullCommandText = "git status",
Intention = "Inspect repository state",
Commands = [],
PossiblePaths = [],
PossibleUrls = [],
HasWriteFileRedirection = false,
CanOfferSessionApproval = false,
},
out string? shellToolName));
Assert.Equal("shell", shellToolName);
}
[Fact]
public void TryGetApprovalToolName_FallsBackToRuntimeApprovalAliasesWhenLookupMissing()
{
Assert.True(
CopilotApprovalCoordinator.TryGetApprovalToolName(
new PermissionRequestRead
{
Kind = "read",
ToolCallId = "tool-call-read",
Intention = "Inspect a file",
Path = "README.md",
},
out string? readToolName));
Assert.Equal("read", readToolName);
Assert.True(
CopilotApprovalCoordinator.TryGetApprovalToolName(
new PermissionRequestWrite
{
Kind = "write",
ToolCallId = "tool-call-write",
Intention = "Update a file",
FileName = "README.md",
Diff = "@@ -1 +1 @@",
},
out string? writeToolName));
Assert.Equal("write", writeToolName);
Assert.True(
CopilotApprovalCoordinator.TryGetApprovalToolName(
new PermissionRequestMemory
{
Kind = "memory",
ToolCallId = "tool-call-memory",
Subject = "repo conventions",
Fact = "Use Bun for script execution.",
Citations = "package.json",
},
out string? memoryToolName));
Assert.Equal("store_memory", memoryToolName);
}
[Fact]
public void TryGetApprovalToolName_UsesToolCallLookupForPermissionCategoriesWithoutDirectToolNames()
{
ToolCallRegistry toolCalls = CreateToolCallRegistry(
("tool-call-url", "web_fetch"),
("tool-call-shell", "shell"),
("tool-call-read", "view"),
("tool-call-write", "write_file"),
("tool-call-memory", "store_memory"));
Assert.True(
CopilotApprovalCoordinator.TryGetApprovalToolName(
new PermissionRequestUrl
{
Kind = "url",
ToolCallId = "tool-call-url",
Intention = "Fetch the requested page",
Url = "https://example.com/docs",
},
toolCalls,
out string? urlToolName));
Assert.Equal("web_fetch", urlToolName);
Assert.True(
CopilotApprovalCoordinator.TryGetApprovalToolName(
new PermissionRequestShell
{
Kind = "shell",
ToolCallId = "tool-call-shell",
FullCommandText = "curl https://example.com/docs",
Intention = "Fetch documentation with curl",
Commands = [],
PossiblePaths = [],
PossibleUrls = [],
HasWriteFileRedirection = false,
CanOfferSessionApproval = false,
},
toolCalls,
out string? shellToolName));
Assert.Equal("shell", shellToolName);
Assert.True(
CopilotApprovalCoordinator.TryGetApprovalToolName(
new PermissionRequestRead
{
Kind = "read",
ToolCallId = "tool-call-read",
Intention = "Inspect a file",
Path = "README.md",
},
toolCalls,
out string? readToolName));
Assert.Equal("view", readToolName);
Assert.True(
CopilotApprovalCoordinator.TryGetApprovalToolName(
new PermissionRequestWrite
{
Kind = "write",
ToolCallId = "tool-call-write",
Intention = "Update a file",
FileName = "README.md",
Diff = "@@ -1 +1 @@",
},
toolCalls,
out string? writeToolName));
Assert.Equal("write_file", writeToolName);
Assert.True(
CopilotApprovalCoordinator.TryGetApprovalToolName(
new PermissionRequestMemory
{
Kind = "memory",
ToolCallId = "tool-call-memory",
Subject = "repo conventions",
Fact = "Use Bun for script execution.",
Citations = "package.json",
},
toolCalls,
out string? memoryToolName));
Assert.Equal("store_memory", memoryToolName);
}
[Fact]
public void TryGetApprovalToolName_FallsBackToWebFetchForUncorrelatedUrlRequests()
{
Assert.True(
CopilotApprovalCoordinator.TryGetApprovalToolName(
new PermissionRequestUrl
{
Kind = "url",
ToolCallId = "tool-call-1",
Intention = "Fetch the requested page",
Url = "https://example.com/docs",
},
out string? urlToolName));
Assert.Equal("web_fetch", urlToolName);
}
[Fact]
public void BuildPermissionApprovalEvent_IncludesToolContextWhenKnown()
{
ApprovalRequestedEventDto approvalEvent = CopilotApprovalCoordinator.BuildPermissionApprovalEvent(
new RunTurnCommandDto
{
RequestId = "turn-1",
SessionId = "session-1",
},
CreateAgent("agent-1", "Primary"),
new PermissionRequestCustomTool
{
Kind = "custom tool",
ToolName = "lsp_ts_hover",
ToolDescription = "Hover information",
},
new PermissionInvocation
{
SessionId = "copilot-session-1",
},
"approval-1",
"lsp_ts_hover");
Assert.Equal("lsp_ts_hover", approvalEvent.ToolName);
Assert.Equal("Approve lsp_ts_hover", approvalEvent.Title);
Assert.Contains("tool \"lsp_ts_hover\"", approvalEvent.Detail);
Assert.NotNull(approvalEvent.PermissionDetail);
Assert.Equal("custom-tool", approvalEvent.PermissionDetail!.Kind);
Assert.Equal("Hover information", approvalEvent.PermissionDetail.ToolDescription);
}
[Fact]
public void BuildPermissionApprovalEvent_IncludesRequestedUrlForUrlPermissions()
{
ApprovalRequestedEventDto approvalEvent = CopilotApprovalCoordinator.BuildPermissionApprovalEvent(
new RunTurnCommandDto
{
RequestId = "turn-1",
SessionId = "session-1",
},
CreateAgent("agent-1", "Analyst"),
new PermissionRequestUrl
{
Kind = "url",
ToolCallId = "tool-call-1",
Intention = "Fetch the requested page",
Url = "https://example.com/docs",
},
new PermissionInvocation
{
SessionId = "copilot-session-1",
},
"approval-1",
"web_fetch");
Assert.Equal("web_fetch", approvalEvent.ToolName);
Assert.Equal("Approve web_fetch", approvalEvent.Title);
Assert.Contains("url permission", approvalEvent.Detail);
Assert.Contains("tool \"web_fetch\"", approvalEvent.Detail);
Assert.Contains("https://example.com/docs", approvalEvent.Detail);
Assert.NotNull(approvalEvent.PermissionDetail);
Assert.Equal("url", approvalEvent.PermissionDetail!.Kind);
Assert.Equal("Fetch the requested page", approvalEvent.PermissionDetail.Intention);
Assert.Equal("https://example.com/docs", approvalEvent.PermissionDetail.Url);
}
[Fact]
public void BuildPermissionDetail_ExtractsShellRequestData()
{
PermissionDetailDto detail = CopilotApprovalCoordinator.BuildPermissionDetail(
new PermissionRequestShell
{
Kind = "shell",
ToolCallId = "tool-call-shell",
FullCommandText = "curl https://example.com/docs > docs.json",
Intention = "Fetch documentation with curl",
Commands =
[
new PermissionRequestShellCommandsItem
{
Identifier = "curl",
ReadOnly = true,
},
],
PossiblePaths = ["docs.json"],
PossibleUrls =
[
new PermissionRequestShellPossibleUrlsItem
{
Url = "https://example.com/docs",
},
],
HasWriteFileRedirection = true,
CanOfferSessionApproval = false,
Warning = "Downloads remote content and writes it to disk.",
});
Assert.Equal("shell", detail.Kind);
Assert.Equal("curl https://example.com/docs > docs.json", detail.Command);
Assert.Equal("Fetch documentation with curl", detail.Intention);
Assert.Equal("Downloads remote content and writes it to disk.", detail.Warning);
Assert.Equal(["docs.json"], detail.PossiblePaths);
Assert.Equal(["https://example.com/docs"], detail.PossibleUrls);
Assert.True(detail.HasWriteFileRedirection);
}
[Fact]
public void BuildPermissionDetail_ExtractsWriteRequestData()
{
PermissionDetailDto detail = CopilotApprovalCoordinator.BuildPermissionDetail(
new PermissionRequestWrite
{
Kind = "write",
ToolCallId = "tool-call-write",
Intention = "Update README guidance",
FileName = "README.md",
Diff = "@@ -1 +1 @@\n-Hello\n+Hello world",
NewFileContents = "# README",
});
Assert.Equal("write", detail.Kind);
Assert.Equal("Update README guidance", detail.Intention);
Assert.Equal("README.md", detail.FileName);
Assert.Equal("@@ -1 +1 @@\n-Hello\n+Hello world", detail.Diff);
Assert.Equal("# README", detail.NewFileContents);
}
[Fact]
public void BuildPermissionDetail_ExtractsReadRequestData()
{
PermissionDetailDto detail = CopilotApprovalCoordinator.BuildPermissionDetail(
new PermissionRequestRead
{
Kind = "read",
ToolCallId = "tool-call-read",
Intention = "Inspect the README",
Path = "README.md",
});
Assert.Equal("read", detail.Kind);
Assert.Equal("Inspect the README", detail.Intention);
Assert.Equal("README.md", detail.Path);
}
[Fact]
public void BuildPermissionDetail_ExtractsMcpRequestData()
{
PermissionDetailDto detail = CopilotApprovalCoordinator.BuildPermissionDetail(
new PermissionRequestMcp
{
Kind = "mcp",
ToolCallId = "tool-call-mcp",
ServerName = "Git MCP",
ToolName = "git.status",
ToolTitle = "Git Status",
Args = new Dictionary<string, object?>
{
["path"] = ".",
},
ReadOnly = true,
});
Assert.Equal("mcp", detail.Kind);
Assert.Equal("Git MCP", detail.ServerName);
Assert.Equal("Git Status", detail.ToolTitle);
Assert.True(detail.ReadOnly);
Dictionary<string, object?> args = Assert.IsType<Dictionary<string, object?>>(detail.Args);
Assert.Equal(".", args["path"]);
}
[Fact]
public void BuildPermissionDetail_ExtractsUrlRequestData()
{
PermissionDetailDto detail = CopilotApprovalCoordinator.BuildPermissionDetail(
new PermissionRequestUrl
{
Kind = "url",
ToolCallId = "tool-call-url",
Intention = "Fetch the requested page",
Url = "https://example.com/docs",
});
Assert.Equal("url", detail.Kind);
Assert.Equal("Fetch the requested page", detail.Intention);
Assert.Equal("https://example.com/docs", detail.Url);
}
[Fact]
public void BuildPermissionDetail_ExtractsMemoryRequestData()
{
PermissionDetailDto detail = CopilotApprovalCoordinator.BuildPermissionDetail(
new PermissionRequestMemory
{
Kind = "memory",
ToolCallId = "tool-call-memory",
Subject = "repo conventions",
Fact = "Use Bun for script execution.",
Citations = "package.json",
});
Assert.Equal("memory", detail.Kind);
Assert.Equal("repo conventions", detail.Subject);
Assert.Equal("Use Bun for script execution.", detail.Fact);
Assert.Equal("package.json", detail.Citations);
}
[Fact]
public void BuildPermissionDetail_ExtractsCustomToolRequestData()
{
PermissionDetailDto detail = CopilotApprovalCoordinator.BuildPermissionDetail(
new PermissionRequestCustomTool
{
Kind = "custom tool",
ToolName = "lsp_ts_hover",
ToolDescription = "Hover information",
Args = new Dictionary<string, object?>
{
["file"] = "src/index.ts",
["line"] = 12,
},
});
Assert.Equal("custom-tool", detail.Kind);
Assert.Equal("Hover information", detail.ToolDescription);
Dictionary<string, object?> args = Assert.IsType<Dictionary<string, object?>>(detail.Args);
Assert.Equal("src/index.ts", args["file"]);
Assert.Equal(12, args["line"]);
}
[Fact]
public void BuildPermissionDetail_ExtractsHookRequestData()
{
PermissionDetailDto detail = CopilotApprovalCoordinator.BuildPermissionDetail(
new PermissionRequestHook
{
Kind = "hook",
ToolName = "web_fetch",
ToolArgs = new Dictionary<string, object?>
{
["url"] = "https://example.com",
},
HookMessage = "Review required before fetch",
});
Assert.Equal("hook", detail.Kind);
Assert.Equal("Review required before fetch", detail.HookMessage);
Dictionary<string, object?> args = Assert.IsType<Dictionary<string, object?>>(detail.Args);
Assert.Equal("https://example.com", args["url"]);
}
[Fact]
public void BuildPermissionDetail_MapsConfiguredMcpHookToMcpDetail()
{
PermissionDetailDto detail = CopilotApprovalCoordinator.BuildPermissionDetail(
new PermissionRequestHook
{
Kind = "hook",
ToolName = "icm-mcp-get_incident_details_by_id",
ToolArgs = new Dictionary<string, object?>
{
["incidentId"] = 769904783,
},
},
[CreateMcpServerConfig("icm-mcp")]);
Assert.Equal("mcp", detail.Kind);
Assert.Equal("icm-mcp", detail.ServerName);
Assert.Equal("get_incident_details_by_id", detail.ToolTitle);
Dictionary<string, object?> args = Assert.IsType<Dictionary<string, object?>>(detail.Args);
Assert.Equal(769904783, args["incidentId"]);
}
[Theory]
[InlineData("view", "read")]
[InlineData("glob", "read")]
[InlineData("grep", "read")]
[InlineData("lsp", "read")]
[InlineData("edit", "write")]
[InlineData("create", "write")]
[InlineData("powershell", "shell")]
[InlineData("read_powershell", "shell")]
[InlineData("write_powershell", "shell")]
[InlineData("stop_powershell", "shell")]
[InlineData("list_powershell", "shell")]
[InlineData("web_fetch", "url")]
[InlineData("web_search", "url")]
[InlineData("store_memory", "memory")]
public void ResolveHookToolCategory_ReturnsExpectedCategoryForKnownTools(string toolName, string expectedCategory)
{
Assert.Equal(expectedCategory, CopilotApprovalCoordinator.ResolveHookToolCategory(toolName));
}
[Theory]
[InlineData("icm-mcp-get_on_call_schedule")]
[InlineData("custom_tool")]
[InlineData("unknown")]
public void ResolveHookToolCategory_ReturnsNullForUnknownTools(string toolName)
{
Assert.Null(CopilotApprovalCoordinator.ResolveHookToolCategory(toolName));
}
[Fact]
public void ResolveHookToolCategory_ReturnsNullForNullOrEmpty()
{
Assert.Null(CopilotApprovalCoordinator.ResolveHookToolCategory(null));
Assert.Null(CopilotApprovalCoordinator.ResolveHookToolCategory(""));
Assert.Null(CopilotApprovalCoordinator.ResolveHookToolCategory(" "));
}
[Fact]
public void ResolveHookMcpServerApprovalKey_PrefersLongestConfiguredServerName()
{
string? approvalKey = CopilotApprovalCoordinator.ResolveHookMcpServerApprovalKey(
"icm-mcp-get_on_call_schedule",
[CreateMcpServerConfig("icm"), CreateMcpServerConfig("icm-mcp")]);
Assert.Equal("mcp_server:icm-mcp", approvalKey);
}
[Fact]
public void TryGetApprovalToolName_ResolvesHookToolToCategory()
{
Assert.True(
CopilotApprovalCoordinator.TryGetApprovalToolName(
new PermissionRequestHook
{
Kind = "hook",
ToolName = "view",
ToolArgs = """{"path":"README.md"}""",
},
out string? toolName));
Assert.Equal("view", toolName);
// But the auto-approved name (fallback) resolves to the category
PermissionRequestHook hookRequest = new()
{
Kind = "hook",
ToolName = "view",
ToolArgs = """{"path":"README.md"}""",
};
// Verify GetFallbackToolName returns category via ResolveAutoApprovedToolName path
Assert.True(
CopilotApprovalCoordinator.TryGetApprovalToolName(
hookRequest,
out _));
}
[Fact]
public void RequiresToolCallApproval_HonorsHookToolCategoryForAutoApproval()
{
ApprovalPolicyDto policy = new()
{
Rules =
[
new ApprovalCheckpointRuleDto
{
Kind = "tool-call",
AgentIds = ["agent-1"],
},
],
AutoApprovedToolNames = ["read"],
};
// "view" is a hook tool that maps to "read" category — should be auto-approved
Assert.False(CopilotApprovalCoordinator.RequiresToolCallApproval(
policy, "agent-1", "view", "read"));
// "grep" also maps to "read"
Assert.False(CopilotApprovalCoordinator.RequiresToolCallApproval(
policy, "agent-1", "grep", "read"));
// "edit" maps to "write" — not auto-approved
Assert.True(CopilotApprovalCoordinator.RequiresToolCallApproval(
policy, "agent-1", "edit", "write"));
}
[Fact]
public void BuildPermissionApprovalEvent_UsesResolvedCategoryForHookPermissionKind()
{
ApprovalRequestedEventDto approvalEvent = CopilotApprovalCoordinator.BuildPermissionApprovalEvent(
new RunTurnCommandDto
{
RequestId = "turn-1",
SessionId = "session-1",
},
CreateAgent("agent-1", "Primary"),
new PermissionRequestHook
{
Kind = "hook",
ToolName = "view",
ToolArgs = """{"path":"README.md"}""",
},
new PermissionInvocation
{
SessionId = "copilot-session-1",
},
"approval-1",
"view");
Assert.Equal("view", approvalEvent.ToolName);
Assert.Equal("read", approvalEvent.PermissionKind);
Assert.Contains("read permission", approvalEvent.Detail);
}
[Fact]
public void BuildPermissionApprovalEvent_UsesMcpKindForConfiguredMcpHookTools()
{
ApprovalRequestedEventDto approvalEvent = CopilotApprovalCoordinator.BuildPermissionApprovalEvent(
new RunTurnCommandDto
{
RequestId = "turn-1",
SessionId = "session-1",
Tooling = new RunTurnToolingConfigDto
{
McpServers = [CreateMcpServerConfig("icm-mcp")],
},
},
CreateAgent("agent-1", "Primary"),
new PermissionRequestHook
{
Kind = "hook",
ToolName = "icm-mcp-get_schedule",
ToolArgs = """{"teamIds":[91982]}""",
},
new PermissionInvocation
{
SessionId = "copilot-session-1",
},
"approval-1",
"icm-mcp-get_schedule");
Assert.Equal("mcp", approvalEvent.PermissionKind);
Assert.Contains("mcp permission", approvalEvent.Detail);
Assert.NotNull(approvalEvent.PermissionDetail);
Assert.Equal("mcp", approvalEvent.PermissionDetail!.Kind);
Assert.Equal("icm-mcp", approvalEvent.PermissionDetail.ServerName);
Assert.Equal("get_schedule", approvalEvent.PermissionDetail.ToolTitle);
}
[Fact]
public void BuildPermissionApprovalEvent_KeepsHookKindForUnknownHookTools()
{
ApprovalRequestedEventDto approvalEvent = CopilotApprovalCoordinator.BuildPermissionApprovalEvent(
new RunTurnCommandDto
{
RequestId = "turn-1",
SessionId = "session-1",
},
CreateAgent("agent-1", "Primary"),
new PermissionRequestHook
{
Kind = "hook",
ToolName = "icm-mcp-get_schedule",
ToolArgs = """{"teamIds":[91982]}""",
},
new PermissionInvocation
{
SessionId = "copilot-session-1",
},
"approval-1",
"icm-mcp-get_schedule");
Assert.Equal("hook", approvalEvent.PermissionKind);
}
[Fact]
public async Task RequestApprovalAsync_RaisesApprovalAndCompletesAfterResolution()
{
CopilotApprovalCoordinator coordinator = new();
ApprovalRequestedEventDto? observedApproval = null;
RunTurnCommandDto command = CreateApprovalCommand();
Task<PermissionRequestResult> pending = coordinator.RequestApprovalAsync(
command,
command.Workflow.GetAgentNodes()[0],
new PermissionRequestCustomTool
{
Kind = "custom tool",
ToolName = "lsp_ts_definition",
ToolDescription = "Go to definition",
},
new PermissionInvocation
{
SessionId = "copilot-session-1",
},
CreateToolCallRegistry(),
approval =>
{
observedApproval = approval;
return Task.CompletedTask;
},
CancellationToken.None);
Assert.False(pending.IsCompleted);
Assert.NotNull(observedApproval);
await coordinator.ResolveApprovalAsync(
new ResolveApprovalCommandDto
{
ApprovalId = observedApproval!.ApprovalId,
Decision = "approved",
},
CancellationToken.None);
PermissionRequestResult result = await pending;
Assert.Equal(PermissionRequestResultKind.Approved, result.Kind);
}
[Fact]
public async Task RequestApprovalAsync_EmitsFileChangeActivityForWriteRequests()
{
CopilotApprovalCoordinator coordinator = new();
AgentActivityEventDto? observedActivity = null;
ApprovalRequestedEventDto? observedApproval = null;
RunTurnCommandDto command = CreateApprovalCommand();
Task<PermissionRequestResult> pending = coordinator.RequestApprovalAsync(
command,
command.Workflow.GetAgentNodes()[0],
new PermissionRequestWrite
{
Kind = "write",
ToolCallId = "tool-call-write-1",
Intention = "Update the README",
FileName = "README.md",
Diff = "@@ -1 +1 @@",
NewFileContents = "# Aryx\n",
},
new PermissionInvocation
{
SessionId = "copilot-session-1",
},
CreateToolCallRegistry(("tool-call-write-1", "apply_patch")),
activity =>
{
observedActivity = activity;
return Task.CompletedTask;
},
approval =>
{
observedApproval = approval;
return Task.CompletedTask;
},
CancellationToken.None);
Assert.False(pending.IsCompleted);
Assert.NotNull(observedActivity);
Assert.NotNull(observedApproval);
Assert.Equal("tool-calling", observedActivity!.ActivityType);
Assert.Equal("apply_patch", observedActivity.ToolName);
Assert.Equal("tool-call-write-1", observedActivity.ToolCallId);
ToolCallFileChangeDto preview = Assert.Single(observedActivity.FileChanges!);
Assert.Equal("README.md", preview.Path);
Assert.Equal("@@ -1 +1 @@", preview.Diff);
Assert.Equal("# Aryx\n", preview.NewFileContents);
await coordinator.ResolveApprovalAsync(
new ResolveApprovalCommandDto
{
ApprovalId = observedApproval!.ApprovalId,
Decision = "approved",
},
CancellationToken.None);
PermissionRequestResult result = await pending;
Assert.Equal(PermissionRequestResultKind.Approved, result.Kind);
}
[Fact]
public async Task RequestApprovalAsync_AutoApprovesToolsThatDoNotRequireApproval()
{
CopilotApprovalCoordinator coordinator = new();
bool sawApproval = false;
RunTurnCommandDto command = CreateApprovalCommand();
PermissionRequestResult result = await coordinator.RequestApprovalAsync(
command,
command.Workflow.GetAgentNodes()[0],
new PermissionRequestCustomTool
{
Kind = "custom tool",
ToolName = "web_fetch",
ToolDescription = "Fetch documentation",
},
new PermissionInvocation
{
SessionId = "copilot-session-1",
},
CreateToolCallRegistry(),
approval =>
{
sawApproval = true;
return Task.CompletedTask;
},
CancellationToken.None);
Assert.False(sawApproval);
Assert.Equal(PermissionRequestResultKind.Approved, result.Kind);
}
[Fact]
public async Task RequestApprovalAsync_AutoApprovesHookRequestsForApprovedMcpServer()
{
CopilotApprovalCoordinator coordinator = new();
bool sawApproval = false;
RunTurnCommandDto command = CreateApprovalCommand(
autoApprovedToolNames: ["mcp_server:icm-mcp"],
mcpServers: [CreateMcpServerConfig("icm-mcp")]);
PermissionRequestResult result = await coordinator.RequestApprovalAsync(
command,
command.Workflow.GetAgentNodes()[0],
new PermissionRequestHook
{
Kind = "hook",
ToolName = "icm-mcp-get_incident_details_by_id",
ToolArgs = new Dictionary<string, object?>
{
["incidentId"] = 769904783,
},
},
new PermissionInvocation
{
SessionId = "copilot-session-1",
},
CreateToolCallRegistry(),
approval =>
{
sawApproval = true;
return Task.CompletedTask;
},
CancellationToken.None);
Assert.False(sawApproval);
Assert.Equal(PermissionRequestResultKind.Approved, result.Kind);
}
[Fact]
public async Task RequestApprovalAsync_AlwaysApproveCachesRuntimeApprovalForCurrentTurn()
{
CopilotApprovalCoordinator coordinator = new();
ApprovalRequestedEventDto? firstApproval = null;
RunTurnCommandDto command = CreateApprovalCommand();
Task<PermissionRequestResult> firstPending = coordinator.RequestApprovalAsync(
command,
command.Workflow.GetAgentNodes()[0],
new PermissionRequestRead
{
Kind = "read",
ToolCallId = "tool-call-read-1",
Intention = "Inspect README guidance",
Path = "README.md",
},
new PermissionInvocation
{
SessionId = "copilot-session-1",
},
CreateToolCallRegistry(("tool-call-read-1", "view")),
approval =>
{
firstApproval = approval;
return Task.CompletedTask;
},
CancellationToken.None);
Assert.False(firstPending.IsCompleted);
Assert.NotNull(firstApproval);
await coordinator.ResolveApprovalAsync(
new ResolveApprovalCommandDto
{
ApprovalId = firstApproval!.ApprovalId,
Decision = "approved",
AlwaysApprove = true,
},
CancellationToken.None);
PermissionRequestResult firstResult = await firstPending;
Assert.Equal(PermissionRequestResultKind.Approved, firstResult.Kind);
bool sawSecondApproval = false;
PermissionRequestResult secondResult = await coordinator.RequestApprovalAsync(
command,
command.Workflow.GetAgentNodes()[0],
new PermissionRequestRead
{
Kind = "read",
ToolCallId = "tool-call-read-2",
Intention = "Inspect docs guidance",
Path = "docs\\guide.md",
},
new PermissionInvocation
{
SessionId = "copilot-session-1",
},
CreateToolCallRegistry(("tool-call-read-2", "grep")),
approval =>
{
sawSecondApproval = true;
return Task.CompletedTask;
},
CancellationToken.None);
Assert.False(sawSecondApproval);
Assert.Equal(PermissionRequestResultKind.Approved, secondResult.Kind);
}
[Fact]
public async Task RequestApprovalAsync_AlwaysApproveCacheDoesNotCarryAcrossTurnRequests()
{
CopilotApprovalCoordinator coordinator = new();
ApprovalRequestedEventDto? firstApproval = null;
RunTurnCommandDto firstCommand = CreateApprovalCommand();
Task<PermissionRequestResult> firstPending = coordinator.RequestApprovalAsync(
firstCommand,
firstCommand.Workflow.GetAgentNodes()[0],
new PermissionRequestRead
{
Kind = "read",
ToolCallId = "tool-call-read-1",
Intention = "Inspect README guidance",
Path = "README.md",
},
new PermissionInvocation
{
SessionId = "copilot-session-1",
},
CreateToolCallRegistry(("tool-call-read-1", "view")),
approval =>
{
firstApproval = approval;
return Task.CompletedTask;
},
CancellationToken.None);
Assert.NotNull(firstApproval);
await coordinator.ResolveApprovalAsync(
new ResolveApprovalCommandDto
{
ApprovalId = firstApproval!.ApprovalId,
Decision = "approved",
AlwaysApprove = true,
},
CancellationToken.None);
await firstPending;
ApprovalRequestedEventDto? secondApproval = null;
RunTurnCommandDto secondCommand = CreateApprovalCommand(requestId: "turn-2");
Task<PermissionRequestResult> secondPending = coordinator.RequestApprovalAsync(
secondCommand,
secondCommand.Workflow.GetAgentNodes()[0],
new PermissionRequestRead
{
Kind = "read",
ToolCallId = "tool-call-read-2",
Intention = "Inspect docs guidance",
Path = "docs\\guide.md",
},
new PermissionInvocation
{
SessionId = "copilot-session-1",
},
CreateToolCallRegistry(("tool-call-read-2", "grep")),
approval =>
{
secondApproval = approval;
return Task.CompletedTask;
},
CancellationToken.None);
Assert.False(secondPending.IsCompleted);
Assert.NotNull(secondApproval);
await coordinator.ResolveApprovalAsync(
new ResolveApprovalCommandDto
{
ApprovalId = secondApproval!.ApprovalId,
Decision = "approved",
},
CancellationToken.None);
PermissionRequestResult secondResult = await secondPending;
Assert.Equal(PermissionRequestResultKind.Approved, secondResult.Kind);
}
[Fact]
public async Task ResolveApprovalAsync_RejectsUnknownApprovalIds()
{
CopilotApprovalCoordinator coordinator = new();
InvalidOperationException error = await Assert.ThrowsAsync<InvalidOperationException>(() =>
coordinator.ResolveApprovalAsync(
new ResolveApprovalCommandDto
{
ApprovalId = "approval-missing",
Decision = "approved",
},
CancellationToken.None));
Assert.Contains("is not pending", error.Message);
}
private static WorkflowNodeDto CreateAgent(string id, string name)
{
return new WorkflowNodeDto
{
Id = id,
Kind = "agent",
Label = name,
Config = new WorkflowNodeConfigDto
{
Kind = "agent",
Id = id,
Name = name,
Model = "gpt-5.4",
Instructions = "Help with the request.",
},
};
}
private static WorkflowNodeDto CreateSubworkflow(
string id,
string label,
string? workflowId = null,
WorkflowDefinitionDto? inlineWorkflow = null)
{
return new WorkflowNodeDto
{
Id = id,
Kind = "sub-workflow",
Label = label,
Config = new WorkflowNodeConfigDto
{
Kind = "sub-workflow",
WorkflowId = workflowId,
InlineWorkflow = inlineWorkflow,
},
};
}
private static RunTurnCommandDto CreateCommand(
string orchestrationMode,
params WorkflowNodeDto[] agents)
{
return CreateCommand(
orchestrationMode,
modeSettings: null,
workflowName: null,
workflowDescription: null,
workflowLibrary: null,
agents: agents);
}
private static RunTurnCommandDto CreateCommand(
string orchestrationMode,
OrchestrationModeSettingsDto? modeSettings = null,
string? workflowName = null,
string? workflowDescription = null,
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null,
params WorkflowNodeDto[] agents)
{
return new RunTurnCommandDto
{
RequestId = "turn-1",
SessionId = "session-1",
WorkflowLibrary = workflowLibrary ?? [],
Workflow = new WorkflowDefinitionDto
{
Id = $"workflow-{orchestrationMode}",
Name = workflowName ?? $"Workflow {orchestrationMode}",
Description = workflowDescription ?? string.Empty,
Graph = new WorkflowGraphDto
{
Nodes = [.. agents],
},
Settings = new WorkflowSettingsDto
{
OrchestrationMode = orchestrationMode,
ModeSettings = modeSettings,
},
},
};
}
private static RunTurnCommandDto CreateGraphWorkflowCommand()
{
return new RunTurnCommandDto
{
RequestId = "turn-graph",
SessionId = "session-graph",
Workflow = new WorkflowDefinitionDto
{
Id = "workflow-single",
Name = "Single Graph Workflow",
Description = "Uses the graph workflow runner.",
Graph = new WorkflowGraphDto
{
Nodes =
[
new WorkflowNodeDto
{
Id = "start",
Kind = "start",
Label = "Start",
Config = new WorkflowNodeConfigDto { Kind = "start" },
},
CreateAgent("agent-primary", "Primary Agent"),
new WorkflowNodeDto
{
Id = "end",
Kind = "end",
Label = "End",
Config = new WorkflowNodeConfigDto { Kind = "end" },
},
],
Edges =
[
new WorkflowEdgeDto
{
Id = "edge-start-agent",
Source = "start",
Target = "agent-primary",
Kind = "direct",
},
new WorkflowEdgeDto
{
Id = "edge-agent-end",
Source = "agent-primary",
Target = "end",
Kind = "direct",
},
],
},
Settings = new WorkflowSettingsDto
{
OrchestrationMode = "single",
},
},
};
}
private static RunTurnCommandDto CreateReferencedSubworkflowCommand()
{
WorkflowDefinitionDto nestedWorkflow = new()
{
Id = "nested-review-workflow",
Name = "Nested Review Workflow",
Graph = new WorkflowGraphDto
{
Nodes =
[
CreateAgent("agent-reviewer", "Reviewer"),
],
},
Settings = new WorkflowSettingsDto
{
OrchestrationMode = "single",
},
};
return CreateCommand(
"single",
workflowName: "Parent Workflow",
workflowLibrary: [nestedWorkflow],
agents:
[
CreateSubworkflow("subworkflow-review", "Review Lane", workflowId: nestedWorkflow.Id),
]);
}
private static RunTurnCommandDto CreateHandoffCommand()
{
return CreateCommand(
"handoff",
CreateAgent("agent-handoff-triage", "Triage"),
CreateAgent("agent-handoff-ux", "UX Specialist"));
}
private static RequestInfoEvent CreateRequestInfoEvent(object payload)
{
RequestPort port = RequestPort.Create<object, object>("test-port");
ExternalRequest request = ExternalRequest.Create(port, payload, "request-1");
return new RequestInfoEvent(request);
}
private static object CreateHandoffTarget(string id, string name)
{
Type type = Type.GetType(
"Microsoft.Agents.AI.Workflows.Specialized.HandoffTarget, Microsoft.Agents.AI.Workflows",
throwOnError: true)!;
return Activator.CreateInstance(type, CreateChatClientAgent(id, name), "Handle the UX work.")!;
}
private static ChatClientAgent CreateChatClientAgent(string id, string name)
{
return new ChatClientAgent(
new StubChatClient(),
id,
name,
"Stub agent for handoff tests.",
[],
null!,
null!);
}
private static RunTurnCommandDto CreateApprovalCommand(
string requestId = "turn-1",
IReadOnlyList<string>? autoApprovedToolNames = null,
IReadOnlyList<RunTurnMcpServerConfigDto>? mcpServers = null)
{
return new RunTurnCommandDto
{
RequestId = requestId,
SessionId = "session-1",
Tooling = mcpServers is null
? null
: new RunTurnToolingConfigDto
{
McpServers = [.. mcpServers],
},
Workflow = new WorkflowDefinitionDto
{
Id = "workflow-1",
Name = "Approval Workflow",
Graph = new WorkflowGraphDto
{
Nodes =
[
CreateAgent("agent-1", "Primary"),
],
},
Settings = new WorkflowSettingsDto
{
OrchestrationMode = "single",
ApprovalPolicy = new ApprovalPolicyDto
{
Rules =
[
new ApprovalCheckpointRuleDto
{
Kind = "tool-call",
AgentIds = ["agent-1"],
},
],
AutoApprovedToolNames = autoApprovedToolNames is null
? ["web_fetch"]
: [.. autoApprovedToolNames],
},
},
},
};
}
private static ToolCallRegistry CreateToolCallRegistry(params (string ToolCallId, string ToolName)[] toolCalls)
{
ToolCallRegistry registry = new();
foreach ((string toolCallId, string toolName) in toolCalls)
{
registry.RecordToolStart(toolCallId, toolName, toolArguments: null);
}
return registry;
}
private static RunTurnCommandDto CreateRequestPortCommand()
{
return new RunTurnCommandDto
{
RequestId = "turn-request-port",
SessionId = "session-request-port",
ProjectPath = "c:\\workspace\\personal\\projects\\aryx.worktrees\\copilot-powerful-vulture",
Messages =
[
new ChatMessageDto
{
Id = "message-1",
Role = "user",
AuthorName = "User",
Content = "Please continue.",
CreatedAt = "2026-04-05T00:00:00.000Z",
},
],
Workflow = new WorkflowDefinitionDto
{
Id = "workflow-request-port",
Name = "Request Port Workflow",
Graph = new WorkflowGraphDto
{
Nodes =
[
new WorkflowNodeDto
{
Id = "start",
Kind = "start",
Label = "Start",
Config = new WorkflowNodeConfigDto { Kind = "start" },
},
new WorkflowNodeDto
{
Id = "approval-port",
Kind = "request-port",
Label = "Approval",
Config = new WorkflowNodeConfigDto
{
Kind = "request-port",
PortId = "approval",
RequestType = "Question",
ResponseType = "string",
Prompt = "Needs approval?",
},
},
new WorkflowNodeDto
{
Id = "end",
Kind = "end",
Label = "End",
Config = new WorkflowNodeConfigDto { Kind = "end" },
},
],
Edges =
[
new WorkflowEdgeDto
{
Id = "edge-start-port",
Source = "start",
Target = "approval-port",
Kind = "direct",
},
new WorkflowEdgeDto
{
Id = "edge-port-end",
Source = "approval-port",
Target = "end",
Kind = "direct",
},
],
},
Settings = new WorkflowSettingsDto
{
Checkpointing = new WorkflowCheckpointSettingsDto(),
ExecutionMode = "lockstep",
},
},
};
}
private static RunTurnMcpServerConfigDto CreateMcpServerConfig(string serverName)
=> new()
{
Id = serverName,
Name = serverName,
};
private sealed class StubChatClient : IChatClient
{
public Task<ChatResponse> GetResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options,
CancellationToken cancellationToken)
{
throw new NotSupportedException();
}
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
IEnumerable<ChatMessage> messages,
ChatOptions? options,
[EnumeratorCancellation]
CancellationToken cancellationToken)
{
yield break;
}
public object? GetService(Type serviceType, object? serviceKey)
{
return null;
}
public void Dispose()
{
}
}
}