mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-24 13:38:43 +02:00
feat: full Copilot SDK feature parity — custom agents, hooks, image input, skills, steering, session persistence
Backend (sidecar): - Extended ProtocolModels with DTOs for custom agents, hooks, skills, infinite sessions, session lifecycle, and 9 new event types - Added CopilotManagedSessionIds for stable SDK session ID mapping - Added CopilotSessionManager/ICopilotSessionManager for session lifecycle - Added CopilotSessionHooks for hook registration - Added CopilotMessageOptionsMetadata for mid-turn steering - Extended CopilotAgentBundle to wire custom agents, hooks, skills, infinite sessions, and stable session IDs - Extended CopilotTurnExecutionState to project 13 new SDK event types - Widened ITurnWorkflowRunner callback to accept SidecarEventDto - Added list/delete/disconnect session commands to SidecarProtocolHost - Added AryxCopilotAgentMessageOptionsTests (14 new tests, 142 total) Frontend (renderer + main + shared): - Added ChatMessageAttachment type and helpers (attachment.ts) - Extended sidecar contracts with MessageMode, 3 new command types, 9 new event types, and agent/session config DTOs - Extended SessionEventRecord with 6 new event kinds and ~20 fields - Added PatternAgentCopilotConfig to pattern domain - Added attachments support to ChatMessageRecord - Updated sidecar client with session lifecycle methods and turn-scoped event routing via onTurnScopedEvent callback - Updated main process: handleTurnScopedEvent(), deleteSession(), steering bypass for mid-turn messages, attachment passthrough - Added deleteSession IPC handler and preload binding - Added TurnEventLog state tracker with format/apply/prune helpers - ChatPane: always-enabled composer, steering indicator, attachment picker with preview, image thumbnails in message history, context-usage bar, amber steer mode for send button - ActivityPanel: turn events section with sub-agent, hook, skill, and compaction event rendering - Sidebar: delete session action in context menu - App.tsx: wired sessionUsage, turnEventLogs, and deleteSession Documentation: - AGENTS.md: added glob safety rule for node_modules - README.md: added steering, image input, and richer observability - ARCHITECTURE.md: added turn-scoped events, steering, and attachments - Website: added steering and image input feature cards, updated live visibility and session cards Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -154,6 +154,7 @@ Every interactive component must include basic accessibility:
|
||||
- Keep changes focused and reviewable. Avoid mixing unrelated concerns into a single change.
|
||||
- Always commit completed repository changes before handing work off. If unrelated pre-existing changes are present in the worktree, stop and ask the user how to proceed before creating the commit.
|
||||
- Do not mark work as done until both the implementation and its verification are complete.
|
||||
- **Never use unscoped glob patterns** (e.g. `**/*`) at or near the repository root. The repository contains large `node_modules/` directories that will cause glob operations to hang or exhaust resources. Always scope globs to a specific subdirectory (e.g. `src/**/*.ts`, `sidecar/src/**/*.cs`) or use `view` on known directories instead.
|
||||
|
||||
## 8. Planning requirements
|
||||
|
||||
|
||||
@@ -202,6 +202,17 @@ This is a structured stdio protocol used for:
|
||||
|
||||
This protocol boundary keeps the AI execution runtime replaceable and prevents the Electron main process from becoming overloaded with workflow-specific behavior.
|
||||
|
||||
The protocol also carries **turn-scoped lifecycle events** alongside output deltas. These events let the UI visualize execution internals without the main process having to interpret AI workflow semantics:
|
||||
|
||||
- **Sub-agent events**: started, completed, failed, selected, deselected — surfaced when custom agents are defined
|
||||
- **Skill invocation events**: emitted when an agent-side skill is triggered
|
||||
- **Hook lifecycle events**: start and end of registered hooks (e.g. pre-turn, post-turn)
|
||||
- **Session compaction events**: start and complete, with token-reduction metrics when infinite sessions trigger context trimming
|
||||
- **Session usage events**: current token count and context-window limit for usage-bar rendering
|
||||
- **Pending-messages-modified events**: emitted when mid-turn steering changes the pending message queue
|
||||
|
||||
These events flow through a single `onTurnScopedEvent` callback on the `runTurn` command, avoiding per-event-type callback proliferation. The main process maps each event to a `SessionEventRecord` and pushes it to the renderer, where lightweight state maps (activity, usage, turn-event log) consume them without touching the persisted workspace.
|
||||
|
||||
## Security model
|
||||
|
||||
Security in this system is mostly about **desktop trust boundaries**.
|
||||
@@ -273,11 +284,20 @@ The architecture treats execution as observable by design:
|
||||
|
||||
- partial output is streamed
|
||||
- agent activity is surfaced
|
||||
- turn-scoped lifecycle events (sub-agent, hook, skill, compaction, usage) are streamed
|
||||
- runs are persisted as timeline history
|
||||
- failures are represented explicitly
|
||||
|
||||
This improves trust and debuggability, especially for multi-agent workflows.
|
||||
|
||||
### Mid-turn steering
|
||||
|
||||
Aryx supports sending user messages while a turn is actively running. These messages are delivered with a `messageMode` flag (`immediate` or `enqueue`) that tells the sidecar to inject the content into the current Copilot session rather than starting a new turn. This enables real-time steering without waiting for turn completion. The main process allows the IPC call even when the session is in `running` status, and the renderer keeps the composer enabled throughout.
|
||||
|
||||
### Image attachments
|
||||
|
||||
User messages can carry image attachments as base64-encoded blobs. These flow from the renderer through IPC, the main process, and the sidecar protocol as `ChatMessageAttachmentDto` objects alongside the text content. The sidecar maps them into the Copilot SDK's `DataPart` model. Attachment metadata is persisted on the `ChatMessageRecord` so thumbnail previews render correctly when revisiting a session.
|
||||
|
||||
## Persistence and repair
|
||||
|
||||
Workspace persistence is intentionally simple: the app stores a durable workspace document and repairs or normalizes it when loading.
|
||||
|
||||
@@ -17,8 +17,10 @@ It works especially well when you want AI help that stays grounded in an actual
|
||||
- **Start fast** with a scratchpad conversation for quick questions and ad-hoc work.
|
||||
- **Work against real projects** by attaching local folders and letting Aryx stay aware of repository context.
|
||||
- **Go beyond one assistant** with orchestration patterns such as single-agent, sequential, concurrent, handoff, and group-chat flows.
|
||||
- **See what is happening** with live activity for each agent while a run is in progress.
|
||||
- **Stay organized** with persistent sessions you can rename, pin, archive, and return to later.
|
||||
- **See what is happening** with live activity for each agent while a run is in progress, including sub-agent delegations, hook lifecycle, skill invocations, and context compaction.
|
||||
- **Stay organized** with persistent sessions you can rename, pin, archive, delete, and return to later.
|
||||
- **Steer while agents work** by sending follow-up messages mid-turn — the agent receives your input immediately.
|
||||
- **Attach images** to any message for visual context the model can reason about.
|
||||
- **Tune how you work** by choosing models and reusing saved patterns that fit different tasks.
|
||||
|
||||
## What you can do in the app
|
||||
@@ -51,11 +53,19 @@ Patterns now require tool-call approval by default. They can also store default
|
||||
|
||||
### Watch runs as they happen
|
||||
|
||||
You can follow agent activity while a session is running, which makes longer or more complex workflows easier to trust and understand.
|
||||
You can follow agent activity while a session is running, which makes longer or more complex workflows easier to trust and understand. The activity panel shows sub-agent delegations, skill invocations, hook lifecycle events, and context compaction in real time. A context-usage bar below the composer shows how much of the model's context window the current session occupies.
|
||||
|
||||
### Steer agents mid-turn
|
||||
|
||||
While an agent is working, you can type a follow-up message that is delivered immediately into the current turn. This lets you redirect, refine, or add context without waiting for the turn to finish. The composer shows an amber "steering" indicator when a message will be injected into an active run.
|
||||
|
||||
### Attach images
|
||||
|
||||
You can attach images (JPEG, PNG, GIF, WebP) to any message using the clip button, drag-and-drop, or paste from clipboard. Image attachments are sent as base64-encoded blobs so the model can reason about visual content alongside your text.
|
||||
|
||||
### Keep important work around
|
||||
|
||||
Sessions are persistent, so you can return to ongoing work instead of starting from scratch every time. You can also rename, pin, archive, and duplicate sessions as your workspace grows.
|
||||
Sessions are persistent, so you can return to ongoing work instead of starting from scratch every time. You can also rename, pin, archive, delete, and duplicate sessions as your workspace grows.
|
||||
|
||||
## Before you start
|
||||
|
||||
|
||||
@@ -10,6 +10,16 @@ public sealed class PatternAgentDefinitionDto
|
||||
public string Instructions { get; init; } = string.Empty;
|
||||
public string Model { get; init; } = string.Empty;
|
||||
public string? ReasoningEffort { get; init; }
|
||||
public PatternAgentCopilotConfigDto? Copilot { get; init; }
|
||||
}
|
||||
|
||||
public sealed class PatternAgentCopilotConfigDto
|
||||
{
|
||||
public IReadOnlyList<RunTurnCustomAgentConfigDto> CustomAgents { get; init; } = [];
|
||||
public string? Agent { get; init; }
|
||||
public IReadOnlyList<string> SkillDirectories { get; init; } = [];
|
||||
public IReadOnlyList<string> DisabledSkills { get; init; } = [];
|
||||
public RunTurnInfiniteSessionsConfigDto? InfiniteSessions { get; init; }
|
||||
}
|
||||
|
||||
public sealed class PatternGraphPositionDto
|
||||
@@ -75,6 +85,16 @@ public sealed class ChatMessageDto
|
||||
public string AuthorName { get; init; } = string.Empty;
|
||||
public string Content { get; init; } = string.Empty;
|
||||
public string CreatedAt { get; init; } = string.Empty;
|
||||
public IReadOnlyList<ChatMessageAttachmentDto> Attachments { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class ChatMessageAttachmentDto
|
||||
{
|
||||
public string Type { get; init; } = string.Empty;
|
||||
public string? Path { get; init; }
|
||||
public string? Data { get; init; }
|
||||
public string? MimeType { get; init; }
|
||||
public string? DisplayName { get; init; }
|
||||
}
|
||||
|
||||
public sealed class PatternValidationIssueDto
|
||||
@@ -162,6 +182,7 @@ public sealed class RunTurnCommandDto : SidecarCommandEnvelope
|
||||
public string ProjectPath { get; init; } = string.Empty;
|
||||
public string WorkspaceKind { get; init; } = "project";
|
||||
public string Mode { get; init; } = "interactive";
|
||||
public string MessageMode { get; init; } = "enqueue";
|
||||
public PatternDefinitionDto Pattern { get; init; } = new();
|
||||
public IReadOnlyList<ChatMessageDto> Messages { get; init; } = [];
|
||||
public RunTurnToolingConfigDto? Tooling { get; init; }
|
||||
@@ -186,6 +207,22 @@ public sealed class ResolveUserInputCommandDto : SidecarCommandEnvelope
|
||||
public bool WasFreeform { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ListSessionsCommandDto : SidecarCommandEnvelope
|
||||
{
|
||||
public CopilotSessionListFilterDto? Filter { get; init; }
|
||||
}
|
||||
|
||||
public sealed class DeleteSessionCommandDto : SidecarCommandEnvelope
|
||||
{
|
||||
public string? SessionId { get; init; }
|
||||
public string? CopilotSessionId { get; init; }
|
||||
}
|
||||
|
||||
public sealed class DisconnectSessionCommandDto : SidecarCommandEnvelope
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
public sealed class RunTurnToolingConfigDto
|
||||
{
|
||||
public IReadOnlyList<RunTurnMcpServerConfigDto> McpServers { get; init; } = [];
|
||||
@@ -217,6 +254,48 @@ public sealed class RunTurnLspProfileConfigDto
|
||||
public IReadOnlyList<string> FileExtensions { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class RunTurnCustomAgentConfigDto
|
||||
{
|
||||
public string Name { get; init; } = string.Empty;
|
||||
public string? DisplayName { get; init; }
|
||||
public string? Description { get; init; }
|
||||
public IReadOnlyList<string>? Tools { get; init; }
|
||||
public string Prompt { get; init; } = string.Empty;
|
||||
public IReadOnlyList<RunTurnMcpServerConfigDto> McpServers { get; init; } = [];
|
||||
public bool? Infer { get; init; }
|
||||
}
|
||||
|
||||
public sealed class RunTurnInfiniteSessionsConfigDto
|
||||
{
|
||||
public bool? Enabled { get; init; }
|
||||
public double? BackgroundCompactionThreshold { get; init; }
|
||||
public double? BufferExhaustionThreshold { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CopilotSessionListFilterDto
|
||||
{
|
||||
public string? Cwd { get; init; }
|
||||
public string? GitRoot { get; init; }
|
||||
public string? Repository { get; init; }
|
||||
public string? Branch { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CopilotSessionInfoDto
|
||||
{
|
||||
public string CopilotSessionId { get; init; } = string.Empty;
|
||||
public bool ManagedByAryx { get; init; }
|
||||
public string? SessionId { get; init; }
|
||||
public string? AgentId { get; init; }
|
||||
public string StartTime { get; init; } = string.Empty;
|
||||
public string ModifiedTime { get; init; } = string.Empty;
|
||||
public string? Summary { get; init; }
|
||||
public bool IsRemote { get; init; }
|
||||
public string? Cwd { get; init; }
|
||||
public string? GitRoot { get; init; }
|
||||
public string? Repository { get; init; }
|
||||
public string? Branch { get; init; }
|
||||
}
|
||||
|
||||
public abstract class SidecarEventDto
|
||||
{
|
||||
public string Type { get; init; } = string.Empty;
|
||||
@@ -260,6 +339,111 @@ public sealed class AgentActivityEventDto : SidecarEventDto
|
||||
public string? ToolName { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SubagentEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string EventKind { get; init; } = string.Empty;
|
||||
public string? AgentId { get; init; }
|
||||
public string? AgentName { get; init; }
|
||||
public string? ToolCallId { get; init; }
|
||||
public string? CustomAgentName { get; init; }
|
||||
public string? CustomAgentDisplayName { get; init; }
|
||||
public string? CustomAgentDescription { get; init; }
|
||||
public string? Error { get; init; }
|
||||
public string? Model { get; init; }
|
||||
public double? TotalToolCalls { get; init; }
|
||||
public double? TotalTokens { get; init; }
|
||||
public double? DurationMs { get; init; }
|
||||
public IReadOnlyList<string>? Tools { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SkillInvokedEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string? AgentId { get; init; }
|
||||
public string? AgentName { get; init; }
|
||||
public string SkillName { get; init; } = string.Empty;
|
||||
public string Path { get; init; } = string.Empty;
|
||||
public string Content { get; init; } = string.Empty;
|
||||
public IReadOnlyList<string>? AllowedTools { get; init; }
|
||||
public string? PluginName { get; init; }
|
||||
public string? PluginVersion { get; init; }
|
||||
public string? Description { get; init; }
|
||||
}
|
||||
|
||||
public sealed class HookLifecycleEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string? AgentId { get; init; }
|
||||
public string? AgentName { get; init; }
|
||||
public string HookInvocationId { get; init; } = string.Empty;
|
||||
public string HookType { get; init; } = string.Empty;
|
||||
public string Phase { get; init; } = string.Empty;
|
||||
public bool? Success { get; init; }
|
||||
public object? Input { get; init; }
|
||||
public object? Output { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SessionUsageEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string? AgentId { get; init; }
|
||||
public string? AgentName { get; init; }
|
||||
public double TokenLimit { get; init; }
|
||||
public double CurrentTokens { get; init; }
|
||||
public double MessagesLength { get; init; }
|
||||
public double? SystemTokens { get; init; }
|
||||
public double? ConversationTokens { get; init; }
|
||||
public double? ToolDefinitionsTokens { get; init; }
|
||||
public bool? IsInitial { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SessionCompactionEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string? AgentId { get; init; }
|
||||
public string? AgentName { get; init; }
|
||||
public string Phase { get; init; } = string.Empty;
|
||||
public bool? Success { get; init; }
|
||||
public string? Error { get; init; }
|
||||
public double? SystemTokens { get; init; }
|
||||
public double? ConversationTokens { get; init; }
|
||||
public double? ToolDefinitionsTokens { get; init; }
|
||||
public double? PreCompactionTokens { get; init; }
|
||||
public double? PostCompactionTokens { get; init; }
|
||||
public double? PreCompactionMessagesLength { get; init; }
|
||||
public double? MessagesRemoved { get; init; }
|
||||
public double? TokensRemoved { get; init; }
|
||||
public string? SummaryContent { get; init; }
|
||||
public double? CheckpointNumber { get; init; }
|
||||
public string? CheckpointPath { get; init; }
|
||||
}
|
||||
|
||||
public sealed class PendingMessagesModifiedEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string? AgentId { get; init; }
|
||||
public string? AgentName { get; init; }
|
||||
}
|
||||
|
||||
public sealed class SessionsListedEventDto : SidecarEventDto
|
||||
{
|
||||
public IReadOnlyList<CopilotSessionInfoDto> Sessions { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class SessionsDeletedEventDto : SidecarEventDto
|
||||
{
|
||||
public string? SessionId { get; init; }
|
||||
public IReadOnlyList<CopilotSessionInfoDto> Sessions { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class SessionDisconnectedEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public IReadOnlyList<string> CancelledRequestIds { get; init; } = [];
|
||||
}
|
||||
|
||||
public sealed class PermissionDetailDto
|
||||
{
|
||||
public string Kind { get; init; } = string.Empty;
|
||||
|
||||
@@ -3,6 +3,7 @@ using System.Runtime.CompilerServices;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using System.Threading.Channels;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
@@ -140,11 +141,16 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
|
||||
try
|
||||
{
|
||||
string prompt = string.Join("\n", messages.Select(message => message.Text));
|
||||
(List<UserMessageDataAttachmentsItem>? attachments, tempDir) = await ProcessDataContentAttachmentsAsync(
|
||||
(List<UserMessageDataAttachmentsItem>? attachments, string? messageMode, tempDir) = await ProcessMessageAttachmentsAsync(
|
||||
messages,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
MessageOptions messageOptions = new() { Prompt = prompt };
|
||||
MessageOptions messageOptions = new()
|
||||
{
|
||||
Prompt = prompt,
|
||||
Mode = string.IsNullOrWhiteSpace(messageMode) ? null : messageMode,
|
||||
};
|
||||
|
||||
if (attachments is not null)
|
||||
{
|
||||
messageOptions.Attachments = [.. attachments];
|
||||
@@ -474,36 +480,103 @@ internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
|
||||
return JsonSerializer.Deserialize<Dictionary<string, object?>>(json);
|
||||
}
|
||||
|
||||
private static async Task<(List<UserMessageDataAttachmentsItem>? Attachments, string? TempDir)> ProcessDataContentAttachmentsAsync(
|
||||
internal static async Task<(List<UserMessageDataAttachmentsItem>? Attachments, string? MessageMode, string? TempDir)> ProcessMessageAttachmentsAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<UserMessageDataAttachmentsItem>? attachments = null;
|
||||
string? messageMode = null;
|
||||
string? tempDir = null;
|
||||
foreach (ChatMessage message in messages)
|
||||
{
|
||||
foreach (AIContent content in message.Contents)
|
||||
{
|
||||
if (content is not DataContent dataContent)
|
||||
if (content is DataContent dataContent)
|
||||
{
|
||||
tempDir ??= Directory.CreateDirectory(
|
||||
Path.Combine(Path.GetTempPath(), $"af_copilot_{Guid.NewGuid():N}")).FullName;
|
||||
|
||||
string tempFilePath = await dataContent.SaveToAsync(tempDir, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
attachments ??= [];
|
||||
attachments.Add(new UserMessageDataAttachmentsItemFile
|
||||
{
|
||||
Path = tempFilePath,
|
||||
DisplayName = Path.GetFileName(tempFilePath),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
tempDir ??= Directory.CreateDirectory(
|
||||
Path.Combine(Path.GetTempPath(), $"af_copilot_{Guid.NewGuid():N}")).FullName;
|
||||
|
||||
string tempFilePath = await dataContent.SaveToAsync(tempDir, cancellationToken).ConfigureAwait(false);
|
||||
|
||||
attachments ??= [];
|
||||
attachments.Add(new UserMessageDataAttachmentsItemFile
|
||||
if (content.RawRepresentation is ChatMessageAttachmentDto protocolAttachment)
|
||||
{
|
||||
Path = tempFilePath,
|
||||
DisplayName = Path.GetFileName(tempFilePath),
|
||||
});
|
||||
attachments ??= [];
|
||||
attachments.Add(CreateProtocolAttachment(protocolAttachment));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (content.RawRepresentation is CopilotMessageOptionsMetadata metadata
|
||||
&& !string.IsNullOrWhiteSpace(metadata.MessageMode))
|
||||
{
|
||||
messageMode = metadata.MessageMode.Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return (attachments, tempDir);
|
||||
return (attachments, messageMode, tempDir);
|
||||
}
|
||||
|
||||
private static UserMessageDataAttachmentsItem CreateProtocolAttachment(ChatMessageAttachmentDto attachment)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(attachment);
|
||||
|
||||
return attachment.Type switch
|
||||
{
|
||||
"file" => CreateFileAttachment(attachment),
|
||||
"blob" => CreateBlobAttachment(attachment),
|
||||
_ => throw new NotSupportedException($"Unsupported attachment type '{attachment.Type}'."),
|
||||
};
|
||||
}
|
||||
|
||||
private static UserMessageDataAttachmentsItemFile CreateFileAttachment(ChatMessageAttachmentDto attachment)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attachment.Path))
|
||||
{
|
||||
throw new InvalidOperationException("File attachments require an absolute path.");
|
||||
}
|
||||
|
||||
string path = attachment.Path.Trim();
|
||||
if (!Path.IsPathRooted(path))
|
||||
{
|
||||
throw new InvalidOperationException($"File attachment path '{path}' must be absolute.");
|
||||
}
|
||||
|
||||
return new UserMessageDataAttachmentsItemFile
|
||||
{
|
||||
Path = path,
|
||||
DisplayName = string.IsNullOrWhiteSpace(attachment.DisplayName)
|
||||
? Path.GetFileName(path)
|
||||
: attachment.DisplayName.Trim(),
|
||||
};
|
||||
}
|
||||
|
||||
private static UserMessageDataAttachmentsItemBlob CreateBlobAttachment(ChatMessageAttachmentDto attachment)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(attachment.Data))
|
||||
{
|
||||
throw new InvalidOperationException("Blob attachments require base64-encoded data.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrWhiteSpace(attachment.MimeType))
|
||||
{
|
||||
throw new InvalidOperationException("Blob attachments require a MIME type.");
|
||||
}
|
||||
|
||||
return new UserMessageDataAttachmentsItemBlob
|
||||
{
|
||||
Data = attachment.Data.Trim(),
|
||||
MimeType = attachment.MimeType.Trim(),
|
||||
DisplayName = string.IsNullOrWhiteSpace(attachment.DisplayName) ? null : attachment.DisplayName.Trim(),
|
||||
};
|
||||
}
|
||||
|
||||
private static void CleanupTempDir(string? tempDir)
|
||||
|
||||
@@ -47,6 +47,7 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
|
||||
SessionConfig sessionConfig = new()
|
||||
{
|
||||
SessionId = CopilotManagedSessionIds.Build(command.SessionId, definition.Id),
|
||||
Model = definition.Model,
|
||||
ReasoningEffort = definition.ReasoningEffort,
|
||||
SystemMessage = new SystemMessageConfig
|
||||
@@ -61,8 +62,14 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
WorkingDirectory = command.ProjectPath,
|
||||
OnPermissionRequest = (request, invocation) => onPermissionRequest(definition, request, invocation),
|
||||
OnUserInputRequest = (request, invocation) => onUserInputRequest(definition, request, invocation),
|
||||
Hooks = CopilotSessionHooks.Create(command, definition),
|
||||
OnEvent = evt => onSessionEvent?.Invoke(definition, evt),
|
||||
Streaming = true,
|
||||
CustomAgents = CreateCustomAgents(definition.Copilot?.CustomAgents),
|
||||
Agent = NormalizeOptionalString(definition.Copilot?.Agent),
|
||||
SkillDirectories = CreateStringList(definition.Copilot?.SkillDirectories),
|
||||
DisabledSkills = CreateStringList(definition.Copilot?.DisabledSkills),
|
||||
InfiniteSessions = CreateInfiniteSessions(definition.Copilot?.InfiniteSessions),
|
||||
};
|
||||
|
||||
ApplySessionTooling(sessionConfig, toolingBundle?.McpServers, toolingBundle?.Tools);
|
||||
@@ -100,6 +107,55 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
}
|
||||
}
|
||||
|
||||
internal static List<CustomAgentConfig>? CreateCustomAgents(
|
||||
IReadOnlyList<RunTurnCustomAgentConfigDto>? customAgents)
|
||||
{
|
||||
if (customAgents is not { Count: > 0 })
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return customAgents.Select(customAgent => new CustomAgentConfig
|
||||
{
|
||||
Name = customAgent.Name,
|
||||
DisplayName = NormalizeOptionalString(customAgent.DisplayName),
|
||||
Description = NormalizeOptionalString(customAgent.Description),
|
||||
Tools = customAgent.Tools is null ? null : [.. customAgent.Tools],
|
||||
Prompt = customAgent.Prompt,
|
||||
McpServers = customAgent.McpServers.Count == 0
|
||||
? null
|
||||
: SessionToolingBundle.BuildMcpServerConfigurations(customAgent.McpServers),
|
||||
Infer = customAgent.Infer,
|
||||
}).ToList();
|
||||
}
|
||||
|
||||
internal static InfiniteSessionConfig? CreateInfiniteSessions(RunTurnInfiniteSessionsConfigDto? config)
|
||||
{
|
||||
if (config is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new InfiniteSessionConfig
|
||||
{
|
||||
Enabled = config.Enabled,
|
||||
BackgroundCompactionThreshold = config.BackgroundCompactionThreshold,
|
||||
BufferExhaustionThreshold = config.BufferExhaustionThreshold,
|
||||
};
|
||||
}
|
||||
|
||||
private static List<string>? CreateStringList(IReadOnlyList<string>? values)
|
||||
{
|
||||
return values is { Count: > 0 }
|
||||
? [.. values]
|
||||
: null;
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
public Workflow BuildWorkflow(PatternDefinitionDto pattern)
|
||||
{
|
||||
return pattern.Mode switch
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal static class CopilotManagedSessionIds
|
||||
{
|
||||
private const string Prefix = "aryx::";
|
||||
private const string Separator = "::";
|
||||
|
||||
public static string Build(string aryxSessionId, string agentId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(aryxSessionId);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(agentId);
|
||||
|
||||
return $"{Prefix}{Uri.EscapeDataString(aryxSessionId)}{Separator}{Uri.EscapeDataString(agentId)}";
|
||||
}
|
||||
|
||||
public static bool IsManagedByAryx(string copilotSessionId)
|
||||
=> TryParse(copilotSessionId, out _, out _);
|
||||
|
||||
public static bool IsManagedByAryx(string copilotSessionId, string aryxSessionId)
|
||||
{
|
||||
return TryParse(copilotSessionId, out string? parsedSessionId, out _)
|
||||
&& string.Equals(parsedSessionId, aryxSessionId, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
public static bool TryParse(string? copilotSessionId, out string aryxSessionId, out string agentId)
|
||||
{
|
||||
aryxSessionId = string.Empty;
|
||||
agentId = string.Empty;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(copilotSessionId)
|
||||
|| !copilotSessionId.StartsWith(Prefix, StringComparison.Ordinal))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
string payload = copilotSessionId[Prefix.Length..];
|
||||
string[] parts = payload.Split(Separator, StringSplitOptions.None);
|
||||
if (parts.Length != 2
|
||||
|| string.IsNullOrWhiteSpace(parts[0])
|
||||
|| string.IsNullOrWhiteSpace(parts[1]))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
aryxSessionId = Uri.UnescapeDataString(parts[0]);
|
||||
agentId = Uri.UnescapeDataString(parts[1]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed record CopilotMessageOptionsMetadata(string MessageMode);
|
||||
@@ -0,0 +1,47 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal static class CopilotSessionHooks
|
||||
{
|
||||
private const string AllowDecision = "allow";
|
||||
private const string AskDecision = "ask";
|
||||
|
||||
public static SessionHooks Create(RunTurnCommandDto command, PatternAgentDefinitionDto agentDefinition)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
ArgumentNullException.ThrowIfNull(agentDefinition);
|
||||
|
||||
return new SessionHooks
|
||||
{
|
||||
OnPreToolUse = (input, _) => Task.FromResult<PreToolUseHookOutput?>(
|
||||
CreatePreToolUseOutput(command, agentDefinition, input)),
|
||||
OnPostToolUse = static (_, _) => Task.FromResult<PostToolUseHookOutput?>(null),
|
||||
OnUserPromptSubmitted = static (_, _) => Task.FromResult<UserPromptSubmittedHookOutput?>(null),
|
||||
OnSessionStart = static (_, _) => Task.FromResult<SessionStartHookOutput?>(null),
|
||||
OnSessionEnd = static (_, _) => Task.FromResult<SessionEndHookOutput?>(null),
|
||||
OnErrorOccurred = static (_, _) => Task.FromResult<ErrorOccurredHookOutput?>(null),
|
||||
};
|
||||
}
|
||||
|
||||
private static PreToolUseHookOutput CreatePreToolUseOutput(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agentDefinition,
|
||||
PreToolUseHookInput input)
|
||||
{
|
||||
bool requiresApproval = CopilotApprovalCoordinator.RequiresToolCallApproval(
|
||||
command.Pattern.ApprovalPolicy,
|
||||
agentDefinition.Id,
|
||||
Normalize(input.ToolName),
|
||||
Normalize(input.ToolName));
|
||||
|
||||
return new PreToolUseHookOutput
|
||||
{
|
||||
PermissionDecision = requiresApproval ? AskDecision : AllowDecision,
|
||||
};
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
@@ -0,0 +1,125 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class CopilotSessionManager : ICopilotSessionManager
|
||||
{
|
||||
public async Task<IReadOnlyList<CopilotSessionInfoDto>> ListSessionsAsync(
|
||||
CopilotSessionListFilterDto? filter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
await using CopilotClient client = await CreateStartedClientAsync(cancellationToken).ConfigureAwait(false);
|
||||
List<SessionMetadata> sessions = await client.ListSessionsAsync(CreateFilter(filter), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return sessions
|
||||
.Select(MapSession)
|
||||
.OrderByDescending(session => session.ModifiedTime, StringComparer.Ordinal)
|
||||
.ToList();
|
||||
}
|
||||
|
||||
public async Task<IReadOnlyList<CopilotSessionInfoDto>> DeleteSessionsAsync(
|
||||
string? aryxSessionId,
|
||||
string? copilotSessionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string? normalizedAryxSessionId = Normalize(aryxSessionId);
|
||||
string? normalizedCopilotSessionId = Normalize(copilotSessionId);
|
||||
if (normalizedAryxSessionId is null && normalizedCopilotSessionId is null)
|
||||
{
|
||||
throw new InvalidOperationException("delete-session requires a sessionId or copilotSessionId.");
|
||||
}
|
||||
|
||||
await using CopilotClient client = await CreateStartedClientAsync(cancellationToken).ConfigureAwait(false);
|
||||
List<SessionMetadata> sessions = await client.ListSessionsAsync(null, cancellationToken).ConfigureAwait(false);
|
||||
List<CopilotSessionInfoDto> targets = sessions
|
||||
.Select(MapSession)
|
||||
.Where(session =>
|
||||
(normalizedCopilotSessionId is not null
|
||||
&& string.Equals(session.CopilotSessionId, normalizedCopilotSessionId, StringComparison.Ordinal))
|
||||
|| (normalizedAryxSessionId is not null
|
||||
&& string.Equals(session.SessionId, normalizedAryxSessionId, StringComparison.Ordinal)
|
||||
&& session.ManagedByAryx))
|
||||
.ToList();
|
||||
|
||||
if (targets.Count == 0 && normalizedCopilotSessionId is not null)
|
||||
{
|
||||
targets.Add(CreateUnknownSessionInfo(normalizedCopilotSessionId));
|
||||
}
|
||||
|
||||
foreach (CopilotSessionInfoDto target in targets)
|
||||
{
|
||||
await client.DeleteSessionAsync(target.CopilotSessionId, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
return targets;
|
||||
}
|
||||
|
||||
private static async Task<CopilotClient> CreateStartedClientAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
CopilotClient client = new(CopilotCliPathResolver.CreateClientOptions());
|
||||
await client.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||
return client;
|
||||
}
|
||||
|
||||
private static SessionListFilter? CreateFilter(CopilotSessionListFilterDto? filter)
|
||||
{
|
||||
if (filter is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SessionListFilter
|
||||
{
|
||||
Cwd = Normalize(filter.Cwd),
|
||||
GitRoot = Normalize(filter.GitRoot),
|
||||
Repository = Normalize(filter.Repository),
|
||||
Branch = Normalize(filter.Branch),
|
||||
};
|
||||
}
|
||||
|
||||
private static CopilotSessionInfoDto MapSession(SessionMetadata session)
|
||||
{
|
||||
bool managedByAryx = CopilotManagedSessionIds.TryParse(
|
||||
session.SessionId,
|
||||
out string aryxSessionId,
|
||||
out string agentId);
|
||||
|
||||
return new CopilotSessionInfoDto
|
||||
{
|
||||
CopilotSessionId = session.SessionId,
|
||||
ManagedByAryx = managedByAryx,
|
||||
SessionId = managedByAryx ? aryxSessionId : null,
|
||||
AgentId = managedByAryx ? agentId : null,
|
||||
StartTime = session.StartTime.ToUniversalTime().ToString("O"),
|
||||
ModifiedTime = session.ModifiedTime.ToUniversalTime().ToString("O"),
|
||||
Summary = Normalize(session.Summary),
|
||||
IsRemote = session.IsRemote,
|
||||
Cwd = Normalize(session.Context?.Cwd),
|
||||
GitRoot = Normalize(session.Context?.GitRoot),
|
||||
Repository = Normalize(session.Context?.Repository),
|
||||
Branch = Normalize(session.Context?.Branch),
|
||||
};
|
||||
}
|
||||
|
||||
private static CopilotSessionInfoDto CreateUnknownSessionInfo(string copilotSessionId)
|
||||
{
|
||||
bool managedByAryx = CopilotManagedSessionIds.TryParse(
|
||||
copilotSessionId,
|
||||
out string aryxSessionId,
|
||||
out string agentId);
|
||||
|
||||
return new CopilotSessionInfoDto
|
||||
{
|
||||
CopilotSessionId = copilotSessionId,
|
||||
ManagedByAryx = managedByAryx,
|
||||
SessionId = managedByAryx ? aryxSessionId : null,
|
||||
AgentId = managedByAryx ? agentId : null,
|
||||
};
|
||||
}
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ internal sealed class CopilotTurnExecutionState
|
||||
{
|
||||
private readonly RunTurnCommandDto _command;
|
||||
private readonly HashSet<string> _startedAgents = new(StringComparer.OrdinalIgnoreCase);
|
||||
private readonly ConcurrentQueue<AgentActivityEventDto> _pendingActivityEvents = new();
|
||||
private readonly ConcurrentQueue<SidecarEventDto> _pendingEvents = new();
|
||||
private readonly ConcurrentQueue<McpOauthRequiredEventDto> _pendingMcpOauthRequests = new();
|
||||
private readonly ConcurrentDictionary<string, AgentIdentity> _observedAgentsByMessageId = new(StringComparer.Ordinal);
|
||||
private readonly StreamingTranscriptBuffer _transcriptBuffer = new();
|
||||
@@ -30,7 +30,7 @@ internal sealed class CopilotTurnExecutionState
|
||||
|
||||
public async Task EmitThinkingIfNeeded(
|
||||
AgentIdentity agent,
|
||||
Func<AgentActivityEventDto, Task> onActivity)
|
||||
Func<SidecarEventDto, Task> onEvent)
|
||||
{
|
||||
AgentActivityEventDto? thinkingActivity = CreateThinkingActivityIfNeeded(agent);
|
||||
if (thinkingActivity is null)
|
||||
@@ -38,7 +38,7 @@ internal sealed class CopilotTurnExecutionState
|
||||
return;
|
||||
}
|
||||
|
||||
await onActivity(thinkingActivity).ConfigureAwait(false);
|
||||
await onEvent(thinkingActivity).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public void QueueThinkingIfNeeded(AgentIdentity agent)
|
||||
@@ -46,13 +46,14 @@ internal sealed class CopilotTurnExecutionState
|
||||
AgentActivityEventDto? thinkingActivity = CreateThinkingActivityIfNeeded(agent);
|
||||
if (thinkingActivity is not null)
|
||||
{
|
||||
_pendingActivityEvents.Enqueue(thinkingActivity);
|
||||
_pendingEvents.Enqueue(thinkingActivity);
|
||||
}
|
||||
}
|
||||
|
||||
public void ApplyActivity(AgentActivityEventDto activity)
|
||||
public void ApplyEvent(SidecarEventDto evt)
|
||||
{
|
||||
if (string.Equals(activity.ActivityType, "handoff", StringComparison.Ordinal)
|
||||
if (evt is AgentActivityEventDto activity
|
||||
&& string.Equals(activity.ActivityType, "handoff", StringComparison.Ordinal)
|
||||
&& !string.IsNullOrWhiteSpace(activity.AgentId)
|
||||
&& !string.IsNullOrWhiteSpace(activity.AgentName))
|
||||
{
|
||||
@@ -86,6 +87,54 @@ internal sealed class CopilotTurnExecutionState
|
||||
ActiveAgent = agent;
|
||||
QueueThinkingIfNeeded(agent);
|
||||
break;
|
||||
case SubagentStartedEvent started:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateSubagentEvent(agent, "started", started.Data));
|
||||
break;
|
||||
case SubagentCompletedEvent completed:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateSubagentCompletedEvent(agent, completed.Data));
|
||||
break;
|
||||
case SubagentFailedEvent failed:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateSubagentFailedEvent(agent, failed.Data));
|
||||
break;
|
||||
case SubagentSelectedEvent selected:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateSubagentSelectedEvent(agent, selected.Data));
|
||||
break;
|
||||
case SubagentDeselectedEvent:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateSubagentDeselectedEvent(agent));
|
||||
break;
|
||||
case SkillInvokedEvent skillInvoked:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateSkillInvokedEvent(agent, skillInvoked.Data));
|
||||
break;
|
||||
case HookStartEvent hookStart:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateHookLifecycleEvent(agent, "start", hookStart.Data));
|
||||
break;
|
||||
case HookEndEvent hookEnd:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateHookLifecycleEvent(agent, "end", hookEnd.Data));
|
||||
break;
|
||||
case SessionUsageInfoEvent usageInfo:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateUsageEvent(agent, usageInfo.Data));
|
||||
break;
|
||||
case SessionCompactionStartEvent compactionStart:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateCompactionStartEvent(agent, compactionStart.Data));
|
||||
break;
|
||||
case SessionCompactionCompleteEvent compactionComplete:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreateCompactionCompleteEvent(agent, compactionComplete.Data));
|
||||
break;
|
||||
case PendingMessagesModifiedEvent:
|
||||
ActiveAgent = agent;
|
||||
_pendingEvents.Enqueue(CreatePendingMessagesModifiedEvent(agent));
|
||||
break;
|
||||
case McpOauthRequiredEvent:
|
||||
ActiveAgent = agent;
|
||||
break;
|
||||
@@ -96,12 +145,12 @@ internal sealed class CopilotTurnExecutionState
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<AgentActivityEventDto> DrainPendingActivityEvents()
|
||||
public IReadOnlyList<SidecarEventDto> DrainPendingEvents()
|
||||
{
|
||||
List<AgentActivityEventDto> pending = [];
|
||||
while (_pendingActivityEvents.TryDequeue(out AgentActivityEventDto? activity))
|
||||
List<SidecarEventDto> pending = [];
|
||||
while (_pendingEvents.TryDequeue(out SidecarEventDto? pendingEvent))
|
||||
{
|
||||
pending.Add(activity);
|
||||
pending.Add(pendingEvent);
|
||||
}
|
||||
|
||||
return pending;
|
||||
@@ -204,4 +253,229 @@ internal sealed class CopilotTurnExecutionState
|
||||
|
||||
return CompletedMessages;
|
||||
}
|
||||
|
||||
private SubagentEventDto CreateSubagentEvent(
|
||||
AgentIdentity agent,
|
||||
string eventKind,
|
||||
SubagentStartedData? data)
|
||||
{
|
||||
return new SubagentEventDto
|
||||
{
|
||||
Type = "subagent-event",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
EventKind = eventKind,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
ToolCallId = data?.ToolCallId,
|
||||
CustomAgentName = data?.AgentName,
|
||||
CustomAgentDisplayName = data?.AgentDisplayName,
|
||||
CustomAgentDescription = data?.AgentDescription,
|
||||
};
|
||||
}
|
||||
|
||||
private SubagentEventDto CreateSubagentCompletedEvent(
|
||||
AgentIdentity agent,
|
||||
SubagentCompletedData? data)
|
||||
{
|
||||
return new SubagentEventDto
|
||||
{
|
||||
Type = "subagent-event",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
EventKind = "completed",
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
ToolCallId = data?.ToolCallId,
|
||||
CustomAgentName = data?.AgentName,
|
||||
CustomAgentDisplayName = data?.AgentDisplayName,
|
||||
};
|
||||
}
|
||||
|
||||
private SubagentEventDto CreateSubagentFailedEvent(
|
||||
AgentIdentity agent,
|
||||
SubagentFailedData? data)
|
||||
{
|
||||
return new SubagentEventDto
|
||||
{
|
||||
Type = "subagent-event",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
EventKind = "failed",
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
ToolCallId = data?.ToolCallId,
|
||||
CustomAgentName = data?.AgentName,
|
||||
CustomAgentDisplayName = data?.AgentDisplayName,
|
||||
Error = data?.Error,
|
||||
};
|
||||
}
|
||||
|
||||
private SubagentEventDto CreateSubagentSelectedEvent(
|
||||
AgentIdentity agent,
|
||||
SubagentSelectedData? data)
|
||||
{
|
||||
return new SubagentEventDto
|
||||
{
|
||||
Type = "subagent-event",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
EventKind = "selected",
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
CustomAgentName = data?.AgentName,
|
||||
CustomAgentDisplayName = data?.AgentDisplayName,
|
||||
Tools = data?.Tools,
|
||||
};
|
||||
}
|
||||
|
||||
private SubagentEventDto CreateSubagentDeselectedEvent(AgentIdentity agent)
|
||||
{
|
||||
return new SubagentEventDto
|
||||
{
|
||||
Type = "subagent-event",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
EventKind = "deselected",
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
};
|
||||
}
|
||||
|
||||
private SkillInvokedEventDto CreateSkillInvokedEvent(
|
||||
AgentIdentity agent,
|
||||
SkillInvokedData? data)
|
||||
{
|
||||
return new SkillInvokedEventDto
|
||||
{
|
||||
Type = "skill-invoked",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
SkillName = data?.Name ?? string.Empty,
|
||||
Path = data?.Path ?? string.Empty,
|
||||
Content = data?.Content ?? string.Empty,
|
||||
AllowedTools = data?.AllowedTools,
|
||||
PluginName = data?.PluginName,
|
||||
PluginVersion = data?.PluginVersion,
|
||||
};
|
||||
}
|
||||
|
||||
private HookLifecycleEventDto CreateHookLifecycleEvent(
|
||||
AgentIdentity agent,
|
||||
string phase,
|
||||
HookStartData? data)
|
||||
{
|
||||
return new HookLifecycleEventDto
|
||||
{
|
||||
Type = "hook-lifecycle",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
HookInvocationId = data?.HookInvocationId ?? string.Empty,
|
||||
HookType = data?.HookType ?? string.Empty,
|
||||
Phase = phase,
|
||||
Input = data?.Input,
|
||||
};
|
||||
}
|
||||
|
||||
private HookLifecycleEventDto CreateHookLifecycleEvent(
|
||||
AgentIdentity agent,
|
||||
string phase,
|
||||
HookEndData? data)
|
||||
{
|
||||
return new HookLifecycleEventDto
|
||||
{
|
||||
Type = "hook-lifecycle",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
HookInvocationId = data?.HookInvocationId ?? string.Empty,
|
||||
HookType = data?.HookType ?? string.Empty,
|
||||
Phase = phase,
|
||||
Success = data?.Success,
|
||||
Output = data?.Output,
|
||||
Error = data?.Error?.Message,
|
||||
};
|
||||
}
|
||||
|
||||
private SessionUsageEventDto CreateUsageEvent(AgentIdentity agent, SessionUsageInfoData? data)
|
||||
{
|
||||
return new SessionUsageEventDto
|
||||
{
|
||||
Type = "session-usage",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
TokenLimit = data?.TokenLimit ?? 0,
|
||||
CurrentTokens = data?.CurrentTokens ?? 0,
|
||||
MessagesLength = data?.MessagesLength ?? 0,
|
||||
SystemTokens = data?.SystemTokens,
|
||||
ConversationTokens = data?.ConversationTokens,
|
||||
ToolDefinitionsTokens = data?.ToolDefinitionsTokens,
|
||||
IsInitial = data?.IsInitial,
|
||||
};
|
||||
}
|
||||
|
||||
private SessionCompactionEventDto CreateCompactionStartEvent(
|
||||
AgentIdentity agent,
|
||||
SessionCompactionStartData? data)
|
||||
{
|
||||
return new SessionCompactionEventDto
|
||||
{
|
||||
Type = "session-compaction",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
Phase = "start",
|
||||
SystemTokens = data?.SystemTokens,
|
||||
ConversationTokens = data?.ConversationTokens,
|
||||
ToolDefinitionsTokens = data?.ToolDefinitionsTokens,
|
||||
};
|
||||
}
|
||||
|
||||
private SessionCompactionEventDto CreateCompactionCompleteEvent(
|
||||
AgentIdentity agent,
|
||||
SessionCompactionCompleteData? data)
|
||||
{
|
||||
return new SessionCompactionEventDto
|
||||
{
|
||||
Type = "session-compaction",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
Phase = "complete",
|
||||
Success = data?.Success,
|
||||
Error = data?.Error,
|
||||
SystemTokens = data?.SystemTokens,
|
||||
ConversationTokens = data?.ConversationTokens,
|
||||
ToolDefinitionsTokens = data?.ToolDefinitionsTokens,
|
||||
PreCompactionTokens = data?.PreCompactionTokens,
|
||||
PostCompactionTokens = data?.PostCompactionTokens,
|
||||
PreCompactionMessagesLength = data?.PreCompactionMessagesLength,
|
||||
MessagesRemoved = data?.MessagesRemoved,
|
||||
TokensRemoved = data?.TokensRemoved,
|
||||
SummaryContent = data?.SummaryContent,
|
||||
CheckpointNumber = data?.CheckpointNumber,
|
||||
CheckpointPath = data?.CheckpointPath,
|
||||
};
|
||||
}
|
||||
|
||||
private PendingMessagesModifiedEventDto CreatePendingMessagesModifiedEvent(AgentIdentity agent)
|
||||
{
|
||||
return new PendingMessagesModifiedEventDto
|
||||
{
|
||||
Type = "pending-messages-modified",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,7 +23,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
public async Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
|
||||
RunTurnCommandDto command,
|
||||
Func<TurnDeltaEventDto, Task> onDelta,
|
||||
Func<AgentActivityEventDto, Task> onActivity,
|
||||
Func<SidecarEventDto, Task> onEvent,
|
||||
Func<ApprovalRequestedEventDto, Task> onApproval,
|
||||
Func<UserInputRequestedEventDto, Task> onUserInput,
|
||||
Func<McpOauthRequiredEventDto, Task> onMcpOAuthRequired,
|
||||
@@ -77,15 +77,16 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
runCancellation.Token);
|
||||
Workflow workflow = bundle.BuildWorkflow(command.Pattern);
|
||||
List<ChatMessage> inputMessages = command.Messages.Select(WorkflowTranscriptProjector.ToChatMessage).ToList();
|
||||
WorkflowTranscriptProjector.AttachMessageMode(inputMessages, command.MessageMode);
|
||||
|
||||
await using StreamingRun run = await InProcessExecution.RunStreamingAsync(workflow, inputMessages).ConfigureAwait(false);
|
||||
await run.TrySendMessageAsync(new TurnToken(emitEvents: true)).ConfigureAwait(false);
|
||||
|
||||
await foreach (WorkflowEvent evt in run.WatchStreamAsync(runCancellation.Token).ConfigureAwait(false))
|
||||
{
|
||||
bool shouldEndTurn = await HandleWorkflowEventAsync(command, evt, inputMessages, state, onDelta, onActivity)
|
||||
bool shouldEndTurn = await HandleWorkflowEventAsync(command, evt, inputMessages, state, onDelta, onEvent)
|
||||
.ConfigureAwait(false);
|
||||
await EmitPendingActivityEventsAsync(state, onActivity).ConfigureAwait(false);
|
||||
await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false);
|
||||
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
|
||||
if (shouldEndTurn)
|
||||
{
|
||||
@@ -93,13 +94,13 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
}
|
||||
}
|
||||
|
||||
await EmitPendingActivityEventsAsync(state, onActivity).ConfigureAwait(false);
|
||||
await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false);
|
||||
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
|
||||
return state.FinalizeCompletedMessages();
|
||||
}
|
||||
catch (OperationCanceledException) when (runCancellation.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await EmitPendingActivityEventsAsync(state, onActivity).ConfigureAwait(false);
|
||||
await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false);
|
||||
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
|
||||
ExitPlanModeRequestedEventDto? exitPlanModeEvent =
|
||||
_exitPlanModeCoordinator.ConsumePendingRequest(command.RequestId);
|
||||
@@ -117,13 +118,13 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task EmitPendingActivityEventsAsync(
|
||||
private static async Task EmitPendingEventsAsync(
|
||||
CopilotTurnExecutionState state,
|
||||
Func<AgentActivityEventDto, Task> onActivity)
|
||||
Func<SidecarEventDto, Task> onEvent)
|
||||
{
|
||||
foreach (AgentActivityEventDto activity in state.DrainPendingActivityEvents())
|
||||
foreach (SidecarEventDto pendingEvent in state.DrainPendingEvents())
|
||||
{
|
||||
await onActivity(activity).ConfigureAwait(false);
|
||||
await onEvent(pendingEvent).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -157,7 +158,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
IReadOnlyList<ChatMessage> inputMessages,
|
||||
CopilotTurnExecutionState state,
|
||||
Func<TurnDeltaEventDto, Task> onDelta,
|
||||
Func<AgentActivityEventDto, Task> onActivity)
|
||||
Func<SidecarEventDto, Task> onEvent)
|
||||
{
|
||||
if (evt is ExecutorInvokedEvent invoked)
|
||||
{
|
||||
@@ -167,7 +168,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
out AgentIdentity invokedAgent))
|
||||
{
|
||||
TraceHandoff(command, $"Executor invoked: {invoked.ExecutorId} -> {invokedAgent.AgentName} ({invokedAgent.AgentId}).");
|
||||
await state.EmitThinkingIfNeeded(invokedAgent, onActivity).ConfigureAwait(false);
|
||||
await state.EmitThinkingIfNeeded(invokedAgent, onEvent).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
@@ -194,13 +195,13 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
return requiresBoundary;
|
||||
}
|
||||
|
||||
await EmitActivityAsync(command, state, activity, onActivity).ConfigureAwait(false);
|
||||
await EmitActivityAsync(command, state, activity, onEvent).ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
if (evt is AgentResponseUpdateEvent update)
|
||||
{
|
||||
await HandleAgentResponseUpdateAsync(command, update, state, onDelta, onActivity).ConfigureAwait(false);
|
||||
await HandleAgentResponseUpdateAsync(command, update, state, onDelta, onEvent).ConfigureAwait(false);
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -237,7 +238,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
AgentResponseUpdateEvent update,
|
||||
CopilotTurnExecutionState state,
|
||||
Func<TurnDeltaEventDto, Task> onDelta,
|
||||
Func<AgentActivityEventDto, Task> onActivity)
|
||||
Func<SidecarEventDto, Task> onEvent)
|
||||
{
|
||||
AgentIdentity? updateAgent = null;
|
||||
string authorName = update.ExecutorId;
|
||||
@@ -271,7 +272,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
$"Agent response update from {updateAgent.Value.AgentName} ({updateAgent.Value.AgentId}) requested handoff via {string.Join(", ", handoffFunctionCalls)}.");
|
||||
}
|
||||
|
||||
await state.EmitThinkingIfNeeded(updateAgent.Value, onActivity).ConfigureAwait(false);
|
||||
await state.EmitThinkingIfNeeded(updateAgent.Value, onEvent).ConfigureAwait(false);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(update.Update.Text) || handoffFunctionCalls.Length > 0)
|
||||
{
|
||||
@@ -307,13 +308,13 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
RunTurnCommandDto command,
|
||||
CopilotTurnExecutionState state,
|
||||
AgentActivityEventDto activity,
|
||||
Func<AgentActivityEventDto, Task> onActivity)
|
||||
Func<SidecarEventDto, Task> onEvent)
|
||||
{
|
||||
state.ApplyActivity(activity);
|
||||
state.ApplyEvent(activity);
|
||||
TraceHandoff(
|
||||
command,
|
||||
$"Activity emitted: {activity.ActivityType} -> {activity.AgentName ?? activity.AgentId ?? "<unknown>"}.");
|
||||
await onActivity(activity).ConfigureAwait(false);
|
||||
await onEvent(activity).ConfigureAwait(false);
|
||||
|
||||
if (string.Equals(activity.ActivityType, "handoff", StringComparison.Ordinal)
|
||||
&& !string.IsNullOrWhiteSpace(activity.AgentId)
|
||||
@@ -324,7 +325,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
$"Promoting handoff target to thinking: {activity.AgentName} ({activity.AgentId}).");
|
||||
await state.EmitThinkingIfNeeded(
|
||||
new AgentIdentity(activity.AgentId, activity.AgentName),
|
||||
onActivity).ConfigureAwait(false);
|
||||
onEvent).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,16 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
public interface ICopilotSessionManager
|
||||
{
|
||||
Task<IReadOnlyList<CopilotSessionInfoDto>> ListSessionsAsync(
|
||||
CopilotSessionListFilterDto? filter,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task<IReadOnlyList<CopilotSessionInfoDto>> DeleteSessionsAsync(
|
||||
string? aryxSessionId,
|
||||
string? copilotSessionId,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@ public interface ITurnWorkflowRunner
|
||||
Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
|
||||
RunTurnCommandDto command,
|
||||
Func<TurnDeltaEventDto, Task> onDelta,
|
||||
Func<AgentActivityEventDto, Task> onActivity,
|
||||
Func<SidecarEventDto, Task> onEvent,
|
||||
Func<ApprovalRequestedEventDto, Task> onApproval,
|
||||
Func<UserInputRequestedEventDto, Task> onUserInput,
|
||||
Func<McpOauthRequiredEventDto, Task> onMcpOAuthRequired,
|
||||
|
||||
@@ -15,6 +15,9 @@ public sealed class SidecarProtocolHost
|
||||
private const string CancelTurnCommandType = "cancel-turn";
|
||||
private const string ResolveApprovalCommandType = "resolve-approval";
|
||||
private const string ResolveUserInputCommandType = "resolve-user-input";
|
||||
private const string ListSessionsCommandType = "list-sessions";
|
||||
private const string DeleteSessionCommandType = "delete-session";
|
||||
private const string DisconnectSessionCommandType = "disconnect-session";
|
||||
private const string AskUserToolName = "ask_user";
|
||||
private static readonly HashSet<string> ExcludedRuntimeToolNames = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
@@ -39,11 +42,14 @@ public sealed class SidecarProtocolHost
|
||||
private readonly Func<CancellationToken, Task<SidecarCapabilitiesDto>> _capabilitiesProvider;
|
||||
private readonly PatternValidator _patternValidator;
|
||||
private readonly ITurnWorkflowRunner _workflowRunner;
|
||||
private readonly ICopilotSessionManager _sessionManager;
|
||||
private readonly JsonSerializerOptions _jsonOptions;
|
||||
private readonly IReadOnlyDictionary<string, Func<CommandContext, Task>> _commandHandlers;
|
||||
private readonly SemaphoreSlim _writeLock = new(1, 1);
|
||||
private readonly ConcurrentDictionary<string, Task> _inFlight = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, CancellationTokenSource> _turnCancellations = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> _turnRequestIdsBySessionId =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
public SidecarProtocolHost()
|
||||
: this(new PatternValidator())
|
||||
@@ -53,11 +59,13 @@ public sealed class SidecarProtocolHost
|
||||
public SidecarProtocolHost(
|
||||
PatternValidator patternValidator,
|
||||
ITurnWorkflowRunner? workflowRunner = null,
|
||||
Func<CancellationToken, Task<SidecarCapabilitiesDto>>? capabilitiesProvider = null)
|
||||
Func<CancellationToken, Task<SidecarCapabilitiesDto>>? capabilitiesProvider = null,
|
||||
ICopilotSessionManager? sessionManager = null)
|
||||
{
|
||||
_patternValidator = patternValidator;
|
||||
_workflowRunner = workflowRunner ?? new CopilotWorkflowRunner(_patternValidator);
|
||||
_capabilitiesProvider = capabilitiesProvider ?? BuildCapabilitiesAsync;
|
||||
_sessionManager = sessionManager ?? new CopilotSessionManager();
|
||||
_jsonOptions = new JsonSerializerOptions(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
@@ -71,6 +79,9 @@ public sealed class SidecarProtocolHost
|
||||
[CancelTurnCommandType] = HandleCancelTurnAsync,
|
||||
[ResolveApprovalCommandType] = HandleResolveApprovalAsync,
|
||||
[ResolveUserInputCommandType] = HandleResolveUserInputAsync,
|
||||
[ListSessionsCommandType] = HandleListSessionsAsync,
|
||||
[DeleteSessionCommandType] = HandleDeleteSessionAsync,
|
||||
[DisconnectSessionCommandType] = HandleDisconnectSessionAsync,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -180,12 +191,13 @@ public sealed class SidecarProtocolHost
|
||||
$"A turn with request ID '{context.Envelope.RequestId}' is already in progress.");
|
||||
}
|
||||
|
||||
RegisterTurnRequest(command.SessionId, context.Envelope.RequestId);
|
||||
try
|
||||
{
|
||||
IReadOnlyList<ChatMessageDto> messages = await _workflowRunner.RunTurnAsync(
|
||||
command,
|
||||
delta => WriteAsync(context.Output, delta, turnCancellation.Token),
|
||||
activity => WriteAsync(context.Output, activity, turnCancellation.Token),
|
||||
evt => WriteAsync(context.Output, evt, turnCancellation.Token),
|
||||
approval => WriteAsync(context.Output, approval, turnCancellation.Token),
|
||||
userInput => WriteAsync(context.Output, userInput, turnCancellation.Token),
|
||||
mcpOauth => WriteAsync(context.Output, mcpOauth, turnCancellation.Token),
|
||||
@@ -216,6 +228,7 @@ public sealed class SidecarProtocolHost
|
||||
finally
|
||||
{
|
||||
_turnCancellations.TryRemove(context.Envelope.RequestId, out _);
|
||||
UnregisterTurnRequest(command.SessionId, context.Envelope.RequestId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -249,6 +262,57 @@ public sealed class SidecarProtocolHost
|
||||
await _workflowRunner.ResolveUserInputAsync(command, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task HandleListSessionsAsync(CommandContext context)
|
||||
{
|
||||
ListSessionsCommandDto command = DeserializeCommand<ListSessionsCommandDto>(context);
|
||||
IReadOnlyList<CopilotSessionInfoDto> sessions = await _sessionManager.ListSessionsAsync(
|
||||
command.Filter,
|
||||
context.CancellationToken).ConfigureAwait(false);
|
||||
|
||||
await WriteAsync(context.Output, new SessionsListedEventDto
|
||||
{
|
||||
Type = "sessions-listed",
|
||||
RequestId = context.Envelope.RequestId,
|
||||
Sessions = sessions,
|
||||
}, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task HandleDeleteSessionAsync(CommandContext context)
|
||||
{
|
||||
DeleteSessionCommandDto command = DeserializeCommand<DeleteSessionCommandDto>(context);
|
||||
if (!string.IsNullOrWhiteSpace(command.SessionId))
|
||||
{
|
||||
CancelTurnRequestsForSession(command.SessionId);
|
||||
}
|
||||
|
||||
IReadOnlyList<CopilotSessionInfoDto> deletedSessions = await _sessionManager.DeleteSessionsAsync(
|
||||
command.SessionId,
|
||||
command.CopilotSessionId,
|
||||
context.CancellationToken).ConfigureAwait(false);
|
||||
|
||||
await WriteAsync(context.Output, new SessionsDeletedEventDto
|
||||
{
|
||||
Type = "sessions-deleted",
|
||||
RequestId = context.Envelope.RequestId,
|
||||
SessionId = string.IsNullOrWhiteSpace(command.SessionId) ? null : command.SessionId.Trim(),
|
||||
Sessions = deletedSessions,
|
||||
}, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task HandleDisconnectSessionAsync(CommandContext context)
|
||||
{
|
||||
DisconnectSessionCommandDto command = DeserializeCommand<DisconnectSessionCommandDto>(context);
|
||||
IReadOnlyList<string> cancelledRequestIds = CancelTurnRequestsForSession(command.SessionId);
|
||||
|
||||
await WriteAsync(context.Output, new SessionDisconnectedEventDto
|
||||
{
|
||||
Type = "session-disconnected",
|
||||
RequestId = context.Envelope.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
CancelledRequestIds = cancelledRequestIds,
|
||||
}, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private TCommand DeserializeCommand<TCommand>(CommandContext context)
|
||||
where TCommand : SidecarCommandEnvelope
|
||||
{
|
||||
@@ -309,6 +373,67 @@ public sealed class SidecarProtocolHost
|
||||
}
|
||||
}
|
||||
|
||||
private void RegisterTurnRequest(string sessionId, string requestId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId) || string.IsNullOrWhiteSpace(requestId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ConcurrentDictionary<string, byte> requestIds = _turnRequestIdsBySessionId.GetOrAdd(
|
||||
sessionId.Trim(),
|
||||
static _ => new ConcurrentDictionary<string, byte>(StringComparer.Ordinal));
|
||||
requestIds[requestId.Trim()] = 0;
|
||||
}
|
||||
|
||||
private void UnregisterTurnRequest(string sessionId, string requestId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId) || string.IsNullOrWhiteSpace(requestId))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (!_turnRequestIdsBySessionId.TryGetValue(sessionId.Trim(), out ConcurrentDictionary<string, byte>? requestIds))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
requestIds.TryRemove(requestId.Trim(), out _);
|
||||
if (requestIds.IsEmpty)
|
||||
{
|
||||
_turnRequestIdsBySessionId.TryRemove(sessionId.Trim(), out _);
|
||||
}
|
||||
}
|
||||
|
||||
private IReadOnlyList<string> CancelTurnRequestsForSession(string sessionId)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sessionId)
|
||||
|| !_turnRequestIdsBySessionId.TryGetValue(sessionId.Trim(), out ConcurrentDictionary<string, byte>? requestIds))
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
List<string> cancelledRequestIds = [];
|
||||
foreach (string requestId in requestIds.Keys)
|
||||
{
|
||||
if (!_turnCancellations.TryGetValue(requestId, out CancellationTokenSource? turnCancellation))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
turnCancellation.Cancel();
|
||||
cancelledRequestIds.Add(requestId);
|
||||
}
|
||||
catch (ObjectDisposedException)
|
||||
{
|
||||
}
|
||||
}
|
||||
|
||||
return cancelledRequestIds;
|
||||
}
|
||||
|
||||
private static async Task<SidecarCapabilitiesDto> BuildCapabilitiesAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
|
||||
@@ -26,9 +26,30 @@ internal static class WorkflowTranscriptProjector
|
||||
mapped.AuthorName = message.AuthorName;
|
||||
}
|
||||
|
||||
foreach (ChatMessageAttachmentDto attachment in message.Attachments)
|
||||
{
|
||||
mapped.Contents.Add(new AIContent
|
||||
{
|
||||
RawRepresentation = attachment,
|
||||
});
|
||||
}
|
||||
|
||||
return mapped;
|
||||
}
|
||||
|
||||
public static void AttachMessageMode(IList<ChatMessage> messages, string? messageMode)
|
||||
{
|
||||
if (messages.Count == 0 || string.IsNullOrWhiteSpace(messageMode))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
messages[^1].Contents.Add(new AIContent
|
||||
{
|
||||
RawRepresentation = new CopilotMessageOptionsMetadata(messageMode.Trim()),
|
||||
});
|
||||
}
|
||||
|
||||
public static List<ChatMessageDto> ProjectCompletedMessages(
|
||||
RunTurnCommandDto command,
|
||||
IReadOnlyList<ChatMessage> newMessages,
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
|
||||
public sealed class AryxCopilotAgentMessageOptionsTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task ProcessMessageAttachmentsAsync_MapsProtocolAttachmentsAndMessageMode()
|
||||
{
|
||||
ChatMessage message = new(ChatRole.User, "Please inspect these images.");
|
||||
message.Contents.Add(new AIContent
|
||||
{
|
||||
RawRepresentation = new ChatMessageAttachmentDto
|
||||
{
|
||||
Type = "file",
|
||||
Path = @"C:\workspace\project\assets\diagram.png",
|
||||
DisplayName = "diagram.png",
|
||||
},
|
||||
});
|
||||
message.Contents.Add(new AIContent
|
||||
{
|
||||
RawRepresentation = new ChatMessageAttachmentDto
|
||||
{
|
||||
Type = "blob",
|
||||
Data = "QUJDRA==",
|
||||
MimeType = "image/png",
|
||||
DisplayName = "clipboard.png",
|
||||
},
|
||||
});
|
||||
message.Contents.Add(new AIContent
|
||||
{
|
||||
RawRepresentation = new CopilotMessageOptionsMetadata("immediate"),
|
||||
});
|
||||
|
||||
(List<UserMessageDataAttachmentsItem>? attachments, string? messageMode, string? tempDir) =
|
||||
await AryxCopilotAgent.ProcessMessageAttachmentsAsync([message], CancellationToken.None);
|
||||
|
||||
Assert.Equal("immediate", messageMode);
|
||||
Assert.Null(tempDir);
|
||||
|
||||
Assert.NotNull(attachments);
|
||||
Assert.Collection(
|
||||
attachments!,
|
||||
first =>
|
||||
{
|
||||
UserMessageDataAttachmentsItemFile file = Assert.IsType<UserMessageDataAttachmentsItemFile>(first);
|
||||
Assert.Equal(@"C:\workspace\project\assets\diagram.png", file.Path);
|
||||
Assert.Equal("diagram.png", file.DisplayName);
|
||||
},
|
||||
second =>
|
||||
{
|
||||
UserMessageDataAttachmentsItemBlob blob = Assert.IsType<UserMessageDataAttachmentsItemBlob>(second);
|
||||
Assert.Equal("QUJDRA==", blob.Data);
|
||||
Assert.Equal("image/png", blob.MimeType);
|
||||
Assert.Equal("clipboard.png", blob.DisplayName);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ProcessMessageAttachmentsAsync_RejectsRelativeFileAttachments()
|
||||
{
|
||||
ChatMessage message = new(ChatRole.User, "Inspect this file.");
|
||||
message.Contents.Add(new AIContent
|
||||
{
|
||||
RawRepresentation = new ChatMessageAttachmentDto
|
||||
{
|
||||
Type = "file",
|
||||
Path = "relative\\image.png",
|
||||
},
|
||||
});
|
||||
|
||||
InvalidOperationException error = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
AryxCopilotAgent.ProcessMessageAttachmentsAsync([message], CancellationToken.None));
|
||||
|
||||
Assert.Contains("absolute", error.Message, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Reflection;
|
||||
using System.Text.Json;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Aryx.AgentHost.Services;
|
||||
using Microsoft.Agents.AI;
|
||||
@@ -143,6 +144,122 @@ public sealed class CopilotAgentBundleTests
|
||||
Assert.Equal("handoff_to_reviewer", single.Name);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateCustomAgents_MapsSdkCustomAgentConfiguration()
|
||||
{
|
||||
List<CustomAgentConfig> customAgents = Assert.IsType<List<CustomAgentConfig>>(CopilotAgentBundle.CreateCustomAgents(
|
||||
[
|
||||
new RunTurnCustomAgentConfigDto
|
||||
{
|
||||
Name = "designer",
|
||||
DisplayName = "Designer",
|
||||
Description = "Design specialist",
|
||||
Tools = ["view", "glob"],
|
||||
Prompt = "Focus on UX design.",
|
||||
Infer = true,
|
||||
McpServers =
|
||||
[
|
||||
new RunTurnMcpServerConfigDto
|
||||
{
|
||||
Id = "designer-mcp",
|
||||
Name = "Designer MCP",
|
||||
Transport = "local",
|
||||
Command = "node",
|
||||
Args = ["designer.js"],
|
||||
},
|
||||
],
|
||||
},
|
||||
]));
|
||||
|
||||
CustomAgentConfig customAgent = Assert.Single(customAgents);
|
||||
Assert.Equal("designer", customAgent.Name);
|
||||
Assert.Equal("Designer", customAgent.DisplayName);
|
||||
Assert.Equal("Design specialist", customAgent.Description);
|
||||
Assert.Equal(["view", "glob"], customAgent.Tools);
|
||||
Assert.Equal("Focus on UX design.", customAgent.Prompt);
|
||||
Assert.True(customAgent.Infer);
|
||||
|
||||
KeyValuePair<string, object> mcpServer = Assert.Single(customAgent.McpServers!);
|
||||
Assert.Equal("Designer MCP", mcpServer.Key);
|
||||
McpLocalServerConfig localServer = Assert.IsType<McpLocalServerConfig>(mcpServer.Value);
|
||||
Assert.Equal("node", localServer.Command);
|
||||
Assert.Equal(["designer.js"], localServer.Args);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateInfiniteSessions_MapsSdkInfiniteSessionConfiguration()
|
||||
{
|
||||
InfiniteSessionConfig config = Assert.IsType<InfiniteSessionConfig>(CopilotAgentBundle.CreateInfiniteSessions(
|
||||
new RunTurnInfiniteSessionsConfigDto
|
||||
{
|
||||
Enabled = true,
|
||||
BackgroundCompactionThreshold = 0.75,
|
||||
BufferExhaustionThreshold = 0.9,
|
||||
}));
|
||||
|
||||
Assert.True(config.Enabled);
|
||||
Assert.Equal(0.75, config.BackgroundCompactionThreshold);
|
||||
Assert.Equal(0.9, config.BufferExhaustionThreshold);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CopilotSessionHooks_Create_UsesApprovalPolicyForPreToolUse()
|
||||
{
|
||||
RunTurnCommandDto command = new()
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
Name = "Pattern",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
ApprovalPolicy = new ApprovalPolicyDto
|
||||
{
|
||||
Rules =
|
||||
[
|
||||
new ApprovalCheckpointRuleDto
|
||||
{
|
||||
Kind = "tool-call",
|
||||
AgentIds = ["agent-1"],
|
||||
},
|
||||
],
|
||||
},
|
||||
Agents =
|
||||
[
|
||||
new PatternAgentDefinitionDto
|
||||
{
|
||||
Id = "agent-1",
|
||||
Name = "Primary",
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help.",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0]);
|
||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||
new PreToolUseHookInput
|
||||
{
|
||||
ToolName = "view",
|
||||
},
|
||||
null!);
|
||||
|
||||
Assert.Equal("ask", decision?.PermissionDecision);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CopilotManagedSessionIds_BuildsAndParsesStableIds()
|
||||
{
|
||||
string sessionId = CopilotManagedSessionIds.Build("session-1", "agent-ux");
|
||||
|
||||
Assert.True(CopilotManagedSessionIds.TryParse(sessionId, out string aryxSessionId, out string agentId));
|
||||
Assert.Equal("session-1", aryxSessionId);
|
||||
Assert.Equal("agent-ux", agentId);
|
||||
}
|
||||
|
||||
private static AIFunction CreateTool()
|
||||
{
|
||||
ToolTarget target = new();
|
||||
|
||||
@@ -50,7 +50,7 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
}
|
||||
"""));
|
||||
|
||||
AgentActivityEventDto activity = Assert.Single(state.DrainPendingActivityEvents());
|
||||
AgentActivityEventDto activity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
|
||||
Assert.Equal("thinking", activity.ActivityType);
|
||||
Assert.Equal("agent-1", activity.AgentId);
|
||||
Assert.Equal("Primary", activity.AgentName);
|
||||
@@ -104,13 +104,13 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
}
|
||||
"""));
|
||||
|
||||
List<AgentActivityEventDto> activities = [.. state.DrainPendingActivityEvents()];
|
||||
List<AgentActivityEventDto> activities = [.. state.DrainPendingEvents().OfType<AgentActivityEventDto>()];
|
||||
|
||||
await state.EmitThinkingIfNeeded(
|
||||
new AgentIdentity("agent-1", "Primary"),
|
||||
activity =>
|
||||
sidecarEvent =>
|
||||
{
|
||||
activities.Add(activity);
|
||||
activities.Add(Assert.IsType<AgentActivityEventDto>(sidecarEvent));
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
@@ -144,6 +144,157 @@ public sealed class CopilotTurnExecutionStateTests
|
||||
Assert.Empty(secondDrain);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObserveSessionEvent_SubagentStarted_QueuesSubagentEvent()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
"type": "subagent.started",
|
||||
"data": {
|
||||
"toolCallId": "tool-call-1",
|
||||
"agentName": "designer",
|
||||
"agentDisplayName": "Designer",
|
||||
"agentDescription": "Design specialist"
|
||||
},
|
||||
"id": "44444444-4444-4444-4444-444444444444",
|
||||
"timestamp": "2026-03-27T00:00:00Z"
|
||||
}
|
||||
"""));
|
||||
|
||||
SubagentEventDto evt = Assert.Single(state.DrainPendingEvents().OfType<SubagentEventDto>());
|
||||
Assert.Equal("started", evt.EventKind);
|
||||
Assert.Equal("tool-call-1", evt.ToolCallId);
|
||||
Assert.Equal("designer", evt.CustomAgentName);
|
||||
Assert.Equal("Designer", evt.CustomAgentDisplayName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObserveSessionEvent_SkillInvoked_QueuesSkillEvent()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
"type": "skill.invoked",
|
||||
"data": {
|
||||
"name": "reviewer",
|
||||
"path": "C:\\skills\\reviewer\\SKILL.md",
|
||||
"content": "# Reviewer",
|
||||
"allowedTools": ["view"],
|
||||
"pluginName": "aryx-plugin",
|
||||
"pluginVersion": "1.0.0"
|
||||
},
|
||||
"id": "55555555-5555-5555-5555-555555555555",
|
||||
"timestamp": "2026-03-27T00:00:00Z"
|
||||
}
|
||||
"""));
|
||||
|
||||
SkillInvokedEventDto evt = Assert.Single(state.DrainPendingEvents().OfType<SkillInvokedEventDto>());
|
||||
Assert.Equal("reviewer", evt.SkillName);
|
||||
Assert.Equal(@"C:\skills\reviewer\SKILL.md", evt.Path);
|
||||
Assert.Equal(["view"], evt.AllowedTools);
|
||||
Assert.Equal("aryx-plugin", evt.PluginName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObserveSessionEvent_HookStart_QueuesHookLifecycleEvent()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
"type": "hook.start",
|
||||
"data": {
|
||||
"hookInvocationId": "hook-1",
|
||||
"hookType": "postToolUse",
|
||||
"input": {
|
||||
"toolName": "view"
|
||||
}
|
||||
},
|
||||
"id": "66666666-6666-6666-6666-666666666666",
|
||||
"timestamp": "2026-03-27T00:00:00Z"
|
||||
}
|
||||
"""));
|
||||
|
||||
HookLifecycleEventDto evt = Assert.Single(state.DrainPendingEvents().OfType<HookLifecycleEventDto>());
|
||||
Assert.Equal("start", evt.Phase);
|
||||
Assert.Equal("postToolUse", evt.HookType);
|
||||
Assert.Equal("hook-1", evt.HookInvocationId);
|
||||
Assert.NotNull(evt.Input);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObserveSessionEvent_SessionCompactionComplete_QueuesCompactionEvent()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
"type": "session.compaction_complete",
|
||||
"data": {
|
||||
"success": true,
|
||||
"preCompactionTokens": 1000,
|
||||
"postCompactionTokens": 400,
|
||||
"messagesRemoved": 8,
|
||||
"tokensRemoved": 600,
|
||||
"summaryContent": "Compacted summary",
|
||||
"checkpointNumber": 2,
|
||||
"checkpointPath": "C:\\Users\\me\\.copilot\\session-state\\checkpoint-2.json"
|
||||
},
|
||||
"id": "77777777-7777-7777-7777-777777777777",
|
||||
"timestamp": "2026-03-27T00:00:00Z"
|
||||
}
|
||||
"""));
|
||||
|
||||
SessionCompactionEventDto evt = Assert.Single(state.DrainPendingEvents().OfType<SessionCompactionEventDto>());
|
||||
Assert.Equal("complete", evt.Phase);
|
||||
Assert.True(evt.Success);
|
||||
Assert.Equal(1000, evt.PreCompactionTokens);
|
||||
Assert.Equal(400, evt.PostCompactionTokens);
|
||||
Assert.Equal("Compacted summary", evt.SummaryContent);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObserveSessionEvent_PendingMessagesModified_QueuesPendingMessageSignal()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
"type": "pending_messages.modified",
|
||||
"data": {},
|
||||
"id": "88888888-8888-8888-8888-888888888888",
|
||||
"timestamp": "2026-03-27T00:00:00Z"
|
||||
}
|
||||
"""));
|
||||
|
||||
PendingMessagesModifiedEventDto evt = Assert.Single(state.DrainPendingEvents().OfType<PendingMessagesModifiedEventDto>());
|
||||
Assert.Equal("session-1", evt.SessionId);
|
||||
Assert.Equal("agent-1", evt.AgentId);
|
||||
}
|
||||
|
||||
private static RunTurnCommandDto CreateCommand()
|
||||
{
|
||||
return new RunTurnCommandDto
|
||||
|
||||
@@ -649,7 +649,7 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
Assert.Equal("agent-handoff-ux", observedAgent.AgentId);
|
||||
Assert.Equal("UX Specialist", observedAgent.AgentName);
|
||||
Assert.Equal("agent-handoff-ux", state.ActiveAgent?.AgentId);
|
||||
AgentActivityEventDto activity = Assert.Single(state.DrainPendingActivityEvents());
|
||||
AgentActivityEventDto activity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
|
||||
Assert.Equal("thinking", activity.ActivityType);
|
||||
Assert.Equal("agent-handoff-ux", activity.AgentId);
|
||||
}
|
||||
@@ -675,7 +675,7 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
|
||||
Assert.Equal("agent-handoff-ux", state.ActiveAgent?.AgentId);
|
||||
Assert.Equal("UX Specialist", state.ActiveAgent?.AgentName);
|
||||
AgentActivityEventDto activity = Assert.Single(state.DrainPendingActivityEvents());
|
||||
AgentActivityEventDto activity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
|
||||
Assert.Equal("thinking", activity.ActivityType);
|
||||
Assert.Equal("agent-handoff-ux", activity.AgentId);
|
||||
}
|
||||
@@ -699,7 +699,7 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
"timestamp": "2026-03-27T00:00:00Z"
|
||||
}
|
||||
"""));
|
||||
_ = state.DrainPendingActivityEvents();
|
||||
_ = state.DrainPendingEvents();
|
||||
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
|
||||
CreateHandoffTarget("agent-handoff-ux", "UX Specialist"));
|
||||
List<AgentActivityEventDto> activities = [];
|
||||
@@ -715,9 +715,9 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
Array.Empty<ChatMessage>(),
|
||||
state,
|
||||
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
|
||||
(Func<AgentActivityEventDto, Task>)(activity =>
|
||||
(Func<SidecarEventDto, Task>)(sidecarEvent =>
|
||||
{
|
||||
activities.Add(activity);
|
||||
activities.Add(Assert.IsType<AgentActivityEventDto>(sidecarEvent));
|
||||
return Task.CompletedTask;
|
||||
}),
|
||||
])!;
|
||||
|
||||
@@ -780,6 +780,109 @@ public sealed class SidecarProtocolHostTests
|
||||
Assert.False(string.IsNullOrWhiteSpace(diagnostics.CheckedAt));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ListSessionsCommand_ReturnsSessionsListedEvent()
|
||||
{
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
sessionManager: new FakeSessionManager
|
||||
{
|
||||
Sessions =
|
||||
[
|
||||
new CopilotSessionInfoDto
|
||||
{
|
||||
CopilotSessionId = "aryx::session-1::agent-1",
|
||||
ManagedByAryx = true,
|
||||
SessionId = "session-1",
|
||||
AgentId = "agent-1",
|
||||
Summary = "Review session",
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
||||
new ListSessionsCommandDto
|
||||
{
|
||||
Type = "list-sessions",
|
||||
RequestId = "list-1",
|
||||
},
|
||||
host);
|
||||
|
||||
JsonElement listedEvent = AssertSingleEvent(events, "sessions-listed", "list-1");
|
||||
JsonElement session = Assert.Single(listedEvent.GetProperty("sessions").EnumerateArray());
|
||||
Assert.Equal("aryx::session-1::agent-1", session.GetProperty("copilotSessionId").GetString());
|
||||
Assert.Equal("session-1", session.GetProperty("sessionId").GetString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DeleteSessionCommand_ReturnsDeletedSessionsEvent()
|
||||
{
|
||||
FakeSessionManager sessionManager = new()
|
||||
{
|
||||
DeletedSessions =
|
||||
[
|
||||
new CopilotSessionInfoDto
|
||||
{
|
||||
CopilotSessionId = "aryx::session-1::agent-1",
|
||||
ManagedByAryx = true,
|
||||
SessionId = "session-1",
|
||||
AgentId = "agent-1",
|
||||
},
|
||||
],
|
||||
};
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
sessionManager: sessionManager);
|
||||
|
||||
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
||||
new DeleteSessionCommandDto
|
||||
{
|
||||
Type = "delete-session",
|
||||
RequestId = "delete-1",
|
||||
SessionId = "session-1",
|
||||
},
|
||||
host);
|
||||
|
||||
JsonElement deletedEvent = AssertSingleEvent(events, "sessions-deleted", "delete-1");
|
||||
JsonElement session = Assert.Single(deletedEvent.GetProperty("sessions").EnumerateArray());
|
||||
Assert.Equal("session-1", deletedEvent.GetProperty("sessionId").GetString());
|
||||
Assert.Equal("aryx::session-1::agent-1", session.GetProperty("copilotSessionId").GetString());
|
||||
Assert.Equal("session-1", sessionManager.DeletedAryxSessionId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task DisconnectSessionCommand_CancelsActiveTurnsForSession()
|
||||
{
|
||||
FakeWorkflowRunner runner = new(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
|
||||
{
|
||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||
return [];
|
||||
});
|
||||
SidecarProtocolHost host = new(new PatternValidator(), runner);
|
||||
|
||||
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
||||
[
|
||||
CreateRunTurnCommand(requestId: "turn-1", sessionId: "session-1"),
|
||||
new DisconnectSessionCommandDto
|
||||
{
|
||||
Type = "disconnect-session",
|
||||
RequestId = "disconnect-1",
|
||||
SessionId = "session-1",
|
||||
},
|
||||
],
|
||||
host);
|
||||
|
||||
JsonElement disconnectedEvent = AssertSingleEvent(events, "session-disconnected", "disconnect-1");
|
||||
string[] cancelledRequestIds = disconnectedEvent.GetProperty("cancelledRequestIds")
|
||||
.EnumerateArray()
|
||||
.Select(value => value.GetString() ?? string.Empty)
|
||||
.ToArray();
|
||||
Assert.Equal(["turn-1"], cancelledRequestIds);
|
||||
|
||||
JsonElement turnComplete = AssertSingleEvent(events, "turn-complete", "turn-1");
|
||||
Assert.True(turnComplete.GetProperty("cancelled").GetBoolean());
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<JsonElement>> RunHostAsync(
|
||||
object command,
|
||||
SidecarProtocolHost? host = null)
|
||||
@@ -949,7 +1052,7 @@ public sealed class SidecarProtocolHostTests
|
||||
private readonly Func<
|
||||
RunTurnCommandDto,
|
||||
Func<TurnDeltaEventDto, Task>,
|
||||
Func<AgentActivityEventDto, Task>,
|
||||
Func<SidecarEventDto, Task>,
|
||||
Func<ApprovalRequestedEventDto, Task>,
|
||||
Func<UserInputRequestedEventDto, Task>,
|
||||
Func<McpOauthRequiredEventDto, Task>,
|
||||
@@ -963,7 +1066,7 @@ public sealed class SidecarProtocolHostTests
|
||||
Func<
|
||||
RunTurnCommandDto,
|
||||
Func<TurnDeltaEventDto, Task>,
|
||||
Func<AgentActivityEventDto, Task>,
|
||||
Func<SidecarEventDto, Task>,
|
||||
Func<ApprovalRequestedEventDto, Task>,
|
||||
Func<UserInputRequestedEventDto, Task>,
|
||||
Func<McpOauthRequiredEventDto, Task>,
|
||||
@@ -981,7 +1084,7 @@ public sealed class SidecarProtocolHostTests
|
||||
public Task<IReadOnlyList<ChatMessageDto>> RunTurnAsync(
|
||||
RunTurnCommandDto command,
|
||||
Func<TurnDeltaEventDto, Task> onDelta,
|
||||
Func<AgentActivityEventDto, Task> onActivity,
|
||||
Func<SidecarEventDto, Task> onActivity,
|
||||
Func<ApprovalRequestedEventDto, Task> onApproval,
|
||||
Func<UserInputRequestedEventDto, Task> onUserInput,
|
||||
Func<McpOauthRequiredEventDto, Task> onMcpOAuthRequired,
|
||||
@@ -1005,4 +1108,32 @@ public sealed class SidecarProtocolHostTests
|
||||
return _resolveUserInputHandler(command, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeSessionManager : ICopilotSessionManager
|
||||
{
|
||||
public IReadOnlyList<CopilotSessionInfoDto> Sessions { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<CopilotSessionInfoDto> DeletedSessions { get; init; } = [];
|
||||
|
||||
public string? DeletedAryxSessionId { get; private set; }
|
||||
|
||||
public string? DeletedCopilotSessionId { get; private set; }
|
||||
|
||||
public Task<IReadOnlyList<CopilotSessionInfoDto>> ListSessionsAsync(
|
||||
CopilotSessionListFilterDto? filter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(Sessions);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<CopilotSessionInfoDto>> DeleteSessionsAsync(
|
||||
string? aryxSessionId,
|
||||
string? copilotSessionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DeletedAryxSessionId = aryxSessionId;
|
||||
DeletedCopilotSessionId = copilotSessionId;
|
||||
return Task.FromResult(DeletedSessions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+122
-2
@@ -14,6 +14,7 @@ import type {
|
||||
TurnDeltaEvent,
|
||||
UserInputRequestedEvent,
|
||||
} from '@shared/contracts/sidecar';
|
||||
import type { TurnScopedEvent } from '@main/sidecar/runTurnPending';
|
||||
import {
|
||||
buildAvailableModelCatalog,
|
||||
findModel,
|
||||
@@ -564,10 +565,40 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async sendSessionMessage(sessionId: string, content: string): Promise<void> {
|
||||
async deleteSession(sessionId: string): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const sessionIndex = workspace.sessions.findIndex((s) => s.id === sessionId);
|
||||
if (sessionIndex < 0) {
|
||||
throw new Error(`Session ${sessionId} not found.`);
|
||||
}
|
||||
|
||||
workspace.sessions.splice(sessionIndex, 1);
|
||||
|
||||
if (workspace.selectedSessionId === sessionId) {
|
||||
workspace.selectedSessionId = workspace.sessions[0]?.id;
|
||||
}
|
||||
|
||||
// Clean up corresponding Copilot SDK session data
|
||||
try {
|
||||
await this.sidecar.deleteSession(sessionId);
|
||||
} catch {
|
||||
// Best-effort — don't fail the deletion if SDK cleanup fails
|
||||
}
|
||||
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async sendSessionMessage(
|
||||
sessionId: string,
|
||||
content: string,
|
||||
attachments?: import('@shared/domain/attachment').ChatMessageAttachment[],
|
||||
messageMode?: import('@shared/contracts/sidecar').MessageMode,
|
||||
): Promise<void> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
if (session.status === 'running') {
|
||||
|
||||
// Steering/queueing: allow messages during an active turn when messageMode is set
|
||||
if (session.status === 'running' && !messageMode) {
|
||||
throw new Error('Wait for the current response or approval checkpoint to finish before sending another message.');
|
||||
}
|
||||
const project = this.requireProject(workspace, session.projectId);
|
||||
@@ -589,6 +620,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
authorName: 'You',
|
||||
content: preparedContent,
|
||||
createdAt: occurredAt,
|
||||
attachments: attachments?.length ? attachments : undefined,
|
||||
});
|
||||
session.title = resolveSessionTitle(session, effectivePattern, session.messages);
|
||||
session.status = 'running';
|
||||
@@ -625,8 +657,10 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
projectPath: project.path,
|
||||
workspaceKind,
|
||||
mode: session.interactionMode ?? 'interactive',
|
||||
messageMode,
|
||||
pattern: effectivePattern,
|
||||
messages: session.messages,
|
||||
attachments: attachments?.length ? attachments : undefined,
|
||||
tooling: this.buildRunTurnToolingConfig(workspace, session),
|
||||
},
|
||||
async (event) => {
|
||||
@@ -649,6 +683,9 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
async (event) => {
|
||||
await this.handleExitPlanModeRequested(workspace, session.id, event);
|
||||
},
|
||||
async (event) => {
|
||||
await this.handleTurnScopedEvent(workspace, session.id, event);
|
||||
},
|
||||
);
|
||||
|
||||
await this.awaitFinalResponseApproval(workspace, session.id, requestId, effectivePattern, responseMessages);
|
||||
@@ -1525,6 +1562,89 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
await this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
private handleTurnScopedEvent(
|
||||
_workspace: WorkspaceState,
|
||||
sessionId: string,
|
||||
event: TurnScopedEvent,
|
||||
): void {
|
||||
const occurredAt = nowIso();
|
||||
|
||||
switch (event.type) {
|
||||
case 'subagent-event':
|
||||
this.emitSessionEvent({
|
||||
sessionId,
|
||||
kind: 'subagent',
|
||||
occurredAt,
|
||||
agentId: event.agentId,
|
||||
agentName: event.agentName,
|
||||
subagentEventKind: event.eventKind,
|
||||
customAgentName: event.customAgentName,
|
||||
customAgentDisplayName: event.customAgentDisplayName,
|
||||
});
|
||||
return;
|
||||
case 'skill-invoked':
|
||||
this.emitSessionEvent({
|
||||
sessionId,
|
||||
kind: 'skill-invoked',
|
||||
occurredAt,
|
||||
agentId: event.agentId,
|
||||
agentName: event.agentName,
|
||||
skillName: event.skillName,
|
||||
skillPath: event.path,
|
||||
pluginName: event.pluginName,
|
||||
});
|
||||
return;
|
||||
case 'hook-lifecycle':
|
||||
this.emitSessionEvent({
|
||||
sessionId,
|
||||
kind: 'hook-lifecycle',
|
||||
occurredAt,
|
||||
agentId: event.agentId,
|
||||
agentName: event.agentName,
|
||||
hookInvocationId: event.hookInvocationId,
|
||||
hookType: event.hookType,
|
||||
hookPhase: event.phase,
|
||||
hookSuccess: event.success,
|
||||
});
|
||||
return;
|
||||
case 'session-usage':
|
||||
this.emitSessionEvent({
|
||||
sessionId,
|
||||
kind: 'session-usage',
|
||||
occurredAt,
|
||||
agentId: event.agentId,
|
||||
agentName: event.agentName,
|
||||
tokenLimit: event.tokenLimit,
|
||||
currentTokens: event.currentTokens,
|
||||
messagesLength: event.messagesLength,
|
||||
});
|
||||
return;
|
||||
case 'session-compaction':
|
||||
this.emitSessionEvent({
|
||||
sessionId,
|
||||
kind: 'session-compaction',
|
||||
occurredAt,
|
||||
agentId: event.agentId,
|
||||
agentName: event.agentName,
|
||||
compactionPhase: event.phase,
|
||||
compactionSuccess: event.success,
|
||||
preCompactionTokens: event.preCompactionTokens,
|
||||
postCompactionTokens: event.postCompactionTokens,
|
||||
tokensRemoved: event.tokensRemoved,
|
||||
});
|
||||
return;
|
||||
case 'pending-messages-modified':
|
||||
this.emitSessionEvent({
|
||||
sessionId,
|
||||
kind: 'pending-messages-modified',
|
||||
occurredAt,
|
||||
agentId: event.agentId,
|
||||
agentName: event.agentName,
|
||||
});
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
private createPendingApprovalFromSidecarEvent(event: ApprovalRequestedEvent): PendingApprovalRecord {
|
||||
return {
|
||||
id: event.approvalId,
|
||||
|
||||
@@ -26,6 +26,7 @@ import type {
|
||||
UpdateSessionApprovalSettingsInput,
|
||||
UpdateSessionToolingInput,
|
||||
UpdateSessionModelConfigInput,
|
||||
DeleteSessionInput,
|
||||
} from '@shared/contracts/ipc';
|
||||
import type { QuerySessionsInput } from '@shared/domain/sessionLibrary';
|
||||
import type { AppearanceTheme } from '@shared/domain/tooling';
|
||||
@@ -106,8 +107,11 @@ export function registerIpcHandlers(window: BrowserWindow, service: AryxAppServi
|
||||
ipcMain.handle(ipcChannels.setSessionArchived, (_event, input: SetSessionArchivedInput) =>
|
||||
service.setSessionArchived(input.sessionId, input.isArchived),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.deleteSession, (_event, input: DeleteSessionInput) =>
|
||||
service.deleteSession(input.sessionId),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.sendSessionMessage, (_event, input: SendSessionMessageInput) =>
|
||||
service.sendSessionMessage(input.sessionId, input.content),
|
||||
service.sendSessionMessage(input.sessionId, input.content, input.attachments, input.messageMode),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.cancelSessionTurn, (_event, input: CancelSessionTurnInput) =>
|
||||
service.cancelSessionTurn(input.sessionId),
|
||||
|
||||
@@ -5,9 +5,23 @@ import type {
|
||||
McpOauthRequiredEvent,
|
||||
TurnDeltaEvent,
|
||||
UserInputRequestedEvent,
|
||||
SubagentEvent,
|
||||
SkillInvokedEvent,
|
||||
HookLifecycleEvent,
|
||||
SessionUsageEvent,
|
||||
SessionCompactionEvent,
|
||||
PendingMessagesModifiedEvent,
|
||||
} from '@shared/contracts/sidecar';
|
||||
import type { ChatMessageRecord } from '@shared/domain/session';
|
||||
|
||||
export type TurnScopedEvent =
|
||||
| SubagentEvent
|
||||
| SkillInvokedEvent
|
||||
| HookLifecycleEvent
|
||||
| SessionUsageEvent
|
||||
| SessionCompactionEvent
|
||||
| PendingMessagesModifiedEvent;
|
||||
|
||||
export interface RunTurnPendingCommand {
|
||||
kind: 'run-turn';
|
||||
resolve: (messages: ChatMessageRecord[]) => void;
|
||||
@@ -18,6 +32,7 @@ export interface RunTurnPendingCommand {
|
||||
onUserInput: (event: UserInputRequestedEvent) => void | Promise<void>;
|
||||
onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise<void>;
|
||||
onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise<void>;
|
||||
onTurnScopedEvent: (event: TurnScopedEvent) => void | Promise<void>;
|
||||
errored: boolean;
|
||||
}
|
||||
|
||||
|
||||
@@ -14,6 +14,8 @@ import type {
|
||||
ExitPlanModeRequestedEvent,
|
||||
ValidatePatternCommand,
|
||||
RunTurnCommand,
|
||||
CopilotSessionListFilter,
|
||||
CopilotSessionInfo,
|
||||
} from '@shared/contracts/sidecar';
|
||||
import type { ApprovalDecision } from '@shared/domain/approval';
|
||||
import type { ChatMessageRecord } from '@shared/domain/session';
|
||||
@@ -22,6 +24,7 @@ import {
|
||||
markRunTurnPendingErrored,
|
||||
shouldHandleRunTurnEvent,
|
||||
type RunTurnPendingCommand,
|
||||
type TurnScopedEvent,
|
||||
} from '@main/sidecar/runTurnPending';
|
||||
import { TurnCancelledError } from '@main/sidecar/turnCancelledError';
|
||||
import { resolveSidecarProcess } from '@main/sidecar/sidecarRuntime';
|
||||
@@ -59,6 +62,24 @@ type PendingCommand =
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
})
|
||||
| ({
|
||||
processId: number;
|
||||
kind: 'list-sessions';
|
||||
resolve: (sessions: CopilotSessionInfo[]) => void;
|
||||
reject: (error: Error) => void;
|
||||
})
|
||||
| ({
|
||||
processId: number;
|
||||
kind: 'delete-session';
|
||||
resolve: (sessions: CopilotSessionInfo[]) => void;
|
||||
reject: (error: Error) => void;
|
||||
})
|
||||
| ({
|
||||
processId: number;
|
||||
kind: 'disconnect-session';
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
})
|
||||
| ({
|
||||
processId: number;
|
||||
} & RunTurnPendingCommand);
|
||||
@@ -106,8 +127,9 @@ export class SidecarClient {
|
||||
onUserInput: (event: UserInputRequestedEvent) => void | Promise<void>,
|
||||
onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise<void>,
|
||||
onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise<void>,
|
||||
onTurnScopedEvent: (event: TurnScopedEvent) => void | Promise<void>,
|
||||
): Promise<ChatMessageRecord[]> {
|
||||
return this.dispatch<ChatMessageRecord[]>(command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode);
|
||||
return this.dispatch<ChatMessageRecord[]>(command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, onTurnScopedEvent);
|
||||
}
|
||||
|
||||
async resolveUserInput(userInputId: string, answer: string, wasFreeform: boolean): Promise<void> {
|
||||
@@ -138,6 +160,31 @@ export class SidecarClient {
|
||||
} satisfies CancelTurnCommand);
|
||||
}
|
||||
|
||||
async listSessions(filter?: CopilotSessionListFilter): Promise<CopilotSessionInfo[]> {
|
||||
return this.dispatch<CopilotSessionInfo[]>({
|
||||
type: 'list-sessions',
|
||||
requestId: `list-sessions-${Date.now()}`,
|
||||
filter,
|
||||
});
|
||||
}
|
||||
|
||||
async deleteSession(sessionId?: string, copilotSessionId?: string): Promise<CopilotSessionInfo[]> {
|
||||
return this.dispatch<CopilotSessionInfo[]>({
|
||||
type: 'delete-session',
|
||||
requestId: `delete-session-${Date.now()}`,
|
||||
sessionId,
|
||||
copilotSessionId,
|
||||
});
|
||||
}
|
||||
|
||||
async disconnectSession(sessionId: string): Promise<void> {
|
||||
return this.dispatch<void>({
|
||||
type: 'disconnect-session',
|
||||
requestId: `disconnect-session-${Date.now()}`,
|
||||
sessionId,
|
||||
});
|
||||
}
|
||||
|
||||
async dispose(): Promise<void> {
|
||||
const state = this.processState;
|
||||
if (!state) {
|
||||
@@ -225,6 +272,7 @@ export class SidecarClient {
|
||||
onUserInput?: (event: UserInputRequestedEvent) => void | Promise<void>,
|
||||
onMcpOAuthRequired?: (event: McpOauthRequiredEvent) => void | Promise<void>,
|
||||
onExitPlanMode?: (event: ExitPlanModeRequestedEvent) => void | Promise<void>,
|
||||
onTurnScopedEvent?: (event: TurnScopedEvent) => void | Promise<void>,
|
||||
): Promise<TResult> {
|
||||
const state = await this.ensureProcess();
|
||||
|
||||
@@ -241,6 +289,7 @@ export class SidecarClient {
|
||||
onUserInput: onUserInput ?? (() => undefined),
|
||||
onMcpOAuthRequired: onMcpOAuthRequired ?? (() => undefined),
|
||||
onExitPlanMode: onExitPlanMode ?? (() => undefined),
|
||||
onTurnScopedEvent: onTurnScopedEvent ?? (() => undefined),
|
||||
errored: false,
|
||||
});
|
||||
} else if (command.type === 'validate-pattern') {
|
||||
@@ -271,6 +320,27 @@ export class SidecarClient {
|
||||
resolve: resolve as () => void,
|
||||
reject,
|
||||
});
|
||||
} else if (command.type === 'list-sessions') {
|
||||
this.pending.set(command.requestId, {
|
||||
processId: state.id,
|
||||
kind: 'list-sessions',
|
||||
resolve: resolve as (sessions: CopilotSessionInfo[]) => void,
|
||||
reject,
|
||||
});
|
||||
} else if (command.type === 'delete-session') {
|
||||
this.pending.set(command.requestId, {
|
||||
processId: state.id,
|
||||
kind: 'delete-session',
|
||||
resolve: resolve as (sessions: CopilotSessionInfo[]) => void,
|
||||
reject,
|
||||
});
|
||||
} else if (command.type === 'disconnect-session') {
|
||||
this.pending.set(command.requestId, {
|
||||
processId: state.id,
|
||||
kind: 'disconnect-session',
|
||||
resolve: resolve as () => void,
|
||||
reject,
|
||||
});
|
||||
} else {
|
||||
this.pending.set(command.requestId, {
|
||||
processId: state.id,
|
||||
@@ -348,6 +418,34 @@ export class SidecarClient {
|
||||
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onExitPlanMode(event));
|
||||
}
|
||||
return;
|
||||
case 'subagent-event':
|
||||
case 'skill-invoked':
|
||||
case 'hook-lifecycle':
|
||||
case 'session-usage':
|
||||
case 'session-compaction':
|
||||
case 'pending-messages-modified':
|
||||
if (pending.kind === 'run-turn' && shouldHandleRunTurnEvent(pending)) {
|
||||
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onTurnScopedEvent(event));
|
||||
}
|
||||
return;
|
||||
case 'sessions-listed':
|
||||
if (pending.kind === 'list-sessions') {
|
||||
pending.resolve(event.sessions);
|
||||
this.pending.delete(event.requestId);
|
||||
}
|
||||
return;
|
||||
case 'sessions-deleted':
|
||||
if (pending.kind === 'delete-session') {
|
||||
pending.resolve(event.sessions);
|
||||
this.pending.delete(event.requestId);
|
||||
}
|
||||
return;
|
||||
case 'session-disconnected':
|
||||
if (pending.kind === 'disconnect-session') {
|
||||
pending.resolve();
|
||||
this.pending.delete(event.requestId);
|
||||
}
|
||||
return;
|
||||
case 'turn-complete':
|
||||
if (pending.kind === 'run-turn') {
|
||||
if (shouldHandleRunTurnEvent(pending)) {
|
||||
|
||||
@@ -33,6 +33,7 @@ const api: ElectronApi = {
|
||||
renameSession: (input) => ipcRenderer.invoke(ipcChannels.renameSession, input),
|
||||
setSessionPinned: (input) => ipcRenderer.invoke(ipcChannels.setSessionPinned, input),
|
||||
setSessionArchived: (input) => ipcRenderer.invoke(ipcChannels.setSessionArchived, input),
|
||||
deleteSession: (input) => ipcRenderer.invoke(ipcChannels.deleteSession, input),
|
||||
sendSessionMessage: (input) => ipcRenderer.invoke(ipcChannels.sendSessionMessage, input),
|
||||
cancelSessionTurn: (input) => ipcRenderer.invoke(ipcChannels.cancelSessionTurn, input),
|
||||
resolveSessionApproval: (input) => ipcRenderer.invoke(ipcChannels.resolveSessionApproval, input),
|
||||
|
||||
+41
-1
@@ -10,8 +10,14 @@ import { Sidebar } from '@renderer/components/Sidebar';
|
||||
import { resolveChatToolingSettings } from '@renderer/lib/chatTooling';
|
||||
import {
|
||||
applySessionEventActivity,
|
||||
applySessionUsageEvent,
|
||||
applyTurnEventLog,
|
||||
pruneSessionActivities,
|
||||
pruneSessionUsage,
|
||||
pruneTurnEventLogs,
|
||||
type SessionActivityMap,
|
||||
type SessionUsageMap,
|
||||
type TurnEventLogMap,
|
||||
} from '@renderer/lib/sessionActivity';
|
||||
import { applySessionEventWorkspace } from '@renderer/lib/sessionWorkspace';
|
||||
import { WelcomePane } from '@renderer/components/WelcomePane';
|
||||
@@ -91,6 +97,8 @@ export default function App() {
|
||||
const [error, setError] = useState<string>();
|
||||
const { capabilities: sidecarCapabilities, isRefreshing: isRefreshingCapabilities, refresh: refreshCapabilities } = useSidecarCapabilities(api);
|
||||
const [sessionActivities, setSessionActivities] = useState<SessionActivityMap>({});
|
||||
const [sessionUsage, setSessionUsage] = useState<SessionUsageMap>({});
|
||||
const [turnEventLogs, setTurnEventLogs] = useState<TurnEventLogMap>({});
|
||||
|
||||
const [showSettings, setShowSettings] = useState(false);
|
||||
const [newSessionProjectId, setNewSessionProjectId] = useState<string>();
|
||||
@@ -114,11 +122,25 @@ export default function App() {
|
||||
ws.sessions.map((session) => session.id),
|
||||
),
|
||||
);
|
||||
setSessionUsage((current) =>
|
||||
pruneSessionUsage(
|
||||
current,
|
||||
ws.sessions.map((session) => session.id),
|
||||
),
|
||||
);
|
||||
setTurnEventLogs((current) =>
|
||||
pruneTurnEventLogs(
|
||||
current,
|
||||
ws.sessions.map((session) => session.id),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
const offSessionEvent = api.onSessionEvent((event) => {
|
||||
setWorkspace((current) => applySessionEventWorkspace(current, event));
|
||||
setSessionActivities((current) => applySessionEventActivity(current, event));
|
||||
setSessionUsage((current) => applySessionUsageEvent(current, event));
|
||||
setTurnEventLogs((current) => applyTurnEventLog(current, event));
|
||||
});
|
||||
|
||||
return () => {
|
||||
@@ -169,6 +191,14 @@ export default function App() {
|
||||
() => (selectedSession ? sessionActivities[selectedSession.id] : undefined),
|
||||
[selectedSession, sessionActivities],
|
||||
);
|
||||
const usageForSession = useMemo(
|
||||
() => (selectedSession ? sessionUsage[selectedSession.id] : undefined),
|
||||
[selectedSession, sessionUsage],
|
||||
);
|
||||
const turnEventsForSession = useMemo(
|
||||
() => (selectedSession ? turnEventLogs[selectedSession.id] : undefined),
|
||||
[selectedSession, turnEventLogs],
|
||||
);
|
||||
const hasUserProjects = useMemo(
|
||||
() => (workspace?.projects.some((project) => !isScratchpadProject(project)) ?? false),
|
||||
[workspace?.projects],
|
||||
@@ -245,7 +275,12 @@ export default function App() {
|
||||
} else if (selectedSession && patternForSession && projectForSession) {
|
||||
content = (
|
||||
<ChatPane
|
||||
onSend={(c) => api.sendSessionMessage({ sessionId: selectedSession.id, content: c })}
|
||||
onSend={(c, attachments, messageMode) => api.sendSessionMessage({
|
||||
sessionId: selectedSession.id,
|
||||
content: c,
|
||||
attachments: attachments?.length ? attachments : undefined,
|
||||
messageMode,
|
||||
})}
|
||||
onCancelTurn={() => { void api.cancelSessionTurn({ sessionId: selectedSession.id }); }}
|
||||
onResolveApproval={(approvalId, decision, alwaysApprove) =>
|
||||
api.resolveSessionApproval({ sessionId: selectedSession.id, approvalId, decision, alwaysApprove })
|
||||
@@ -290,6 +325,7 @@ export default function App() {
|
||||
project={projectForSession}
|
||||
runtimeTools={sidecarCapabilities?.runtimeTools}
|
||||
session={selectedSession}
|
||||
sessionUsage={usageForSession}
|
||||
toolingSettings={chatToolingSettings ?? workspace.settings.tooling}
|
||||
/>
|
||||
);
|
||||
@@ -299,6 +335,7 @@ export default function App() {
|
||||
onJumpToMessage={jumpToMessage}
|
||||
pattern={patternForSession}
|
||||
session={selectedSession}
|
||||
turnEvents={turnEventsForSession}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
@@ -404,6 +441,9 @@ export default function App() {
|
||||
onSetSessionArchived={(sessionId, isArchived) => {
|
||||
void api.setSessionArchived({ sessionId, isArchived });
|
||||
}}
|
||||
onDeleteSession={(sessionId) => {
|
||||
void api.deleteSession({ sessionId });
|
||||
}}
|
||||
onRefreshGitContext={(projectId) => {
|
||||
void api.refreshProjectGitContext(projectId);
|
||||
}}
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useMemo, type ReactNode } from 'react';
|
||||
import { Activity, Clock, ShieldAlert, Sparkles, Users } from 'lucide-react';
|
||||
import { Activity, ArrowRight, CheckCircle2, Clock, Cog, ShieldAlert, Sparkles, Users, Zap } from 'lucide-react';
|
||||
|
||||
import {
|
||||
buildAgentActivityRows,
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
isAgentActivityCompleted,
|
||||
type AgentActivityRow,
|
||||
type SessionActivityState,
|
||||
type TurnEventLog,
|
||||
} from '@renderer/lib/sessionActivity';
|
||||
import { RunTimeline } from '@renderer/components/RunTimeline';
|
||||
import { inferProvider } from '@shared/domain/models';
|
||||
@@ -145,6 +146,35 @@ function AgentRow({
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Turn event helpers ─────────────────────────────────────── */
|
||||
|
||||
import type { SessionEventKind } from '@shared/domain/event';
|
||||
|
||||
function TurnEventIcon({ kind, phase, success }: { kind: SessionEventKind; phase?: string; success?: boolean }) {
|
||||
const base = 'size-3';
|
||||
switch (kind) {
|
||||
case 'subagent':
|
||||
return <ArrowRight className={`${base} ${success === false ? 'text-red-400' : 'text-sky-400'}`} />;
|
||||
case 'hook-lifecycle':
|
||||
return <Cog className={`${base} ${phase === 'start' ? 'animate-spin text-amber-400' : success === false ? 'text-red-400' : 'text-emerald-400'}`} />;
|
||||
case 'skill-invoked':
|
||||
return <Sparkles className={`${base} text-violet-400`} />;
|
||||
case 'session-compaction':
|
||||
return <CheckCircle2 className={`${base} ${phase === 'start' ? 'animate-pulse text-amber-400' : 'text-emerald-400'}`} />;
|
||||
default:
|
||||
return <Zap className={`${base} text-zinc-500`} />;
|
||||
}
|
||||
}
|
||||
|
||||
function formatTurnEventTimestamp(iso: string): string {
|
||||
try {
|
||||
const d = new Date(iso);
|
||||
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
||||
} catch {
|
||||
return '';
|
||||
}
|
||||
}
|
||||
|
||||
/* ── ActivityPanel ─────────────────────────────────────────── */
|
||||
|
||||
interface ActivityPanelProps {
|
||||
@@ -152,6 +182,7 @@ interface ActivityPanelProps {
|
||||
onJumpToMessage?: (messageId: string) => void;
|
||||
pattern: PatternDefinition;
|
||||
session: SessionRecord;
|
||||
turnEvents?: TurnEventLog;
|
||||
}
|
||||
|
||||
export function ActivityPanel({
|
||||
@@ -159,6 +190,7 @@ export function ActivityPanel({
|
||||
onJumpToMessage,
|
||||
pattern,
|
||||
session,
|
||||
turnEvents,
|
||||
}: ActivityPanelProps) {
|
||||
const activityRows = useMemo(
|
||||
() => buildAgentActivityRows(activity, pattern.agents),
|
||||
@@ -239,6 +271,39 @@ export function ActivityPanel({
|
||||
<RunTimeline onJumpToMessage={onJumpToMessage} runs={session.runs} />
|
||||
</div>
|
||||
|
||||
{/* ── Turn events section ─────────────────────────── */}
|
||||
{turnEvents && turnEvents.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<SectionHeader>
|
||||
<Zap className="size-3" />
|
||||
<span>Events</span>
|
||||
<span className="rounded-full bg-zinc-800 px-1.5 py-0.5 text-[9px] tabular-nums text-zinc-500">
|
||||
{turnEvents.length}
|
||||
</span>
|
||||
</SectionHeader>
|
||||
|
||||
<div className="space-y-0.5 rounded-lg border border-zinc-800 bg-zinc-900/40 px-3 py-2">
|
||||
{turnEvents.slice().reverse().map((entry, index) => (
|
||||
<div key={index} className="flex items-start gap-2 py-1">
|
||||
<div className="mt-0.5 shrink-0">
|
||||
<TurnEventIcon kind={entry.kind} phase={entry.phase} success={entry.success} />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span className="text-[11px] font-medium text-zinc-300">{entry.label}</span>
|
||||
<span className="ml-auto shrink-0 text-[9px] tabular-nums text-zinc-700">
|
||||
{formatTurnEventTimestamp(entry.occurredAt)}
|
||||
</span>
|
||||
</div>
|
||||
{entry.detail && (
|
||||
<p className="text-[10px] leading-snug text-zinc-600">{entry.detail}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AlertCircle, ArrowUp, Bot, Circle, ClipboardList, GitBranch, Loader2, MessageCircleQuestion, ShieldAlert, Square, User } from 'lucide-react';
|
||||
import { AlertCircle, ArrowUp, Bot, Circle, ClipboardList, GitBranch, Loader2, MessageCircleQuestion, Paperclip, ShieldAlert, Square, User, X } from 'lucide-react';
|
||||
|
||||
import { MarkdownContent } from '@renderer/components/MarkdownContent';
|
||||
import { MarkdownComposer, type MarkdownComposerHandle } from '@renderer/components/MarkdownComposer';
|
||||
@@ -11,7 +11,10 @@ import { InlineApprovalPill, InlineModelPill, InlineThinkingPill, InlineToolsPil
|
||||
import { ThinkingDots } from '@renderer/components/chat/ThinkingDots';
|
||||
import { getAssistantMessagePhase } from '@renderer/lib/messagePhase';
|
||||
import type { ApprovalDecision } from '@shared/domain/approval';
|
||||
import type { InteractionMode } from '@shared/contracts/sidecar';
|
||||
import type { InteractionMode, MessageMode } from '@shared/contracts/sidecar';
|
||||
import type { ChatMessageAttachment } from '@shared/domain/attachment';
|
||||
import { getAttachmentDisplayName, isImageAttachment } from '@shared/domain/attachment';
|
||||
import type { SessionUsageState } from '@renderer/lib/sessionActivity';
|
||||
import {
|
||||
findModel,
|
||||
getSupportedReasoningEfforts,
|
||||
@@ -37,7 +40,8 @@ interface ChatPaneProps {
|
||||
availableModels: ReadonlyArray<ModelDefinition>;
|
||||
toolingSettings: WorkspaceToolingSettings;
|
||||
runtimeTools?: ReadonlyArray<RuntimeToolDefinition>;
|
||||
onSend: (content: string) => Promise<void>;
|
||||
sessionUsage?: SessionUsageState;
|
||||
onSend: (content: string, attachments?: ChatMessageAttachment[], messageMode?: MessageMode) => Promise<void>;
|
||||
onCancelTurn?: () => void;
|
||||
onResolveApproval?: (approvalId: string, decision: ApprovalDecision, alwaysApprove?: boolean) => Promise<unknown>;
|
||||
onResolveUserInput?: (userInputId: string, answer: string, wasFreeform: boolean) => Promise<unknown>;
|
||||
@@ -60,6 +64,7 @@ export function ChatPane({
|
||||
availableModels,
|
||||
toolingSettings,
|
||||
runtimeTools,
|
||||
sessionUsage,
|
||||
onSend,
|
||||
onCancelTurn,
|
||||
onResolveApproval,
|
||||
@@ -99,8 +104,9 @@ export function ChatPane({
|
||||
const selectedModel = primaryAgent ? findModel(primaryAgent.model, availableModels) : undefined;
|
||||
const supportedEfforts = getSupportedReasoningEfforts(selectedModel);
|
||||
const sessionReasoningEffort = resolveReasoningEffort(selectedModel, primaryAgent?.reasoningEffort);
|
||||
const isComposerDisabled = isSessionBusy || isUpdatingSessionModelConfig;
|
||||
const isComposerDisabled = isUpdatingSessionModelConfig;
|
||||
const canSubmitInput = hasComposerContent && !isComposerDisabled;
|
||||
const [pendingAttachments, setPendingAttachments] = useState<ChatMessageAttachment[]>([]);
|
||||
|
||||
const toolSelection = useMemo(() => resolveSessionToolingSelection(session), [session]);
|
||||
const mcpServers = toolingSettings.mcpServers;
|
||||
@@ -140,7 +146,10 @@ export function ChatPane({
|
||||
}, [session.id]);
|
||||
|
||||
function handleComposerSubmit(content: string) {
|
||||
void onSend(content);
|
||||
const attachments = pendingAttachments.length > 0 ? [...pendingAttachments] : undefined;
|
||||
const messageMode: MessageMode | undefined = isSessionBusy ? 'immediate' : undefined;
|
||||
setPendingAttachments([]);
|
||||
void onSend(content, attachments, messageMode);
|
||||
}
|
||||
|
||||
function handleDismissPlan() {
|
||||
@@ -336,6 +345,29 @@ export function ChatPane({
|
||||
: `rounded-xl border px-4 py-3 text-[14px] leading-relaxed text-zinc-200 ${assistantContainerClass}`
|
||||
}
|
||||
>
|
||||
{/* Attachment thumbnails */}
|
||||
{isUser && message.attachments && message.attachments.length > 0 && (
|
||||
<div className="mb-2 flex flex-wrap gap-2">
|
||||
{message.attachments.map((att, attIdx) =>
|
||||
isImageAttachment(att) ? (
|
||||
<img
|
||||
key={attIdx}
|
||||
alt={getAttachmentDisplayName(att)}
|
||||
className="max-h-48 max-w-xs rounded-lg border border-zinc-700 object-cover"
|
||||
src={`data:${att.mimeType};base64,${att.data}`}
|
||||
/>
|
||||
) : (
|
||||
<div
|
||||
key={attIdx}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-zinc-700 bg-zinc-800 px-2 py-1 text-[11px] text-zinc-400"
|
||||
>
|
||||
<Paperclip className="size-3" />
|
||||
{getAttachmentDisplayName(att)}
|
||||
</div>
|
||||
),
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{!isUser && message.pending ? (
|
||||
<div className="whitespace-pre-wrap break-words text-[14px] leading-relaxed text-zinc-200">
|
||||
{message.content}
|
||||
@@ -510,6 +542,29 @@ export function ChatPane({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Attachment preview */}
|
||||
{pendingAttachments.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2 px-1 pb-2">
|
||||
{pendingAttachments.map((attachment, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center gap-1.5 rounded-lg border border-zinc-700 bg-zinc-800 px-2.5 py-1.5 text-[11px] text-zinc-300"
|
||||
>
|
||||
<Paperclip className="size-3 text-zinc-500" />
|
||||
<span className="max-w-[160px] truncate">{getAttachmentDisplayName(attachment)}</span>
|
||||
<button
|
||||
aria-label="Remove attachment"
|
||||
className="ml-1 rounded p-0.5 text-zinc-500 hover:bg-zinc-700 hover:text-zinc-300"
|
||||
onClick={() => setPendingAttachments((prev) => prev.filter((_, i) => i !== index))}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="rounded-xl border border-zinc-700 bg-zinc-900 transition-colors focus-within:border-indigo-500/50">
|
||||
<MarkdownComposer
|
||||
ref={composerRef}
|
||||
@@ -526,7 +581,7 @@ export function ChatPane({
|
||||
: pendingMcpAuth
|
||||
? 'MCP server requires authentication...'
|
||||
: isSessionBusy
|
||||
? 'Waiting for response...'
|
||||
? 'Steer the agent (sends immediately)...'
|
||||
: isUpdatingSessionModelConfig
|
||||
? 'Saving model settings...'
|
||||
: isPlanMode
|
||||
@@ -535,6 +590,38 @@ export function ChatPane({
|
||||
}
|
||||
>
|
||||
<div className="absolute bottom-2 right-2 flex items-center gap-1">
|
||||
{/* Attachment picker */}
|
||||
<button
|
||||
aria-label="Attach image"
|
||||
className="flex size-8 items-center justify-center rounded-lg text-zinc-500 transition hover:bg-zinc-800 hover:text-zinc-300"
|
||||
disabled={isComposerDisabled}
|
||||
onClick={() => {
|
||||
const input = document.createElement('input');
|
||||
input.type = 'file';
|
||||
input.accept = 'image/jpeg,image/png,image/gif,image/webp';
|
||||
input.multiple = true;
|
||||
input.onchange = () => {
|
||||
if (!input.files) return;
|
||||
const newAttachments: ChatMessageAttachment[] = [];
|
||||
for (const file of input.files) {
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
const base64 = (reader.result as string).split(',')[1];
|
||||
setPendingAttachments((prev) => [
|
||||
...prev,
|
||||
{ type: 'blob', data: base64, mimeType: file.type, displayName: file.name },
|
||||
]);
|
||||
};
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
};
|
||||
input.click();
|
||||
}}
|
||||
type="button"
|
||||
>
|
||||
<Paperclip className="size-3.5" />
|
||||
</button>
|
||||
|
||||
{/* Plan mode toggle */}
|
||||
{onSetInteractionMode && !isSessionBusy && (
|
||||
<button
|
||||
@@ -553,29 +640,39 @@ export function ChatPane({
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Send / Stop button */}
|
||||
{/* Send / Stop / Steer button */}
|
||||
<button
|
||||
className={`flex size-8 items-center justify-center rounded-lg transition ${
|
||||
isSessionBusy
|
||||
isSessionBusy && !hasComposerContent && pendingAttachments.length === 0
|
||||
? 'bg-red-600/80 text-white hover:bg-red-500'
|
||||
: canSubmitInput
|
||||
? isPlanMode
|
||||
? 'bg-emerald-600 text-white hover:bg-emerald-500'
|
||||
: 'bg-indigo-600 text-white hover:bg-indigo-500'
|
||||
: canSubmitInput || pendingAttachments.length > 0
|
||||
? isSessionBusy
|
||||
? 'bg-amber-600 text-white hover:bg-amber-500'
|
||||
: isPlanMode
|
||||
? 'bg-emerald-600 text-white hover:bg-emerald-500'
|
||||
: 'bg-indigo-600 text-white hover:bg-indigo-500'
|
||||
: 'bg-zinc-800 text-zinc-600'
|
||||
}`}
|
||||
disabled={!canSubmitInput && !isSessionBusy}
|
||||
disabled={!canSubmitInput && !isSessionBusy && pendingAttachments.length === 0}
|
||||
onClick={() => {
|
||||
if (isSessionBusy) {
|
||||
if (isSessionBusy && !hasComposerContent && pendingAttachments.length === 0) {
|
||||
onCancelTurn?.();
|
||||
} else {
|
||||
composerRef.current?.submit();
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
aria-label={isSessionBusy ? 'Stop generating' : isPlanMode ? 'Send as plan request' : 'Send message'}
|
||||
aria-label={
|
||||
isSessionBusy && !hasComposerContent && pendingAttachments.length === 0
|
||||
? 'Stop generating'
|
||||
: isSessionBusy
|
||||
? 'Steer agent'
|
||||
: isPlanMode
|
||||
? 'Send as plan request'
|
||||
: 'Send message'
|
||||
}
|
||||
>
|
||||
{isSessionBusy ? (
|
||||
{isSessionBusy && !hasComposerContent && pendingAttachments.length === 0 ? (
|
||||
<Square className="size-3.5" fill="currentColor" />
|
||||
) : (
|
||||
<ArrowUp className="size-4" />
|
||||
@@ -591,7 +688,38 @@ export function ChatPane({
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{isSessionBusy && (hasComposerContent || pendingAttachments.length > 0) && (
|
||||
<div className="flex items-center gap-1.5 px-3 pb-1.5 pt-0.5">
|
||||
<div className="size-1.5 rounded-full bg-amber-500" />
|
||||
<span className="text-[10px] font-medium text-amber-400/80">
|
||||
Steering — your message will be injected into the current turn
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Session usage bar */}
|
||||
{sessionUsage && sessionUsage.tokenLimit > 0 && (
|
||||
<div className="px-1 pt-1.5">
|
||||
<div className="flex items-center gap-2 text-[10px] text-zinc-500">
|
||||
<div className="h-1 flex-1 overflow-hidden rounded-full bg-zinc-800">
|
||||
<div
|
||||
className={`h-full rounded-full transition-all ${
|
||||
sessionUsage.currentTokens / sessionUsage.tokenLimit > 0.9
|
||||
? 'bg-red-500'
|
||||
: sessionUsage.currentTokens / sessionUsage.tokenLimit > 0.7
|
||||
? 'bg-amber-500'
|
||||
: 'bg-indigo-500/60'
|
||||
}`}
|
||||
style={{ width: `${Math.min(100, (sessionUsage.currentTokens / sessionUsage.tokenLimit) * 100)}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="tabular-nums">
|
||||
{Math.round((sessionUsage.currentTokens / sessionUsage.tokenLimit) * 100)}% context
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
RefreshCw,
|
||||
Search,
|
||||
Settings,
|
||||
Trash2,
|
||||
Users,
|
||||
X,
|
||||
type LucideIcon,
|
||||
@@ -45,6 +46,7 @@ interface SidebarProps {
|
||||
onDuplicateSession: (sessionId: string) => void;
|
||||
onSetSessionPinned: (sessionId: string, isPinned: boolean) => void;
|
||||
onSetSessionArchived: (sessionId: string, isArchived: boolean) => void;
|
||||
onDeleteSession: (sessionId: string) => void;
|
||||
onRefreshGitContext: (projectId: string) => void;
|
||||
}
|
||||
|
||||
@@ -128,14 +130,16 @@ function ActionMenuItem({
|
||||
icon: Icon,
|
||||
label,
|
||||
onClick,
|
||||
className,
|
||||
}: {
|
||||
icon: LucideIcon;
|
||||
label: string;
|
||||
onClick: () => void;
|
||||
className?: string;
|
||||
}) {
|
||||
return (
|
||||
<button
|
||||
className="flex w-full items-center gap-2 px-3 py-1.5 text-[12px] text-zinc-300 transition hover:bg-zinc-800"
|
||||
className={`flex w-full items-center gap-2 px-3 py-1.5 text-[12px] transition hover:bg-zinc-800 ${className ?? 'text-zinc-300'}`}
|
||||
onClick={onClick}
|
||||
role="menuitem"
|
||||
type="button"
|
||||
@@ -475,6 +479,7 @@ export function Sidebar({
|
||||
onDuplicateSession,
|
||||
onSetSessionPinned,
|
||||
onSetSessionArchived,
|
||||
onDeleteSession,
|
||||
onRefreshGitContext,
|
||||
}: SidebarProps) {
|
||||
const scratchpadProject = workspace.projects.find((project) => isScratchpadProject(project));
|
||||
@@ -742,6 +747,15 @@ export function Sidebar({
|
||||
closeMenu();
|
||||
}}
|
||||
/>
|
||||
<ActionMenuItem
|
||||
className="text-red-400 hover:bg-red-500/10"
|
||||
icon={Trash2}
|
||||
label="Delete"
|
||||
onClick={() => {
|
||||
onDeleteSession(menuState.sessionId);
|
||||
closeMenu();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -8,8 +8,15 @@ export interface AgentActivityState {
|
||||
toolName?: string;
|
||||
}
|
||||
|
||||
export interface SessionUsageState {
|
||||
tokenLimit: number;
|
||||
currentTokens: number;
|
||||
messagesLength: number;
|
||||
}
|
||||
|
||||
export type SessionActivityState = Record<string, AgentActivityState>;
|
||||
export type SessionActivityMap = Record<string, SessionActivityState | undefined>;
|
||||
export type SessionUsageMap = Record<string, SessionUsageState | undefined>;
|
||||
|
||||
export interface AgentActivityRow {
|
||||
key: string;
|
||||
@@ -184,3 +191,131 @@ function clearActiveSessionActivity(
|
||||
function resolveAgentKey(event: SessionEventRecord): string | undefined {
|
||||
return event.agentId?.trim() || event.agentName?.trim();
|
||||
}
|
||||
|
||||
export function applySessionUsageEvent(
|
||||
current: SessionUsageMap,
|
||||
event: SessionEventRecord,
|
||||
): SessionUsageMap {
|
||||
if (event.kind !== 'session-usage' || event.tokenLimit === undefined || event.currentTokens === undefined) {
|
||||
return current;
|
||||
}
|
||||
|
||||
return {
|
||||
...current,
|
||||
[event.sessionId]: {
|
||||
tokenLimit: event.tokenLimit,
|
||||
currentTokens: event.currentTokens,
|
||||
messagesLength: event.messagesLength ?? 0,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function pruneSessionUsage(
|
||||
current: SessionUsageMap,
|
||||
sessionIds: Iterable<string>,
|
||||
): SessionUsageMap {
|
||||
const allowed = new Set(sessionIds);
|
||||
const next: SessionUsageMap = {};
|
||||
let changed = false;
|
||||
|
||||
for (const [sessionId, usage] of Object.entries(current)) {
|
||||
if (!allowed.has(sessionId)) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
next[sessionId] = usage;
|
||||
}
|
||||
|
||||
return changed || Object.keys(next).length !== Object.keys(current).length ? next : current;
|
||||
}
|
||||
|
||||
/* ── Turn-scoped event log ──────────────────────────────── */
|
||||
|
||||
const TURN_EVENT_LOG_LIMIT = 50;
|
||||
|
||||
export interface TurnEventEntry {
|
||||
kind: SessionEventRecord['kind'];
|
||||
occurredAt: string;
|
||||
label: string;
|
||||
detail?: string;
|
||||
phase?: 'start' | 'end' | 'complete';
|
||||
success?: boolean;
|
||||
}
|
||||
|
||||
export type TurnEventLog = readonly TurnEventEntry[];
|
||||
export type TurnEventLogMap = Record<string, TurnEventLog | undefined>;
|
||||
|
||||
function formatTurnEventEntry(event: SessionEventRecord): TurnEventEntry | undefined {
|
||||
switch (event.kind) {
|
||||
case 'subagent':
|
||||
return {
|
||||
kind: event.kind,
|
||||
occurredAt: event.occurredAt,
|
||||
label: `Sub-agent ${event.subagentEventKind ?? 'update'}: ${event.customAgentDisplayName ?? event.customAgentName ?? 'unknown'}`,
|
||||
detail: event.agentName ? `from ${event.agentName}` : undefined,
|
||||
phase: event.subagentEventKind === 'started' ? 'start' : event.subagentEventKind === 'completed' ? 'end' : undefined,
|
||||
success: event.subagentEventKind === 'completed' ? true : event.subagentEventKind === 'failed' ? false : undefined,
|
||||
};
|
||||
case 'hook-lifecycle':
|
||||
return {
|
||||
kind: event.kind,
|
||||
occurredAt: event.occurredAt,
|
||||
label: `Hook ${event.hookType ?? 'unknown'}`,
|
||||
detail: event.hookInvocationId,
|
||||
phase: event.hookPhase,
|
||||
success: event.hookSuccess,
|
||||
};
|
||||
case 'skill-invoked':
|
||||
return {
|
||||
kind: event.kind,
|
||||
occurredAt: event.occurredAt,
|
||||
label: `Skill: ${event.skillName ?? 'unknown'}`,
|
||||
detail: event.pluginName ? `via ${event.pluginName}` : undefined,
|
||||
};
|
||||
case 'session-compaction':
|
||||
return {
|
||||
kind: event.kind,
|
||||
occurredAt: event.occurredAt,
|
||||
label: event.compactionPhase === 'start' ? 'Context compaction started' : 'Context compaction complete',
|
||||
detail: event.compactionPhase === 'complete' && event.tokensRemoved
|
||||
? `${event.tokensRemoved.toLocaleString()} tokens freed`
|
||||
: undefined,
|
||||
phase: event.compactionPhase,
|
||||
success: event.compactionSuccess,
|
||||
};
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
export function applyTurnEventLog(
|
||||
current: TurnEventLogMap,
|
||||
event: SessionEventRecord,
|
||||
): TurnEventLogMap {
|
||||
const entry = formatTurnEventEntry(event);
|
||||
if (!entry) return current;
|
||||
|
||||
const existing = current[event.sessionId] ?? [];
|
||||
const next = [...existing, entry].slice(-TURN_EVENT_LOG_LIMIT);
|
||||
|
||||
return { ...current, [event.sessionId]: next };
|
||||
}
|
||||
|
||||
export function pruneTurnEventLogs(
|
||||
current: TurnEventLogMap,
|
||||
sessionIds: Iterable<string>,
|
||||
): TurnEventLogMap {
|
||||
const allowed = new Set(sessionIds);
|
||||
const next: TurnEventLogMap = {};
|
||||
let changed = false;
|
||||
|
||||
for (const [sessionId, log] of Object.entries(current)) {
|
||||
if (!allowed.has(sessionId)) {
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
next[sessionId] = log;
|
||||
}
|
||||
|
||||
return changed || Object.keys(next).length !== Object.keys(current).length ? next : current;
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ export const ipcChannels = {
|
||||
renameSession: 'sessions:rename',
|
||||
setSessionPinned: 'sessions:set-pinned',
|
||||
setSessionArchived: 'sessions:set-archived',
|
||||
deleteSession: 'sessions:delete',
|
||||
sendSessionMessage: 'sessions:send-message',
|
||||
cancelSessionTurn: 'sessions:cancel-turn',
|
||||
resolveSessionApproval: 'sessions:resolve-approval',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ApprovalDecision } from '@shared/domain/approval';
|
||||
import type { SidecarCapabilities, InteractionMode } from '@shared/contracts/sidecar';
|
||||
import type { SidecarCapabilities, InteractionMode, MessageMode } from '@shared/contracts/sidecar';
|
||||
import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
|
||||
import type { ProjectRecord } from '@shared/domain/project';
|
||||
import type { QuerySessionsInput, SessionQueryResult } from '@shared/domain/sessionLibrary';
|
||||
@@ -11,6 +11,7 @@ import type {
|
||||
AppearanceTheme,
|
||||
} from '@shared/domain/tooling';
|
||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||
import type { ChatMessageAttachment } from '@shared/domain/attachment';
|
||||
|
||||
export interface CreateSessionInput {
|
||||
projectId: string;
|
||||
@@ -24,6 +25,8 @@ export interface SavePatternInput {
|
||||
export interface SendSessionMessageInput {
|
||||
sessionId: string;
|
||||
content: string;
|
||||
attachments?: ChatMessageAttachment[];
|
||||
messageMode?: MessageMode;
|
||||
}
|
||||
|
||||
export interface CancelSessionTurnInput {
|
||||
@@ -125,6 +128,10 @@ export interface StartSessionMcpAuthInput {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface DeleteSessionInput {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface ElectronApi {
|
||||
describeSidecarCapabilities(): Promise<SidecarCapabilities>;
|
||||
refreshSidecarCapabilities(): Promise<SidecarCapabilities>;
|
||||
@@ -148,6 +155,7 @@ export interface ElectronApi {
|
||||
renameSession(input: RenameSessionInput): Promise<WorkspaceState>;
|
||||
setSessionPinned(input: SetSessionPinnedInput): Promise<WorkspaceState>;
|
||||
setSessionArchived(input: SetSessionArchivedInput): Promise<WorkspaceState>;
|
||||
deleteSession(input: DeleteSessionInput): Promise<WorkspaceState>;
|
||||
sendSessionMessage(input: SendSessionMessageInput): Promise<void>;
|
||||
cancelSessionTurn(input: CancelSessionTurnInput): Promise<void>;
|
||||
resolveSessionApproval(input: ResolveSessionApprovalInput): Promise<WorkspaceState>;
|
||||
|
||||
@@ -2,6 +2,7 @@ import type { PatternDefinition, PatternValidationIssue, ReasoningEffort } from
|
||||
import type { ApprovalCheckpointKind, ApprovalDecision } from '@shared/domain/approval';
|
||||
import type { ChatMessageRecord } from '@shared/domain/session';
|
||||
import type { RuntimeToolDefinition } from '@shared/domain/tooling';
|
||||
import type { ChatMessageAttachment } from '@shared/domain/attachment';
|
||||
|
||||
export interface SidecarModeCapability {
|
||||
available: boolean;
|
||||
@@ -69,6 +70,7 @@ export interface ValidatePatternCommand {
|
||||
}
|
||||
|
||||
export type InteractionMode = 'interactive' | 'plan';
|
||||
export type MessageMode = 'enqueue' | 'immediate';
|
||||
|
||||
export interface RunTurnCommand {
|
||||
type: 'run-turn';
|
||||
@@ -77,8 +79,10 @@ export interface RunTurnCommand {
|
||||
projectPath: string;
|
||||
workspaceKind?: 'project' | 'scratchpad';
|
||||
mode?: InteractionMode;
|
||||
messageMode?: MessageMode;
|
||||
pattern: PatternDefinition;
|
||||
messages: ChatMessageRecord[];
|
||||
attachments?: ChatMessageAttachment[];
|
||||
tooling?: RunTurnToolingConfig;
|
||||
}
|
||||
|
||||
@@ -104,13 +108,42 @@ export interface ResolveUserInputCommand {
|
||||
wasFreeform: boolean;
|
||||
}
|
||||
|
||||
export interface ListSessionsCommand {
|
||||
type: 'list-sessions';
|
||||
requestId: string;
|
||||
filter?: CopilotSessionListFilter;
|
||||
}
|
||||
|
||||
export interface DeleteSessionCommand {
|
||||
type: 'delete-session';
|
||||
requestId: string;
|
||||
sessionId?: string;
|
||||
copilotSessionId?: string;
|
||||
}
|
||||
|
||||
export interface DisconnectSessionCommand {
|
||||
type: 'disconnect-session';
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface CopilotSessionListFilter {
|
||||
cwd?: string;
|
||||
gitRoot?: string;
|
||||
repository?: string;
|
||||
branch?: string;
|
||||
}
|
||||
|
||||
export type SidecarCommand =
|
||||
| DescribeCapabilitiesCommand
|
||||
| ValidatePatternCommand
|
||||
| RunTurnCommand
|
||||
| CancelTurnCommand
|
||||
| ResolveApprovalCommand
|
||||
| ResolveUserInputCommand;
|
||||
| ResolveUserInputCommand
|
||||
| ListSessionsCommand
|
||||
| DeleteSessionCommand
|
||||
| DisconnectSessionCommand;
|
||||
|
||||
export interface RunTurnLocalMcpServerConfig {
|
||||
id: string;
|
||||
@@ -150,6 +183,30 @@ export interface RunTurnToolingConfig {
|
||||
lspProfiles: RunTurnLspProfileConfig[];
|
||||
}
|
||||
|
||||
export interface RunTurnCustomAgentConfig {
|
||||
name: string;
|
||||
displayName?: string;
|
||||
description?: string;
|
||||
tools?: string[];
|
||||
prompt: string;
|
||||
mcpServers?: RunTurnMcpServerConfig[];
|
||||
infer?: boolean;
|
||||
}
|
||||
|
||||
export interface RunTurnInfiniteSessionsConfig {
|
||||
enabled?: boolean;
|
||||
backgroundCompactionThreshold?: number;
|
||||
bufferExhaustionThreshold?: number;
|
||||
}
|
||||
|
||||
export interface PatternAgentCopilotConfig {
|
||||
customAgents?: RunTurnCustomAgentConfig[];
|
||||
agent?: string;
|
||||
skillDirectories?: string[];
|
||||
disabledSkills?: string[];
|
||||
infiniteSessions?: RunTurnInfiniteSessionsConfig;
|
||||
}
|
||||
|
||||
export interface CapabilitiesEvent {
|
||||
type: 'capabilities';
|
||||
requestId: string;
|
||||
@@ -194,6 +251,137 @@ export interface AgentActivityEvent {
|
||||
toolName?: string;
|
||||
}
|
||||
|
||||
export type SubagentEventKind = 'started' | 'completed' | 'failed' | 'selected' | 'deselected';
|
||||
|
||||
export interface SubagentEvent {
|
||||
type: 'subagent-event';
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
eventKind: SubagentEventKind;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
toolCallId?: string;
|
||||
customAgentName?: string;
|
||||
customAgentDisplayName?: string;
|
||||
customAgentDescription?: string;
|
||||
error?: string;
|
||||
model?: string;
|
||||
totalToolCalls?: number;
|
||||
totalTokens?: number;
|
||||
durationMs?: number;
|
||||
tools?: string[];
|
||||
}
|
||||
|
||||
export interface SkillInvokedEvent {
|
||||
type: 'skill-invoked';
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
skillName: string;
|
||||
path: string;
|
||||
content: string;
|
||||
allowedTools?: string[];
|
||||
pluginName?: string;
|
||||
pluginVersion?: string;
|
||||
description?: string;
|
||||
}
|
||||
|
||||
export interface HookLifecycleEvent {
|
||||
type: 'hook-lifecycle';
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
hookInvocationId: string;
|
||||
hookType: string;
|
||||
phase: 'start' | 'end';
|
||||
success?: boolean;
|
||||
input?: unknown;
|
||||
output?: unknown;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface SessionUsageEvent {
|
||||
type: 'session-usage';
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
tokenLimit: number;
|
||||
currentTokens: number;
|
||||
messagesLength: number;
|
||||
systemTokens?: number;
|
||||
conversationTokens?: number;
|
||||
toolDefinitionsTokens?: number;
|
||||
isInitial?: boolean;
|
||||
}
|
||||
|
||||
export interface SessionCompactionEvent {
|
||||
type: 'session-compaction';
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
phase: 'start' | 'complete';
|
||||
success?: boolean;
|
||||
error?: string;
|
||||
systemTokens?: number;
|
||||
conversationTokens?: number;
|
||||
toolDefinitionsTokens?: number;
|
||||
preCompactionTokens?: number;
|
||||
postCompactionTokens?: number;
|
||||
preCompactionMessagesLength?: number;
|
||||
messagesRemoved?: number;
|
||||
tokensRemoved?: number;
|
||||
summaryContent?: string;
|
||||
checkpointNumber?: number;
|
||||
checkpointPath?: string;
|
||||
}
|
||||
|
||||
export interface PendingMessagesModifiedEvent {
|
||||
type: 'pending-messages-modified';
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
}
|
||||
|
||||
export interface CopilotSessionInfo {
|
||||
copilotSessionId: string;
|
||||
managedByAryx: boolean;
|
||||
sessionId?: string;
|
||||
agentId?: string;
|
||||
startTime: string;
|
||||
modifiedTime: string;
|
||||
summary?: string;
|
||||
isRemote: boolean;
|
||||
cwd?: string;
|
||||
gitRoot?: string;
|
||||
repository?: string;
|
||||
branch?: string;
|
||||
}
|
||||
|
||||
export interface SessionsListedEvent {
|
||||
type: 'sessions-listed';
|
||||
requestId: string;
|
||||
sessions: CopilotSessionInfo[];
|
||||
}
|
||||
|
||||
export interface SessionsDeletedEvent {
|
||||
type: 'sessions-deleted';
|
||||
requestId: string;
|
||||
sessionId?: string;
|
||||
sessions: CopilotSessionInfo[];
|
||||
}
|
||||
|
||||
export interface SessionDisconnectedEvent {
|
||||
type: 'session-disconnected';
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
cancelledRequestIds: string[];
|
||||
}
|
||||
|
||||
export interface PermissionDetail {
|
||||
kind: string;
|
||||
intention?: string;
|
||||
@@ -292,9 +480,18 @@ export type SidecarEvent =
|
||||
| TurnDeltaEvent
|
||||
| TurnCompleteEvent
|
||||
| AgentActivityEvent
|
||||
| SubagentEvent
|
||||
| SkillInvokedEvent
|
||||
| HookLifecycleEvent
|
||||
| SessionUsageEvent
|
||||
| SessionCompactionEvent
|
||||
| PendingMessagesModifiedEvent
|
||||
| ApprovalRequestedEvent
|
||||
| UserInputRequestedEvent
|
||||
| McpOauthRequiredEvent
|
||||
| ExitPlanModeRequestedEvent
|
||||
| SessionsListedEvent
|
||||
| SessionsDeletedEvent
|
||||
| SessionDisconnectedEvent
|
||||
| CommandErrorEvent
|
||||
| CommandCompleteEvent;
|
||||
|
||||
@@ -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';
|
||||
}
|
||||
@@ -8,7 +8,15 @@ export type SessionEventKind =
|
||||
| 'message-complete'
|
||||
| 'agent-activity'
|
||||
| 'run-updated'
|
||||
| 'error';
|
||||
| 'error'
|
||||
| 'subagent'
|
||||
| 'skill-invoked'
|
||||
| 'hook-lifecycle'
|
||||
| 'session-usage'
|
||||
| 'session-compaction'
|
||||
| 'pending-messages-modified';
|
||||
|
||||
export type SubagentEventKind = 'started' | 'completed' | 'failed' | 'selected' | 'deselected';
|
||||
|
||||
export interface SessionEventRecord {
|
||||
sessionId: string;
|
||||
@@ -27,4 +35,32 @@ export interface SessionEventRecord {
|
||||
toolName?: string;
|
||||
run?: SessionRunRecord;
|
||||
error?: string;
|
||||
|
||||
// Subagent event fields
|
||||
subagentEventKind?: SubagentEventKind;
|
||||
customAgentName?: string;
|
||||
customAgentDisplayName?: string;
|
||||
|
||||
// Skill invoked fields
|
||||
skillName?: string;
|
||||
skillPath?: string;
|
||||
pluginName?: string;
|
||||
|
||||
// Hook lifecycle fields
|
||||
hookInvocationId?: string;
|
||||
hookType?: string;
|
||||
hookPhase?: 'start' | 'end';
|
||||
hookSuccess?: boolean;
|
||||
|
||||
// Session usage fields
|
||||
tokenLimit?: number;
|
||||
currentTokens?: number;
|
||||
messagesLength?: number;
|
||||
|
||||
// Session compaction fields
|
||||
compactionPhase?: 'start' | 'complete';
|
||||
compactionSuccess?: boolean;
|
||||
preCompactionTokens?: number;
|
||||
postCompactionTokens?: number;
|
||||
tokensRemoved?: number;
|
||||
}
|
||||
|
||||
@@ -32,6 +32,8 @@ export const reasoningEffortOptions: ReadonlyArray<{ value: ReasoningEffort; lab
|
||||
{ value: 'xhigh', label: 'Maximum' },
|
||||
];
|
||||
|
||||
import type { PatternAgentCopilotConfig } from '@shared/contracts/sidecar';
|
||||
|
||||
export interface PatternAgentDefinition {
|
||||
id: string;
|
||||
name: string;
|
||||
@@ -39,6 +41,7 @@ export interface PatternAgentDefinition {
|
||||
instructions: string;
|
||||
model: string;
|
||||
reasoningEffort?: ReasoningEffort;
|
||||
copilot?: PatternAgentCopilotConfig;
|
||||
}
|
||||
|
||||
export interface PatternGraphPosition {
|
||||
|
||||
@@ -14,6 +14,7 @@ import type { SessionRunRecord } from '@shared/domain/runTimeline';
|
||||
import type { PendingUserInputRecord } from '@shared/domain/userInput';
|
||||
import type { PendingPlanReviewRecord } from '@shared/domain/planReview';
|
||||
import type { PendingMcpAuthRecord } from '@shared/domain/mcpAuth';
|
||||
import type { ChatMessageAttachment } from '@shared/domain/attachment';
|
||||
import type { InteractionMode } from '@shared/contracts/sidecar';
|
||||
|
||||
export type ChatRole = 'system' | 'user' | 'assistant';
|
||||
@@ -32,6 +33,7 @@ export interface ChatMessageRecord {
|
||||
content: string;
|
||||
createdAt: string;
|
||||
pending?: boolean;
|
||||
attachments?: ChatMessageAttachment[];
|
||||
}
|
||||
|
||||
export interface SessionRecord {
|
||||
|
||||
@@ -19,6 +19,7 @@ describe('run turn pending helpers', () => {
|
||||
onUserInput: () => undefined,
|
||||
onExitPlanMode: () => undefined,
|
||||
onMcpOAuthRequired: () => undefined,
|
||||
onTurnScopedEvent: () => undefined,
|
||||
errored: false,
|
||||
};
|
||||
|
||||
@@ -44,6 +45,7 @@ describe('run turn pending helpers', () => {
|
||||
onUserInput: () => undefined,
|
||||
onExitPlanMode: () => undefined,
|
||||
onMcpOAuthRequired: () => undefined,
|
||||
onTurnScopedEvent: () => undefined,
|
||||
errored: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -141,7 +141,7 @@ import Base from '../layouts/Base.astro';
|
||||
</div>
|
||||
<h3 class="text-base font-semibold">Live Visibility</h3>
|
||||
<p class="mt-2 text-sm leading-relaxed text-zinc-400">
|
||||
Watch each agent think, use tools, and hand off work in real time. Know exactly what's happening during complex runs.
|
||||
Watch each agent think, delegate to sub-agents, invoke skills, and run hooks in real time. A context-usage bar shows how much of the model's window you've used.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -154,11 +154,37 @@ import Base from '../layouts/Base.astro';
|
||||
</div>
|
||||
<h3 class="text-base font-semibold">Persistent Sessions</h3>
|
||||
<p class="mt-2 text-sm leading-relaxed text-zinc-400">
|
||||
Rename, pin, archive, and return to sessions later. Your work persists instead of disappearing after each conversation.
|
||||
Rename, pin, archive, delete, and return to sessions later. Your work persists instead of disappearing after each conversation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature 6: Model Selection -->
|
||||
<!-- Feature 6: Mid-turn Steering -->
|
||||
<div class="group rounded-2xl border border-zinc-800 bg-zinc-900/40 p-6 transition hover:border-zinc-700 hover:bg-zinc-900/60">
|
||||
<div class="mb-4 flex size-10 items-center justify-center rounded-xl bg-yellow-500/10 text-yellow-400">
|
||||
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-base font-semibold">Steer While It Works</h3>
|
||||
<p class="mt-2 text-sm leading-relaxed text-zinc-400">
|
||||
Send follow-up messages while an agent is running. Your input is injected immediately so you can redirect without waiting.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature 7: Image Attachments -->
|
||||
<div class="group rounded-2xl border border-zinc-800 bg-zinc-900/40 p-6 transition hover:border-zinc-700 hover:bg-zinc-900/60">
|
||||
<div class="mb-4 flex size-10 items-center justify-center rounded-xl bg-pink-500/10 text-pink-400">
|
||||
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M4 16l4.586-4.586a2 2 0 012.828 0L16 16m-2-2l1.586-1.586a2 2 0 012.828 0L20 14m-6-6h.01M6 20h12a2 2 0 002-2V6a2 2 0 00-2-2H6a2 2 0 00-2 2v12a2 2 0 002 2z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-base font-semibold">Image Input</h3>
|
||||
<p class="mt-2 text-sm leading-relaxed text-zinc-400">
|
||||
Attach screenshots, diagrams, or photos to any message. The model sees the image alongside your text for visual reasoning.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature 8: Model Selection -->
|
||||
<div class="group rounded-2xl border border-zinc-800 bg-zinc-900/40 p-6 transition hover:border-zinc-700 hover:bg-zinc-900/60">
|
||||
<div class="mb-4 flex size-10 items-center justify-center rounded-xl bg-rose-500/10 text-rose-400">
|
||||
<svg class="size-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
|
||||
Reference in New Issue
Block a user