mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-26 21:03:58 +02:00
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:
@@ -161,6 +161,7 @@ public sealed class RunTurnCommandDto : SidecarCommandEnvelope
|
|||||||
public string SessionId { get; init; } = string.Empty;
|
public string SessionId { get; init; } = string.Empty;
|
||||||
public string ProjectPath { get; init; } = string.Empty;
|
public string ProjectPath { get; init; } = string.Empty;
|
||||||
public string WorkspaceKind { get; init; } = "project";
|
public string WorkspaceKind { get; init; } = "project";
|
||||||
|
public string Mode { get; init; } = "interactive";
|
||||||
public PatternDefinitionDto Pattern { get; init; } = new();
|
public PatternDefinitionDto Pattern { get; init; } = new();
|
||||||
public IReadOnlyList<ChatMessageDto> Messages { get; init; } = [];
|
public IReadOnlyList<ChatMessageDto> Messages { get; init; } = [];
|
||||||
public RunTurnToolingConfigDto? Tooling { get; init; }
|
public RunTurnToolingConfigDto? Tooling { get; init; }
|
||||||
@@ -308,6 +309,18 @@ public sealed class UserInputRequestedEventDto : SidecarEventDto
|
|||||||
public bool? AllowFreeform { get; init; }
|
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 sealed class CommandErrorEventDto : SidecarEventDto
|
||||||
{
|
{
|
||||||
public string Message { get; init; } = string.Empty;
|
public string Message { get; init; } = string.Empty;
|
||||||
|
|||||||
@@ -8,7 +8,8 @@ internal static class AgentInstructionComposer
|
|||||||
PatternDefinitionDto pattern,
|
PatternDefinitionDto pattern,
|
||||||
PatternAgentDefinitionDto agent,
|
PatternAgentDefinitionDto agent,
|
||||||
int agentIndex,
|
int agentIndex,
|
||||||
string workspaceKind = "project")
|
string workspaceKind = "project",
|
||||||
|
string interactionMode = "interactive")
|
||||||
{
|
{
|
||||||
string baseInstructions = agent.Instructions.Trim();
|
string baseInstructions = agent.Instructions.Trim();
|
||||||
string workspaceGuidance = string.Equals(workspaceKind, "scratchpad", StringComparison.OrdinalIgnoreCase)
|
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.
|
Answer conversationally and focus on the user's question directly.
|
||||||
"""
|
"""
|
||||||
: string.Empty;
|
: 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))
|
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.
|
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))
|
if (!string.Equals(pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return JoinInstructionBlocks(baseInstructions, workspaceGuidance);
|
return JoinInstructionBlocks(baseInstructions, workspaceGuidance, planModeGuidance);
|
||||||
}
|
}
|
||||||
|
|
||||||
string runtimeGuidance = agentIndex == 0
|
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.
|
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)
|
private static string JoinInstructionBlocks(params string[] blocks)
|
||||||
|
|||||||
@@ -50,7 +50,12 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
|||||||
ReasoningEffort = definition.ReasoningEffort,
|
ReasoningEffort = definition.ReasoningEffort,
|
||||||
SystemMessage = new SystemMessageConfig
|
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,
|
WorkingDirectory = command.ProjectPath,
|
||||||
OnPermissionRequest = (request, invocation) => onPermissionRequest(definition, request, invocation),
|
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 List<ChatMessageDto> CompletedMessages { get; private set; } = [];
|
||||||
|
|
||||||
|
public bool HasPendingExitPlanModeRequest { get; private set; }
|
||||||
|
|
||||||
public async Task EmitThinkingIfNeeded(
|
public async Task EmitThinkingIfNeeded(
|
||||||
AgentIdentity agent,
|
AgentIdentity agent,
|
||||||
Func<AgentActivityEventDto, Task> onActivity)
|
Func<AgentActivityEventDto, Task> onActivity)
|
||||||
@@ -74,6 +76,10 @@ internal sealed class CopilotTurnExecutionState
|
|||||||
case AssistantReasoningDeltaEvent:
|
case AssistantReasoningDeltaEvent:
|
||||||
ActiveAgent = agent;
|
ActiveAgent = agent;
|
||||||
break;
|
break;
|
||||||
|
case ExitPlanModeRequestedEvent:
|
||||||
|
HasPendingExitPlanModeRequest = true;
|
||||||
|
ActiveAgent = agent;
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Aryx.AgentHost.Contracts;
|
using Aryx.AgentHost.Contracts;
|
||||||
|
using GitHub.Copilot.SDK;
|
||||||
using Microsoft.Agents.AI.Workflows;
|
using Microsoft.Agents.AI.Workflows;
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
|
|
||||||
@@ -9,6 +10,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
private readonly PatternValidator _patternValidator;
|
private readonly PatternValidator _patternValidator;
|
||||||
private readonly CopilotApprovalCoordinator _approvalCoordinator = new();
|
private readonly CopilotApprovalCoordinator _approvalCoordinator = new();
|
||||||
private readonly CopilotUserInputCoordinator _userInputCoordinator = new();
|
private readonly CopilotUserInputCoordinator _userInputCoordinator = new();
|
||||||
|
private readonly CopilotExitPlanModeCoordinator _exitPlanModeCoordinator = new();
|
||||||
|
|
||||||
public CopilotWorkflowRunner(PatternValidator patternValidator)
|
public CopilotWorkflowRunner(PatternValidator patternValidator)
|
||||||
{
|
{
|
||||||
@@ -21,6 +23,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
Func<AgentActivityEventDto, Task> onActivity,
|
Func<AgentActivityEventDto, Task> onActivity,
|
||||||
Func<ApprovalRequestedEventDto, Task> onApproval,
|
Func<ApprovalRequestedEventDto, Task> onApproval,
|
||||||
Func<UserInputRequestedEventDto, Task> onUserInput,
|
Func<UserInputRequestedEventDto, Task> onUserInput,
|
||||||
|
Func<ExitPlanModeRequestedEventDto, Task> onExitPlanMode,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
PatternValidationIssueDto? validationError = _patternValidator.Validate(command.Pattern).FirstOrDefault();
|
PatternValidationIssueDto? validationError = _patternValidator.Validate(command.Pattern).FirstOrDefault();
|
||||||
@@ -30,42 +33,68 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
}
|
}
|
||||||
|
|
||||||
CopilotTurnExecutionState state = new(command);
|
CopilotTurnExecutionState state = new(command);
|
||||||
await using CopilotAgentBundle bundle = await CopilotAgentBundle.CreateAsync(
|
using CancellationTokenSource runCancellation =
|
||||||
command,
|
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||||
(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();
|
|
||||||
|
|
||||||
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, inputMessages).ConfigureAwait(false);
|
try
|
||||||
await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false);
|
|
||||||
|
|
||||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync(cancellationToken).ConfigureAwait(false))
|
|
||||||
{
|
{
|
||||||
bool shouldEndTurn = await HandleWorkflowEventAsync(command, evt, inputMessages, state, onDelta, onActivity)
|
await using CopilotAgentBundle bundle = await CopilotAgentBundle.CreateAsync(
|
||||||
.ConfigureAwait(false);
|
command,
|
||||||
if (shouldEndTurn)
|
(agent, request, invocation) => _approvalCoordinator.RequestApprovalAsync(
|
||||||
{
|
command,
|
||||||
break;
|
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(
|
public Task ResolveApprovalAsync(
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ public interface ITurnWorkflowRunner
|
|||||||
Func<AgentActivityEventDto, Task> onActivity,
|
Func<AgentActivityEventDto, Task> onActivity,
|
||||||
Func<ApprovalRequestedEventDto, Task> onApproval,
|
Func<ApprovalRequestedEventDto, Task> onApproval,
|
||||||
Func<UserInputRequestedEventDto, Task> onUserInput,
|
Func<UserInputRequestedEventDto, Task> onUserInput,
|
||||||
|
Func<ExitPlanModeRequestedEventDto, Task> onExitPlanMode,
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task ResolveApprovalAsync(
|
Task ResolveApprovalAsync(
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ public sealed class SidecarProtocolHost
|
|||||||
AskUserToolName,
|
AskUserToolName,
|
||||||
"report_intent",
|
"report_intent",
|
||||||
"task_complete",
|
"task_complete",
|
||||||
"exit_plan_mode",
|
|
||||||
};
|
};
|
||||||
|
|
||||||
private static readonly string[] AuthenticationErrorIndicators =
|
private static readonly string[] AuthenticationErrorIndicators =
|
||||||
@@ -189,6 +188,7 @@ public sealed class SidecarProtocolHost
|
|||||||
activity => WriteAsync(context.Output, activity, turnCancellation.Token),
|
activity => WriteAsync(context.Output, activity, turnCancellation.Token),
|
||||||
approval => WriteAsync(context.Output, approval, turnCancellation.Token),
|
approval => WriteAsync(context.Output, approval, turnCancellation.Token),
|
||||||
userInput => WriteAsync(context.Output, userInput, turnCancellation.Token),
|
userInput => WriteAsync(context.Output, userInput, turnCancellation.Token),
|
||||||
|
exitPlanMode => WriteAsync(context.Output, exitPlanMode, turnCancellation.Token),
|
||||||
turnCancellation.Token)
|
turnCancellation.Token)
|
||||||
.ConfigureAwait(false);
|
.ConfigureAwait(false);
|
||||||
|
|
||||||
|
|||||||
@@ -124,6 +124,33 @@ public sealed class AgentInstructionComposerTests
|
|||||||
Assert.DoesNotContain("Do not inspect, modify, create, or delete files", instructions, StringComparison.OrdinalIgnoreCase);
|
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)
|
private static PatternAgentDefinitionDto CreateAgent(string id, string name, string instructions)
|
||||||
{
|
{
|
||||||
return new PatternAgentDefinitionDto
|
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(
|
SidecarProtocolHost host = new(
|
||||||
new PatternValidator(),
|
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
|
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]
|
[Fact]
|
||||||
public async Task RunTurnCommand_ReturnsApprovalEvents()
|
public async Task RunTurnCommand_ReturnsApprovalEvents()
|
||||||
{
|
{
|
||||||
SidecarProtocolHost host = new(
|
SidecarProtocolHost host = new(
|
||||||
new PatternValidator(),
|
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
|
await onApproval(new ApprovalRequestedEventDto
|
||||||
{
|
{
|
||||||
@@ -315,7 +352,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
{
|
{
|
||||||
SidecarProtocolHost host = new(
|
SidecarProtocolHost host = new(
|
||||||
new PatternValidator(),
|
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
|
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]
|
[Fact]
|
||||||
public async Task CancelTurnCommand_CancelsInProgressTurnAndCompletesBothCommands()
|
public async Task CancelTurnCommand_CancelsInProgressTurnAndCompletesBothCommands()
|
||||||
{
|
{
|
||||||
SidecarProtocolHost host = new(
|
SidecarProtocolHost host = new(
|
||||||
new PatternValidator(),
|
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);
|
await Task.Delay(Timeout.Infinite, cancellationToken);
|
||||||
return [];
|
return [];
|
||||||
@@ -436,7 +548,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
{
|
{
|
||||||
SidecarProtocolHost host = new(
|
SidecarProtocolHost host = new(
|
||||||
new PatternValidator(),
|
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);
|
await RunHostAsync(CreateRunTurnCommand(requestId: "turn-completed"), host);
|
||||||
|
|
||||||
@@ -459,7 +571,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
SidecarProtocolHost host = new(
|
SidecarProtocolHost host = new(
|
||||||
new PatternValidator(),
|
new PatternValidator(),
|
||||||
new FakeWorkflowRunner(
|
new FakeWorkflowRunner(
|
||||||
handler: async (command, onDelta, onActivity, onApproval, onUserInput, cancellationToken) => [],
|
handler: async (command, onDelta, onActivity, onApproval, onUserInput, onExitPlanMode, cancellationToken) => [],
|
||||||
resolveApprovalHandler: (command, cancellationToken) =>
|
resolveApprovalHandler: (command, cancellationToken) =>
|
||||||
{
|
{
|
||||||
captured = command;
|
captured = command;
|
||||||
@@ -490,7 +602,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
SidecarProtocolHost host = new(
|
SidecarProtocolHost host = new(
|
||||||
new PatternValidator(),
|
new PatternValidator(),
|
||||||
new FakeWorkflowRunner(
|
new FakeWorkflowRunner(
|
||||||
handler: async (command, onDelta, onActivity, onApproval, onUserInput, cancellationToken) => [],
|
handler: async (command, onDelta, onActivity, onApproval, onUserInput, onExitPlanMode, cancellationToken) => [],
|
||||||
resolveUserInputHandler: (command, cancellationToken) =>
|
resolveUserInputHandler: (command, cancellationToken) =>
|
||||||
{
|
{
|
||||||
captured = command;
|
captured = command;
|
||||||
@@ -517,7 +629,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void MapRuntimeTools_ExcludesInternalToolsAndDeduplicatesByName()
|
public void MapRuntimeTools_ExcludesOnlyInternalMetaToolsAndDeduplicatesByName()
|
||||||
{
|
{
|
||||||
IReadOnlyList<SidecarRuntimeToolDto> runtimeTools = SidecarProtocolHost.MapRuntimeTools(
|
IReadOnlyList<SidecarRuntimeToolDto> runtimeTools = SidecarProtocolHost.MapRuntimeTools(
|
||||||
[
|
[
|
||||||
@@ -553,10 +665,20 @@ public sealed class SidecarProtocolHostTests
|
|||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
SidecarRuntimeToolDto runtimeTool = Assert.Single(runtimeTools);
|
Assert.Collection(
|
||||||
Assert.Equal("web_fetch", runtimeTool.Id);
|
runtimeTools,
|
||||||
Assert.Equal("web_fetch", runtimeTool.Label);
|
exitPlanTool =>
|
||||||
Assert.Equal("Fetch content from the web.", runtimeTool.Description);
|
{
|
||||||
|
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]
|
[Fact]
|
||||||
@@ -626,9 +748,9 @@ public sealed class SidecarProtocolHostTests
|
|||||||
string eventType,
|
string eventType,
|
||||||
string requestId)
|
string requestId)
|
||||||
{
|
{
|
||||||
return Assert.Single(events.Where(evt =>
|
return Assert.Single(events, evt =>
|
||||||
evt.GetProperty("type").GetString() == eventType
|
evt.GetProperty("type").GetString() == eventType
|
||||||
&& evt.GetProperty("requestId").GetString() == requestId));
|
&& evt.GetProperty("requestId").GetString() == requestId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static SidecarProtocolHost CreateHostForTests()
|
private static SidecarProtocolHost CreateHostForTests()
|
||||||
@@ -770,6 +892,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
Func<AgentActivityEventDto, Task>,
|
Func<AgentActivityEventDto, Task>,
|
||||||
Func<ApprovalRequestedEventDto, Task>,
|
Func<ApprovalRequestedEventDto, Task>,
|
||||||
Func<UserInputRequestedEventDto, Task>,
|
Func<UserInputRequestedEventDto, Task>,
|
||||||
|
Func<ExitPlanModeRequestedEventDto, Task>,
|
||||||
CancellationToken,
|
CancellationToken,
|
||||||
Task<IReadOnlyList<ChatMessageDto>>> _handler;
|
Task<IReadOnlyList<ChatMessageDto>>> _handler;
|
||||||
private readonly Func<ResolveApprovalCommandDto, CancellationToken, Task> _resolveApprovalHandler;
|
private readonly Func<ResolveApprovalCommandDto, CancellationToken, Task> _resolveApprovalHandler;
|
||||||
@@ -782,6 +905,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
Func<AgentActivityEventDto, Task>,
|
Func<AgentActivityEventDto, Task>,
|
||||||
Func<ApprovalRequestedEventDto, Task>,
|
Func<ApprovalRequestedEventDto, Task>,
|
||||||
Func<UserInputRequestedEventDto, Task>,
|
Func<UserInputRequestedEventDto, Task>,
|
||||||
|
Func<ExitPlanModeRequestedEventDto, Task>,
|
||||||
CancellationToken,
|
CancellationToken,
|
||||||
Task<IReadOnlyList<ChatMessageDto>>> handler,
|
Task<IReadOnlyList<ChatMessageDto>>> handler,
|
||||||
Func<ResolveApprovalCommandDto, CancellationToken, Task>? resolveApprovalHandler = null,
|
Func<ResolveApprovalCommandDto, CancellationToken, Task>? resolveApprovalHandler = null,
|
||||||
@@ -798,9 +922,10 @@ public sealed class SidecarProtocolHostTests
|
|||||||
Func<AgentActivityEventDto, Task> onActivity,
|
Func<AgentActivityEventDto, Task> onActivity,
|
||||||
Func<ApprovalRequestedEventDto, Task> onApproval,
|
Func<ApprovalRequestedEventDto, Task> onApproval,
|
||||||
Func<UserInputRequestedEventDto, Task> onUserInput,
|
Func<UserInputRequestedEventDto, Task> onUserInput,
|
||||||
|
Func<ExitPlanModeRequestedEventDto, Task> onExitPlanMode,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
return _handler(command, onDelta, onActivity, onApproval, onUserInput, cancellationToken);
|
return _handler(command, onDelta, onActivity, onApproval, onUserInput, onExitPlanMode, cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
public Task ResolveApprovalAsync(
|
public Task ResolveApprovalAsync(
|
||||||
|
|||||||
Reference in New Issue
Block a user