feat: full Copilot SDK feature parity — custom agents, hooks, image input, skills, steering, session persistence

Backend (sidecar):
- Extended ProtocolModels with DTOs for custom agents, hooks, skills,
  infinite sessions, session lifecycle, and 9 new event types
- Added CopilotManagedSessionIds for stable SDK session ID mapping
- Added CopilotSessionManager/ICopilotSessionManager for session lifecycle
- Added CopilotSessionHooks for hook registration
- Added CopilotMessageOptionsMetadata for mid-turn steering
- Extended CopilotAgentBundle to wire custom agents, hooks, skills,
  infinite sessions, and stable session IDs
- Extended CopilotTurnExecutionState to project 13 new SDK event types
- Widened ITurnWorkflowRunner callback to accept SidecarEventDto
- Added list/delete/disconnect session commands to SidecarProtocolHost
- Added AryxCopilotAgentMessageOptionsTests (14 new tests, 142 total)

Frontend (renderer + main + shared):
- Added ChatMessageAttachment type and helpers (attachment.ts)
- Extended sidecar contracts with MessageMode, 3 new command types,
  9 new event types, and agent/session config DTOs
- Extended SessionEventRecord with 6 new event kinds and ~20 fields
- Added PatternAgentCopilotConfig to pattern domain
- Added attachments support to ChatMessageRecord
- Updated sidecar client with session lifecycle methods and
  turn-scoped event routing via onTurnScopedEvent callback
- Updated main process: handleTurnScopedEvent(), deleteSession(),
  steering bypass for mid-turn messages, attachment passthrough
- Added deleteSession IPC handler and preload binding
- Added TurnEventLog state tracker with format/apply/prune helpers
- ChatPane: always-enabled composer, steering indicator, attachment
  picker with preview, image thumbnails in message history,
  context-usage bar, amber steer mode for send button
- ActivityPanel: turn events section with sub-agent, hook, skill,
  and compaction event rendering
- Sidebar: delete session action in context menu
- App.tsx: wired sessionUsage, turnEventLogs, and deleteSession

