mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-07 20:28:46 +02:00
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:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user