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>
This commit is contained in:
David Kaya
2026-03-26 23:29:32 +01:00
co-authored by Copilot
parent c069b86add
commit 380e402512
11 changed files with 422 additions and 54 deletions
@@ -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<ChatMessageDto> 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<string>? Actions { get; init; }
public string? RecommendedAction { get; init; }
}
public sealed class CommandErrorEventDto : SidecarEventDto
{
public string Message { get; init; } = string.Empty;
@@ -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)
@@ -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),
@@ -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<string, ExitPlanModeRequestedEventDto> _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<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;
}
}
@@ -24,6 +24,8 @@ internal sealed class CopilotTurnExecutionState
public List<ChatMessageDto> CompletedMessages { get; private set; } = [];
public bool HasPendingExitPlanModeRequest { get; private set; }
public async Task EmitThinkingIfNeeded(
AgentIdentity agent,
Func<AgentActivityEventDto, Task> onActivity)
@@ -74,6 +76,10 @@ internal sealed class CopilotTurnExecutionState
case AssistantReasoningDeltaEvent:
ActiveAgent = agent;
break;
case ExitPlanModeRequestedEvent:
HasPendingExitPlanModeRequest = true;
ActiveAgent = agent;
break;
}
}
@@ -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<AgentActivityEventDto, Task> onActivity,
Func<ApprovalRequestedEventDto, Task> onApproval,
Func<UserInputRequestedEventDto, Task> onUserInput,
Func<ExitPlanModeRequestedEventDto, Task> 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<ChatMessage> 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<ChatMessage> 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(
@@ -10,6 +10,7 @@ public interface ITurnWorkflowRunner
Func<AgentActivityEventDto, Task> onActivity,
Func<ApprovalRequestedEventDto, Task> onApproval,
Func<UserInputRequestedEventDto, Task> onUserInput,
Func<ExitPlanModeRequestedEventDto, Task> onExitPlanMode,
CancellationToken cancellationToken);
Task ResolveApprovalAsync(
@@ -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);
@@ -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
@@ -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.",
},
],
},
};
}
}
@@ -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<JsonElement> 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<SidecarRuntimeToolDto> 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<AgentActivityEventDto, Task>,
Func<ApprovalRequestedEventDto, Task>,
Func<UserInputRequestedEventDto, Task>,
Func<ExitPlanModeRequestedEventDto, Task>,
CancellationToken,
Task<IReadOnlyList<ChatMessageDto>>> _handler;
private readonly Func<ResolveApprovalCommandDto, CancellationToken, Task> _resolveApprovalHandler;
@@ -782,6 +905,7 @@ public sealed class SidecarProtocolHostTests
Func<AgentActivityEventDto, Task>,
Func<ApprovalRequestedEventDto, Task>,
Func<UserInputRequestedEventDto, Task>,
Func<ExitPlanModeRequestedEventDto, Task>,
CancellationToken,
Task<IReadOnlyList<ChatMessageDto>>> handler,
Func<ResolveApprovalCommandDto, CancellationToken, Task>? resolveApprovalHandler = null,
@@ -798,9 +922,10 @@ public sealed class SidecarProtocolHostTests
Func<AgentActivityEventDto, Task> onActivity,
Func<ApprovalRequestedEventDto, Task> onApproval,
Func<UserInputRequestedEventDto, Task> onUserInput,
Func<ExitPlanModeRequestedEventDto, Task> onExitPlanMode,
CancellationToken cancellationToken)
{
return _handler(command, onDelta, onActivity, onApproval, onUserInput, cancellationToken);
return _handler(command, onDelta, onActivity, onApproval, onUserInput, onExitPlanMode, cancellationToken);
}
public Task ResolveApprovalAsync(