From 2ae4bd01b464cf7634e524810afbf0a9b96fc4d9 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Thu, 26 Mar 2026 17:34:49 +0100 Subject: [PATCH] feat: add ask_user support to sidecar Implement the Copilot SDK user input round-trip in the backend: - add user-input-requested and resolve-user-input protocol DTOs - add a CopilotUserInputCoordinator that mirrors approval flow with pending TaskCompletionSource state and explicit resolution - wire SessionConfig.OnUserInputRequest through CopilotAgentBundle and CopilotWorkflowRunner - extend SidecarProtocolHost to emit user input events, accept resolve-user-input commands, and filter ask_user from runtime approval tools - add regression tests for the new coordinator and protocol flow Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Contracts/ProtocolModels.cs | 18 ++ .../Services/CopilotAgentBundle.cs | 2 + .../Services/CopilotUserInputCoordinator.cs | 153 +++++++++++++++++ .../Services/CopilotWorkflowRunner.cs | 16 ++ .../Services/ITurnWorkflowRunner.cs | 5 + .../Services/SidecarProtocolHost.cs | 25 ++- .../CopilotUserInputCoordinatorTests.cs | 109 ++++++++++++ .../SidecarProtocolHostTests.cs | 162 +++++++++++++++++- 8 files changed, 482 insertions(+), 8 deletions(-) create mode 100644 sidecar/src/Aryx.AgentHost/Services/CopilotUserInputCoordinator.cs create mode 100644 sidecar/tests/Aryx.AgentHost.Tests/CopilotUserInputCoordinatorTests.cs diff --git a/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs b/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs index 3db3622..4b405b7 100644 --- a/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs +++ b/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs @@ -177,6 +177,13 @@ public sealed class ResolveApprovalCommandDto : SidecarCommandEnvelope public string Decision { get; init; } = string.Empty; } +public sealed class ResolveUserInputCommandDto : SidecarCommandEnvelope +{ + public string UserInputId { get; init; } = string.Empty; + public string Answer { get; init; } = string.Empty; + public bool WasFreeform { get; init; } +} + public sealed class RunTurnToolingConfigDto { public IReadOnlyList McpServers { get; init; } = []; @@ -290,6 +297,17 @@ public sealed class ApprovalRequestedEventDto : SidecarEventDto public PermissionDetailDto? PermissionDetail { get; init; } } +public sealed class UserInputRequestedEventDto : SidecarEventDto +{ + public string SessionId { get; init; } = string.Empty; + public string UserInputId { get; init; } = string.Empty; + public string? AgentId { get; init; } + public string? AgentName { get; init; } + public string Question { get; init; } = string.Empty; + public IReadOnlyList? Choices { get; init; } + public bool? AllowFreeform { get; init; } +} + public sealed class CommandErrorEventDto : SidecarEventDto { public string Message { get; init; } = string.Empty; diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs index f724e9e..1293902 100644 --- a/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs @@ -22,6 +22,7 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable public static async Task CreateAsync( RunTurnCommandDto command, Func> onPermissionRequest, + Func> onUserInputRequest, Action? onSessionEvent, CancellationToken cancellationToken) { @@ -53,6 +54,7 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable }, WorkingDirectory = command.ProjectPath, OnPermissionRequest = (request, invocation) => onPermissionRequest(definition, request, invocation), + OnUserInputRequest = (request, invocation) => onUserInputRequest(definition, request, invocation), OnEvent = evt => onSessionEvent?.Invoke(definition, evt), Streaming = true, }; diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotUserInputCoordinator.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotUserInputCoordinator.cs new file mode 100644 index 0000000..c7df86e --- /dev/null +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotUserInputCoordinator.cs @@ -0,0 +1,153 @@ +using System.Collections.Concurrent; +using Aryx.AgentHost.Contracts; +using GitHub.Copilot.SDK; + +namespace Aryx.AgentHost.Services; + +internal sealed class CopilotUserInputCoordinator +{ + private readonly ConcurrentDictionary _pendingUserInputs = new(StringComparer.Ordinal); + + public Task ResolveUserInputAsync( + ResolveUserInputCommandDto command, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + + string userInputId = RequireUserInputId(command.UserInputId); + PendingUserInputRequest pending = GetPendingUserInput(userInputId); + UserInputResponse response = new() + { + Answer = command.Answer ?? string.Empty, + WasFreeform = command.WasFreeform, + }; + + if (!pending.Response.TrySetResult(response)) + { + throw new InvalidOperationException($"User input request \"{userInputId}\" is no longer pending."); + } + + return Task.CompletedTask; + } + + public async Task RequestUserInputAsync( + RunTurnCommandDto command, + PatternAgentDefinitionDto agent, + UserInputRequest request, + UserInputInvocation invocation, + Func onUserInput, + CancellationToken cancellationToken) + { + ArgumentNullException.ThrowIfNull(command); + ArgumentNullException.ThrowIfNull(agent); + ArgumentNullException.ThrowIfNull(request); + ArgumentNullException.ThrowIfNull(invocation); + ArgumentNullException.ThrowIfNull(onUserInput); + + PendingUserInputRequest pending = CreatePendingUserInput(command); + if (!_pendingUserInputs.TryAdd(pending.UserInputId, pending)) + { + throw new InvalidOperationException($"User input request \"{pending.UserInputId}\" is already pending."); + } + + try + { + await onUserInput(BuildUserInputRequestedEvent(command, agent, request, pending.UserInputId)) + .ConfigureAwait(false); + + using CancellationTokenRegistration registration = cancellationToken.Register( + static state => + { + ((TaskCompletionSource)state!) + .TrySetCanceled(); + }, + pending.Response); + + return await pending.Response.Task.ConfigureAwait(false); + } + finally + { + _pendingUserInputs.TryRemove(pending.UserInputId, out _); + } + } + + internal static UserInputRequestedEventDto BuildUserInputRequestedEvent( + RunTurnCommandDto command, + PatternAgentDefinitionDto agent, + UserInputRequest request, + string userInputId) + { + ArgumentNullException.ThrowIfNull(command); + ArgumentNullException.ThrowIfNull(agent); + ArgumentNullException.ThrowIfNull(request); + + string? normalizedAgentId = NormalizeOptionalString(agent.Id); + string? normalizedAgentName = NormalizeOptionalString(agent.Name) ?? normalizedAgentId; + + return new UserInputRequestedEventDto + { + Type = "user-input-requested", + RequestId = command.RequestId, + SessionId = command.SessionId, + UserInputId = userInputId, + AgentId = normalizedAgentId, + AgentName = normalizedAgentName, + Question = NormalizeOptionalString(request.Question) ?? string.Empty, + Choices = NormalizeOptionalStringList(request.Choices ?? []), + AllowFreeform = request.AllowFreeform, + }; + } + + private static PendingUserInputRequest CreatePendingUserInput(RunTurnCommandDto command) + { + return new PendingUserInputRequest( + command.RequestId, + command.SessionId, + CreateUserInputRequestId(), + new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)); + } + + private PendingUserInputRequest GetPendingUserInput(string userInputId) + { + if (_pendingUserInputs.TryGetValue(userInputId, out PendingUserInputRequest? pending)) + { + return pending; + } + + throw new InvalidOperationException($"User input request \"{userInputId}\" is not pending."); + } + + private static string RequireUserInputId(string? userInputId) + { + string? normalizedUserInputId = NormalizeOptionalString(userInputId); + return normalizedUserInputId + ?? throw new InvalidOperationException("User input ID is required."); + } + + private static string CreateUserInputRequestId() + { + return $"user-input-{Guid.NewGuid():N}"; + } + + private static string? NormalizeOptionalString(string? value) + { + return string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + } + + private static IReadOnlyList? NormalizeOptionalStringList(IEnumerable values) + { + List normalized = values + .Select(NormalizeOptionalString) + .Where(static value => value is not null) + .Cast() + .ToList(); + + return normalized.Count > 0 ? normalized : null; + } + + private sealed record PendingUserInputRequest( + string RequestId, + string SessionId, + string UserInputId, + TaskCompletionSource Response); +} diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs index 93aeacb..f41ff33 100644 --- a/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs @@ -8,6 +8,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner { private readonly PatternValidator _patternValidator; private readonly CopilotApprovalCoordinator _approvalCoordinator = new(); + private readonly CopilotUserInputCoordinator _userInputCoordinator = new(); public CopilotWorkflowRunner(PatternValidator patternValidator) { @@ -19,6 +20,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner Func onDelta, Func onActivity, Func onApproval, + Func onUserInput, CancellationToken cancellationToken) { PatternValidationIssueDto? validationError = _patternValidator.Validate(command.Pattern).FirstOrDefault(); @@ -38,6 +40,13 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner state.ToolNamesByCallId, onApproval, cancellationToken), + (agent, request, invocation) => _userInputCoordinator.RequestUserInputAsync( + command, + agent, + request, + invocation, + onUserInput, + cancellationToken), (agent, sessionEvent) => state.ObserveSessionEvent(agent, sessionEvent), cancellationToken); Workflow workflow = bundle.BuildWorkflow(command.Pattern); @@ -66,6 +75,13 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner return _approvalCoordinator.ResolveApprovalAsync(command, cancellationToken); } + public Task ResolveUserInputAsync( + ResolveUserInputCommandDto command, + CancellationToken cancellationToken) + { + return _userInputCoordinator.ResolveUserInputAsync(command, cancellationToken); + } + private static async Task HandleWorkflowEventAsync( RunTurnCommandDto command, WorkflowEvent evt, diff --git a/sidecar/src/Aryx.AgentHost/Services/ITurnWorkflowRunner.cs b/sidecar/src/Aryx.AgentHost/Services/ITurnWorkflowRunner.cs index df75bed..b325978 100644 --- a/sidecar/src/Aryx.AgentHost/Services/ITurnWorkflowRunner.cs +++ b/sidecar/src/Aryx.AgentHost/Services/ITurnWorkflowRunner.cs @@ -9,9 +9,14 @@ public interface ITurnWorkflowRunner Func onDelta, Func onActivity, Func onApproval, + Func onUserInput, CancellationToken cancellationToken); Task ResolveApprovalAsync( ResolveApprovalCommandDto command, CancellationToken cancellationToken); + + Task ResolveUserInputAsync( + ResolveUserInputCommandDto command, + CancellationToken cancellationToken); } diff --git a/sidecar/src/Aryx.AgentHost/Services/SidecarProtocolHost.cs b/sidecar/src/Aryx.AgentHost/Services/SidecarProtocolHost.cs index 39af1b2..0475c5e 100644 --- a/sidecar/src/Aryx.AgentHost/Services/SidecarProtocolHost.cs +++ b/sidecar/src/Aryx.AgentHost/Services/SidecarProtocolHost.cs @@ -14,6 +14,8 @@ public sealed class SidecarProtocolHost private const string RunTurnCommandType = "run-turn"; private const string CancelTurnCommandType = "cancel-turn"; private const string ResolveApprovalCommandType = "resolve-approval"; + private const string ResolveUserInputCommandType = "resolve-user-input"; + private const string AskUserToolName = "ask_user"; private static readonly string[] AuthenticationErrorIndicators = [ @@ -62,6 +64,7 @@ public sealed class SidecarProtocolHost [RunTurnCommandType] = HandleRunTurnAsync, [CancelTurnCommandType] = HandleCancelTurnAsync, [ResolveApprovalCommandType] = HandleResolveApprovalAsync, + [ResolveUserInputCommandType] = HandleResolveUserInputAsync, }; } @@ -178,6 +181,7 @@ public sealed class SidecarProtocolHost delta => WriteAsync(context.Output, delta, turnCancellation.Token), activity => WriteAsync(context.Output, activity, turnCancellation.Token), approval => WriteAsync(context.Output, approval, turnCancellation.Token), + userInput => WriteAsync(context.Output, userInput, turnCancellation.Token), turnCancellation.Token) .ConfigureAwait(false); @@ -231,6 +235,12 @@ public sealed class SidecarProtocolHost await _workflowRunner.ResolveApprovalAsync(command, context.CancellationToken).ConfigureAwait(false); } + private async Task HandleResolveUserInputAsync(CommandContext context) + { + ResolveUserInputCommandDto command = DeserializeCommand(context); + await _workflowRunner.ResolveUserInputAsync(command, context.CancellationToken).ConfigureAwait(false); + } + private TCommand DeserializeCommand(CommandContext context) where TCommand : SidecarCommandEnvelope { @@ -427,7 +437,13 @@ public sealed class SidecarProtocolHost CancellationToken cancellationToken) { ToolsListResult result = await client.Rpc.Tools.ListAsync(null!, cancellationToken).ConfigureAwait(false); - return result.Tools + return MapRuntimeTools(result.Tools); + } + + internal static IReadOnlyList MapRuntimeTools(IEnumerable tools) + { + return tools + .Where(ShouldIncludeRuntimeTool) .Where(tool => !string.IsNullOrWhiteSpace(tool.Name)) .Select(tool => new SidecarRuntimeToolDto { @@ -440,6 +456,13 @@ public sealed class SidecarProtocolHost .ToList(); } + private static bool ShouldIncludeRuntimeTool(Tool tool) + { + string? toolName = string.IsNullOrWhiteSpace(tool.Name) ? null : tool.Name.Trim(); + return toolName is not null + && !string.Equals(toolName, AskUserToolName, StringComparison.OrdinalIgnoreCase); + } + private static bool IsReasoningEffort(string? value) { return value is "low" or "medium" or "high" or "xhigh"; diff --git a/sidecar/tests/Aryx.AgentHost.Tests/CopilotUserInputCoordinatorTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/CopilotUserInputCoordinatorTests.cs new file mode 100644 index 0000000..c9e3292 --- /dev/null +++ b/sidecar/tests/Aryx.AgentHost.Tests/CopilotUserInputCoordinatorTests.cs @@ -0,0 +1,109 @@ +using Aryx.AgentHost.Contracts; +using Aryx.AgentHost.Services; +using GitHub.Copilot.SDK; + +namespace Aryx.AgentHost.Tests; + +public sealed class CopilotUserInputCoordinatorTests +{ + [Fact] + public async Task RequestUserInputAsync_RaisesUserInputEventAndCompletesAfterResolution() + { + CopilotUserInputCoordinator coordinator = new(); + UserInputRequestedEventDto? observedEvent = null; + RunTurnCommandDto command = CreateUserInputCommand(); + + Task pending = coordinator.RequestUserInputAsync( + command, + command.Pattern.Agents[0], + new UserInputRequest + { + Question = "How should I proceed?", + Choices = ["Continue", "Stop"], + AllowFreeform = true, + }, + new UserInputInvocation + { + SessionId = "copilot-session-1", + }, + userInputEvent => + { + observedEvent = userInputEvent; + return Task.CompletedTask; + }, + CancellationToken.None); + + Assert.False(pending.IsCompleted); + Assert.NotNull(observedEvent); + Assert.Equal("user-input-requested", observedEvent!.Type); + Assert.Equal("turn-1", observedEvent.RequestId); + Assert.Equal("session-1", observedEvent.SessionId); + Assert.Equal("agent-1", observedEvent.AgentId); + Assert.Equal("Primary", observedEvent.AgentName); + Assert.Equal("How should I proceed?", observedEvent.Question); + Assert.Equal(["Continue", "Stop"], observedEvent.Choices); + Assert.True(observedEvent.AllowFreeform); + + await coordinator.ResolveUserInputAsync( + new ResolveUserInputCommandDto + { + UserInputId = observedEvent.UserInputId, + Answer = "Continue", + WasFreeform = false, + }, + CancellationToken.None); + + UserInputResponse response = await pending; + Assert.Equal("Continue", response.Answer); + Assert.False(response.WasFreeform); + } + + [Fact] + public async Task ResolveUserInputAsync_RejectsUnknownUserInputIds() + { + CopilotUserInputCoordinator coordinator = new(); + + InvalidOperationException error = await Assert.ThrowsAsync(() => + coordinator.ResolveUserInputAsync( + new ResolveUserInputCommandDto + { + UserInputId = "user-input-missing", + Answer = "Continue", + WasFreeform = false, + }, + CancellationToken.None)); + + Assert.Contains("is not pending", error.Message); + } + + private static PatternAgentDefinitionDto CreateAgent(string id, string name) + { + return new PatternAgentDefinitionDto + { + Id = id, + Name = name, + Model = "gpt-5.4", + Instructions = "Help with the request.", + }; + } + + private static RunTurnCommandDto CreateUserInputCommand() + { + return new RunTurnCommandDto + { + RequestId = "turn-1", + SessionId = "session-1", + Pattern = new PatternDefinitionDto + { + Id = "pattern-1", + Name = "User Input Pattern", + Mode = "single", + Availability = "available", + Agents = + [ + CreateAgent("agent-1", "Primary"), + ], + }, + }; + } +} diff --git a/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs index 22aea7b..fb4e3fb 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs @@ -1,6 +1,7 @@ using System.Text.Json; using Aryx.AgentHost.Contracts; using Aryx.AgentHost.Services; +using GitHub.Copilot.SDK.Rpc; namespace Aryx.AgentHost.Tests; @@ -112,7 +113,7 @@ public sealed class SidecarProtocolHostTests { SidecarProtocolHost host = new( new PatternValidator(), - new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, cancellationToken) => + new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, cancellationToken) => { await onActivity(new AgentActivityEventDto { @@ -236,7 +237,7 @@ public sealed class SidecarProtocolHostTests { SidecarProtocolHost host = new( new PatternValidator(), - new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, cancellationToken) => + new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, cancellationToken) => { await onApproval(new ApprovalRequestedEventDto { @@ -309,12 +310,85 @@ public sealed class SidecarProtocolHostTests }); } + [Fact] + public async Task RunTurnCommand_ReturnsUserInputEvents() + { + SidecarProtocolHost host = new( + new PatternValidator(), + new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, 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 CancelTurnCommand_CancelsInProgressTurnAndCompletesBothCommands() { SidecarProtocolHost host = new( new PatternValidator(), - new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, cancellationToken) => + new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, cancellationToken) => { await Task.Delay(Timeout.Infinite, cancellationToken); return []; @@ -362,7 +436,7 @@ public sealed class SidecarProtocolHostTests { SidecarProtocolHost host = new( new PatternValidator(), - new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, cancellationToken) => [])); + new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, cancellationToken) => [])); await RunHostAsync(CreateRunTurnCommand(requestId: "turn-completed"), host); @@ -385,7 +459,7 @@ public sealed class SidecarProtocolHostTests SidecarProtocolHost host = new( new PatternValidator(), new FakeWorkflowRunner( - handler: async (command, onDelta, onActivity, onApproval, cancellationToken) => [], + handler: async (command, onDelta, onActivity, onApproval, onUserInput, cancellationToken) => [], resolveApprovalHandler: (command, cancellationToken) => { captured = command; @@ -409,6 +483,67 @@ public sealed class SidecarProtocolHostTests Assert.Equal("approved", captured?.Decision); } + [Fact] + public async Task ResolveUserInputCommand_DelegatesToWorkflowRunnerAndCompletes() + { + ResolveUserInputCommandDto? captured = null; + SidecarProtocolHost host = new( + new PatternValidator(), + new FakeWorkflowRunner( + handler: async (command, onDelta, onActivity, onApproval, onUserInput, 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_ExcludesAskUserAndDeduplicatesByName() + { + IReadOnlyList runtimeTools = SidecarProtocolHost.MapRuntimeTools( + [ + new Tool + { + Name = "ask_user", + Description = "Ask the user a question.", + }, + new Tool + { + Name = " web_fetch ", + Description = " Fetch content from the web. ", + }, + new Tool + { + Name = "WEB_FETCH", + Description = "Duplicate entry", + }, + ]); + + SidecarRuntimeToolDto runtimeTool = Assert.Single(runtimeTools); + 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() { @@ -619,9 +754,11 @@ public sealed class SidecarProtocolHostTests Func, Func, Func, + Func, CancellationToken, Task>> _handler; private readonly Func _resolveApprovalHandler; + private readonly Func _resolveUserInputHandler; public FakeWorkflowRunner( Func< @@ -629,12 +766,15 @@ public sealed class SidecarProtocolHostTests Func, Func, Func, + Func, CancellationToken, Task>> handler, - Func? resolveApprovalHandler = null) + Func? resolveApprovalHandler = null, + Func? resolveUserInputHandler = null) { _handler = handler; _resolveApprovalHandler = resolveApprovalHandler ?? ((_, _) => Task.CompletedTask); + _resolveUserInputHandler = resolveUserInputHandler ?? ((_, _) => Task.CompletedTask); } public Task> RunTurnAsync( @@ -642,9 +782,10 @@ public sealed class SidecarProtocolHostTests Func onDelta, Func onActivity, Func onApproval, + Func onUserInput, CancellationToken cancellationToken) { - return _handler(command, onDelta, onActivity, onApproval, cancellationToken); + return _handler(command, onDelta, onActivity, onApproval, onUserInput, cancellationToken); } public Task ResolveApprovalAsync( @@ -653,5 +794,12 @@ public sealed class SidecarProtocolHostTests { return _resolveApprovalHandler(command, cancellationToken); } + + public Task ResolveUserInputAsync( + ResolveUserInputCommandDto command, + CancellationToken cancellationToken) + { + return _resolveUserInputHandler(command, cancellationToken); + } } }