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. - 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. - 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. - 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 ## 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. 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 model
Security in this system is mostly about **desktop trust boundaries**. 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 - partial output is streamed
- agent activity is surfaced - agent activity is surfaced
- turn-scoped lifecycle events (sub-agent, hook, skill, compaction, usage) are streamed
- runs are persisted as timeline history - runs are persisted as timeline history
- failures are represented explicitly - failures are represented explicitly
This improves trust and debuggability, especially for multi-agent workflows. 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 ## Persistence and repair
Workspace persistence is intentionally simple: the app stores a durable workspace document and repairs or normalizes it when loading. 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. - **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. - **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. - **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. - **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, and return to later. - **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. - **Tune how you work** by choosing models and reusing saved patterns that fit different tasks.
## What you can do in the app ## 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 ### 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 ### 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 ## Before you start
@@ -10,6 +10,16 @@ public sealed class PatternAgentDefinitionDto
public string Instructions { get; init; } = string.Empty; public string Instructions { get; init; } = string.Empty;
public string Model { get; init; } = string.Empty; public string Model { get; init; } = string.Empty;
public string? ReasoningEffort { get; init; } 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 public sealed class PatternGraphPositionDto
@@ -75,6 +85,16 @@ public sealed class ChatMessageDto
public string AuthorName { get; init; } = string.Empty; public string AuthorName { get; init; } = string.Empty;
public string Content { get; init; } = string.Empty; public string Content { get; init; } = string.Empty;
public string CreatedAt { 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 public sealed class PatternValidationIssueDto
@@ -162,6 +182,7 @@ public sealed class RunTurnCommandDto : SidecarCommandEnvelope
public string ProjectPath { get; init; } = string.Empty; public string ProjectPath { get; init; } = string.Empty;
public string WorkspaceKind { get; init; } = "project"; public string WorkspaceKind { get; init; } = "project";
public string Mode { get; init; } = "interactive"; public string Mode { get; init; } = "interactive";
public string MessageMode { get; init; } = "enqueue";
public PatternDefinitionDto Pattern { get; init; } = new(); public PatternDefinitionDto Pattern { get; init; } = new();
public IReadOnlyList<ChatMessageDto> Messages { get; init; } = []; public IReadOnlyList<ChatMessageDto> Messages { get; init; } = [];
public RunTurnToolingConfigDto? Tooling { get; init; } public RunTurnToolingConfigDto? Tooling { get; init; }
@@ -186,6 +207,22 @@ public sealed class ResolveUserInputCommandDto : SidecarCommandEnvelope
public bool WasFreeform { get; init; } 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 sealed class RunTurnToolingConfigDto
{ {
public IReadOnlyList<RunTurnMcpServerConfigDto> McpServers { get; init; } = []; public IReadOnlyList<RunTurnMcpServerConfigDto> McpServers { get; init; } = [];
@@ -217,6 +254,48 @@ public sealed class RunTurnLspProfileConfigDto
public IReadOnlyList<string> FileExtensions { get; init; } = []; 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 abstract class SidecarEventDto
{ {
public string Type { get; init; } = string.Empty; public string Type { get; init; } = string.Empty;
@@ -260,6 +339,111 @@ public sealed class AgentActivityEventDto : SidecarEventDto
public string? ToolName { get; init; } 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 sealed class PermissionDetailDto
{ {
public string Kind { get; init; } = string.Empty; public string Kind { get; init; } = string.Empty;
@@ -3,6 +3,7 @@ using System.Runtime.CompilerServices;
using System.Text.Json; using System.Text.Json;
using System.Text.Json.Serialization; using System.Text.Json.Serialization;
using System.Threading.Channels; using System.Threading.Channels;
using Aryx.AgentHost.Contracts;
using GitHub.Copilot.SDK; using GitHub.Copilot.SDK;
using Microsoft.Agents.AI; using Microsoft.Agents.AI;
using Microsoft.Extensions.AI; using Microsoft.Extensions.AI;
@@ -140,11 +141,16 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
try try
{ {
string prompt = string.Join("\n", messages.Select(message => message.Text)); 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, messages,
cancellationToken).ConfigureAwait(false); cancellationToken).ConfigureAwait(false);
MessageOptions messageOptions = new() { Prompt = prompt }; MessageOptions messageOptions = new()
{
Prompt = prompt,
Mode = string.IsNullOrWhiteSpace(messageMode) ? null : messageMode,
};
if (attachments is not null) if (attachments is not null)
{ {
messageOptions.Attachments = [.. attachments]; messageOptions.Attachments = [.. attachments];
@@ -474,36 +480,103 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
return JsonSerializer.Deserialize<Dictionary<string, object?>>(json); 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, IEnumerable<ChatMessage> messages,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
List<UserMessageDataAttachmentsItem>? attachments = null; List<UserMessageDataAttachmentsItem>? attachments = null;
string? messageMode = null;
string? tempDir = null; string? tempDir = null;
foreach (ChatMessage message in messages) foreach (ChatMessage message in messages)
{ {
foreach (AIContent content in message.Contents) 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; continue;
} }
tempDir ??= Directory.CreateDirectory( if (content.RawRepresentation is ChatMessageAttachmentDto protocolAttachment)
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, attachments ??= [];
DisplayName = Path.GetFileName(tempFilePath), 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) private static void CleanupTempDir(string? tempDir)
@@ -47,6 +47,7 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
SessionConfig sessionConfig = new() SessionConfig sessionConfig = new()
{ {
SessionId = CopilotManagedSessionIds.Build(command.SessionId, definition.Id),
Model = definition.Model, Model = definition.Model,
ReasoningEffort = definition.ReasoningEffort, ReasoningEffort = definition.ReasoningEffort,
SystemMessage = new SystemMessageConfig SystemMessage = new SystemMessageConfig
@@ -61,8 +62,14 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
WorkingDirectory = command.ProjectPath, WorkingDirectory = command.ProjectPath,
OnPermissionRequest = (request, invocation) => onPermissionRequest(definition, request, invocation), OnPermissionRequest = (request, invocation) => onPermissionRequest(definition, request, invocation),
OnUserInputRequest = (request, invocation) => onUserInputRequest(definition, request, invocation), OnUserInputRequest = (request, invocation) => onUserInputRequest(definition, request, invocation),
Hooks = CopilotSessionHooks.Create(command, definition),
OnEvent = evt => onSessionEvent?.Invoke(definition, evt), OnEvent = evt => onSessionEvent?.Invoke(definition, evt),
Streaming = true, 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); 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) public Workflow BuildWorkflow(PatternDefinitionDto pattern)
{ {
return pattern.Mode switch 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 RunTurnCommandDto _command;
private readonly HashSet<string> _startedAgents = new(StringComparer.OrdinalIgnoreCase); 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 ConcurrentQueue<McpOauthRequiredEventDto> _pendingMcpOauthRequests = new();
private readonly ConcurrentDictionary<string, AgentIdentity> _observedAgentsByMessageId = new(StringComparer.Ordinal); private readonly ConcurrentDictionary<string, AgentIdentity> _observedAgentsByMessageId = new(StringComparer.Ordinal);
private readonly StreamingTranscriptBuffer _transcriptBuffer = new(); private readonly StreamingTranscriptBuffer _transcriptBuffer = new();
@@ -30,7 +30,7 @@ internal sealed class CopilotTurnExecutionState
public async Task EmitThinkingIfNeeded( public async Task EmitThinkingIfNeeded(
AgentIdentity agent, AgentIdentity agent,
Func<AgentActivityEventDto, Task> onActivity) Func<SidecarEventDto, Task> onEvent)
{ {
AgentActivityEventDto? thinkingActivity = CreateThinkingActivityIfNeeded(agent); AgentActivityEventDto? thinkingActivity = CreateThinkingActivityIfNeeded(agent);
if (thinkingActivity is null) if (thinkingActivity is null)
@@ -38,7 +38,7 @@ internal sealed class CopilotTurnExecutionState
return; return;
} }
await onActivity(thinkingActivity).ConfigureAwait(false); await onEvent(thinkingActivity).ConfigureAwait(false);
} }
public void QueueThinkingIfNeeded(AgentIdentity agent) public void QueueThinkingIfNeeded(AgentIdentity agent)
@@ -46,13 +46,14 @@ internal sealed class CopilotTurnExecutionState
AgentActivityEventDto? thinkingActivity = CreateThinkingActivityIfNeeded(agent); AgentActivityEventDto? thinkingActivity = CreateThinkingActivityIfNeeded(agent);
if (thinkingActivity is not null) 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.AgentId)
&& !string.IsNullOrWhiteSpace(activity.AgentName)) && !string.IsNullOrWhiteSpace(activity.AgentName))
{ {
@@ -86,6 +87,54 @@ internal sealed class CopilotTurnExecutionState
ActiveAgent = agent; ActiveAgent = agent;
QueueThinkingIfNeeded(agent); QueueThinkingIfNeeded(agent);
break; 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: case McpOauthRequiredEvent:
ActiveAgent = agent; ActiveAgent = agent;
break; break;
@@ -96,12 +145,12 @@ internal sealed class CopilotTurnExecutionState
} }
} }
public IReadOnlyList<AgentActivityEventDto> DrainPendingActivityEvents() public IReadOnlyList<SidecarEventDto> DrainPendingEvents()
{ {
List<AgentActivityEventDto> pending = []; List<SidecarEventDto> pending = [];
while (_pendingActivityEvents.TryDequeue(out AgentActivityEventDto? activity)) while (_pendingEvents.TryDequeue(out SidecarEventDto? pendingEvent))
{ {
pending.Add(activity); pending.Add(pendingEvent);
} }
return pending; return pending;
@@ -204,4 +253,229 @@ internal sealed class CopilotTurnExecutionState
return CompletedMessages; 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( public async Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
RunTurnCommandDto command, RunTurnCommandDto command,
Func<TurnDeltaEventDto, Task> onDelta, Func<TurnDeltaEventDto, Task> onDelta,
Func<AgentActivityEventDto, Task> onActivity, Func<SidecarEventDto, Task> onEvent,
Func<ApprovalRequestedEventDto, Task> onApproval, Func<ApprovalRequestedEventDto, Task> onApproval,
Func<UserInputRequestedEventDto, Task> onUserInput, Func<UserInputRequestedEventDto, Task> onUserInput,
Func<McpOauthRequiredEventDto, Task> onMcpOAuthRequired, Func<McpOauthRequiredEventDto, Task> onMcpOAuthRequired,
@@ -77,15 +77,16 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
runCancellation.Token); runCancellation.Token);
Workflow workflow = bundle.BuildWorkflow(command.Pattern); Workflow workflow = bundle.BuildWorkflow(command.Pattern);
List<ChatMessage> inputMessages = command.Messages.Select(WorkflowTranscriptProjector.ToChatMessage).ToList(); 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 using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, inputMessages).ConfigureAwait(false);
await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false); await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false);
await foreach (WorkflowEvent evt in run.WatchStreamAsync(runCancellation.Token).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); .ConfigureAwait(false);
await EmitPendingActivityEventsAsync(state, onActivity).ConfigureAwait(false); await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false);
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false); await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
if (shouldEndTurn) 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); await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
return state.FinalizeCompletedMessages(); return state.FinalizeCompletedMessages();
} }
catch (OperationCanceledException) when (runCancellation.IsCancellationRequested && !cancellationToken.IsCancellationRequested) 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); await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
ExitPlanModeRequestedEventDto? exitPlanModeEvent = ExitPlanModeRequestedEventDto? exitPlanModeEvent =
_exitPlanModeCoordinator.ConsumePendingRequest(command.RequestId); _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, 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, IReadOnlyList<ChatMessage> inputMessages,
CopilotTurnExecutionState state, CopilotTurnExecutionState state,
Func<TurnDeltaEventDto, Task> onDelta, Func<TurnDeltaEventDto, Task> onDelta,
Func<AgentActivityEventDto, Task> onActivity) Func<SidecarEventDto, Task> onEvent)
{ {
if (evt is ExecutorInvokedEvent invoked) if (evt is ExecutorInvokedEvent invoked)
{ {
@@ -167,7 +168,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
out AgentIdentity invokedAgent)) out AgentIdentity invokedAgent))
{ {
TraceHandoff(command, $"Executor invoked: {invoked.ExecutorId} -> {invokedAgent.AgentName} ({invokedAgent.AgentId})."); 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 else
{ {
@@ -194,13 +195,13 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
return requiresBoundary; return requiresBoundary;
} }
await EmitActivityAsync(command, state, activity, onActivity).ConfigureAwait(false); await EmitActivityAsync(command, state, activity, onEvent).ConfigureAwait(false);
return false; return false;
} }
if (evt is AgentResponseUpdateEvent update) 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; return false;
} }
@@ -237,7 +238,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
AgentResponseUpdateEvent update, AgentResponseUpdateEvent update,
CopilotTurnExecutionState state, CopilotTurnExecutionState state,
Func<TurnDeltaEventDto, Task> onDelta, Func<TurnDeltaEventDto, Task> onDelta,
Func<AgentActivityEventDto, Task> onActivity) Func<SidecarEventDto, Task> onEvent)
{ {
AgentIdentity? updateAgent = null; AgentIdentity? updateAgent = null;
string authorName = update.ExecutorId; 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)}."); $"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) else if (!string.IsNullOrEmpty(update.Update.Text) || handoffFunctionCalls.Length > 0)
{ {
@@ -307,13 +308,13 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
RunTurnCommandDto command, RunTurnCommandDto command,
CopilotTurnExecutionState state, CopilotTurnExecutionState state,
AgentActivityEventDto activity, AgentActivityEventDto activity,
Func<AgentActivityEventDto, Task> onActivity) Func<SidecarEventDto, Task> onEvent)
{ {
state.ApplyActivity(activity); state.ApplyEvent(activity);
TraceHandoff( TraceHandoff(
command, command,
$"Activity emitted: {activity.ActivityType} -> {activity.AgentName ?? activity.AgentId ?? "<unknown>"}."); $"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) if (string.Equals(activity.ActivityType, "handoff", StringComparison.Ordinal)
&& !string.IsNullOrWhiteSpace(activity.AgentId) && !string.IsNullOrWhiteSpace(activity.AgentId)
@@ -324,7 +325,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
$"Promoting handoff target to thinking: {activity.AgentName} ({activity.AgentId})."); $"Promoting handoff target to thinking: {activity.AgentName} ({activity.AgentId}).");
await state.EmitThinkingIfNeeded( await state.EmitThinkingIfNeeded(
new AgentIdentity(activity.AgentId, activity.AgentName), 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( Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
RunTurnCommandDto command, RunTurnCommandDto command,
Func<TurnDeltaEventDto, Task> onDelta, Func<TurnDeltaEventDto, Task> onDelta,
Func<AgentActivityEventDto, Task> onActivity, Func<SidecarEventDto, Task> onEvent,
Func<ApprovalRequestedEventDto, Task> onApproval, Func<ApprovalRequestedEventDto, Task> onApproval,
Func<UserInputRequestedEventDto, Task> onUserInput, Func<UserInputRequestedEventDto, Task> onUserInput,
Func<McpOauthRequiredEventDto, Task> onMcpOAuthRequired, Func<McpOauthRequiredEventDto, Task> onMcpOAuthRequired,
@@ -15,6 +15,9 @@ public sealed class SidecarProtocolHost
private const string CancelTurnCommandType = "cancel-turn"; private const string CancelTurnCommandType = "cancel-turn";
private const string ResolveApprovalCommandType = "resolve-approval"; private const string ResolveApprovalCommandType = "resolve-approval";
private const string ResolveUserInputCommandType = "resolve-user-input"; 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 const string AskUserToolName = "ask_user";
private static readonly HashSet<string> ExcludedRuntimeToolNames = new(StringComparer.OrdinalIgnoreCase) 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 Func<CancellationToken, Task<SidecarCapabilitiesDto>> _capabilitiesProvider;
private readonly PatternValidator _patternValidator; private readonly PatternValidator _patternValidator;
private readonly ITurnWorkflowRunner _workflowRunner; private readonly ITurnWorkflowRunner _workflowRunner;
private readonly ICopilotSessionManager _sessionManager;
private readonly JsonSerializerOptions _jsonOptions; private readonly JsonSerializerOptions _jsonOptions;
private readonly IReadOnlyDictionary<string, Func<CommandContext, Task>> _commandHandlers; private readonly IReadOnlyDictionary<string, Func<CommandContext, Task>> _commandHandlers;
private readonly SemaphoreSlim _writeLock = new(1, 1); private readonly SemaphoreSlim _writeLock = new(1, 1);
private readonly ConcurrentDictionary<string, Task> _inFlight = new(StringComparer.Ordinal); private readonly ConcurrentDictionary<string, Task> _inFlight = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, CancellationTokenSource> _turnCancellations = 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() public SidecarProtocolHost()
: this(new PatternValidator()) : this(new PatternValidator())
@@ -53,11 +59,13 @@ public sealed class SidecarProtocolHost
public SidecarProtocolHost( public SidecarProtocolHost(
PatternValidator patternValidator, PatternValidator patternValidator,
ITurnWorkflowRunner? workflowRunner = null, ITurnWorkflowRunner? workflowRunner = null,
Func<CancellationToken, Task<SidecarCapabilitiesDto>>? capabilitiesProvider = null) Func<CancellationToken, Task<SidecarCapabilitiesDto>>? capabilitiesProvider = null,
ICopilotSessionManager? sessionManager = null)
{ {
_patternValidator = patternValidator; _patternValidator = patternValidator;
_workflowRunner = workflowRunner ?? new CopilotWorkflowRunner(_patternValidator); _workflowRunner = workflowRunner ?? new CopilotWorkflowRunner(_patternValidator);
_capabilitiesProvider = capabilitiesProvider ?? BuildCapabilitiesAsync; _capabilitiesProvider = capabilitiesProvider ?? BuildCapabilitiesAsync;
_sessionManager = sessionManager ?? new CopilotSessionManager();
_jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web) _jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
{ {
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull, DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
@@ -71,6 +79,9 @@ public sealed class SidecarProtocolHost
[CancelTurnCommandType] = HandleCancelTurnAsync, [CancelTurnCommandType] = HandleCancelTurnAsync,
[ResolveApprovalCommandType] = HandleResolveApprovalAsync, [ResolveApprovalCommandType] = HandleResolveApprovalAsync,
[ResolveUserInputCommandType] = HandleResolveUserInputAsync, [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."); $"A turn with request ID '{context.Envelope.RequestId}' is already in progress.");
} }
RegisterTurnRequest(command.SessionId, context.Envelope.RequestId);
try try
{ {
IReadOnlyList<ChatMessageDto> messages = await _workflowRunner.RunTurnAsync( IReadOnlyList<ChatMessageDto> messages = await _workflowRunner.RunTurnAsync(
command, command,
delta => WriteAsync(context.Output, delta, turnCancellation.Token), 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), approval => WriteAsync(context.Output, approval, turnCancellation.Token),
userInput => WriteAsync(context.Output, userInput, turnCancellation.Token), userInput => WriteAsync(context.Output, userInput, turnCancellation.Token),
mcpOauth => WriteAsync(context.Output, mcpOauth, turnCancellation.Token), mcpOauth => WriteAsync(context.Output, mcpOauth, turnCancellation.Token),
@@ -216,6 +228,7 @@ public sealed class SidecarProtocolHost
finally finally
{ {
_turnCancellations.TryRemove(context.Envelope.RequestId, out _); _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); 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) private TCommand DeserializeCommand<TCommand>(CommandContext context)
where TCommand : SidecarCommandEnvelope 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) private static async Task<SidecarCapabilitiesDto> BuildCapabilitiesAsync(CancellationToken cancellationToken)
{ {
try try
@@ -26,9 +26,30 @@ internal static class WorkflowTranscriptProjector
mapped.AuthorName = message.AuthorName; mapped.AuthorName = message.AuthorName;
} }
foreach (ChatMessageAttachmentDto attachment in message.Attachments)
{
mapped.Contents.Add(new AIContent
{
RawRepresentation = attachment,
});
}
return mapped; 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( public static List<ChatMessageDto> ProjectCompletedMessages(
RunTurnCommandDto command, RunTurnCommandDto command,
IReadOnlyList<ChatMessage> newMessages, 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.Reflection;
using System.Text.Json; using System.Text.Json;
using Aryx.AgentHost.Contracts;
using GitHub.Copilot.SDK; using GitHub.Copilot.SDK;
using Aryx.AgentHost.Services; using Aryx.AgentHost.Services;
using Microsoft.Agents.AI; using Microsoft.Agents.AI;
@@ -143,6 +144,122 @@ public sealed class CopilotAgentBundleTests
Assert.Equal("handoff_to_reviewer", single.Name); 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() private static AIFunction CreateTool()
{ {
ToolTarget target = new(); 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("thinking", activity.ActivityType);
Assert.Equal("agent-1", activity.AgentId); Assert.Equal("agent-1", activity.AgentId);
Assert.Equal("Primary", activity.AgentName); 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( await state.EmitThinkingIfNeeded(
new AgentIdentity("agent-1", "Primary"), new AgentIdentity("agent-1", "Primary"),
activity => sidecarEvent =>
{ {
activities.Add(activity); activities.Add(Assert.IsType<AgentActivityEventDto>(sidecarEvent));
return Task.CompletedTask; return Task.CompletedTask;
}); });
@@ -144,6 +144,157 @@ public sealed class CopilotTurnExecutionStateTests
Assert.Empty(secondDrain); 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() private static RunTurnCommandDto CreateCommand()
{ {
return new RunTurnCommandDto return new RunTurnCommandDto
@@ -649,7 +649,7 @@ public sealed class CopilotWorkflowRunnerTests
Assert.Equal("agent-handoff-ux", observedAgent.AgentId); Assert.Equal("agent-handoff-ux", observedAgent.AgentId);
Assert.Equal("UX Specialist", observedAgent.AgentName); Assert.Equal("UX Specialist", observedAgent.AgentName);
Assert.Equal("agent-handoff-ux", state.ActiveAgent?.AgentId); 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("thinking", activity.ActivityType);
Assert.Equal("agent-handoff-ux", activity.AgentId); 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("agent-handoff-ux", state.ActiveAgent?.AgentId);
Assert.Equal("UX Specialist", state.ActiveAgent?.AgentName); 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("thinking", activity.ActivityType);
Assert.Equal("agent-handoff-ux", activity.AgentId); Assert.Equal("agent-handoff-ux", activity.AgentId);
} }
@@ -699,7 +699,7 @@ public sealed class CopilotWorkflowRunnerTests
"timestamp": "2026-03-27T00:00:00Z" "timestamp": "2026-03-27T00:00:00Z"
} }
""")); """));
_ = state.DrainPendingActivityEvents(); _ = state.DrainPendingEvents();
RequestInfoEvent requestInfo = CreateRequestInfoEvent( RequestInfoEvent requestInfo = CreateRequestInfoEvent(
CreateHandoffTarget("agent-handoff-ux", "UX Specialist")); CreateHandoffTarget("agent-handoff-ux", "UX Specialist"));
List<AgentActivityEventDto> activities = []; List<AgentActivityEventDto> activities = [];
@@ -715,9 +715,9 @@ public sealed class CopilotWorkflowRunnerTests
Array.Empty<ChatMessage>(), Array.Empty<ChatMessage>(),
state, state,
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask), (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; return Task.CompletedTask;
}), }),
])!; ])!;
@@ -780,6 +780,109 @@ public sealed class SidecarProtocolHostTests
Assert.False(string.IsNullOrWhiteSpace(diagnostics.CheckedAt)); 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( private static async Task<IReadOnlyList<JsonElement>> RunHostAsync(
object command, object command,
SidecarProtocolHost? host = null) SidecarProtocolHost? host = null)
@@ -949,7 +1052,7 @@ public sealed class SidecarProtocolHostTests
private readonly Func< private readonly Func<
RunTurnCommandDto, RunTurnCommandDto,
Func<TurnDeltaEventDto, Task>, Func<TurnDeltaEventDto, Task>,
Func<AgentActivityEventDto, Task>, Func<SidecarEventDto, Task>,
Func<ApprovalRequestedEventDto, Task>, Func<ApprovalRequestedEventDto, Task>,
Func<UserInputRequestedEventDto, Task>, Func<UserInputRequestedEventDto, Task>,
Func<McpOauthRequiredEventDto, Task>, Func<McpOauthRequiredEventDto, Task>,
@@ -963,7 +1066,7 @@ public sealed class SidecarProtocolHostTests
Func< Func<
RunTurnCommandDto, RunTurnCommandDto,
Func<TurnDeltaEventDto, Task>, Func<TurnDeltaEventDto, Task>,
Func<AgentActivityEventDto, Task>, Func<SidecarEventDto, Task>,
Func<ApprovalRequestedEventDto, Task>, Func<ApprovalRequestedEventDto, Task>,
Func<UserInputRequestedEventDto, Task>, Func<UserInputRequestedEventDto, Task>,
Func<McpOauthRequiredEventDto, Task>, Func<McpOauthRequiredEventDto, Task>,
@@ -981,7 +1084,7 @@ public sealed class SidecarProtocolHostTests
public Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync( public Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
RunTurnCommandDto command, RunTurnCommandDto command,
Func<TurnDeltaEventDto, Task> onDelta, Func<TurnDeltaEventDto, Task> onDelta,
Func<AgentActivityEventDto, Task> onActivity, Func<SidecarEventDto, Task> onActivity,
Func<ApprovalRequestedEventDto, Task> onApproval, Func<ApprovalRequestedEventDto, Task> onApproval,
Func<UserInputRequestedEventDto, Task> onUserInput, Func<UserInputRequestedEventDto, Task> onUserInput,
Func<McpOauthRequiredEventDto, Task> onMcpOAuthRequired, Func<McpOauthRequiredEventDto, Task> onMcpOAuthRequired,
@@ -1005,4 +1108,32 @@ public sealed class SidecarProtocolHostTests
return _resolveUserInputHandler(command, cancellationToken); 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, TurnDeltaEvent,
UserInputRequestedEvent, UserInputRequestedEvent,
} from '@shared/contracts/sidecar'; } from '@shared/contracts/sidecar';
import type { TurnScopedEvent } from '@main/sidecar/runTurnPending';
import { import {
buildAvailableModelCatalog, buildAvailableModelCatalog,
findModel, findModel,
@@ -564,10 +565,40 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return this.persistAndBroadcast(workspace); 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 workspace = await this.loadWorkspace();
const session = this.requireSession(workspace, sessionId); 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.'); throw new Error('Wait for the current response or approval checkpoint to finish before sending another message.');
} }
const project = this.requireProject(workspace, session.projectId); const project = this.requireProject(workspace, session.projectId);
@@ -589,6 +620,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
authorName: 'You', authorName: 'You',
content: preparedContent, content: preparedContent,
createdAt: occurredAt, createdAt: occurredAt,
attachments: attachments?.length ? attachments : undefined,
}); });
session.title = resolveSessionTitle(session, effectivePattern, session.messages); session.title = resolveSessionTitle(session, effectivePattern, session.messages);
session.status = 'running'; session.status = 'running';
@@ -625,8 +657,10 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
projectPath: project.path, projectPath: project.path,
workspaceKind, workspaceKind,
mode: session.interactionMode ?? 'interactive', mode: session.interactionMode ?? 'interactive',
messageMode,
pattern: effectivePattern, pattern: effectivePattern,
messages: session.messages, messages: session.messages,
attachments: attachments?.length ? attachments : undefined,
tooling: this.buildRunTurnToolingConfig(workspace, session), tooling: this.buildRunTurnToolingConfig(workspace, session),
}, },
async (event) => { async (event) => {
@@ -649,6 +683,9 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
async (event) => { async (event) => {
await this.handleExitPlanModeRequested(workspace, session.id, 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); await this.awaitFinalResponseApproval(workspace, session.id, requestId, effectivePattern, responseMessages);
@@ -1525,6 +1562,89 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
await this.persistAndBroadcast(workspace); 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 { private createPendingApprovalFromSidecarEvent(event: ApprovalRequestedEvent): PendingApprovalRecord {
return { return {
id: event.approvalId, id: event.approvalId,
+5 -1
View File
@@ -26,6 +26,7 @@ import type {
UpdateSessionApprovalSettingsInput, UpdateSessionApprovalSettingsInput,
UpdateSessionToolingInput, UpdateSessionToolingInput,
UpdateSessionModelConfigInput, UpdateSessionModelConfigInput,
DeleteSessionInput,
} from '@shared/contracts/ipc'; } from '@shared/contracts/ipc';
import type { QuerySessionsInput } from '@shared/domain/sessionLibrary'; import type { QuerySessionsInput } from '@shared/domain/sessionLibrary';
import type { AppearanceTheme } from '@shared/domain/tooling'; 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) => ipcMain.handle(ipcChannels.setSessionArchived, (_event, input: SetSessionArchivedInput) =>
service.setSessionArchived(input.sessionId, input.isArchived), service.setSessionArchived(input.sessionId, input.isArchived),
); );
ipcMain.handle(ipcChannels.deleteSession, (_event, input: DeleteSessionInput) =>
service.deleteSession(input.sessionId),
);
ipcMain.handle(ipcChannels.sendSessionMessage, (_event, input: SendSessionMessageInput) => 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) => ipcMain.handle(ipcChannels.cancelSessionTurn, (_event, input: CancelSessionTurnInput) =>
service.cancelSessionTurn(input.sessionId), service.cancelSessionTurn(input.sessionId),
+15
View File
@@ -5,9 +5,23 @@ import type {
McpOauthRequiredEvent, McpOauthRequiredEvent,
TurnDeltaEvent, TurnDeltaEvent,
UserInputRequestedEvent, UserInputRequestedEvent,
SubagentEvent,
SkillInvokedEvent,
HookLifecycleEvent,
SessionUsageEvent,
SessionCompactionEvent,
PendingMessagesModifiedEvent,
} from '@shared/contracts/sidecar'; } from '@shared/contracts/sidecar';
import type { ChatMessageRecord } from '@shared/domain/session'; import type { ChatMessageRecord } from '@shared/domain/session';
export type TurnScopedEvent =
| SubagentEvent
| SkillInvokedEvent
| HookLifecycleEvent
| SessionUsageEvent
| SessionCompactionEvent
| PendingMessagesModifiedEvent;
export interface RunTurnPendingCommand { export interface RunTurnPendingCommand {
kind: 'run-turn'; kind: 'run-turn';
resolve: (messages: ChatMessageRecord[]) => void; resolve: (messages: ChatMessageRecord[]) => void;
@@ -18,6 +32,7 @@ export interface RunTurnPendingCommand {
onUserInput: (event: UserInputRequestedEvent) => void | Promise<void>; onUserInput: (event: UserInputRequestedEvent) => void | Promise<void>;
onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise<void>; onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise<void>;
onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise<void>; onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise<void>;
onTurnScopedEvent: (event: TurnScopedEvent) => void | Promise<void>;
errored: boolean; errored: boolean;
} }
+99 -1
View File
@@ -14,6 +14,8 @@ import type {
ExitPlanModeRequestedEvent, ExitPlanModeRequestedEvent,
ValidatePatternCommand, ValidatePatternCommand,
RunTurnCommand, RunTurnCommand,
CopilotSessionListFilter,
CopilotSessionInfo,
} from '@shared/contracts/sidecar'; } from '@shared/contracts/sidecar';
import type { ApprovalDecision } from '@shared/domain/approval'; import type { ApprovalDecision } from '@shared/domain/approval';
import type { ChatMessageRecord } from '@shared/domain/session'; import type { ChatMessageRecord } from '@shared/domain/session';
@@ -22,6 +24,7 @@ import {
markRunTurnPendingErrored, markRunTurnPendingErrored,
shouldHandleRunTurnEvent, shouldHandleRunTurnEvent,
type RunTurnPendingCommand, type RunTurnPendingCommand,
type TurnScopedEvent,
} from '@main/sidecar/runTurnPending'; } from '@main/sidecar/runTurnPending';
import { TurnCancelledError } from '@main/sidecar/turnCancelledError'; import { TurnCancelledError } from '@main/sidecar/turnCancelledError';
import { resolveSidecarProcess } from '@main/sidecar/sidecarRuntime'; import { resolveSidecarProcess } from '@main/sidecar/sidecarRuntime';
@@ -59,6 +62,24 @@ type PendingCommand =
resolve: () => void; resolve: () => void;
reject: (error: Error) => 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; processId: number;
} & RunTurnPendingCommand); } & RunTurnPendingCommand);
@@ -106,8 +127,9 @@ export class SidecarClient {
onUserInput: (event: UserInputRequestedEvent) => void | Promise<void>, onUserInput: (event: UserInputRequestedEvent) => void | Promise<void>,
onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise<void>, onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise<void>,
onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise<void>, onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise<void>,
onTurnScopedEvent: (event: TurnScopedEvent) => void | Promise<void>,
): Promise<ChatMessageRecord[]> { ): 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> { async resolveUserInput(userInputId: string, answer: string, wasFreeform: boolean): Promise<void> {
@@ -138,6 +160,31 @@ export class SidecarClient {
} satisfies CancelTurnCommand); } 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> { async dispose(): Promise<void> {
const state = this.processState; const state = this.processState;
if (!state) { if (!state) {
@@ -225,6 +272,7 @@ export class SidecarClient {
onUserInput?: (event: UserInputRequestedEvent) => void | Promise<void>, onUserInput?: (event: UserInputRequestedEvent) => void | Promise<void>,
onMcpOAuthRequired?: (event: McpOauthRequiredEvent) => void | Promise<void>, onMcpOAuthRequired?: (event: McpOauthRequiredEvent) => void | Promise<void>,
onExitPlanMode?: (event: ExitPlanModeRequestedEvent) => void | Promise<void>, onExitPlanMode?: (event: ExitPlanModeRequestedEvent) => void | Promise<void>,
onTurnScopedEvent?: (event: TurnScopedEvent) => void | Promise<void>,
): Promise<TResult> { ): Promise<TResult> {
const state = await this.ensureProcess(); const state = await this.ensureProcess();
@@ -241,6 +289,7 @@ export class SidecarClient {
onUserInput: onUserInput ?? (() => undefined), onUserInput: onUserInput ?? (() => undefined),
onMcpOAuthRequired: onMcpOAuthRequired ?? (() => undefined), onMcpOAuthRequired: onMcpOAuthRequired ?? (() => undefined),
onExitPlanMode: onExitPlanMode ?? (() => undefined), onExitPlanMode: onExitPlanMode ?? (() => undefined),
onTurnScopedEvent: onTurnScopedEvent ?? (() => undefined),
errored: false, errored: false,
}); });
} else if (command.type === 'validate-pattern') { } else if (command.type === 'validate-pattern') {
@@ -271,6 +320,27 @@ export class SidecarClient {
resolve: resolve as () => void, resolve: resolve as () => void,
reject, 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 { } else {
this.pending.set(command.requestId, { this.pending.set(command.requestId, {
processId: state.id, processId: state.id,
@@ -348,6 +418,34 @@ export class SidecarClient {
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onExitPlanMode(event)); this.invokeRunTurnHandler(event.requestId, pending, () => pending.onExitPlanMode(event));
} }
return; 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': case 'turn-complete':
if (pending.kind === 'run-turn') { if (pending.kind === 'run-turn') {
if (shouldHandleRunTurnEvent(pending)) { if (shouldHandleRunTurnEvent(pending)) {
+1
View File
@@ -33,6 +33,7 @@ const api: ElectronApi = {
renameSession: (input) => ipcRenderer.invoke(ipcChannels.renameSession, input), renameSession: (input) => ipcRenderer.invoke(ipcChannels.renameSession, input),
setSessionPinned: (input) => ipcRenderer.invoke(ipcChannels.setSessionPinned, input), setSessionPinned: (input) => ipcRenderer.invoke(ipcChannels.setSessionPinned, input),
setSessionArchived: (input) => ipcRenderer.invoke(ipcChannels.setSessionArchived, input), setSessionArchived: (input) => ipcRenderer.invoke(ipcChannels.setSessionArchived, input),
deleteSession: (input) => ipcRenderer.invoke(ipcChannels.deleteSession, input),
sendSessionMessage: (input) => ipcRenderer.invoke(ipcChannels.sendSessionMessage, input), sendSessionMessage: (input) => ipcRenderer.invoke(ipcChannels.sendSessionMessage, input),
cancelSessionTurn: (input) => ipcRenderer.invoke(ipcChannels.cancelSessionTurn, input), cancelSessionTurn: (input) => ipcRenderer.invoke(ipcChannels.cancelSessionTurn, input),
resolveSessionApproval: (input) => ipcRenderer.invoke(ipcChannels.resolveSessionApproval, 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 { resolveChatToolingSettings } from '@renderer/lib/chatTooling';
import { import {
applySessionEventActivity, applySessionEventActivity,
applySessionUsageEvent,
applyTurnEventLog,
pruneSessionActivities, pruneSessionActivities,
pruneSessionUsage,
pruneTurnEventLogs,
type SessionActivityMap, type SessionActivityMap,
type SessionUsageMap,
type TurnEventLogMap,
} from '@renderer/lib/sessionActivity'; } from '@renderer/lib/sessionActivity';
import { applySessionEventWorkspace } from '@renderer/lib/sessionWorkspace'; import { applySessionEventWorkspace } from '@renderer/lib/sessionWorkspace';
import { WelcomePane } from '@renderer/components/WelcomePane'; import { WelcomePane } from '@renderer/components/WelcomePane';
@@ -91,6 +97,8 @@ export default function App() {
const [error, setError] = useState<string>(); const [error, setError] = useState<string>();
const { capabilities: sidecarCapabilities, isRefreshing: isRefreshingCapabilities, refresh: refreshCapabilities } = useSidecarCapabilities(api); const { capabilities: sidecarCapabilities, isRefreshing: isRefreshingCapabilities, refresh: refreshCapabilities } = useSidecarCapabilities(api);
const [sessionActivities, setSessionActivities] = useState<SessionActivityMap>({}); const [sessionActivities, setSessionActivities] = useState<SessionActivityMap>({});
const [sessionUsage, setSessionUsage] = useState<SessionUsageMap>({});
const [turnEventLogs, setTurnEventLogs] = useState<TurnEventLogMap>({});
const [showSettings, setShowSettings] = useState(false); const [showSettings, setShowSettings] = useState(false);
const [newSessionProjectId, setNewSessionProjectId] = useState<string>(); const [newSessionProjectId, setNewSessionProjectId] = useState<string>();
@@ -114,11 +122,25 @@ export default function App() {
ws.sessions.map((session) => session.id), 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) => { const offSessionEvent = api.onSessionEvent((event) => {
setWorkspace((current) => applySessionEventWorkspace(current, event)); setWorkspace((current) => applySessionEventWorkspace(current, event));
setSessionActivities((current) => applySessionEventActivity(current, event)); setSessionActivities((current) => applySessionEventActivity(current, event));
setSessionUsage((current) => applySessionUsageEvent(current, event));
setTurnEventLogs((current) => applyTurnEventLog(current, event));
}); });
return () => { return () => {
@@ -169,6 +191,14 @@ export default function App() {
() => (selectedSession ? sessionActivities[selectedSession.id] : undefined), () => (selectedSession ? sessionActivities[selectedSession.id] : undefined),
[selectedSession, sessionActivities], [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( const hasUserProjects = useMemo(
() => (workspace?.projects.some((project) => !isScratchpadProject(project)) ?? false), () => (workspace?.projects.some((project) => !isScratchpadProject(project)) ?? false),
[workspace?.projects], [workspace?.projects],
@@ -245,7 +275,12 @@ export default function App() {
} else if (selectedSession && patternForSession && projectForSession) { } else if (selectedSession && patternForSession && projectForSession) {
content = ( content = (
<ChatPane <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 }); }} onCancelTurn={() => { void api.cancelSessionTurn({ sessionId: selectedSession.id }); }}
onResolveApproval={(approvalId, decision, alwaysApprove) => onResolveApproval={(approvalId, decision, alwaysApprove) =>
api.resolveSessionApproval({ sessionId: selectedSession.id, approvalId, decision, alwaysApprove }) api.resolveSessionApproval({ sessionId: selectedSession.id, approvalId, decision, alwaysApprove })
@@ -290,6 +325,7 @@ export default function App() {
project={projectForSession} project={projectForSession}
runtimeTools={sidecarCapabilities?.runtimeTools} runtimeTools={sidecarCapabilities?.runtimeTools}
session={selectedSession} session={selectedSession}
sessionUsage={usageForSession}
toolingSettings={chatToolingSettings ?? workspace.settings.tooling} toolingSettings={chatToolingSettings ?? workspace.settings.tooling}
/> />
); );
@@ -299,6 +335,7 @@ export default function App() {
onJumpToMessage={jumpToMessage} onJumpToMessage={jumpToMessage}
pattern={patternForSession} pattern={patternForSession}
session={selectedSession} session={selectedSession}
turnEvents={turnEventsForSession}
/> />
); );
} else { } else {
@@ -404,6 +441,9 @@ export default function App() {
onSetSessionArchived={(sessionId, isArchived) => { onSetSessionArchived={(sessionId, isArchived) => {
void api.setSessionArchived({ sessionId, isArchived }); void api.setSessionArchived({ sessionId, isArchived });
}} }}
onDeleteSession={(sessionId) => {
void api.deleteSession({ sessionId });
}}
onRefreshGitContext={(projectId) => { onRefreshGitContext={(projectId) => {
void api.refreshProjectGitContext(projectId); void api.refreshProjectGitContext(projectId);
}} }}
+66 -1
View File
@@ -1,5 +1,5 @@
import { useMemo, type ReactNode } from 'react'; 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 { import {
buildAgentActivityRows, buildAgentActivityRows,
@@ -8,6 +8,7 @@ import {
isAgentActivityCompleted, isAgentActivityCompleted,
type AgentActivityRow, type AgentActivityRow,
type SessionActivityState, type SessionActivityState,
type TurnEventLog,
} from '@renderer/lib/sessionActivity'; } from '@renderer/lib/sessionActivity';
import { RunTimeline } from '@renderer/components/RunTimeline'; import { RunTimeline } from '@renderer/components/RunTimeline';
import { inferProvider } from '@shared/domain/models'; 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 ─────────────────────────────────────────── */ /* ── ActivityPanel ─────────────────────────────────────────── */
interface ActivityPanelProps { interface ActivityPanelProps {
@@ -152,6 +182,7 @@ interface ActivityPanelProps {
onJumpToMessage?: (messageId: string) => void; onJumpToMessage?: (messageId: string) => void;
pattern: PatternDefinition; pattern: PatternDefinition;
session: SessionRecord; session: SessionRecord;
turnEvents?: TurnEventLog;
} }
export function ActivityPanel({ export function ActivityPanel({
@@ -159,6 +190,7 @@ export function ActivityPanel({
onJumpToMessage, onJumpToMessage,
pattern, pattern,
session, session,
turnEvents,
}: ActivityPanelProps) { }: ActivityPanelProps) {
const activityRows = useMemo( const activityRows = useMemo(
() => buildAgentActivityRows(activity, pattern.agents), () => buildAgentActivityRows(activity, pattern.agents),
@@ -239,6 +271,39 @@ export function ActivityPanel({
<RunTimeline onJumpToMessage={onJumpToMessage} runs={session.runs} /> <RunTimeline onJumpToMessage={onJumpToMessage} runs={session.runs} />
</div> </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>
</div> </div>
+144 -16
View File
@@ -1,5 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'; 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 { MarkdownContent } from '@renderer/components/MarkdownContent';
import { MarkdownComposer, type MarkdownComposerHandle } from '@renderer/components/MarkdownComposer'; 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 { ThinkingDots } from '@renderer/components/chat/ThinkingDots';
import { getAssistantMessagePhase } from '@renderer/lib/messagePhase'; import { getAssistantMessagePhase } from '@renderer/lib/messagePhase';
import type { ApprovalDecision } from '@shared/domain/approval'; 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 { import {
findModel, findModel,
getSupportedReasoningEfforts, getSupportedReasoningEfforts,
@@ -37,7 +40,8 @@ interface ChatPaneProps {
availableModels: ReadonlyArray<ModelDefinition>; availableModels: ReadonlyArray<ModelDefinition>;
toolingSettings: WorkspaceToolingSettings; toolingSettings: WorkspaceToolingSettings;
runtimeTools?: ReadonlyArray<RuntimeToolDefinition>; runtimeTools?: ReadonlyArray<RuntimeToolDefinition>;
onSend: (content: string) => Promise<void>; sessionUsage?: SessionUsageState;
onSend: (content: string, attachments?: ChatMessageAttachment[], messageMode?: MessageMode) => Promise<void>;
onCancelTurn?: () => void; onCancelTurn?: () => void;
onResolveApproval?: (approvalId: string, decision: ApprovalDecision, alwaysApprove?: boolean) => Promise<unknown>; onResolveApproval?: (approvalId: string, decision: ApprovalDecision, alwaysApprove?: boolean) => Promise<unknown>;
onResolveUserInput?: (userInputId: string, answer: string, wasFreeform: boolean) => Promise<unknown>; onResolveUserInput?: (userInputId: string, answer: string, wasFreeform: boolean) => Promise<unknown>;
@@ -60,6 +64,7 @@ export function ChatPane({
availableModels, availableModels,
toolingSettings, toolingSettings,
runtimeTools, runtimeTools,
sessionUsage,
onSend, onSend,
onCancelTurn, onCancelTurn,
onResolveApproval, onResolveApproval,
@@ -99,8 +104,9 @@ export function ChatPane({
const selectedModel = primaryAgent ? findModel(primaryAgent.model, availableModels) : undefined; const selectedModel = primaryAgent ? findModel(primaryAgent.model, availableModels) : undefined;
const supportedEfforts = getSupportedReasoningEfforts(selectedModel); const supportedEfforts = getSupportedReasoningEfforts(selectedModel);
const sessionReasoningEffort = resolveReasoningEffort(selectedModel, primaryAgent?.reasoningEffort); const sessionReasoningEffort = resolveReasoningEffort(selectedModel, primaryAgent?.reasoningEffort);
const isComposerDisabled = isSessionBusy || isUpdatingSessionModelConfig; const isComposerDisabled = isUpdatingSessionModelConfig;
const canSubmitInput = hasComposerContent && !isComposerDisabled; const canSubmitInput = hasComposerContent && !isComposerDisabled;
const [pendingAttachments, setPendingAttachments] = useState<ChatMessageAttachment[]>([]);
const toolSelection = useMemo(() => resolveSessionToolingSelection(session), [session]); const toolSelection = useMemo(() => resolveSessionToolingSelection(session), [session]);
const mcpServers = toolingSettings.mcpServers; const mcpServers = toolingSettings.mcpServers;
@@ -140,7 +146,10 @@ export function ChatPane({
}, [session.id]); }, [session.id]);
function handleComposerSubmit(content: string) { 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() { function handleDismissPlan() {
@@ -336,6 +345,29 @@ export function ChatPane({
: `rounded-xl border px-4 py-3 text-[14px] leading-relaxed text-zinc-200 ${assistantContainerClass}` : `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 ? ( {!isUser && message.pending ? (
<div className="whitespace-pre-wrap break-words text-[14px] leading-relaxed text-zinc-200"> <div className="whitespace-pre-wrap break-words text-[14px] leading-relaxed text-zinc-200">
{message.content} {message.content}
@@ -510,6 +542,29 @@ export function ChatPane({
</div> </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"> <div className="rounded-xl border border-zinc-700 bg-zinc-900 transition-colors focus-within:border-indigo-500/50">
<MarkdownComposer <MarkdownComposer
ref={composerRef} ref={composerRef}
@@ -526,7 +581,7 @@ export function ChatPane({
: pendingMcpAuth : pendingMcpAuth
? 'MCP server requires authentication...' ? 'MCP server requires authentication...'
: isSessionBusy : isSessionBusy
? 'Waiting for response...' ? 'Steer the agent (sends immediately)...'
: isUpdatingSessionModelConfig : isUpdatingSessionModelConfig
? 'Saving model settings...' ? 'Saving model settings...'
: isPlanMode : isPlanMode
@@ -535,6 +590,38 @@ export function ChatPane({
} }
> >
<div className="absolute bottom-2 right-2 flex items-center gap-1"> <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 */} {/* Plan mode toggle */}
{onSetInteractionMode && !isSessionBusy && ( {onSetInteractionMode && !isSessionBusy && (
<button <button
@@ -553,29 +640,39 @@ export function ChatPane({
</button> </button>
)} )}
{/* Send / Stop button */} {/* Send / Stop / Steer button */}
<button <button
className={`flex size-8 items-center justify-center rounded-lg transition ${ 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' ? 'bg-red-600/80 text-white hover:bg-red-500'
: canSubmitInput : canSubmitInput || pendingAttachments.length > 0
? isPlanMode ? isSessionBusy
? 'bg-emerald-600 text-white hover:bg-emerald-500' ? 'bg-amber-600 text-white hover:bg-amber-500'
: 'bg-indigo-600 text-white hover:bg-indigo-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' : 'bg-zinc-800 text-zinc-600'
}`} }`}
disabled={!canSubmitInput && !isSessionBusy} disabled={!canSubmitInput && !isSessionBusy && pendingAttachments.length === 0}
onClick={() => { onClick={() => {
if (isSessionBusy) { if (isSessionBusy && !hasComposerContent && pendingAttachments.length === 0) {
onCancelTurn?.(); onCancelTurn?.();
} else { } else {
composerRef.current?.submit(); composerRef.current?.submit();
} }
}} }}
type="button" 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" /> <Square className="size-3.5" fill="currentColor" />
) : ( ) : (
<ArrowUp className="size-4" /> <ArrowUp className="size-4" />
@@ -591,7 +688,38 @@ export function ChatPane({
</span> </span>
</div> </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> </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> </div>
</div> </div>
+15 -1
View File
@@ -21,6 +21,7 @@ import {
RefreshCw, RefreshCw,
Search, Search,
Settings, Settings,
Trash2,
Users, Users,
X, X,
type LucideIcon, type LucideIcon,
@@ -45,6 +46,7 @@ interface SidebarProps {
onDuplicateSession: (sessionId: string) => void; onDuplicateSession: (sessionId: string) => void;
onSetSessionPinned: (sessionId: string, isPinned: boolean) => void; onSetSessionPinned: (sessionId: string, isPinned: boolean) => void;
onSetSessionArchived: (sessionId: string, isArchived: boolean) => void; onSetSessionArchived: (sessionId: string, isArchived: boolean) => void;
onDeleteSession: (sessionId: string) => void;
onRefreshGitContext: (projectId: string) => void; onRefreshGitContext: (projectId: string) => void;
} }
@@ -128,14 +130,16 @@ function ActionMenuItem({
icon: Icon, icon: Icon,
label, label,
onClick, onClick,
className,
}: { }: {
icon: LucideIcon; icon: LucideIcon;
label: string; label: string;
onClick: () => void; onClick: () => void;
className?: string;
}) { }) {
return ( return (
<button <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} onClick={onClick}
role="menuitem" role="menuitem"
type="button" type="button"
@@ -475,6 +479,7 @@ export function Sidebar({
onDuplicateSession, onDuplicateSession,
onSetSessionPinned, onSetSessionPinned,
onSetSessionArchived, onSetSessionArchived,
onDeleteSession,
onRefreshGitContext, onRefreshGitContext,
}: SidebarProps) { }: SidebarProps) {
const scratchpadProject = workspace.projects.find((project) => isScratchpadProject(project)); const scratchpadProject = workspace.projects.find((project) => isScratchpadProject(project));
@@ -742,6 +747,15 @@ export function Sidebar({
closeMenu(); closeMenu();
}} }}
/> />
<ActionMenuItem
className="text-red-400 hover:bg-red-500/10"
icon={Trash2}
label="Delete"
onClick={() => {
onDeleteSession(menuState.sessionId);
closeMenu();
}}
/>
</div> </div>
</> </>
)} )}
+135
View File
@@ -8,8 +8,15 @@ export interface AgentActivityState {
toolName?: string; toolName?: string;
} }
export interface SessionUsageState {
tokenLimit: number;
currentTokens: number;
messagesLength: number;
}
export type SessionActivityState = Record<string, AgentActivityState>; export type SessionActivityState = Record<string, AgentActivityState>;
export type SessionActivityMap = Record<string, SessionActivityState | undefined>; export type SessionActivityMap = Record<string, SessionActivityState | undefined>;
export type SessionUsageMap = Record<string, SessionUsageState | undefined>;
export interface AgentActivityRow { export interface AgentActivityRow {
key: string; key: string;
@@ -184,3 +191,131 @@ function clearActiveSessionActivity(
function resolveAgentKey(event: SessionEventRecord): string | undefined { function resolveAgentKey(event: SessionEventRecord): string | undefined {
return event.agentId?.trim() || event.agentName?.trim(); 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', renameSession: 'sessions:rename',
setSessionPinned: 'sessions:set-pinned', setSessionPinned: 'sessions:set-pinned',
setSessionArchived: 'sessions:set-archived', setSessionArchived: 'sessions:set-archived',
deleteSession: 'sessions:delete',
sendSessionMessage: 'sessions:send-message', sendSessionMessage: 'sessions:send-message',
cancelSessionTurn: 'sessions:cancel-turn', cancelSessionTurn: 'sessions:cancel-turn',
resolveSessionApproval: 'sessions:resolve-approval', resolveSessionApproval: 'sessions:resolve-approval',
+9 -1
View File
@@ -1,5 +1,5 @@
import type { ApprovalDecision } from '@shared/domain/approval'; 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 { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
import type { ProjectRecord } from '@shared/domain/project'; import type { ProjectRecord } from '@shared/domain/project';
import type { QuerySessionsInput, SessionQueryResult } from '@shared/domain/sessionLibrary'; import type { QuerySessionsInput, SessionQueryResult } from '@shared/domain/sessionLibrary';
@@ -11,6 +11,7 @@ import type {
AppearanceTheme, AppearanceTheme,
} from '@shared/domain/tooling'; } from '@shared/domain/tooling';
import type { WorkspaceState } from '@shared/domain/workspace'; import type { WorkspaceState } from '@shared/domain/workspace';
import type { ChatMessageAttachment } from '@shared/domain/attachment';
export interface CreateSessionInput { export interface CreateSessionInput {
projectId: string; projectId: string;
@@ -24,6 +25,8 @@ export interface SavePatternInput {
export interface SendSessionMessageInput { export interface SendSessionMessageInput {
sessionId: string; sessionId: string;
content: string; content: string;
attachments?: ChatMessageAttachment[];
messageMode?: MessageMode;
} }
export interface CancelSessionTurnInput { export interface CancelSessionTurnInput {
@@ -125,6 +128,10 @@ export interface StartSessionMcpAuthInput {
sessionId: string; sessionId: string;
} }
export interface DeleteSessionInput {
sessionId: string;
}
export interface ElectronApi { export interface ElectronApi {
describeSidecarCapabilities(): Promise<SidecarCapabilities>; describeSidecarCapabilities(): Promise<SidecarCapabilities>;
refreshSidecarCapabilities(): Promise<SidecarCapabilities>; refreshSidecarCapabilities(): Promise<SidecarCapabilities>;
@@ -148,6 +155,7 @@ export interface ElectronApi {
renameSession(input: RenameSessionInput): Promise<WorkspaceState>; renameSession(input: RenameSessionInput): Promise<WorkspaceState>;
setSessionPinned(input: SetSessionPinnedInput): Promise<WorkspaceState>; setSessionPinned(input: SetSessionPinnedInput): Promise<WorkspaceState>;
setSessionArchived(input: SetSessionArchivedInput): Promise<WorkspaceState>; setSessionArchived(input: SetSessionArchivedInput): Promise<WorkspaceState>;
deleteSession(input: DeleteSessionInput): Promise<WorkspaceState>;
sendSessionMessage(input: SendSessionMessageInput): Promise<void>; sendSessionMessage(input: SendSessionMessageInput): Promise<void>;
cancelSessionTurn(input: CancelSessionTurnInput): Promise<void>; cancelSessionTurn(input: CancelSessionTurnInput): Promise<void>;
resolveSessionApproval(input: ResolveSessionApprovalInput): Promise<WorkspaceState>; 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 { ApprovalCheckpointKind, ApprovalDecision } from '@shared/domain/approval';
import type { ChatMessageRecord } from '@shared/domain/session'; import type { ChatMessageRecord } from '@shared/domain/session';
import type { RuntimeToolDefinition } from '@shared/domain/tooling'; import type { RuntimeToolDefinition } from '@shared/domain/tooling';
import type { ChatMessageAttachment } from '@shared/domain/attachment';
export interface SidecarModeCapability { export interface SidecarModeCapability {
available: boolean; available: boolean;
@@ -69,6 +70,7 @@ export interface ValidatePatternCommand {
} }
export type InteractionMode = 'interactive' | 'plan'; export type InteractionMode = 'interactive' | 'plan';
export type MessageMode = 'enqueue' | 'immediate';
export interface RunTurnCommand { export interface RunTurnCommand {
type: 'run-turn'; type: 'run-turn';
@@ -77,8 +79,10 @@ export interface RunTurnCommand {
projectPath: string; projectPath: string;
workspaceKind?: 'project' | 'scratchpad'; workspaceKind?: 'project' | 'scratchpad';
mode?: InteractionMode; mode?: InteractionMode;
messageMode?: MessageMode;
pattern: PatternDefinition; pattern: PatternDefinition;
messages: ChatMessageRecord[]; messages: ChatMessageRecord[];
attachments?: ChatMessageAttachment[];
tooling?: RunTurnToolingConfig; tooling?: RunTurnToolingConfig;
} }
@@ -104,13 +108,42 @@ export interface ResolveUserInputCommand {
wasFreeform: boolean; 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 = export type SidecarCommand =
| DescribeCapabilitiesCommand | DescribeCapabilitiesCommand
| ValidatePatternCommand | ValidatePatternCommand
| RunTurnCommand | RunTurnCommand
| CancelTurnCommand | CancelTurnCommand
| ResolveApprovalCommand | ResolveApprovalCommand
| ResolveUserInputCommand; | ResolveUserInputCommand
| ListSessionsCommand
| DeleteSessionCommand
| DisconnectSessionCommand;
export interface RunTurnLocalMcpServerConfig { export interface RunTurnLocalMcpServerConfig {
id: string; id: string;
@@ -150,6 +183,30 @@ export interface RunTurnToolingConfig {
lspProfiles: RunTurnLspProfileConfig[]; 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 { export interface CapabilitiesEvent {
type: 'capabilities'; type: 'capabilities';
requestId: string; requestId: string;
@@ -194,6 +251,137 @@ export interface AgentActivityEvent {
toolName?: string; 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 { export interface PermissionDetail {
kind: string; kind: string;
intention?: string; intention?: string;
@@ -292,9 +480,18 @@ export type SidecarEvent =
| TurnDeltaEvent | TurnDeltaEvent
| TurnCompleteEvent | TurnCompleteEvent
| AgentActivityEvent | AgentActivityEvent
| SubagentEvent
| SkillInvokedEvent
| HookLifecycleEvent
| SessionUsageEvent
| SessionCompactionEvent
| PendingMessagesModifiedEvent
| ApprovalRequestedEvent | ApprovalRequestedEvent
| UserInputRequestedEvent | UserInputRequestedEvent
| McpOauthRequiredEvent | McpOauthRequiredEvent
| ExitPlanModeRequestedEvent | ExitPlanModeRequestedEvent
| SessionsListedEvent
| SessionsDeletedEvent
| SessionDisconnectedEvent
| CommandErrorEvent | CommandErrorEvent
| CommandCompleteEvent; | 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' | 'message-complete'
| 'agent-activity' | 'agent-activity'
| 'run-updated' | '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 { export interface SessionEventRecord {
sessionId: string; sessionId: string;
@@ -27,4 +35,32 @@ export interface SessionEventRecord {
toolName?: string; toolName?: string;
run?: SessionRunRecord; run?: SessionRunRecord;
error?: string; 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' }, { value: 'xhigh', label: 'Maximum' },
]; ];
import type { PatternAgentCopilotConfig } from '@shared/contracts/sidecar';
export interface PatternAgentDefinition { export interface PatternAgentDefinition {
id: string; id: string;
name: string; name: string;
@@ -39,6 +41,7 @@ export interface PatternAgentDefinition {
instructions: string; instructions: string;
model: string; model: string;
reasoningEffort?: ReasoningEffort; reasoningEffort?: ReasoningEffort;
copilot?: PatternAgentCopilotConfig;
} }
export interface PatternGraphPosition { 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 { PendingUserInputRecord } from '@shared/domain/userInput';
import type { PendingPlanReviewRecord } from '@shared/domain/planReview'; import type { PendingPlanReviewRecord } from '@shared/domain/planReview';
import type { PendingMcpAuthRecord } from '@shared/domain/mcpAuth'; import type { PendingMcpAuthRecord } from '@shared/domain/mcpAuth';
import type { ChatMessageAttachment } from '@shared/domain/attachment';
import type { InteractionMode } from '@shared/contracts/sidecar'; import type { InteractionMode } from '@shared/contracts/sidecar';
export type ChatRole = 'system' | 'user' | 'assistant'; export type ChatRole = 'system' | 'user' | 'assistant';
@@ -32,6 +33,7 @@ export interface ChatMessageRecord {
content: string; content: string;
createdAt: string; createdAt: string;
pending?: boolean; pending?: boolean;
attachments?: ChatMessageAttachment[];
} }
export interface SessionRecord { export interface SessionRecord {
+2
View File
@@ -19,6 +19,7 @@ describe('run turn pending helpers', () => {
onUserInput: () => undefined, onUserInput: () => undefined,
onExitPlanMode: () => undefined, onExitPlanMode: () => undefined,
onMcpOAuthRequired: () => undefined, onMcpOAuthRequired: () => undefined,
onTurnScopedEvent: () => undefined,
errored: false, errored: false,
}; };
@@ -44,6 +45,7 @@ describe('run turn pending helpers', () => {
onUserInput: () => undefined, onUserInput: () => undefined,
onExitPlanMode: () => undefined, onExitPlanMode: () => undefined,
onMcpOAuthRequired: () => undefined, onMcpOAuthRequired: () => undefined,
onTurnScopedEvent: () => undefined,
errored: false, errored: false,
}; };
+29 -3
View File
@@ -141,7 +141,7 @@ import Base from '../layouts/Base.astro';
</div> </div>
<h3 class="text-base font-semibold">Live Visibility</h3> <h3 class="text-base font-semibold">Live Visibility</h3>
<p class="mt-2 text-sm leading-relaxed text-zinc-400"> <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> </p>
</div> </div>
@@ -154,11 +154,37 @@ import Base from '../layouts/Base.astro';
</div> </div>
<h3 class="text-base font-semibold">Persistent Sessions</h3> <h3 class="text-base font-semibold">Persistent Sessions</h3>
<p class="mt-2 text-sm leading-relaxed text-zinc-400"> <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> </p>
</div> </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="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"> <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"> <svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">