Documentation:
- AGENTS.md: added glob safety rule for node_modules
- README.md: added steering, image input, and richer observability
- ARCHITECTURE.md: added turn-scoped events, steering, and attachments
- Website: added steering and image input feature cards, updated
  live visibility and session cards

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
David Kaya
2026-03-28 12:28:20 +01:00
co-authored by Copilot
parent 0c2973c599
commit f1fa52f9c3
40 changed files with 2515 additions and 92 deletions
+1
View File
@@ -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
+20
View File
@@ -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.
+14 -4
View File
@@ -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
@@ -10,6 +10,16 @@ public sealed class PatternAgentDefinitionDto
public string Instructions { get; init; } = string.Empty;
public string Model { get; init; } = string.Empty;
public string? ReasoningEffort { get; init; }
public PatternAgentCopilotConfigDto? Copilot { get; init; }
}
public sealed class PatternAgentCopilotConfigDto
{
public IReadOnlyList<RunTurnCustomAgentConfigDto> CustomAgents { get; init; } = [];
public string? Agent { get; init; }
public IReadOnlyList<string> SkillDirectories { get; init; } = [];
public IReadOnlyList<string> DisabledSkills { get; init; } = [];
public RunTurnInfiniteSessionsConfigDto? InfiniteSessions { get; init; }
}
public sealed class PatternGraphPositionDto
@@ -75,6 +85,16 @@ public sealed class ChatMessageDto
public string AuthorName { get; init; } = string.Empty;
public string Content { get; init; } = string.Empty;
public string CreatedAt { get; init; } = string.Empty;
public IReadOnlyList<ChatMessageAttachmentDto> Attachments { get; init; } = [];
}
public sealed class ChatMessageAttachmentDto
{
public string Type { get; init; } = string.Empty;
public string? Path { get; init; }
public string? Data { get; init; }
public string? MimeType { get; init; }
public string? DisplayName { get; init; }
}
public sealed class PatternValidationIssueDto
@@ -162,6 +182,7 @@ public sealed class RunTurnCommandDto : SidecarCommandEnvelope
public string ProjectPath { get; init; } = string.Empty;
public string WorkspaceKind { get; init; } = "project";
public string Mode { get; init; } = "interactive";
public string MessageMode { get; init; } = "enqueue";
public PatternDefinitionDto Pattern { get; init; } = new();
public IReadOnlyList<ChatMessageDto> Messages { get; init; } = [];
public RunTurnToolingConfigDto? Tooling { get; init; }
@@ -186,6 +207,22 @@ public sealed class ResolveUserInputCommandDto : SidecarCommandEnvelope
public bool WasFreeform { get; init; }
}
public sealed class ListSessionsCommandDto : SidecarCommandEnvelope
{
public CopilotSessionListFilterDto? Filter { get; init; }
}
public sealed class DeleteSessionCommandDto : SidecarCommandEnvelope
{
public string? SessionId { get; init; }
public string? CopilotSessionId { get; init; }
}
public sealed class DisconnectSessionCommandDto : SidecarCommandEnvelope
{
public string SessionId { get; init; } = string.Empty;
}
public sealed class RunTurnToolingConfigDto
{
public IReadOnlyList<RunTurnMcpServerConfigDto> McpServers { get; init; } = [];
@@ -217,6 +254,48 @@ public sealed class RunTurnLspProfileConfigDto
public IReadOnlyList<string> FileExtensions { get; init; } = [];
}
public sealed class RunTurnCustomAgentConfigDto
{
public string Name { get; init; } = string.Empty;
public string? DisplayName { get; init; }
public string? Description { get; init; }
public IReadOnlyList<string>? Tools { get; init; }
public string Prompt { get; init; } = string.Empty;
public IReadOnlyList<RunTurnMcpServerConfigDto> McpServers { get; init; } = [];
public bool? Infer { get; init; }
}
public sealed class RunTurnInfiniteSessionsConfigDto
{
public bool? Enabled { get; init; }
public double? BackgroundCompactionThreshold { get; init; }
public double? BufferExhaustionThreshold { get; init; }
}
public sealed class CopilotSessionListFilterDto
{
public string? Cwd { get; init; }
public string? GitRoot { get; init; }
public string? Repository { get; init; }
public string? Branch { get; init; }
}
public sealed class CopilotSessionInfoDto
{
public string CopilotSessionId { get; init; } = string.Empty;
public bool ManagedByAryx { get; init; }
public string? SessionId { get; init; }
public string? AgentId { get; init; }
public string StartTime { get; init; } = string.Empty;
public string ModifiedTime { get; init; } = string.Empty;
public string? Summary { get; init; }
public bool IsRemote { get; init; }
public string? Cwd { get; init; }
public string? GitRoot { get; init; }
public string? Repository { get; init; }
public string? Branch { get; init; }
}
public abstract class SidecarEventDto
{
public string Type { get; init; } = string.Empty;
@@ -260,6 +339,111 @@ public sealed class AgentActivityEventDto : SidecarEventDto
public string? ToolName { get; init; }
}
public sealed class SubagentEventDto : SidecarEventDto
{
public string SessionId { get; init; } = string.Empty;
public string EventKind { get; init; } = string.Empty;
public string? AgentId { get; init; }
public string? AgentName { get; init; }
public string? ToolCallId { get; init; }
public string? CustomAgentName { get; init; }
public string? CustomAgentDisplayName { get; init; }
public string? CustomAgentDescription { get; init; }
public string? Error { get; init; }
public string? Model { get; init; }
public double? TotalToolCalls { get; init; }
public double? TotalTokens { get; init; }
public double? DurationMs { get; init; }
public IReadOnlyList<string>? Tools { get; init; }
}
public sealed class SkillInvokedEventDto : SidecarEventDto
{
public string SessionId { get; init; } = string.Empty;
public string? AgentId { get; init; }
public string? AgentName { get; init; }
public string SkillName { get; init; } = string.Empty;
public string Path { get; init; } = string.Empty;
public string Content { get; init; } = string.Empty;
public IReadOnlyList<string>? AllowedTools { get; init; }
public string? PluginName { get; init; }
public string? PluginVersion { get; init; }
public string? Description { get; init; }
}
public sealed class HookLifecycleEventDto : SidecarEventDto
{
public string SessionId { get; init; } = string.Empty;
public string? AgentId { get; init; }
public string? AgentName { get; init; }
public string HookInvocationId { get; init; } = string.Empty;
public string HookType { get; init; } = string.Empty;
public string Phase { get; init; } = string.Empty;
public bool? Success { get; init; }
public object? Input { get; init; }
public object? Output { get; init; }
public string? Error { get; init; }
}
public sealed class SessionUsageEventDto : SidecarEventDto
{
public string SessionId { get; init; } = string.Empty;
public string? AgentId { get; init; }
public string? AgentName { get; init; }
public double TokenLimit { get; init; }
public double CurrentTokens { get; init; }
public double MessagesLength { get; init; }
public double? SystemTokens { get; init; }
public double? ConversationTokens { get; init; }
public double? ToolDefinitionsTokens { get; init; }
public bool? IsInitial { get; init; }
}
public sealed class SessionCompactionEventDto : SidecarEventDto
{
public string SessionId { get; init; } = string.Empty;
public string? AgentId { get; init; }
public string? AgentName { get; init; }
public string Phase { get; init; } = string.Empty;
public bool? Success { get; init; }
public string? Error { get; init; }
public double? SystemTokens { get; init; }
public double? ConversationTokens { get; init; }
public double? ToolDefinitionsTokens { get; init; }
public double? PreCompactionTokens { get; init; }
public double? PostCompactionTokens { get; init; }
public double? PreCompactionMessagesLength { get; init; }
public double? MessagesRemoved { get; init; }
public double? TokensRemoved { get; init; }
public string? SummaryContent { get; init; }
public double? CheckpointNumber { get; init; }
public string? CheckpointPath { get; init; }
}
public sealed class PendingMessagesModifiedEventDto : SidecarEventDto
{
public string SessionId { get; init; } = string.Empty;
public string? AgentId { get; init; }
public string? AgentName { get; init; }
}
public sealed class SessionsListedEventDto : SidecarEventDto
{
public IReadOnlyList<CopilotSessionInfoDto> Sessions { get; init; } = [];
}
public sealed class SessionsDeletedEventDto : SidecarEventDto
{
public string? SessionId { get; init; }
public IReadOnlyList<CopilotSessionInfoDto> Sessions { get; init; } = [];
}
public sealed class SessionDisconnectedEventDto : SidecarEventDto
{
public string SessionId { get; init; } = string.Empty;
public IReadOnlyList<string> CancelledRequestIds { get; init; } = [];
}
public sealed class PermissionDetailDto
{
public string Kind { get; init; } = string.Empty;
@@ -3,6 +3,7 @@ using System.Runtime.CompilerServices;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading.Channels;
using Aryx.AgentHost.Contracts;
using GitHub.Copilot.SDK;
using Microsoft.Agents.AI;
using Microsoft.Extensions.AI;
@@ -140,11 +141,16 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
try
{
string prompt = string.Join("\n", messages.Select(message => message.Text));
(List<UserMessageDataAttachmentsItem>? attachments, tempDir) = await ProcessDataContentAttachmentsAsync(
(List<UserMessageDataAttachmentsItem>? attachments, string? messageMode, tempDir) = await ProcessMessageAttachmentsAsync(
messages,
cancellationToken).ConfigureAwait(false);
MessageOptions messageOptions = new() { Prompt = prompt };
MessageOptions messageOptions = new()
{
Prompt = prompt,
Mode = string.IsNullOrWhiteSpace(messageMode) ? null : messageMode,
};
if (attachments is not null)
{
messageOptions.Attachments = [.. attachments];
@@ -474,36 +480,103 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
return JsonSerializer.Deserialize<Dictionary<string, object?>>(json);
}
private static async Task<(List<UserMessageDataAttachmentsItem>? Attachments, string? TempDir)> ProcessDataContentAttachmentsAsync(
internal static async Task<(List<UserMessageDataAttachmentsItem>? Attachments, string? MessageMode, string? TempDir)> ProcessMessageAttachmentsAsync(
IEnumerable<ChatMessage> messages,
CancellationToken cancellationToken)
{
List<UserMessageDataAttachmentsItem>? attachments = null;
string? messageMode = null;
string? tempDir = null;
foreach (ChatMessage message in messages)
{
foreach (AIContent content in message.Contents)
{
if (content is not DataContent dataContent)
if (content is DataContent dataContent)
{
tempDir ??= Directory.CreateDirectory(
Path.Combine(Path.GetTempPath(), $"af_copilot_{Guid.NewGuid():N}")).FullName;
string tempFilePath = await dataContent.SaveToAsync(tempDir, cancellationToken).ConfigureAwait(false);
attachments ??= [];
attachments.Add(new UserMessageDataAttachmentsItemFile
{
Path = tempFilePath,
DisplayName = Path.GetFileName(tempFilePath),
});
continue;
}
tempDir ??= Directory.CreateDirectory(
Path.Combine(Path.GetTempPath(), $"af_copilot_{Guid.NewGuid():N}")).FullName;
string tempFilePath = await dataContent.SaveToAsync(tempDir, cancellationToken).ConfigureAwait(false);
attachments ??= [];
attachments.Add(new UserMessageDataAttachmentsItemFile
if (content.RawRepresentation is ChatMessageAttachmentDto protocolAttachment)
{
Path = tempFilePath,
DisplayName = Path.GetFileName(tempFilePath),
});
attachments ??= [];
attachments.Add(CreateProtocolAttachment(protocolAttachment));
continue;
}
if (content.RawRepresentation is CopilotMessageOptionsMetadata metadata
&& !string.IsNullOrWhiteSpace(metadata.MessageMode))
{
messageMode = metadata.MessageMode.Trim();
}
}
}
return (attachments, tempDir);
return (attachments, messageMode, tempDir);
}
private static UserMessageDataAttachmentsItem CreateProtocolAttachment(ChatMessageAttachmentDto attachment)
{
ArgumentNullException.ThrowIfNull(attachment);
return attachment.Type switch
{
"file" => CreateFileAttachment(attachment),
"blob" => CreateBlobAttachment(attachment),
_ => throw new NotSupportedException($"Unsupported attachment type '{attachment.Type}'."),
};
}
private static UserMessageDataAttachmentsItemFile CreateFileAttachment(ChatMessageAttachmentDto attachment)
{
if (string.IsNullOrWhiteSpace(attachment.Path))
{
throw new InvalidOperationException("File attachments require an absolute path.");
}
string path = attachment.Path.Trim();
if (!Path.IsPathRooted(path))
{
throw new InvalidOperationException($"File attachment path '{path}' must be absolute.");
}
return new UserMessageDataAttachmentsItemFile
{
Path = path,
DisplayName = string.IsNullOrWhiteSpace(attachment.DisplayName)
? Path.GetFileName(path)
: attachment.DisplayName.Trim(),
};
}
private static UserMessageDataAttachmentsItemBlob CreateBlobAttachment(ChatMessageAttachmentDto attachment)
{
if (string.IsNullOrWhiteSpace(attachment.Data))
{
throw new InvalidOperationException("Blob attachments require base64-encoded data.");
}
if (string.IsNullOrWhiteSpace(attachment.MimeType))
{
throw new InvalidOperationException("Blob attachments require a MIME type.");
}
return new UserMessageDataAttachmentsItemBlob
{
Data = attachment.Data.Trim(),
MimeType = attachment.MimeType.Trim(),
DisplayName = string.IsNullOrWhiteSpace(attachment.DisplayName) ? null : attachment.DisplayName.Trim(),
};
}
private static void CleanupTempDir(string? tempDir)
@@ -47,6 +47,7 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
SessionConfig sessionConfig = new()
{
SessionId = CopilotManagedSessionIds.Build(command.SessionId, definition.Id),
Model = definition.Model,
ReasoningEffort = definition.ReasoningEffort,
SystemMessage = new SystemMessageConfig
@@ -61,8 +62,14 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
WorkingDirectory = command.ProjectPath,
OnPermissionRequest = (request, invocation) => onPermissionRequest(definition, request, invocation),
OnUserInputRequest = (request, invocation) => onUserInputRequest(definition, request, invocation),
Hooks = CopilotSessionHooks.Create(command, definition),
OnEvent = evt => onSessionEvent?.Invoke(definition, evt),
Streaming = true,
CustomAgents = CreateCustomAgents(definition.Copilot?.CustomAgents),
Agent = NormalizeOptionalString(definition.Copilot?.Agent),
SkillDirectories = CreateStringList(definition.Copilot?.SkillDirectories),
DisabledSkills = CreateStringList(definition.Copilot?.DisabledSkills),
InfiniteSessions = CreateInfiniteSessions(definition.Copilot?.InfiniteSessions),
};
ApplySessionTooling(sessionConfig, toolingBundle?.McpServers, toolingBundle?.Tools);
@@ -100,6 +107,55 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
}
}
internal static List<CustomAgentConfig>? CreateCustomAgents(
IReadOnlyList<RunTurnCustomAgentConfigDto>? customAgents)
{
if (customAgents is not { Count: > 0 })
{
return null;
}
return customAgents.Select(customAgent => new CustomAgentConfig
{
Name = customAgent.Name,
DisplayName = NormalizeOptionalString(customAgent.DisplayName),
Description = NormalizeOptionalString(customAgent.Description),
Tools = customAgent.Tools is null ? null : [.. customAgent.Tools],
Prompt = customAgent.Prompt,
McpServers = customAgent.McpServers.Count == 0
? null
: SessionToolingBundle.BuildMcpServerConfigurations(customAgent.McpServers),
Infer = customAgent.Infer,
}).ToList();
}
internal static InfiniteSessionConfig? CreateInfiniteSessions(RunTurnInfiniteSessionsConfigDto? config)
{
if (config is null)
{
return null;
}
return new InfiniteSessionConfig
{
Enabled = config.Enabled,
BackgroundCompactionThreshold = config.BackgroundCompactionThreshold,
BufferExhaustionThreshold = config.BufferExhaustionThreshold,
};
}
private static List<string>? CreateStringList(IReadOnlyList<string>? values)
{
return values is { Count: > 0 }
? [.. values]
: null;
}
private static string? NormalizeOptionalString(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
public Workflow BuildWorkflow(PatternDefinitionDto pattern)
{
return pattern.Mode switch
@@ -0,0 +1,50 @@
namespace Aryx.AgentHost.Services;
internal static class CopilotManagedSessionIds
{
private const string Prefix = "aryx::";
private const string Separator = "::";
public static string Build(string aryxSessionId, string agentId)
{
ArgumentException.ThrowIfNullOrWhiteSpace(aryxSessionId);
ArgumentException.ThrowIfNullOrWhiteSpace(agentId);
return $"{Prefix}{Uri.EscapeDataString(aryxSessionId)}{Separator}{Uri.EscapeDataString(agentId)}";
}
public static bool IsManagedByAryx(string copilotSessionId)
=> TryParse(copilotSessionId, out _, out _);
public static bool IsManagedByAryx(string copilotSessionId, string aryxSessionId)
{
return TryParse(copilotSessionId, out string? parsedSessionId, out _)
&& string.Equals(parsedSessionId, aryxSessionId, StringComparison.Ordinal);
}
public static bool TryParse(string? copilotSessionId, out string aryxSessionId, out string agentId)
{
aryxSessionId = string.Empty;
agentId = string.Empty;
if (string.IsNullOrWhiteSpace(copilotSessionId)
|| !copilotSessionId.StartsWith(Prefix, StringComparison.Ordinal))
{
return false;
}
string payload = copilotSessionId[Prefix.Length..];
string[] parts = payload.Split(Separator, StringSplitOptions.None);
if (parts.Length != 2
|| string.IsNullOrWhiteSpace(parts[0])
|| string.IsNullOrWhiteSpace(parts[1]))
{
return false;
}
aryxSessionId = Uri.UnescapeDataString(parts[0]);
agentId = Uri.UnescapeDataString(parts[1]);
return true;
}
}
@@ -0,0 +1,3 @@
namespace Aryx.AgentHost.Services;
internal sealed record CopilotMessageOptionsMetadata(string MessageMode);
@@ -0,0 +1,47 @@
using Aryx.AgentHost.Contracts;
using GitHub.Copilot.SDK;
namespace Aryx.AgentHost.Services;
internal static class CopilotSessionHooks
{
private const string AllowDecision = "allow";
private const string AskDecision = "ask";
public static SessionHooks Create(RunTurnCommandDto command, PatternAgentDefinitionDto agentDefinition)
{
ArgumentNullException.ThrowIfNull(command);
ArgumentNullException.ThrowIfNull(agentDefinition);
return new SessionHooks
{
OnPreToolUse = (input, _) => Task.FromResult<PreToolUseHookOutput?>(
CreatePreToolUseOutput(command, agentDefinition, input)),
OnPostToolUse = static (_, _) => Task.FromResult<PostToolUseHookOutput?>(null),
OnUserPromptSubmitted = static (_, _) => Task.FromResult<UserPromptSubmittedHookOutput?>(null),
OnSessionStart = static (_, _) => Task.FromResult<SessionStartHookOutput?>(null),
OnSessionEnd = static (_, _) => Task.FromResult<SessionEndHookOutput?>(null),
OnErrorOccurred = static (_, _) => Task.FromResult<ErrorOccurredHookOutput?>(null),
};
}
private static PreToolUseHookOutput CreatePreToolUseOutput(
RunTurnCommandDto command,
PatternAgentDefinitionDto agentDefinition,
PreToolUseHookInput input)
{
bool requiresApproval = CopilotApprovalCoordinator.RequiresToolCallApproval(
command.Pattern.ApprovalPolicy,
agentDefinition.Id,
Normalize(input.ToolName),
Normalize(input.ToolName));
return new PreToolUseHookOutput
{
PermissionDecision = requiresApproval ? AskDecision : AllowDecision,
};
}
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
@@ -0,0 +1,125 @@
using Aryx.AgentHost.Contracts;
using GitHub.Copilot.SDK;
namespace Aryx.AgentHost.Services;
internal sealed class CopilotSessionManager : ICopilotSessionManager
{
public async Task<IReadOnlyList<CopilotSessionInfoDto>> ListSessionsAsync(
CopilotSessionListFilterDto? filter,
CancellationToken cancellationToken)
{
await using CopilotClient client = await CreateStartedClientAsync(cancellationToken).ConfigureAwait(false);
List<SessionMetadata> sessions = await client.ListSessionsAsync(CreateFilter(filter), cancellationToken)
.ConfigureAwait(false);
return sessions
.Select(MapSession)
.OrderByDescending(session => session.ModifiedTime, StringComparer.Ordinal)
.ToList();
}
public async Task<IReadOnlyList<CopilotSessionInfoDto>> DeleteSessionsAsync(
string? aryxSessionId,
string? copilotSessionId,
CancellationToken cancellationToken)
{
string? normalizedAryxSessionId = Normalize(aryxSessionId);
string? normalizedCopilotSessionId = Normalize(copilotSessionId);
if (normalizedAryxSessionId is null && normalizedCopilotSessionId is null)
{
throw new InvalidOperationException("delete-session requires a sessionId or copilotSessionId.");
}
await using CopilotClient client = await CreateStartedClientAsync(cancellationToken).ConfigureAwait(false);
List<SessionMetadata> sessions = await client.ListSessionsAsync(null, cancellationToken).ConfigureAwait(false);
List<CopilotSessionInfoDto> targets = sessions
.Select(MapSession)
.Where(session =>
(normalizedCopilotSessionId is not null
&& string.Equals(session.CopilotSessionId, normalizedCopilotSessionId, StringComparison.Ordinal))
|| (normalizedAryxSessionId is not null
&& string.Equals(session.SessionId, normalizedAryxSessionId, StringComparison.Ordinal)
&& session.ManagedByAryx))
.ToList();
if (targets.Count == 0 && normalizedCopilotSessionId is not null)
{
targets.Add(CreateUnknownSessionInfo(normalizedCopilotSessionId));
}
foreach (CopilotSessionInfoDto target in targets)
{
await client.DeleteSessionAsync(target.CopilotSessionId, cancellationToken).ConfigureAwait(false);
}
return targets;
}
private static async Task<CopilotClient> CreateStartedClientAsync(CancellationToken cancellationToken)
{
CopilotClient client = new(CopilotCliPathResolver.CreateClientOptions());
await client.StartAsync(cancellationToken).ConfigureAwait(false);
return client;
}
private static SessionListFilter? CreateFilter(CopilotSessionListFilterDto? filter)
{
if (filter is null)
{
return null;
}
return new SessionListFilter
{
Cwd = Normalize(filter.Cwd),
GitRoot = Normalize(filter.GitRoot),
Repository = Normalize(filter.Repository),
Branch = Normalize(filter.Branch),
};
}
private static CopilotSessionInfoDto MapSession(SessionMetadata session)
{
bool managedByAryx = CopilotManagedSessionIds.TryParse(
session.SessionId,
out string aryxSessionId,
out string agentId);
return new CopilotSessionInfoDto
{
CopilotSessionId = session.SessionId,
ManagedByAryx = managedByAryx,
SessionId = managedByAryx ? aryxSessionId : null,
AgentId = managedByAryx ? agentId : null,
StartTime = session.StartTime.ToUniversalTime().ToString("O"),
ModifiedTime = session.ModifiedTime.ToUniversalTime().ToString("O"),
Summary = Normalize(session.Summary),
IsRemote = session.IsRemote,
Cwd = Normalize(session.Context?.Cwd),
GitRoot = Normalize(session.Context?.GitRoot),
Repository = Normalize(session.Context?.Repository),
Branch = Normalize(session.Context?.Branch),
};
}
private static CopilotSessionInfoDto CreateUnknownSessionInfo(string copilotSessionId)
{
bool managedByAryx = CopilotManagedSessionIds.TryParse(
copilotSessionId,
out string aryxSessionId,
out string agentId);
return new CopilotSessionInfoDto
{
CopilotSessionId = copilotSessionId,
ManagedByAryx = managedByAryx,
SessionId = managedByAryx ? aryxSessionId : null,
AgentId = managedByAryx ? agentId : null,
};
}
private static string? Normalize(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
@@ -9,7 +9,7 @@ internal sealed class CopilotTurnExecutionState
{
private readonly RunTurnCommandDto _command;
private readonly HashSet<string> _startedAgents = new(StringComparer.OrdinalIgnoreCase);
private readonly ConcurrentQueue<AgentActivityEventDto> _pendingActivityEvents = new();
private readonly ConcurrentQueue<SidecarEventDto> _pendingEvents = new();
private readonly ConcurrentQueue<McpOauthRequiredEventDto> _pendingMcpOauthRequests = new();
private readonly ConcurrentDictionary<string, AgentIdentity> _observedAgentsByMessageId = new(StringComparer.Ordinal);
private readonly StreamingTranscriptBuffer _transcriptBuffer = new();
@@ -30,7 +30,7 @@ internal sealed class CopilotTurnExecutionState
public async Task EmitThinkingIfNeeded(
AgentIdentity agent,
Func<AgentActivityEventDto, Task> onActivity)
Func<SidecarEventDto, Task> onEvent)
{
AgentActivityEventDto? thinkingActivity = CreateThinkingActivityIfNeeded(agent);
if (thinkingActivity is null)
@@ -38,7 +38,7 @@ internal sealed class CopilotTurnExecutionState
return;
}
await onActivity(thinkingActivity).ConfigureAwait(false);
await onEvent(thinkingActivity).ConfigureAwait(false);
}
public void QueueThinkingIfNeeded(AgentIdentity agent)
@@ -46,13 +46,14 @@ internal sealed class CopilotTurnExecutionState
AgentActivityEventDto? thinkingActivity = CreateThinkingActivityIfNeeded(agent);
if (thinkingActivity is not null)
{
_pendingActivityEvents.Enqueue(thinkingActivity);
_pendingEvents.Enqueue(thinkingActivity);
}
}
public void ApplyActivity(AgentActivityEventDto activity)
public void ApplyEvent(SidecarEventDto evt)
{
if (string.Equals(activity.ActivityType, "handoff", StringComparison.Ordinal)
if (evt is AgentActivityEventDto activity
&& string.Equals(activity.ActivityType, "handoff", StringComparison.Ordinal)
&& !string.IsNullOrWhiteSpace(activity.AgentId)
&& !string.IsNullOrWhiteSpace(activity.AgentName))
{
@@ -86,6 +87,54 @@ internal sealed class CopilotTurnExecutionState
ActiveAgent = agent;
QueueThinkingIfNeeded(agent);
break;
case SubagentStartedEvent started:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateSubagentEvent(agent, "started", started.Data));
break;
case SubagentCompletedEvent completed:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateSubagentCompletedEvent(agent, completed.Data));
break;
case SubagentFailedEvent failed:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateSubagentFailedEvent(agent, failed.Data));
break;
case SubagentSelectedEvent selected:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateSubagentSelectedEvent(agent, selected.Data));
break;
case SubagentDeselectedEvent:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateSubagentDeselectedEvent(agent));
break;
case SkillInvokedEvent skillInvoked:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateSkillInvokedEvent(agent, skillInvoked.Data));
break;
case HookStartEvent hookStart:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateHookLifecycleEvent(agent, "start", hookStart.Data));
break;
case HookEndEvent hookEnd:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateHookLifecycleEvent(agent, "end", hookEnd.Data));
break;
case SessionUsageInfoEvent usageInfo:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateUsageEvent(agent, usageInfo.Data));
break;
case SessionCompactionStartEvent compactionStart:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateCompactionStartEvent(agent, compactionStart.Data));
break;
case SessionCompactionCompleteEvent compactionComplete:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateCompactionCompleteEvent(agent, compactionComplete.Data));
break;
case PendingMessagesModifiedEvent:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreatePendingMessagesModifiedEvent(agent));
break;
case McpOauthRequiredEvent:
ActiveAgent = agent;
break;
@@ -96,12 +145,12 @@ internal sealed class CopilotTurnExecutionState
}
}
public IReadOnlyList<AgentActivityEventDto> DrainPendingActivityEvents()
public IReadOnlyList<SidecarEventDto> DrainPendingEvents()
{
List<AgentActivityEventDto> pending = [];
while (_pendingActivityEvents.TryDequeue(out AgentActivityEventDto? activity))
List<SidecarEventDto> pending = [];
while (_pendingEvents.TryDequeue(out SidecarEventDto? pendingEvent))
{
pending.Add(activity);
pending.Add(pendingEvent);
}
return pending;
@@ -204,4 +253,229 @@ internal sealed class CopilotTurnExecutionState
return CompletedMessages;
}
private SubagentEventDto CreateSubagentEvent(
AgentIdentity agent,
string eventKind,
SubagentStartedData? data)
{
return new SubagentEventDto
{
Type = "subagent-event",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
EventKind = eventKind,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
ToolCallId = data?.ToolCallId,
CustomAgentName = data?.AgentName,
CustomAgentDisplayName = data?.AgentDisplayName,
CustomAgentDescription = data?.AgentDescription,
};
}
private SubagentEventDto CreateSubagentCompletedEvent(
AgentIdentity agent,
SubagentCompletedData? data)
{
return new SubagentEventDto
{
Type = "subagent-event",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
EventKind = "completed",
AgentId = agent.AgentId,
AgentName = agent.AgentName,
ToolCallId = data?.ToolCallId,
CustomAgentName = data?.AgentName,
CustomAgentDisplayName = data?.AgentDisplayName,
};
}
private SubagentEventDto CreateSubagentFailedEvent(
AgentIdentity agent,
SubagentFailedData? data)
{
return new SubagentEventDto
{
Type = "subagent-event",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
EventKind = "failed",
AgentId = agent.AgentId,
AgentName = agent.AgentName,
ToolCallId = data?.ToolCallId,
CustomAgentName = data?.AgentName,
CustomAgentDisplayName = data?.AgentDisplayName,
Error = data?.Error,
};
}
private SubagentEventDto CreateSubagentSelectedEvent(
AgentIdentity agent,
SubagentSelectedData? data)
{
return new SubagentEventDto
{
Type = "subagent-event",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
EventKind = "selected",
AgentId = agent.AgentId,
AgentName = agent.AgentName,
CustomAgentName = data?.AgentName,
CustomAgentDisplayName = data?.AgentDisplayName,
Tools = data?.Tools,
};
}
private SubagentEventDto CreateSubagentDeselectedEvent(AgentIdentity agent)
{
return new SubagentEventDto
{
Type = "subagent-event",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
EventKind = "deselected",
AgentId = agent.AgentId,
AgentName = agent.AgentName,
};
}
private SkillInvokedEventDto CreateSkillInvokedEvent(
AgentIdentity agent,
SkillInvokedData? data)
{
return new SkillInvokedEventDto
{
Type = "skill-invoked",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
SkillName = data?.Name ?? string.Empty,
Path = data?.Path ?? string.Empty,
Content = data?.Content ?? string.Empty,
AllowedTools = data?.AllowedTools,
PluginName = data?.PluginName,
PluginVersion = data?.PluginVersion,
};
}
private HookLifecycleEventDto CreateHookLifecycleEvent(
AgentIdentity agent,
string phase,
HookStartData? data)
{
return new HookLifecycleEventDto
{
Type = "hook-lifecycle",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
HookInvocationId = data?.HookInvocationId ?? string.Empty,
HookType = data?.HookType ?? string.Empty,
Phase = phase,
Input = data?.Input,
};
}
private HookLifecycleEventDto CreateHookLifecycleEvent(
AgentIdentity agent,
string phase,
HookEndData? data)
{
return new HookLifecycleEventDto
{
Type = "hook-lifecycle",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
HookInvocationId = data?.HookInvocationId ?? string.Empty,
HookType = data?.HookType ?? string.Empty,
Phase = phase,
Success = data?.Success,
Output = data?.Output,
Error = data?.Error?.Message,
};
}
private SessionUsageEventDto CreateUsageEvent(AgentIdentity agent, SessionUsageInfoData? data)
{
return new SessionUsageEventDto
{
Type = "session-usage",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
TokenLimit = data?.TokenLimit ?? 0,
CurrentTokens = data?.CurrentTokens ?? 0,
MessagesLength = data?.MessagesLength ?? 0,
SystemTokens = data?.SystemTokens,
ConversationTokens = data?.ConversationTokens,
ToolDefinitionsTokens = data?.ToolDefinitionsTokens,
IsInitial = data?.IsInitial,
};
}
private SessionCompactionEventDto CreateCompactionStartEvent(
AgentIdentity agent,
SessionCompactionStartData? data)
{
return new SessionCompactionEventDto
{
Type = "session-compaction",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
Phase = "start",
SystemTokens = data?.SystemTokens,
ConversationTokens = data?.ConversationTokens,
ToolDefinitionsTokens = data?.ToolDefinitionsTokens,
};
}
private SessionCompactionEventDto CreateCompactionCompleteEvent(
AgentIdentity agent,
SessionCompactionCompleteData? data)
{
return new SessionCompactionEventDto
{
Type = "session-compaction",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
Phase = "complete",
Success = data?.Success,
Error = data?.Error,
SystemTokens = data?.SystemTokens,
ConversationTokens = data?.ConversationTokens,
ToolDefinitionsTokens = data?.ToolDefinitionsTokens,
PreCompactionTokens = data?.PreCompactionTokens,
PostCompactionTokens = data?.PostCompactionTokens,
PreCompactionMessagesLength = data?.PreCompactionMessagesLength,
MessagesRemoved = data?.MessagesRemoved,
TokensRemoved = data?.TokensRemoved,
SummaryContent = data?.SummaryContent,
CheckpointNumber = data?.CheckpointNumber,
CheckpointPath = data?.CheckpointPath,
};
}
private PendingMessagesModifiedEventDto CreatePendingMessagesModifiedEvent(AgentIdentity agent)
{
return new PendingMessagesModifiedEventDto
{
Type = "pending-messages-modified",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
};
}
}
@@ -23,7 +23,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
public async Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
RunTurnCommandDto command,
Func<TurnDeltaEventDto, Task> onDelta,
Func<AgentActivityEventDto, Task> onActivity,
Func<SidecarEventDto, Task> onEvent,
Func<ApprovalRequestedEventDto, Task> onApproval,
Func<UserInputRequestedEventDto, Task> onUserInput,
Func<McpOauthRequiredEventDto, Task> onMcpOAuthRequired,
@@ -77,15 +77,16 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
runCancellation.Token);
Workflow workflow = bundle.BuildWorkflow(command.Pattern);
List<ChatMessage> inputMessages = command.Messages.Select(WorkflowTranscriptProjector.ToChatMessage).ToList();
WorkflowTranscriptProjector.AttachMessageMode(inputMessages, command.MessageMode);
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, inputMessages).ConfigureAwait(false);
await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false);
await foreach (WorkflowEvent evt in run.WatchStreamAsync(runCancellation.Token).ConfigureAwait(false))
{
bool shouldEndTurn = await HandleWorkflowEventAsync(command, evt, inputMessages, state, onDelta, onActivity)
bool shouldEndTurn = await HandleWorkflowEventAsync(command, evt, inputMessages, state, onDelta, onEvent)
.ConfigureAwait(false);
await EmitPendingActivityEventsAsync(state, onActivity).ConfigureAwait(false);
await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false);
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
if (shouldEndTurn)
{
@@ -93,13 +94,13 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
}
}
await EmitPendingActivityEventsAsync(state, onActivity).ConfigureAwait(false);
await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false);
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
return state.FinalizeCompletedMessages();
}
catch (OperationCanceledException) when (runCancellation.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
{
await EmitPendingActivityEventsAsync(state, onActivity).ConfigureAwait(false);
await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false);
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
ExitPlanModeRequestedEventDto? exitPlanModeEvent =
_exitPlanModeCoordinator.ConsumePendingRequest(command.RequestId);
@@ -117,13 +118,13 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
}
}
private static async Task EmitPendingActivityEventsAsync(
private static async Task EmitPendingEventsAsync(
CopilotTurnExecutionState state,
Func<AgentActivityEventDto, Task> onActivity)
Func<SidecarEventDto, Task> onEvent)
{
foreach (AgentActivityEventDto activity in state.DrainPendingActivityEvents())
foreach (SidecarEventDto pendingEvent in state.DrainPendingEvents())
{
await onActivity(activity).ConfigureAwait(false);
await onEvent(pendingEvent).ConfigureAwait(false);
}
}
@@ -157,7 +158,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
IReadOnlyList<ChatMessage> inputMessages,
CopilotTurnExecutionState state,
Func<TurnDeltaEventDto, Task> onDelta,
Func<AgentActivityEventDto, Task> onActivity)
Func<SidecarEventDto, Task> onEvent)
{
if (evt is ExecutorInvokedEvent invoked)
{
@@ -167,7 +168,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
out AgentIdentity invokedAgent))
{
TraceHandoff(command, $"Executor invoked: {invoked.ExecutorId} -> {invokedAgent.AgentName} ({invokedAgent.AgentId}).");
await state.EmitThinkingIfNeeded(invokedAgent, onActivity).ConfigureAwait(false);
await state.EmitThinkingIfNeeded(invokedAgent, onEvent).ConfigureAwait(false);
}
else
{
@@ -194,13 +195,13 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
return requiresBoundary;
}
await EmitActivityAsync(command, state, activity, onActivity).ConfigureAwait(false);
await EmitActivityAsync(command, state, activity, onEvent).ConfigureAwait(false);
return false;
}
if (evt is AgentResponseUpdateEvent update)
{
await HandleAgentResponseUpdateAsync(command, update, state, onDelta, onActivity).ConfigureAwait(false);
await HandleAgentResponseUpdateAsync(command, update, state, onDelta, onEvent).ConfigureAwait(false);
return false;
}
@@ -237,7 +238,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
AgentResponseUpdateEvent update,
CopilotTurnExecutionState state,
Func<TurnDeltaEventDto, Task> onDelta,
Func<AgentActivityEventDto, Task> onActivity)
Func<SidecarEventDto, Task> onEvent)
{
AgentIdentity? updateAgent = null;
string authorName = update.ExecutorId;
@@ -271,7 +272,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
$"Agent response update from {updateAgent.Value.AgentName} ({updateAgent.Value.AgentId}) requested handoff via {string.Join(", ", handoffFunctionCalls)}.");
}
await state.EmitThinkingIfNeeded(updateAgent.Value, onActivity).ConfigureAwait(false);
await state.EmitThinkingIfNeeded(updateAgent.Value, onEvent).ConfigureAwait(false);
}
else if (!string.IsNullOrEmpty(update.Update.Text) || handoffFunctionCalls.Length > 0)
{
@@ -307,13 +308,13 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
RunTurnCommandDto command,
CopilotTurnExecutionState state,
AgentActivityEventDto activity,
Func<AgentActivityEventDto, Task> onActivity)
Func<SidecarEventDto, Task> onEvent)
{
state.ApplyActivity(activity);
state.ApplyEvent(activity);
TraceHandoff(
command,
$"Activity emitted: {activity.ActivityType} -> {activity.AgentName ?? activity.AgentId ?? "<unknown>"}.");
await onActivity(activity).ConfigureAwait(false);
await onEvent(activity).ConfigureAwait(false);
if (string.Equals(activity.ActivityType, "handoff", StringComparison.Ordinal)
&& !string.IsNullOrWhiteSpace(activity.AgentId)
@@ -324,7 +325,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
$"Promoting handoff target to thinking: {activity.AgentName} ({activity.AgentId}).");
await state.EmitThinkingIfNeeded(
new AgentIdentity(activity.AgentId, activity.AgentName),
onActivity).ConfigureAwait(false);
onEvent).ConfigureAwait(false);
}
}
@@ -0,0 +1,16 @@
using Aryx.AgentHost.Contracts;
namespace Aryx.AgentHost.Services;
public interface ICopilotSessionManager
{
Task<IReadOnlyList<CopilotSessionInfoDto>> ListSessionsAsync(
CopilotSessionListFilterDto? filter,
CancellationToken cancellationToken);
Task<IReadOnlyList<CopilotSessionInfoDto>> DeleteSessionsAsync(
string? aryxSessionId,
string? copilotSessionId,
CancellationToken cancellationToken);
}
@@ -7,7 +7,7 @@ public interface ITurnWorkflowRunner
Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
RunTurnCommandDto command,
Func<TurnDeltaEventDto, Task> onDelta,
Func<AgentActivityEventDto, Task> onActivity,
Func<SidecarEventDto, Task> onEvent,
Func<ApprovalRequestedEventDto, Task> onApproval,
Func<UserInputRequestedEventDto, Task> onUserInput,
Func<McpOauthRequiredEventDto, Task> onMcpOAuthRequired,
@@ -15,6 +15,9 @@ public sealed class SidecarProtocolHost
private const string CancelTurnCommandType = "cancel-turn";
private const string ResolveApprovalCommandType = "resolve-approval";
private const string ResolveUserInputCommandType = "resolve-user-input";
private const string ListSessionsCommandType = "list-sessions";
private const string DeleteSessionCommandType = "delete-session";
private const string DisconnectSessionCommandType = "disconnect-session";
private const string AskUserToolName = "ask_user";
private static readonly HashSet<string> ExcludedRuntimeToolNames = new(StringComparer.OrdinalIgnoreCase)
{
@@ -39,11 +42,14 @@ public sealed class SidecarProtocolHost
private readonly Func<CancellationToken, Task<SidecarCapabilitiesDto>> _capabilitiesProvider;
private readonly PatternValidator _patternValidator;
private readonly ITurnWorkflowRunner _workflowRunner;
private readonly ICopilotSessionManager _sessionManager;
private readonly JsonSerializerOptions _jsonOptions;
private readonly IReadOnlyDictionary<string, Func<CommandContext, Task>> _commandHandlers;
private readonly SemaphoreSlim _writeLock = new(1, 1);
private readonly ConcurrentDictionary<string, Task> _inFlight = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, CancellationTokenSource> _turnCancellations = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> _turnRequestIdsBySessionId =
new(StringComparer.Ordinal);
public SidecarProtocolHost()
: this(new PatternValidator())
@@ -53,11 +59,13 @@ public sealed class SidecarProtocolHost
public SidecarProtocolHost(
PatternValidator patternValidator,
ITurnWorkflowRunner? workflowRunner = null,
Func<CancellationToken, Task<SidecarCapabilitiesDto>>? capabilitiesProvider = null)
Func<CancellationToken, Task<SidecarCapabilitiesDto>>? capabilitiesProvider = null,
ICopilotSessionManager? sessionManager = null)
{
_patternValidator = patternValidator;
_workflowRunner = workflowRunner ?? new CopilotWorkflowRunner(_patternValidator);
_capabilitiesProvider = capabilitiesProvider ?? BuildCapabilitiesAsync;
_sessionManager = sessionManager ?? new CopilotSessionManager();
_jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
@@ -71,6 +79,9 @@ public sealed class SidecarProtocolHost
[CancelTurnCommandType] = HandleCancelTurnAsync,
[ResolveApprovalCommandType] = HandleResolveApprovalAsync,
[ResolveUserInputCommandType] = HandleResolveUserInputAsync,
[ListSessionsCommandType] = HandleListSessionsAsync,
[DeleteSessionCommandType] = HandleDeleteSessionAsync,
[DisconnectSessionCommandType] = HandleDisconnectSessionAsync,
};
}
@@ -180,12 +191,13 @@ public sealed class SidecarProtocolHost
$"A turn with request ID '{context.Envelope.RequestId}' is already in progress.");
}
RegisterTurnRequest(command.SessionId, context.Envelope.RequestId);
try
{
IReadOnlyList<ChatMessageDto> messages = await _workflowRunner.RunTurnAsync(
command,
delta => WriteAsync(context.Output, delta, turnCancellation.Token),
activity => WriteAsync(context.Output, activity, turnCancellation.Token),
evt => WriteAsync(context.Output, evt, turnCancellation.Token),
approval => WriteAsync(context.Output, approval, turnCancellation.Token),
userInput => WriteAsync(context.Output, userInput, turnCancellation.Token),
mcpOauth => WriteAsync(context.Output, mcpOauth, turnCancellation.Token),
@@ -216,6 +228,7 @@ public sealed class SidecarProtocolHost
finally
{
_turnCancellations.TryRemove(context.Envelope.RequestId, out _);
UnregisterTurnRequest(command.SessionId, context.Envelope.RequestId);
}
}
@@ -249,6 +262,57 @@ public sealed class SidecarProtocolHost
await _workflowRunner.ResolveUserInputAsync(command, context.CancellationToken).ConfigureAwait(false);
}
private async Task HandleListSessionsAsync(CommandContext context)
{
ListSessionsCommandDto command = DeserializeCommand<ListSessionsCommandDto>(context);
IReadOnlyList<CopilotSessionInfoDto> sessions = await _sessionManager.ListSessionsAsync(
command.Filter,
context.CancellationToken).ConfigureAwait(false);
await WriteAsync(context.Output, new SessionsListedEventDto
{
Type = "sessions-listed",
RequestId = context.Envelope.RequestId,
Sessions = sessions,
}, context.CancellationToken).ConfigureAwait(false);
}
private async Task HandleDeleteSessionAsync(CommandContext context)
{
DeleteSessionCommandDto command = DeserializeCommand<DeleteSessionCommandDto>(context);
if (!string.IsNullOrWhiteSpace(command.SessionId))
{
CancelTurnRequestsForSession(command.SessionId);
}
IReadOnlyList<CopilotSessionInfoDto> deletedSessions = await _sessionManager.DeleteSessionsAsync(
command.SessionId,
command.CopilotSessionId,
context.CancellationToken).ConfigureAwait(false);
await WriteAsync(context.Output, new SessionsDeletedEventDto
{
Type = "sessions-deleted",
RequestId = context.Envelope.RequestId,
SessionId = string.IsNullOrWhiteSpace(command.SessionId) ? null : command.SessionId.Trim(),
Sessions = deletedSessions,
}, context.CancellationToken).ConfigureAwait(false);
}
private async Task HandleDisconnectSessionAsync(CommandContext context)
{
DisconnectSessionCommandDto command = DeserializeCommand<DisconnectSessionCommandDto>(context);
IReadOnlyList<string> cancelledRequestIds = CancelTurnRequestsForSession(command.SessionId);
await WriteAsync(context.Output, new SessionDisconnectedEventDto
{
Type = "session-disconnected",
RequestId = context.Envelope.RequestId,
SessionId = command.SessionId,
CancelledRequestIds = cancelledRequestIds,
}, context.CancellationToken).ConfigureAwait(false);
}
private TCommand DeserializeCommand<TCommand>(CommandContext context)
where TCommand : SidecarCommandEnvelope
{
@@ -309,6 +373,67 @@ public sealed class SidecarProtocolHost
}
}
private void RegisterTurnRequest(string sessionId, string requestId)
{
if (string.IsNullOrWhiteSpace(sessionId) || string.IsNullOrWhiteSpace(requestId))
{
return;
}
ConcurrentDictionary<string, byte> requestIds = _turnRequestIdsBySessionId.GetOrAdd(
sessionId.Trim(),
static _ => new ConcurrentDictionary<string, byte>(StringComparer.Ordinal));
requestIds[requestId.Trim()] = 0;
}
private void UnregisterTurnRequest(string sessionId, string requestId)
{
if (string.IsNullOrWhiteSpace(sessionId) || string.IsNullOrWhiteSpace(requestId))
{
return;
}
if (!_turnRequestIdsBySessionId.TryGetValue(sessionId.Trim(), out ConcurrentDictionary<string, byte>? requestIds))
{
return;
}
requestIds.TryRemove(requestId.Trim(), out _);
if (requestIds.IsEmpty)
{
_turnRequestIdsBySessionId.TryRemove(sessionId.Trim(), out _);
}
}
private IReadOnlyList<string> CancelTurnRequestsForSession(string sessionId)
{
if (string.IsNullOrWhiteSpace(sessionId)
|| !_turnRequestIdsBySessionId.TryGetValue(sessionId.Trim(), out ConcurrentDictionary<string, byte>? requestIds))
{
return [];
}
List<string> cancelledRequestIds = [];
foreach (string requestId in requestIds.Keys)
{
if (!_turnCancellations.TryGetValue(requestId, out CancellationTokenSource? turnCancellation))
{
continue;
}
try
{
turnCancellation.Cancel();
cancelledRequestIds.Add(requestId);
}
catch (ObjectDisposedException)
{
}
}
return cancelledRequestIds;
}
private static async Task<SidecarCapabilitiesDto> BuildCapabilitiesAsync(CancellationToken cancellationToken)
{
try
@@ -26,9 +26,30 @@ internal static class WorkflowTranscriptProjector
mapped.AuthorName = message.AuthorName;
}
foreach (ChatMessageAttachmentDto attachment in message.Attachments)
{
mapped.Contents.Add(new AIContent
{
RawRepresentation = attachment,
});
}
return mapped;
}
public static void AttachMessageMode(IList<ChatMessage> messages, string? messageMode)
{
if (messages.Count == 0 || string.IsNullOrWhiteSpace(messageMode))
{
return;
}
messages[^1].Contents.Add(new AIContent
{
RawRepresentation = new CopilotMessageOptionsMetadata(messageMode.Trim()),
});
}
public static List<ChatMessageDto> ProjectCompletedMessages(
RunTurnCommandDto command,
IReadOnlyList<ChatMessage> newMessages,
@@ -0,0 +1,81 @@
using Aryx.AgentHost.Contracts;
using Aryx.AgentHost.Services;
using GitHub.Copilot.SDK;
using Microsoft.Extensions.AI;
namespace Aryx.AgentHost.Tests;
public sealed class AryxCopilotAgentMessageOptionsTests
{
[Fact]
public async Task ProcessMessageAttachmentsAsync_MapsProtocolAttachmentsAndMessageMode()
{
ChatMessage message = new(ChatRole.User, "Please inspect these images.");
message.Contents.Add(new AIContent
{
RawRepresentation = new ChatMessageAttachmentDto
{
Type = "file",
Path = @"C:\workspace\project\assets\diagram.png",
DisplayName = "diagram.png",
},
});
message.Contents.Add(new AIContent
{
RawRepresentation = new ChatMessageAttachmentDto
{
Type = "blob",
Data = "QUJDRA==",
MimeType = "image/png",
DisplayName = "clipboard.png",
},
});
message.Contents.Add(new AIContent
{
RawRepresentation = new CopilotMessageOptionsMetadata("immediate"),
});
(List<UserMessageDataAttachmentsItem>? attachments, string? messageMode, string? tempDir) =
await AryxCopilotAgent.ProcessMessageAttachmentsAsync([message], CancellationToken.None);
Assert.Equal("immediate", messageMode);
Assert.Null(tempDir);
Assert.NotNull(attachments);
Assert.Collection(
attachments!,
first =>
{
UserMessageDataAttachmentsItemFile file = Assert.IsType<UserMessageDataAttachmentsItemFile>(first);
Assert.Equal(@"C:\workspace\project\assets\diagram.png", file.Path);
Assert.Equal("diagram.png", file.DisplayName);
},
second =>
{
UserMessageDataAttachmentsItemBlob blob = Assert.IsType<UserMessageDataAttachmentsItemBlob>(second);
Assert.Equal("QUJDRA==", blob.Data);
Assert.Equal("image/png", blob.MimeType);
Assert.Equal("clipboard.png", blob.DisplayName);
});
}
[Fact]
public async Task ProcessMessageAttachmentsAsync_RejectsRelativeFileAttachments()
{
ChatMessage message = new(ChatRole.User, "Inspect this file.");
message.Contents.Add(new AIContent
{
RawRepresentation = new ChatMessageAttachmentDto
{
Type = "file",
Path = "relative\\image.png",
},
});
InvalidOperationException error = await Assert.ThrowsAsync<InvalidOperationException>(() =>
AryxCopilotAgent.ProcessMessageAttachmentsAsync([message], CancellationToken.None));
Assert.Contains("absolute", error.Message, StringComparison.OrdinalIgnoreCase);
}
}
@@ -1,5 +1,6 @@
using System.Reflection;
using System.Text.Json;
using Aryx.AgentHost.Contracts;
using GitHub.Copilot.SDK;
using Aryx.AgentHost.Services;
using Microsoft.Agents.AI;
@@ -143,6 +144,122 @@ public sealed class CopilotAgentBundleTests
Assert.Equal("handoff_to_reviewer", single.Name);
}
[Fact]
public void CreateCustomAgents_MapsSdkCustomAgentConfiguration()
{
List<CustomAgentConfig> customAgents = Assert.IsType<List<CustomAgentConfig>>(CopilotAgentBundle.CreateCustomAgents(
[
new RunTurnCustomAgentConfigDto
{
Name = "designer",
DisplayName = "Designer",
Description = "Design specialist",
Tools = ["view", "glob"],
Prompt = "Focus on UX design.",
Infer = true,
McpServers =
[
new RunTurnMcpServerConfigDto
{
Id = "designer-mcp",
Name = "Designer MCP",
Transport = "local",
Command = "node",
Args = ["designer.js"],
},
],
},
]));
CustomAgentConfig customAgent = Assert.Single(customAgents);
Assert.Equal("designer", customAgent.Name);
Assert.Equal("Designer", customAgent.DisplayName);
Assert.Equal("Design specialist", customAgent.Description);
Assert.Equal(["view", "glob"], customAgent.Tools);
Assert.Equal("Focus on UX design.", customAgent.Prompt);
Assert.True(customAgent.Infer);
KeyValuePair<string, object> mcpServer = Assert.Single(customAgent.McpServers!);
Assert.Equal("Designer MCP", mcpServer.Key);
McpLocalServerConfig localServer = Assert.IsType<McpLocalServerConfig>(mcpServer.Value);
Assert.Equal("node", localServer.Command);
Assert.Equal(["designer.js"], localServer.Args);
}
[Fact]
public void CreateInfiniteSessions_MapsSdkInfiniteSessionConfiguration()
{
InfiniteSessionConfig config = Assert.IsType<InfiniteSessionConfig>(CopilotAgentBundle.CreateInfiniteSessions(
new RunTurnInfiniteSessionsConfigDto
{
Enabled = true,
BackgroundCompactionThreshold = 0.75,
BufferExhaustionThreshold = 0.9,
}));
Assert.True(config.Enabled);
Assert.Equal(0.75, config.BackgroundCompactionThreshold);
Assert.Equal(0.9, config.BufferExhaustionThreshold);
}
[Fact]
public async Task CopilotSessionHooks_Create_UsesApprovalPolicyForPreToolUse()
{
RunTurnCommandDto command = new()
{
RequestId = "turn-1",
SessionId = "session-1",
Pattern = new PatternDefinitionDto
{
Id = "pattern-1",
Name = "Pattern",
Mode = "single",
Availability = "available",
ApprovalPolicy = new ApprovalPolicyDto
{
Rules =
[
new ApprovalCheckpointRuleDto
{
Kind = "tool-call",
AgentIds = ["agent-1"],
},
],
},
Agents =
[
new PatternAgentDefinitionDto
{
Id = "agent-1",
Name = "Primary",
Model = "gpt-5.4",
Instructions = "Help.",
},
],
},
};
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0]);
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
new PreToolUseHookInput
{
ToolName = "view",
},
null!);
Assert.Equal("ask", decision?.PermissionDecision);
}
[Fact]
public void CopilotManagedSessionIds_BuildsAndParsesStableIds()
{
string sessionId = CopilotManagedSessionIds.Build("session-1", "agent-ux");
Assert.True(CopilotManagedSessionIds.TryParse(sessionId, out string aryxSessionId, out string agentId));
Assert.Equal("session-1", aryxSessionId);
Assert.Equal("agent-ux", agentId);
}
private static AIFunction CreateTool()
{
ToolTarget target = new();
@@ -50,7 +50,7 @@ public sealed class CopilotTurnExecutionStateTests
}
"""));
AgentActivityEventDto activity = Assert.Single(state.DrainPendingActivityEvents());
AgentActivityEventDto activity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
Assert.Equal("thinking", activity.ActivityType);
Assert.Equal("agent-1", activity.AgentId);
Assert.Equal("Primary", activity.AgentName);
@@ -104,13 +104,13 @@ public sealed class CopilotTurnExecutionStateTests
}
"""));
List<AgentActivityEventDto> activities = [.. state.DrainPendingActivityEvents()];
List<AgentActivityEventDto> activities = [.. state.DrainPendingEvents().OfType<AgentActivityEventDto>()];
await state.EmitThinkingIfNeeded(
new AgentIdentity("agent-1", "Primary"),
activity =>
sidecarEvent =>
{
activities.Add(activity);
activities.Add(Assert.IsType<AgentActivityEventDto>(sidecarEvent));
return Task.CompletedTask;
});
@@ -144,6 +144,157 @@ public sealed class CopilotTurnExecutionStateTests
Assert.Empty(secondDrain);
}
[Fact]
public void ObserveSessionEvent_SubagentStarted_QueuesSubagentEvent()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Pattern.Agents[0],
SessionEvent.FromJson(
"""
{
"type": "subagent.started",
"data": {
"toolCallId": "tool-call-1",
"agentName": "designer",
"agentDisplayName": "Designer",
"agentDescription": "Design specialist"
},
"id": "44444444-4444-4444-4444-444444444444",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
SubagentEventDto evt = Assert.Single(state.DrainPendingEvents().OfType<SubagentEventDto>());
Assert.Equal("started", evt.EventKind);
Assert.Equal("tool-call-1", evt.ToolCallId);
Assert.Equal("designer", evt.CustomAgentName);
Assert.Equal("Designer", evt.CustomAgentDisplayName);
}
[Fact]
public void ObserveSessionEvent_SkillInvoked_QueuesSkillEvent()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Pattern.Agents[0],
SessionEvent.FromJson(
"""
{
"type": "skill.invoked",
"data": {
"name": "reviewer",
"path": "C:\\skills\\reviewer\\SKILL.md",
"content": "# Reviewer",
"allowedTools": ["view"],
"pluginName": "aryx-plugin",
"pluginVersion": "1.0.0"
},
"id": "55555555-5555-5555-5555-555555555555",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
SkillInvokedEventDto evt = Assert.Single(state.DrainPendingEvents().OfType<SkillInvokedEventDto>());
Assert.Equal("reviewer", evt.SkillName);
Assert.Equal(@"C:\skills\reviewer\SKILL.md", evt.Path);
Assert.Equal(["view"], evt.AllowedTools);
Assert.Equal("aryx-plugin", evt.PluginName);
}
[Fact]
public void ObserveSessionEvent_HookStart_QueuesHookLifecycleEvent()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Pattern.Agents[0],
SessionEvent.FromJson(
"""
{
"type": "hook.start",
"data": {
"hookInvocationId": "hook-1",
"hookType": "postToolUse",
"input": {
"toolName": "view"
}
},
"id": "66666666-6666-6666-6666-666666666666",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
HookLifecycleEventDto evt = Assert.Single(state.DrainPendingEvents().OfType<HookLifecycleEventDto>());
Assert.Equal("start", evt.Phase);
Assert.Equal("postToolUse", evt.HookType);
Assert.Equal("hook-1", evt.HookInvocationId);
Assert.NotNull(evt.Input);
}
[Fact]
public void ObserveSessionEvent_SessionCompactionComplete_QueuesCompactionEvent()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Pattern.Agents[0],
SessionEvent.FromJson(
"""
{
"type": "session.compaction_complete",
"data": {
"success": true,
"preCompactionTokens": 1000,
"postCompactionTokens": 400,
"messagesRemoved": 8,
"tokensRemoved": 600,
"summaryContent": "Compacted summary",
"checkpointNumber": 2,
"checkpointPath": "C:\\Users\\me\\.copilot\\session-state\\checkpoint-2.json"
},
"id": "77777777-7777-7777-7777-777777777777",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
SessionCompactionEventDto evt = Assert.Single(state.DrainPendingEvents().OfType<SessionCompactionEventDto>());
Assert.Equal("complete", evt.Phase);
Assert.True(evt.Success);
Assert.Equal(1000, evt.PreCompactionTokens);
Assert.Equal(400, evt.PostCompactionTokens);
Assert.Equal("Compacted summary", evt.SummaryContent);
}
[Fact]
public void ObserveSessionEvent_PendingMessagesModified_QueuesPendingMessageSignal()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Pattern.Agents[0],
SessionEvent.FromJson(
"""
{
"type": "pending_messages.modified",
"data": {},
"id": "88888888-8888-8888-8888-888888888888",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
PendingMessagesModifiedEventDto evt = Assert.Single(state.DrainPendingEvents().OfType<PendingMessagesModifiedEventDto>());
Assert.Equal("session-1", evt.SessionId);
Assert.Equal("agent-1", evt.AgentId);
}
private static RunTurnCommandDto CreateCommand()
{
return new RunTurnCommandDto
@@ -649,7 +649,7 @@ public sealed class CopilotWorkflowRunnerTests
Assert.Equal("agent-handoff-ux", observedAgent.AgentId);
Assert.Equal("UX Specialist", observedAgent.AgentName);
Assert.Equal("agent-handoff-ux", state.ActiveAgent?.AgentId);
AgentActivityEventDto activity = Assert.Single(state.DrainPendingActivityEvents());
AgentActivityEventDto activity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
Assert.Equal("thinking", activity.ActivityType);
Assert.Equal("agent-handoff-ux", activity.AgentId);
}
@@ -675,7 +675,7 @@ public sealed class CopilotWorkflowRunnerTests
Assert.Equal("agent-handoff-ux", state.ActiveAgent?.AgentId);
Assert.Equal("UX Specialist", state.ActiveAgent?.AgentName);
AgentActivityEventDto activity = Assert.Single(state.DrainPendingActivityEvents());
AgentActivityEventDto activity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
Assert.Equal("thinking", activity.ActivityType);
Assert.Equal("agent-handoff-ux", activity.AgentId);
}
@@ -699,7 +699,7 @@ public sealed class CopilotWorkflowRunnerTests
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
_ = state.DrainPendingActivityEvents();
_ = state.DrainPendingEvents();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
CreateHandoffTarget("agent-handoff-ux", "UX Specialist"));
List<AgentActivityEventDto> activities = [];
@@ -715,9 +715,9 @@ public sealed class CopilotWorkflowRunnerTests
Array.Empty<ChatMessage>(),
state,
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
(Func<AgentActivityEventDto, Task>)(activity =>
(Func<SidecarEventDto, Task>)(sidecarEvent =>
{
activities.Add(activity);
activities.Add(Assert.IsType<AgentActivityEventDto>(sidecarEvent));
return Task.CompletedTask;
}),
])!;
@@ -780,6 +780,109 @@ public sealed class SidecarProtocolHostTests
Assert.False(string.IsNullOrWhiteSpace(diagnostics.CheckedAt));
}
[Fact]
public async Task ListSessionsCommand_ReturnsSessionsListedEvent()
{
SidecarProtocolHost host = new(
new PatternValidator(),
sessionManager: new FakeSessionManager
{
Sessions =
[
new CopilotSessionInfoDto
{
CopilotSessionId = "aryx::session-1::agent-1",
ManagedByAryx = true,
SessionId = "session-1",
AgentId = "agent-1",
Summary = "Review session",
},
],
});
IReadOnlyList<JsonElement> events = await RunHostAsync(
new ListSessionsCommandDto
{
Type = "list-sessions",
RequestId = "list-1",
},
host);
JsonElement listedEvent = AssertSingleEvent(events, "sessions-listed", "list-1");
JsonElement session = Assert.Single(listedEvent.GetProperty("sessions").EnumerateArray());
Assert.Equal("aryx::session-1::agent-1", session.GetProperty("copilotSessionId").GetString());
Assert.Equal("session-1", session.GetProperty("sessionId").GetString());
}
[Fact]
public async Task DeleteSessionCommand_ReturnsDeletedSessionsEvent()
{
FakeSessionManager sessionManager = new()
{
DeletedSessions =
[
new CopilotSessionInfoDto
{
CopilotSessionId = "aryx::session-1::agent-1",
ManagedByAryx = true,
SessionId = "session-1",
AgentId = "agent-1",
},
],
};
SidecarProtocolHost host = new(
new PatternValidator(),
sessionManager: sessionManager);
IReadOnlyList<JsonElement> events = await RunHostAsync(
new DeleteSessionCommandDto
{
Type = "delete-session",
RequestId = "delete-1",
SessionId = "session-1",
},
host);
JsonElement deletedEvent = AssertSingleEvent(events, "sessions-deleted", "delete-1");
JsonElement session = Assert.Single(deletedEvent.GetProperty("sessions").EnumerateArray());
Assert.Equal("session-1", deletedEvent.GetProperty("sessionId").GetString());
Assert.Equal("aryx::session-1::agent-1", session.GetProperty("copilotSessionId").GetString());
Assert.Equal("session-1", sessionManager.DeletedAryxSessionId);
}
[Fact]
public async Task DisconnectSessionCommand_CancelsActiveTurnsForSession()
{
FakeWorkflowRunner runner = new(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
{
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
return [];
});
SidecarProtocolHost host = new(new PatternValidator(), runner);
IReadOnlyList<JsonElement> events = await RunHostAsync(
[
CreateRunTurnCommand(requestId: "turn-1", sessionId: "session-1"),
new DisconnectSessionCommandDto
{
Type = "disconnect-session",
RequestId = "disconnect-1",
SessionId = "session-1",
},
],
host);
JsonElement disconnectedEvent = AssertSingleEvent(events, "session-disconnected", "disconnect-1");
string[] cancelledRequestIds = disconnectedEvent.GetProperty("cancelledRequestIds")
.EnumerateArray()
.Select(value => value.GetString() ?? string.Empty)
.ToArray();
Assert.Equal(["turn-1"], cancelledRequestIds);
JsonElement turnComplete = AssertSingleEvent(events, "turn-complete", "turn-1");
Assert.True(turnComplete.GetProperty("cancelled").GetBoolean());
}
private static async Task<IReadOnlyList<JsonElement>> RunHostAsync(
object command,
SidecarProtocolHost? host = null)
@@ -949,7 +1052,7 @@ public sealed class SidecarProtocolHostTests
private readonly Func<
RunTurnCommandDto,
Func<TurnDeltaEventDto, Task>,
Func<AgentActivityEventDto, Task>,
Func<SidecarEventDto, Task>,
Func<ApprovalRequestedEventDto, Task>,
Func<UserInputRequestedEventDto, Task>,
Func<McpOauthRequiredEventDto, Task>,
@@ -963,7 +1066,7 @@ public sealed class SidecarProtocolHostTests
Func<
RunTurnCommandDto,
Func<TurnDeltaEventDto, Task>,
Func<AgentActivityEventDto, Task>,
Func<SidecarEventDto, Task>,
Func<ApprovalRequestedEventDto, Task>,
Func<UserInputRequestedEventDto, Task>,
Func<McpOauthRequiredEventDto, Task>,
@@ -981,7 +1084,7 @@ public sealed class SidecarProtocolHostTests
public Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
RunTurnCommandDto command,
Func<TurnDeltaEventDto, Task> onDelta,
Func<AgentActivityEventDto, Task> onActivity,
Func<SidecarEventDto, Task> onActivity,
Func<ApprovalRequestedEventDto, Task> onApproval,
Func<UserInputRequestedEventDto, Task> onUserInput,
Func<McpOauthRequiredEventDto, Task> onMcpOAuthRequired,
@@ -1005,4 +1108,32 @@ public sealed class SidecarProtocolHostTests
return _resolveUserInputHandler(command, cancellationToken);
}
}
private sealed class FakeSessionManager : ICopilotSessionManager
{
public IReadOnlyList<CopilotSessionInfoDto> Sessions { get; init; } = [];
public IReadOnlyList<CopilotSessionInfoDto> DeletedSessions { get; init; } = [];
public string? DeletedAryxSessionId { get; private set; }
public string? DeletedCopilotSessionId { get; private set; }
public Task<IReadOnlyList<CopilotSessionInfoDto>> ListSessionsAsync(
CopilotSessionListFilterDto? filter,
CancellationToken cancellationToken)
{
return Task.FromResult(Sessions);
}
public Task<IReadOnlyList<CopilotSessionInfoDto>> DeleteSessionsAsync(
string? aryxSessionId,
string? copilotSessionId,
CancellationToken cancellationToken)
{
DeletedAryxSessionId = aryxSessionId;
DeletedCopilotSessionId = copilotSessionId;
return Task.FromResult(DeletedSessions);
}
}
}
+122 -2
View File
@@ -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<AppServiceEvents> {
return this.persistAndBroadcast(workspace);
}
async sendSessionMessage(sessionId: string, content: string): Promise<void> {
async deleteSession(sessionId: string): Promise<WorkspaceState> {
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<void> {
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<AppServiceEvents> {
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<AppServiceEvents> {
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<AppServiceEvents> {
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<AppServiceEvents> {
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,
+5 -1
View File
@@ -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),
+15
View File
@@ -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<void>;
onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise<void>;
onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise<void>;
onTurnScopedEvent: (event: TurnScopedEvent) => void | Promise<void>;
errored: boolean;
}
+99 -1
View File
@@ -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<void>,
onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise<void>,
onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise<void>,
onTurnScopedEvent: (event: TurnScopedEvent) => void | Promise<void>,
): Promise<ChatMessageRecord[]> {
return this.dispatch<ChatMessageRecord[]>(command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode);
return this.dispatch<ChatMessageRecord[]>(command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, onTurnScopedEvent);
}
async resolveUserInput(userInputId: string, answer: string, wasFreeform: boolean): Promise<void> {
@@ -138,6 +160,31 @@ export class SidecarClient {
} satisfies CancelTurnCommand);
}
async listSessions(filter?: CopilotSessionListFilter): Promise<CopilotSessionInfo[]> {
return this.dispatch<CopilotSessionInfo[]>({
type: 'list-sessions',
requestId: `list-sessions-${Date.now()}`,
filter,
});
}
async deleteSession(sessionId?: string, copilotSessionId?: string): Promise<CopilotSessionInfo[]> {
return this.dispatch<CopilotSessionInfo[]>({
type: 'delete-session',
requestId: `delete-session-${Date.now()}`,
sessionId,
copilotSessionId,
});
}
async disconnectSession(sessionId: string): Promise<void> {
return this.dispatch<void>({
type: 'disconnect-session',
requestId: `disconnect-session-${Date.now()}`,
sessionId,
});
}
async dispose(): Promise<void> {
const state = this.processState;
if (!state) {
@@ -225,6 +272,7 @@ export class SidecarClient {
onUserInput?: (event: UserInputRequestedEvent) => void | Promise<void>,
onMcpOAuthRequired?: (event: McpOauthRequiredEvent) => void | Promise<void>,
onExitPlanMode?: (event: ExitPlanModeRequestedEvent) => void | Promise<void>,
onTurnScopedEvent?: (event: TurnScopedEvent) => void | Promise<void>,
): Promise<TResult> {
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)) {
+1
View File
@@ -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),
+41 -1
View File
@@ -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<string>();
const { capabilities: sidecarCapabilities, isRefreshing: isRefreshingCapabilities, refresh: refreshCapabilities } = useSidecarCapabilities(api);
const [sessionActivities, setSessionActivities] = useState<SessionActivityMap>({});
const [sessionUsage, setSessionUsage] = useState<SessionUsageMap>({});
const [turnEventLogs, setTurnEventLogs] = useState<TurnEventLogMap>({});
const [showSettings, setShowSettings] = useState(false);
const [newSessionProjectId, setNewSessionProjectId] = useState<string>();
@@ -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 = (
<ChatPane
onSend={(c) => 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);
}}
+66 -1
View File
@@ -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 <ArrowRight className={`${base} ${success === false ? 'text-red-400' : 'text-sky-400'}`} />;
case 'hook-lifecycle':
return <Cog className={`${base} ${phase === 'start' ? 'animate-spin text-amber-400' : success === false ? 'text-red-400' : 'text-emerald-400'}`} />;
case 'skill-invoked':
return <Sparkles className={`${base} text-violet-400`} />;
case 'session-compaction':
return <CheckCircle2 className={`${base} ${phase === 'start' ? 'animate-pulse text-amber-400' : 'text-emerald-400'}`} />;
default:
return <Zap className={`${base} text-zinc-500`} />;
}
}
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({
<RunTimeline onJumpToMessage={onJumpToMessage} runs={session.runs} />
</div>
{/* ── Turn events section ─────────────────────────── */}
{turnEvents && turnEvents.length > 0 && (
<div className="mb-4">
<SectionHeader>
<Zap className="size-3" />
<span>Events</span>
<span className="rounded-full bg-zinc-800 px-1.5 py-0.5 text-[9px] tabular-nums text-zinc-500">
{turnEvents.length}
</span>
</SectionHeader>
<div className="space-y-0.5 rounded-lg border border-zinc-800 bg-zinc-900/40 px-3 py-2">
{turnEvents.slice().reverse().map((entry, index) => (
<div key={index} className="flex items-start gap-2 py-1">
<div className="mt-0.5 shrink-0">
<TurnEventIcon kind={entry.kind} phase={entry.phase} success={entry.success} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-medium text-zinc-300">{entry.label}</span>
<span className="ml-auto shrink-0 text-[9px] tabular-nums text-zinc-700">
{formatTurnEventTimestamp(entry.occurredAt)}
</span>
</div>
{entry.detail && (
<p className="text-[10px] leading-snug text-zinc-600">{entry.detail}</p>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
+144 -16
View File
@@ -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<ModelDefinition>;
toolingSettings: WorkspaceToolingSettings;
runtimeTools?: ReadonlyArray<RuntimeToolDefinition>;
onSend: (content: string) => Promise<void>;
sessionUsage?: SessionUsageState;
onSend: (content: string, attachments?: ChatMessageAttachment[], messageMode?: MessageMode) => Promise<void>;
onCancelTurn?: () => void;
onResolveApproval?: (approvalId: string, decision: ApprovalDecision, alwaysApprove?: boolean) => Promise<unknown>;
onResolveUserInput?: (userInputId: string, answer: string, wasFreeform: boolean) => Promise<unknown>;
@@ -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<ChatMessageAttachment[]>([]);
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 && (
<div className="mb-2 flex flex-wrap gap-2">
{message.attachments.map((att, attIdx) =>
isImageAttachment(att) ? (
<img
key={attIdx}
alt={getAttachmentDisplayName(att)}
className="max-h-48 max-w-xs rounded-lg border border-zinc-700 object-cover"
src={`data:${att.mimeType};base64,${att.data}`}
/>
) : (
<div
key={attIdx}
className="flex items-center gap-1.5 rounded-lg border border-zinc-700 bg-zinc-800 px-2 py-1 text-[11px] text-zinc-400"
>
<Paperclip className="size-3" />
{getAttachmentDisplayName(att)}
</div>
),
)}
</div>
)}
{!isUser && message.pending ? (
<div className="whitespace-pre-wrap break-words text-[14px] leading-relaxed text-zinc-200">
{message.content}
@@ -510,6 +542,29 @@ export function ChatPane({
</div>
)}
{/* Attachment preview */}
{pendingAttachments.length > 0 && (
<div className="flex flex-wrap gap-2 px-1 pb-2">
{pendingAttachments.map((attachment, index) => (
<div
key={index}
className="flex items-center gap-1.5 rounded-lg border border-zinc-700 bg-zinc-800 px-2.5 py-1.5 text-[11px] text-zinc-300"
>
<Paperclip className="size-3 text-zinc-500" />
<span className="max-w-[160px] truncate">{getAttachmentDisplayName(attachment)}</span>
<button
aria-label="Remove attachment"
className="ml-1 rounded p-0.5 text-zinc-500 hover:bg-zinc-700 hover:text-zinc-300"
onClick={() => setPendingAttachments((prev) => prev.filter((_, i) => i !== index))}
type="button"
>
<X className="size-3" />
</button>
</div>
))}
</div>
)}
<div className="rounded-xl border border-zinc-700 bg-zinc-900 transition-colors focus-within:border-indigo-500/50">
<MarkdownComposer
ref={composerRef}
@@ -526,7 +581,7 @@ export function ChatPane({
: pendingMcpAuth
? 'MCP server requires authentication...'
: isSessionBusy
? 'Waiting for response...'
? 'Steer the agent (sends immediately)...'
: isUpdatingSessionModelConfig
? 'Saving model settings...'
: isPlanMode
@@ -535,6 +590,38 @@ export function ChatPane({
}
>
<div className="absolute bottom-2 right-2 flex items-center gap-1">
{/* Attachment picker */}
<button
aria-label="Attach image"
className="flex size-8 items-center justify-center rounded-lg text-zinc-500 transition hover:bg-zinc-800 hover:text-zinc-300"
disabled={isComposerDisabled}
onClick={() => {
const input = document.createElement('input');
input.type = 'file';
input.accept = 'image/jpeg,image/png,image/gif,image/webp';
input.multiple = true;
input.onchange = () => {
if (!input.files) return;
const newAttachments: ChatMessageAttachment[] = [];
for (const file of input.files) {
const reader = new FileReader();
reader.onload = () => {
const base64 = (reader.result as string).split(',')[1];
setPendingAttachments((prev) => [
...prev,
{ type: 'blob', data: base64, mimeType: file.type, displayName: file.name },
]);
};
reader.readAsDataURL(file);
}
};
input.click();
}}
type="button"
>
<Paperclip className="size-3.5" />
</button>
{/* Plan mode toggle */}
{onSetInteractionMode && !isSessionBusy && (
<button
@@ -553,29 +640,39 @@ export function ChatPane({
</button>
)}
{/* Send / Stop button */}
{/* Send / Stop / Steer button */}
<button
className={`flex size-8 items-center justify-center rounded-lg transition ${
isSessionBusy
isSessionBusy && !hasComposerContent && pendingAttachments.length === 0
? 'bg-red-600/80 text-white hover:bg-red-500'
: canSubmitInput
? isPlanMode
? 'bg-emerald-600 text-white hover:bg-emerald-500'
: 'bg-indigo-600 text-white hover:bg-indigo-500'
: canSubmitInput || pendingAttachments.length > 0
? isSessionBusy
? 'bg-amber-600 text-white hover:bg-amber-500'
: isPlanMode
? 'bg-emerald-600 text-white hover:bg-emerald-500'
: 'bg-indigo-600 text-white hover:bg-indigo-500'
: 'bg-zinc-800 text-zinc-600'
}`}
disabled={!canSubmitInput && !isSessionBusy}
disabled={!canSubmitInput && !isSessionBusy && pendingAttachments.length === 0}
onClick={() => {
if (isSessionBusy) {
if (isSessionBusy && !hasComposerContent && pendingAttachments.length === 0) {
onCancelTurn?.();
} else {
composerRef.current?.submit();
}
}}
type="button"
aria-label={isSessionBusy ? 'Stop generating' : isPlanMode ? 'Send as plan request' : 'Send message'}
aria-label={
isSessionBusy && !hasComposerContent && pendingAttachments.length === 0
? 'Stop generating'
: isSessionBusy
? 'Steer agent'
: isPlanMode
? 'Send as plan request'
: 'Send message'
}
>
{isSessionBusy ? (
{isSessionBusy && !hasComposerContent && pendingAttachments.length === 0 ? (
<Square className="size-3.5" fill="currentColor" />
) : (
<ArrowUp className="size-4" />
@@ -591,7 +688,38 @@ export function ChatPane({
</span>
</div>
)}
{isSessionBusy && (hasComposerContent || pendingAttachments.length > 0) && (
<div className="flex items-center gap-1.5 px-3 pb-1.5 pt-0.5">
<div className="size-1.5 rounded-full bg-amber-500" />
<span className="text-[10px] font-medium text-amber-400/80">
Steering your message will be injected into the current turn
</span>
</div>
)}
</div>
{/* Session usage bar */}
{sessionUsage && sessionUsage.tokenLimit > 0 && (
<div className="px-1 pt-1.5">
<div className="flex items-center gap-2 text-[10px] text-zinc-500">
<div className="h-1 flex-1 overflow-hidden rounded-full bg-zinc-800">
<div
className={`h-full rounded-full transition-all ${
sessionUsage.currentTokens / sessionUsage.tokenLimit > 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)}%` }}
/>
</div>
<span className="tabular-nums">
{Math.round((sessionUsage.currentTokens / sessionUsage.tokenLimit) * 100)}% context
</span>
</div>
</div>
)}
</div>
</div>
</div>
+15 -1
View File
@@ -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 (
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-[12px] text-zinc-300 transition hover:bg-zinc-800"
className={`flex w-full items-center gap-2 px-3 py-1.5 text-[12px] transition hover:bg-zinc-800 ${className ?? 'text-zinc-300'}`}
onClick={onClick}
role="menuitem"
type="button"
@@ -475,6 +479,7 @@ export function Sidebar({
onDuplicateSession,
onSetSessionPinned,
onSetSessionArchived,
onDeleteSession,
onRefreshGitContext,
}: SidebarProps) {
const scratchpadProject = workspace.projects.find((project) => isScratchpadProject(project));
@@ -742,6 +747,15 @@ export function Sidebar({
closeMenu();
}}
/>
<ActionMenuItem
className="text-red-400 hover:bg-red-500/10"
icon={Trash2}
label="Delete"
onClick={() => {
onDeleteSession(menuState.sessionId);
closeMenu();
}}
/>
</div>
</>
)}
+135
View File
@@ -8,8 +8,15 @@ export interface AgentActivityState {
toolName?: string;
}
export interface SessionUsageState {
tokenLimit: number;
currentTokens: number;
messagesLength: number;
}
export type SessionActivityState = Record<string, AgentActivityState>;
export type SessionActivityMap = Record<string, SessionActivityState | undefined>;
export type SessionUsageMap = Record<string, SessionUsageState | undefined>;
export interface AgentActivityRow {
key: string;
@@ -184,3 +191,131 @@ function clearActiveSessionActivity(
function resolveAgentKey(event: SessionEventRecord): string | undefined {
return event.agentId?.trim() || event.agentName?.trim();
}
export function applySessionUsageEvent(
current: SessionUsageMap,
event: SessionEventRecord,
): SessionUsageMap {
if (event.kind !== 'session-usage' || event.tokenLimit === undefined || event.currentTokens === undefined) {
return current;
}
return {
...current,
[event.sessionId]: {
tokenLimit: event.tokenLimit,
currentTokens: event.currentTokens,
messagesLength: event.messagesLength ?? 0,
},
};
}
export function pruneSessionUsage(
current: SessionUsageMap,
sessionIds: Iterable<string>,
): SessionUsageMap {
const allowed = new Set(sessionIds);
const next: SessionUsageMap = {};
let changed = false;
for (const [sessionId, usage] of Object.entries(current)) {
if (!allowed.has(sessionId)) {
changed = true;
continue;
}
next[sessionId] = usage;
}
return changed || Object.keys(next).length !== Object.keys(current).length ? next : current;
}
/* ── Turn-scoped event log ──────────────────────────────── */
const TURN_EVENT_LOG_LIMIT = 50;
export interface TurnEventEntry {
kind: SessionEventRecord['kind'];
occurredAt: string;
label: string;
detail?: string;
phase?: 'start' | 'end' | 'complete';
success?: boolean;
}
export type TurnEventLog = readonly TurnEventEntry[];
export type TurnEventLogMap = Record<string, TurnEventLog | undefined>;
function formatTurnEventEntry(event: SessionEventRecord): TurnEventEntry | undefined {
switch (event.kind) {
case 'subagent':
return {
kind: event.kind,
occurredAt: event.occurredAt,
label: `Sub-agent ${event.subagentEventKind ?? 'update'}: ${event.customAgentDisplayName ?? event.customAgentName ?? 'unknown'}`,
detail: event.agentName ? `from ${event.agentName}` : undefined,
phase: event.subagentEventKind === 'started' ? 'start' : event.subagentEventKind === 'completed' ? 'end' : undefined,
success: event.subagentEventKind === 'completed' ? true : event.subagentEventKind === 'failed' ? false : undefined,
};
case 'hook-lifecycle':
return {
kind: event.kind,
occurredAt: event.occurredAt,
label: `Hook ${event.hookType ?? 'unknown'}`,
detail: event.hookInvocationId,
phase: event.hookPhase,
success: event.hookSuccess,
};
case 'skill-invoked':
return {
kind: event.kind,
occurredAt: event.occurredAt,
label: `Skill: ${event.skillName ?? 'unknown'}`,
detail: event.pluginName ? `via ${event.pluginName}` : undefined,
};
case 'session-compaction':
return {
kind: event.kind,
occurredAt: event.occurredAt,
label: event.compactionPhase === 'start' ? 'Context compaction started' : 'Context compaction complete',
detail: event.compactionPhase === 'complete' && event.tokensRemoved
? `${event.tokensRemoved.toLocaleString()} tokens freed`
: undefined,
phase: event.compactionPhase,
success: event.compactionSuccess,
};
default:
return undefined;
}
}
export function applyTurnEventLog(
current: TurnEventLogMap,
event: SessionEventRecord,
): TurnEventLogMap {
const entry = formatTurnEventEntry(event);
if (!entry) return current;
const existing = current[event.sessionId] ?? [];
const next = [...existing, entry].slice(-TURN_EVENT_LOG_LIMIT);
return { ...current, [event.sessionId]: next };
}
export function pruneTurnEventLogs(
current: TurnEventLogMap,
sessionIds: Iterable<string>,
): TurnEventLogMap {
const allowed = new Set(sessionIds);
const next: TurnEventLogMap = {};
let changed = false;
for (const [sessionId, log] of Object.entries(current)) {
if (!allowed.has(sessionId)) {
changed = true;
continue;
}
next[sessionId] = log;
}
return changed || Object.keys(next).length !== Object.keys(current).length ? next : current;
}
+1
View File
@@ -23,6 +23,7 @@ export const ipcChannels = {
renameSession: 'sessions:rename',
setSessionPinned: 'sessions:set-pinned',
setSessionArchived: 'sessions:set-archived',
deleteSession: 'sessions:delete',
sendSessionMessage: 'sessions:send-message',
cancelSessionTurn: 'sessions:cancel-turn',
resolveSessionApproval: 'sessions:resolve-approval',
+9 -1
View File
@@ -1,5 +1,5 @@
import type { ApprovalDecision } from '@shared/domain/approval';
import type { SidecarCapabilities, InteractionMode } from '@shared/contracts/sidecar';
import type { SidecarCapabilities, InteractionMode, MessageMode } from '@shared/contracts/sidecar';
import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
import type { ProjectRecord } from '@shared/domain/project';
import type { QuerySessionsInput, SessionQueryResult } from '@shared/domain/sessionLibrary';
@@ -11,6 +11,7 @@ import type {
AppearanceTheme,
} from '@shared/domain/tooling';
import type { WorkspaceState } from '@shared/domain/workspace';
import type { ChatMessageAttachment } from '@shared/domain/attachment';
export interface CreateSessionInput {
projectId: string;
@@ -24,6 +25,8 @@ export interface SavePatternInput {
export interface SendSessionMessageInput {
sessionId: string;
content: string;
attachments?: ChatMessageAttachment[];
messageMode?: MessageMode;
}
export interface CancelSessionTurnInput {
@@ -125,6 +128,10 @@ export interface StartSessionMcpAuthInput {
sessionId: string;
}
export interface DeleteSessionInput {
sessionId: string;
}
export interface ElectronApi {
describeSidecarCapabilities(): Promise<SidecarCapabilities>;
refreshSidecarCapabilities(): Promise<SidecarCapabilities>;
@@ -148,6 +155,7 @@ export interface ElectronApi {
renameSession(input: RenameSessionInput): Promise<WorkspaceState>;
setSessionPinned(input: SetSessionPinnedInput): Promise<WorkspaceState>;
setSessionArchived(input: SetSessionArchivedInput): Promise<WorkspaceState>;
deleteSession(input: DeleteSessionInput): Promise<WorkspaceState>;
sendSessionMessage(input: SendSessionMessageInput): Promise<void>;
cancelSessionTurn(input: CancelSessionTurnInput): Promise<void>;
resolveSessionApproval(input: ResolveSessionApprovalInput): Promise<WorkspaceState>;
+198 -1
View File
@@ -2,6 +2,7 @@ import type { PatternDefinition, PatternValidationIssue, ReasoningEffort } from
import type { ApprovalCheckpointKind, ApprovalDecision } from '@shared/domain/approval';
import type { ChatMessageRecord } from '@shared/domain/session';
import type { RuntimeToolDefinition } from '@shared/domain/tooling';
import type { ChatMessageAttachment } from '@shared/domain/attachment';
export interface SidecarModeCapability {
available: boolean;
@@ -69,6 +70,7 @@ export interface ValidatePatternCommand {
}
export type InteractionMode = 'interactive' | 'plan';
export type MessageMode = 'enqueue' | 'immediate';
export interface RunTurnCommand {
type: 'run-turn';
@@ -77,8 +79,10 @@ export interface RunTurnCommand {
projectPath: string;
workspaceKind?: 'project' | 'scratchpad';
mode?: InteractionMode;
messageMode?: MessageMode;
pattern: PatternDefinition;
messages: ChatMessageRecord[];
attachments?: ChatMessageAttachment[];
tooling?: RunTurnToolingConfig;
}
@@ -104,13 +108,42 @@ export interface ResolveUserInputCommand {
wasFreeform: boolean;
}
export interface ListSessionsCommand {
type: 'list-sessions';
requestId: string;
filter?: CopilotSessionListFilter;
}
export interface DeleteSessionCommand {
type: 'delete-session';
requestId: string;
sessionId?: string;
copilotSessionId?: string;
}
export interface DisconnectSessionCommand {
type: 'disconnect-session';
requestId: string;
sessionId: string;
}
export interface CopilotSessionListFilter {
cwd?: string;
gitRoot?: string;
repository?: string;
branch?: string;
}
export type SidecarCommand =
| DescribeCapabilitiesCommand
| ValidatePatternCommand
| RunTurnCommand
| CancelTurnCommand
| ResolveApprovalCommand
| ResolveUserInputCommand;
| ResolveUserInputCommand
| ListSessionsCommand
| DeleteSessionCommand
| DisconnectSessionCommand;
export interface RunTurnLocalMcpServerConfig {
id: string;
@@ -150,6 +183,30 @@ export interface RunTurnToolingConfig {
lspProfiles: RunTurnLspProfileConfig[];
}
export interface RunTurnCustomAgentConfig {
name: string;
displayName?: string;
description?: string;
tools?: string[];
prompt: string;
mcpServers?: RunTurnMcpServerConfig[];
infer?: boolean;
}
export interface RunTurnInfiniteSessionsConfig {
enabled?: boolean;
backgroundCompactionThreshold?: number;
bufferExhaustionThreshold?: number;
}
export interface PatternAgentCopilotConfig {
customAgents?: RunTurnCustomAgentConfig[];
agent?: string;
skillDirectories?: string[];
disabledSkills?: string[];
infiniteSessions?: RunTurnInfiniteSessionsConfig;
}
export interface CapabilitiesEvent {
type: 'capabilities';
requestId: string;
@@ -194,6 +251,137 @@ export interface AgentActivityEvent {
toolName?: string;
}
export type SubagentEventKind = 'started' | 'completed' | 'failed' | 'selected' | 'deselected';
export interface SubagentEvent {
type: 'subagent-event';
requestId: string;
sessionId: string;
eventKind: SubagentEventKind;
agentId?: string;
agentName?: string;
toolCallId?: string;
customAgentName?: string;
customAgentDisplayName?: string;
customAgentDescription?: string;
error?: string;
model?: string;
totalToolCalls?: number;
totalTokens?: number;
durationMs?: number;
tools?: string[];
}
export interface SkillInvokedEvent {
type: 'skill-invoked';
requestId: string;
sessionId: string;
agentId?: string;
agentName?: string;
skillName: string;
path: string;
content: string;
allowedTools?: string[];
pluginName?: string;
pluginVersion?: string;
description?: string;
}
export interface HookLifecycleEvent {
type: 'hook-lifecycle';
requestId: string;
sessionId: string;
agentId?: string;
agentName?: string;
hookInvocationId: string;
hookType: string;
phase: 'start' | 'end';
success?: boolean;
input?: unknown;
output?: unknown;
error?: string;
}
export interface SessionUsageEvent {
type: 'session-usage';
requestId: string;
sessionId: string;
agentId?: string;
agentName?: string;
tokenLimit: number;
currentTokens: number;
messagesLength: number;
systemTokens?: number;
conversationTokens?: number;
toolDefinitionsTokens?: number;
isInitial?: boolean;
}
export interface SessionCompactionEvent {
type: 'session-compaction';
requestId: string;
sessionId: string;
agentId?: string;
agentName?: string;
phase: 'start' | 'complete';
success?: boolean;
error?: string;
systemTokens?: number;
conversationTokens?: number;
toolDefinitionsTokens?: number;
preCompactionTokens?: number;
postCompactionTokens?: number;
preCompactionMessagesLength?: number;
messagesRemoved?: number;
tokensRemoved?: number;
summaryContent?: string;
checkpointNumber?: number;
checkpointPath?: string;
}
export interface PendingMessagesModifiedEvent {
type: 'pending-messages-modified';
requestId: string;
sessionId: string;
agentId?: string;
agentName?: string;
}
export interface CopilotSessionInfo {
copilotSessionId: string;
managedByAryx: boolean;
sessionId?: string;
agentId?: string;
startTime: string;
modifiedTime: string;
summary?: string;
isRemote: boolean;
cwd?: string;
gitRoot?: string;
repository?: string;
branch?: string;
}
export interface SessionsListedEvent {
type: 'sessions-listed';
requestId: string;
sessions: CopilotSessionInfo[];
}
export interface SessionsDeletedEvent {
type: 'sessions-deleted';
requestId: string;
sessionId?: string;
sessions: CopilotSessionInfo[];
}
export interface SessionDisconnectedEvent {
type: 'session-disconnected';
requestId: string;
sessionId: string;
cancelledRequestIds: string[];
}
export interface PermissionDetail {
kind: string;
intention?: string;
@@ -292,9 +480,18 @@ export type SidecarEvent =
| TurnDeltaEvent
| TurnCompleteEvent
| AgentActivityEvent
| SubagentEvent
| SkillInvokedEvent
| HookLifecycleEvent
| SessionUsageEvent
| SessionCompactionEvent
| PendingMessagesModifiedEvent
| ApprovalRequestedEvent
| UserInputRequestedEvent
| McpOauthRequiredEvent
| ExitPlanModeRequestedEvent
| SessionsListedEvent
| SessionsDeletedEvent
| SessionDisconnectedEvent
| CommandErrorEvent
| CommandCompleteEvent;
+42
View File
@@ -0,0 +1,42 @@
export type ChatMessageAttachmentType = 'file' | 'blob';
export interface ChatMessageAttachment {
type: ChatMessageAttachmentType;
path?: string;
data?: string;
mimeType?: string;
displayName?: string;
}
const supportedImageMimeTypes = new Set([
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
]);
export function isImageAttachment(attachment: ChatMessageAttachment): boolean {
if (attachment.mimeType) {
return supportedImageMimeTypes.has(attachment.mimeType);
}
if (attachment.path) {
const ext = attachment.path.split('.').pop()?.toLowerCase();
return ext === 'jpg' || ext === 'jpeg' || ext === 'png' || ext === 'gif' || ext === 'webp';
}
return false;
}
export function getAttachmentDisplayName(attachment: ChatMessageAttachment): string {
if (attachment.displayName) {
return attachment.displayName;
}
if (attachment.path) {
const parts = attachment.path.replace(/\\/g, '/').split('/');
return parts[parts.length - 1] || attachment.path;
}
return attachment.mimeType ?? 'Attachment';
}
+37 -1
View File
@@ -8,7 +8,15 @@ export type SessionEventKind =
| 'message-complete'
| 'agent-activity'
| 'run-updated'
| 'error';
| 'error'
| 'subagent'
| 'skill-invoked'
| 'hook-lifecycle'
| 'session-usage'
| 'session-compaction'
| 'pending-messages-modified';
export type SubagentEventKind = 'started' | 'completed' | 'failed' | 'selected' | 'deselected';
export interface SessionEventRecord {
sessionId: string;
@@ -27,4 +35,32 @@ export interface SessionEventRecord {
toolName?: string;
run?: SessionRunRecord;
error?: string;
// Subagent event fields
subagentEventKind?: SubagentEventKind;
customAgentName?: string;
customAgentDisplayName?: string;
// Skill invoked fields
skillName?: string;
skillPath?: string;
pluginName?: string;
// Hook lifecycle fields
hookInvocationId?: string;
hookType?: string;
hookPhase?: 'start' | 'end';
hookSuccess?: boolean;
// Session usage fields
tokenLimit?: number;
currentTokens?: number;
messagesLength?: number;
// Session compaction fields
compactionPhase?: 'start' | 'complete';
compactionSuccess?: boolean;
preCompactionTokens?: number;
postCompactionTokens?: number;
tokensRemoved?: number;
}
+3
View File
@@ -32,6 +32,8 @@ export const reasoningEffortOptions: ReadonlyArray<{ value: ReasoningEffort; lab
{ value: 'xhigh', label: 'Maximum' },
];
import type { PatternAgentCopilotConfig } from '@shared/contracts/sidecar';
export interface PatternAgentDefinition {
id: string;
name: string;
@@ -39,6 +41,7 @@ export interface PatternAgentDefinition {
instructions: string;
model: string;
reasoningEffort?: ReasoningEffort;
copilot?: PatternAgentCopilotConfig;
}
export interface PatternGraphPosition {
+2
View File
@@ -14,6 +14,7 @@ import type { SessionRunRecord } from '@shared/domain/runTimeline';
import type { PendingUserInputRecord } from '@shared/domain/userInput';
import type { PendingPlanReviewRecord } from '@shared/domain/planReview';
import type { PendingMcpAuthRecord } from '@shared/domain/mcpAuth';
import type { ChatMessageAttachment } from '@shared/domain/attachment';
import type { InteractionMode } from '@shared/contracts/sidecar';
export type ChatRole = 'system' | 'user' | 'assistant';
@@ -32,6 +33,7 @@ export interface ChatMessageRecord {
content: string;
createdAt: string;
pending?: boolean;
attachments?: ChatMessageAttachment[];
}
export interface SessionRecord {
+2
View File
@@ -19,6 +19,7 @@ describe('run turn pending helpers', () => {
onUserInput: () => undefined,
onExitPlanMode: () => undefined,
onMcpOAuthRequired: () => undefined,
onTurnScopedEvent: () => undefined,
errored: false,
};
@@ -44,6 +45,7 @@ describe('run turn pending helpers', () => {
onUserInput: () => undefined,
onExitPlanMode: () => undefined,
onMcpOAuthRequired: () => undefined,
onTurnScopedEvent: () => undefined,
errored: false,
};
+29 -3
View File
@@ -141,7 +141,7 @@ import Base from '../layouts/Base.astro';
</div>
<h3 class="text-base font-semibold">Live Visibility</h3>
<p class="mt-2 text-sm leading-relaxed text-zinc-400">
Watch each agent think, use tools, and hand off work in real time. Know exactly what's happening during complex runs.
Watch each agent think, delegate to sub-agents, invoke skills, and run hooks in real time. A context-usage bar shows how much of the model's window you've used.
</p>
</div>
@@ -154,11 +154,37 @@ import Base from '../layouts/Base.astro';
</div>
<h3 class="text-base font-semibold">Persistent Sessions</h3>
<p class="mt-2 text-sm leading-relaxed text-zinc-400">
Rename, pin, archive, and return to sessions later. Your work persists instead of disappearing after each conversation.
Rename, pin, archive, delete, and return to sessions later. Your work persists instead of disappearing after each conversation.
</p>
</div>
<!-- Feature 6: Model Selection -->
<!-- Feature 6: Mid-turn Steering -->
<div class="group rounded-2xl border border-zinc-800 bg-zinc-900/40 p-6 transition hover:border-zinc-700 hover:bg-zinc-900/60">
<div class="mb-4 flex size-10 items-center justify-center rounded-xl bg-yellow-500/10 text-yellow-400">
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
</svg>
</div>
<h3 class="text-base font-semibold">Steer While It Works</h3>
<p class="mt-2 text-sm leading-relaxed text-zinc-400">
Send follow-up messages while an agent is running. Your input is injected immediately so you can redirect without waiting.
</p>
</div>
<!-- Feature 7: Image Attachments -->
<div class="group rounded-2xl border border-zinc-800 bg-zinc-900/40 p-6 transition hover:border-zinc-700 hover:bg-zinc-900/60">
<div class="mb-4 flex size-10 items-center justify-center rounded-xl bg-pink-500/10 text-pink-400">
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
</svg>
</div>
<h3 class="text-base font-semibold">Image Input</h3>
<p class="mt-2 text-sm leading-relaxed text-zinc-400">
Attach screenshots, diagrams, or photos to any message. The model sees the image alongside your text for visual reasoning.
</p>
</div>
<!-- Feature 8: Model Selection -->
<div class="group rounded-2xl border border-zinc-800 bg-zinc-900/40 p-6 transition hover:border-zinc-700 hover:bg-zinc-900/60">
<div class="mb-4 flex size-10 items-center justify-center rounded-xl bg-rose-500/10 text-rose-400">
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">