From f1fa52f9c38ca603dea6474d75d4ec51cc37d39a Mon Sep 17 00:00:00 2001 From: David Kaya Date: Sat, 28 Mar 2026 12:28:20 +0100 Subject: [PATCH] =?UTF-8?q?feat:=20full=20Copilot=20SDK=20feature=20parity?= =?UTF-8?q?=20=E2=80=94=20custom=20agents,=20hooks,=20image=20input,=20ski?= =?UTF-8?q?lls,=20steering,=20session=20persistence?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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> --- AGENTS.md | 1 + ARCHITECTURE.md | 20 ++ README.md | 18 +- .../Contracts/ProtocolModels.cs | 184 +++++++++++ .../Services/AryxCopilotAgent.cs | 103 +++++- .../Services/CopilotAgentBundle.cs | 56 ++++ .../Services/CopilotManagedSessionIds.cs | 50 +++ .../Services/CopilotMessageOptionsMetadata.cs | 3 + .../Services/CopilotSessionHooks.cs | 47 +++ .../Services/CopilotSessionManager.cs | 125 ++++++++ .../Services/CopilotTurnExecutionState.cs | 294 +++++++++++++++++- .../Services/CopilotWorkflowRunner.cs | 39 +-- .../Services/ICopilotSessionManager.cs | 16 + .../Services/ITurnWorkflowRunner.cs | 2 +- .../Services/SidecarProtocolHost.cs | 129 +++++++- .../Services/WorkflowTranscriptProjector.cs | 21 ++ .../AryxCopilotAgentMessageOptionsTests.cs | 81 +++++ .../CopilotAgentBundleTests.cs | 117 +++++++ .../CopilotTurnExecutionStateTests.cs | 159 +++++++++- .../CopilotWorkflowRunnerTests.cs | 10 +- .../SidecarProtocolHostTests.cs | 137 +++++++- src/main/AryxAppService.ts | 124 +++++++- src/main/ipc/registerIpcHandlers.ts | 6 +- src/main/sidecar/runTurnPending.ts | 15 + src/main/sidecar/sidecarProcess.ts | 100 +++++- src/preload/index.ts | 1 + src/renderer/App.tsx | 42 ++- src/renderer/components/ActivityPanel.tsx | 67 +++- src/renderer/components/ChatPane.tsx | 160 +++++++++- src/renderer/components/Sidebar.tsx | 16 +- src/renderer/lib/sessionActivity.ts | 135 ++++++++ src/shared/contracts/channels.ts | 1 + src/shared/contracts/ipc.ts | 10 +- src/shared/contracts/sidecar.ts | 199 +++++++++++- src/shared/domain/attachment.ts | 42 +++ src/shared/domain/event.ts | 38 ++- src/shared/domain/pattern.ts | 3 + src/shared/domain/session.ts | 2 + tests/main/runTurnPending.test.ts | 2 + website/src/pages/index.astro | 32 +- 40 files changed, 2515 insertions(+), 92 deletions(-) create mode 100644 sidecar/src/Aryx.AgentHost/Services/CopilotManagedSessionIds.cs create mode 100644 sidecar/src/Aryx.AgentHost/Services/CopilotMessageOptionsMetadata.cs create mode 100644 sidecar/src/Aryx.AgentHost/Services/CopilotSessionHooks.cs create mode 100644 sidecar/src/Aryx.AgentHost/Services/CopilotSessionManager.cs create mode 100644 sidecar/src/Aryx.AgentHost/Services/ICopilotSessionManager.cs create mode 100644 sidecar/tests/Aryx.AgentHost.Tests/AryxCopilotAgentMessageOptionsTests.cs create mode 100644 src/shared/domain/attachment.ts diff --git a/AGENTS.md b/AGENTS.md index 2e48fda..26da7ee 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -154,6 +154,7 @@ Every interactive component must include basic accessibility: - Keep changes focused and reviewable. Avoid mixing unrelated concerns into a single change. - Always commit completed repository changes before handing work off. If unrelated pre-existing changes are present in the worktree, stop and ask the user how to proceed before creating the commit. - Do not mark work as done until both the implementation and its verification are complete. +- **Never use unscoped glob patterns** (e.g. `**/*`) at or near the repository root. The repository contains large `node_modules/` directories that will cause glob operations to hang or exhaust resources. Always scope globs to a specific subdirectory (e.g. `src/**/*.ts`, `sidecar/src/**/*.cs`) or use `view` on known directories instead. ## 8. Planning requirements diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 4ee7e6d..1b4a8d5 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -202,6 +202,17 @@ This is a structured stdio protocol used for: This protocol boundary keeps the AI execution runtime replaceable and prevents the Electron main process from becoming overloaded with workflow-specific behavior. +The protocol also carries **turn-scoped lifecycle events** alongside output deltas. These events let the UI visualize execution internals without the main process having to interpret AI workflow semantics: + +- **Sub-agent events**: started, completed, failed, selected, deselected — surfaced when custom agents are defined +- **Skill invocation events**: emitted when an agent-side skill is triggered +- **Hook lifecycle events**: start and end of registered hooks (e.g. pre-turn, post-turn) +- **Session compaction events**: start and complete, with token-reduction metrics when infinite sessions trigger context trimming +- **Session usage events**: current token count and context-window limit for usage-bar rendering +- **Pending-messages-modified events**: emitted when mid-turn steering changes the pending message queue + +These events flow through a single `onTurnScopedEvent` callback on the `runTurn` command, avoiding per-event-type callback proliferation. The main process maps each event to a `SessionEventRecord` and pushes it to the renderer, where lightweight state maps (activity, usage, turn-event log) consume them without touching the persisted workspace. + ## Security model Security in this system is mostly about **desktop trust boundaries**. @@ -273,11 +284,20 @@ The architecture treats execution as observable by design: - partial output is streamed - agent activity is surfaced +- turn-scoped lifecycle events (sub-agent, hook, skill, compaction, usage) are streamed - runs are persisted as timeline history - failures are represented explicitly This improves trust and debuggability, especially for multi-agent workflows. +### Mid-turn steering + +Aryx supports sending user messages while a turn is actively running. These messages are delivered with a `messageMode` flag (`immediate` or `enqueue`) that tells the sidecar to inject the content into the current Copilot session rather than starting a new turn. This enables real-time steering without waiting for turn completion. The main process allows the IPC call even when the session is in `running` status, and the renderer keeps the composer enabled throughout. + +### Image attachments + +User messages can carry image attachments as base64-encoded blobs. These flow from the renderer through IPC, the main process, and the sidecar protocol as `ChatMessageAttachmentDto` objects alongside the text content. The sidecar maps them into the Copilot SDK's `DataPart` model. Attachment metadata is persisted on the `ChatMessageRecord` so thumbnail previews render correctly when revisiting a session. + ## Persistence and repair Workspace persistence is intentionally simple: the app stores a durable workspace document and repairs or normalizes it when loading. diff --git a/README.md b/README.md index f62b9c1..9709659 100644 --- a/README.md +++ b/README.md @@ -17,8 +17,10 @@ It works especially well when you want AI help that stays grounded in an actual - **Start fast** with a scratchpad conversation for quick questions and ad-hoc work. - **Work against real projects** by attaching local folders and letting Aryx stay aware of repository context. - **Go beyond one assistant** with orchestration patterns such as single-agent, sequential, concurrent, handoff, and group-chat flows. -- **See what is happening** with live activity for each agent while a run is in progress. -- **Stay organized** with persistent sessions you can rename, pin, archive, and return to later. +- **See what is happening** with live activity for each agent while a run is in progress, including sub-agent delegations, hook lifecycle, skill invocations, and context compaction. +- **Stay organized** with persistent sessions you can rename, pin, archive, delete, and return to later. +- **Steer while agents work** by sending follow-up messages mid-turn — the agent receives your input immediately. +- **Attach images** to any message for visual context the model can reason about. - **Tune how you work** by choosing models and reusing saved patterns that fit different tasks. ## What you can do in the app @@ -51,11 +53,19 @@ Patterns now require tool-call approval by default. They can also store default ### Watch runs as they happen -You can follow agent activity while a session is running, which makes longer or more complex workflows easier to trust and understand. +You can follow agent activity while a session is running, which makes longer or more complex workflows easier to trust and understand. The activity panel shows sub-agent delegations, skill invocations, hook lifecycle events, and context compaction in real time. A context-usage bar below the composer shows how much of the model's context window the current session occupies. + +### Steer agents mid-turn + +While an agent is working, you can type a follow-up message that is delivered immediately into the current turn. This lets you redirect, refine, or add context without waiting for the turn to finish. The composer shows an amber "steering" indicator when a message will be injected into an active run. + +### Attach images + +You can attach images (JPEG, PNG, GIF, WebP) to any message using the clip button, drag-and-drop, or paste from clipboard. Image attachments are sent as base64-encoded blobs so the model can reason about visual content alongside your text. ### Keep important work around -Sessions are persistent, so you can return to ongoing work instead of starting from scratch every time. You can also rename, pin, archive, and duplicate sessions as your workspace grows. +Sessions are persistent, so you can return to ongoing work instead of starting from scratch every time. You can also rename, pin, archive, delete, and duplicate sessions as your workspace grows. ## Before you start diff --git a/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs b/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs index 356420e..61b671e 100644 --- a/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs +++ b/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs @@ -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 CustomAgents { get; init; } = []; + public string? Agent { get; init; } + public IReadOnlyList SkillDirectories { get; init; } = []; + public IReadOnlyList 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 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 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 McpServers { get; init; } = []; @@ -217,6 +254,48 @@ public sealed class RunTurnLspProfileConfigDto public IReadOnlyList 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? Tools { get; init; } + public string Prompt { get; init; } = string.Empty; + public IReadOnlyList 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? 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? 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 Sessions { get; init; } = []; +} + +public sealed class SessionsDeletedEventDto : SidecarEventDto +{ + public string? SessionId { get; init; } + public IReadOnlyList Sessions { get; init; } = []; +} + +public sealed class SessionDisconnectedEventDto : SidecarEventDto +{ + public string SessionId { get; init; } = string.Empty; + public IReadOnlyList CancelledRequestIds { get; init; } = []; +} + public sealed class PermissionDetailDto { public string Kind { get; init; } = string.Empty; diff --git a/sidecar/src/Aryx.AgentHost/Services/AryxCopilotAgent.cs b/sidecar/src/Aryx.AgentHost/Services/AryxCopilotAgent.cs index aac1fcb..3d44156 100644 --- a/sidecar/src/Aryx.AgentHost/Services/AryxCopilotAgent.cs +++ b/sidecar/src/Aryx.AgentHost/Services/AryxCopilotAgent.cs @@ -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? attachments, tempDir) = await ProcessDataContentAttachmentsAsync( + (List? 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>(json); } - private static async Task<(List? Attachments, string? TempDir)> ProcessDataContentAttachmentsAsync( + internal static async Task<(List? Attachments, string? MessageMode, string? TempDir)> ProcessMessageAttachmentsAsync( IEnumerable messages, CancellationToken cancellationToken) { List? 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) diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs index bdf1752..083dc7d 100644 --- a/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotAgentBundle.cs @@ -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? CreateCustomAgents( + IReadOnlyList? 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? CreateStringList(IReadOnlyList? 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 diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotManagedSessionIds.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotManagedSessionIds.cs new file mode 100644 index 0000000..cc23e40 --- /dev/null +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotManagedSessionIds.cs @@ -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; + } +} + diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotMessageOptionsMetadata.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotMessageOptionsMetadata.cs new file mode 100644 index 0000000..2e8714d --- /dev/null +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotMessageOptionsMetadata.cs @@ -0,0 +1,3 @@ +namespace Aryx.AgentHost.Services; + +internal sealed record CopilotMessageOptionsMetadata(string MessageMode); diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotSessionHooks.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotSessionHooks.cs new file mode 100644 index 0000000..210f016 --- /dev/null +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotSessionHooks.cs @@ -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( + CreatePreToolUseOutput(command, agentDefinition, input)), + OnPostToolUse = static (_, _) => Task.FromResult(null), + OnUserPromptSubmitted = static (_, _) => Task.FromResult(null), + OnSessionStart = static (_, _) => Task.FromResult(null), + OnSessionEnd = static (_, _) => Task.FromResult(null), + OnErrorOccurred = static (_, _) => Task.FromResult(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(); +} diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotSessionManager.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotSessionManager.cs new file mode 100644 index 0000000..f8a1e50 --- /dev/null +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotSessionManager.cs @@ -0,0 +1,125 @@ +using Aryx.AgentHost.Contracts; +using GitHub.Copilot.SDK; + +namespace Aryx.AgentHost.Services; + +internal sealed class CopilotSessionManager : ICopilotSessionManager +{ + public async Task> ListSessionsAsync( + CopilotSessionListFilterDto? filter, + CancellationToken cancellationToken) + { + await using CopilotClient client = await CreateStartedClientAsync(cancellationToken).ConfigureAwait(false); + List sessions = await client.ListSessionsAsync(CreateFilter(filter), cancellationToken) + .ConfigureAwait(false); + + return sessions + .Select(MapSession) + .OrderByDescending(session => session.ModifiedTime, StringComparer.Ordinal) + .ToList(); + } + + public async Task> 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 sessions = await client.ListSessionsAsync(null, cancellationToken).ConfigureAwait(false); + List 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 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(); +} + diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotTurnExecutionState.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotTurnExecutionState.cs index d2d2ed0..9aba7e0 100644 --- a/sidecar/src/Aryx.AgentHost/Services/CopilotTurnExecutionState.cs +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotTurnExecutionState.cs @@ -9,7 +9,7 @@ internal sealed class CopilotTurnExecutionState { private readonly RunTurnCommandDto _command; private readonly HashSet _startedAgents = new(StringComparer.OrdinalIgnoreCase); - private readonly ConcurrentQueue _pendingActivityEvents = new(); + private readonly ConcurrentQueue _pendingEvents = new(); private readonly ConcurrentQueue _pendingMcpOauthRequests = new(); private readonly ConcurrentDictionary _observedAgentsByMessageId = new(StringComparer.Ordinal); private readonly StreamingTranscriptBuffer _transcriptBuffer = new(); @@ -30,7 +30,7 @@ internal sealed class CopilotTurnExecutionState public async Task EmitThinkingIfNeeded( AgentIdentity agent, - Func onActivity) + Func 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 DrainPendingActivityEvents() + public IReadOnlyList DrainPendingEvents() { - List pending = []; - while (_pendingActivityEvents.TryDequeue(out AgentActivityEventDto? activity)) + List 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, + }; + } } diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs index 6022029..1bba6e8 100644 --- a/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs @@ -23,7 +23,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner public async Task> RunTurnAsync( RunTurnCommandDto command, Func onDelta, - Func onActivity, + Func onEvent, Func onApproval, Func onUserInput, Func onMcpOAuthRequired, @@ -77,15 +77,16 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner runCancellation.Token); Workflow workflow = bundle.BuildWorkflow(command.Pattern); List 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 onActivity) + Func 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 inputMessages, CopilotTurnExecutionState state, Func onDelta, - Func onActivity) + Func 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 onDelta, - Func onActivity) + Func 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 onActivity) + Func onEvent) { - state.ApplyActivity(activity); + state.ApplyEvent(activity); TraceHandoff( command, $"Activity emitted: {activity.ActivityType} -> {activity.AgentName ?? activity.AgentId ?? ""}."); - 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); } } diff --git a/sidecar/src/Aryx.AgentHost/Services/ICopilotSessionManager.cs b/sidecar/src/Aryx.AgentHost/Services/ICopilotSessionManager.cs new file mode 100644 index 0000000..297592e --- /dev/null +++ b/sidecar/src/Aryx.AgentHost/Services/ICopilotSessionManager.cs @@ -0,0 +1,16 @@ +using Aryx.AgentHost.Contracts; + +namespace Aryx.AgentHost.Services; + +public interface ICopilotSessionManager +{ + Task> ListSessionsAsync( + CopilotSessionListFilterDto? filter, + CancellationToken cancellationToken); + + Task> DeleteSessionsAsync( + string? aryxSessionId, + string? copilotSessionId, + CancellationToken cancellationToken); +} + diff --git a/sidecar/src/Aryx.AgentHost/Services/ITurnWorkflowRunner.cs b/sidecar/src/Aryx.AgentHost/Services/ITurnWorkflowRunner.cs index a8d71f5..2a513c5 100644 --- a/sidecar/src/Aryx.AgentHost/Services/ITurnWorkflowRunner.cs +++ b/sidecar/src/Aryx.AgentHost/Services/ITurnWorkflowRunner.cs @@ -7,7 +7,7 @@ public interface ITurnWorkflowRunner Task> RunTurnAsync( RunTurnCommandDto command, Func onDelta, - Func onActivity, + Func onEvent, Func onApproval, Func onUserInput, Func onMcpOAuthRequired, diff --git a/sidecar/src/Aryx.AgentHost/Services/SidecarProtocolHost.cs b/sidecar/src/Aryx.AgentHost/Services/SidecarProtocolHost.cs index 54a2d04..2ec89d4 100644 --- a/sidecar/src/Aryx.AgentHost/Services/SidecarProtocolHost.cs +++ b/sidecar/src/Aryx.AgentHost/Services/SidecarProtocolHost.cs @@ -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 ExcludedRuntimeToolNames = new(StringComparer.OrdinalIgnoreCase) { @@ -39,11 +42,14 @@ public sealed class SidecarProtocolHost private readonly Func> _capabilitiesProvider; private readonly PatternValidator _patternValidator; private readonly ITurnWorkflowRunner _workflowRunner; + private readonly ICopilotSessionManager _sessionManager; private readonly JsonSerializerOptions _jsonOptions; private readonly IReadOnlyDictionary> _commandHandlers; private readonly SemaphoreSlim _writeLock = new(1, 1); private readonly ConcurrentDictionary _inFlight = new(StringComparer.Ordinal); private readonly ConcurrentDictionary _turnCancellations = new(StringComparer.Ordinal); + private readonly ConcurrentDictionary> _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>? capabilitiesProvider = null) + Func>? 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 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(context); + IReadOnlyList 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(context); + if (!string.IsNullOrWhiteSpace(command.SessionId)) + { + CancelTurnRequestsForSession(command.SessionId); + } + + IReadOnlyList 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(context); + IReadOnlyList 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(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 requestIds = _turnRequestIdsBySessionId.GetOrAdd( + sessionId.Trim(), + static _ => new ConcurrentDictionary(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? requestIds)) + { + return; + } + + requestIds.TryRemove(requestId.Trim(), out _); + if (requestIds.IsEmpty) + { + _turnRequestIdsBySessionId.TryRemove(sessionId.Trim(), out _); + } + } + + private IReadOnlyList CancelTurnRequestsForSession(string sessionId) + { + if (string.IsNullOrWhiteSpace(sessionId) + || !_turnRequestIdsBySessionId.TryGetValue(sessionId.Trim(), out ConcurrentDictionary? requestIds)) + { + return []; + } + + List 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 BuildCapabilitiesAsync(CancellationToken cancellationToken) { try diff --git a/sidecar/src/Aryx.AgentHost/Services/WorkflowTranscriptProjector.cs b/sidecar/src/Aryx.AgentHost/Services/WorkflowTranscriptProjector.cs index a585e58..ed25810 100644 --- a/sidecar/src/Aryx.AgentHost/Services/WorkflowTranscriptProjector.cs +++ b/sidecar/src/Aryx.AgentHost/Services/WorkflowTranscriptProjector.cs @@ -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 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 ProjectCompletedMessages( RunTurnCommandDto command, IReadOnlyList newMessages, diff --git a/sidecar/tests/Aryx.AgentHost.Tests/AryxCopilotAgentMessageOptionsTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/AryxCopilotAgentMessageOptionsTests.cs new file mode 100644 index 0000000..3959020 --- /dev/null +++ b/sidecar/tests/Aryx.AgentHost.Tests/AryxCopilotAgentMessageOptionsTests.cs @@ -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? 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(first); + Assert.Equal(@"C:\workspace\project\assets\diagram.png", file.Path); + Assert.Equal("diagram.png", file.DisplayName); + }, + second => + { + UserMessageDataAttachmentsItemBlob blob = Assert.IsType(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(() => + AryxCopilotAgent.ProcessMessageAttachmentsAsync([message], CancellationToken.None)); + + Assert.Contains("absolute", error.Message, StringComparison.OrdinalIgnoreCase); + } +} + diff --git a/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs index b6a3a8f..8f20c54 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/CopilotAgentBundleTests.cs @@ -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 customAgents = Assert.IsType>(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 mcpServer = Assert.Single(customAgent.McpServers!); + Assert.Equal("Designer MCP", mcpServer.Key); + McpLocalServerConfig localServer = Assert.IsType(mcpServer.Value); + Assert.Equal("node", localServer.Command); + Assert.Equal(["designer.js"], localServer.Args); + } + + [Fact] + public void CreateInfiniteSessions_MapsSdkInfiniteSessionConfiguration() + { + InfiniteSessionConfig config = Assert.IsType(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(); diff --git a/sidecar/tests/Aryx.AgentHost.Tests/CopilotTurnExecutionStateTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/CopilotTurnExecutionStateTests.cs index cb74dba..0a888de 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/CopilotTurnExecutionStateTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/CopilotTurnExecutionStateTests.cs @@ -50,7 +50,7 @@ public sealed class CopilotTurnExecutionStateTests } """)); - AgentActivityEventDto activity = Assert.Single(state.DrainPendingActivityEvents()); + AgentActivityEventDto activity = Assert.Single(state.DrainPendingEvents().OfType()); 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 activities = [.. state.DrainPendingActivityEvents()]; + List activities = [.. state.DrainPendingEvents().OfType()]; await state.EmitThinkingIfNeeded( new AgentIdentity("agent-1", "Primary"), - activity => + sidecarEvent => { - activities.Add(activity); + activities.Add(Assert.IsType(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()); + 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()); + 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()); + 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()); + 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()); + Assert.Equal("session-1", evt.SessionId); + Assert.Equal("agent-1", evt.AgentId); + } + private static RunTurnCommandDto CreateCommand() { return new RunTurnCommandDto diff --git a/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs index e9fcb23..ab4024d 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs @@ -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()); 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()); 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 activities = []; @@ -715,9 +715,9 @@ public sealed class CopilotWorkflowRunnerTests Array.Empty(), state, (Func)(_ => Task.CompletedTask), - (Func)(activity => + (Func)(sidecarEvent => { - activities.Add(activity); + activities.Add(Assert.IsType(sidecarEvent)); return Task.CompletedTask; }), ])!; diff --git a/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs index 33203fb..5e42cfb 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs @@ -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 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 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 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> RunHostAsync( object command, SidecarProtocolHost? host = null) @@ -949,7 +1052,7 @@ public sealed class SidecarProtocolHostTests private readonly Func< RunTurnCommandDto, Func, - Func, + Func, Func, Func, Func, @@ -963,7 +1066,7 @@ public sealed class SidecarProtocolHostTests Func< RunTurnCommandDto, Func, - Func, + Func, Func, Func, Func, @@ -981,7 +1084,7 @@ public sealed class SidecarProtocolHostTests public Task> RunTurnAsync( RunTurnCommandDto command, Func onDelta, - Func onActivity, + Func onActivity, Func onApproval, Func onUserInput, Func onMcpOAuthRequired, @@ -1005,4 +1108,32 @@ public sealed class SidecarProtocolHostTests return _resolveUserInputHandler(command, cancellationToken); } } + + private sealed class FakeSessionManager : ICopilotSessionManager + { + public IReadOnlyList Sessions { get; init; } = []; + + public IReadOnlyList DeletedSessions { get; init; } = []; + + public string? DeletedAryxSessionId { get; private set; } + + public string? DeletedCopilotSessionId { get; private set; } + + public Task> ListSessionsAsync( + CopilotSessionListFilterDto? filter, + CancellationToken cancellationToken) + { + return Task.FromResult(Sessions); + } + + public Task> DeleteSessionsAsync( + string? aryxSessionId, + string? copilotSessionId, + CancellationToken cancellationToken) + { + DeletedAryxSessionId = aryxSessionId; + DeletedCopilotSessionId = copilotSessionId; + return Task.FromResult(DeletedSessions); + } + } } diff --git a/src/main/AryxAppService.ts b/src/main/AryxAppService.ts index 210472f..19f921b 100644 --- a/src/main/AryxAppService.ts +++ b/src/main/AryxAppService.ts @@ -14,6 +14,7 @@ import type { TurnDeltaEvent, UserInputRequestedEvent, } from '@shared/contracts/sidecar'; +import type { TurnScopedEvent } from '@main/sidecar/runTurnPending'; import { buildAvailableModelCatalog, findModel, @@ -564,10 +565,40 @@ export class AryxAppService extends EventEmitter { return this.persistAndBroadcast(workspace); } - async sendSessionMessage(sessionId: string, content: string): Promise { + async deleteSession(sessionId: string): Promise { + const workspace = await this.loadWorkspace(); + const sessionIndex = workspace.sessions.findIndex((s) => s.id === sessionId); + if (sessionIndex < 0) { + throw new Error(`Session ${sessionId} not found.`); + } + + workspace.sessions.splice(sessionIndex, 1); + + if (workspace.selectedSessionId === sessionId) { + workspace.selectedSessionId = workspace.sessions[0]?.id; + } + + // Clean up corresponding Copilot SDK session data + try { + await this.sidecar.deleteSession(sessionId); + } catch { + // Best-effort — don't fail the deletion if SDK cleanup fails + } + + return this.persistAndBroadcast(workspace); + } + + async sendSessionMessage( + sessionId: string, + content: string, + attachments?: import('@shared/domain/attachment').ChatMessageAttachment[], + messageMode?: import('@shared/contracts/sidecar').MessageMode, + ): Promise { const workspace = await this.loadWorkspace(); const session = this.requireSession(workspace, sessionId); - if (session.status === 'running') { + + // Steering/queueing: allow messages during an active turn when messageMode is set + if (session.status === 'running' && !messageMode) { throw new Error('Wait for the current response or approval checkpoint to finish before sending another message.'); } const project = this.requireProject(workspace, session.projectId); @@ -589,6 +620,7 @@ export class AryxAppService extends EventEmitter { authorName: 'You', content: preparedContent, createdAt: occurredAt, + attachments: attachments?.length ? attachments : undefined, }); session.title = resolveSessionTitle(session, effectivePattern, session.messages); session.status = 'running'; @@ -625,8 +657,10 @@ export class AryxAppService extends EventEmitter { projectPath: project.path, workspaceKind, mode: session.interactionMode ?? 'interactive', + messageMode, pattern: effectivePattern, messages: session.messages, + attachments: attachments?.length ? attachments : undefined, tooling: this.buildRunTurnToolingConfig(workspace, session), }, async (event) => { @@ -649,6 +683,9 @@ export class AryxAppService extends EventEmitter { async (event) => { await this.handleExitPlanModeRequested(workspace, session.id, event); }, + async (event) => { + await this.handleTurnScopedEvent(workspace, session.id, event); + }, ); await this.awaitFinalResponseApproval(workspace, session.id, requestId, effectivePattern, responseMessages); @@ -1525,6 +1562,89 @@ export class AryxAppService extends EventEmitter { await this.persistAndBroadcast(workspace); } + private handleTurnScopedEvent( + _workspace: WorkspaceState, + sessionId: string, + event: TurnScopedEvent, + ): void { + const occurredAt = nowIso(); + + switch (event.type) { + case 'subagent-event': + this.emitSessionEvent({ + sessionId, + kind: 'subagent', + occurredAt, + agentId: event.agentId, + agentName: event.agentName, + subagentEventKind: event.eventKind, + customAgentName: event.customAgentName, + customAgentDisplayName: event.customAgentDisplayName, + }); + return; + case 'skill-invoked': + this.emitSessionEvent({ + sessionId, + kind: 'skill-invoked', + occurredAt, + agentId: event.agentId, + agentName: event.agentName, + skillName: event.skillName, + skillPath: event.path, + pluginName: event.pluginName, + }); + return; + case 'hook-lifecycle': + this.emitSessionEvent({ + sessionId, + kind: 'hook-lifecycle', + occurredAt, + agentId: event.agentId, + agentName: event.agentName, + hookInvocationId: event.hookInvocationId, + hookType: event.hookType, + hookPhase: event.phase, + hookSuccess: event.success, + }); + return; + case 'session-usage': + this.emitSessionEvent({ + sessionId, + kind: 'session-usage', + occurredAt, + agentId: event.agentId, + agentName: event.agentName, + tokenLimit: event.tokenLimit, + currentTokens: event.currentTokens, + messagesLength: event.messagesLength, + }); + return; + case 'session-compaction': + this.emitSessionEvent({ + sessionId, + kind: 'session-compaction', + occurredAt, + agentId: event.agentId, + agentName: event.agentName, + compactionPhase: event.phase, + compactionSuccess: event.success, + preCompactionTokens: event.preCompactionTokens, + postCompactionTokens: event.postCompactionTokens, + tokensRemoved: event.tokensRemoved, + }); + return; + case 'pending-messages-modified': + this.emitSessionEvent({ + sessionId, + kind: 'pending-messages-modified', + occurredAt, + agentId: event.agentId, + agentName: event.agentName, + }); + return; + } + } + private createPendingApprovalFromSidecarEvent(event: ApprovalRequestedEvent): PendingApprovalRecord { return { id: event.approvalId, diff --git a/src/main/ipc/registerIpcHandlers.ts b/src/main/ipc/registerIpcHandlers.ts index 218d6b5..6f15dcd 100644 --- a/src/main/ipc/registerIpcHandlers.ts +++ b/src/main/ipc/registerIpcHandlers.ts @@ -26,6 +26,7 @@ import type { UpdateSessionApprovalSettingsInput, UpdateSessionToolingInput, UpdateSessionModelConfigInput, + DeleteSessionInput, } from '@shared/contracts/ipc'; import type { QuerySessionsInput } from '@shared/domain/sessionLibrary'; import type { AppearanceTheme } from '@shared/domain/tooling'; @@ -106,8 +107,11 @@ export function registerIpcHandlers(window: BrowserWindow, service: AryxAppServi ipcMain.handle(ipcChannels.setSessionArchived, (_event, input: SetSessionArchivedInput) => service.setSessionArchived(input.sessionId, input.isArchived), ); + ipcMain.handle(ipcChannels.deleteSession, (_event, input: DeleteSessionInput) => + service.deleteSession(input.sessionId), + ); ipcMain.handle(ipcChannels.sendSessionMessage, (_event, input: SendSessionMessageInput) => - service.sendSessionMessage(input.sessionId, input.content), + service.sendSessionMessage(input.sessionId, input.content, input.attachments, input.messageMode), ); ipcMain.handle(ipcChannels.cancelSessionTurn, (_event, input: CancelSessionTurnInput) => service.cancelSessionTurn(input.sessionId), diff --git a/src/main/sidecar/runTurnPending.ts b/src/main/sidecar/runTurnPending.ts index 5caae73..a8ec574 100644 --- a/src/main/sidecar/runTurnPending.ts +++ b/src/main/sidecar/runTurnPending.ts @@ -5,9 +5,23 @@ import type { McpOauthRequiredEvent, TurnDeltaEvent, UserInputRequestedEvent, + SubagentEvent, + SkillInvokedEvent, + HookLifecycleEvent, + SessionUsageEvent, + SessionCompactionEvent, + PendingMessagesModifiedEvent, } from '@shared/contracts/sidecar'; import type { ChatMessageRecord } from '@shared/domain/session'; +export type TurnScopedEvent = + | SubagentEvent + | SkillInvokedEvent + | HookLifecycleEvent + | SessionUsageEvent + | SessionCompactionEvent + | PendingMessagesModifiedEvent; + export interface RunTurnPendingCommand { kind: 'run-turn'; resolve: (messages: ChatMessageRecord[]) => void; @@ -18,6 +32,7 @@ export interface RunTurnPendingCommand { onUserInput: (event: UserInputRequestedEvent) => void | Promise; onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise; onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise; + onTurnScopedEvent: (event: TurnScopedEvent) => void | Promise; errored: boolean; } diff --git a/src/main/sidecar/sidecarProcess.ts b/src/main/sidecar/sidecarProcess.ts index be795b7..680d6a0 100644 --- a/src/main/sidecar/sidecarProcess.ts +++ b/src/main/sidecar/sidecarProcess.ts @@ -14,6 +14,8 @@ import type { ExitPlanModeRequestedEvent, ValidatePatternCommand, RunTurnCommand, + CopilotSessionListFilter, + CopilotSessionInfo, } from '@shared/contracts/sidecar'; import type { ApprovalDecision } from '@shared/domain/approval'; import type { ChatMessageRecord } from '@shared/domain/session'; @@ -22,6 +24,7 @@ import { markRunTurnPendingErrored, shouldHandleRunTurnEvent, type RunTurnPendingCommand, + type TurnScopedEvent, } from '@main/sidecar/runTurnPending'; import { TurnCancelledError } from '@main/sidecar/turnCancelledError'; import { resolveSidecarProcess } from '@main/sidecar/sidecarRuntime'; @@ -59,6 +62,24 @@ type PendingCommand = resolve: () => void; reject: (error: Error) => void; }) + | ({ + processId: number; + kind: 'list-sessions'; + resolve: (sessions: CopilotSessionInfo[]) => void; + reject: (error: Error) => void; + }) + | ({ + processId: number; + kind: 'delete-session'; + resolve: (sessions: CopilotSessionInfo[]) => void; + reject: (error: Error) => void; + }) + | ({ + processId: number; + kind: 'disconnect-session'; + resolve: () => void; + reject: (error: Error) => void; + }) | ({ processId: number; } & RunTurnPendingCommand); @@ -106,8 +127,9 @@ export class SidecarClient { onUserInput: (event: UserInputRequestedEvent) => void | Promise, onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise, onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise, + onTurnScopedEvent: (event: TurnScopedEvent) => void | Promise, ): Promise { - return this.dispatch(command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode); + return this.dispatch(command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, onTurnScopedEvent); } async resolveUserInput(userInputId: string, answer: string, wasFreeform: boolean): Promise { @@ -138,6 +160,31 @@ export class SidecarClient { } satisfies CancelTurnCommand); } + async listSessions(filter?: CopilotSessionListFilter): Promise { + return this.dispatch({ + type: 'list-sessions', + requestId: `list-sessions-${Date.now()}`, + filter, + }); + } + + async deleteSession(sessionId?: string, copilotSessionId?: string): Promise { + return this.dispatch({ + type: 'delete-session', + requestId: `delete-session-${Date.now()}`, + sessionId, + copilotSessionId, + }); + } + + async disconnectSession(sessionId: string): Promise { + return this.dispatch({ + type: 'disconnect-session', + requestId: `disconnect-session-${Date.now()}`, + sessionId, + }); + } + async dispose(): Promise { const state = this.processState; if (!state) { @@ -225,6 +272,7 @@ export class SidecarClient { onUserInput?: (event: UserInputRequestedEvent) => void | Promise, onMcpOAuthRequired?: (event: McpOauthRequiredEvent) => void | Promise, onExitPlanMode?: (event: ExitPlanModeRequestedEvent) => void | Promise, + onTurnScopedEvent?: (event: TurnScopedEvent) => void | Promise, ): Promise { const state = await this.ensureProcess(); @@ -241,6 +289,7 @@ export class SidecarClient { onUserInput: onUserInput ?? (() => undefined), onMcpOAuthRequired: onMcpOAuthRequired ?? (() => undefined), onExitPlanMode: onExitPlanMode ?? (() => undefined), + onTurnScopedEvent: onTurnScopedEvent ?? (() => undefined), errored: false, }); } else if (command.type === 'validate-pattern') { @@ -271,6 +320,27 @@ export class SidecarClient { resolve: resolve as () => void, reject, }); + } else if (command.type === 'list-sessions') { + this.pending.set(command.requestId, { + processId: state.id, + kind: 'list-sessions', + resolve: resolve as (sessions: CopilotSessionInfo[]) => void, + reject, + }); + } else if (command.type === 'delete-session') { + this.pending.set(command.requestId, { + processId: state.id, + kind: 'delete-session', + resolve: resolve as (sessions: CopilotSessionInfo[]) => void, + reject, + }); + } else if (command.type === 'disconnect-session') { + this.pending.set(command.requestId, { + processId: state.id, + kind: 'disconnect-session', + resolve: resolve as () => void, + reject, + }); } else { this.pending.set(command.requestId, { processId: state.id, @@ -348,6 +418,34 @@ export class SidecarClient { this.invokeRunTurnHandler(event.requestId, pending, () => pending.onExitPlanMode(event)); } return; + case 'subagent-event': + case 'skill-invoked': + case 'hook-lifecycle': + case 'session-usage': + case 'session-compaction': + case 'pending-messages-modified': + if (pending.kind === 'run-turn' && shouldHandleRunTurnEvent(pending)) { + this.invokeRunTurnHandler(event.requestId, pending, () => pending.onTurnScopedEvent(event)); + } + return; + case 'sessions-listed': + if (pending.kind === 'list-sessions') { + pending.resolve(event.sessions); + this.pending.delete(event.requestId); + } + return; + case 'sessions-deleted': + if (pending.kind === 'delete-session') { + pending.resolve(event.sessions); + this.pending.delete(event.requestId); + } + return; + case 'session-disconnected': + if (pending.kind === 'disconnect-session') { + pending.resolve(); + this.pending.delete(event.requestId); + } + return; case 'turn-complete': if (pending.kind === 'run-turn') { if (shouldHandleRunTurnEvent(pending)) { diff --git a/src/preload/index.ts b/src/preload/index.ts index 3f66352..8a3d50f 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -33,6 +33,7 @@ const api: ElectronApi = { renameSession: (input) => ipcRenderer.invoke(ipcChannels.renameSession, input), setSessionPinned: (input) => ipcRenderer.invoke(ipcChannels.setSessionPinned, input), setSessionArchived: (input) => ipcRenderer.invoke(ipcChannels.setSessionArchived, input), + deleteSession: (input) => ipcRenderer.invoke(ipcChannels.deleteSession, input), sendSessionMessage: (input) => ipcRenderer.invoke(ipcChannels.sendSessionMessage, input), cancelSessionTurn: (input) => ipcRenderer.invoke(ipcChannels.cancelSessionTurn, input), resolveSessionApproval: (input) => ipcRenderer.invoke(ipcChannels.resolveSessionApproval, input), diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 7247755..6911365 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -10,8 +10,14 @@ import { Sidebar } from '@renderer/components/Sidebar'; import { resolveChatToolingSettings } from '@renderer/lib/chatTooling'; import { applySessionEventActivity, + applySessionUsageEvent, + applyTurnEventLog, pruneSessionActivities, + pruneSessionUsage, + pruneTurnEventLogs, type SessionActivityMap, + type SessionUsageMap, + type TurnEventLogMap, } from '@renderer/lib/sessionActivity'; import { applySessionEventWorkspace } from '@renderer/lib/sessionWorkspace'; import { WelcomePane } from '@renderer/components/WelcomePane'; @@ -91,6 +97,8 @@ export default function App() { const [error, setError] = useState(); const { capabilities: sidecarCapabilities, isRefreshing: isRefreshingCapabilities, refresh: refreshCapabilities } = useSidecarCapabilities(api); const [sessionActivities, setSessionActivities] = useState({}); + const [sessionUsage, setSessionUsage] = useState({}); + const [turnEventLogs, setTurnEventLogs] = useState({}); const [showSettings, setShowSettings] = useState(false); const [newSessionProjectId, setNewSessionProjectId] = useState(); @@ -114,11 +122,25 @@ export default function App() { ws.sessions.map((session) => session.id), ), ); + setSessionUsage((current) => + pruneSessionUsage( + current, + ws.sessions.map((session) => session.id), + ), + ); + setTurnEventLogs((current) => + pruneTurnEventLogs( + current, + ws.sessions.map((session) => session.id), + ), + ); }); const offSessionEvent = api.onSessionEvent((event) => { setWorkspace((current) => applySessionEventWorkspace(current, event)); setSessionActivities((current) => applySessionEventActivity(current, event)); + setSessionUsage((current) => applySessionUsageEvent(current, event)); + setTurnEventLogs((current) => applyTurnEventLog(current, event)); }); return () => { @@ -169,6 +191,14 @@ export default function App() { () => (selectedSession ? sessionActivities[selectedSession.id] : undefined), [selectedSession, sessionActivities], ); + const usageForSession = useMemo( + () => (selectedSession ? sessionUsage[selectedSession.id] : undefined), + [selectedSession, sessionUsage], + ); + const turnEventsForSession = useMemo( + () => (selectedSession ? turnEventLogs[selectedSession.id] : undefined), + [selectedSession, turnEventLogs], + ); const hasUserProjects = useMemo( () => (workspace?.projects.some((project) => !isScratchpadProject(project)) ?? false), [workspace?.projects], @@ -245,7 +275,12 @@ export default function App() { } else if (selectedSession && patternForSession && projectForSession) { content = ( api.sendSessionMessage({ sessionId: selectedSession.id, content: c })} + onSend={(c, attachments, messageMode) => api.sendSessionMessage({ + sessionId: selectedSession.id, + content: c, + attachments: attachments?.length ? attachments : undefined, + messageMode, + })} onCancelTurn={() => { void api.cancelSessionTurn({ sessionId: selectedSession.id }); }} onResolveApproval={(approvalId, decision, alwaysApprove) => api.resolveSessionApproval({ sessionId: selectedSession.id, approvalId, decision, alwaysApprove }) @@ -290,6 +325,7 @@ export default function App() { project={projectForSession} runtimeTools={sidecarCapabilities?.runtimeTools} session={selectedSession} + sessionUsage={usageForSession} toolingSettings={chatToolingSettings ?? workspace.settings.tooling} /> ); @@ -299,6 +335,7 @@ export default function App() { onJumpToMessage={jumpToMessage} pattern={patternForSession} session={selectedSession} + turnEvents={turnEventsForSession} /> ); } else { @@ -404,6 +441,9 @@ export default function App() { onSetSessionArchived={(sessionId, isArchived) => { void api.setSessionArchived({ sessionId, isArchived }); }} + onDeleteSession={(sessionId) => { + void api.deleteSession({ sessionId }); + }} onRefreshGitContext={(projectId) => { void api.refreshProjectGitContext(projectId); }} diff --git a/src/renderer/components/ActivityPanel.tsx b/src/renderer/components/ActivityPanel.tsx index f036820..fc39071 100644 --- a/src/renderer/components/ActivityPanel.tsx +++ b/src/renderer/components/ActivityPanel.tsx @@ -1,5 +1,5 @@ import { useMemo, type ReactNode } from 'react'; -import { Activity, Clock, ShieldAlert, Sparkles, Users } from 'lucide-react'; +import { Activity, ArrowRight, CheckCircle2, Clock, Cog, ShieldAlert, Sparkles, Users, Zap } from 'lucide-react'; import { buildAgentActivityRows, @@ -8,6 +8,7 @@ import { isAgentActivityCompleted, type AgentActivityRow, type SessionActivityState, + type TurnEventLog, } from '@renderer/lib/sessionActivity'; import { RunTimeline } from '@renderer/components/RunTimeline'; import { inferProvider } from '@shared/domain/models'; @@ -145,6 +146,35 @@ function AgentRow({ ); } +/* ── Turn event helpers ─────────────────────────────────────── */ + +import type { SessionEventKind } from '@shared/domain/event'; + +function TurnEventIcon({ kind, phase, success }: { kind: SessionEventKind; phase?: string; success?: boolean }) { + const base = 'size-3'; + switch (kind) { + case 'subagent': + return ; + case 'hook-lifecycle': + return ; + case 'skill-invoked': + return ; + case 'session-compaction': + return ; + default: + return ; + } +} + +function formatTurnEventTimestamp(iso: string): string { + try { + const d = new Date(iso); + return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' }); + } catch { + return ''; + } +} + /* ── ActivityPanel ─────────────────────────────────────────── */ interface ActivityPanelProps { @@ -152,6 +182,7 @@ interface ActivityPanelProps { onJumpToMessage?: (messageId: string) => void; pattern: PatternDefinition; session: SessionRecord; + turnEvents?: TurnEventLog; } export function ActivityPanel({ @@ -159,6 +190,7 @@ export function ActivityPanel({ onJumpToMessage, pattern, session, + turnEvents, }: ActivityPanelProps) { const activityRows = useMemo( () => buildAgentActivityRows(activity, pattern.agents), @@ -239,6 +271,39 @@ export function ActivityPanel({ + {/* ── Turn events section ─────────────────────────── */} + {turnEvents && turnEvents.length > 0 && ( +
+ + + Events + + {turnEvents.length} + + + +
+ {turnEvents.slice().reverse().map((entry, index) => ( +
+
+ +
+
+
+ {entry.label} + + {formatTurnEventTimestamp(entry.occurredAt)} + +
+ {entry.detail && ( +

{entry.detail}

+ )} +
+
+ ))} +
+
+ )} diff --git a/src/renderer/components/ChatPane.tsx b/src/renderer/components/ChatPane.tsx index 6d1a319..2d67568 100644 --- a/src/renderer/components/ChatPane.tsx +++ b/src/renderer/components/ChatPane.tsx @@ -1,5 +1,5 @@ import { useEffect, useMemo, useRef, useState } from 'react'; -import { AlertCircle, ArrowUp, Bot, Circle, ClipboardList, GitBranch, Loader2, MessageCircleQuestion, ShieldAlert, Square, User } from 'lucide-react'; +import { AlertCircle, ArrowUp, Bot, Circle, ClipboardList, GitBranch, Loader2, MessageCircleQuestion, Paperclip, ShieldAlert, Square, User, X } from 'lucide-react'; import { MarkdownContent } from '@renderer/components/MarkdownContent'; import { MarkdownComposer, type MarkdownComposerHandle } from '@renderer/components/MarkdownComposer'; @@ -11,7 +11,10 @@ import { InlineApprovalPill, InlineModelPill, InlineThinkingPill, InlineToolsPil import { ThinkingDots } from '@renderer/components/chat/ThinkingDots'; import { getAssistantMessagePhase } from '@renderer/lib/messagePhase'; import type { ApprovalDecision } from '@shared/domain/approval'; -import type { InteractionMode } from '@shared/contracts/sidecar'; +import type { InteractionMode, MessageMode } from '@shared/contracts/sidecar'; +import type { ChatMessageAttachment } from '@shared/domain/attachment'; +import { getAttachmentDisplayName, isImageAttachment } from '@shared/domain/attachment'; +import type { SessionUsageState } from '@renderer/lib/sessionActivity'; import { findModel, getSupportedReasoningEfforts, @@ -37,7 +40,8 @@ interface ChatPaneProps { availableModels: ReadonlyArray; toolingSettings: WorkspaceToolingSettings; runtimeTools?: ReadonlyArray; - onSend: (content: string) => Promise; + sessionUsage?: SessionUsageState; + onSend: (content: string, attachments?: ChatMessageAttachment[], messageMode?: MessageMode) => Promise; onCancelTurn?: () => void; onResolveApproval?: (approvalId: string, decision: ApprovalDecision, alwaysApprove?: boolean) => Promise; onResolveUserInput?: (userInputId: string, answer: string, wasFreeform: boolean) => Promise; @@ -60,6 +64,7 @@ export function ChatPane({ availableModels, toolingSettings, runtimeTools, + sessionUsage, onSend, onCancelTurn, onResolveApproval, @@ -99,8 +104,9 @@ export function ChatPane({ const selectedModel = primaryAgent ? findModel(primaryAgent.model, availableModels) : undefined; const supportedEfforts = getSupportedReasoningEfforts(selectedModel); const sessionReasoningEffort = resolveReasoningEffort(selectedModel, primaryAgent?.reasoningEffort); - const isComposerDisabled = isSessionBusy || isUpdatingSessionModelConfig; + const isComposerDisabled = isUpdatingSessionModelConfig; const canSubmitInput = hasComposerContent && !isComposerDisabled; + const [pendingAttachments, setPendingAttachments] = useState([]); const toolSelection = useMemo(() => resolveSessionToolingSelection(session), [session]); const mcpServers = toolingSettings.mcpServers; @@ -140,7 +146,10 @@ export function ChatPane({ }, [session.id]); function handleComposerSubmit(content: string) { - void onSend(content); + const attachments = pendingAttachments.length > 0 ? [...pendingAttachments] : undefined; + const messageMode: MessageMode | undefined = isSessionBusy ? 'immediate' : undefined; + setPendingAttachments([]); + void onSend(content, attachments, messageMode); } function handleDismissPlan() { @@ -336,6 +345,29 @@ export function ChatPane({ : `rounded-xl border px-4 py-3 text-[14px] leading-relaxed text-zinc-200 ${assistantContainerClass}` } > + {/* Attachment thumbnails */} + {isUser && message.attachments && message.attachments.length > 0 && ( +
+ {message.attachments.map((att, attIdx) => + isImageAttachment(att) ? ( + {getAttachmentDisplayName(att)} + ) : ( +
+ + {getAttachmentDisplayName(att)} +
+ ), + )} +
+ )} {!isUser && message.pending ? (
{message.content} @@ -510,6 +542,29 @@ export function ChatPane({
)} + {/* Attachment preview */} + {pendingAttachments.length > 0 && ( +
+ {pendingAttachments.map((attachment, index) => ( +
+ + {getAttachmentDisplayName(attachment)} + +
+ ))} +
+ )} +
+ {/* Attachment picker */} + + {/* Plan mode toggle */} {onSetInteractionMode && !isSessionBusy && (
)} + {isSessionBusy && (hasComposerContent || pendingAttachments.length > 0) && ( +
+
+ + Steering — your message will be injected into the current turn + +
+ )}
+ + {/* Session usage bar */} + {sessionUsage && sessionUsage.tokenLimit > 0 && ( +
+
+
+
0.9 + ? 'bg-red-500' + : sessionUsage.currentTokens / sessionUsage.tokenLimit > 0.7 + ? 'bg-amber-500' + : 'bg-indigo-500/60' + }`} + style={{ width: `${Math.min(100, (sessionUsage.currentTokens / sessionUsage.tokenLimit) * 100)}%` }} + /> +
+ + {Math.round((sessionUsage.currentTokens / sessionUsage.tokenLimit) * 100)}% context + +
+
+ )}
diff --git a/src/renderer/components/Sidebar.tsx b/src/renderer/components/Sidebar.tsx index 354a212..47c1c1d 100644 --- a/src/renderer/components/Sidebar.tsx +++ b/src/renderer/components/Sidebar.tsx @@ -21,6 +21,7 @@ import { RefreshCw, Search, Settings, + Trash2, Users, X, type LucideIcon, @@ -45,6 +46,7 @@ interface SidebarProps { onDuplicateSession: (sessionId: string) => void; onSetSessionPinned: (sessionId: string, isPinned: boolean) => void; onSetSessionArchived: (sessionId: string, isArchived: boolean) => void; + onDeleteSession: (sessionId: string) => void; onRefreshGitContext: (projectId: string) => void; } @@ -128,14 +130,16 @@ function ActionMenuItem({ icon: Icon, label, onClick, + className, }: { icon: LucideIcon; label: string; onClick: () => void; + className?: string; }) { return (