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>
This commit is contained in:
David Kaya
2026-03-26 17:34:49 +01:00
co-authored by Copilot
parent 4c01d8b1a7
commit 2ae4bd01b4
8 changed files with 482 additions and 8 deletions
@@ -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<UserInputResponse> 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<InvalidOperationException>(() =>
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"),
],
},
};
}
}
@@ -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<JsonElement> 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<JsonElement> 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<SidecarRuntimeToolDto> 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<TurnDeltaEventDto, Task>,
Func<AgentActivityEventDto, Task>,
Func<ApprovalRequestedEventDto, Task>,
Func<UserInputRequestedEventDto, Task>,
CancellationToken,
Task<IReadOnlyList<ChatMessageDto>>> _handler;
private readonly Func<ResolveApprovalCommandDto, CancellationToken, Task> _resolveApprovalHandler;
private readonly Func<ResolveUserInputCommandDto, CancellationToken, Task> _resolveUserInputHandler;
public FakeWorkflowRunner(
Func<
@@ -629,12 +766,15 @@ public sealed class SidecarProtocolHostTests
Func<TurnDeltaEventDto, Task>,
Func<AgentActivityEventDto, Task>,
Func<ApprovalRequestedEventDto, Task>,
Func<UserInputRequestedEventDto, Task>,
CancellationToken,
Task<IReadOnlyList<ChatMessageDto>>> handler,
Func<ResolveApprovalCommandDto, CancellationToken, Task>? resolveApprovalHandler = null)
Func<ResolveApprovalCommandDto, CancellationToken, Task>? resolveApprovalHandler = null,
Func<ResolveUserInputCommandDto, CancellationToken, Task>? resolveUserInputHandler = null)
{
_handler = handler;
_resolveApprovalHandler = resolveApprovalHandler ?? ((_, _) => Task.CompletedTask);
_resolveUserInputHandler = resolveUserInputHandler ?? ((_, _) => Task.CompletedTask);
}
public Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
@@ -642,9 +782,10 @@ public sealed class SidecarProtocolHostTests
Func<TurnDeltaEventDto, Task> onDelta,
Func<AgentActivityEventDto, Task> onActivity,
Func<ApprovalRequestedEventDto, Task> onApproval,
Func<UserInputRequestedEventDto, Task> 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);
}
}
}