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(), 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)); } [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)); } [Fact] public void SelectNewOutputMessages_SkipsFullTranscriptPrefix() { List inputMessages = [ new(ChatRole.User, "Hello"), ]; List outputMessages = [ new(ChatRole.User, "Hello"), new(ChatRole.Assistant, "Hi there."), ]; IReadOnlyList 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 inputMessages = [ new(ChatRole.Assistant, "Earlier answer"), new(ChatRole.User, "Hello"), ]; List outputMessages = [ new(ChatRole.User, "Hello"), new(ChatRole.Assistant, "Hi there."), ]; IReadOnlyList 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 inputMessages = [ new(ChatRole.User, "Hello"), ]; List outputMessages = [ new(ChatRole.Assistant, "Hi there."), ]; IReadOnlyList 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 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 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 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 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 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 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 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 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 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 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 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 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 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 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()); 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()); 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 activities = []; MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod( "HandleWorkflowEventAsync", BindingFlags.NonPublic | BindingFlags.Static)!; Task handleTask = (Task)handleWorkflowEvent.Invoke( null, [ command, requestInfo, Array.Empty(), state, (Func)(_ => Task.CompletedTask), (Func)(sidecarEvent => { activities.Add(Assert.IsType(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 { ["path"] = @"C:\workspace\README.md", })); List activities = []; MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod( "HandleWorkflowEventAsync", BindingFlags.NonPublic | BindingFlags.Static)!; Task handleTask = (Task)handleWorkflowEvent.Invoke( null, [ command, requestInfo, Array.Empty(), state, (Func)(_ => Task.CompletedTask), (Func)(sidecarEvent => { activities.Add(Assert.IsType(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 { ["path"] = @"C:\workspace\README.md", })); List requestActivities = []; MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod( "HandleWorkflowEventAsync", BindingFlags.NonPublic | BindingFlags.Static)!; Task handleTask = (Task)handleWorkflowEvent.Invoke( null, [ command, requestInfo, Array.Empty(), state, (Func)(_ => Task.CompletedTask), (Func)(sidecarEvent => { requestActivities.Add(Assert.IsType(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 pending = state.DrainPendingEvents(); Assert.DoesNotContain( pending.OfType(), activity => activity.ActivityType == "tool-calling"); MessageReclassifiedEventDto reclassified = Assert.Single(pending.OfType()); 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())); List requestActivities = []; MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod( "HandleWorkflowEventAsync", BindingFlags.NonPublic | BindingFlags.Static)!; Task handleTask = (Task)handleWorkflowEvent.Invoke( null, [ command, requestInfo, Array.Empty(), state, (Func)(_ => Task.CompletedTask), (Func)(sidecarEvent => { requestActivities.Add(Assert.IsType(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(), 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(CopilotWorkflowRunner.CoerceRequestPortResponse("json", "{\"ok\":true}")); Assert.True(json.GetProperty("ok").GetBoolean()); } [Fact] public async Task RunTurnAsync_RequestPortWorkflowUsesUserInputBridge() { CopilotWorkflowRunner runner = new(new WorkflowValidator()); List requests = []; IReadOnlyList 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 deltas = []; MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod( "HandleWorkflowEventAsync", BindingFlags.NonPublic | BindingFlags.Static)!; Task handleTask = (Task)handleWorkflowEvent.Invoke( null, [ command, new AgentResponseUpdateEvent( "copilot-executor-ux", new AgentResponseUpdate(ChatRole.Assistant, "The button is ready.") { MessageId = "msg-ux-1", }), Array.Empty(), state, (Func)(delta => { deltas.Add(delta); return Task.CompletedTask; }), (Func)(_ => 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 checkpoints = []; MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod( "HandleWorkflowEventAsync", BindingFlags.NonPublic | BindingFlags.Static)!; Task handleTask = (Task)handleWorkflowEvent.Invoke( null, [ command, new SuperStepCompletedEvent( 3, new SuperStepCompletionInfo([]) { Checkpoint = new CheckpointInfo(command.RequestId, "checkpoint-1"), }), Array.Empty(), state, (Func)(_ => Task.CompletedTask), (Func)(sidecarEvent => { checkpoints.Add(Assert.IsType(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 diagnostics = []; MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod( "HandleWorkflowEventAsync", BindingFlags.NonPublic | BindingFlags.Static)!; Task handleTask = (Task)handleWorkflowEvent.Invoke( null, [ command, new ExecutorFailedEvent("agent-1", new InvalidOperationException("Tool crashed.")), Array.Empty(), state, (Func)(_ => Task.CompletedTask), (Func)(sidecarEvent => { diagnostics.Add(Assert.IsType(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 handleTask = (Task)handleWorkflowEvent.Invoke( null, [ command, new ExecutorCompletedEvent("agent-1", null), Array.Empty(), state, (Func)(_ => Task.CompletedTask), (Func)(_ => Task.CompletedTask), ])!; bool shouldEndTurn = await handleTask; Assert.False(shouldEndTurn); Assert.Null(state.ActiveAgent); AgentActivityEventDto completed = Assert.Single(state.DrainPendingEvents().OfType()); 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 activities = []; MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod( "HandleWorkflowEventAsync", BindingFlags.NonPublic | BindingFlags.Static)!; Task handleTask = (Task)handleWorkflowEvent.Invoke( null, [ command, new ExecutorInvokedEvent("subworkflow-review", null!), Array.Empty(), state, (Func)(_ => Task.CompletedTask), (Func)(sidecarEvent => { activities.Add(Assert.IsType(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 activities = []; MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod( "HandleWorkflowEventAsync", BindingFlags.NonPublic | BindingFlags.Static)!; Task handleTask = (Task)handleWorkflowEvent.Invoke( null, [ command, new ExecutorCompletedEvent("subworkflow-review", null), Array.Empty(), state, (Func)(_ => Task.CompletedTask), (Func)(sidecarEvent => { activities.Add(Assert.IsType(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 diagnostics = []; MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod( "HandleWorkflowEventAsync", BindingFlags.NonPublic | BindingFlags.Static)!; Task handleTask = (Task)handleWorkflowEvent.Invoke( null, [ command, new WorkflowWarningEvent("Token budget is nearly exhausted."), Array.Empty(), state, (Func)(_ => Task.CompletedTask), (Func)(sidecarEvent => { diagnostics.Add(Assert.IsType(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 diagnostics = []; MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod( "HandleWorkflowEventAsync", BindingFlags.NonPublic | BindingFlags.Static)!; Task handleTask = (Task)handleWorkflowEvent.Invoke( null, [ command, new SubworkflowErrorEvent("subworkflow-review", new InvalidOperationException("Reviewer agent failed.")), Array.Empty(), state, (Func)(_ => Task.CompletedTask), (Func)(sidecarEvent => { diagnostics.Add(Assert.IsType(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_HonorsCanonicalHookApprovalKeysForModernToolNames() { ApprovalPolicyDto policy = new() { Rules = [ new ApprovalCheckpointRuleDto { Kind = "tool-call", AgentIds = ["agent-1"], }, ], AutoApprovedToolNames = ["read", "write", "web_fetch"], }; Assert.False(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-1", "rg", "read")); Assert.False(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-1", "apply_patch", "write")); Assert.False(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-1", "web_search", "web_fetch")); Assert.True(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-1", "powershell", "shell")); } [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("lsp_ts_hover", approvalEvent.ApprovalToolKey); 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("web_fetch", approvalEvent.ApprovalToolKey); 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 { ["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 args = Assert.IsType>(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 { ["file"] = "src/index.ts", ["line"] = 12, }, }); Assert.Equal("custom-tool", detail.Kind); Assert.Equal("Hover information", detail.ToolDescription); Dictionary args = Assert.IsType>(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 { ["url"] = "https://example.com", }, HookMessage = "Review required before fetch", }); Assert.Equal("hook", detail.Kind); Assert.Equal("Review required before fetch", detail.HookMessage); Dictionary args = Assert.IsType>(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 { ["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 args = Assert.IsType>(detail.Args); Assert.Equal(769904783, args["incidentId"]); } [Theory] [InlineData("view", "read")] [InlineData("show_file", "read")] [InlineData("read_file", "read")] [InlineData("glob", "read")] [InlineData("grep", "read")] [InlineData("rg", "read")] [InlineData("lsp", "read")] [InlineData("edit", "write")] [InlineData("create", "write")] [InlineData("write_file", "write")] [InlineData("apply_patch", "write")] [InlineData("bash", "shell")] [InlineData("read_bash", "shell")] [InlineData("write_bash", "shell")] [InlineData("stop_bash", "shell")] [InlineData("list_bash", "shell")] [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")] [InlineData("remember_fact", "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(" ")); } [Theory] [InlineData("view", "read")] [InlineData("rg", "read")] [InlineData("apply_patch", "write")] [InlineData("bash", "shell")] [InlineData("web_fetch", "web_fetch")] [InlineData("web_search", "web_fetch")] [InlineData("store_memory", "store_memory")] [InlineData("remember_fact", "store_memory")] public void ResolveHookApprovalToolKey_ReturnsExpectedKeyForKnownTools(string toolName, string expectedKey) { Assert.Equal(expectedKey, CopilotApprovalCoordinator.ResolveHookApprovalToolKey(toolName)); } [Fact] public void ResolveHookApprovalToolKey_ReturnsNullForUnknownTools() { Assert.Null(CopilotApprovalCoordinator.ResolveHookApprovalToolKey("custom_tool")); Assert.Null(CopilotApprovalCoordinator.ResolveHookApprovalToolKey(null)); Assert.Null(CopilotApprovalCoordinator.ResolveHookApprovalToolKey("")); Assert.Null(CopilotApprovalCoordinator.ResolveHookApprovalToolKey(" ")); } [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.ApprovalToolKey); 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.Equal("icm-mcp-get_schedule", approvalEvent.ApprovalToolKey); 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); Assert.Equal("icm-mcp-get_schedule", approvalEvent.ApprovalToolKey); } [Fact] public async Task RequestApprovalAsync_RaisesApprovalAndCompletesAfterResolution() { CopilotApprovalCoordinator coordinator = new(); ApprovalRequestedEventDto? observedApproval = null; RunTurnCommandDto command = CreateApprovalCommand(); Task 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); Assert.Equal("lsp_ts_definition", observedApproval!.ApprovalToolKey); 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 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); Assert.Equal("apply_patch", observedApproval!.ToolName); Assert.Equal("write", observedApproval.ApprovalToolKey); 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 { ["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 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); Assert.Equal("read", firstApproval!.ApprovalToolKey); 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_AlwaysApproveCachesCanonicalWriteApprovalForModernHookTools() { CopilotApprovalCoordinator coordinator = new(); ApprovalRequestedEventDto? firstApproval = null; RunTurnCommandDto command = CreateApprovalCommand(); Task firstPending = 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")), approval => { firstApproval = approval; return Task.CompletedTask; }, CancellationToken.None); Assert.False(firstPending.IsCompleted); Assert.NotNull(firstApproval); Assert.Equal("write", firstApproval!.ApprovalToolKey); 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 PermissionRequestWrite { Kind = "write", ToolCallId = "tool-call-write-2", Intention = "Create docs draft", FileName = "docs\\guide.md", Diff = "@@ -0,0 +1 @@", NewFileContents = "# Guide\n", }, new PermissionInvocation { SessionId = "copilot-session-1", }, CreateToolCallRegistry(("tool-call-write-2", "write_file")), 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 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 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(() => 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? 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("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? autoApprovedToolNames = null, IReadOnlyList? 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 GetResponseAsync( IEnumerable messages, ChatOptions? options, CancellationToken cancellationToken) { throw new NotSupportedException(); } public async IAsyncEnumerable GetStreamingResponseAsync( IEnumerable messages, ChatOptions? options, [EnumeratorCancellation] CancellationToken cancellationToken) { yield break; } public object? GetService(Type serviceType, object? serviceKey) { return null; } public void Dispose() { } } }