From 380e402512f9e93c8a9cbb9a28fdb2004006d992 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Thu, 26 Mar 2026 23:29:32 +0100 Subject: [PATCH] feat: add backend plan mode checkpoints Add backend support for plan-mode turn shaping and exit-plan review checkpoints. Run-turn commands now accept an interaction mode, plan-mode prompt guidance tells agents to produce a plan and call exit_plan_mode, and the sidecar emits a structured exit-plan-mode-requested event when the SDK raises that session event. For now the backend intentionally treats exit_plan_mode as a turn boundary and graceful-degradation review checkpoint. The current Agent Framework GitHubCopilotAgent wrapper does not expose a clean way to bridge the live CopilotSession back into same-turn exit-plan resolution, so that follow-up remains documented for the frontend and a future backend slice. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Contracts/ProtocolModels.cs | 13 ++ .../Services/AgentInstructionComposer.cs | 17 +- .../Services/CopilotAgentBundle.cs | 7 +- .../CopilotExitPlanModeCoordinator.cs | 81 +++++++++ .../Services/CopilotTurnExecutionState.cs | 6 + .../Services/CopilotWorkflowRunner.cs | 95 +++++++---- .../Services/ITurnWorkflowRunner.cs | 1 + .../Services/SidecarProtocolHost.cs | 2 +- .../AgentInstructionComposerTests.cs | 27 +++ .../CopilotExitPlanModeCoordinatorTests.cs | 72 ++++++++ .../SidecarProtocolHostTests.cs | 155 ++++++++++++++++-- 11 files changed, 422 insertions(+), 54 deletions(-) create mode 100644 sidecar/src/Aryx.AgentHost/Services/CopilotExitPlanModeCoordinator.cs create mode 100644 sidecar/tests/Aryx.AgentHost.Tests/CopilotExitPlanModeCoordinatorTests.cs diff --git a/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs b/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs index 4b405b7..9c3947c 100644 --- a/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs +++ b/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs @@ -161,6 +161,7 @@ public sealed class RunTurnCommandDto : SidecarCommandEnvelope public string SessionId { get; init; } = string.Empty; public string ProjectPath { get; init; } = string.Empty; public string WorkspaceKind { get; init; } = "project"; + public string Mode { get; init; } = "interactive"; public PatternDefinitionDto Pattern { get; init; } = new(); public IReadOnlyList Messages { get; init; } = []; public RunTurnToolingConfigDto? Tooling { get; init; } @@ -308,6 +309,18 @@ public sealed class UserInputRequestedEventDto : SidecarEventDto public bool? AllowFreeform { get; init; } } +public sealed class ExitPlanModeRequestedEventDto : SidecarEventDto +{ + public string SessionId { get; init; } = string.Empty; + public string ExitPlanId { get; init; } = string.Empty; + public string? AgentId { get; init; } + public string? AgentName { get; init; } + public string Summary { get; init; } = string.Empty; + public string PlanContent { get; init; } = string.Empty; + public IReadOnlyList? Actions { get; init; } + public string? RecommendedAction { get; init; } +} + public sealed class CommandErrorEventDto : SidecarEventDto { public string Message { get; init; } = string.Empty; diff --git a/sidecar/src/Aryx.AgentHost/Services/AgentInstructionComposer.cs b/sidecar/src/Aryx.AgentHost/Services/AgentInstructionComposer.cs index 178602f..a1be204 100644 --- a/sidecar/src/Aryx.AgentHost/Services/AgentInstructionComposer.cs +++ b/sidecar/src/Aryx.AgentHost/Services/AgentInstructionComposer.cs @@ -8,7 +8,8 @@ internal static class AgentInstructionComposer PatternDefinitionDto pattern, PatternAgentDefinitionDto agent, int agentIndex, - string workspaceKind = "project") + string workspaceKind = "project", + string interactionMode = "interactive") { string baseInstructions = agent.Instructions.Trim(); string workspaceGuidance = string.Equals(workspaceKind, "scratchpad", StringComparison.OrdinalIgnoreCase) @@ -20,6 +21,14 @@ internal static class AgentInstructionComposer Answer conversationally and focus on the user's question directly. """ : string.Empty; + string planModeGuidance = string.Equals(interactionMode, "plan", StringComparison.OrdinalIgnoreCase) + ? """ + You are operating in plan mode. + Your job in this phase is to analyze the request, identify constraints, and produce a concrete implementation plan instead of carrying out the implementation. + Once the plan is ready, call the built-in `exit_plan_mode` tool so the host can present the plan for review. + Do not continue into implementation, file edits, builds, or tests after producing the plan unless the user explicitly asks to leave plan mode and proceed. + """ + : string.Empty; if (string.Equals(pattern.Mode, "group-chat", StringComparison.OrdinalIgnoreCase)) { @@ -37,12 +46,12 @@ internal static class AgentInstructionComposer Focus on refining the answer already in progress. """; - return JoinInstructionBlocks(baseInstructions, workspaceGuidance, groupChatGuidance); + return JoinInstructionBlocks(baseInstructions, workspaceGuidance, planModeGuidance, groupChatGuidance); } if (!string.Equals(pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase)) { - return JoinInstructionBlocks(baseInstructions, workspaceGuidance); + return JoinInstructionBlocks(baseInstructions, workspaceGuidance, planModeGuidance); } string runtimeGuidance = agentIndex == 0 @@ -60,7 +69,7 @@ internal static class AgentInstructionComposer Do not push the actual work back to triage unless you are blocked or the request is clearly outside your specialty. """; - return JoinInstructionBlocks(baseInstructions, workspaceGuidance, runtimeGuidance); + return JoinInstructionBlocks(baseInstructions, workspaceGuidance, planModeGuidance, runtimeGuidance); } private static string JoinInstructionBlocks(params string[] blocks) diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs index 1293902..f4f0f94 100644 --- a/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs @@ -50,7 +50,12 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable ReasoningEffort = definition.ReasoningEffort, SystemMessage = new SystemMessageConfig { - Content = AgentInstructionComposer.Compose(command.Pattern, definition, agentIndex, command.WorkspaceKind), + Content = AgentInstructionComposer.Compose( + command.Pattern, + definition, + agentIndex, + command.WorkspaceKind, + command.Mode), }, WorkingDirectory = command.ProjectPath, OnPermissionRequest = (request, invocation) => onPermissionRequest(definition, request, invocation), diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotExitPlanModeCoordinator.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotExitPlanModeCoordinator.cs new file mode 100644 index 0000000..d272fd9 --- /dev/null +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotExitPlanModeCoordinator.cs @@ -0,0 +1,81 @@ +using System.Collections.Concurrent; +using Aryx.AgentHost.Contracts; +using GitHub.Copilot.SDK; + +namespace Aryx.AgentHost.Services; + +internal sealed class CopilotExitPlanModeCoordinator +{ + private readonly ConcurrentDictionary _pendingExitPlanRequests = + new(StringComparer.Ordinal); + + public ExitPlanModeRequestedEventDto RecordExitPlanModeRequest( + RunTurnCommandDto command, + PatternAgentDefinitionDto agent, + ExitPlanModeRequestedEvent request) + { + ArgumentNullException.ThrowIfNull(command); + ArgumentNullException.ThrowIfNull(agent); + ArgumentNullException.ThrowIfNull(request); + + ExitPlanModeRequestedEventDto exitPlanEvent = BuildExitPlanModeRequestedEvent(command, agent, request); + _pendingExitPlanRequests[command.RequestId] = exitPlanEvent; + return exitPlanEvent; + } + + public ExitPlanModeRequestedEventDto? ConsumePendingRequest(string turnRequestId) + { + ArgumentException.ThrowIfNullOrWhiteSpace(turnRequestId); + return _pendingExitPlanRequests.TryRemove(turnRequestId, out ExitPlanModeRequestedEventDto? pending) + ? pending + : null; + } + + internal static ExitPlanModeRequestedEventDto BuildExitPlanModeRequestedEvent( + RunTurnCommandDto command, + PatternAgentDefinitionDto agent, + ExitPlanModeRequestedEvent request) + { + ArgumentNullException.ThrowIfNull(command); + ArgumentNullException.ThrowIfNull(agent); + ArgumentNullException.ThrowIfNull(request); + + ExitPlanModeRequestedData requestData = request.Data + ?? throw new InvalidOperationException("Exit plan mode request data is required."); + + string exitPlanId = NormalizeOptionalString(requestData.RequestId) + ?? throw new InvalidOperationException("Exit plan mode request ID is required."); + string? normalizedAgentId = NormalizeOptionalString(agent.Id); + string? normalizedAgentName = NormalizeOptionalString(agent.Name) ?? normalizedAgentId; + + return new ExitPlanModeRequestedEventDto + { + Type = "exit-plan-mode-requested", + RequestId = command.RequestId, + SessionId = command.SessionId, + ExitPlanId = exitPlanId, + AgentId = normalizedAgentId, + AgentName = normalizedAgentName, + Summary = NormalizeOptionalString(requestData.Summary) ?? string.Empty, + PlanContent = NormalizeOptionalString(requestData.PlanContent) ?? string.Empty, + Actions = NormalizeOptionalStringList(requestData.Actions ?? []), + RecommendedAction = NormalizeOptionalString(requestData.RecommendedAction), + }; + } + + 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; + } +} diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotTurnExecutionState.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotTurnExecutionState.cs index de5add6..2fb98d3 100644 --- a/sidecar/src/Aryx.AgentHost/Services/CopilotTurnExecutionState.cs +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotTurnExecutionState.cs @@ -24,6 +24,8 @@ internal sealed class CopilotTurnExecutionState public List CompletedMessages { get; private set; } = []; + public bool HasPendingExitPlanModeRequest { get; private set; } + public async Task EmitThinkingIfNeeded( AgentIdentity agent, Func onActivity) @@ -74,6 +76,10 @@ internal sealed class CopilotTurnExecutionState case AssistantReasoningDeltaEvent: ActiveAgent = agent; break; + case ExitPlanModeRequestedEvent: + HasPendingExitPlanModeRequest = true; + ActiveAgent = agent; + break; } } diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs index f41ff33..b54011c 100644 --- a/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs @@ -1,4 +1,5 @@ using Aryx.AgentHost.Contracts; +using GitHub.Copilot.SDK; using Microsoft.Agents.AI.Workflows; using Microsoft.Extensions.AI; @@ -9,6 +10,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner private readonly PatternValidator _patternValidator; private readonly CopilotApprovalCoordinator _approvalCoordinator = new(); private readonly CopilotUserInputCoordinator _userInputCoordinator = new(); + private readonly CopilotExitPlanModeCoordinator _exitPlanModeCoordinator = new(); public CopilotWorkflowRunner(PatternValidator patternValidator) { @@ -21,6 +23,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner Func onActivity, Func onApproval, Func onUserInput, + Func onExitPlanMode, CancellationToken cancellationToken) { PatternValidationIssueDto? validationError = _patternValidator.Validate(command.Pattern).FirstOrDefault(); @@ -30,42 +33,68 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner } CopilotTurnExecutionState state = new(command); - await using CopilotAgentBundle bundle = await CopilotAgentBundle.CreateAsync( - command, - (agent, request, invocation) => _approvalCoordinator.RequestApprovalAsync( - command, - agent, - request, - invocation, - 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); - List inputMessages = command.Messages.Select(WorkflowTranscriptProjector.ToChatMessage).ToList(); + using CancellationTokenSource runCancellation = + CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); - await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, inputMessages).ConfigureAwait(false); - await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false); - - await foreach (WorkflowEvent evt in run.WatchStreamAsync(cancellationToken).ConfigureAwait(false)) + try { - bool shouldEndTurn = await HandleWorkflowEventAsync(command, evt, inputMessages, state, onDelta, onActivity) - .ConfigureAwait(false); - if (shouldEndTurn) - { - break; - } - } + await using CopilotAgentBundle bundle = await CopilotAgentBundle.CreateAsync( + command, + (agent, request, invocation) => _approvalCoordinator.RequestApprovalAsync( + command, + agent, + request, + invocation, + state.ToolNamesByCallId, + onApproval, + runCancellation.Token), + (agent, request, invocation) => _userInputCoordinator.RequestUserInputAsync( + command, + agent, + request, + invocation, + onUserInput, + runCancellation.Token), + (agent, sessionEvent) => + { + state.ObserveSessionEvent(agent, sessionEvent); + if (sessionEvent is ExitPlanModeRequestedEvent exitPlanModeRequested) + { + _exitPlanModeCoordinator.RecordExitPlanModeRequest(command, agent, exitPlanModeRequested); + runCancellation.Cancel(); + } + }, + runCancellation.Token); + Workflow workflow = bundle.BuildWorkflow(command.Pattern); + List inputMessages = command.Messages.Select(WorkflowTranscriptProjector.ToChatMessage).ToList(); - return state.FinalizeCompletedMessages(); + await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, inputMessages).ConfigureAwait(false); + await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false); + + await foreach (WorkflowEvent evt in run.WatchStreamAsync(runCancellation.Token).ConfigureAwait(false)) + { + bool shouldEndTurn = await HandleWorkflowEventAsync(command, evt, inputMessages, state, onDelta, onActivity) + .ConfigureAwait(false); + if (shouldEndTurn) + { + break; + } + } + + return state.FinalizeCompletedMessages(); + } + catch (OperationCanceledException) when (runCancellation.IsCancellationRequested && !cancellationToken.IsCancellationRequested) + { + ExitPlanModeRequestedEventDto? exitPlanModeEvent = + _exitPlanModeCoordinator.ConsumePendingRequest(command.RequestId); + if (exitPlanModeEvent is null || !state.HasPendingExitPlanModeRequest) + { + throw; + } + + await onExitPlanMode(exitPlanModeEvent).ConfigureAwait(false); + return state.FinalizeCompletedMessages(); + } } public Task ResolveApprovalAsync( diff --git a/sidecar/src/Aryx.AgentHost/Services/ITurnWorkflowRunner.cs b/sidecar/src/Aryx.AgentHost/Services/ITurnWorkflowRunner.cs index b325978..800ba7e 100644 --- a/sidecar/src/Aryx.AgentHost/Services/ITurnWorkflowRunner.cs +++ b/sidecar/src/Aryx.AgentHost/Services/ITurnWorkflowRunner.cs @@ -10,6 +10,7 @@ public interface ITurnWorkflowRunner Func onActivity, Func onApproval, Func onUserInput, + Func onExitPlanMode, CancellationToken cancellationToken); Task ResolveApprovalAsync( diff --git a/sidecar/src/Aryx.AgentHost/Services/SidecarProtocolHost.cs b/sidecar/src/Aryx.AgentHost/Services/SidecarProtocolHost.cs index 33ee0f2..8771fc3 100644 --- a/sidecar/src/Aryx.AgentHost/Services/SidecarProtocolHost.cs +++ b/sidecar/src/Aryx.AgentHost/Services/SidecarProtocolHost.cs @@ -21,7 +21,6 @@ public sealed class SidecarProtocolHost AskUserToolName, "report_intent", "task_complete", - "exit_plan_mode", }; private static readonly string[] AuthenticationErrorIndicators = @@ -189,6 +188,7 @@ public sealed class SidecarProtocolHost activity => WriteAsync(context.Output, activity, turnCancellation.Token), approval => WriteAsync(context.Output, approval, turnCancellation.Token), userInput => WriteAsync(context.Output, userInput, turnCancellation.Token), + exitPlanMode => WriteAsync(context.Output, exitPlanMode, turnCancellation.Token), turnCancellation.Token) .ConfigureAwait(false); diff --git a/sidecar/tests/Aryx.AgentHost.Tests/AgentInstructionComposerTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/AgentInstructionComposerTests.cs index 1740635..3a01ed0 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/AgentInstructionComposerTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/AgentInstructionComposerTests.cs @@ -124,6 +124,33 @@ public sealed class AgentInstructionComposerTests Assert.DoesNotContain("Do not inspect, modify, create, or delete files", instructions, StringComparison.OrdinalIgnoreCase); } + [Fact] + public void Compose_AddsPlanModeGuidanceWhenRequested() + { + PatternDefinitionDto pattern = new() + { + Id = "pattern-single", + Name = "Single", + Mode = "single", + Availability = "available", + }; + PatternAgentDefinitionDto agent = CreateAgent( + id: "agent-primary", + name: "Primary Agent", + instructions: "You are a helpful assistant."); + + string instructions = AgentInstructionComposer.Compose( + pattern, + agent, + agentIndex: 0, + interactionMode: "plan"); + + Assert.Contains("operating in plan mode", instructions, StringComparison.OrdinalIgnoreCase); + Assert.Contains("produce a concrete implementation plan", instructions, StringComparison.OrdinalIgnoreCase); + Assert.Contains("exit_plan_mode", instructions, StringComparison.OrdinalIgnoreCase); + Assert.Contains("Do not continue into implementation", instructions, StringComparison.OrdinalIgnoreCase); + } + private static PatternAgentDefinitionDto CreateAgent(string id, string name, string instructions) { return new PatternAgentDefinitionDto diff --git a/sidecar/tests/Aryx.AgentHost.Tests/CopilotExitPlanModeCoordinatorTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/CopilotExitPlanModeCoordinatorTests.cs new file mode 100644 index 0000000..cfcd964 --- /dev/null +++ b/sidecar/tests/Aryx.AgentHost.Tests/CopilotExitPlanModeCoordinatorTests.cs @@ -0,0 +1,72 @@ +using Aryx.AgentHost.Contracts; +using Aryx.AgentHost.Services; +using GitHub.Copilot.SDK; + +namespace Aryx.AgentHost.Tests; + +public sealed class CopilotExitPlanModeCoordinatorTests +{ + [Fact] + public void RecordExitPlanModeRequest_BuildsEventAndMakesItConsumable() + { + CopilotExitPlanModeCoordinator coordinator = new(); + RunTurnCommandDto command = CreateCommand(); + + ExitPlanModeRequestedEventDto exitPlanEvent = coordinator.RecordExitPlanModeRequest( + command, + command.Pattern.Agents[0], + new ExitPlanModeRequestedEvent + { + Data = new ExitPlanModeRequestedData + { + RequestId = "exit-plan-1", + Summary = "Proposed plan", + PlanContent = "1. Investigate\n2. Implement", + Actions = ["interactive", "autopilot"], + RecommendedAction = "interactive", + }, + }); + + Assert.Equal("exit-plan-mode-requested", exitPlanEvent.Type); + Assert.Equal("turn-1", exitPlanEvent.RequestId); + Assert.Equal("session-1", exitPlanEvent.SessionId); + Assert.Equal("exit-plan-1", exitPlanEvent.ExitPlanId); + Assert.Equal("agent-1", exitPlanEvent.AgentId); + Assert.Equal("Primary", exitPlanEvent.AgentName); + Assert.Equal("Proposed plan", exitPlanEvent.Summary); + Assert.Equal("1. Investigate\n2. Implement", exitPlanEvent.PlanContent); + Assert.Equal(["interactive", "autopilot"], exitPlanEvent.Actions); + Assert.Equal("interactive", exitPlanEvent.RecommendedAction); + + ExitPlanModeRequestedEventDto? consumed = coordinator.ConsumePendingRequest(command.RequestId); + Assert.NotNull(consumed); + Assert.Equal("exit-plan-1", consumed!.ExitPlanId); + Assert.Null(coordinator.ConsumePendingRequest(command.RequestId)); + } + + private static RunTurnCommandDto CreateCommand() + { + return new RunTurnCommandDto + { + RequestId = "turn-1", + SessionId = "session-1", + Pattern = new PatternDefinitionDto + { + Id = "pattern-1", + Name = "Plan Mode Pattern", + Mode = "single", + Availability = "available", + Agents = + [ + new PatternAgentDefinitionDto + { + Id = "agent-1", + Name = "Primary", + Model = "gpt-5.4", + Instructions = "Help with the request.", + }, + ], + }, + }; + } +} diff --git a/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs index e8c192c..b8e84a3 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs @@ -113,7 +113,7 @@ public sealed class SidecarProtocolHostTests { SidecarProtocolHost host = new( new PatternValidator(), - new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, cancellationToken) => + new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onExitPlanMode, cancellationToken) => { await onActivity(new AgentActivityEventDto { @@ -232,12 +232,49 @@ public sealed class SidecarProtocolHostTests }); } + [Fact] + public async Task RunTurnCommand_DeserializesInteractionMode() + { + string? capturedMode = null; + SidecarProtocolHost host = new( + new PatternValidator(), + new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onExitPlanMode, cancellationToken) => + { + capturedMode = command.Mode; + return []; + })); + + await RunHostAsync( + new RunTurnCommandDto + { + Type = "run-turn", + RequestId = "turn-plan", + SessionId = "session-1", + ProjectPath = "C:\\workspace\\project", + Mode = "plan", + Pattern = new PatternDefinitionDto + { + Id = "pattern-1", + Name = "Single Agent", + Mode = "single", + Availability = "available", + Agents = + [ + CreateAgent(name: "Primary"), + ], + }, + }, + host); + + Assert.Equal("plan", capturedMode); + } + [Fact] public async Task RunTurnCommand_ReturnsApprovalEvents() { SidecarProtocolHost host = new( new PatternValidator(), - new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, cancellationToken) => + new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onExitPlanMode, cancellationToken) => { await onApproval(new ApprovalRequestedEventDto { @@ -315,7 +352,7 @@ public sealed class SidecarProtocolHostTests { SidecarProtocolHost host = new( new PatternValidator(), - new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, cancellationToken) => + new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onExitPlanMode, cancellationToken) => { await onUserInput(new UserInputRequestedEventDto { @@ -383,12 +420,87 @@ public sealed class SidecarProtocolHostTests }); } + [Fact] + public async Task RunTurnCommand_ReturnsExitPlanModeEvents() + { + SidecarProtocolHost host = new( + new PatternValidator(), + new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onExitPlanMode, cancellationToken) => + { + await onExitPlanMode(new ExitPlanModeRequestedEventDto + { + Type = "exit-plan-mode-requested", + RequestId = command.RequestId, + SessionId = command.SessionId, + ExitPlanId = "exit-plan-1", + AgentId = "agent-1", + AgentName = "Primary", + Summary = "Proposed implementation plan", + PlanContent = "1. Inspect\n2. Change\n3. Validate", + Actions = ["interactive", "autopilot"], + RecommendedAction = "interactive", + }); + + return []; + })); + + IReadOnlyList events = await RunHostAsync( + new RunTurnCommandDto + { + Type = "run-turn", + RequestId = "turn-plan-mode", + SessionId = "session-1", + ProjectPath = "C:\\workspace\\project", + Mode = "plan", + Pattern = new PatternDefinitionDto + { + Id = "pattern-1", + Name = "Single Agent", + Mode = "single", + Availability = "available", + Agents = + [ + CreateAgent(name: "Primary"), + ], + }, + }, + host); + + Assert.Collection( + events, + exitPlanEvent => + { + Assert.Equal("exit-plan-mode-requested", exitPlanEvent.GetProperty("type").GetString()); + Assert.Equal("turn-plan-mode", exitPlanEvent.GetProperty("requestId").GetString()); + Assert.Equal("exit-plan-1", exitPlanEvent.GetProperty("exitPlanId").GetString()); + Assert.Equal("Primary", exitPlanEvent.GetProperty("agentName").GetString()); + Assert.Equal("Proposed implementation plan", exitPlanEvent.GetProperty("summary").GetString()); + Assert.Equal("1. Inspect\n2. Change\n3. Validate", exitPlanEvent.GetProperty("planContent").GetString()); + string[] actions = exitPlanEvent.GetProperty("actions") + .EnumerateArray() + .Select(action => action.GetString() ?? string.Empty) + .ToArray(); + Assert.Equal(["interactive", "autopilot"], actions); + Assert.Equal("interactive", exitPlanEvent.GetProperty("recommendedAction").GetString()); + }, + 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-plan-mode", commandCompleteEvent.GetProperty("requestId").GetString()); + }); + } + [Fact] public async Task CancelTurnCommand_CancelsInProgressTurnAndCompletesBothCommands() { SidecarProtocolHost host = new( new PatternValidator(), - new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, cancellationToken) => + new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onExitPlanMode, cancellationToken) => { await Task.Delay(Timeout.Infinite, cancellationToken); return []; @@ -436,7 +548,7 @@ public sealed class SidecarProtocolHostTests { SidecarProtocolHost host = new( new PatternValidator(), - new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, cancellationToken) => [])); + new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onExitPlanMode, cancellationToken) => [])); await RunHostAsync(CreateRunTurnCommand(requestId: "turn-completed"), host); @@ -459,7 +571,7 @@ public sealed class SidecarProtocolHostTests SidecarProtocolHost host = new( new PatternValidator(), new FakeWorkflowRunner( - handler: async (command, onDelta, onActivity, onApproval, onUserInput, cancellationToken) => [], + handler: async (command, onDelta, onActivity, onApproval, onUserInput, onExitPlanMode, cancellationToken) => [], resolveApprovalHandler: (command, cancellationToken) => { captured = command; @@ -490,7 +602,7 @@ public sealed class SidecarProtocolHostTests SidecarProtocolHost host = new( new PatternValidator(), new FakeWorkflowRunner( - handler: async (command, onDelta, onActivity, onApproval, onUserInput, cancellationToken) => [], + handler: async (command, onDelta, onActivity, onApproval, onUserInput, onExitPlanMode, cancellationToken) => [], resolveUserInputHandler: (command, cancellationToken) => { captured = command; @@ -517,7 +629,7 @@ public sealed class SidecarProtocolHostTests } [Fact] - public void MapRuntimeTools_ExcludesInternalToolsAndDeduplicatesByName() + public void MapRuntimeTools_ExcludesOnlyInternalMetaToolsAndDeduplicatesByName() { IReadOnlyList runtimeTools = SidecarProtocolHost.MapRuntimeTools( [ @@ -553,10 +665,20 @@ public sealed class SidecarProtocolHostTests }, ]); - 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); + Assert.Collection( + runtimeTools, + exitPlanTool => + { + Assert.Equal("exit_plan_mode", exitPlanTool.Id); + Assert.Equal("exit_plan_mode", exitPlanTool.Label); + Assert.Equal("Exit plan mode.", exitPlanTool.Description); + }, + runtimeTool => + { + Assert.Equal("web_fetch", runtimeTool.Id); + Assert.Equal("web_fetch", runtimeTool.Label); + Assert.Equal("Fetch content from the web.", runtimeTool.Description); + }); } [Fact] @@ -626,9 +748,9 @@ public sealed class SidecarProtocolHostTests string eventType, string requestId) { - return Assert.Single(events.Where(evt => + return Assert.Single(events, evt => evt.GetProperty("type").GetString() == eventType - && evt.GetProperty("requestId").GetString() == requestId)); + && evt.GetProperty("requestId").GetString() == requestId); } private static SidecarProtocolHost CreateHostForTests() @@ -770,6 +892,7 @@ public sealed class SidecarProtocolHostTests Func, Func, Func, + Func, CancellationToken, Task>> _handler; private readonly Func _resolveApprovalHandler; @@ -782,6 +905,7 @@ public sealed class SidecarProtocolHostTests Func, Func, Func, + Func, CancellationToken, Task>> handler, Func? resolveApprovalHandler = null, @@ -798,9 +922,10 @@ public sealed class SidecarProtocolHostTests Func onActivity, Func onApproval, Func onUserInput, + Func onExitPlanMode, CancellationToken cancellationToken) { - return _handler(command, onDelta, onActivity, onApproval, onUserInput, cancellationToken); + return _handler(command, onDelta, onActivity, onApproval, onUserInput, onExitPlanMode, cancellationToken); } public Task ResolveApprovalAsync(