using System.Text.Json; using Aryx.AgentHost.Contracts; using Aryx.AgentHost.Services; using GitHub.Copilot.SDK.Rpc; namespace Aryx.AgentHost.Tests; public sealed class SidecarProtocolHostTests { private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) { PropertyNameCaseInsensitive = true, }; [Fact] public async Task DescribeCapabilitiesCommand_ReturnsCapabilitiesAndCompletion() { IReadOnlyList events = await RunHostAsync(new DescribeCapabilitiesCommandDto { Type = "describe-capabilities", RequestId = "cap-1", }, CreateHostForTests()); Assert.Collection( events, capabilitiesEvent => { Assert.Equal("capabilities", capabilitiesEvent.GetProperty("type").GetString()); Assert.Equal("cap-1", capabilitiesEvent.GetProperty("requestId").GetString()); JsonElement capabilities = capabilitiesEvent.GetProperty("capabilities"); Assert.Equal("dotnet-maf", capabilities.GetProperty("runtime").GetString()); JsonElement modes = capabilities.GetProperty("modes"); Assert.True(modes.GetProperty("single").GetProperty("available").GetBoolean()); Assert.False(modes.GetProperty("magentic").GetProperty("available").GetBoolean()); JsonElement[] models = capabilities.GetProperty("models").EnumerateArray().ToArray(); JsonElement model = Assert.Single(models); Assert.Equal("gpt-5.4", model.GetProperty("id").GetString()); Assert.Equal("medium", model.GetProperty("defaultReasoningEffort").GetString()); JsonElement[] runtimeTools = capabilities.GetProperty("runtimeTools").EnumerateArray().ToArray(); JsonElement runtimeTool = Assert.Single(runtimeTools); Assert.Equal("web_fetch", runtimeTool.GetProperty("id").GetString()); Assert.Equal("web_fetch", runtimeTool.GetProperty("label").GetString()); JsonElement connection = capabilities.GetProperty("connection"); Assert.Equal("ready", connection.GetProperty("status").GetString()); Assert.Equal(@"C:\tools\copilot\copilot.exe", connection.GetProperty("copilotCliPath").GetString()); JsonElement cliVersion = connection.GetProperty("copilotCliVersion"); Assert.Equal("latest", cliVersion.GetProperty("status").GetString()); Assert.Equal("1.0.10", cliVersion.GetProperty("installedVersion").GetString()); JsonElement account = connection.GetProperty("account"); Assert.True(account.GetProperty("authenticated").GetBoolean()); Assert.Equal("octocat", account.GetProperty("login").GetString()); string magenticReason = modes.GetProperty("magentic").GetProperty("reason").GetString() ?? string.Empty; Assert.Contains("unsupported", magenticReason, StringComparison.OrdinalIgnoreCase); }, completionEvent => { Assert.Equal("command-complete", completionEvent.GetProperty("type").GetString()); Assert.Equal("cap-1", completionEvent.GetProperty("requestId").GetString()); }); } [Fact] public async Task ValidatePatternCommand_ReturnsIssuesAndCompletion() { IReadOnlyList events = await RunHostAsync(new ValidatePatternCommandDto { Type = "validate-pattern", RequestId = "validate-1", Pattern = new PatternDefinitionDto { Id = "single-pattern", Name = "", Mode = "single", Availability = "available", Agents = [ CreateAgent(), CreateAgent(id: "agent-2", name: "Reviewer", model: ""), ], }, }); Assert.Collection( events, validationEvent => { Assert.Equal("pattern-validation", validationEvent.GetProperty("type").GetString()); Assert.Equal("validate-1", validationEvent.GetProperty("requestId").GetString()); JsonElement[] issues = validationEvent.GetProperty("issues").EnumerateArray().ToArray(); Assert.Contains(issues, issue => issue.GetProperty("field").GetString() == "name" && issue.GetProperty("message").GetString() == "Pattern name is required."); Assert.Contains(issues, issue => issue.GetProperty("field").GetString() == "agents" && issue.GetProperty("message").GetString() == "Single-agent chat requires exactly one agent."); Assert.Contains(issues, issue => issue.GetProperty("field").GetString() == "agents.model" && issue.GetProperty("message").GetString() == "Agent \"Reviewer\" requires a model identifier."); }, completionEvent => { Assert.Equal("command-complete", completionEvent.GetProperty("type").GetString()); Assert.Equal("validate-1", completionEvent.GetProperty("requestId").GetString()); }); } [Fact] public async Task RunTurnCommand_ReturnsActivityEventsAndCompletion() { SidecarProtocolHost host = new( new PatternValidator(), new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) => { await onActivity(new AgentActivityEventDto { Type = "agent-activity", RequestId = command.RequestId, SessionId = command.SessionId, ActivityType = "thinking", AgentId = "agent-1", AgentName = "Primary", }); await onDelta(new TurnDeltaEventDto { Type = "turn-delta", RequestId = command.RequestId, SessionId = command.SessionId, MessageId = "assistant-1", AuthorName = "Primary", ContentDelta = "Hello", Content = "Hello", }); await onActivity(new AgentActivityEventDto { Type = "agent-activity", RequestId = command.RequestId, SessionId = command.SessionId, ActivityType = "tool-calling", AgentId = "agent-1", AgentName = "Primary", ToolName = "read_file", }); return [ new ChatMessageDto { Id = "assistant-1", Role = "assistant", AuthorName = "Primary", Content = "Hello world", CreatedAt = "2026-01-01T00:00:00.0000000Z", }, ]; })); IReadOnlyList events = await RunHostAsync( new RunTurnCommandDto { Type = "run-turn", RequestId = "turn-1", SessionId = "session-1", ProjectPath = "C:\\workspace\\project", Pattern = new PatternDefinitionDto { Id = "pattern-1", Name = "Single Agent", Mode = "single", Availability = "available", Agents = [ CreateAgent(name: "Primary"), ], }, Messages = [ new ChatMessageDto { Id = "user-1", Role = "user", AuthorName = "You", Content = "Hello", CreatedAt = "2026-01-01T00:00:00.0000000Z", }, ], }, host); Assert.Collection( events, thinkingEvent => { Assert.Equal("agent-activity", thinkingEvent.GetProperty("type").GetString()); Assert.Equal("turn-1", thinkingEvent.GetProperty("requestId").GetString()); Assert.Equal("session-1", thinkingEvent.GetProperty("sessionId").GetString()); Assert.Equal("thinking", thinkingEvent.GetProperty("activityType").GetString()); Assert.Equal("agent-1", thinkingEvent.GetProperty("agentId").GetString()); Assert.Equal("Primary", thinkingEvent.GetProperty("agentName").GetString()); }, deltaEvent => { Assert.Equal("turn-delta", deltaEvent.GetProperty("type").GetString()); Assert.Equal("Hello", deltaEvent.GetProperty("contentDelta").GetString()); Assert.Equal("Hello", deltaEvent.GetProperty("content").GetString()); }, toolEvent => { Assert.Equal("agent-activity", toolEvent.GetProperty("type").GetString()); Assert.Equal("tool-calling", toolEvent.GetProperty("activityType").GetString()); Assert.Equal("agent-1", toolEvent.GetProperty("agentId").GetString()); Assert.Equal("read_file", toolEvent.GetProperty("toolName").GetString()); }, completionEvent => { Assert.Equal("turn-complete", completionEvent.GetProperty("type").GetString()); Assert.Equal("session-1", completionEvent.GetProperty("sessionId").GetString()); Assert.False(completionEvent.GetProperty("cancelled").GetBoolean()); JsonElement[] messages = completionEvent.GetProperty("messages").EnumerateArray().ToArray(); Assert.Single(messages); Assert.Equal("Hello world", messages[0].GetProperty("content").GetString()); }, commandCompleteEvent => { Assert.Equal("command-complete", commandCompleteEvent.GetProperty("type").GetString()); Assert.Equal("turn-1", commandCompleteEvent.GetProperty("requestId").GetString()); }); } [Fact] public async Task RunTurnCommand_DeserializesInteractionMode() { string? capturedMode = null; SidecarProtocolHost host = new( new PatternValidator(), new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) => { capturedMode = command.Mode; return []; })); await RunHostAsync( new RunTurnCommandDto { Type = "run-turn", RequestId = "turn-plan", SessionId = "session-1", ProjectPath = "C:\\workspace\\project", Mode = "plan", Pattern = new PatternDefinitionDto { Id = "pattern-1", Name = "Single Agent", Mode = "single", Availability = "available", Agents = [ CreateAgent(name: "Primary"), ], }, }, host); Assert.Equal("plan", capturedMode); } [Fact] public async Task RunTurnCommand_ReturnsApprovalEvents() { SidecarProtocolHost host = new( new PatternValidator(), new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) => { await onApproval(new ApprovalRequestedEventDto { Type = "approval-requested", RequestId = command.RequestId, SessionId = command.SessionId, ApprovalId = "approval-1", ApprovalKind = "tool-call", AgentId = "agent-1", AgentName = "Primary", PermissionKind = "tool access", Title = "Approve tool access", PermissionDetail = new PermissionDetailDto { Kind = "shell", Command = "git status", Intention = "Inspect repository state", PossiblePaths = ["README.md"], }, }); return []; })); IReadOnlyList events = await RunHostAsync( new RunTurnCommandDto { Type = "run-turn", RequestId = "turn-approval", SessionId = "session-1", ProjectPath = "C:\\workspace\\project", Pattern = new PatternDefinitionDto { Id = "pattern-1", Name = "Single Agent", Mode = "single", Availability = "available", Agents = [ CreateAgent(name: "Primary"), ], }, }, host); Assert.Collection( events, approvalEvent => { Assert.Equal("approval-requested", approvalEvent.GetProperty("type").GetString()); Assert.Equal("turn-approval", approvalEvent.GetProperty("requestId").GetString()); Assert.Equal("approval-1", approvalEvent.GetProperty("approvalId").GetString()); Assert.Equal("tool-call", approvalEvent.GetProperty("approvalKind").GetString()); Assert.Equal("Approve tool access", approvalEvent.GetProperty("title").GetString()); JsonElement permissionDetail = approvalEvent.GetProperty("permissionDetail"); Assert.Equal("shell", permissionDetail.GetProperty("kind").GetString()); Assert.Equal("git status", permissionDetail.GetProperty("command").GetString()); Assert.Equal("Inspect repository state", permissionDetail.GetProperty("intention").GetString()); Assert.Equal("README.md", Assert.Single(permissionDetail.GetProperty("possiblePaths").EnumerateArray()).GetString()); }, completionEvent => { Assert.Equal("turn-complete", completionEvent.GetProperty("type").GetString()); Assert.False(completionEvent.GetProperty("cancelled").GetBoolean()); }, commandCompleteEvent => { Assert.Equal("command-complete", commandCompleteEvent.GetProperty("type").GetString()); Assert.Equal("turn-approval", commandCompleteEvent.GetProperty("requestId").GetString()); }); } [Fact] public async Task RunTurnCommand_ReturnsUserInputEvents() { SidecarProtocolHost host = new( new PatternValidator(), new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) => { await onUserInput(new UserInputRequestedEventDto { Type = "user-input-requested", RequestId = command.RequestId, SessionId = command.SessionId, UserInputId = "user-input-1", AgentId = "agent-1", AgentName = "Primary", Question = "What should I do next?", Choices = ["Continue", "Stop"], AllowFreeform = true, }); return []; })); IReadOnlyList events = await RunHostAsync( new RunTurnCommandDto { Type = "run-turn", RequestId = "turn-user-input", SessionId = "session-1", ProjectPath = "C:\\workspace\\project", Pattern = new PatternDefinitionDto { Id = "pattern-1", Name = "Single Agent", Mode = "single", Availability = "available", Agents = [ CreateAgent(name: "Primary"), ], }, }, host); Assert.Collection( events, userInputEvent => { Assert.Equal("user-input-requested", userInputEvent.GetProperty("type").GetString()); Assert.Equal("turn-user-input", userInputEvent.GetProperty("requestId").GetString()); Assert.Equal("session-1", userInputEvent.GetProperty("sessionId").GetString()); Assert.Equal("user-input-1", userInputEvent.GetProperty("userInputId").GetString()); Assert.Equal("Primary", userInputEvent.GetProperty("agentName").GetString()); Assert.Equal("What should I do next?", userInputEvent.GetProperty("question").GetString()); string[] choices = userInputEvent.GetProperty("choices") .EnumerateArray() .Select(choice => choice.GetString() ?? string.Empty) .ToArray(); Assert.Equal(["Continue", "Stop"], choices); Assert.True(userInputEvent.GetProperty("allowFreeform").GetBoolean()); }, completionEvent => { Assert.Equal("turn-complete", completionEvent.GetProperty("type").GetString()); Assert.False(completionEvent.GetProperty("cancelled").GetBoolean()); }, commandCompleteEvent => { Assert.Equal("command-complete", commandCompleteEvent.GetProperty("type").GetString()); Assert.Equal("turn-user-input", commandCompleteEvent.GetProperty("requestId").GetString()); }); } [Fact] public async Task RunTurnCommand_ReturnsMcpOauthRequiredEvents() { SidecarProtocolHost host = new( new PatternValidator(), new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) => { await onMcpOAuthRequired(new McpOauthRequiredEventDto { Type = "mcp-oauth-required", RequestId = command.RequestId, SessionId = command.SessionId, OauthRequestId = "oauth-request-1", AgentId = "agent-1", AgentName = "Primary", ServerName = "Example MCP", ServerUrl = "https://example.com/mcp", StaticClientConfig = new McpOauthStaticClientConfigDto { ClientId = "aryx-client", PublicClient = true, }, }); return []; })); IReadOnlyList events = await RunHostAsync( CreateRunTurnCommand(requestId: "turn-mcp-oauth"), host); Assert.Collection( events, oauthEvent => { Assert.Equal("mcp-oauth-required", oauthEvent.GetProperty("type").GetString()); Assert.Equal("turn-mcp-oauth", oauthEvent.GetProperty("requestId").GetString()); Assert.Equal("session-1", oauthEvent.GetProperty("sessionId").GetString()); Assert.Equal("oauth-request-1", oauthEvent.GetProperty("oauthRequestId").GetString()); Assert.Equal("Primary", oauthEvent.GetProperty("agentName").GetString()); Assert.Equal("Example MCP", oauthEvent.GetProperty("serverName").GetString()); Assert.Equal("https://example.com/mcp", oauthEvent.GetProperty("serverUrl").GetString()); JsonElement staticClientConfig = oauthEvent.GetProperty("staticClientConfig"); Assert.Equal("aryx-client", staticClientConfig.GetProperty("clientId").GetString()); Assert.True(staticClientConfig.GetProperty("publicClient").GetBoolean()); }, completionEvent => { Assert.Equal("turn-complete", completionEvent.GetProperty("type").GetString()); Assert.False(completionEvent.GetProperty("cancelled").GetBoolean()); }, commandCompleteEvent => { Assert.Equal("command-complete", commandCompleteEvent.GetProperty("type").GetString()); Assert.Equal("turn-mcp-oauth", commandCompleteEvent.GetProperty("requestId").GetString()); }); } [Fact] public async Task RunTurnCommand_ReturnsExitPlanModeEvents() { SidecarProtocolHost host = new( new PatternValidator(), new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) => { await onExitPlanMode(new ExitPlanModeRequestedEventDto { Type = "exit-plan-mode-requested", RequestId = command.RequestId, SessionId = command.SessionId, ExitPlanId = "exit-plan-1", AgentId = "agent-1", AgentName = "Primary", Summary = "Proposed implementation plan", PlanContent = "1. Inspect\n2. Change\n3. Validate", Actions = ["interactive", "autopilot"], RecommendedAction = "interactive", }); return []; })); IReadOnlyList events = await RunHostAsync( new RunTurnCommandDto { Type = "run-turn", RequestId = "turn-plan-mode", SessionId = "session-1", ProjectPath = "C:\\workspace\\project", Mode = "plan", Pattern = new PatternDefinitionDto { Id = "pattern-1", Name = "Single Agent", Mode = "single", Availability = "available", Agents = [ CreateAgent(name: "Primary"), ], }, }, host); Assert.Collection( events, exitPlanEvent => { Assert.Equal("exit-plan-mode-requested", exitPlanEvent.GetProperty("type").GetString()); Assert.Equal("turn-plan-mode", exitPlanEvent.GetProperty("requestId").GetString()); Assert.Equal("exit-plan-1", exitPlanEvent.GetProperty("exitPlanId").GetString()); Assert.Equal("Primary", exitPlanEvent.GetProperty("agentName").GetString()); Assert.Equal("Proposed implementation plan", exitPlanEvent.GetProperty("summary").GetString()); Assert.Equal("1. Inspect\n2. Change\n3. Validate", exitPlanEvent.GetProperty("planContent").GetString()); string[] actions = exitPlanEvent.GetProperty("actions") .EnumerateArray() .Select(action => action.GetString() ?? string.Empty) .ToArray(); Assert.Equal(["interactive", "autopilot"], actions); Assert.Equal("interactive", exitPlanEvent.GetProperty("recommendedAction").GetString()); }, completionEvent => { Assert.Equal("turn-complete", completionEvent.GetProperty("type").GetString()); Assert.False(completionEvent.GetProperty("cancelled").GetBoolean()); }, commandCompleteEvent => { Assert.Equal("command-complete", commandCompleteEvent.GetProperty("type").GetString()); Assert.Equal("turn-plan-mode", commandCompleteEvent.GetProperty("requestId").GetString()); }); } [Fact] public async Task CancelTurnCommand_CancelsInProgressTurnAndCompletesBothCommands() { SidecarProtocolHost host = new( new PatternValidator(), new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) => { await Task.Delay(Timeout.Infinite, cancellationToken); return []; })); IReadOnlyList events = await RunHostAsync( [ CreateRunTurnCommand(requestId: "turn-cancel"), new CancelTurnCommandDto { Type = "cancel-turn", RequestId = "cancel-command-1", TargetRequestId = "turn-cancel", }, ], host); JsonElement turnCompleteEvent = AssertSingleEvent(events, "turn-complete", "turn-cancel"); Assert.Equal("session-1", turnCompleteEvent.GetProperty("sessionId").GetString()); Assert.True(turnCompleteEvent.GetProperty("cancelled").GetBoolean()); Assert.Empty(turnCompleteEvent.GetProperty("messages").EnumerateArray().ToArray()); AssertSingleEvent(events, "command-complete", "turn-cancel"); AssertSingleEvent(events, "command-complete", "cancel-command-1"); Assert.DoesNotContain(events, evt => evt.GetProperty("type").GetString() == "command-error"); } [Fact] public async Task CancelTurnCommand_UnknownTarget_CompletesWithoutError() { IReadOnlyList events = await RunHostAsync(new CancelTurnCommandDto { Type = "cancel-turn", RequestId = "cancel-command-unknown", TargetRequestId = "missing-turn", }); JsonElement completionEvent = Assert.Single(events); Assert.Equal("command-complete", completionEvent.GetProperty("type").GetString()); Assert.Equal("cancel-command-unknown", completionEvent.GetProperty("requestId").GetString()); } [Fact] public async Task CancelTurnCommand_AfterTurnCompletion_IsNoOp() { SidecarProtocolHost host = new( new PatternValidator(), new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) => [])); await RunHostAsync(CreateRunTurnCommand(requestId: "turn-completed"), host); IReadOnlyList events = await RunHostAsync(new CancelTurnCommandDto { Type = "cancel-turn", RequestId = "cancel-command-completed", TargetRequestId = "turn-completed", }, host); JsonElement completionEvent = Assert.Single(events); Assert.Equal("command-complete", completionEvent.GetProperty("type").GetString()); Assert.Equal("cancel-command-completed", completionEvent.GetProperty("requestId").GetString()); } [Fact] public async Task ResolveApprovalCommand_DelegatesToWorkflowRunnerAndCompletes() { ResolveApprovalCommandDto? captured = null; SidecarProtocolHost host = new( new PatternValidator(), new FakeWorkflowRunner( handler: async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) => [], resolveApprovalHandler: (command, cancellationToken) => { captured = command; return Task.CompletedTask; })); IReadOnlyList events = await RunHostAsync( new ResolveApprovalCommandDto { Type = "resolve-approval", RequestId = "approval-command-1", ApprovalId = "approval-1", Decision = "approved", AlwaysApprove = true, }, host); JsonElement completionEvent = Assert.Single(events); Assert.Equal("command-complete", completionEvent.GetProperty("type").GetString()); Assert.Equal("approval-command-1", completionEvent.GetProperty("requestId").GetString()); Assert.Equal("approval-1", captured?.ApprovalId); Assert.Equal("approved", captured?.Decision); Assert.True(captured?.AlwaysApprove ?? false); } [Fact] public async Task ResolveUserInputCommand_DelegatesToWorkflowRunnerAndCompletes() { ResolveUserInputCommandDto? captured = null; SidecarProtocolHost host = new( new PatternValidator(), new FakeWorkflowRunner( handler: async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) => [], resolveUserInputHandler: (command, cancellationToken) => { captured = command; return Task.CompletedTask; })); IReadOnlyList events = await RunHostAsync( new ResolveUserInputCommandDto { Type = "resolve-user-input", RequestId = "user-input-command-1", UserInputId = "user-input-1", Answer = "Continue", WasFreeform = false, }, host); JsonElement completionEvent = Assert.Single(events); Assert.Equal("command-complete", completionEvent.GetProperty("type").GetString()); Assert.Equal("user-input-command-1", completionEvent.GetProperty("requestId").GetString()); Assert.Equal("user-input-1", captured?.UserInputId); Assert.Equal("Continue", captured?.Answer); Assert.False(captured?.WasFreeform); } [Fact] public void MapRuntimeTools_ExcludesOnlyInternalMetaToolsAndDeduplicatesByName() { IReadOnlyList runtimeTools = SidecarProtocolHost.MapRuntimeTools( [ new Tool { Name = "ask_user", Description = "Ask the user a question.", }, new Tool { Name = "report_intent", Description = "Report current intent.", }, new Tool { Name = "task_complete", Description = "Signal task completion.", }, new Tool { Name = "exit_plan_mode", Description = "Exit plan mode.", }, new Tool { Name = " web_fetch ", Description = " Fetch content from the web. ", }, new Tool { Name = "WEB_FETCH", Description = "Duplicate entry", }, ]); Assert.Collection( runtimeTools, exitPlanTool => { Assert.Equal("exit_plan_mode", exitPlanTool.Id); Assert.Equal("exit_plan_mode", exitPlanTool.Label); Assert.Equal("Exit plan mode.", exitPlanTool.Description); }, runtimeTool => { Assert.Equal("web_fetch", runtimeTool.Id); Assert.Equal("web_fetch", runtimeTool.Label); Assert.Equal("Fetch content from the web.", runtimeTool.Description); }); } [Fact] public void ClassifyConnectionStatus_ReturnsAuthRequiredForLoginFailures() { string status = SidecarProtocolHost.ClassifyConnectionStatus( new InvalidOperationException("Please run copilot auth login to continue.")); Assert.Equal("copilot-auth-required", status); } [Fact] public void CreateReadyConnectionDiagnostics_ReportsCliPathAndModelCount() { SidecarConnectionDiagnosticsDto diagnostics = SidecarProtocolHost.CreateReadyConnectionDiagnostics( @"C:\tools\copilot\copilot.exe", 2, new SidecarCopilotCliVersionDiagnosticsDto { Status = "outdated", InstalledVersion = "1.0.9", LatestVersion = "1.0.10", }, new SidecarCopilotAccountDiagnosticsDto { Authenticated = true, Login = "octocat", Host = "github.com", Organizations = ["github"], }); Assert.Equal("ready", diagnostics.Status); Assert.Equal(@"C:\tools\copilot\copilot.exe", diagnostics.CopilotCliPath); Assert.Contains("2 models", diagnostics.Summary, StringComparison.OrdinalIgnoreCase); Assert.Equal("outdated", diagnostics.CopilotCliVersion?.Status); Assert.Equal("octocat", diagnostics.Account?.Login); Assert.Equal(["github"], diagnostics.Account?.Organizations); Assert.False(string.IsNullOrWhiteSpace(diagnostics.CheckedAt)); } [Fact] public async Task ListSessionsCommand_ReturnsSessionsListedEvent() { SidecarProtocolHost host = new( new PatternValidator(), sessionManager: new FakeSessionManager { Sessions = [ new CopilotSessionInfoDto { CopilotSessionId = "aryx::session-1::agent-1", ManagedByAryx = true, SessionId = "session-1", AgentId = "agent-1", Summary = "Review session", }, ], }); IReadOnlyList events = await RunHostAsync( new ListSessionsCommandDto { Type = "list-sessions", RequestId = "list-1", }, host); JsonElement listedEvent = AssertSingleEvent(events, "sessions-listed", "list-1"); JsonElement session = Assert.Single(listedEvent.GetProperty("sessions").EnumerateArray()); Assert.Equal("aryx::session-1::agent-1", session.GetProperty("copilotSessionId").GetString()); Assert.Equal("session-1", session.GetProperty("sessionId").GetString()); } [Fact] public async Task DeleteSessionCommand_ReturnsDeletedSessionsEvent() { FakeSessionManager sessionManager = new() { DeletedSessions = [ new CopilotSessionInfoDto { CopilotSessionId = "aryx::session-1::agent-1", ManagedByAryx = true, SessionId = "session-1", AgentId = "agent-1", }, ], }; SidecarProtocolHost host = new( new PatternValidator(), sessionManager: sessionManager); IReadOnlyList events = await RunHostAsync( new DeleteSessionCommandDto { Type = "delete-session", RequestId = "delete-1", SessionId = "session-1", }, host); JsonElement deletedEvent = AssertSingleEvent(events, "sessions-deleted", "delete-1"); JsonElement session = Assert.Single(deletedEvent.GetProperty("sessions").EnumerateArray()); Assert.Equal("session-1", deletedEvent.GetProperty("sessionId").GetString()); Assert.Equal("aryx::session-1::agent-1", session.GetProperty("copilotSessionId").GetString()); Assert.Equal("session-1", sessionManager.DeletedAryxSessionId); } [Fact] public async Task GetQuotaCommand_ReturnsQuotaResultEvent() { SidecarProtocolHost host = new( new PatternValidator(), sessionManager: new FakeSessionManager { QuotaSnapshots = new Dictionary(StringComparer.Ordinal) { ["premium_interactions"] = new() { EntitlementRequests = 50, UsedRequests = 12, RemainingPercentage = 76, Overage = 0, OverageAllowedWithExhaustedQuota = true, ResetDate = "2026-04-01T00:00:00Z", }, }, }); IReadOnlyList events = await RunHostAsync( new GetQuotaCommandDto { Type = "get-quota", RequestId = "quota-1", }, host); JsonElement quotaEvent = AssertSingleEvent(events, "quota-result", "quota-1"); JsonElement snapshot = quotaEvent.GetProperty("quotaSnapshots").GetProperty("premium_interactions"); Assert.Equal(50, snapshot.GetProperty("entitlementRequests").GetDouble()); Assert.Equal(12, snapshot.GetProperty("usedRequests").GetDouble()); Assert.Equal(76, snapshot.GetProperty("remainingPercentage").GetDouble()); Assert.True(snapshot.GetProperty("overageAllowedWithExhaustedQuota").GetBoolean()); Assert.Equal("2026-04-01T00:00:00Z", snapshot.GetProperty("resetDate").GetString()); } [Fact] public async Task DisconnectSessionCommand_CancelsActiveTurnsForSession() { FakeWorkflowRunner runner = new(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) => { await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken); return []; }); SidecarProtocolHost host = new(new PatternValidator(), runner); IReadOnlyList events = await RunHostAsync( [ CreateRunTurnCommand(requestId: "turn-1", sessionId: "session-1"), new DisconnectSessionCommandDto { Type = "disconnect-session", RequestId = "disconnect-1", SessionId = "session-1", }, ], host); JsonElement disconnectedEvent = AssertSingleEvent(events, "session-disconnected", "disconnect-1"); string[] cancelledRequestIds = disconnectedEvent.GetProperty("cancelledRequestIds") .EnumerateArray() .Select(value => value.GetString() ?? string.Empty) .ToArray(); Assert.Equal(["turn-1"], cancelledRequestIds); JsonElement turnComplete = AssertSingleEvent(events, "turn-complete", "turn-1"); Assert.True(turnComplete.GetProperty("cancelled").GetBoolean()); } private static async Task> RunHostAsync( object command, SidecarProtocolHost? host = null) { return await RunHostAsync([command], host); } private static async Task> RunHostAsync( IReadOnlyList commands, SidecarProtocolHost? host = null) { string input = string.Join( Environment.NewLine, commands.Select(command => JsonSerializer.Serialize(command, JsonOptions))) + Environment.NewLine; using StringReader reader = new(input); using StringWriter writer = new(); await (host ?? CreateHostForTests()).RunAsync(reader, writer, CancellationToken.None); return ParseEvents(writer.ToString()); } private static JsonElement AssertSingleEvent( IEnumerable events, string eventType, string requestId) { return Assert.Single(events, evt => evt.GetProperty("type").GetString() == eventType && evt.GetProperty("requestId").GetString() == requestId); } private static SidecarProtocolHost CreateHostForTests() { return new SidecarProtocolHost( new PatternValidator(), capabilitiesProvider: _ => Task.FromResult(new SidecarCapabilitiesDto { Modes = new Dictionary(StringComparer.OrdinalIgnoreCase) { ["single"] = new() { Available = true }, ["sequential"] = new() { Available = true }, ["concurrent"] = new() { Available = true }, ["handoff"] = new() { Available = true }, ["group-chat"] = new() { Available = true }, ["magentic"] = new() { Available = false, Reason = "Microsoft Agent Framework currently documents Magentic orchestration as unsupported in C#.", }, }, Models = [ new SidecarModelCapabilityDto { Id = "gpt-5.4", Name = "GPT-5.4", SupportedReasoningEfforts = ["low", "medium", "high", "xhigh"], DefaultReasoningEffort = "medium", }, ], RuntimeTools = [ new SidecarRuntimeToolDto { Id = "web_fetch", Label = "web_fetch", Description = "Fetch content from the web.", }, ], Connection = new SidecarConnectionDiagnosticsDto { Status = "ready", Summary = "Connected to GitHub Copilot. 1 model is available.", CopilotCliPath = @"C:\tools\copilot\copilot.exe", CopilotCliVersion = new SidecarCopilotCliVersionDiagnosticsDto { Status = "latest", InstalledVersion = "1.0.10", LatestVersion = "1.0.10", }, Account = new SidecarCopilotAccountDiagnosticsDto { Authenticated = true, Login = "octocat", Host = "github.com", Organizations = ["github", "mona"], }, CheckedAt = "2026-01-01T00:00:00.0000000Z", }, })); } private static IReadOnlyList ParseEvents(string output) { List events = []; using StringReader reader = new(output); string? line; while ((line = reader.ReadLine()) is not null) { if (string.IsNullOrWhiteSpace(line)) { continue; } using JsonDocument document = JsonDocument.Parse(line); events.Add(document.RootElement.Clone()); } return events; } private static PatternAgentDefinitionDto CreateAgent( string id = "agent-1", string name = "Primary", string model = "gpt-5.4", string instructions = "Help with the user's request.") { return new PatternAgentDefinitionDto { Id = id, Name = name, Model = model, Instructions = instructions, }; } private static RunTurnCommandDto CreateRunTurnCommand( string requestId = "turn-1", string sessionId = "session-1") { return new RunTurnCommandDto { Type = "run-turn", RequestId = requestId, SessionId = sessionId, ProjectPath = "C:\\workspace\\project", Pattern = new PatternDefinitionDto { Id = "pattern-1", Name = "Single Agent", Mode = "single", Availability = "available", Agents = [ CreateAgent(name: "Primary"), ], }, Messages = [ new ChatMessageDto { Id = "user-1", Role = "user", AuthorName = "You", Content = "Hello", CreatedAt = "2026-01-01T00:00:00.0000000Z", }, ], }; } private sealed class FakeWorkflowRunner : ITurnWorkflowRunner { private readonly Func< RunTurnCommandDto, Func, Func, Func, Func, Func, Func, CancellationToken, Task>> _handler; private readonly Func _resolveApprovalHandler; private readonly Func _resolveUserInputHandler; public FakeWorkflowRunner( Func< RunTurnCommandDto, Func, Func, Func, Func, Func, Func, CancellationToken, Task>> handler, Func? resolveApprovalHandler = null, Func? resolveUserInputHandler = null) { _handler = handler; _resolveApprovalHandler = resolveApprovalHandler ?? ((_, _) => Task.CompletedTask); _resolveUserInputHandler = resolveUserInputHandler ?? ((_, _) => Task.CompletedTask); } public Task> RunTurnAsync( RunTurnCommandDto command, Func onDelta, Func onActivity, Func onApproval, Func onUserInput, Func onMcpOAuthRequired, Func onExitPlanMode, CancellationToken cancellationToken) { return _handler(command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken); } public Task ResolveApprovalAsync( ResolveApprovalCommandDto command, CancellationToken cancellationToken) { return _resolveApprovalHandler(command, cancellationToken); } public Task ResolveUserInputAsync( ResolveUserInputCommandDto command, CancellationToken cancellationToken) { return _resolveUserInputHandler(command, cancellationToken); } } private sealed class FakeSessionManager : ICopilotSessionManager { public IReadOnlyList Sessions { get; init; } = []; public IReadOnlyList DeletedSessions { get; init; } = []; public IReadOnlyDictionary QuotaSnapshots { get; init; } = new Dictionary(StringComparer.Ordinal); public string? DeletedAryxSessionId { get; private set; } public string? DeletedCopilotSessionId { get; private set; } public Task> ListSessionsAsync( CopilotSessionListFilterDto? filter, CancellationToken cancellationToken) { return Task.FromResult(Sessions); } public Task> DeleteSessionsAsync( string? aryxSessionId, string? copilotSessionId, CancellationToken cancellationToken) { DeletedAryxSessionId = aryxSessionId; DeletedCopilotSessionId = copilotSessionId; return Task.FromResult(DeletedSessions); } public Task> GetQuotaAsync( CancellationToken cancellationToken) { return Task.FromResult(QuotaSnapshots); } } }