using Aryx.AgentHost.Contracts; using Aryx.AgentHost.Services; using GitHub.Copilot.SDK; using Microsoft.Extensions.AI; namespace Aryx.AgentHost.Tests; public sealed class CopilotTurnExecutionStateTests { [Fact] public void ObserveSessionEvent_McpOauthRequired_SetsActiveAgent() { RunTurnCommandDto command = CreateCommand(); CopilotTurnExecutionState state = new(command); state.ObserveSessionEvent( command.Pattern.Agents[0], new McpOauthRequiredEvent { Data = new McpOauthRequiredData { RequestId = "oauth-request-1", ServerName = "Example MCP", ServerUrl = "https://example.com/mcp", }, }); Assert.True(state.ActiveAgent.HasValue); Assert.Equal("agent-1", state.ActiveAgent.Value.AgentId); Assert.Equal("Primary", state.ActiveAgent.Value.AgentName); } [Fact] public void ObserveSessionEvent_AssistantMessageDelta_QueuesThinkingActivity() { RunTurnCommandDto command = CreateCommand(); CopilotTurnExecutionState state = new(command); state.ObserveSessionEvent( command.Pattern.Agents[0], SessionEvent.FromJson( """ { "type": "assistant.message_delta", "data": { "messageId": "msg-1", "deltaContent": "Hello" }, "id": "11111111-1111-1111-1111-111111111111", "timestamp": "2026-03-27T00:00:00Z" } """)); AgentActivityEventDto activity = Assert.Single(state.DrainPendingEvents().OfType()); Assert.Equal("thinking", activity.ActivityType); Assert.Equal("agent-1", activity.AgentId); Assert.Equal("Primary", activity.AgentName); Assert.True(state.TryResolveObservedAgentForMessage("msg-1", out AgentIdentity observedAgent)); Assert.Equal("agent-1", observedAgent.AgentId); } [Fact] public void ObserveSessionEvent_ToolExecutionStart_TracksToolNameByCallIdAndQueuesToolActivity() { RunTurnCommandDto command = CreateCommand(); CopilotTurnExecutionState state = new(command); state.ObserveSessionEvent( command.Pattern.Agents[0], SessionEvent.FromJson( """{"type":"tool.execution_start","data":{"toolCallId":"tool-call-1","toolName":"view"},"id":"33333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:00Z"}""")); AgentActivityEventDto toolActivity = Assert.Single(state.DrainPendingEvents().OfType()); Assert.Equal("tool-calling", toolActivity.ActivityType); Assert.Equal("view", toolActivity.ToolName); Assert.Equal("tool-call-1", toolActivity.ToolCallId); Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-1", out string? toolName)); Assert.Equal("view", toolName); } [Fact] public void ObserveSessionEvent_ToolExecutionStart_DoesNotQueueToolActivityForHandoffTools() { RunTurnCommandDto command = CreateCommand(); CopilotTurnExecutionState state = new(command); state.ObserveSessionEvent( command.Pattern.Agents[0], SessionEvent.FromJson( """{"type":"tool.execution_start","data":{"toolCallId":"tool-call-1","toolName":"handoff_to_specialist"},"id":"1ce9d1dc-68f1-4df5-9728-f97017233279","timestamp":"2026-03-27T00:00:00Z"}""")); Assert.Empty(state.DrainPendingEvents().OfType()); Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-1", out string? toolName)); Assert.Equal("handoff_to_specialist", toolName); } [Fact] public void ObserveSessionEvent_AssistantMessageWithToolRequests_QueuesMessageReclassifiedEvent() { RunTurnCommandDto command = CreateCommand(); CopilotTurnExecutionState state = new(command); state.ObserveSessionEvent( command.Pattern.Agents[0], SessionEvent.FromJson( """ { "type": "assistant.message", "data": { "messageId": "msg-2", "content": "Let me search for that.", "toolRequests": [ { "toolCallId": "tool-call-1", "name": "rg", "arguments": { "pattern": "identifierUri" } } ] }, "id": "3f75988b-8e69-4c90-a203-6b01d1c1f90b", "timestamp": "2026-03-27T00:00:00Z" } """)); IReadOnlyList pending = state.DrainPendingEvents(); AgentActivityEventDto thinking = Assert.Single(pending.OfType()); Assert.Equal("thinking", thinking.ActivityType); MessageReclassifiedEventDto reclassified = Assert.Single(pending.OfType()); Assert.Equal("session-1", reclassified.SessionId); Assert.Equal("msg-2", reclassified.MessageId); Assert.Equal("thinking", reclassified.NewKind); } [Fact] public void ObserveSessionEvent_ToolExecutionStart_ReclassifiesLastObservedMessageOnce() { RunTurnCommandDto command = CreateCommand(); CopilotTurnExecutionState state = new(command); state.ObserveSessionEvent( command.Pattern.Agents[0], SessionEvent.FromJson( """ { "type": "assistant.message_delta", "data": { "messageId": "msg-3", "deltaContent": "Searching" }, "id": "0b65f0e9-d0fb-417e-ab5c-7a3343d8581b", "timestamp": "2026-03-27T00:00:00Z" } """)); _ = state.DrainPendingEvents(); state.ObserveSessionEvent( command.Pattern.Agents[0], SessionEvent.FromJson( """ { "type": "tool.execution_start", "data": { "toolCallId": "tool-call-1", "toolName": "rg" }, "id": "8f33240e-bd3f-475c-aeb6-a4b7908e47b0", "timestamp": "2026-03-27T00:00:01Z" } """)); state.ObserveSessionEvent( command.Pattern.Agents[0], SessionEvent.FromJson( """ { "type": "tool.execution_start", "data": { "toolCallId": "tool-call-2", "toolName": "view" }, "id": "a23f9c9a-f947-4282-866d-f599451c3899", "timestamp": "2026-03-27T00:00:02Z" } """)); IReadOnlyList pending = state.DrainPendingEvents(); AgentActivityEventDto[] toolActivities = [.. pending.OfType().Where(activity => activity.ActivityType == "tool-calling")]; Assert.Equal(2, toolActivities.Length); Assert.Contains(toolActivities, activity => activity.ToolCallId == "tool-call-1" && activity.ToolName == "rg"); Assert.Contains(toolActivities, activity => activity.ToolCallId == "tool-call-2" && activity.ToolName == "view"); MessageReclassifiedEventDto reclassified = Assert.Single(pending.OfType()); Assert.Equal("msg-3", reclassified.MessageId); Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-1", out string? firstToolName)); Assert.Equal("rg", firstToolName); Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-2", out string? secondToolName)); Assert.Equal("view", secondToolName); } [Fact] public void ObserveSessionEvent_AssistantMessageWithoutToolRequests_DoesNotQueueMessageReclassifiedEvent() { RunTurnCommandDto command = CreateCommand(); CopilotTurnExecutionState state = new(command); state.ObserveSessionEvent( command.Pattern.Agents[0], SessionEvent.FromJson( """ { "type": "assistant.message", "data": { "messageId": "msg-4", "content": "Final answer." }, "id": "d07fe954-1258-4f6a-bf79-1550d6143ed0", "timestamp": "2026-03-27T00:00:00Z" } """)); Assert.Empty(state.DrainPendingEvents().OfType()); } [Fact] public async Task EmitThinkingIfNeeded_DoesNotDuplicateQueuedThinkingActivity() { RunTurnCommandDto command = CreateCommand(); CopilotTurnExecutionState state = new(command); state.ObserveSessionEvent( command.Pattern.Agents[0], SessionEvent.FromJson( """ { "type": "assistant.reasoning_delta", "data": { "reasoningId": "reasoning-1", "deltaContent": "Planning" }, "id": "22222222-2222-2222-2222-222222222222", "timestamp": "2026-03-27T00:00:00Z" } """)); List activities = [.. state.DrainPendingEvents().OfType()]; await state.EmitThinkingIfNeeded( new AgentIdentity("agent-1", "Primary"), sidecarEvent => { activities.Add(Assert.IsType(sidecarEvent)); return Task.CompletedTask; }); AgentActivityEventDto thinking = Assert.Single(activities); Assert.Equal("thinking", thinking.ActivityType); Assert.Equal("agent-1", thinking.AgentId); } [Fact] public void ObserveSessionEvent_AssistantIntent_QueuesIntentEvent() { RunTurnCommandDto command = CreateCommand(); CopilotTurnExecutionState state = new(command); state.ObserveSessionEvent( command.Pattern.Agents[0], SessionEvent.FromJson( """ { "type": "assistant.intent", "data": { "intent": "Searching incident playbooks" }, "id": "64cf59fe-63f0-4217-adf4-9bd6b3a80452", "timestamp": "2026-03-27T00:00:00Z" } """)); IReadOnlyList pending = state.DrainPendingEvents(); AgentActivityEventDto thinking = Assert.Single(pending.OfType()); Assert.Equal("thinking", thinking.ActivityType); AssistantIntentEventDto intent = Assert.Single(pending.OfType()); Assert.Equal("session-1", intent.SessionId); Assert.Equal("agent-1", intent.AgentId); Assert.Equal("Searching incident playbooks", intent.Intent); } [Fact] public void ObserveSessionEvent_AssistantReasoningDelta_QueuesReasoningDeltaEvent() { RunTurnCommandDto command = CreateCommand(); CopilotTurnExecutionState state = new(command); state.ObserveSessionEvent( command.Pattern.Agents[0], SessionEvent.FromJson( """ { "type": "assistant.reasoning_delta", "data": { "reasoningId": "reasoning-2", "deltaContent": "Searching logs." }, "id": "bd269258-5e5d-46b6-bf3f-bd8cba793b1a", "timestamp": "2026-03-27T00:00:00Z" } """)); IReadOnlyList pending = state.DrainPendingEvents(); AgentActivityEventDto thinking = Assert.Single(pending.OfType()); Assert.Equal("thinking", thinking.ActivityType); ReasoningDeltaEventDto reasoning = Assert.Single(pending.OfType()); Assert.Equal("session-1", reasoning.SessionId); Assert.Equal("agent-1", reasoning.AgentId); Assert.Equal("reasoning-2", reasoning.ReasoningId); Assert.Equal("Searching logs.", reasoning.ContentDelta); } [Fact] public void DrainPendingMcpOauthRequests_ReturnsQueuedRequestsAndClearsQueue() { RunTurnCommandDto command = CreateCommand(); CopilotTurnExecutionState state = new(command); McpOauthRequiredEventDto request = new() { Type = "mcp-oauth-required", RequestId = command.RequestId, SessionId = command.SessionId, OauthRequestId = "oauth-request-1", ServerName = "Example MCP", ServerUrl = "https://example.com/mcp", }; state.EnqueuePendingMcpOauthRequest(request); IReadOnlyList firstDrain = state.DrainPendingMcpOauthRequests(); IReadOnlyList secondDrain = state.DrainPendingMcpOauthRequests(); McpOauthRequiredEventDto drained = Assert.Single(firstDrain); Assert.Equal("oauth-request-1", drained.OauthRequestId); Assert.Empty(secondDrain); } [Fact] public void ObserveSessionEvent_SubagentStarted_QueuesSubagentEvent() { RunTurnCommandDto command = CreateCommand(); CopilotTurnExecutionState state = new(command); state.ObserveSessionEvent( command.Pattern.Agents[0], SessionEvent.FromJson( """ { "type": "subagent.started", "data": { "toolCallId": "tool-call-1", "agentName": "designer", "agentDisplayName": "Designer", "agentDescription": "Design specialist" }, "id": "44444444-4444-4444-4444-444444444444", "timestamp": "2026-03-27T00:00:00Z" } """)); SubagentEventDto evt = Assert.Single(state.DrainPendingEvents().OfType()); Assert.Equal("started", evt.EventKind); Assert.Equal("tool-call-1", evt.ToolCallId); Assert.Equal("designer", evt.CustomAgentName); Assert.Equal("Designer", evt.CustomAgentDisplayName); } [Fact] public void ObserveSessionEvent_SkillInvoked_QueuesSkillEvent() { RunTurnCommandDto command = CreateCommand(); CopilotTurnExecutionState state = new(command); state.ObserveSessionEvent( command.Pattern.Agents[0], SessionEvent.FromJson( """ { "type": "skill.invoked", "data": { "name": "reviewer", "path": "C:\\skills\\reviewer\\SKILL.md", "content": "# Reviewer", "allowedTools": ["view"], "pluginName": "aryx-plugin", "pluginVersion": "1.0.0" }, "id": "55555555-5555-5555-5555-555555555555", "timestamp": "2026-03-27T00:00:00Z" } """)); SkillInvokedEventDto evt = Assert.Single(state.DrainPendingEvents().OfType()); Assert.Equal("reviewer", evt.SkillName); Assert.Equal(@"C:\skills\reviewer\SKILL.md", evt.Path); Assert.Equal(["view"], evt.AllowedTools); Assert.Equal("aryx-plugin", evt.PluginName); } [Fact] public void ObserveSessionEvent_HookStart_QueuesHookLifecycleEvent() { RunTurnCommandDto command = CreateCommand(); CopilotTurnExecutionState state = new(command); state.ObserveSessionEvent(command.Pattern.Agents[0], CreateHookStartEvent()); HookLifecycleEventDto evt = Assert.Single(state.DrainPendingEvents().OfType()); Assert.Equal("start", evt.Phase); Assert.Equal("postToolUse", evt.HookType); Assert.Equal("hook-1", evt.HookInvocationId); Assert.NotNull(evt.Input); } [Fact] public void ObserveSessionEvent_HookEnd_QueuesHookLifecycleEvent() { RunTurnCommandDto command = CreateCommand(); CopilotTurnExecutionState state = new(command); state.ObserveSessionEvent(command.Pattern.Agents[0], CreateHookEndEvent()); HookLifecycleEventDto evt = Assert.Single(state.DrainPendingEvents().OfType()); Assert.Equal("end", evt.Phase); Assert.Equal("postToolUse", evt.HookType); Assert.Equal("hook-1", evt.HookInvocationId); Assert.True(evt.Success); } [Fact] public void ObserveSessionEvent_HookLifecycleEvents_AreSuppressedWhenConfigured() { RunTurnCommandDto command = CreateCommand(); CopilotTurnExecutionState state = new(command) { SuppressHookLifecycleEvents = true, }; state.ObserveSessionEvent(command.Pattern.Agents[0], CreateHookStartEvent()); state.ObserveSessionEvent(command.Pattern.Agents[0], CreateHookEndEvent()); Assert.Empty(state.DrainPendingEvents()); } [Fact] public void ObserveSessionEvent_AssistantUsage_QueuesAssistantUsageEvent() { RunTurnCommandDto command = CreateCommand(); CopilotTurnExecutionState state = new(command); state.ObserveSessionEvent( command.Pattern.Agents[0], SessionEvent.FromJson( """ { "type": "assistant.usage", "data": { "model": "gpt-5.4", "inputTokens": 1200, "outputTokens": 300, "cacheReadTokens": 50, "cacheWriteTokens": 10, "cost": 0.42, "duration": 8200, "quotaSnapshots": { "premium_interactions": { "entitlementRequests": 50, "usedRequests": 12, "remainingPercentage": 76, "overage": 0, "overageAllowedWithExhaustedQuota": true, "resetDate": "2026-04-01T00:00:00Z" } }, "copilotUsage": { "tokenDetails": [ { "batchSize": 1, "costPerBatch": 1, "tokenCount": 1500, "tokenType": "input" } ], "totalNanoAiu": 1200000000 } }, "id": "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa", "timestamp": "2026-03-27T00:00:00Z" } """)); AssistantUsageEventDto evt = Assert.Single(state.DrainPendingEvents().OfType()); Assert.Equal("session-1", evt.SessionId); Assert.Equal("agent-1", evt.AgentId); Assert.Equal("Primary", evt.AgentName); Assert.Equal("gpt-5.4", evt.Model); Assert.Equal(1200, evt.InputTokens); Assert.Equal(300, evt.OutputTokens); Assert.Equal(0.42, evt.Cost); Assert.Equal(8200, evt.Duration); Assert.Equal(1200000000, evt.TotalNanoAiu); QuotaSnapshotDto snapshot = Assert.Single(evt.QuotaSnapshots!.Values); Assert.Equal(50, snapshot.EntitlementRequests); Assert.Equal(12, snapshot.UsedRequests); Assert.Equal(76, snapshot.RemainingPercentage); } [Fact] public void ObserveSessionEvent_SessionCompactionComplete_QueuesCompactionEvent() { RunTurnCommandDto command = CreateCommand(); CopilotTurnExecutionState state = new(command); state.ObserveSessionEvent( command.Pattern.Agents[0], SessionEvent.FromJson( """ { "type": "session.compaction_complete", "data": { "success": true, "preCompactionTokens": 1000, "postCompactionTokens": 400, "messagesRemoved": 8, "tokensRemoved": 600, "summaryContent": "Compacted summary", "checkpointNumber": 2, "checkpointPath": "C:\\Users\\me\\.copilot\\session-state\\checkpoint-2.json" }, "id": "77777777-7777-7777-7777-777777777777", "timestamp": "2026-03-27T00:00:00Z" } """)); SessionCompactionEventDto evt = Assert.Single(state.DrainPendingEvents().OfType()); Assert.Equal("complete", evt.Phase); Assert.True(evt.Success); Assert.Equal(1000, evt.PreCompactionTokens); Assert.Equal(400, evt.PostCompactionTokens); Assert.Equal("Compacted summary", evt.SummaryContent); } [Fact] public void ObserveSessionEvent_PendingMessagesModified_QueuesPendingMessageSignal() { RunTurnCommandDto command = CreateCommand(); CopilotTurnExecutionState state = new(command); state.ObserveSessionEvent( command.Pattern.Agents[0], SessionEvent.FromJson( """ { "type": "pending_messages.modified", "data": {}, "id": "88888888-8888-8888-8888-888888888888", "timestamp": "2026-03-27T00:00:00Z" } """)); PendingMessagesModifiedEventDto evt = Assert.Single(state.DrainPendingEvents().OfType()); Assert.Equal("session-1", evt.SessionId); Assert.Equal("agent-1", evt.AgentId); } private static SessionEvent CreateHookStartEvent() { return SessionEvent.FromJson( """ { "type": "hook.start", "data": { "hookInvocationId": "hook-1", "hookType": "postToolUse", "input": { "toolName": "view" } }, "id": "66666666-6666-6666-6666-666666666666", "timestamp": "2026-03-27T00:00:00Z" } """); } private static SessionEvent CreateHookEndEvent() { return SessionEvent.FromJson( """ { "type": "hook.end", "data": { "hookInvocationId": "hook-1", "hookType": "postToolUse", "success": true, "output": { "status": "ok" } }, "id": "99999999-9999-9999-9999-999999999999", "timestamp": "2026-03-27T00:00:00Z" } """); } [Fact] public void FinalizeCompletedMessages_TagsReclassifiedMessagesAsThinking() { RunTurnCommandDto command = CreateCommand(); CopilotTurnExecutionState state = new(command); // Simulate assistant message with tool requests → triggers reclassification state.ObserveSessionEvent( command.Pattern.Agents[0], SessionEvent.FromJson( """ { "type": "assistant.message", "data": { "messageId": "msg-intermediate", "content": "Let me search...", "toolRequests": [ { "toolCallId": "tool-call-1", "name": "grep", "arguments": {} } ] }, "id": "11111111-1111-1111-1111-111111111111", "timestamp": "2026-03-27T00:00:00Z" } """)); state.DrainPendingEvents(); // Build completed messages with a reclassified and a non-reclassified message ChatMessage intermediateMsg = new(ChatRole.Assistant, "Let me search..."); intermediateMsg.MessageId = "msg-intermediate"; intermediateMsg.AuthorName = "Primary"; ChatMessage finalMsg = new(ChatRole.Assistant, "Here are the results."); finalMsg.MessageId = "msg-final"; finalMsg.AuthorName = "Primary"; state.UpdateCompletedMessages([intermediateMsg, finalMsg], []); IReadOnlyList messages = state.FinalizeCompletedMessages(); ChatMessageDto intermediate = Assert.Single(messages, m => m.Id == "msg-intermediate"); Assert.Equal("thinking", intermediate.MessageKind); ChatMessageDto final_ = Assert.Single(messages, m => m.Id == "msg-final"); Assert.Null(final_.MessageKind); } private static RunTurnCommandDto CreateCommand() { return new RunTurnCommandDto { RequestId = "turn-1", SessionId = "session-1", Pattern = new PatternDefinitionDto { Id = "pattern-1", Name = "MCP OAuth Pattern", Mode = "single", Availability = "available", Agents = [ new PatternAgentDefinitionDto { Id = "agent-1", Name = "Primary", Model = "gpt-5.4", Instructions = "Help with the request.", }, ], }, }; } }