From f04e3b9dcc3cd55bcdbfeb8e5e855237a032c65b Mon Sep 17 00:00:00 2001 From: David Kaya Date: Fri, 27 Mar 2026 21:24:55 +0100 Subject: [PATCH] feat: cache same-turn approval selections Add an AlwaysApprove flag to resolve-approval commands and cache approved tool keys for the current request so repeated runtime permissions are auto-approved immediately within the same turn. The cache is cleared when the turn finishes to avoid leaking across future requests. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Contracts/ProtocolModels.cs | 1 + .../Services/CopilotApprovalCoordinator.cs | 65 ++++++- .../Services/CopilotWorkflowRunner.cs | 4 + .../CopilotWorkflowRunnerTests.cs | 168 +++++++++++++++++- .../SidecarProtocolHostTests.cs | 2 + 5 files changed, 235 insertions(+), 5 deletions(-) diff --git a/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs b/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs index 3bffa47..356420e 100644 --- a/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs +++ b/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs @@ -176,6 +176,7 @@ public sealed class ResolveApprovalCommandDto : SidecarCommandEnvelope { public string ApprovalId { get; init; } = string.Empty; public string Decision { get; init; } = string.Empty; + public bool AlwaysApprove { get; init; } } public sealed class ResolveUserInputCommandDto : SidecarCommandEnvelope diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotApprovalCoordinator.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotApprovalCoordinator.cs index b2b1315..1cc7e41 100644 --- a/sidecar/src/Aryx.AgentHost/Services/CopilotApprovalCoordinator.cs +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotApprovalCoordinator.cs @@ -21,6 +21,7 @@ internal sealed class CopilotApprovalCoordinator private const string HookPermissionKind = "hook"; private readonly ConcurrentDictionary _pendingApprovals = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary> _requestApprovedTools = new(StringComparer.Ordinal); public Task ResolveApprovalAsync( ResolveApprovalCommandDto command, @@ -37,6 +38,11 @@ internal sealed class CopilotApprovalCoordinator throw new InvalidOperationException($"Approval \"{approvalId}\" is no longer pending."); } + if (decision == PermissionRequestResultKind.Approved && command.AlwaysApprove) + { + CacheApprovedToolForRequest(pending.RequestId, pending.ApprovalCacheKey); + } + return Task.CompletedTask; } @@ -51,12 +57,14 @@ internal sealed class CopilotApprovalCoordinator { string? toolName = ResolveApprovalToolName(request, toolNamesByCallId); string? autoApprovedToolName = ResolveAutoApprovedToolName(request); - if (!RequiresToolCallApproval(command.Pattern.ApprovalPolicy, agent.Id, toolName, autoApprovedToolName)) + string? approvalCacheKey = ResolveApprovalCacheKey(toolName, autoApprovedToolName); + if (IsToolApprovedForRequest(command.RequestId, approvalCacheKey) + || !RequiresToolCallApproval(command.Pattern.ApprovalPolicy, agent.Id, toolName, autoApprovedToolName)) { return CreateApprovalResult(PermissionRequestResultKind.Approved); } - PendingApprovalRequest pending = CreatePendingApproval(command); + PendingApprovalRequest pending = CreatePendingApproval(command, approvalCacheKey); if (!_pendingApprovals.TryAdd(pending.ApprovalId, pending)) { throw new InvalidOperationException($"Approval \"{pending.ApprovalId}\" is already pending."); @@ -252,6 +260,17 @@ internal sealed class CopilotApprovalCoordinator internal static bool TryGetApprovalToolName(PermissionRequest request, out string? toolName) => TryGetApprovalToolName(request, toolNamesByCallId: null, out toolName); + internal void ClearRequestApprovals(string requestId) + { + string? normalizedRequestId = NormalizeOptionalString(requestId); + if (normalizedRequestId is null) + { + return; + } + + _requestApprovedTools.TryRemove(normalizedRequestId, out _); + } + private static bool HasMatchingToolCallCheckpoint( IReadOnlyList rules, string agentId) @@ -274,12 +293,15 @@ internal sealed class CopilotApprovalCoordinator return false; } - private static PendingApprovalRequest CreatePendingApproval(RunTurnCommandDto command) + private static PendingApprovalRequest CreatePendingApproval( + RunTurnCommandDto command, + string? approvalCacheKey) { return new PendingApprovalRequest( command.RequestId, command.SessionId, CreateApprovalRequestId(), + NormalizeOptionalString(approvalCacheKey), new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously)); } @@ -305,6 +327,14 @@ internal sealed class CopilotApprovalCoordinator return GetFallbackToolName(request); } + private static string? ResolveApprovalCacheKey( + string? toolName, + string? autoApprovedToolName) + { + return NormalizeOptionalString(autoApprovedToolName) + ?? NormalizeOptionalString(toolName); + } + private static string? GetDirectToolName(PermissionRequest request) { return request switch @@ -383,6 +413,34 @@ internal sealed class CopilotApprovalCoordinator string.Equals(candidate, normalizedToolName, StringComparison.OrdinalIgnoreCase)); } + private bool IsToolApprovedForRequest(string requestId, string? approvalCacheKey) + { + string? normalizedRequestId = NormalizeOptionalString(requestId); + string? normalizedApprovalCacheKey = NormalizeOptionalString(approvalCacheKey); + if (normalizedRequestId is null || normalizedApprovalCacheKey is null) + { + return false; + } + + return _requestApprovedTools.TryGetValue(normalizedRequestId, out ConcurrentDictionary? approvedTools) + && approvedTools.ContainsKey(normalizedApprovalCacheKey); + } + + private void CacheApprovedToolForRequest(string requestId, string? approvalCacheKey) + { + string? normalizedRequestId = NormalizeOptionalString(requestId); + string? normalizedApprovalCacheKey = NormalizeOptionalString(approvalCacheKey); + if (normalizedRequestId is null || normalizedApprovalCacheKey is null) + { + return; + } + + ConcurrentDictionary approvedTools = _requestApprovedTools.GetOrAdd( + normalizedRequestId, + static _ => new ConcurrentDictionary(StringComparer.OrdinalIgnoreCase)); + approvedTools.TryAdd(normalizedApprovalCacheKey, 0); + } + private PendingApprovalRequest GetPendingApproval(string approvalId) { if (_pendingApprovals.TryGetValue(approvalId, out PendingApprovalRequest? pending)) @@ -436,5 +494,6 @@ internal sealed class CopilotApprovalCoordinator string RequestId, string SessionId, string ApprovalId, + string? ApprovalCacheKey, TaskCompletionSource Decision); } diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs index 280a31c..6022029 100644 --- a/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs @@ -111,6 +111,10 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner await onExitPlanMode(exitPlanModeEvent).ConfigureAwait(false); return state.FinalizeCompletedMessages(); } + finally + { + _approvalCoordinator.ClearRequestApprovals(command.RequestId); + } } private static async Task EmitPendingActivityEventsAsync( diff --git a/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs index 3427ce4..e9fcb23 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs @@ -1314,6 +1314,170 @@ public sealed class CopilotWorkflowRunnerTests Assert.Equal(PermissionRequestResultKind.Approved, result.Kind); } + [Fact] + public async Task RequestApprovalAsync_AlwaysApproveCachesRuntimeApprovalForCurrentTurn() + { + CopilotApprovalCoordinator coordinator = new(); + ApprovalRequestedEventDto? firstApproval = null; + RunTurnCommandDto command = CreateApprovalCommand(); + + Task firstPending = coordinator.RequestApprovalAsync( + command, + command.Pattern.Agents[0], + new PermissionRequestRead + { + Kind = "read", + ToolCallId = "tool-call-read-1", + Intention = "Inspect README guidance", + Path = "README.md", + }, + new PermissionInvocation + { + SessionId = "copilot-session-1", + }, + new Dictionary(StringComparer.Ordinal) + { + ["tool-call-read-1"] = "view", + }, + approval => + { + firstApproval = approval; + return Task.CompletedTask; + }, + CancellationToken.None); + + Assert.False(firstPending.IsCompleted); + Assert.NotNull(firstApproval); + + await coordinator.ResolveApprovalAsync( + new ResolveApprovalCommandDto + { + ApprovalId = firstApproval!.ApprovalId, + Decision = "approved", + AlwaysApprove = true, + }, + CancellationToken.None); + + PermissionRequestResult firstResult = await firstPending; + Assert.Equal(PermissionRequestResultKind.Approved, firstResult.Kind); + + bool sawSecondApproval = false; + PermissionRequestResult secondResult = await coordinator.RequestApprovalAsync( + command, + command.Pattern.Agents[0], + new PermissionRequestRead + { + Kind = "read", + ToolCallId = "tool-call-read-2", + Intention = "Inspect docs guidance", + Path = "docs\\guide.md", + }, + new PermissionInvocation + { + SessionId = "copilot-session-1", + }, + new Dictionary(StringComparer.Ordinal) + { + ["tool-call-read-2"] = "grep", + }, + approval => + { + sawSecondApproval = true; + return Task.CompletedTask; + }, + CancellationToken.None); + + Assert.False(sawSecondApproval); + Assert.Equal(PermissionRequestResultKind.Approved, secondResult.Kind); + } + + [Fact] + public async Task RequestApprovalAsync_AlwaysApproveCacheDoesNotCarryAcrossTurnRequests() + { + CopilotApprovalCoordinator coordinator = new(); + ApprovalRequestedEventDto? firstApproval = null; + RunTurnCommandDto firstCommand = CreateApprovalCommand(); + + Task firstPending = coordinator.RequestApprovalAsync( + firstCommand, + firstCommand.Pattern.Agents[0], + new PermissionRequestRead + { + Kind = "read", + ToolCallId = "tool-call-read-1", + Intention = "Inspect README guidance", + Path = "README.md", + }, + new PermissionInvocation + { + SessionId = "copilot-session-1", + }, + new Dictionary(StringComparer.Ordinal) + { + ["tool-call-read-1"] = "view", + }, + approval => + { + firstApproval = approval; + return Task.CompletedTask; + }, + CancellationToken.None); + + Assert.NotNull(firstApproval); + + await coordinator.ResolveApprovalAsync( + new ResolveApprovalCommandDto + { + ApprovalId = firstApproval!.ApprovalId, + Decision = "approved", + AlwaysApprove = true, + }, + CancellationToken.None); + + await firstPending; + + ApprovalRequestedEventDto? secondApproval = null; + RunTurnCommandDto secondCommand = CreateApprovalCommand(requestId: "turn-2"); + Task secondPending = coordinator.RequestApprovalAsync( + secondCommand, + secondCommand.Pattern.Agents[0], + new PermissionRequestRead + { + Kind = "read", + ToolCallId = "tool-call-read-2", + Intention = "Inspect docs guidance", + Path = "docs\\guide.md", + }, + new PermissionInvocation + { + SessionId = "copilot-session-1", + }, + new Dictionary(StringComparer.Ordinal) + { + ["tool-call-read-2"] = "grep", + }, + approval => + { + secondApproval = approval; + return Task.CompletedTask; + }, + CancellationToken.None); + + Assert.False(secondPending.IsCompleted); + Assert.NotNull(secondApproval); + + await coordinator.ResolveApprovalAsync( + new ResolveApprovalCommandDto + { + ApprovalId = secondApproval!.ApprovalId, + Decision = "approved", + }, + CancellationToken.None); + + PermissionRequestResult secondResult = await secondPending; + Assert.Equal(PermissionRequestResultKind.Approved, secondResult.Kind); + } + [Fact] public async Task ResolveApprovalAsync_RejectsUnknownApprovalIds() { @@ -1390,11 +1554,11 @@ public sealed class CopilotWorkflowRunnerTests null!); } - private static RunTurnCommandDto CreateApprovalCommand() + private static RunTurnCommandDto CreateApprovalCommand(string requestId = "turn-1") { return new RunTurnCommandDto { - RequestId = "turn-1", + RequestId = requestId, SessionId = "session-1", Pattern = new PatternDefinitionDto { diff --git a/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs index bf0134c..33203fb 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs @@ -643,6 +643,7 @@ public sealed class SidecarProtocolHostTests RequestId = "approval-command-1", ApprovalId = "approval-1", Decision = "approved", + AlwaysApprove = true, }, host); @@ -651,6 +652,7 @@ public sealed class SidecarProtocolHostTests Assert.Equal("approval-command-1", completionEvent.GetProperty("requestId").GetString()); Assert.Equal("approval-1", captured?.ApprovalId); Assert.Equal("approved", captured?.Decision); + Assert.True(captured?.AlwaysApprove ?? false); } [Fact]