From 008d8c1bd0fc3f9361332e22c331184fa9b741c4 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Wed, 8 Apr 2026 11:18:20 +0200 Subject: [PATCH] refactor: add provider abstraction to sidecar host Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Services/CopilotAgentProvider.cs | 288 +++++++++++++++++ .../Aryx.AgentHost/Services/IAgentProvider.cs | 12 + .../Services/ICopilotSessionManager.cs | 4 +- .../Services/SidecarProtocolHost.cs | 290 ++---------------- .../SidecarProtocolHostTests.cs | 135 +++++++- 5 files changed, 454 insertions(+), 275 deletions(-) create mode 100644 sidecar/src/Aryx.AgentHost/Services/CopilotAgentProvider.cs create mode 100644 sidecar/src/Aryx.AgentHost/Services/IAgentProvider.cs diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotAgentProvider.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotAgentProvider.cs new file mode 100644 index 0000000..a091c22 --- /dev/null +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotAgentProvider.cs @@ -0,0 +1,288 @@ +using GitHub.Copilot.SDK; +using GitHub.Copilot.SDK.Rpc; +using Aryx.AgentHost.Contracts; + +namespace Aryx.AgentHost.Services; + +internal sealed class CopilotAgentProvider : IAgentProvider +{ + private const string AskUserToolName = "ask_user"; + private static readonly HashSet ExcludedRuntimeToolNames = new(StringComparer.OrdinalIgnoreCase) + { + AskUserToolName, + "report_intent", + "task_complete", + }; + + private static readonly string[] AuthenticationErrorIndicators = + [ + "login", + "log in", + "sign in", + "authenticate", + "authentication", + "not signed in", + "not logged in", + "reauth", + "credential", + ]; + + public ITurnWorkflowRunner CreateWorkflowRunner(WorkflowValidator workflowValidator) + { + ArgumentNullException.ThrowIfNull(workflowValidator); + return new CopilotWorkflowRunner(workflowValidator); + } + + public Task GetCapabilitiesAsync(CancellationToken cancellationToken) + { + return BuildCapabilitiesAsync(cancellationToken); + } + + public IProviderSessionManager CreateSessionManager() + { + return new CopilotSessionManager(); + } + + private static async Task BuildCapabilitiesAsync(CancellationToken cancellationToken) + { + try + { + CopilotCliContext cliContext = CopilotCliPathResolver.ResolveCliContext(); + CapabilityProbeResult probe = await ProbeCapabilitiesAsync(cliContext, cancellationToken).ConfigureAwait(false); + return CreateCapabilities(probe.Models, probe.RuntimeTools, probe.Connection); + } + catch (Exception exception) + { + SidecarConnectionDiagnosticsDto connection = CreateMissingCliDiagnostics(exception); + Console.Error.WriteLine($"[aryx sidecar] {connection.Summary} {exception.Message}"); + return CreateCapabilities([], [], connection); + } + } + + private static async Task ProbeCapabilitiesAsync( + CopilotCliContext cliContext, + CancellationToken cancellationToken) + { + IReadOnlyList models = []; + IReadOnlyList runtimeTools = []; + SidecarCopilotAccountDiagnosticsDto? account = null; + SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null; + Task cliVersionTask = + CopilotConnectionMetadataResolver.GetCliVersionDiagnosticsAsync(cliContext, cancellationToken); + + try + { + CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions(cliContext); + + await using CopilotClient client = new(clientOptions); + await client.StartAsync(cancellationToken).ConfigureAwait(false); + + GetAuthStatusResponse? authStatus = + await CopilotConnectionMetadataResolver.TryGetAuthStatusAsync(client, cancellationToken).ConfigureAwait(false); + account = await CopilotConnectionMetadataResolver.CreateAccountDiagnosticsAsync( + authStatus, + cliContext.Environment, + cancellationToken) + .ConfigureAwait(false); + + models = await ListAvailableModelsAsync(client, cancellationToken).ConfigureAwait(false); + runtimeTools = await TryListAvailableRuntimeToolsAsync(client, cancellationToken).ConfigureAwait(false); + cliVersion = await cliVersionTask.ConfigureAwait(false); + + return new CapabilityProbeResult( + models, + runtimeTools, + CreateReadyConnectionDiagnostics(cliContext.CliPath, models.Count, cliVersion, account)); + } + catch (Exception exception) + { + cliVersion = await cliVersionTask.ConfigureAwait(false); + Console.Error.WriteLine($"[aryx sidecar] Failed to list available Copilot models: {exception.Message}"); + + return new CapabilityProbeResult( + models, + runtimeTools, + CreateFailureConnectionDiagnostics(cliContext.CliPath, exception, cliVersion, account)); + } + } + + private static SidecarCapabilitiesDto CreateCapabilities( + IReadOnlyList models, + IReadOnlyList runtimeTools, + SidecarConnectionDiagnosticsDto connection) + { + return new SidecarCapabilitiesDto + { + Modes = BuildModeCapabilities(), + Models = models, + RuntimeTools = runtimeTools, + Connection = connection, + }; + } + + private static Dictionary BuildModeCapabilities() + { + return 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#.", + }, + }; + } + + private static async Task> ListAvailableModelsAsync( + CopilotClient client, + CancellationToken cancellationToken) + { + List models = await client.ListModelsAsync(cancellationToken).ConfigureAwait(false); + return models + .Select(model => new SidecarModelCapabilityDto + { + Id = model.Id, + Name = model.Name, + SupportedReasoningEfforts = (model.SupportedReasoningEfforts ?? []) + .Where(IsReasoningEffort) + .Distinct(StringComparer.Ordinal) + .ToList(), + DefaultReasoningEffort = IsReasoningEffort(model.DefaultReasoningEffort) + ? model.DefaultReasoningEffort + : null, + }) + .OrderBy(model => model.Name, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static async Task> TryListAvailableRuntimeToolsAsync( + CopilotClient client, + CancellationToken cancellationToken) + { + try + { + return await ListAvailableRuntimeToolsAsync(client, cancellationToken).ConfigureAwait(false); + } + catch (Exception exception) + { + Console.Error.WriteLine($"[aryx sidecar] Failed to list available Copilot runtime tools: {exception.Message}"); + return []; + } + } + + private static async Task> ListAvailableRuntimeToolsAsync( + CopilotClient client, + CancellationToken cancellationToken) + { + ToolsListResult result = await client.Rpc.Tools.ListAsync(null!, cancellationToken).ConfigureAwait(false); + return MapRuntimeTools(result.Tools); + } + + internal static IReadOnlyList MapRuntimeTools(IEnumerable tools) + { + return tools + .Where(ShouldIncludeRuntimeTool) + .Where(tool => !string.IsNullOrWhiteSpace(tool.Name)) + .Select(tool => new SidecarRuntimeToolDto + { + Id = tool.Name.Trim(), + Label = tool.Name.Trim(), + Description = string.IsNullOrWhiteSpace(tool.Description) ? null : tool.Description.Trim(), + }) + .DistinctBy(tool => tool.Id, StringComparer.OrdinalIgnoreCase) + .OrderBy(tool => tool.Label, StringComparer.OrdinalIgnoreCase) + .ToList(); + } + + private static bool ShouldIncludeRuntimeTool(Tool tool) + { + string? toolName = string.IsNullOrWhiteSpace(tool.Name) ? null : tool.Name.Trim(); + return toolName is not null + && !ExcludedRuntimeToolNames.Contains(toolName); + } + + private static bool IsReasoningEffort(string? value) + { + return value is "low" or "medium" or "high" or "xhigh"; + } + + internal static SidecarConnectionDiagnosticsDto CreateMissingCliDiagnostics(Exception exception) + { + return new SidecarConnectionDiagnosticsDto + { + Status = "copilot-cli-missing", + Summary = "GitHub Copilot CLI is not installed or is not available on PATH.", + Detail = exception.Message, + CheckedAt = DateTimeOffset.UtcNow.ToString("O"), + }; + } + + internal static SidecarConnectionDiagnosticsDto CreateReadyConnectionDiagnostics( + string cliPath, + int modelCount, + SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null, + SidecarCopilotAccountDiagnosticsDto? account = null) + { + string summary = modelCount switch + { + 0 => "Connected to GitHub Copilot, but no models were reported.", + 1 => "Connected to GitHub Copilot. 1 model is available.", + _ => $"Connected to GitHub Copilot. {modelCount} models are available.", + }; + + return new SidecarConnectionDiagnosticsDto + { + Status = "ready", + Summary = summary, + Detail = $"Using Copilot CLI at {cliPath}.", + CopilotCliPath = cliPath, + CopilotCliVersion = cliVersion, + Account = account, + CheckedAt = DateTimeOffset.UtcNow.ToString("O"), + }; + } + + internal static SidecarConnectionDiagnosticsDto CreateFailureConnectionDiagnostics( + string? cliPath, + Exception exception, + SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null, + SidecarCopilotAccountDiagnosticsDto? account = null) + { + string status = ClassifyConnectionStatus(exception); + string summary = status == "copilot-auth-required" + ? "GitHub Copilot requires authentication before Aryx can load models." + : "GitHub Copilot was found, but Aryx could not load its model list."; + + return new SidecarConnectionDiagnosticsDto + { + Status = status, + Summary = summary, + Detail = exception.Message, + CopilotCliPath = cliPath, + CopilotCliVersion = cliVersion, + Account = account, + CheckedAt = DateTimeOffset.UtcNow.ToString("O"), + }; + } + + internal static string ClassifyConnectionStatus(Exception exception) + { + string message = exception.Message; + if (AuthenticationErrorIndicators.Any(indicator => + message.Contains(indicator, StringComparison.OrdinalIgnoreCase))) + { + return "copilot-auth-required"; + } + + return "copilot-error"; + } + + private sealed record CapabilityProbeResult( + IReadOnlyList Models, + IReadOnlyList RuntimeTools, + SidecarConnectionDiagnosticsDto Connection); +} diff --git a/sidecar/src/Aryx.AgentHost/Services/IAgentProvider.cs b/sidecar/src/Aryx.AgentHost/Services/IAgentProvider.cs new file mode 100644 index 0000000..c4f509b --- /dev/null +++ b/sidecar/src/Aryx.AgentHost/Services/IAgentProvider.cs @@ -0,0 +1,12 @@ +using Aryx.AgentHost.Contracts; + +namespace Aryx.AgentHost.Services; + +internal interface IAgentProvider +{ + ITurnWorkflowRunner CreateWorkflowRunner(WorkflowValidator workflowValidator); + + Task GetCapabilitiesAsync(CancellationToken cancellationToken); + + IProviderSessionManager CreateSessionManager(); +} diff --git a/sidecar/src/Aryx.AgentHost/Services/ICopilotSessionManager.cs b/sidecar/src/Aryx.AgentHost/Services/ICopilotSessionManager.cs index e8b6b39..f83d440 100644 --- a/sidecar/src/Aryx.AgentHost/Services/ICopilotSessionManager.cs +++ b/sidecar/src/Aryx.AgentHost/Services/ICopilotSessionManager.cs @@ -2,7 +2,7 @@ using Aryx.AgentHost.Contracts; namespace Aryx.AgentHost.Services; -public interface ICopilotSessionManager +public interface IProviderSessionManager { Task> ListSessionsAsync( CopilotSessionListFilterDto? filter, @@ -17,3 +17,5 @@ public interface ICopilotSessionManager CancellationToken cancellationToken); } +public interface ICopilotSessionManager : IProviderSessionManager; + diff --git a/sidecar/src/Aryx.AgentHost/Services/SidecarProtocolHost.cs b/sidecar/src/Aryx.AgentHost/Services/SidecarProtocolHost.cs index ef45277..ce2a3d6 100644 --- a/sidecar/src/Aryx.AgentHost/Services/SidecarProtocolHost.cs +++ b/sidecar/src/Aryx.AgentHost/Services/SidecarProtocolHost.cs @@ -1,8 +1,6 @@ using System.Collections.Concurrent; using System.Text.Json; using System.Text.Json.Serialization; -using GitHub.Copilot.SDK; -using GitHub.Copilot.SDK.Rpc; using Aryx.AgentHost.Contracts; namespace Aryx.AgentHost.Services; @@ -19,31 +17,11 @@ public sealed class SidecarProtocolHost private const string DeleteSessionCommandType = "delete-session"; private const string DisconnectSessionCommandType = "disconnect-session"; private const string GetQuotaCommandType = "get-quota"; - private const string AskUserToolName = "ask_user"; - private static readonly HashSet ExcludedRuntimeToolNames = new(StringComparer.OrdinalIgnoreCase) - { - AskUserToolName, - "report_intent", - "task_complete", - }; - - private static readonly string[] AuthenticationErrorIndicators = - [ - "login", - "log in", - "sign in", - "authenticate", - "authentication", - "not signed in", - "not logged in", - "reauth", - "credential", - ]; private readonly Func> _capabilitiesProvider; private readonly WorkflowValidator _workflowValidator; private readonly ITurnWorkflowRunner _workflowRunner; - private readonly ICopilotSessionManager _sessionManager; + private readonly IProviderSessionManager _sessionManager; private readonly JsonSerializerOptions _jsonOptions; private readonly IReadOnlyDictionary> _commandHandlers; private readonly SemaphoreSlim _writeLock = new(1, 1); @@ -60,7 +38,7 @@ public sealed class SidecarProtocolHost public SidecarProtocolHost( ITurnWorkflowRunner? workflowRunner = null, Func>? capabilitiesProvider = null, - ICopilotSessionManager? sessionManager = null) + IProviderSessionManager? sessionManager = null) : this(new WorkflowValidator(), workflowRunner, capabilitiesProvider, sessionManager) { } @@ -69,12 +47,25 @@ public sealed class SidecarProtocolHost WorkflowValidator workflowValidator, ITurnWorkflowRunner? workflowRunner = null, Func>? capabilitiesProvider = null, - ICopilotSessionManager? sessionManager = null) + IProviderSessionManager? sessionManager = null) + : this(workflowValidator, new CopilotAgentProvider(), workflowRunner, capabilitiesProvider, sessionManager) { + } + + internal SidecarProtocolHost( + WorkflowValidator workflowValidator, + IAgentProvider agentProvider, + ITurnWorkflowRunner? workflowRunner = null, + Func>? capabilitiesProvider = null, + IProviderSessionManager? sessionManager = null) + { + ArgumentNullException.ThrowIfNull(workflowValidator); + ArgumentNullException.ThrowIfNull(agentProvider); + _workflowValidator = workflowValidator; - _workflowRunner = workflowRunner ?? new CopilotWorkflowRunner(_workflowValidator); - _capabilitiesProvider = capabilitiesProvider ?? BuildCapabilitiesAsync; - _sessionManager = sessionManager ?? new CopilotSessionManager(); + _workflowRunner = workflowRunner ?? agentProvider.CreateWorkflowRunner(_workflowValidator); + _capabilitiesProvider = capabilitiesProvider ?? agentProvider.GetCapabilitiesAsync; + _sessionManager = sessionManager ?? agentProvider.CreateSessionManager(); _jsonOptions = JsonSerialization.CreateWebOptions(); _jsonOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull; _jsonOptions.PropertyNameCaseInsensitive = true; @@ -459,252 +450,9 @@ public sealed class SidecarProtocolHost return cancelledRequestIds; } - private static async Task BuildCapabilitiesAsync(CancellationToken cancellationToken) - { - try - { - CopilotCliContext cliContext = CopilotCliPathResolver.ResolveCliContext(); - CapabilityProbeResult probe = await ProbeCapabilitiesAsync(cliContext, cancellationToken).ConfigureAwait(false); - return CreateCapabilities(probe.Models, probe.RuntimeTools, probe.Connection); - } - catch (Exception exception) - { - SidecarConnectionDiagnosticsDto connection = CreateMissingCliDiagnostics(exception); - Console.Error.WriteLine($"[aryx sidecar] {connection.Summary} {exception.Message}"); - return CreateCapabilities([], [], connection); - } - } - - private static async Task ProbeCapabilitiesAsync( - CopilotCliContext cliContext, - CancellationToken cancellationToken) - { - IReadOnlyList models = []; - IReadOnlyList runtimeTools = []; - SidecarCopilotAccountDiagnosticsDto? account = null; - SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null; - Task cliVersionTask = - CopilotConnectionMetadataResolver.GetCliVersionDiagnosticsAsync(cliContext, cancellationToken); - - try - { - CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions(cliContext); - - await using CopilotClient client = new(clientOptions); - await client.StartAsync(cancellationToken).ConfigureAwait(false); - - GetAuthStatusResponse? authStatus = - await CopilotConnectionMetadataResolver.TryGetAuthStatusAsync(client, cancellationToken).ConfigureAwait(false); - account = await CopilotConnectionMetadataResolver.CreateAccountDiagnosticsAsync( - authStatus, - cliContext.Environment, - cancellationToken) - .ConfigureAwait(false); - - models = await ListAvailableModelsAsync(client, cancellationToken).ConfigureAwait(false); - runtimeTools = await TryListAvailableRuntimeToolsAsync(client, cancellationToken).ConfigureAwait(false); - cliVersion = await cliVersionTask.ConfigureAwait(false); - - return new CapabilityProbeResult( - models, - runtimeTools, - CreateReadyConnectionDiagnostics(cliContext.CliPath, models.Count, cliVersion, account)); - } - catch (Exception exception) - { - cliVersion = await cliVersionTask.ConfigureAwait(false); - Console.Error.WriteLine($"[aryx sidecar] Failed to list available Copilot models: {exception.Message}"); - - return new CapabilityProbeResult( - models, - runtimeTools, - CreateFailureConnectionDiagnostics(cliContext.CliPath, exception, cliVersion, account)); - } - } - - private static SidecarCapabilitiesDto CreateCapabilities( - IReadOnlyList models, - IReadOnlyList runtimeTools, - SidecarConnectionDiagnosticsDto connection) - { - return new SidecarCapabilitiesDto - { - Modes = BuildModeCapabilities(), - Models = models, - RuntimeTools = runtimeTools, - Connection = connection, - }; - } - - private static Dictionary BuildModeCapabilities() - { - return 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#.", - }, - }; - } - - private static async Task> ListAvailableModelsAsync( - CopilotClient client, - CancellationToken cancellationToken) - { - List models = await client.ListModelsAsync(cancellationToken).ConfigureAwait(false); - return models - .Select(model => new SidecarModelCapabilityDto - { - Id = model.Id, - Name = model.Name, - SupportedReasoningEfforts = (model.SupportedReasoningEfforts ?? []) - .Where(IsReasoningEffort) - .Distinct(StringComparer.Ordinal) - .ToList(), - DefaultReasoningEffort = IsReasoningEffort(model.DefaultReasoningEffort) - ? model.DefaultReasoningEffort - : null, - }) - .OrderBy(model => model.Name, StringComparer.OrdinalIgnoreCase) - .ToList(); - } - - private static async Task> TryListAvailableRuntimeToolsAsync( - CopilotClient client, - CancellationToken cancellationToken) - { - try - { - return await ListAvailableRuntimeToolsAsync(client, cancellationToken).ConfigureAwait(false); - } - catch (Exception exception) - { - Console.Error.WriteLine($"[aryx sidecar] Failed to list available Copilot runtime tools: {exception.Message}"); - return []; - } - } - - private static async Task> ListAvailableRuntimeToolsAsync( - CopilotClient client, - CancellationToken cancellationToken) - { - ToolsListResult result = await client.Rpc.Tools.ListAsync(null!, cancellationToken).ConfigureAwait(false); - return MapRuntimeTools(result.Tools); - } - - internal static IReadOnlyList MapRuntimeTools(IEnumerable tools) - { - return tools - .Where(ShouldIncludeRuntimeTool) - .Where(tool => !string.IsNullOrWhiteSpace(tool.Name)) - .Select(tool => new SidecarRuntimeToolDto - { - Id = tool.Name.Trim(), - Label = tool.Name.Trim(), - Description = string.IsNullOrWhiteSpace(tool.Description) ? null : tool.Description.Trim(), - }) - .DistinctBy(tool => tool.Id, StringComparer.OrdinalIgnoreCase) - .OrderBy(tool => tool.Label, StringComparer.OrdinalIgnoreCase) - .ToList(); - } - - private static bool ShouldIncludeRuntimeTool(Tool tool) - { - string? toolName = string.IsNullOrWhiteSpace(tool.Name) ? null : tool.Name.Trim(); - return toolName is not null - && !ExcludedRuntimeToolNames.Contains(toolName); - } - - private static bool IsReasoningEffort(string? value) - { - return value is "low" or "medium" or "high" or "xhigh"; - } - - internal static SidecarConnectionDiagnosticsDto CreateMissingCliDiagnostics(Exception exception) - { - return new SidecarConnectionDiagnosticsDto - { - Status = "copilot-cli-missing", - Summary = "GitHub Copilot CLI is not installed or is not available on PATH.", - Detail = exception.Message, - CheckedAt = DateTimeOffset.UtcNow.ToString("O"), - }; - } - - internal static SidecarConnectionDiagnosticsDto CreateReadyConnectionDiagnostics( - string cliPath, - int modelCount, - SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null, - SidecarCopilotAccountDiagnosticsDto? account = null) - { - string summary = modelCount switch - { - 0 => "Connected to GitHub Copilot, but no models were reported.", - 1 => "Connected to GitHub Copilot. 1 model is available.", - _ => $"Connected to GitHub Copilot. {modelCount} models are available.", - }; - - return new SidecarConnectionDiagnosticsDto - { - Status = "ready", - Summary = summary, - Detail = $"Using Copilot CLI at {cliPath}.", - CopilotCliPath = cliPath, - CopilotCliVersion = cliVersion, - Account = account, - CheckedAt = DateTimeOffset.UtcNow.ToString("O"), - }; - } - - internal static SidecarConnectionDiagnosticsDto CreateFailureConnectionDiagnostics( - string? cliPath, - Exception exception, - SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null, - SidecarCopilotAccountDiagnosticsDto? account = null) - { - string status = ClassifyConnectionStatus(exception); - string summary = status == "copilot-auth-required" - ? "GitHub Copilot requires authentication before Aryx can load models." - : "GitHub Copilot was found, but Aryx could not load its model list."; - - return new SidecarConnectionDiagnosticsDto - { - Status = status, - Summary = summary, - Detail = exception.Message, - CopilotCliPath = cliPath, - CopilotCliVersion = cliVersion, - Account = account, - CheckedAt = DateTimeOffset.UtcNow.ToString("O"), - }; - } - - internal static string ClassifyConnectionStatus(Exception exception) - { - string message = exception.Message; - if (AuthenticationErrorIndicators.Any(indicator => - message.Contains(indicator, StringComparison.OrdinalIgnoreCase))) - { - return "copilot-auth-required"; - } - - return "copilot-error"; - } - private sealed record CommandContext( string RawCommand, SidecarCommandEnvelope Envelope, TextWriter Output, CancellationToken CancellationToken); - - private sealed record CapabilityProbeResult( - IReadOnlyList Models, - IReadOnlyList RuntimeTools, - SidecarConnectionDiagnosticsDto Connection); } diff --git a/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs index 62fc2c0..7b85013 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs @@ -62,6 +62,103 @@ public sealed class SidecarProtocolHostTests }); } + [Fact] + public async Task InternalConstructor_UsesAgentProviderDefaults() + { + FakeWorkflowRunner workflowRunner = new(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-provider", + AgentName = "Provider Agent", + }); + + return + [ + new ChatMessageDto + { + Id = "assistant-provider", + Role = "assistant", + AuthorName = "Provider Agent", + Content = "Hello from the provider.", + CreatedAt = "2026-01-01T00:00:00.0000000Z", + }, + ]; + }); + FakeSessionManager sessionManager = new() + { + Sessions = + [ + new CopilotSessionInfoDto + { + CopilotSessionId = "aryx::provider-session::agent-provider", + ManagedByAryx = true, + SessionId = "provider-session", + AgentId = "agent-provider", + }, + ], + }; + SidecarCapabilitiesDto capabilities = new() + { + Modes = new Dictionary(StringComparer.OrdinalIgnoreCase) + { + ["single"] = new() { Available = true }, + }, + Models = + [ + new SidecarModelCapabilityDto + { + Id = "provider-model", + Name = "Provider Model", + }, + ], + RuntimeTools = [], + Connection = new SidecarConnectionDiagnosticsDto + { + Status = "ready", + Summary = "Provider is ready.", + CheckedAt = "2026-01-01T00:00:00.0000000Z", + }, + }; + SidecarProtocolHost host = new( + new WorkflowValidator(), + new FakeAgentProvider(workflowRunner, sessionManager, capabilities)); + + IReadOnlyList capabilityEvents = await RunHostAsync( + new DescribeCapabilitiesCommandDto + { + Type = "describe-capabilities", + RequestId = "provider-capabilities", + }, + host); + IReadOnlyList sessionEvents = await RunHostAsync( + new ListSessionsCommandDto + { + Type = "list-sessions", + RequestId = "provider-sessions", + }, + host); + IReadOnlyList turnEvents = await RunHostAsync( + CreateRunTurnCommand(requestId: "provider-turn"), + host); + + JsonElement capabilityEvent = AssertSingleEvent(capabilityEvents, "capabilities", "provider-capabilities"); + JsonElement model = Assert.Single(capabilityEvent.GetProperty("capabilities").GetProperty("models").EnumerateArray()); + Assert.Equal("provider-model", model.GetProperty("id").GetString()); + + JsonElement listedEvent = AssertSingleEvent(sessionEvents, "sessions-listed", "provider-sessions"); + JsonElement session = Assert.Single(listedEvent.GetProperty("sessions").EnumerateArray()); + Assert.Equal("provider-session", session.GetProperty("sessionId").GetString()); + + JsonElement turnComplete = AssertSingleEvent(turnEvents, "turn-complete", "provider-turn"); + JsonElement message = Assert.Single(turnComplete.GetProperty("messages").EnumerateArray()); + Assert.Equal("Hello from the provider.", message.GetProperty("content").GetString()); + } + [Fact] public async Task ValidateWorkflowCommand_ReturnsIssuesAndCompletion() { @@ -668,7 +765,7 @@ public sealed class SidecarProtocolHostTests [Fact] public void MapRuntimeTools_ExcludesOnlyInternalMetaToolsAndDeduplicatesByName() { - IReadOnlyList runtimeTools = SidecarProtocolHost.MapRuntimeTools( + IReadOnlyList runtimeTools = CopilotAgentProvider.MapRuntimeTools( [ new Tool { @@ -721,7 +818,7 @@ public sealed class SidecarProtocolHostTests [Fact] public void ClassifyConnectionStatus_ReturnsAuthRequiredForLoginFailures() { - string status = SidecarProtocolHost.ClassifyConnectionStatus( + string status = CopilotAgentProvider.ClassifyConnectionStatus( new InvalidOperationException("Please run copilot auth login to continue.")); Assert.Equal("copilot-auth-required", status); @@ -731,7 +828,7 @@ public sealed class SidecarProtocolHostTests public void CreateReadyConnectionDiagnostics_ReportsCliPathAndModelCount() { SidecarConnectionDiagnosticsDto diagnostics = - SidecarProtocolHost.CreateReadyConnectionDiagnostics( + CopilotAgentProvider.CreateReadyConnectionDiagnostics( @"C:\tools\copilot\copilot.exe", 2, new SidecarCopilotCliVersionDiagnosticsDto @@ -1145,6 +1242,38 @@ public sealed class SidecarProtocolHostTests } } + private sealed class FakeAgentProvider : IAgentProvider + { + private readonly ITurnWorkflowRunner _workflowRunner; + private readonly IProviderSessionManager _sessionManager; + private readonly SidecarCapabilitiesDto _capabilities; + + public FakeAgentProvider( + ITurnWorkflowRunner workflowRunner, + IProviderSessionManager sessionManager, + SidecarCapabilitiesDto capabilities) + { + _workflowRunner = workflowRunner; + _sessionManager = sessionManager; + _capabilities = capabilities; + } + + public ITurnWorkflowRunner CreateWorkflowRunner(WorkflowValidator workflowValidator) + { + return _workflowRunner; + } + + public Task GetCapabilitiesAsync(CancellationToken cancellationToken) + { + return Task.FromResult(_capabilities); + } + + public IProviderSessionManager CreateSessionManager() + { + return _sessionManager; + } + } + private sealed class FakeSessionManager : ICopilotSessionManager { public IReadOnlyList Sessions { get; init; } = [];