mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-23 21:18:40 +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:
@@ -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<RunTurnMcpServerConfigDto> 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<string>? Choices { get; init; }
|
||||
public bool? AllowFreeform { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CommandErrorEventDto : SidecarEventDto
|
||||
{
|
||||
public string Message { get; init; } = string.Empty;
|
||||
|
||||
@@ -22,6 +22,7 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
public static async Task<CopilotAgentBundle> CreateAsync(
|
||||
RunTurnCommandDto command,
|
||||
Func<PatternAgentDefinitionDto, PermissionRequest, PermissionInvocation, Task<PermissionRequestResult>> onPermissionRequest,
|
||||
Func<PatternAgentDefinitionDto, UserInputRequest, UserInputInvocation, Task<UserInputResponse>> onUserInputRequest,
|
||||
Action<PatternAgentDefinitionDto, SessionEvent>? 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,
|
||||
};
|
||||
|
||||
@@ -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<string, PendingUserInputRequest> _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<UserInputResponse> RequestUserInputAsync(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agent,
|
||||
UserInputRequest request,
|
||||
UserInputInvocation invocation,
|
||||
Func<UserInputRequestedEventDto, Task> 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<UserInputResponse>)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<UserInputResponse>(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<string>? NormalizeOptionalStringList(IEnumerable<string?> values)
|
||||
{
|
||||
List<string> normalized = values
|
||||
.Select(NormalizeOptionalString)
|
||||
.Where(static value => value is not null)
|
||||
.Cast<string>()
|
||||
.ToList();
|
||||
|
||||
return normalized.Count > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
private sealed record PendingUserInputRequest(
|
||||
string RequestId,
|
||||
string SessionId,
|
||||
string UserInputId,
|
||||
TaskCompletionSource<UserInputResponse> Response);
|
||||
}
|
||||
@@ -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<TurnDeltaEventDto, Task> onDelta,
|
||||
Func<AgentActivityEventDto, Task> onActivity,
|
||||
Func<ApprovalRequestedEventDto, Task> onApproval,
|
||||
Func<UserInputRequestedEventDto, Task> 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<bool> HandleWorkflowEventAsync(
|
||||
RunTurnCommandDto command,
|
||||
WorkflowEvent evt,
|
||||
|
||||
@@ -9,9 +9,14 @@ public interface ITurnWorkflowRunner
|
||||
Func<TurnDeltaEventDto, Task> onDelta,
|
||||
Func<AgentActivityEventDto, Task> onActivity,
|
||||
Func<ApprovalRequestedEventDto, Task> onApproval,
|
||||
Func<UserInputRequestedEventDto, Task> onUserInput,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task ResolveApprovalAsync(
|
||||
ResolveApprovalCommandDto command,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task ResolveUserInputAsync(
|
||||
ResolveUserInputCommandDto command,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -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<ResolveUserInputCommandDto>(context);
|
||||
await _workflowRunner.ResolveUserInputAsync(command, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private TCommand DeserializeCommand<TCommand>(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<SidecarRuntimeToolDto> MapRuntimeTools(IEnumerable<Tool> 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";
|
||||
|
||||
@@ -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