mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-06 19:58:43 +02:00
feat: full Copilot SDK feature parity — custom agents, hooks, image input, skills, steering, session persistence
Backend (sidecar): - Extended ProtocolModels with DTOs for custom agents, hooks, skills, infinite sessions, session lifecycle, and 9 new event types - Added CopilotManagedSessionIds for stable SDK session ID mapping - Added CopilotSessionManager/ICopilotSessionManager for session lifecycle - Added CopilotSessionHooks for hook registration - Added CopilotMessageOptionsMetadata for mid-turn steering - Extended CopilotAgentBundle to wire custom agents, hooks, skills, infinite sessions, and stable session IDs - Extended CopilotTurnExecutionState to project 13 new SDK event types - Widened ITurnWorkflowRunner callback to accept SidecarEventDto - Added list/delete/disconnect session commands to SidecarProtocolHost - Added AryxCopilotAgentMessageOptionsTests (14 new tests, 142 total) Frontend (renderer + main + shared): - Added ChatMessageAttachment type and helpers (attachment.ts) - Extended sidecar contracts with MessageMode, 3 new command types, 9 new event types, and agent/session config DTOs - Extended SessionEventRecord with 6 new event kinds and ~20 fields - Added PatternAgentCopilotConfig to pattern domain - Added attachments support to ChatMessageRecord - Updated sidecar client with session lifecycle methods and turn-scoped event routing via onTurnScopedEvent callback - Updated main process: handleTurnScopedEvent(), deleteSession(), steering bypass for mid-turn messages, attachment passthrough - Added deleteSession IPC handler and preload binding - Added TurnEventLog state tracker with format/apply/prune helpers - ChatPane: always-enabled composer, steering indicator, attachment picker with preview, image thumbnails in message history, context-usage bar, amber steer mode for send button - ActivityPanel: turn events section with sub-agent, hook, skill, and compaction event rendering - Sidebar: delete session action in context menu - App.tsx: wired sessionUsage, turnEventLogs, and deleteSession Documentation: - AGENTS.md: added glob safety rule for node_modules - README.md: added steering, image input, and richer observability - ARCHITECTURE.md: added turn-scoped events, steering, and attachments - Website: added steering and image input feature cards, updated live visibility and session cards Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -10,6 +10,16 @@ public sealed class PatternAgentDefinitionDto
|
||||
public string Instructions { get; init; } = string.Empty;
|
||||
public string Model { get; init; } = string.Empty;
|
||||
public string? ReasoningEffort { get; init; }
|
||||
public PatternAgentCopilotConfigDto? Copilot { get; init; }
|
||||
}
|
||||
|
||||
public sealed class PatternAgentCopilotConfigDto
|
||||
{
|
||||
public IReadOnlyList<RunTurnCustomAgentConfigDto> CustomAgents { get; init; } = [];
|
||||
public string? Agent { get; init; }
|
||||
public IReadOnlyList<string> SkillDirectories { get; init; } = [];
|
||||
public IReadOnlyList<string> DisabledSkills { get; init; } = [];
|
||||
public RunTurnInfiniteSessionsConfigDto? InfiniteSessions { get; init; }
|
||||
}
|
||||
|
||||
public sealed class PatternGraphPositionDto
|
||||
@@ -75,6 +85,16 @@ public sealed class ChatMessageDto
|
||||
public string AuthorName { get; init; } = string.Empty;
|
||||
public string Content { get; init; } = string.Empty;
|
||||
public string CreatedAt { get; init; } = string.Empty;
|
||||
public IReadOnlyList<ChatMessageAttachmentDto> Attachments { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class ChatMessageAttachmentDto
|
||||
{
|
||||
public string Type { get; init; } = string.Empty;
|
||||
public string? Path { get; init; }
|
||||
public string? Data { get; init; }
|
||||
public string? MimeType { get; init; }
|
||||
public string? DisplayName { get; init; }
|
||||
}
|
||||
|
||||
public sealed class PatternValidationIssueDto
|
||||
@@ -162,6 +182,7 @@ public sealed class RunTurnCommandDto : SidecarCommandEnvelope
|
||||
public string ProjectPath { get; init; } = string.Empty;
|
||||
public string WorkspaceKind { get; init; } = "project";
|
||||
public string Mode { get; init; } = "interactive";
|
||||
public string MessageMode { get; init; } = "enqueue";
|
||||
public PatternDefinitionDto Pattern { get; init; } = new();
|
||||
public IReadOnlyList<ChatMessageDto> Messages { get; init; } = [];
|
||||
public RunTurnToolingConfigDto? Tooling { get; init; }
|
||||
@@ -186,6 +207,22 @@ public sealed class ResolveUserInputCommandDto : SidecarCommandEnvelope
|
||||
public bool WasFreeform { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ListSessionsCommandDto : SidecarCommandEnvelope
|
||||
{
|
||||
public CopilotSessionListFilterDto? Filter { get; init; }
|
||||
}
|
||||
|
||||
public sealed class DeleteSessionCommandDto : SidecarCommandEnvelope
|
||||
{
|
||||
public string? SessionId { get; init; }
|
||||
public string? CopilotSessionId { get; init; }
|
||||
}
|
||||
|
||||
public sealed class DisconnectSessionCommandDto : SidecarCommandEnvelope
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class RunTurnToolingConfigDto
|
||||
{
|
||||
public IReadOnlyList<RunTurnMcpServerConfigDto> McpServers { get; init; } = [];
|
||||
@@ -217,6 +254,48 @@ public sealed class RunTurnLspProfileConfigDto
|
||||
public IReadOnlyList<string> FileExtensions { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class RunTurnCustomAgentConfigDto
|
||||
{
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string? DisplayName { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public IReadOnlyList<string>? Tools { get; init; }
|
||||
public string Prompt { get; init; } = string.Empty;
|
||||
public IReadOnlyList<RunTurnMcpServerConfigDto> McpServers { get; init; } = [];
|
||||
public bool? Infer { get; init; }
|
||||
}
|
||||
|
||||
public sealed class RunTurnInfiniteSessionsConfigDto
|
||||
{
|
||||
public bool? Enabled { get; init; }
|
||||
public double? BackgroundCompactionThreshold { get; init; }
|
||||
public double? BufferExhaustionThreshold { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CopilotSessionListFilterDto
|
||||
{
|
||||
public string? Cwd { get; init; }
|
||||
public string? GitRoot { get; init; }
|
||||
public string? Repository { get; init; }
|
||||
public string? Branch { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CopilotSessionInfoDto
|
||||
{
|
||||
public string CopilotSessionId { get; init; } = string.Empty;
|
||||
public bool ManagedByAryx { get; init; }
|
||||
public string? SessionId { get; init; }
|
||||
public string? AgentId { get; init; }
|
||||
public string StartTime { get; init; } = string.Empty;
|
||||
public string ModifiedTime { get; init; } = string.Empty;
|
||||
public string? Summary { get; init; }
|
||||
public bool IsRemote { get; init; }
|
||||
public string? Cwd { get; init; }
|
||||
public string? GitRoot { get; init; }
|
||||
public string? Repository { get; init; }
|
||||
public string? Branch { get; init; }
|
||||
}
|
||||
|
||||
public abstract class SidecarEventDto
|
||||
{
|
||||
public string Type { get; init; } = string.Empty;
|
||||
@@ -260,6 +339,111 @@ public sealed class AgentActivityEventDto : SidecarEventDto
|
||||
public string? ToolName { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SubagentEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string EventKind { get; init; } = string.Empty;
|
||||
public string? AgentId { get; init; }
|
||||
public string? AgentName { get; init; }
|
||||
public string? ToolCallId { get; init; }
|
||||
public string? CustomAgentName { get; init; }
|
||||
public string? CustomAgentDisplayName { get; init; }
|
||||
public string? CustomAgentDescription { get; init; }
|
||||
public string? Error { get; init; }
|
||||
public string? Model { get; init; }
|
||||
public double? TotalToolCalls { get; init; }
|
||||
public double? TotalTokens { get; init; }
|
||||
public double? DurationMs { get; init; }
|
||||
public IReadOnlyList<string>? Tools { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SkillInvokedEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string? AgentId { get; init; }
|
||||
public string? AgentName { get; init; }
|
||||
public string SkillName { get; init; } = string.Empty;
|
||||
public string Path { get; init; } = string.Empty;
|
||||
public string Content { get; init; } = string.Empty;
|
||||
public IReadOnlyList<string>? AllowedTools { get; init; }
|
||||
public string? PluginName { get; init; }
|
||||
public string? PluginVersion { get; init; }
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
public sealed class HookLifecycleEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string? AgentId { get; init; }
|
||||
public string? AgentName { get; init; }
|
||||
public string HookInvocationId { get; init; } = string.Empty;
|
||||
public string HookType { get; init; } = string.Empty;
|
||||
public string Phase { get; init; } = string.Empty;
|
||||
public bool? Success { get; init; }
|
||||
public object? Input { get; init; }
|
||||
public object? Output { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SessionUsageEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string? AgentId { get; init; }
|
||||
public string? AgentName { get; init; }
|
||||
public double TokenLimit { get; init; }
|
||||
public double CurrentTokens { get; init; }
|
||||
public double MessagesLength { get; init; }
|
||||
public double? SystemTokens { get; init; }
|
||||
public double? ConversationTokens { get; init; }
|
||||
public double? ToolDefinitionsTokens { get; init; }
|
||||
public bool? IsInitial { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SessionCompactionEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string? AgentId { get; init; }
|
||||
public string? AgentName { get; init; }
|
||||
public string Phase { get; init; } = string.Empty;
|
||||
public bool? Success { get; init; }
|
||||
public string? Error { get; init; }
|
||||
public double? SystemTokens { get; init; }
|
||||
public double? ConversationTokens { get; init; }
|
||||
public double? ToolDefinitionsTokens { get; init; }
|
||||
public double? PreCompactionTokens { get; init; }
|
||||
public double? PostCompactionTokens { get; init; }
|
||||
public double? PreCompactionMessagesLength { get; init; }
|
||||
public double? MessagesRemoved { get; init; }
|
||||
public double? TokensRemoved { get; init; }
|
||||
public string? SummaryContent { get; init; }
|
||||
public double? CheckpointNumber { get; init; }
|
||||
public string? CheckpointPath { get; init; }
|
||||
}
|
||||
|
||||
public sealed class PendingMessagesModifiedEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string? AgentId { get; init; }
|
||||
public string? AgentName { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SessionsListedEventDto : SidecarEventDto
|
||||
{
|
||||
public IReadOnlyList<CopilotSessionInfoDto> Sessions { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class SessionsDeletedEventDto : SidecarEventDto
|
||||
{
|
||||
public string? SessionId { get; init; }
|
||||
public IReadOnlyList<CopilotSessionInfoDto> Sessions { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class SessionDisconnectedEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public IReadOnlyList<string> CancelledRequestIds { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class PermissionDetailDto
|
||||
{
|
||||
public string Kind { get; init; } = string.Empty;
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.Channels;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -140,11 +141,16 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
|
||||
try
|
||||
{
|
||||
string prompt = string.Join("\n", messages.Select(message => message.Text));
|
||||
(List<UserMessageDataAttachmentsItem>? attachments, tempDir) = await ProcessDataContentAttachmentsAsync(
|
||||
(List<UserMessageDataAttachmentsItem>? attachments, string? messageMode, tempDir) = await ProcessMessageAttachmentsAsync(
|
||||
messages,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
MessageOptions messageOptions = new() { Prompt = prompt };
|
||||
MessageOptions messageOptions = new()
|
||||
{
|
||||
Prompt = prompt,
|
||||
Mode = string.IsNullOrWhiteSpace(messageMode) ? null : messageMode,
|
||||
};
|
||||
|
||||
if (attachments is not null)
|
||||
{
|
||||
messageOptions.Attachments = [.. attachments];
|
||||
@@ -474,36 +480,103 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
|
||||
return JsonSerializer.Deserialize<Dictionary<string, object?>>(json);
|
||||
}
|
||||
|
||||
private static async Task<(List<UserMessageDataAttachmentsItem>? Attachments, string? TempDir)> ProcessDataContentAttachmentsAsync(
|
||||
internal static async Task<(List<UserMessageDataAttachmentsItem>? Attachments, string? MessageMode, string? TempDir)> ProcessMessageAttachmentsAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<UserMessageDataAttachmentsItem>? attachments = null;
|
||||
string? messageMode = null;
|
||||
string? tempDir = null;
|
||||
foreach (ChatMessage message in messages)
|
||||
{
|
||||
foreach (AIContent content in message.Contents)
|
||||
{
|
||||
if (content is not DataContent dataContent)
|
||||
if (content is DataContent dataContent)
|
||||
{
|
||||
tempDir ??= Directory.CreateDirectory(
|
||||
Path.Combine(Path.GetTempPath(), $"af_copilot_{Guid.NewGuid():N}")).FullName;
|
||||
|
||||
string tempFilePath = await dataContent.SaveToAsync(tempDir, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
attachments ??= [];
|
||||
attachments.Add(new UserMessageDataAttachmentsItemFile
|
||||
{
|
||||
Path = tempFilePath,
|
||||
DisplayName = Path.GetFileName(tempFilePath),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
tempDir ??= Directory.CreateDirectory(
|
||||
Path.Combine(Path.GetTempPath(), $"af_copilot_{Guid.NewGuid():N}")).FullName;
|
||||
|
||||
string tempFilePath = await dataContent.SaveToAsync(tempDir, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
attachments ??= [];
|
||||
attachments.Add(new UserMessageDataAttachmentsItemFile
|
||||
if (content.RawRepresentation is ChatMessageAttachmentDto protocolAttachment)
|
||||
{
|
||||
Path = tempFilePath,
|
||||
DisplayName = Path.GetFileName(tempFilePath),
|
||||
});
|
||||
attachments ??= [];
|
||||
attachments.Add(CreateProtocolAttachment(protocolAttachment));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (content.RawRepresentation is CopilotMessageOptionsMetadata metadata
|
||||
&& !string.IsNullOrWhiteSpace(metadata.MessageMode))
|
||||
{
|
||||
messageMode = metadata.MessageMode.Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (attachments, tempDir);
|
||||
return (attachments, messageMode, tempDir);
|
||||
}
|
||||
|
||||
private static UserMessageDataAttachmentsItem CreateProtocolAttachment(ChatMessageAttachmentDto attachment)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(attachment);
|
||||
|
||||
return attachment.Type switch
|
||||
{
|
||||
"file" => CreateFileAttachment(attachment),
|
||||
"blob" => CreateBlobAttachment(attachment),
|
||||
_ => throw new NotSupportedException($"Unsupported attachment type '{attachment.Type}'."),
|
||||
};
|
||||
}
|
||||
|
||||
private static UserMessageDataAttachmentsItemFile CreateFileAttachment(ChatMessageAttachmentDto attachment)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attachment.Path))
|
||||
{
|
||||
throw new InvalidOperationException("File attachments require an absolute path.");
|
||||
}
|
||||
|
||||
string path = attachment.Path.Trim();
|
||||
if (!Path.IsPathRooted(path))
|
||||
{
|
||||
throw new InvalidOperationException($"File attachment path '{path}' must be absolute.");
|
||||
}
|
||||
|
||||
return new UserMessageDataAttachmentsItemFile
|
||||
{
|
||||
Path = path,
|
||||
DisplayName = string.IsNullOrWhiteSpace(attachment.DisplayName)
|
||||
? Path.GetFileName(path)
|
||||
: attachment.DisplayName.Trim(),
|
||||
};
|
||||
}
|
||||
|
||||
private static UserMessageDataAttachmentsItemBlob CreateBlobAttachment(ChatMessageAttachmentDto attachment)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attachment.Data))
|
||||
{
|
||||
throw new InvalidOperationException("Blob attachments require base64-encoded data.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(attachment.MimeType))
|
||||
{
|
||||
throw new InvalidOperationException("Blob attachments require a MIME type.");
|
||||
}
|
||||
|
||||
return new UserMessageDataAttachmentsItemBlob
|
||||
{
|
||||
Data = attachment.Data.Trim(),
|
||||
MimeType = attachment.MimeType.Trim(),
|
||||
DisplayName = string.IsNullOrWhiteSpace(attachment.DisplayName) ? null : attachment.DisplayName.Trim(),
|
||||
};
|
||||
}
|
||||
|
||||
private static void CleanupTempDir(string? tempDir)
|
||||
|
||||
@@ -47,6 +47,7 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
|
||||
SessionConfig sessionConfig = new()
|
||||
{
|
||||
SessionId = CopilotManagedSessionIds.Build(command.SessionId, definition.Id),
|
||||
Model = definition.Model,
|
||||
ReasoningEffort = definition.ReasoningEffort,
|
||||
SystemMessage = new SystemMessageConfig
|
||||
@@ -61,8 +62,14 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
WorkingDirectory = command.ProjectPath,
|
||||
OnPermissionRequest = (request, invocation) => onPermissionRequest(definition, request, invocation),
|
||||
OnUserInputRequest = (request, invocation) => onUserInputRequest(definition, request, invocation),
|
||||
Hooks = CopilotSessionHooks.Create(command, definition),
|
||||
OnEvent = evt => onSessionEvent?.Invoke(definition, evt),
|
||||
Streaming = true,
|
||||
CustomAgents = CreateCustomAgents(definition.Copilot?.CustomAgents),
|
||||
Agent = NormalizeOptionalString(definition.Copilot?.Agent),
|
||||
SkillDirectories = CreateStringList(definition.Copilot?.SkillDirectories),
|
||||
DisabledSkills = CreateStringList(definition.Copilot?.DisabledSkills),
|
||||
InfiniteSessions = CreateInfiniteSessions(definition.Copilot?.InfiniteSessions),
|
||||
};
|
||||
|
||||
ApplySessionTooling(sessionConfig, toolingBundle?.McpServers, toolingBundle?.Tools);
|
||||
@@ -100,6 +107,55 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
internal static List<CustomAgentConfig>? CreateCustomAgents(
|
||||
IReadOnlyList<RunTurnCustomAgentConfigDto>? customAgents)
|
||||
{
|
||||
if (customAgents is not { Count: > 0 })
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return customAgents.Select(customAgent => new CustomAgentConfig
|
||||
{
|
||||
Name = customAgent.Name,
|
||||
DisplayName = NormalizeOptionalString(customAgent.DisplayName),
|
||||
Description = NormalizeOptionalString(customAgent.Description),
|
||||
Tools = customAgent.Tools is null ? null : [.. customAgent.Tools],
|
||||
Prompt = customAgent.Prompt,
|
||||
McpServers = customAgent.McpServers.Count == 0
|
||||
? null
|
||||
: SessionToolingBundle.BuildMcpServerConfigurations(customAgent.McpServers),
|
||||
Infer = customAgent.Infer,
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
internal static InfiniteSessionConfig? CreateInfiniteSessions(RunTurnInfiniteSessionsConfigDto? config)
|
||||
{
|
||||
if (config is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new InfiniteSessionConfig
|
||||
{
|
||||
Enabled = config.Enabled,
|
||||
BackgroundCompactionThreshold = config.BackgroundCompactionThreshold,
|
||||
BufferExhaustionThreshold = config.BufferExhaustionThreshold,
|
||||
};
|
||||
}
|
||||
|
||||
private static List<string>? CreateStringList(IReadOnlyList<string>? values)
|
||||
{
|
||||
return values is { Count: > 0 }
|
||||
? [.. values]
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
public Workflow BuildWorkflow(PatternDefinitionDto pattern)
|
||||
{
|
||||
return pattern.Mode switch
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal static class CopilotManagedSessionIds
|
||||
{
|
||||
private const string Prefix = "aryx::";
|
||||
private const string Separator = "::";
|
||||
|
||||
public static string Build(string aryxSessionId, string agentId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(aryxSessionId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(agentId);
|
||||
|
||||
return $"{Prefix}{Uri.EscapeDataString(aryxSessionId)}{Separator}{Uri.EscapeDataString(agentId)}";
|
||||
}
|
||||
|
||||
public static bool IsManagedByAryx(string copilotSessionId)
|
||||
=> TryParse(copilotSessionId, out _, out _);
|
||||
|
||||
public static bool IsManagedByAryx(string copilotSessionId, string aryxSessionId)
|
||||
{
|
||||
return TryParse(copilotSessionId, out string? parsedSessionId, out _)
|
||||
&& string.Equals(parsedSessionId, aryxSessionId, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public static bool TryParse(string? copilotSessionId, out string aryxSessionId, out string agentId)
|
||||
{
|
||||
aryxSessionId = string.Empty;
|
||||
agentId = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(copilotSessionId)
|
||||
|| !copilotSessionId.StartsWith(Prefix, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string payload = copilotSessionId[Prefix.Length..];
|
||||
string[] parts = payload.Split(Separator, StringSplitOptions.None);
|
||||
if (parts.Length != 2
|
||||
|| string.IsNullOrWhiteSpace(parts[0])
|
||||
|| string.IsNullOrWhiteSpace(parts[1]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
aryxSessionId = Uri.UnescapeDataString(parts[0]);
|
||||
agentId = Uri.UnescapeDataString(parts[1]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed record CopilotMessageOptionsMetadata(string MessageMode);
|
||||
@@ -0,0 +1,47 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal static class CopilotSessionHooks
|
||||
{
|
||||
private const string AllowDecision = "allow";
|
||||
private const string AskDecision = "ask";
|
||||
|
||||
public static SessionHooks Create(RunTurnCommandDto command, PatternAgentDefinitionDto agentDefinition)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
ArgumentNullException.ThrowIfNull(agentDefinition);
|
||||
|
||||
return new SessionHooks
|
||||
{
|
||||
OnPreToolUse = (input, _) => Task.FromResult<PreToolUseHookOutput?>(
|
||||
CreatePreToolUseOutput(command, agentDefinition, input)),
|
||||
OnPostToolUse = static (_, _) => Task.FromResult<PostToolUseHookOutput?>(null),
|
||||
OnUserPromptSubmitted = static (_, _) => Task.FromResult<UserPromptSubmittedHookOutput?>(null),
|
||||
OnSessionStart = static (_, _) => Task.FromResult<SessionStartHookOutput?>(null),
|
||||
OnSessionEnd = static (_, _) => Task.FromResult<SessionEndHookOutput?>(null),
|
||||
OnErrorOccurred = static (_, _) => Task.FromResult<ErrorOccurredHookOutput?>(null),
|
||||
};
|
||||
}
|
||||
|
||||
private static PreToolUseHookOutput CreatePreToolUseOutput(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agentDefinition,
|
||||
PreToolUseHookInput input)
|
||||
{
|
||||
bool requiresApproval = CopilotApprovalCoordinator.RequiresToolCallApproval(
|
||||
command.Pattern.ApprovalPolicy,
|
||||
agentDefinition.Id,
|
||||
Normalize(input.ToolName),
|
||||
Normalize(input.ToolName));
|
||||
|
||||
return new PreToolUseHookOutput
|
||||
{
|
||||
PermissionDecision = requiresApproval ? AskDecision : AllowDecision,
|
||||
};
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class CopilotSessionManager : ICopilotSessionManager
|
||||
{
|
||||
public async Task<IReadOnlyList<CopilotSessionInfoDto>> ListSessionsAsync(
|
||||
CopilotSessionListFilterDto? filter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using CopilotClient client = await CreateStartedClientAsync(cancellationToken).ConfigureAwait(false);
|
||||
List<SessionMetadata> sessions = await client.ListSessionsAsync(CreateFilter(filter), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return sessions
|
||||
.Select(MapSession)
|
||||
.OrderByDescending(session => session.ModifiedTime, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CopilotSessionInfoDto>> DeleteSessionsAsync(
|
||||
string? aryxSessionId,
|
||||
string? copilotSessionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string? normalizedAryxSessionId = Normalize(aryxSessionId);
|
||||
string? normalizedCopilotSessionId = Normalize(copilotSessionId);
|
||||
if (normalizedAryxSessionId is null && normalizedCopilotSessionId is null)
|
||||
{
|
||||
throw new InvalidOperationException("delete-session requires a sessionId or copilotSessionId.");
|
||||
}
|
||||
|
||||
await using CopilotClient client = await CreateStartedClientAsync(cancellationToken).ConfigureAwait(false);
|
||||
List<SessionMetadata> sessions = await client.ListSessionsAsync(null, cancellationToken).ConfigureAwait(false);
|
||||
List<CopilotSessionInfoDto> targets = sessions
|
||||
.Select(MapSession)
|
||||
.Where(session =>
|
||||
(normalizedCopilotSessionId is not null
|
||||
&& string.Equals(session.CopilotSessionId, normalizedCopilotSessionId, StringComparison.Ordinal))
|
||||
|| (normalizedAryxSessionId is not null
|
||||
&& string.Equals(session.SessionId, normalizedAryxSessionId, StringComparison.Ordinal)
|
||||
&& session.ManagedByAryx))
|
||||
.ToList();
|
||||
|
||||
if (targets.Count == 0 && normalizedCopilotSessionId is not null)
|
||||
{
|
||||
targets.Add(CreateUnknownSessionInfo(normalizedCopilotSessionId));
|
||||
}
|
||||
|
||||
foreach (CopilotSessionInfoDto target in targets)
|
||||
{
|
||||
await client.DeleteSessionAsync(target.CopilotSessionId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
private static async Task<CopilotClient> CreateStartedClientAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
CopilotClient client = new(CopilotCliPathResolver.CreateClientOptions());
|
||||
await client.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static SessionListFilter? CreateFilter(CopilotSessionListFilterDto? filter)
|
||||
{
|
||||
if (filter is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SessionListFilter
|
||||
{
|
||||
Cwd = Normalize(filter.Cwd),
|
||||
GitRoot = Normalize(filter.GitRoot),
|
||||
Repository = Normalize(filter.Repository),
|
||||
Branch = Normalize(filter.Branch),
|
||||
};
|
||||
}
|
||||
|
||||
private static CopilotSessionInfoDto MapSession(SessionMetadata session)
|
||||
{
|
||||
bool managedByAryx = CopilotManagedSessionIds.TryParse(
|
||||
session.SessionId,
|
||||
out string aryxSessionId,
|
||||
out string agentId);
|
||||
|
||||
return new CopilotSessionInfoDto
|
||||
{
|
||||
CopilotSessionId = session.SessionId,
|
||||
ManagedByAryx = managedByAryx,
|
||||
SessionId = managedByAryx ? aryxSessionId : null,
|
||||
AgentId = managedByAryx ? agentId : null,
|
||||
StartTime = session.StartTime.ToUniversalTime().ToString("O"),
|
||||
ModifiedTime = session.ModifiedTime.ToUniversalTime().ToString("O"),
|
||||
Summary = Normalize(session.Summary),
|
||||
IsRemote = session.IsRemote,
|
||||
Cwd = Normalize(session.Context?.Cwd),
|
||||
GitRoot = Normalize(session.Context?.GitRoot),
|
||||
Repository = Normalize(session.Context?.Repository),
|
||||
Branch = Normalize(session.Context?.Branch),
|
||||
};
|
||||
}
|
||||
|
||||
private static CopilotSessionInfoDto CreateUnknownSessionInfo(string copilotSessionId)
|
||||
{
|
||||
bool managedByAryx = CopilotManagedSessionIds.TryParse(
|
||||
copilotSessionId,
|
||||
out string aryxSessionId,
|
||||
out string agentId);
|
||||
|
||||
return new CopilotSessionInfoDto
|
||||
{
|
||||
CopilotSessionId = copilotSessionId,
|
||||
ManagedByAryx = managedByAryx,
|
||||
SessionId = managedByAryx ? aryxSessionId : null,
|
||||
AgentId = managedByAryx ? agentId : null,
|
||||
};
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ internal sealed class CopilotTurnExecutionState
|
||||
{
|
||||
private readonly RunTurnCommandDto _command;
|
||||
private readonly HashSet<string> _startedAgents = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentQueue<AgentActivityEventDto> _pendingActivityEvents = new();
|
||||
private readonly ConcurrentQueue<SidecarEventDto> _pendingEvents = new();
|
||||
private readonly ConcurrentQueue<McpOauthRequiredEventDto> _pendingMcpOauthRequests = new();
|
||||
private readonly ConcurrentDictionary<string, AgentIdentity> _observedAgentsByMessageId = new(StringComparer.Ordinal);
|
||||
private readonly StreamingTranscriptBuffer _transcriptBuffer = new();
|
||||
@@ -30,7 +30,7 @@ internal sealed class CopilotTurnExecutionState
|
||||
|
||||
public async Task EmitThinkingIfNeeded(
|
||||
AgentIdentity agent,
|
||||
Func<AgentActivityEventDto, Task> onActivity)
|
||||
Func<SidecarEventDto, Task> onEvent)
|
||||
{
|
||||
AgentActivityEventDto? thinkingActivity = CreateThinkingActivityIfNeeded(agent);
|
||||
if (thinkingActivity is null)
|
||||
@@ -38,7 +38,7 @@ internal sealed class CopilotTurnExecutionState
|
||||
return;
|
||||
}
|
||||
|
||||
await onActivity(thinkingActivity).ConfigureAwait(false);
|
||||
await onEvent(thinkingActivity).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public void QueueThinkingIfNeeded(AgentIdentity agent)
|
||||
@@ -46,13 +46,14 @@ internal sealed class CopilotTurnExecutionState
|
||||
AgentActivityEventDto? thinkingActivity = CreateThinkingActivityIfNeeded(agent);
|
||||
if (thinkingActivity is not null)
|
||||
{
|
||||
_pendingActivityEvents.Enqueue(thinkingActivity);
|
||||
_pendingEvents.Enqueue(thinkingActivity);
|
||||
}
|
||||
}
|
||||
|
||||
public void ApplyActivity(AgentActivityEventDto activity)
|
||||
public void ApplyEvent(SidecarEventDto evt)
|
||||
{
|
||||
if (string.Equals(activity.ActivityType, "handoff", StringComparison.Ordinal)
|
||||
if (evt is AgentActivityEventDto activity
|
||||
&& string.Equals(activity.ActivityType, "handoff", StringComparison.Ordinal)
|
||||
&& !string.IsNullOrWhiteSpace(activity.AgentId)
|
||||
&& !string.IsNullOrWhiteSpace(activity.AgentName))
|
||||
{
|
||||
@@ -86,6 +87,54 @@ internal sealed class CopilotTurnExecutionState
|
||||
ActiveAgent = agent;
|
||||
QueueThinkingIfNeeded(agent);
|
||||
break;
|
||||
case SubagentStartedEvent started:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateSubagentEvent(agent, "started", started.Data));
|
||||
break;
|
||||
case SubagentCompletedEvent completed:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateSubagentCompletedEvent(agent, completed.Data));
|
||||
break;
|
||||
case SubagentFailedEvent failed:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateSubagentFailedEvent(agent, failed.Data));
|
||||
break;
|
||||
case SubagentSelectedEvent selected:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateSubagentSelectedEvent(agent, selected.Data));
|
||||
break;
|
||||
case SubagentDeselectedEvent:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateSubagentDeselectedEvent(agent));
|
||||
break;
|
||||
case SkillInvokedEvent skillInvoked:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateSkillInvokedEvent(agent, skillInvoked.Data));
|
||||
break;
|
||||
case HookStartEvent hookStart:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateHookLifecycleEvent(agent, "start", hookStart.Data));
|
||||
break;
|
||||
case HookEndEvent hookEnd:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateHookLifecycleEvent(agent, "end", hookEnd.Data));
|
||||
break;
|
||||
case SessionUsageInfoEvent usageInfo:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateUsageEvent(agent, usageInfo.Data));
|
||||
break;
|
||||
case SessionCompactionStartEvent compactionStart:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateCompactionStartEvent(agent, compactionStart.Data));
|
||||
break;
|
||||
case SessionCompactionCompleteEvent compactionComplete:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateCompactionCompleteEvent(agent, compactionComplete.Data));
|
||||
break;
|
||||
case PendingMessagesModifiedEvent:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreatePendingMessagesModifiedEvent(agent));
|
||||
break;
|
||||
case McpOauthRequiredEvent:
|
||||
ActiveAgent = agent;
|
||||
break;
|
||||
@@ -96,12 +145,12 @@ internal sealed class CopilotTurnExecutionState
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<AgentActivityEventDto> DrainPendingActivityEvents()
|
||||
public IReadOnlyList<SidecarEventDto> DrainPendingEvents()
|
||||
{
|
||||
List<AgentActivityEventDto> pending = [];
|
||||
while (_pendingActivityEvents.TryDequeue(out AgentActivityEventDto? activity))
|
||||
List<SidecarEventDto> pending = [];
|
||||
while (_pendingEvents.TryDequeue(out SidecarEventDto? pendingEvent))
|
||||
{
|
||||
pending.Add(activity);
|
||||
pending.Add(pendingEvent);
|
||||
}
|
||||
|
||||
return pending;
|
||||
@@ -204,4 +253,229 @@ internal sealed class CopilotTurnExecutionState
|
||||
|
||||
return CompletedMessages;
|
||||
}
|
||||
|
||||
private SubagentEventDto CreateSubagentEvent(
|
||||
AgentIdentity agent,
|
||||
string eventKind,
|
||||
SubagentStartedData? data)
|
||||
{
|
||||
return new SubagentEventDto
|
||||
{
|
||||
Type = "subagent-event",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
EventKind = eventKind,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
ToolCallId = data?.ToolCallId,
|
||||
CustomAgentName = data?.AgentName,
|
||||
CustomAgentDisplayName = data?.AgentDisplayName,
|
||||
CustomAgentDescription = data?.AgentDescription,
|
||||
};
|
||||
}
|
||||
|
||||
private SubagentEventDto CreateSubagentCompletedEvent(
|
||||
AgentIdentity agent,
|
||||
SubagentCompletedData? data)
|
||||
{
|
||||
return new SubagentEventDto
|
||||
{
|
||||
Type = "subagent-event",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
EventKind = "completed",
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
ToolCallId = data?.ToolCallId,
|
||||
CustomAgentName = data?.AgentName,
|
||||
CustomAgentDisplayName = data?.AgentDisplayName,
|
||||
};
|
||||
}
|
||||
|
||||
private SubagentEventDto CreateSubagentFailedEvent(
|
||||
AgentIdentity agent,
|
||||
SubagentFailedData? data)
|
||||
{
|
||||
return new SubagentEventDto
|
||||
{
|
||||
Type = "subagent-event",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
EventKind = "failed",
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
ToolCallId = data?.ToolCallId,
|
||||
CustomAgentName = data?.AgentName,
|
||||
CustomAgentDisplayName = data?.AgentDisplayName,
|
||||
Error = data?.Error,
|
||||
};
|
||||
}
|
||||
|
||||
private SubagentEventDto CreateSubagentSelectedEvent(
|
||||
AgentIdentity agent,
|
||||
SubagentSelectedData? data)
|
||||
{
|
||||
return new SubagentEventDto
|
||||
{
|
||||
Type = "subagent-event",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
EventKind = "selected",
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
CustomAgentName = data?.AgentName,
|
||||
CustomAgentDisplayName = data?.AgentDisplayName,
|
||||
Tools = data?.Tools,
|
||||
};
|
||||
}
|
||||
|
||||
private SubagentEventDto CreateSubagentDeselectedEvent(AgentIdentity agent)
|
||||
{
|
||||
return new SubagentEventDto
|
||||
{
|
||||
Type = "subagent-event",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
EventKind = "deselected",
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
};
|
||||
}
|
||||
|
||||
private SkillInvokedEventDto CreateSkillInvokedEvent(
|
||||
AgentIdentity agent,
|
||||
SkillInvokedData? data)
|
||||
{
|
||||
return new SkillInvokedEventDto
|
||||
{
|
||||
Type = "skill-invoked",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
SkillName = data?.Name ?? string.Empty,
|
||||
Path = data?.Path ?? string.Empty,
|
||||
Content = data?.Content ?? string.Empty,
|
||||
AllowedTools = data?.AllowedTools,
|
||||
PluginName = data?.PluginName,
|
||||
PluginVersion = data?.PluginVersion,
|
||||
};
|
||||
}
|
||||
|
||||
private HookLifecycleEventDto CreateHookLifecycleEvent(
|
||||
AgentIdentity agent,
|
||||
string phase,
|
||||
HookStartData? data)
|
||||
{
|
||||
return new HookLifecycleEventDto
|
||||
{
|
||||
Type = "hook-lifecycle",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
HookInvocationId = data?.HookInvocationId ?? string.Empty,
|
||||
HookType = data?.HookType ?? string.Empty,
|
||||
Phase = phase,
|
||||
Input = data?.Input,
|
||||
};
|
||||
}
|
||||
|
||||
private HookLifecycleEventDto CreateHookLifecycleEvent(
|
||||
AgentIdentity agent,
|
||||
string phase,
|
||||
HookEndData? data)
|
||||
{
|
||||
return new HookLifecycleEventDto
|
||||
{
|
||||
Type = "hook-lifecycle",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
HookInvocationId = data?.HookInvocationId ?? string.Empty,
|
||||
HookType = data?.HookType ?? string.Empty,
|
||||
Phase = phase,
|
||||
Success = data?.Success,
|
||||
Output = data?.Output,
|
||||
Error = data?.Error?.Message,
|
||||
};
|
||||
}
|
||||
|
||||
private SessionUsageEventDto CreateUsageEvent(AgentIdentity agent, SessionUsageInfoData? data)
|
||||
{
|
||||
return new SessionUsageEventDto
|
||||
{
|
||||
Type = "session-usage",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
TokenLimit = data?.TokenLimit ?? 0,
|
||||
CurrentTokens = data?.CurrentTokens ?? 0,
|
||||
MessagesLength = data?.MessagesLength ?? 0,
|
||||
SystemTokens = data?.SystemTokens,
|
||||
ConversationTokens = data?.ConversationTokens,
|
||||
ToolDefinitionsTokens = data?.ToolDefinitionsTokens,
|
||||
IsInitial = data?.IsInitial,
|
||||
};
|
||||
}
|
||||
|
||||
private SessionCompactionEventDto CreateCompactionStartEvent(
|
||||
AgentIdentity agent,
|
||||
SessionCompactionStartData? data)
|
||||
{
|
||||
return new SessionCompactionEventDto
|
||||
{
|
||||
Type = "session-compaction",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
Phase = "start",
|
||||
SystemTokens = data?.SystemTokens,
|
||||
ConversationTokens = data?.ConversationTokens,
|
||||
ToolDefinitionsTokens = data?.ToolDefinitionsTokens,
|
||||
};
|
||||
}
|
||||
|
||||
private SessionCompactionEventDto CreateCompactionCompleteEvent(
|
||||
AgentIdentity agent,
|
||||
SessionCompactionCompleteData? data)
|
||||
{
|
||||
return new SessionCompactionEventDto
|
||||
{
|
||||
Type = "session-compaction",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
Phase = "complete",
|
||||
Success = data?.Success,
|
||||
Error = data?.Error,
|
||||
SystemTokens = data?.SystemTokens,
|
||||
ConversationTokens = data?.ConversationTokens,
|
||||
ToolDefinitionsTokens = data?.ToolDefinitionsTokens,
|
||||
PreCompactionTokens = data?.PreCompactionTokens,
|
||||
PostCompactionTokens = data?.PostCompactionTokens,
|
||||
PreCompactionMessagesLength = data?.PreCompactionMessagesLength,
|
||||
MessagesRemoved = data?.MessagesRemoved,
|
||||
TokensRemoved = data?.TokensRemoved,
|
||||
SummaryContent = data?.SummaryContent,
|
||||
CheckpointNumber = data?.CheckpointNumber,
|
||||
CheckpointPath = data?.CheckpointPath,
|
||||
};
|
||||
}
|
||||
|
||||
private PendingMessagesModifiedEventDto CreatePendingMessagesModifiedEvent(AgentIdentity agent)
|
||||
{
|
||||
return new PendingMessagesModifiedEventDto
|
||||
{
|
||||
Type = "pending-messages-modified",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
public async Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
|
||||
RunTurnCommandDto command,
|
||||
Func<TurnDeltaEventDto, Task> onDelta,
|
||||
Func<AgentActivityEventDto, Task> onActivity,
|
||||
Func<SidecarEventDto, Task> onEvent,
|
||||
Func<ApprovalRequestedEventDto, Task> onApproval,
|
||||
Func<UserInputRequestedEventDto, Task> onUserInput,
|
||||
Func<McpOauthRequiredEventDto, Task> onMcpOAuthRequired,
|
||||
@@ -77,15 +77,16 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
runCancellation.Token);
|
||||
Workflow workflow = bundle.BuildWorkflow(command.Pattern);
|
||||
List<ChatMessage> inputMessages = command.Messages.Select(WorkflowTranscriptProjector.ToChatMessage).ToList();
|
||||
WorkflowTranscriptProjector.AttachMessageMode(inputMessages, command.MessageMode);
|
||||
|
||||
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)
|
||||
bool shouldEndTurn = await HandleWorkflowEventAsync(command, evt, inputMessages, state, onDelta, onEvent)
|
||||
.ConfigureAwait(false);
|
||||
await EmitPendingActivityEventsAsync(state, onActivity).ConfigureAwait(false);
|
||||
await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false);
|
||||
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
|
||||
if (shouldEndTurn)
|
||||
{
|
||||
@@ -93,13 +94,13 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
}
|
||||
}
|
||||
|
||||
await EmitPendingActivityEventsAsync(state, onActivity).ConfigureAwait(false);
|
||||
await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false);
|
||||
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
|
||||
return state.FinalizeCompletedMessages();
|
||||
}
|
||||
catch (OperationCanceledException) when (runCancellation.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await EmitPendingActivityEventsAsync(state, onActivity).ConfigureAwait(false);
|
||||
await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false);
|
||||
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
|
||||
ExitPlanModeRequestedEventDto? exitPlanModeEvent =
|
||||
_exitPlanModeCoordinator.ConsumePendingRequest(command.RequestId);
|
||||
@@ -117,13 +118,13 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task EmitPendingActivityEventsAsync(
|
||||
private static async Task EmitPendingEventsAsync(
|
||||
CopilotTurnExecutionState state,
|
||||
Func<AgentActivityEventDto, Task> onActivity)
|
||||
Func<SidecarEventDto, Task> onEvent)
|
||||
{
|
||||
foreach (AgentActivityEventDto activity in state.DrainPendingActivityEvents())
|
||||
foreach (SidecarEventDto pendingEvent in state.DrainPendingEvents())
|
||||
{
|
||||
await onActivity(activity).ConfigureAwait(false);
|
||||
await onEvent(pendingEvent).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +158,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
IReadOnlyList<ChatMessage> inputMessages,
|
||||
CopilotTurnExecutionState state,
|
||||
Func<TurnDeltaEventDto, Task> onDelta,
|
||||
Func<AgentActivityEventDto, Task> onActivity)
|
||||
Func<SidecarEventDto, Task> onEvent)
|
||||
{
|
||||
if (evt is ExecutorInvokedEvent invoked)
|
||||
{
|
||||
@@ -167,7 +168,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
out AgentIdentity invokedAgent))
|
||||
{
|
||||
TraceHandoff(command, $"Executor invoked: {invoked.ExecutorId} -> {invokedAgent.AgentName} ({invokedAgent.AgentId}).");
|
||||
await state.EmitThinkingIfNeeded(invokedAgent, onActivity).ConfigureAwait(false);
|
||||
await state.EmitThinkingIfNeeded(invokedAgent, onEvent).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -194,13 +195,13 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
return requiresBoundary;
|
||||
}
|
||||
|
||||
await EmitActivityAsync(command, state, activity, onActivity).ConfigureAwait(false);
|
||||
await EmitActivityAsync(command, state, activity, onEvent).ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (evt is AgentResponseUpdateEvent update)
|
||||
{
|
||||
await HandleAgentResponseUpdateAsync(command, update, state, onDelta, onActivity).ConfigureAwait(false);
|
||||
await HandleAgentResponseUpdateAsync(command, update, state, onDelta, onEvent).ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -237,7 +238,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
AgentResponseUpdateEvent update,
|
||||
CopilotTurnExecutionState state,
|
||||
Func<TurnDeltaEventDto, Task> onDelta,
|
||||
Func<AgentActivityEventDto, Task> onActivity)
|
||||
Func<SidecarEventDto, Task> onEvent)
|
||||
{
|
||||
AgentIdentity? updateAgent = null;
|
||||
string authorName = update.ExecutorId;
|
||||
@@ -271,7 +272,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
$"Agent response update from {updateAgent.Value.AgentName} ({updateAgent.Value.AgentId}) requested handoff via {string.Join(", ", handoffFunctionCalls)}.");
|
||||
}
|
||||
|
||||
await state.EmitThinkingIfNeeded(updateAgent.Value, onActivity).ConfigureAwait(false);
|
||||
await state.EmitThinkingIfNeeded(updateAgent.Value, onEvent).ConfigureAwait(false);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(update.Update.Text) || handoffFunctionCalls.Length > 0)
|
||||
{
|
||||
@@ -307,13 +308,13 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
RunTurnCommandDto command,
|
||||
CopilotTurnExecutionState state,
|
||||
AgentActivityEventDto activity,
|
||||
Func<AgentActivityEventDto, Task> onActivity)
|
||||
Func<SidecarEventDto, Task> onEvent)
|
||||
{
|
||||
state.ApplyActivity(activity);
|
||||
state.ApplyEvent(activity);
|
||||
TraceHandoff(
|
||||
command,
|
||||
$"Activity emitted: {activity.ActivityType} -> {activity.AgentName ?? activity.AgentId ?? "<unknown>"}.");
|
||||
await onActivity(activity).ConfigureAwait(false);
|
||||
await onEvent(activity).ConfigureAwait(false);
|
||||
|
||||
if (string.Equals(activity.ActivityType, "handoff", StringComparison.Ordinal)
|
||||
&& !string.IsNullOrWhiteSpace(activity.AgentId)
|
||||
@@ -324,7 +325,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
$"Promoting handoff target to thinking: {activity.AgentName} ({activity.AgentId}).");
|
||||
await state.EmitThinkingIfNeeded(
|
||||
new AgentIdentity(activity.AgentId, activity.AgentName),
|
||||
onActivity).ConfigureAwait(false);
|
||||
onEvent).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
public interface ICopilotSessionManager
|
||||
{
|
||||
Task<IReadOnlyList<CopilotSessionInfoDto>> ListSessionsAsync(
|
||||
CopilotSessionListFilterDto? filter,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<CopilotSessionInfoDto>> DeleteSessionsAsync(
|
||||
string? aryxSessionId,
|
||||
string? copilotSessionId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ public interface ITurnWorkflowRunner
|
||||
Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
|
||||
RunTurnCommandDto command,
|
||||
Func<TurnDeltaEventDto, Task> onDelta,
|
||||
Func<AgentActivityEventDto, Task> onActivity,
|
||||
Func<SidecarEventDto, Task> onEvent,
|
||||
Func<ApprovalRequestedEventDto, Task> onApproval,
|
||||
Func<UserInputRequestedEventDto, Task> onUserInput,
|
||||
Func<McpOauthRequiredEventDto, Task> onMcpOAuthRequired,
|
||||
|
||||
@@ -15,6 +15,9 @@ public sealed class SidecarProtocolHost
|
||||
private const string CancelTurnCommandType = "cancel-turn";
|
||||
private const string ResolveApprovalCommandType = "resolve-approval";
|
||||
private const string ResolveUserInputCommandType = "resolve-user-input";
|
||||
private const string ListSessionsCommandType = "list-sessions";
|
||||
private const string DeleteSessionCommandType = "delete-session";
|
||||
private const string DisconnectSessionCommandType = "disconnect-session";
|
||||
private const string AskUserToolName = "ask_user";
|
||||
private static readonly HashSet<string> ExcludedRuntimeToolNames = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
@@ -39,11 +42,14 @@ public sealed class SidecarProtocolHost
|
||||
private readonly Func<CancellationToken, Task<SidecarCapabilitiesDto>> _capabilitiesProvider;
|
||||
private readonly PatternValidator _patternValidator;
|
||||
private readonly ITurnWorkflowRunner _workflowRunner;
|
||||
private readonly ICopilotSessionManager _sessionManager;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly IReadOnlyDictionary<string, Func<CommandContext, Task>> _commandHandlers;
|
||||
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||
private readonly ConcurrentDictionary<string, Task> _inFlight = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, CancellationTokenSource> _turnCancellations = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> _turnRequestIdsBySessionId =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
public SidecarProtocolHost()
|
||||
: this(new PatternValidator())
|
||||
@@ -53,11 +59,13 @@ public sealed class SidecarProtocolHost
|
||||
public SidecarProtocolHost(
|
||||
PatternValidator patternValidator,
|
||||
ITurnWorkflowRunner? workflowRunner = null,
|
||||
Func<CancellationToken, Task<SidecarCapabilitiesDto>>? capabilitiesProvider = null)
|
||||
Func<CancellationToken, Task<SidecarCapabilitiesDto>>? capabilitiesProvider = null,
|
||||
ICopilotSessionManager? sessionManager = null)
|
||||
{
|
||||
_patternValidator = patternValidator;
|
||||
_workflowRunner = workflowRunner ?? new CopilotWorkflowRunner(_patternValidator);
|
||||
_capabilitiesProvider = capabilitiesProvider ?? BuildCapabilitiesAsync;
|
||||
_sessionManager = sessionManager ?? new CopilotSessionManager();
|
||||
_jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
@@ -71,6 +79,9 @@ public sealed class SidecarProtocolHost
|
||||
[CancelTurnCommandType] = HandleCancelTurnAsync,
|
||||
[ResolveApprovalCommandType] = HandleResolveApprovalAsync,
|
||||
[ResolveUserInputCommandType] = HandleResolveUserInputAsync,
|
||||
[ListSessionsCommandType] = HandleListSessionsAsync,
|
||||
[DeleteSessionCommandType] = HandleDeleteSessionAsync,
|
||||
[DisconnectSessionCommandType] = HandleDisconnectSessionAsync,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -180,12 +191,13 @@ public sealed class SidecarProtocolHost
|
||||
$"A turn with request ID '{context.Envelope.RequestId}' is already in progress.");
|
||||
}
|
||||
|
||||
RegisterTurnRequest(command.SessionId, context.Envelope.RequestId);
|
||||
try
|
||||
{
|
||||
IReadOnlyList<ChatMessageDto> messages = await _workflowRunner.RunTurnAsync(
|
||||
command,
|
||||
delta => WriteAsync(context.Output, delta, turnCancellation.Token),
|
||||
activity => WriteAsync(context.Output, activity, turnCancellation.Token),
|
||||
evt => WriteAsync(context.Output, evt, turnCancellation.Token),
|
||||
approval => WriteAsync(context.Output, approval, turnCancellation.Token),
|
||||
userInput => WriteAsync(context.Output, userInput, turnCancellation.Token),
|
||||
mcpOauth => WriteAsync(context.Output, mcpOauth, turnCancellation.Token),
|
||||
@@ -216,6 +228,7 @@ public sealed class SidecarProtocolHost
|
||||
finally
|
||||
{
|
||||
_turnCancellations.TryRemove(context.Envelope.RequestId, out _);
|
||||
UnregisterTurnRequest(command.SessionId, context.Envelope.RequestId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,6 +262,57 @@ public sealed class SidecarProtocolHost
|
||||
await _workflowRunner.ResolveUserInputAsync(command, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task HandleListSessionsAsync(CommandContext context)
|
||||
{
|
||||
ListSessionsCommandDto command = DeserializeCommand<ListSessionsCommandDto>(context);
|
||||
IReadOnlyList<CopilotSessionInfoDto> sessions = await _sessionManager.ListSessionsAsync(
|
||||
command.Filter,
|
||||
context.CancellationToken).ConfigureAwait(false);
|
||||
|
||||
await WriteAsync(context.Output, new SessionsListedEventDto
|
||||
{
|
||||
Type = "sessions-listed",
|
||||
RequestId = context.Envelope.RequestId,
|
||||
Sessions = sessions,
|
||||
}, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task HandleDeleteSessionAsync(CommandContext context)
|
||||
{
|
||||
DeleteSessionCommandDto command = DeserializeCommand<DeleteSessionCommandDto>(context);
|
||||
if (!string.IsNullOrWhiteSpace(command.SessionId))
|
||||
{
|
||||
CancelTurnRequestsForSession(command.SessionId);
|
||||
}
|
||||
|
||||
IReadOnlyList<CopilotSessionInfoDto> deletedSessions = await _sessionManager.DeleteSessionsAsync(
|
||||
command.SessionId,
|
||||
command.CopilotSessionId,
|
||||
context.CancellationToken).ConfigureAwait(false);
|
||||
|
||||
await WriteAsync(context.Output, new SessionsDeletedEventDto
|
||||
{
|
||||
Type = "sessions-deleted",
|
||||
RequestId = context.Envelope.RequestId,
|
||||
SessionId = string.IsNullOrWhiteSpace(command.SessionId) ? null : command.SessionId.Trim(),
|
||||
Sessions = deletedSessions,
|
||||
}, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task HandleDisconnectSessionAsync(CommandContext context)
|
||||
{
|
||||
DisconnectSessionCommandDto command = DeserializeCommand<DisconnectSessionCommandDto>(context);
|
||||
IReadOnlyList<string> cancelledRequestIds = CancelTurnRequestsForSession(command.SessionId);
|
||||
|
||||
await WriteAsync(context.Output, new SessionDisconnectedEventDto
|
||||
{
|
||||
Type = "session-disconnected",
|
||||
RequestId = context.Envelope.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
CancelledRequestIds = cancelledRequestIds,
|
||||
}, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private TCommand DeserializeCommand<TCommand>(CommandContext context)
|
||||
where TCommand : SidecarCommandEnvelope
|
||||
{
|
||||
@@ -309,6 +373,67 @@ public sealed class SidecarProtocolHost
|
||||
}
|
||||
}
|
||||
|
||||
private void RegisterTurnRequest(string sessionId, string requestId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId) || string.IsNullOrWhiteSpace(requestId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ConcurrentDictionary<string, byte> requestIds = _turnRequestIdsBySessionId.GetOrAdd(
|
||||
sessionId.Trim(),
|
||||
static _ => new ConcurrentDictionary<string, byte>(StringComparer.Ordinal));
|
||||
requestIds[requestId.Trim()] = 0;
|
||||
}
|
||||
|
||||
private void UnregisterTurnRequest(string sessionId, string requestId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId) || string.IsNullOrWhiteSpace(requestId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_turnRequestIdsBySessionId.TryGetValue(sessionId.Trim(), out ConcurrentDictionary<string, byte>? requestIds))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
requestIds.TryRemove(requestId.Trim(), out _);
|
||||
if (requestIds.IsEmpty)
|
||||
{
|
||||
_turnRequestIdsBySessionId.TryRemove(sessionId.Trim(), out _);
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<string> CancelTurnRequestsForSession(string sessionId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId)
|
||||
|| !_turnRequestIdsBySessionId.TryGetValue(sessionId.Trim(), out ConcurrentDictionary<string, byte>? requestIds))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
List<string> cancelledRequestIds = [];
|
||||
foreach (string requestId in requestIds.Keys)
|
||||
{
|
||||
if (!_turnCancellations.TryGetValue(requestId, out CancellationTokenSource? turnCancellation))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
turnCancellation.Cancel();
|
||||
cancelledRequestIds.Add(requestId);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return cancelledRequestIds;
|
||||
}
|
||||
|
||||
private static async Task<SidecarCapabilitiesDto> BuildCapabilitiesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -26,9 +26,30 @@ internal static class WorkflowTranscriptProjector
|
||||
mapped.AuthorName = message.AuthorName;
|
||||
}
|
||||
|
||||
foreach (ChatMessageAttachmentDto attachment in message.Attachments)
|
||||
{
|
||||
mapped.Contents.Add(new AIContent
|
||||
{
|
||||
RawRepresentation = attachment,
|
||||
});
|
||||
}
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
public static void AttachMessageMode(IList<ChatMessage> messages, string? messageMode)
|
||||
{
|
||||
if (messages.Count == 0 || string.IsNullOrWhiteSpace(messageMode))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
messages[^1].Contents.Add(new AIContent
|
||||
{
|
||||
RawRepresentation = new CopilotMessageOptionsMetadata(messageMode.Trim()),
|
||||
});
|
||||
}
|
||||
|
||||
public static List<ChatMessageDto> ProjectCompletedMessages(
|
||||
RunTurnCommandDto command,
|
||||
IReadOnlyList<ChatMessage> newMessages,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
|
||||
public sealed class AryxCopilotAgentMessageOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ProcessMessageAttachmentsAsync_MapsProtocolAttachmentsAndMessageMode()
|
||||
{
|
||||
ChatMessage message = new(ChatRole.User, "Please inspect these images.");
|
||||
message.Contents.Add(new AIContent
|
||||
{
|
||||
RawRepresentation = new ChatMessageAttachmentDto
|
||||
{
|
||||
Type = "file",
|
||||
Path = @"C:\workspace\project\assets\diagram.png",
|
||||
DisplayName = "diagram.png",
|
||||
},
|
||||
});
|
||||
message.Contents.Add(new AIContent
|
||||
{
|
||||
RawRepresentation = new ChatMessageAttachmentDto
|
||||
{
|
||||
Type = "blob",
|
||||
Data = "QUJDRA==",
|
||||
MimeType = "image/png",
|
||||
DisplayName = "clipboard.png",
|
||||
},
|
||||
});
|
||||
message.Contents.Add(new AIContent
|
||||
{
|
||||
RawRepresentation = new CopilotMessageOptionsMetadata("immediate"),
|
||||
});
|
||||
|
||||
(List<UserMessageDataAttachmentsItem>? attachments, string? messageMode, string? tempDir) =
|
||||
await AryxCopilotAgent.ProcessMessageAttachmentsAsync([message], CancellationToken.None);
|
||||
|
||||
Assert.Equal("immediate", messageMode);
|
||||
Assert.Null(tempDir);
|
||||
|
||||
Assert.NotNull(attachments);
|
||||
Assert.Collection(
|
||||
attachments!,
|
||||
first =>
|
||||
{
|
||||
UserMessageDataAttachmentsItemFile file = Assert.IsType<UserMessageDataAttachmentsItemFile>(first);
|
||||
Assert.Equal(@"C:\workspace\project\assets\diagram.png", file.Path);
|
||||
Assert.Equal("diagram.png", file.DisplayName);
|
||||
},
|
||||
second =>
|
||||
{
|
||||
UserMessageDataAttachmentsItemBlob blob = Assert.IsType<UserMessageDataAttachmentsItemBlob>(second);
|
||||
Assert.Equal("QUJDRA==", blob.Data);
|
||||
Assert.Equal("image/png", blob.MimeType);
|
||||
Assert.Equal("clipboard.png", blob.DisplayName);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessageAttachmentsAsync_RejectsRelativeFileAttachments()
|
||||
{
|
||||
ChatMessage message = new(ChatRole.User, "Inspect this file.");
|
||||
message.Contents.Add(new AIContent
|
||||
{
|
||||
RawRepresentation = new ChatMessageAttachmentDto
|
||||
{
|
||||
Type = "file",
|
||||
Path = "relative\\image.png",
|
||||
},
|
||||
});
|
||||
|
||||
InvalidOperationException error = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
AryxCopilotAgent.ProcessMessageAttachmentsAsync([message], CancellationToken.None));
|
||||
|
||||
Assert.Contains("absolute", error.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Aryx.AgentHost.Services;
|
||||
using Microsoft.Agents.AI;
|
||||
@@ -143,6 +144,122 @@ public sealed class CopilotAgentBundleTests
|
||||
Assert.Equal("handoff_to_reviewer", single.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateCustomAgents_MapsSdkCustomAgentConfiguration()
|
||||
{
|
||||
List<CustomAgentConfig> customAgents = Assert.IsType<List<CustomAgentConfig>>(CopilotAgentBundle.CreateCustomAgents(
|
||||
[
|
||||
new RunTurnCustomAgentConfigDto
|
||||
{
|
||||
Name = "designer",
|
||||
DisplayName = "Designer",
|
||||
Description = "Design specialist",
|
||||
Tools = ["view", "glob"],
|
||||
Prompt = "Focus on UX design.",
|
||||
Infer = true,
|
||||
McpServers =
|
||||
[
|
||||
new RunTurnMcpServerConfigDto
|
||||
{
|
||||
Id = "designer-mcp",
|
||||
Name = "Designer MCP",
|
||||
Transport = "local",
|
||||
Command = "node",
|
||||
Args = ["designer.js"],
|
||||
},
|
||||
],
|
||||
},
|
||||
]));
|
||||
|
||||
CustomAgentConfig customAgent = Assert.Single(customAgents);
|
||||
Assert.Equal("designer", customAgent.Name);
|
||||
Assert.Equal("Designer", customAgent.DisplayName);
|
||||
Assert.Equal("Design specialist", customAgent.Description);
|
||||
Assert.Equal(["view", "glob"], customAgent.Tools);
|
||||
Assert.Equal("Focus on UX design.", customAgent.Prompt);
|
||||
Assert.True(customAgent.Infer);
|
||||
|
||||
KeyValuePair<string, object> mcpServer = Assert.Single(customAgent.McpServers!);
|
||||
Assert.Equal("Designer MCP", mcpServer.Key);
|
||||
McpLocalServerConfig localServer = Assert.IsType<McpLocalServerConfig>(mcpServer.Value);
|
||||
Assert.Equal("node", localServer.Command);
|
||||
Assert.Equal(["designer.js"], localServer.Args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInfiniteSessions_MapsSdkInfiniteSessionConfiguration()
|
||||
{
|
||||
InfiniteSessionConfig config = Assert.IsType<InfiniteSessionConfig>(CopilotAgentBundle.CreateInfiniteSessions(
|
||||
new RunTurnInfiniteSessionsConfigDto
|
||||
{
|
||||
Enabled = true,
|
||||
BackgroundCompactionThreshold = 0.75,
|
||||
BufferExhaustionThreshold = 0.9,
|
||||
}));
|
||||
|
||||
Assert.True(config.Enabled);
|
||||
Assert.Equal(0.75, config.BackgroundCompactionThreshold);
|
||||
Assert.Equal(0.9, config.BufferExhaustionThreshold);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopilotSessionHooks_Create_UsesApprovalPolicyForPreToolUse()
|
||||
{
|
||||
RunTurnCommandDto command = new()
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
Name = "Pattern",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
ApprovalPolicy = new ApprovalPolicyDto
|
||||
{
|
||||
Rules =
|
||||
[
|
||||
new ApprovalCheckpointRuleDto
|
||||
{
|
||||
Kind = "tool-call",
|
||||
AgentIds = ["agent-1"],
|
||||
},
|
||||
],
|
||||
},
|
||||
Agents =
|
||||
[
|
||||
new PatternAgentDefinitionDto
|
||||
{
|
||||
Id = "agent-1",
|
||||
Name = "Primary",
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help.",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0]);
|
||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||
new PreToolUseHookInput
|
||||
{
|
||||
ToolName = "view",
|
||||
},
|
||||
null!);
|
||||
|
||||
Assert.Equal("ask", decision?.PermissionDecision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CopilotManagedSessionIds_BuildsAndParsesStableIds()
|
||||
{
|
||||
string sessionId = CopilotManagedSessionIds.Build("session-1", "agent-ux");
|
||||
|
||||
Assert.True(CopilotManagedSessionIds.TryParse(sessionId, out string aryxSessionId, out string agentId));
|
||||
Assert.Equal("session-1", aryxSessionId);
|
||||
Assert.Equal("agent-ux", agentId);
|
||||
}
|
||||
|
||||
private static AIFunction CreateTool()
|
||||
{
|
||||
ToolTarget target = new();
|
||||
|
||||
@@ -50,7 +50,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
}
|
||||
"""));
|
||||
|
||||
AgentActivityEventDto activity = Assert.Single(state.DrainPendingActivityEvents());
|
||||
AgentActivityEventDto activity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
|
||||
Assert.Equal("thinking", activity.ActivityType);
|
||||
Assert.Equal("agent-1", activity.AgentId);
|
||||
Assert.Equal("Primary", activity.AgentName);
|
||||
@@ -104,13 +104,13 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
}
|
||||
"""));
|
||||
|
||||
List<AgentActivityEventDto> activities = [.. state.DrainPendingActivityEvents()];
|
||||
List<AgentActivityEventDto> activities = [.. state.DrainPendingEvents().OfType<AgentActivityEventDto>()];
|
||||
|
||||
await state.EmitThinkingIfNeeded(
|
||||
new AgentIdentity("agent-1", "Primary"),
|
||||
activity =>
|
||||
sidecarEvent =>
|
||||
{
|
||||
activities.Add(activity);
|
||||
activities.Add(Assert.IsType<AgentActivityEventDto>(sidecarEvent));
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
@@ -144,6 +144,157 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
Assert.Empty(secondDrain);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObserveSessionEvent_SubagentStarted_QueuesSubagentEvent()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
"type": "subagent.started",
|
||||
"data": {
|
||||
"toolCallId": "tool-call-1",
|
||||
"agentName": "designer",
|
||||
"agentDisplayName": "Designer",
|
||||
"agentDescription": "Design specialist"
|
||||
},
|
||||
"id": "44444444-4444-4444-4444-444444444444",
|
||||
"timestamp": "2026-03-27T00:00:00Z"
|
||||
}
|
||||
"""));
|
||||
|
||||
SubagentEventDto evt = Assert.Single(state.DrainPendingEvents().OfType<SubagentEventDto>());
|
||||
Assert.Equal("started", evt.EventKind);
|
||||
Assert.Equal("tool-call-1", evt.ToolCallId);
|
||||
Assert.Equal("designer", evt.CustomAgentName);
|
||||
Assert.Equal("Designer", evt.CustomAgentDisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObserveSessionEvent_SkillInvoked_QueuesSkillEvent()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
"type": "skill.invoked",
|
||||
"data": {
|
||||
"name": "reviewer",
|
||||
"path": "C:\\skills\\reviewer\\SKILL.md",
|
||||
"content": "# Reviewer",
|
||||
"allowedTools": ["view"],
|
||||
"pluginName": "aryx-plugin",
|
||||
"pluginVersion": "1.0.0"
|
||||
},
|
||||
"id": "55555555-5555-5555-5555-555555555555",
|
||||
"timestamp": "2026-03-27T00:00:00Z"
|
||||
}
|
||||
"""));
|
||||
|
||||
SkillInvokedEventDto evt = Assert.Single(state.DrainPendingEvents().OfType<SkillInvokedEventDto>());
|
||||
Assert.Equal("reviewer", evt.SkillName);
|
||||
Assert.Equal(@"C:\skills\reviewer\SKILL.md", evt.Path);
|
||||
Assert.Equal(["view"], evt.AllowedTools);
|
||||
Assert.Equal("aryx-plugin", evt.PluginName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObserveSessionEvent_HookStart_QueuesHookLifecycleEvent()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
"type": "hook.start",
|
||||
"data": {
|
||||
"hookInvocationId": "hook-1",
|
||||
"hookType": "postToolUse",
|
||||
"input": {
|
||||
"toolName": "view"
|
||||
}
|
||||
},
|
||||
"id": "66666666-6666-6666-6666-666666666666",
|
||||
"timestamp": "2026-03-27T00:00:00Z"
|
||||
}
|
||||
"""));
|
||||
|
||||
HookLifecycleEventDto evt = Assert.Single(state.DrainPendingEvents().OfType<HookLifecycleEventDto>());
|
||||
Assert.Equal("start", evt.Phase);
|
||||
Assert.Equal("postToolUse", evt.HookType);
|
||||
Assert.Equal("hook-1", evt.HookInvocationId);
|
||||
Assert.NotNull(evt.Input);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObserveSessionEvent_SessionCompactionComplete_QueuesCompactionEvent()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
"type": "session.compaction_complete",
|
||||
"data": {
|
||||
"success": true,
|
||||
"preCompactionTokens": 1000,
|
||||
"postCompactionTokens": 400,
|
||||
"messagesRemoved": 8,
|
||||
"tokensRemoved": 600,
|
||||
"summaryContent": "Compacted summary",
|
||||
"checkpointNumber": 2,
|
||||
"checkpointPath": "C:\\Users\\me\\.copilot\\session-state\\checkpoint-2.json"
|
||||
},
|
||||
"id": "77777777-7777-7777-7777-777777777777",
|
||||
"timestamp": "2026-03-27T00:00:00Z"
|
||||
}
|
||||
"""));
|
||||
|
||||
SessionCompactionEventDto evt = Assert.Single(state.DrainPendingEvents().OfType<SessionCompactionEventDto>());
|
||||
Assert.Equal("complete", evt.Phase);
|
||||
Assert.True(evt.Success);
|
||||
Assert.Equal(1000, evt.PreCompactionTokens);
|
||||
Assert.Equal(400, evt.PostCompactionTokens);
|
||||
Assert.Equal("Compacted summary", evt.SummaryContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObserveSessionEvent_PendingMessagesModified_QueuesPendingMessageSignal()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
"type": "pending_messages.modified",
|
||||
"data": {},
|
||||
"id": "88888888-8888-8888-8888-888888888888",
|
||||
"timestamp": "2026-03-27T00:00:00Z"
|
||||
}
|
||||
"""));
|
||||
|
||||
PendingMessagesModifiedEventDto evt = Assert.Single(state.DrainPendingEvents().OfType<PendingMessagesModifiedEventDto>());
|
||||
Assert.Equal("session-1", evt.SessionId);
|
||||
Assert.Equal("agent-1", evt.AgentId);
|
||||
}
|
||||
|
||||
private static RunTurnCommandDto CreateCommand()
|
||||
{
|
||||
return new RunTurnCommandDto
|
||||
|
||||
@@ -649,7 +649,7 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
Assert.Equal("agent-handoff-ux", observedAgent.AgentId);
|
||||
Assert.Equal("UX Specialist", observedAgent.AgentName);
|
||||
Assert.Equal("agent-handoff-ux", state.ActiveAgent?.AgentId);
|
||||
AgentActivityEventDto activity = Assert.Single(state.DrainPendingActivityEvents());
|
||||
AgentActivityEventDto activity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
|
||||
Assert.Equal("thinking", activity.ActivityType);
|
||||
Assert.Equal("agent-handoff-ux", activity.AgentId);
|
||||
}
|
||||
@@ -675,7 +675,7 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
|
||||
Assert.Equal("agent-handoff-ux", state.ActiveAgent?.AgentId);
|
||||
Assert.Equal("UX Specialist", state.ActiveAgent?.AgentName);
|
||||
AgentActivityEventDto activity = Assert.Single(state.DrainPendingActivityEvents());
|
||||
AgentActivityEventDto activity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
|
||||
Assert.Equal("thinking", activity.ActivityType);
|
||||
Assert.Equal("agent-handoff-ux", activity.AgentId);
|
||||
}
|
||||
@@ -699,7 +699,7 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
"timestamp": "2026-03-27T00:00:00Z"
|
||||
}
|
||||
"""));
|
||||
_ = state.DrainPendingActivityEvents();
|
||||
_ = state.DrainPendingEvents();
|
||||
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
|
||||
CreateHandoffTarget("agent-handoff-ux", "UX Specialist"));
|
||||
List<AgentActivityEventDto> activities = [];
|
||||
@@ -715,9 +715,9 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
Array.Empty<ChatMessage>(),
|
||||
state,
|
||||
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
|
||||
(Func<AgentActivityEventDto, Task>)(activity =>
|
||||
(Func<SidecarEventDto, Task>)(sidecarEvent =>
|
||||
{
|
||||
activities.Add(activity);
|
||||
activities.Add(Assert.IsType<AgentActivityEventDto>(sidecarEvent));
|
||||
return Task.CompletedTask;
|
||||
}),
|
||||
])!;
|
||||
|
||||
@@ -780,6 +780,109 @@ public sealed class SidecarProtocolHostTests
|
||||
Assert.False(string.IsNullOrWhiteSpace(diagnostics.CheckedAt));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListSessionsCommand_ReturnsSessionsListedEvent()
|
||||
{
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
sessionManager: new FakeSessionManager
|
||||
{
|
||||
Sessions =
|
||||
[
|
||||
new CopilotSessionInfoDto
|
||||
{
|
||||
CopilotSessionId = "aryx::session-1::agent-1",
|
||||
ManagedByAryx = true,
|
||||
SessionId = "session-1",
|
||||
AgentId = "agent-1",
|
||||
Summary = "Review session",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
||||
new ListSessionsCommandDto
|
||||
{
|
||||
Type = "list-sessions",
|
||||
RequestId = "list-1",
|
||||
},
|
||||
host);
|
||||
|
||||
JsonElement listedEvent = AssertSingleEvent(events, "sessions-listed", "list-1");
|
||||
JsonElement session = Assert.Single(listedEvent.GetProperty("sessions").EnumerateArray());
|
||||
Assert.Equal("aryx::session-1::agent-1", session.GetProperty("copilotSessionId").GetString());
|
||||
Assert.Equal("session-1", session.GetProperty("sessionId").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSessionCommand_ReturnsDeletedSessionsEvent()
|
||||
{
|
||||
FakeSessionManager sessionManager = new()
|
||||
{
|
||||
DeletedSessions =
|
||||
[
|
||||
new CopilotSessionInfoDto
|
||||
{
|
||||
CopilotSessionId = "aryx::session-1::agent-1",
|
||||
ManagedByAryx = true,
|
||||
SessionId = "session-1",
|
||||
AgentId = "agent-1",
|
||||
},
|
||||
],
|
||||
};
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
sessionManager: sessionManager);
|
||||
|
||||
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
||||
new DeleteSessionCommandDto
|
||||
{
|
||||
Type = "delete-session",
|
||||
RequestId = "delete-1",
|
||||
SessionId = "session-1",
|
||||
},
|
||||
host);
|
||||
|
||||
JsonElement deletedEvent = AssertSingleEvent(events, "sessions-deleted", "delete-1");
|
||||
JsonElement session = Assert.Single(deletedEvent.GetProperty("sessions").EnumerateArray());
|
||||
Assert.Equal("session-1", deletedEvent.GetProperty("sessionId").GetString());
|
||||
Assert.Equal("aryx::session-1::agent-1", session.GetProperty("copilotSessionId").GetString());
|
||||
Assert.Equal("session-1", sessionManager.DeletedAryxSessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisconnectSessionCommand_CancelsActiveTurnsForSession()
|
||||
{
|
||||
FakeWorkflowRunner runner = new(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
|
||||
{
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||
return [];
|
||||
});
|
||||
SidecarProtocolHost host = new(new PatternValidator(), runner);
|
||||
|
||||
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
||||
[
|
||||
CreateRunTurnCommand(requestId: "turn-1", sessionId: "session-1"),
|
||||
new DisconnectSessionCommandDto
|
||||
{
|
||||
Type = "disconnect-session",
|
||||
RequestId = "disconnect-1",
|
||||
SessionId = "session-1",
|
||||
},
|
||||
],
|
||||
host);
|
||||
|
||||
JsonElement disconnectedEvent = AssertSingleEvent(events, "session-disconnected", "disconnect-1");
|
||||
string[] cancelledRequestIds = disconnectedEvent.GetProperty("cancelledRequestIds")
|
||||
.EnumerateArray()
|
||||
.Select(value => value.GetString() ?? string.Empty)
|
||||
.ToArray();
|
||||
Assert.Equal(["turn-1"], cancelledRequestIds);
|
||||
|
||||
JsonElement turnComplete = AssertSingleEvent(events, "turn-complete", "turn-1");
|
||||
Assert.True(turnComplete.GetProperty("cancelled").GetBoolean());
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<JsonElement>> RunHostAsync(
|
||||
object command,
|
||||
SidecarProtocolHost? host = null)
|
||||
@@ -949,7 +1052,7 @@ public sealed class SidecarProtocolHostTests
|
||||
private readonly Func<
|
||||
RunTurnCommandDto,
|
||||
Func<TurnDeltaEventDto, Task>,
|
||||
Func<AgentActivityEventDto, Task>,
|
||||
Func<SidecarEventDto, Task>,
|
||||
Func<ApprovalRequestedEventDto, Task>,
|
||||
Func<UserInputRequestedEventDto, Task>,
|
||||
Func<McpOauthRequiredEventDto, Task>,
|
||||
@@ -963,7 +1066,7 @@ public sealed class SidecarProtocolHostTests
|
||||
Func<
|
||||
RunTurnCommandDto,
|
||||
Func<TurnDeltaEventDto, Task>,
|
||||
Func<AgentActivityEventDto, Task>,
|
||||
Func<SidecarEventDto, Task>,
|
||||
Func<ApprovalRequestedEventDto, Task>,
|
||||
Func<UserInputRequestedEventDto, Task>,
|
||||
Func<McpOauthRequiredEventDto, Task>,
|
||||
@@ -981,7 +1084,7 @@ public sealed class SidecarProtocolHostTests
|
||||
public Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
|
||||
RunTurnCommandDto command,
|
||||
Func<TurnDeltaEventDto, Task> onDelta,
|
||||
Func<AgentActivityEventDto, Task> onActivity,
|
||||
Func<SidecarEventDto, Task> onActivity,
|
||||
Func<ApprovalRequestedEventDto, Task> onApproval,
|
||||
Func<UserInputRequestedEventDto, Task> onUserInput,
|
||||
Func<McpOauthRequiredEventDto, Task> onMcpOAuthRequired,
|
||||
@@ -1005,4 +1108,32 @@ public sealed class SidecarProtocolHostTests
|
||||
return _resolveUserInputHandler(command, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeSessionManager : ICopilotSessionManager
|
||||
{
|
||||
public IReadOnlyList<CopilotSessionInfoDto> Sessions { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<CopilotSessionInfoDto> DeletedSessions { get; init; } = [];
|
||||
|
||||
public string? DeletedAryxSessionId { get; private set; }
|
||||
|
||||
public string? DeletedCopilotSessionId { get; private set; }
|
||||
|
||||
public Task<IReadOnlyList<CopilotSessionInfoDto>> ListSessionsAsync(
|
||||
CopilotSessionListFilterDto? filter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(Sessions);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<CopilotSessionInfoDto>> DeleteSessionsAsync(
|
||||
string? aryxSessionId,
|
||||
string? copilotSessionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DeletedAryxSessionId = aryxSessionId;
|
||||
DeletedCopilotSessionId = copilotSessionId;
|
||||
return Task.FromResult(DeletedSessions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user