mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-23 21:18:40 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6321f9192d | ||
|
|
147b437e36 | ||
|
|
fa5774cbc0 | ||
|
|
ae56d55c85 | ||
|
|
1fbfdbbac4 | ||
|
|
db9ffe8399 | ||
|
|
dff97efd58 | ||
|
|
f459cc7291 | ||
|
|
e13e3b818a | ||
|
|
f1fa52f9c3 | ||
|
|
0c2973c599 | ||
|
|
c1dab96bfd | ||
|
|
bb7e5d4108 | ||
|
|
b5ab92e444 | ||
|
|
689b335220 | ||
|
|
f04e3b9dcc | ||
|
|
3ec69d990b | ||
|
|
154787c336 | ||
|
|
9d65e3d209 | ||
|
|
7247a68f24 | ||
|
|
85a9327f65 | ||
|
|
2a952dfebe | ||
|
|
cf41279ff5 | ||
|
|
c02589f4c0 | ||
|
|
73595039fc | ||
|
|
81bddcbd63 | ||
|
|
53f1167681 | ||
|
|
4f7b479996 | ||
|
|
0fd7a04a51 | ||
|
|
6c6b49fde4 | ||
|
|
d73eaae30b | ||
|
|
1868a79d9a | ||
|
|
4f1ae86021 | ||
|
|
f0c2b4982b | ||
|
|
ac48aa58e0 | ||
|
|
f9757d5ce2 | ||
|
|
192c28f721 | ||
|
|
b670680a7d | ||
|
|
231be36e6c | ||
|
|
380e402512 | ||
|
|
c069b86add | ||
|
|
912677fba0 | ||
|
|
a1a044e7ca | ||
|
|
f15b1aedb1 | ||
|
|
2ae4bd01b4 | ||
|
|
4c01d8b1a7 | ||
|
|
13c543e2fd | ||
|
|
69562c19f3 |
@@ -9,3 +9,4 @@ sidecar/**/obj/
|
||||
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
plan/
|
||||
@@ -42,6 +42,7 @@ These instructions apply to any automated or semi-automated agent working in thi
|
||||
- Treat tests as part of the implementation, not as follow-up work. Every fix, behavior change, and new feature must be covered by tests, and the relevant test suite must pass before the work is considered complete.
|
||||
- Always check whether `README.md` needs an update before handing work off. If the change affects user-facing behavior, workflows, prerequisites, installation, packaging, or product positioning, update the README in the same change.
|
||||
- Always check whether `ARCHITECTURE.md` needs an update before handing work off. If the change affects runtime boundaries, data flow, persistence, IPC, orchestration, tooling integration, packaging, or other material technical design, update `ARCHITECTURE.md` in the same change.
|
||||
- Always check whether the product website (`website/`) needs an update before handing work off. If the change introduces a major new feature, or a minor feature that is particularly interesting or noteworthy to end users, update the relevant website content in the same change. The website is a standalone Astro app under `website/` and validates with `bun run build` from that directory.
|
||||
- Do not ship quick fixes, hacks, or "temporary" patches as final solutions. Take the time to understand the problem, plan the change, and implement a maintainable solution that fits the codebase cleanly.
|
||||
- Remove code that is no longer necessary before handing work off. If an experiment, workaround, hotfix, helper, or test path does not end up being part of the final correct solution, delete it rather than leaving dead or misleading code behind.
|
||||
- Apply the same quality bar to feature work. Think through scope, edge cases, integration points, and long-term maintainability before implementing.
|
||||
@@ -153,13 +154,17 @@ 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
|
||||
|
||||
- If a task spans both backend and frontend work, the implementation plan must be split into **Part 1 — Backend** and **Part 2 — Frontend**. The Frontend part will be launched manually by the user.
|
||||
- Backend work must be planned and executed first.
|
||||
- Before frontend work begins, backend work must produce a handover artifact in the session workspace `files\` directory. Do not put this handover document in the repository.
|
||||
> **Hard rule — no exceptions.** Every task that touches both backend (C# / sidecar) and frontend (TypeScript / renderer) code **must** produce a plan with exactly two phases: **Part 1 — Backend** and **Part 2 — Frontend**. A single combined plan that mixes backend and frontend work is never acceptable, even if the changes seem small or tightly coupled. When in doubt about whether a task spans both surfaces, treat it as spanning both and split the plan.
|
||||
|
||||
- **Part 1 — Backend** is always planned, implemented, tested, and committed first. No frontend work may begin until Part 1 is complete.
|
||||
- **Part 2 — Frontend** is launched manually by the user in a separate session. Do not start frontend implementation in the same session as backend work.
|
||||
- Before frontend work begins, backend work must produce a handover artifact in the session workspace `files\` directory. Do not put this handover document in the repository. The handover must describe every new or changed contract (DTOs, IPC messages, events, API shapes) that the frontend needs to consume.
|
||||
- The frontend phase must consume that backend handover artifact and build on it rather than rediscovering backend contracts from scratch.
|
||||
- If a task is frontend-only or backend-only, a two-part split is not required — but you must still confirm the scope before planning.
|
||||
|
||||
## 9. Validation checklist
|
||||
|
||||
|
||||
+27
-3
@@ -33,7 +33,7 @@ flowchart LR
|
||||
Renderer[Renderer UI<br/>React + Tailwind]
|
||||
Preload[Preload bridge]
|
||||
Main[Electron main process]
|
||||
Workspace[Workspace storage<br/>JSON + scratchpad files]
|
||||
Workspace[Workspace storage<br/>workspace.json + per-session scratchpad directories]
|
||||
Git[Local git repositories]
|
||||
Sidecar[.NET sidecar]
|
||||
Copilot[GitHub Copilot CLI<br/>+ agent runtime]
|
||||
@@ -122,7 +122,7 @@ Projects are the container for context. There are two kinds:
|
||||
- a special **scratchpad** project for lightweight work
|
||||
- normal **project-backed** entries pointing at local folders
|
||||
|
||||
The scratchpad is modeled inside the same workspace system instead of as a separate subsystem. That keeps the UI and session model consistent while still allowing special rules for scratchpad behavior.
|
||||
The scratchpad is modeled inside the same workspace system instead of as a separate subsystem. That keeps the UI and session model consistent while still allowing special rules for scratchpad behavior. Each scratchpad session receives its own working directory under the shared scratchpad root, so session-created files stay isolated from other scratchpad conversations.
|
||||
|
||||
### Patterns
|
||||
|
||||
@@ -136,6 +136,8 @@ Patterns describe how agents collaborate. The architecture supports:
|
||||
|
||||
Their runtime semantics follow the Agent Framework orchestration model: sequential and group chat preserve a visible shared conversation, concurrent aggregates multiple independent responses into one turn, and handoff turns can end once the active agent has responded and is waiting for the next user input.
|
||||
|
||||
For Copilot-backed agents, Aryx uses a repo-local adapter around the Copilot SDK session layer so handoff routes still behave like Agent Framework handoffs. This is necessary because the upstream `GitHubCopilotAgent` does not currently project run-time handoff tool declarations into Copilot sessions or surface Copilot tool requests back as `FunctionCallContent` for the workflow runtime.
|
||||
|
||||
Patterns are shared application data, not renderer-only configuration. That means the same pattern definition can drive validation, persistence, UI rendering, and sidecar execution.
|
||||
|
||||
Patterns now persist an explicit graph-backed topology alongside the flat agent list. Agent nodes carry stable agent ids, ordering, and layout metadata, while system nodes such as user input/output, distributor, collector, and orchestrator make mode-specific flow visible in the saved contract.
|
||||
@@ -200,6 +202,19 @@ 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 configured project hook commands discovered from `.github/hooks/*.json`; Aryx suppresses the SDK's built-in no-op hook chatter so the UI only sees meaningful hook activity
|
||||
- **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.
|
||||
|
||||
For project-backed sessions, the sidecar also discovers GitHub Copilot CLI hook definitions from `.github/hooks/*.json` under the repository root. Those files are parsed and merged once per run bundle, then projected onto the SDK session hook delegates. Hook commands run synchronously in the sidecar through the platform shell, with stdin JSON payloads shaped to match Copilot CLI hook expectations as closely as the SDK allows. Hook failures are logged to stderr and treated as non-fatal diagnostics, while `preToolUse` hook outputs can still deny a tool call before Aryx falls back to its built-in approval policy.
|
||||
|
||||
## Security model
|
||||
|
||||
Security in this system is mostly about **desktop trust boundaries**.
|
||||
@@ -263,7 +278,7 @@ This lets the application treat tooling as reusable workspace capability while s
|
||||
|
||||
### Project awareness
|
||||
|
||||
Project-backed sessions can carry repository context such as branch and dirty state, while scratchpad sessions omit git context but still support MCP, LSP, and runtime tooling. Both session kinds share the same tooling selection and approval model. This keeps the architecture grounded in real codebases without forcing every conversation to be project-heavy, while still letting scratchpad sessions leverage configured tools when useful.
|
||||
Project-backed sessions can carry repository context such as branch and dirty state, while scratchpad sessions omit git context but still support MCP, LSP, and runtime tooling. Scratchpad execution uses a per-session working directory instead of a single shared scratchpad folder, so file-based context and generated artifacts stay scoped to the active scratchpad session. Both session kinds share the same tooling selection and approval model. This keeps the architecture grounded in real codebases without forcing every conversation to be project-heavy, while still letting scratchpad sessions leverage configured tools when useful.
|
||||
|
||||
### Execution observability
|
||||
|
||||
@@ -271,11 +286,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
|
||||
@@ -26,6 +28,7 @@ It works especially well when you want AI help that stays grounded in an actual
|
||||
### Ask quick questions in a scratchpad
|
||||
|
||||
If you just want to think through an idea, draft something, or ask for help without connecting a project, start a scratchpad session and begin chatting.
|
||||
Each scratchpad session keeps its own isolated working directory, so files created in one scratchpad do not leak into another.
|
||||
|
||||
### Connect a real project
|
||||
|
||||
@@ -49,13 +52,23 @@ This keeps machine-wide tooling reusable while still letting each session decide
|
||||
|
||||
Patterns now require tool-call approval by default. They can also store default auto-approval for known MCP and LSP tools, and each session can override those auto-approval defaults from the Activity panel before a run starts.
|
||||
|
||||
Project-backed sessions also honor GitHub Copilot CLI-style hook files from `.github/hooks/*.json`. Aryx discovers them automatically in the connected repository and runs the supported lifecycle hooks inside the sidecar, with `preToolUse` deny decisions applied before Aryx's own approval policy.
|
||||
|
||||
### 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
|
||||
|
||||
|
||||
@@ -1,26 +1,27 @@
|
||||
; Inno Setup script for Aryx
|
||||
; Metadata values are passed via /D preprocessor defines from the build script.
|
||||
; Dynamic values are read from environment variables set by the build script.
|
||||
|
||||
#ifndef PRODUCT_NAME
|
||||
#define PRODUCT_NAME "Aryx"
|
||||
#endif
|
||||
#ifndef PRODUCT_VERSION
|
||||
#define PRODUCT_VERSION "0.0.0"
|
||||
#endif
|
||||
#ifndef PRODUCT_PUBLISHER
|
||||
#define PRODUCT_PUBLISHER "David Kaya"
|
||||
#endif
|
||||
#ifndef SOURCE_DIR
|
||||
#error "SOURCE_DIR must be defined (path to the packaged app directory)."
|
||||
#endif
|
||||
#ifndef OUTPUT_DIR
|
||||
#error "OUTPUT_DIR must be defined (path to the output directory)."
|
||||
#endif
|
||||
#ifndef OUTPUT_FILENAME
|
||||
#error "OUTPUT_FILENAME must be defined (output installer filename without extension)."
|
||||
#endif
|
||||
#define PRODUCT_NAME "Aryx"
|
||||
#define PRODUCT_PUBLISHER "David Kaya"
|
||||
#define PRODUCT_VERSION GetEnv("ARYX_BUILD_VERSION")
|
||||
#define SOURCE_DIR GetEnv("ARYX_BUILD_SOURCE_DIR")
|
||||
#define OUTPUT_DIR GetEnv("ARYX_BUILD_OUTPUT_DIR")
|
||||
#define OUTPUT_FILENAME GetEnv("ARYX_BUILD_OUTPUT_FILENAME")
|
||||
#define ICON_PATH GetEnv("ARYX_BUILD_ICON_PATH")
|
||||
|
||||
#ifndef ICON_PATH
|
||||
#if PRODUCT_VERSION == ""
|
||||
#error "ARYX_BUILD_VERSION environment variable must be set."
|
||||
#endif
|
||||
#if SOURCE_DIR == ""
|
||||
#error "ARYX_BUILD_SOURCE_DIR environment variable must be set."
|
||||
#endif
|
||||
#if OUTPUT_DIR == ""
|
||||
#error "ARYX_BUILD_OUTPUT_DIR environment variable must be set."
|
||||
#endif
|
||||
#if OUTPUT_FILENAME == ""
|
||||
#error "ARYX_BUILD_OUTPUT_FILENAME environment variable must be set."
|
||||
#endif
|
||||
#if ICON_PATH == ""
|
||||
#define ICON_PATH SOURCE_DIR + "\" + PRODUCT_NAME + ".exe"
|
||||
#endif
|
||||
|
||||
|
||||
@@ -86,19 +86,13 @@ async function createWindowsInstaller(version: string): Promise<void> {
|
||||
const outputFilename = releaseTarget.installerAssetName.replace(/\.exe$/, '');
|
||||
const iconPath = join(repositoryRoot, 'assets', 'icons', 'windows', 'icon.ico');
|
||||
|
||||
await runCommand(
|
||||
isccPath,
|
||||
[
|
||||
`/DPRODUCT_NAME=${productName}`,
|
||||
`/DPRODUCT_VERSION=${version}`,
|
||||
`/DSOURCE_DIR=${packagedAppDirectory}`,
|
||||
`/DOUTPUT_DIR=${releaseRootDirectory}`,
|
||||
`/DOUTPUT_FILENAME=${outputFilename}`,
|
||||
`/DICON_PATH=${iconPath}`,
|
||||
issScript,
|
||||
],
|
||||
repositoryRoot,
|
||||
);
|
||||
process.env.ARYX_BUILD_VERSION = version;
|
||||
process.env.ARYX_BUILD_SOURCE_DIR = packagedAppDirectory;
|
||||
process.env.ARYX_BUILD_OUTPUT_DIR = releaseRootDirectory;
|
||||
process.env.ARYX_BUILD_OUTPUT_FILENAME = outputFilename;
|
||||
process.env.ARYX_BUILD_ICON_PATH = iconPath;
|
||||
|
||||
await runCommand(isccPath, [issScript], repositoryRoot);
|
||||
}
|
||||
|
||||
// --- macOS: DMG disk image ---
|
||||
|
||||
@@ -0,0 +1,62 @@
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Aryx.AgentHost.Contracts;
|
||||
|
||||
internal static class HookTypeNames
|
||||
{
|
||||
public const string SessionStart = "sessionStart";
|
||||
public const string SessionEnd = "sessionEnd";
|
||||
public const string UserPromptSubmitted = "userPromptSubmitted";
|
||||
public const string PreToolUse = "preToolUse";
|
||||
public const string PostToolUse = "postToolUse";
|
||||
public const string ErrorOccurred = "errorOccurred";
|
||||
}
|
||||
|
||||
internal sealed class HookConfigFile
|
||||
{
|
||||
public int Version { get; init; }
|
||||
public HookConfigHooks Hooks { get; init; } = new();
|
||||
}
|
||||
|
||||
internal sealed class HookConfigHooks
|
||||
{
|
||||
public IReadOnlyList<HookCommandDefinition>? SessionStart { get; init; }
|
||||
public IReadOnlyList<HookCommandDefinition>? SessionEnd { get; init; }
|
||||
public IReadOnlyList<HookCommandDefinition>? UserPromptSubmitted { get; init; }
|
||||
public IReadOnlyList<HookCommandDefinition>? PreToolUse { get; init; }
|
||||
public IReadOnlyList<HookCommandDefinition>? PostToolUse { get; init; }
|
||||
public IReadOnlyList<HookCommandDefinition>? ErrorOccurred { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class HookCommandDefinition
|
||||
{
|
||||
public string Type { get; init; } = string.Empty;
|
||||
public string? Bash { get; init; }
|
||||
|
||||
[JsonPropertyName("powershell")]
|
||||
public string? PowerShell { get; init; }
|
||||
|
||||
public string? Cwd { get; init; }
|
||||
public IReadOnlyDictionary<string, string>? Env { get; init; }
|
||||
public int? TimeoutSec { get; init; }
|
||||
}
|
||||
|
||||
internal sealed class ResolvedHookSet
|
||||
{
|
||||
public static ResolvedHookSet Empty { get; } = new();
|
||||
|
||||
public IReadOnlyList<HookCommandDefinition> SessionStart { get; init; } = [];
|
||||
public IReadOnlyList<HookCommandDefinition> SessionEnd { get; init; } = [];
|
||||
public IReadOnlyList<HookCommandDefinition> UserPromptSubmitted { get; init; } = [];
|
||||
public IReadOnlyList<HookCommandDefinition> PreToolUse { get; init; } = [];
|
||||
public IReadOnlyList<HookCommandDefinition> PostToolUse { get; init; } = [];
|
||||
public IReadOnlyList<HookCommandDefinition> ErrorOccurred { get; init; } = [];
|
||||
|
||||
public bool IsEmpty =>
|
||||
SessionStart.Count == 0
|
||||
&& SessionEnd.Count == 0
|
||||
&& UserPromptSubmitted.Count == 0
|
||||
&& PreToolUse.Count == 0
|
||||
&& PostToolUse.Count == 0
|
||||
&& ErrorOccurred.Count == 0;
|
||||
}
|
||||
@@ -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
|
||||
@@ -161,6 +181,8 @@ public sealed class RunTurnCommandDto : SidecarCommandEnvelope
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
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; }
|
||||
@@ -175,6 +197,30 @@ public sealed class ResolveApprovalCommandDto : SidecarCommandEnvelope
|
||||
{
|
||||
public string ApprovalId { get; init; } = string.Empty;
|
||||
public string Decision { get; init; } = string.Empty;
|
||||
public bool AlwaysApprove { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ResolveUserInputCommandDto : SidecarCommandEnvelope
|
||||
{
|
||||
public string UserInputId { get; init; } = string.Empty;
|
||||
public string Answer { get; init; } = string.Empty;
|
||||
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
|
||||
@@ -208,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;
|
||||
@@ -251,6 +339,136 @@ 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;
|
||||
public string? Intention { get; init; }
|
||||
public string? Command { get; init; }
|
||||
public string? Warning { get; init; }
|
||||
public IReadOnlyList<string>? PossiblePaths { get; init; }
|
||||
public IReadOnlyList<string>? PossibleUrls { get; init; }
|
||||
public bool? HasWriteFileRedirection { get; init; }
|
||||
public string? FileName { get; init; }
|
||||
public string? Diff { get; init; }
|
||||
public string? NewFileContents { get; init; }
|
||||
public string? Path { get; init; }
|
||||
public string? ServerName { get; init; }
|
||||
public string? ToolTitle { get; init; }
|
||||
public object? Args { get; init; }
|
||||
public bool? ReadOnly { get; init; }
|
||||
public string? Url { get; init; }
|
||||
public string? Subject { get; init; }
|
||||
public string? Fact { get; init; }
|
||||
public string? Citations { get; init; }
|
||||
public string? ToolDescription { get; init; }
|
||||
public string? HookMessage { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ApprovalRequestedEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
@@ -262,6 +480,47 @@ public sealed class ApprovalRequestedEventDto : SidecarEventDto
|
||||
public string? PermissionKind { get; init; }
|
||||
public string Title { get; init; } = string.Empty;
|
||||
public string? Detail { get; init; }
|
||||
public PermissionDetailDto? PermissionDetail { get; init; }
|
||||
}
|
||||
|
||||
public sealed class UserInputRequestedEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string UserInputId { get; init; } = string.Empty;
|
||||
public string? AgentId { get; init; }
|
||||
public string? AgentName { get; init; }
|
||||
public string Question { get; init; } = string.Empty;
|
||||
public IReadOnlyList<string>? Choices { get; init; }
|
||||
public bool? AllowFreeform { get; init; }
|
||||
}
|
||||
|
||||
public sealed class McpOauthStaticClientConfigDto
|
||||
{
|
||||
public string ClientId { get; init; } = string.Empty;
|
||||
public bool? PublicClient { get; init; }
|
||||
}
|
||||
|
||||
public sealed class McpOauthRequiredEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string OauthRequestId { get; init; } = string.Empty;
|
||||
public string? AgentId { get; init; }
|
||||
public string? AgentName { get; init; }
|
||||
public string ServerName { get; init; } = string.Empty;
|
||||
public string ServerUrl { get; init; } = string.Empty;
|
||||
public McpOauthStaticClientConfigDto? StaticClientConfig { get; init; }
|
||||
}
|
||||
|
||||
public sealed class ExitPlanModeRequestedEventDto : SidecarEventDto
|
||||
{
|
||||
public string SessionId { get; init; } = string.Empty;
|
||||
public string ExitPlanId { get; init; } = string.Empty;
|
||||
public string? AgentId { get; init; }
|
||||
public string? AgentName { get; init; }
|
||||
public string Summary { get; init; } = string.Empty;
|
||||
public string PlanContent { get; init; } = string.Empty;
|
||||
public IReadOnlyList<string>? Actions { get; init; }
|
||||
public string? RecommendedAction { get; init; }
|
||||
}
|
||||
|
||||
public sealed class CommandErrorEventDto : SidecarEventDto
|
||||
|
||||
@@ -8,7 +8,8 @@ internal static class AgentInstructionComposer
|
||||
PatternDefinitionDto pattern,
|
||||
PatternAgentDefinitionDto agent,
|
||||
int agentIndex,
|
||||
string workspaceKind = "project")
|
||||
string workspaceKind = "project",
|
||||
string interactionMode = "interactive")
|
||||
{
|
||||
string baseInstructions = agent.Instructions.Trim();
|
||||
string workspaceGuidance = string.Equals(workspaceKind, "scratchpad", StringComparison.OrdinalIgnoreCase)
|
||||
@@ -20,6 +21,14 @@ internal static class AgentInstructionComposer
|
||||
Answer conversationally and focus on the user's question directly.
|
||||
"""
|
||||
: string.Empty;
|
||||
string planModeGuidance = string.Equals(interactionMode, "plan", StringComparison.OrdinalIgnoreCase)
|
||||
? """
|
||||
You are operating in plan mode.
|
||||
Your job in this phase is to analyze the request, identify constraints, and produce a concrete implementation plan instead of carrying out the implementation.
|
||||
Once the plan is ready, call the built-in `exit_plan_mode` tool so the host can present the plan for review.
|
||||
Do not continue into implementation, file edits, builds, or tests after producing the plan unless the user explicitly asks to leave plan mode and proceed.
|
||||
"""
|
||||
: string.Empty;
|
||||
|
||||
if (string.Equals(pattern.Mode, "group-chat", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
@@ -37,12 +46,12 @@ internal static class AgentInstructionComposer
|
||||
Focus on refining the answer already in progress.
|
||||
""";
|
||||
|
||||
return JoinInstructionBlocks(baseInstructions, workspaceGuidance, groupChatGuidance);
|
||||
return JoinInstructionBlocks(baseInstructions, workspaceGuidance, planModeGuidance, groupChatGuidance);
|
||||
}
|
||||
|
||||
if (!string.Equals(pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return JoinInstructionBlocks(baseInstructions, workspaceGuidance);
|
||||
return JoinInstructionBlocks(baseInstructions, workspaceGuidance, planModeGuidance);
|
||||
}
|
||||
|
||||
string runtimeGuidance = agentIndex == 0
|
||||
@@ -60,7 +69,7 @@ internal static class AgentInstructionComposer
|
||||
Do not push the actual work back to triage unless you are blocked or the request is clearly outside your specialty.
|
||||
""";
|
||||
|
||||
return JoinInstructionBlocks(baseInstructions, workspaceGuidance, runtimeGuidance);
|
||||
return JoinInstructionBlocks(baseInstructions, workspaceGuidance, planModeGuidance, runtimeGuidance);
|
||||
}
|
||||
|
||||
private static string JoinInstructionBlocks(params string[] blocks)
|
||||
|
||||
@@ -0,0 +1,637 @@
|
||||
using System.IO;
|
||||
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;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class AryxCopilotAgent : AIAgent, IAsyncDisposable
|
||||
{
|
||||
private const string DefaultName = "GitHub Copilot Agent";
|
||||
private const string DefaultDescription = "An AI agent powered by GitHub Copilot";
|
||||
private const string HandoffToolPrefix = "handoff_to_";
|
||||
private readonly CopilotClient _copilotClient;
|
||||
private readonly string? _id;
|
||||
private readonly string _name;
|
||||
private readonly string _description;
|
||||
private readonly SessionConfig? _sessionConfig;
|
||||
private readonly bool _ownsClient;
|
||||
|
||||
public AryxCopilotAgent(
|
||||
CopilotClient copilotClient,
|
||||
SessionConfig? sessionConfig = null,
|
||||
bool ownsClient = false,
|
||||
string? id = null,
|
||||
string? name = null,
|
||||
string? description = null)
|
||||
{
|
||||
_copilotClient = copilotClient ?? throw new ArgumentNullException(nameof(copilotClient));
|
||||
_sessionConfig = sessionConfig;
|
||||
_ownsClient = ownsClient;
|
||||
_id = id;
|
||||
_name = name ?? DefaultName;
|
||||
_description = description ?? DefaultDescription;
|
||||
}
|
||||
|
||||
protected override ValueTask<AgentSession> CreateSessionCoreAsync(CancellationToken cancellationToken = default)
|
||||
=> new(new AryxCopilotAgentSession());
|
||||
|
||||
protected override ValueTask<JsonElement> SerializeSessionCoreAsync(
|
||||
AgentSession session,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (session is not AryxCopilotAgentSession typedSession)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(AryxCopilotAgentSession)}' can be serialized by this agent.");
|
||||
}
|
||||
|
||||
return new(typedSession.Serialize(jsonSerializerOptions));
|
||||
}
|
||||
|
||||
protected override ValueTask<AgentSession> DeserializeSessionCoreAsync(
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> new(AryxCopilotAgentSession.Deserialize(serializedState, jsonSerializerOptions));
|
||||
|
||||
protected override Task<AgentResponse> RunCoreAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
CancellationToken cancellationToken = default)
|
||||
=> RunCoreStreamingAsync(messages, session, options, cancellationToken).ToAgentResponseAsync(cancellationToken);
|
||||
|
||||
protected override async IAsyncEnumerable<AgentResponseUpdate> RunCoreStreamingAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
AgentSession? session = null,
|
||||
AgentRunOptions? options = null,
|
||||
[EnumeratorCancellation] CancellationToken cancellationToken = default)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(messages);
|
||||
|
||||
session ??= await CreateSessionAsync(cancellationToken).ConfigureAwait(false);
|
||||
if (session is not AryxCopilotAgentSession typedSession)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
$"The provided session type '{session.GetType().Name}' is not compatible with this agent. Only sessions of type '{nameof(AryxCopilotAgentSession)}' can be used by this agent.");
|
||||
}
|
||||
|
||||
await EnsureClientStartedAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
SessionConfig sessionConfig = CreateConfiguredSessionConfig(_sessionConfig, options);
|
||||
CopilotSession copilotSession;
|
||||
if (typedSession.SessionId is not null)
|
||||
{
|
||||
copilotSession = await _copilotClient.ResumeSessionAsync(
|
||||
typedSession.SessionId,
|
||||
CreateResumeConfig(sessionConfig),
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
copilotSession = await _copilotClient.CreateSessionAsync(sessionConfig, cancellationToken).ConfigureAwait(false);
|
||||
typedSession.SessionId = copilotSession.SessionId;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Channel<AgentResponseUpdate> channel = Channel.CreateUnbounded<AgentResponseUpdate>();
|
||||
|
||||
using IDisposable subscription = copilotSession.On(evt =>
|
||||
{
|
||||
switch (evt)
|
||||
{
|
||||
case AssistantMessageDeltaEvent deltaEvent:
|
||||
channel.Writer.TryWrite(ConvertToAgentResponseUpdate(deltaEvent));
|
||||
break;
|
||||
|
||||
case AssistantMessageEvent assistantMessage:
|
||||
channel.Writer.TryWrite(ConvertToAgentResponseUpdate(assistantMessage));
|
||||
break;
|
||||
|
||||
case AssistantUsageEvent usageEvent:
|
||||
channel.Writer.TryWrite(ConvertToAgentResponseUpdate(usageEvent));
|
||||
break;
|
||||
|
||||
case SessionIdleEvent idleEvent:
|
||||
channel.Writer.TryWrite(ConvertToAgentResponseUpdate(idleEvent));
|
||||
channel.Writer.TryComplete();
|
||||
break;
|
||||
|
||||
case SessionErrorEvent errorEvent:
|
||||
channel.Writer.TryWrite(ConvertToAgentResponseUpdate(errorEvent));
|
||||
channel.Writer.TryComplete(new InvalidOperationException(
|
||||
$"Session error: {errorEvent.Data?.Message ?? "Unknown error"}"));
|
||||
break;
|
||||
|
||||
default:
|
||||
channel.Writer.TryWrite(ConvertToAgentResponseUpdate(evt));
|
||||
break;
|
||||
}
|
||||
});
|
||||
|
||||
string? tempDir = null;
|
||||
try
|
||||
{
|
||||
string prompt = string.Join("\n", messages.Select(message => message.Text));
|
||||
(List<UserMessageDataAttachmentsItem>? attachments, string? messageMode, tempDir) = await ProcessMessageAttachmentsAsync(
|
||||
messages,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
|
||||
MessageOptions messageOptions = new()
|
||||
{
|
||||
Prompt = prompt,
|
||||
Mode = string.IsNullOrWhiteSpace(messageMode) ? null : messageMode,
|
||||
};
|
||||
|
||||
if (attachments is not null)
|
||||
{
|
||||
messageOptions.Attachments = [.. attachments];
|
||||
}
|
||||
|
||||
await copilotSession.SendAsync(messageOptions, cancellationToken).ConfigureAwait(false);
|
||||
await foreach (AgentResponseUpdate update in channel.Reader.ReadAllAsync(cancellationToken).ConfigureAwait(false))
|
||||
{
|
||||
yield return update;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
CleanupTempDir(tempDir);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
await copilotSession.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
protected override string? IdCore => _id;
|
||||
|
||||
public override string Name => _name;
|
||||
|
||||
public override string Description => _description;
|
||||
|
||||
public async ValueTask DisposeAsync()
|
||||
{
|
||||
if (_ownsClient)
|
||||
{
|
||||
await _copilotClient.DisposeAsync().ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
internal static SessionConfig CreateConfiguredSessionConfig(SessionConfig? source, AgentRunOptions? options)
|
||||
{
|
||||
SessionConfig sessionConfig = source?.Clone() ?? new SessionConfig();
|
||||
sessionConfig.Streaming = true;
|
||||
if (sessionConfig.SystemMessage is not null)
|
||||
{
|
||||
sessionConfig.SystemMessage = CloneSystemMessage(sessionConfig.SystemMessage);
|
||||
}
|
||||
|
||||
if (options is not ChatClientAgentRunOptions { ChatOptions: { } chatOptions })
|
||||
{
|
||||
return sessionConfig;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(chatOptions.ModelId))
|
||||
{
|
||||
sessionConfig.Model = chatOptions.ModelId;
|
||||
}
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(chatOptions.Instructions))
|
||||
{
|
||||
AppendInstructions(sessionConfig, chatOptions.Instructions);
|
||||
}
|
||||
|
||||
sessionConfig.Tools = MergeTools(sessionConfig.Tools, chatOptions.Tools);
|
||||
return sessionConfig;
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<FunctionCallContent> ConvertToolRequestsToFunctionCalls(
|
||||
AssistantMessageDataToolRequestsItem[]? toolRequests)
|
||||
{
|
||||
if (toolRequests is not { Length: > 0 })
|
||||
{
|
||||
return [];
|
||||
}
|
||||
|
||||
List<FunctionCallContent> contents = [];
|
||||
foreach (AssistantMessageDataToolRequestsItem toolRequest in toolRequests)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(toolRequest.ToolCallId) || string.IsNullOrWhiteSpace(toolRequest.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
// Only project handoff tool calls as FunctionCallContent for the Agent Framework.
|
||||
// Other tool calls (ask_user, MCP tools, etc.) are resolved by the Copilot SDK
|
||||
// internally and must not be surfaced, because AIAgentHostExecutor tracks every
|
||||
// FunctionCallContent as an outstanding request. An unmatched request prevents
|
||||
// the executor from emitting a TurnToken, which stalls group-chat advancement.
|
||||
if (!IsHandoffToolName(toolRequest.Name))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
contents.Add(new FunctionCallContent(
|
||||
toolRequest.ToolCallId,
|
||||
toolRequest.Name,
|
||||
ParseToolArguments(toolRequest.Arguments)));
|
||||
}
|
||||
|
||||
return contents;
|
||||
}
|
||||
|
||||
private static bool IsHandoffToolName(string? name)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(name)
|
||||
&& name.StartsWith(HandoffToolPrefix, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private async Task EnsureClientStartedAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
if (_copilotClient.State != ConnectionState.Connected)
|
||||
{
|
||||
await _copilotClient.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static ResumeSessionConfig CreateResumeConfig(SessionConfig source)
|
||||
{
|
||||
return new ResumeSessionConfig
|
||||
{
|
||||
ClientName = source.ClientName,
|
||||
Model = source.Model,
|
||||
Tools = source.Tools is not null ? [.. source.Tools] : null,
|
||||
SystemMessage = CloneSystemMessage(source.SystemMessage),
|
||||
AvailableTools = source.AvailableTools is not null ? [.. source.AvailableTools] : null,
|
||||
ExcludedTools = source.ExcludedTools is not null ? [.. source.ExcludedTools] : null,
|
||||
Provider = source.Provider,
|
||||
OnPermissionRequest = source.OnPermissionRequest,
|
||||
OnUserInputRequest = source.OnUserInputRequest,
|
||||
Hooks = source.Hooks,
|
||||
WorkingDirectory = source.WorkingDirectory,
|
||||
ConfigDir = source.ConfigDir,
|
||||
Streaming = true,
|
||||
McpServers = source.McpServers is not null
|
||||
? new Dictionary<string, object>(source.McpServers, source.McpServers.Comparer)
|
||||
: null,
|
||||
CustomAgents = source.CustomAgents is not null ? [.. source.CustomAgents] : null,
|
||||
Agent = source.Agent,
|
||||
SkillDirectories = source.SkillDirectories is not null ? [.. source.SkillDirectories] : null,
|
||||
DisabledSkills = source.DisabledSkills is not null ? [.. source.DisabledSkills] : null,
|
||||
InfiniteSessions = source.InfiniteSessions,
|
||||
OnEvent = source.OnEvent,
|
||||
ReasoningEffort = source.ReasoningEffort,
|
||||
};
|
||||
}
|
||||
|
||||
private static SystemMessageConfig? CloneSystemMessage(SystemMessageConfig? source)
|
||||
{
|
||||
if (source is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new SystemMessageConfig
|
||||
{
|
||||
Mode = source.Mode,
|
||||
Content = source.Content,
|
||||
Sections = source.Sections is not null ? new Dictionary<string, SectionOverride>(source.Sections) : null,
|
||||
};
|
||||
}
|
||||
|
||||
private static void AppendInstructions(SessionConfig sessionConfig, string instructions)
|
||||
{
|
||||
string trimmedInstructions = instructions.Trim();
|
||||
if (trimmedInstructions.Length == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (sessionConfig.SystemMessage is null)
|
||||
{
|
||||
sessionConfig.SystemMessage = new SystemMessageConfig
|
||||
{
|
||||
Mode = SystemMessageMode.Append,
|
||||
Content = trimmedInstructions,
|
||||
};
|
||||
return;
|
||||
}
|
||||
|
||||
string? existingContent = sessionConfig.SystemMessage.Content;
|
||||
sessionConfig.SystemMessage.Content = string.IsNullOrWhiteSpace(existingContent)
|
||||
? trimmedInstructions
|
||||
: $"{existingContent.Trim()}\n\n{trimmedInstructions}";
|
||||
}
|
||||
|
||||
private static ICollection<AIFunction>? MergeTools(
|
||||
ICollection<AIFunction>? sessionTools,
|
||||
IList<AITool>? runtimeTools)
|
||||
{
|
||||
if (runtimeTools is not { Count: > 0 })
|
||||
{
|
||||
return sessionTools;
|
||||
}
|
||||
|
||||
List<AIFunction> mergedTools = sessionTools is not null ? [.. sessionTools] : [];
|
||||
foreach (AITool runtimeTool in runtimeTools)
|
||||
{
|
||||
mergedTools.Add(MapRuntimeTool(runtimeTool));
|
||||
}
|
||||
|
||||
return mergedTools;
|
||||
}
|
||||
|
||||
private static AIFunction MapRuntimeTool(AITool tool)
|
||||
{
|
||||
return tool switch
|
||||
{
|
||||
AIFunction function => function,
|
||||
AIFunctionDeclaration declaration when IsHandoffDeclaration(declaration) => CreateInvokableHandoffFunction(declaration),
|
||||
AIFunctionDeclaration declaration => throw new NotSupportedException(
|
||||
$"GitHub Copilot session tools must be invokable AIFunctions. Runtime tool '{declaration.Name}' is declaration-only."),
|
||||
_ => throw new NotSupportedException(
|
||||
$"GitHub Copilot session tools must be invokable AIFunctions. Runtime tool '{tool.Name}' is not supported."),
|
||||
};
|
||||
}
|
||||
|
||||
private static bool IsHandoffDeclaration(AIFunctionDeclaration declaration)
|
||||
{
|
||||
return IsHandoffToolName(declaration.Name);
|
||||
}
|
||||
|
||||
private static AIFunction CreateInvokableHandoffFunction(AIFunctionDeclaration declaration)
|
||||
{
|
||||
AIFunction function = AIFunctionFactory.Create(
|
||||
(string? reasonForHandoff) => "Transferred.",
|
||||
new AIFunctionFactoryOptions
|
||||
{
|
||||
Name = declaration.Name,
|
||||
Description = declaration.Description,
|
||||
AdditionalProperties = new Dictionary<string, object?>
|
||||
{
|
||||
["skip_permission"] = true,
|
||||
},
|
||||
});
|
||||
return function;
|
||||
}
|
||||
|
||||
private AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantMessageDeltaEvent deltaEvent)
|
||||
{
|
||||
TextContent textContent = new(deltaEvent.Data?.DeltaContent ?? string.Empty)
|
||||
{
|
||||
RawRepresentation = deltaEvent,
|
||||
};
|
||||
|
||||
return new AgentResponseUpdate(ChatRole.Assistant, [textContent])
|
||||
{
|
||||
AgentId = Id,
|
||||
MessageId = deltaEvent.Data?.MessageId,
|
||||
CreatedAt = deltaEvent.Timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
private AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantMessageEvent assistantMessage)
|
||||
{
|
||||
List<AIContent> contents = [];
|
||||
contents.AddRange(ConvertToolRequestsToFunctionCalls(assistantMessage.Data?.ToolRequests));
|
||||
contents.Add(new AIContent
|
||||
{
|
||||
RawRepresentation = assistantMessage,
|
||||
});
|
||||
|
||||
return new AgentResponseUpdate(ChatRole.Assistant, contents)
|
||||
{
|
||||
AgentId = Id,
|
||||
ResponseId = assistantMessage.Data?.MessageId,
|
||||
MessageId = assistantMessage.Data?.MessageId,
|
||||
CreatedAt = assistantMessage.Timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
private AgentResponseUpdate ConvertToAgentResponseUpdate(AssistantUsageEvent usageEvent)
|
||||
{
|
||||
UsageDetails usageDetails = new()
|
||||
{
|
||||
InputTokenCount = (int?)usageEvent.Data?.InputTokens,
|
||||
OutputTokenCount = (int?)usageEvent.Data?.OutputTokens,
|
||||
TotalTokenCount = (int?)((usageEvent.Data?.InputTokens ?? 0) + (usageEvent.Data?.OutputTokens ?? 0)),
|
||||
CachedInputTokenCount = (int?)usageEvent.Data?.CacheReadTokens,
|
||||
};
|
||||
|
||||
UsageContent usageContent = new(usageDetails)
|
||||
{
|
||||
RawRepresentation = usageEvent,
|
||||
};
|
||||
|
||||
return new AgentResponseUpdate(ChatRole.Assistant, [usageContent])
|
||||
{
|
||||
AgentId = Id,
|
||||
CreatedAt = usageEvent.Timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
private AgentResponseUpdate ConvertToAgentResponseUpdate(SessionEvent sessionEvent)
|
||||
{
|
||||
AIContent content = new()
|
||||
{
|
||||
RawRepresentation = sessionEvent,
|
||||
};
|
||||
|
||||
return new AgentResponseUpdate(ChatRole.Assistant, [content])
|
||||
{
|
||||
AgentId = Id,
|
||||
CreatedAt = sessionEvent.Timestamp,
|
||||
};
|
||||
}
|
||||
|
||||
private static Dictionary<string, object?>? ParseToolArguments(object? arguments)
|
||||
{
|
||||
if (arguments is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
if (arguments is Dictionary<string, object?> dictionary)
|
||||
{
|
||||
return new Dictionary<string, object?>(dictionary, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
if (arguments is JsonElement jsonElement)
|
||||
{
|
||||
if (jsonElement.ValueKind is JsonValueKind.Null or JsonValueKind.Undefined)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return JsonSerializer.Deserialize<Dictionary<string, object?>>(jsonElement.GetRawText());
|
||||
}
|
||||
|
||||
string json = JsonSerializer.Serialize(arguments, arguments.GetType());
|
||||
return JsonSerializer.Deserialize<Dictionary<string, object?>>(json);
|
||||
}
|
||||
|
||||
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 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;
|
||||
}
|
||||
|
||||
if (content.RawRepresentation is ChatMessageAttachmentDto protocolAttachment)
|
||||
{
|
||||
attachments ??= [];
|
||||
attachments.Add(CreateProtocolAttachment(protocolAttachment));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (content.RawRepresentation is CopilotMessageOptionsMetadata metadata
|
||||
&& !string.IsNullOrWhiteSpace(metadata.MessageMode))
|
||||
{
|
||||
messageMode = metadata.MessageMode.Trim();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
{
|
||||
if (tempDir is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
Directory.Delete(tempDir, recursive: true);
|
||||
}
|
||||
catch (IOException)
|
||||
{
|
||||
}
|
||||
catch (UnauthorizedAccessException)
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class AryxCopilotAgentSession : AgentSession
|
||||
{
|
||||
public AryxCopilotAgentSession()
|
||||
{
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
public AryxCopilotAgentSession(string? sessionId, AgentSessionStateBag? stateBag = null)
|
||||
: base(stateBag ?? new AgentSessionStateBag())
|
||||
{
|
||||
SessionId = sessionId;
|
||||
}
|
||||
|
||||
[JsonPropertyName("sessionId")]
|
||||
public string? SessionId { get; set; }
|
||||
|
||||
internal JsonElement Serialize(JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
JsonSerializerOptions options = jsonSerializerOptions ?? new JsonSerializerOptions(JsonSerializerDefaults.Web);
|
||||
return JsonSerializer.SerializeToElement(this, options);
|
||||
}
|
||||
|
||||
internal static AryxCopilotAgentSession Deserialize(
|
||||
JsonElement serializedState,
|
||||
JsonSerializerOptions? jsonSerializerOptions = null)
|
||||
{
|
||||
if (serializedState.ValueKind != JsonValueKind.Object)
|
||||
{
|
||||
throw new ArgumentException("The serialized session state must be a JSON object.", nameof(serializedState));
|
||||
}
|
||||
|
||||
JsonSerializerOptions options = jsonSerializerOptions ?? new JsonSerializerOptions(JsonSerializerDefaults.Web);
|
||||
return serializedState.Deserialize<AryxCopilotAgentSession>(options)
|
||||
?? new AryxCopilotAgentSession();
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
using System.Threading;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Microsoft.Agents.AI;
|
||||
@@ -12,22 +13,29 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
{
|
||||
private readonly List<IAsyncDisposable> _disposables = [];
|
||||
|
||||
private CopilotAgentBundle(IReadOnlyList<AIAgent> agents)
|
||||
internal CopilotAgentBundle(IReadOnlyList<AIAgent> agents, bool hasConfiguredHooks)
|
||||
{
|
||||
Agents = agents;
|
||||
HasConfiguredHooks = hasConfiguredHooks;
|
||||
}
|
||||
|
||||
public IReadOnlyList<AIAgent> Agents { get; }
|
||||
|
||||
public bool HasConfiguredHooks { get; }
|
||||
|
||||
public static async Task<CopilotAgentBundle> CreateAsync(
|
||||
RunTurnCommandDto command,
|
||||
Func<PatternAgentDefinitionDto, PermissionRequest, PermissionInvocation, Task<PermissionRequestResult>> onPermissionRequest,
|
||||
Func<PatternAgentDefinitionDto, UserInputRequest, UserInputInvocation, Task<UserInputResponse>> onUserInputRequest,
|
||||
Action<PatternAgentDefinitionDto, SessionEvent>? onSessionEvent,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<IAsyncDisposable> disposables = [];
|
||||
List<AIAgent> agents = [];
|
||||
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions();
|
||||
ResolvedHookSet configuredHooks = await HookConfigLoader.LoadAsync(command.ProjectPath, cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
IHookCommandRunner hookCommandRunner = HookCommandRunner.Instance;
|
||||
SessionToolingBundle? toolingBundle = command.Tooling is null
|
||||
? null
|
||||
: await SessionToolingBundle.CreateAsync(command.Tooling, command.ProjectPath, cancellationToken)
|
||||
@@ -43,23 +51,19 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
CopilotClient client = new(clientOptions);
|
||||
await client.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||
|
||||
SessionConfig sessionConfig = new()
|
||||
{
|
||||
Model = definition.Model,
|
||||
ReasoningEffort = definition.ReasoningEffort,
|
||||
SystemMessage = new SystemMessageConfig
|
||||
{
|
||||
Content = AgentInstructionComposer.Compose(command.Pattern, definition, agentIndex, command.WorkspaceKind),
|
||||
},
|
||||
WorkingDirectory = command.ProjectPath,
|
||||
OnPermissionRequest = (request, invocation) => onPermissionRequest(definition, request, invocation),
|
||||
OnEvent = evt => onSessionEvent?.Invoke(definition, evt),
|
||||
Streaming = true,
|
||||
};
|
||||
SessionConfig sessionConfig = CreateSessionConfig(
|
||||
command,
|
||||
definition,
|
||||
agentIndex,
|
||||
(request, invocation) => onPermissionRequest(definition, request, invocation),
|
||||
(request, invocation) => onUserInputRequest(definition, request, invocation),
|
||||
evt => onSessionEvent?.Invoke(definition, evt),
|
||||
configuredHooks,
|
||||
hookCommandRunner);
|
||||
|
||||
ApplySessionTooling(sessionConfig, toolingBundle?.McpServers, toolingBundle?.Tools);
|
||||
|
||||
GitHubCopilotAgent agent = new(
|
||||
AryxCopilotAgent agent = new(
|
||||
client,
|
||||
sessionConfig,
|
||||
ownsClient: true,
|
||||
@@ -71,11 +75,50 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
disposables.Add(agent);
|
||||
}
|
||||
|
||||
CopilotAgentBundle bundle = new(agents);
|
||||
CopilotAgentBundle bundle = new(agents, hasConfiguredHooks: !configuredHooks.IsEmpty);
|
||||
bundle._disposables.AddRange(disposables);
|
||||
return bundle;
|
||||
}
|
||||
|
||||
internal static SessionConfig CreateSessionConfig(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto definition,
|
||||
int agentIndex,
|
||||
PermissionRequestHandler? onPermissionRequest = null,
|
||||
UserInputHandler? onUserInputRequest = null,
|
||||
SessionEventHandler? onSessionEvent = null,
|
||||
ResolvedHookSet? configuredHooks = null,
|
||||
IHookCommandRunner? hookCommandRunner = null)
|
||||
{
|
||||
// Let the Copilot SDK allocate session IDs. Explicit custom SessionId values currently
|
||||
// cause turns to complete without assistant output, even for simple single-agent prompts.
|
||||
return new SessionConfig
|
||||
{
|
||||
Model = definition.Model,
|
||||
ReasoningEffort = definition.ReasoningEffort,
|
||||
SystemMessage = new SystemMessageConfig
|
||||
{
|
||||
Content = AgentInstructionComposer.Compose(
|
||||
command.Pattern,
|
||||
definition,
|
||||
agentIndex,
|
||||
command.WorkspaceKind,
|
||||
command.Mode),
|
||||
},
|
||||
WorkingDirectory = command.ProjectPath,
|
||||
OnPermissionRequest = onPermissionRequest,
|
||||
OnUserInputRequest = onUserInputRequest,
|
||||
Hooks = CopilotSessionHooks.Create(command, definition, configuredHooks, hookCommandRunner),
|
||||
OnEvent = onSessionEvent,
|
||||
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),
|
||||
};
|
||||
}
|
||||
|
||||
internal static void ApplySessionTooling(
|
||||
SessionConfig sessionConfig,
|
||||
Dictionary<string, object>? mcpServers,
|
||||
@@ -92,6 +135,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
|
||||
@@ -124,7 +216,10 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
||||
definition => definition,
|
||||
StringComparer.Ordinal);
|
||||
PatternHandoffTopology topology = PatternGraphResolver.ResolveHandoff(pattern);
|
||||
AIAgent entryAgent = agentMap.GetValueOrDefault(topology.EntryAgentId) ?? Agents[0];
|
||||
string entryAgentId = agentMap.ContainsKey(topology.EntryAgentId)
|
||||
? topology.EntryAgentId
|
||||
: pattern.Agents.FirstOrDefault()?.Id ?? topology.EntryAgentId;
|
||||
AIAgent entryAgent = agentMap.GetValueOrDefault(entryAgentId) ?? Agents[0];
|
||||
|
||||
HandoffsWorkflowBuilder builder = AgentWorkflowBuilder.CreateHandoffBuilderWith(entryAgent)
|
||||
.WithHandoffInstructions(HandoffWorkflowGuidance.CreateWorkflowInstructions());
|
||||
|
||||
@@ -9,9 +9,19 @@ internal sealed class CopilotApprovalCoordinator
|
||||
private const string ApprovedDecision = "approved";
|
||||
private const string RejectedDecision = "rejected";
|
||||
private const string ToolCallApprovalKind = "tool-call";
|
||||
private const string StoreMemoryToolName = "store_memory";
|
||||
private const string WebFetchToolName = "web_fetch";
|
||||
private const string ShellPermissionKind = "shell";
|
||||
private const string WritePermissionKind = "write";
|
||||
private const string ReadPermissionKind = "read";
|
||||
private const string McpPermissionKind = "mcp";
|
||||
private const string UrlPermissionKind = "url";
|
||||
private const string MemoryPermissionKind = "memory";
|
||||
private const string CustomToolPermissionKind = "custom-tool";
|
||||
private const string HookPermissionKind = "hook";
|
||||
|
||||
private readonly ConcurrentDictionary<string, PendingApprovalRequest> _pendingApprovals = new(StringComparer.Ordinal);
|
||||
private readonly ConcurrentDictionary<string, ConcurrentDictionary<string, byte>> _requestApprovedTools = new(StringComparer.Ordinal);
|
||||
|
||||
public Task ResolveApprovalAsync(
|
||||
ResolveApprovalCommandDto command,
|
||||
@@ -28,6 +38,11 @@ internal sealed class CopilotApprovalCoordinator
|
||||
throw new InvalidOperationException($"Approval \"{approvalId}\" is no longer pending.");
|
||||
}
|
||||
|
||||
if (decision == PermissionRequestResultKind.Approved && command.AlwaysApprove)
|
||||
{
|
||||
CacheApprovedToolForRequest(pending.RequestId, pending.ApprovalCacheKey);
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
@@ -41,12 +56,15 @@ internal sealed class CopilotApprovalCoordinator
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string? toolName = ResolveApprovalToolName(request, toolNamesByCallId);
|
||||
if (!RequiresToolCallApproval(command.Pattern.ApprovalPolicy, agent.Id, toolName))
|
||||
string? autoApprovedToolName = ResolveAutoApprovedToolName(request);
|
||||
string? approvalCacheKey = ResolveApprovalCacheKey(toolName, autoApprovedToolName);
|
||||
if (IsToolApprovedForRequest(command.RequestId, approvalCacheKey)
|
||||
|| !RequiresToolCallApproval(command.Pattern.ApprovalPolicy, agent.Id, toolName, autoApprovedToolName))
|
||||
{
|
||||
return CreateApprovalResult(PermissionRequestResultKind.Approved);
|
||||
}
|
||||
|
||||
PendingApprovalRequest pending = CreatePendingApproval(command);
|
||||
PendingApprovalRequest pending = CreatePendingApproval(command, approvalCacheKey);
|
||||
if (!_pendingApprovals.TryAdd(pending.ApprovalId, pending))
|
||||
{
|
||||
throw new InvalidOperationException($"Approval \"{pending.ApprovalId}\" is already pending.");
|
||||
@@ -131,13 +149,85 @@ internal sealed class CopilotApprovalCoordinator
|
||||
PermissionKind = permissionKind,
|
||||
Title = title,
|
||||
Detail = detail,
|
||||
PermissionDetail = BuildPermissionDetail(request),
|
||||
};
|
||||
}
|
||||
|
||||
internal static PermissionDetailDto BuildPermissionDetail(PermissionRequest request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
return request switch
|
||||
{
|
||||
PermissionRequestShell shell => new PermissionDetailDto
|
||||
{
|
||||
Kind = ShellPermissionKind,
|
||||
Intention = NormalizeOptionalString(shell.Intention),
|
||||
Command = NormalizeOptionalString(shell.FullCommandText),
|
||||
Warning = NormalizeOptionalString(shell.Warning),
|
||||
PossiblePaths = NormalizeOptionalStringList(shell.PossiblePaths),
|
||||
PossibleUrls = NormalizeOptionalStringList(shell.PossibleUrls.Select(static candidate => candidate.Url)),
|
||||
HasWriteFileRedirection = shell.HasWriteFileRedirection,
|
||||
},
|
||||
PermissionRequestWrite write => new PermissionDetailDto
|
||||
{
|
||||
Kind = WritePermissionKind,
|
||||
Intention = NormalizeOptionalString(write.Intention),
|
||||
FileName = NormalizeOptionalString(write.FileName),
|
||||
Diff = NormalizeOptionalString(write.Diff),
|
||||
NewFileContents = NormalizeOptionalString(write.NewFileContents),
|
||||
},
|
||||
PermissionRequestRead read => new PermissionDetailDto
|
||||
{
|
||||
Kind = ReadPermissionKind,
|
||||
Intention = NormalizeOptionalString(read.Intention),
|
||||
Path = NormalizeOptionalString(read.Path),
|
||||
},
|
||||
PermissionRequestMcp mcp => new PermissionDetailDto
|
||||
{
|
||||
Kind = McpPermissionKind,
|
||||
ServerName = NormalizeOptionalString(mcp.ServerName),
|
||||
ToolTitle = NormalizeOptionalString(mcp.ToolTitle),
|
||||
Args = mcp.Args,
|
||||
ReadOnly = mcp.ReadOnly,
|
||||
},
|
||||
PermissionRequestUrl url => new PermissionDetailDto
|
||||
{
|
||||
Kind = UrlPermissionKind,
|
||||
Intention = NormalizeOptionalString(url.Intention),
|
||||
Url = NormalizeOptionalString(url.Url),
|
||||
},
|
||||
PermissionRequestMemory memory => new PermissionDetailDto
|
||||
{
|
||||
Kind = MemoryPermissionKind,
|
||||
Subject = NormalizeOptionalString(memory.Subject),
|
||||
Fact = NormalizeOptionalString(memory.Fact),
|
||||
Citations = NormalizeOptionalString(memory.Citations),
|
||||
},
|
||||
PermissionRequestCustomTool customTool => new PermissionDetailDto
|
||||
{
|
||||
Kind = CustomToolPermissionKind,
|
||||
ToolDescription = NormalizeOptionalString(customTool.ToolDescription),
|
||||
Args = customTool.Args,
|
||||
},
|
||||
PermissionRequestHook hook => new PermissionDetailDto
|
||||
{
|
||||
Kind = HookPermissionKind,
|
||||
Args = hook.ToolArgs,
|
||||
HookMessage = NormalizeOptionalString(hook.HookMessage),
|
||||
},
|
||||
_ => new PermissionDetailDto
|
||||
{
|
||||
Kind = NormalizeOptionalString(request.Kind) ?? "unknown",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
internal static bool RequiresToolCallApproval(
|
||||
ApprovalPolicyDto? approvalPolicy,
|
||||
string agentId,
|
||||
string? toolName)
|
||||
string? toolName,
|
||||
string? autoApprovedToolName = null)
|
||||
{
|
||||
if (approvalPolicy?.Rules is null || approvalPolicy.Rules.Count == 0)
|
||||
{
|
||||
@@ -149,9 +239,13 @@ internal sealed class CopilotApprovalCoordinator
|
||||
return false;
|
||||
}
|
||||
|
||||
return string.IsNullOrWhiteSpace(toolName)
|
||||
|| !approvalPolicy.AutoApprovedToolNames.Any(candidate =>
|
||||
string.Equals(candidate, toolName, StringComparison.OrdinalIgnoreCase));
|
||||
IReadOnlyList<string> autoApprovedToolNames = approvalPolicy.AutoApprovedToolNames;
|
||||
if (autoApprovedToolNames.Count == 0)
|
||||
{
|
||||
return true;
|
||||
}
|
||||
|
||||
return !MatchesAutoApprovedTool(autoApprovedToolNames, toolName, autoApprovedToolName);
|
||||
}
|
||||
|
||||
internal static bool TryGetApprovalToolName(
|
||||
@@ -166,6 +260,17 @@ internal sealed class CopilotApprovalCoordinator
|
||||
internal static bool TryGetApprovalToolName(PermissionRequest request, out string? toolName)
|
||||
=> TryGetApprovalToolName(request, toolNamesByCallId: null, out toolName);
|
||||
|
||||
internal void ClearRequestApprovals(string requestId)
|
||||
{
|
||||
string? normalizedRequestId = NormalizeOptionalString(requestId);
|
||||
if (normalizedRequestId is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
_requestApprovedTools.TryRemove(normalizedRequestId, out _);
|
||||
}
|
||||
|
||||
private static bool HasMatchingToolCallCheckpoint(
|
||||
IReadOnlyList<ApprovalCheckpointRuleDto> rules,
|
||||
string agentId)
|
||||
@@ -188,12 +293,15 @@ internal sealed class CopilotApprovalCoordinator
|
||||
return false;
|
||||
}
|
||||
|
||||
private static PendingApprovalRequest CreatePendingApproval(RunTurnCommandDto command)
|
||||
private static PendingApprovalRequest CreatePendingApproval(
|
||||
RunTurnCommandDto command,
|
||||
string? approvalCacheKey)
|
||||
{
|
||||
return new PendingApprovalRequest(
|
||||
command.RequestId,
|
||||
command.SessionId,
|
||||
CreateApprovalRequestId(),
|
||||
NormalizeOptionalString(approvalCacheKey),
|
||||
new TaskCompletionSource<PermissionRequestResultKind>(TaskCreationOptions.RunContinuationsAsynchronously));
|
||||
}
|
||||
|
||||
@@ -214,6 +322,19 @@ internal sealed class CopilotApprovalCoordinator
|
||||
?? GetFallbackToolName(request);
|
||||
}
|
||||
|
||||
private static string? ResolveAutoApprovedToolName(PermissionRequest request)
|
||||
{
|
||||
return GetFallbackToolName(request);
|
||||
}
|
||||
|
||||
private static string? ResolveApprovalCacheKey(
|
||||
string? toolName,
|
||||
string? autoApprovedToolName)
|
||||
{
|
||||
return NormalizeOptionalString(autoApprovedToolName)
|
||||
?? NormalizeOptionalString(toolName);
|
||||
}
|
||||
|
||||
private static string? GetDirectToolName(PermissionRequest request)
|
||||
{
|
||||
return request switch
|
||||
@@ -265,10 +386,61 @@ internal sealed class CopilotApprovalCoordinator
|
||||
return request switch
|
||||
{
|
||||
PermissionRequestUrl => WebFetchToolName,
|
||||
PermissionRequestShell => ShellPermissionKind,
|
||||
PermissionRequestWrite => WritePermissionKind,
|
||||
PermissionRequestRead => ReadPermissionKind,
|
||||
PermissionRequestMemory => StoreMemoryToolName,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
private static bool MatchesAutoApprovedTool(
|
||||
IReadOnlyList<string> autoApprovedToolNames,
|
||||
string? toolName,
|
||||
string? autoApprovedToolName)
|
||||
{
|
||||
return MatchesAutoApprovedToolName(autoApprovedToolNames, toolName)
|
||||
|| MatchesAutoApprovedToolName(autoApprovedToolNames, autoApprovedToolName);
|
||||
}
|
||||
|
||||
private static bool MatchesAutoApprovedToolName(
|
||||
IReadOnlyList<string> autoApprovedToolNames,
|
||||
string? toolName)
|
||||
{
|
||||
string? normalizedToolName = NormalizeOptionalString(toolName);
|
||||
return normalizedToolName is not null
|
||||
&& autoApprovedToolNames.Any(candidate =>
|
||||
string.Equals(candidate, normalizedToolName, StringComparison.OrdinalIgnoreCase));
|
||||
}
|
||||
|
||||
private bool IsToolApprovedForRequest(string requestId, string? approvalCacheKey)
|
||||
{
|
||||
string? normalizedRequestId = NormalizeOptionalString(requestId);
|
||||
string? normalizedApprovalCacheKey = NormalizeOptionalString(approvalCacheKey);
|
||||
if (normalizedRequestId is null || normalizedApprovalCacheKey is null)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return _requestApprovedTools.TryGetValue(normalizedRequestId, out ConcurrentDictionary<string, byte>? approvedTools)
|
||||
&& approvedTools.ContainsKey(normalizedApprovalCacheKey);
|
||||
}
|
||||
|
||||
private void CacheApprovedToolForRequest(string requestId, string? approvalCacheKey)
|
||||
{
|
||||
string? normalizedRequestId = NormalizeOptionalString(requestId);
|
||||
string? normalizedApprovalCacheKey = NormalizeOptionalString(approvalCacheKey);
|
||||
if (normalizedRequestId is null || normalizedApprovalCacheKey is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
ConcurrentDictionary<string, byte> approvedTools = _requestApprovedTools.GetOrAdd(
|
||||
normalizedRequestId,
|
||||
static _ => new ConcurrentDictionary<string, byte>(StringComparer.OrdinalIgnoreCase));
|
||||
approvedTools.TryAdd(normalizedApprovalCacheKey, 0);
|
||||
}
|
||||
|
||||
private PendingApprovalRequest GetPendingApproval(string approvalId)
|
||||
{
|
||||
if (_pendingApprovals.TryGetValue(approvalId, out PendingApprovalRequest? pending))
|
||||
@@ -307,9 +479,21 @@ internal sealed class CopilotApprovalCoordinator
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? NormalizeOptionalStringList(IEnumerable<string?> values)
|
||||
{
|
||||
List<string> normalized = values
|
||||
.Select(NormalizeOptionalString)
|
||||
.Where(static value => value is not null)
|
||||
.Cast<string>()
|
||||
.ToList();
|
||||
|
||||
return normalized.Count > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
private sealed record PendingApprovalRequest(
|
||||
string RequestId,
|
||||
string SessionId,
|
||||
string ApprovalId,
|
||||
string? ApprovalCacheKey,
|
||||
TaskCompletionSource<PermissionRequestResultKind> Decision);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class CopilotExitPlanModeCoordinator
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, ExitPlanModeRequestedEventDto> _pendingExitPlanRequests =
|
||||
new(StringComparer.Ordinal);
|
||||
|
||||
public ExitPlanModeRequestedEventDto RecordExitPlanModeRequest(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agent,
|
||||
ExitPlanModeRequestedEvent request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
ExitPlanModeRequestedEventDto exitPlanEvent = BuildExitPlanModeRequestedEvent(command, agent, request);
|
||||
_pendingExitPlanRequests[command.RequestId] = exitPlanEvent;
|
||||
return exitPlanEvent;
|
||||
}
|
||||
|
||||
public ExitPlanModeRequestedEventDto? ConsumePendingRequest(string turnRequestId)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(turnRequestId);
|
||||
return _pendingExitPlanRequests.TryRemove(turnRequestId, out ExitPlanModeRequestedEventDto? pending)
|
||||
? pending
|
||||
: null;
|
||||
}
|
||||
|
||||
internal static ExitPlanModeRequestedEventDto BuildExitPlanModeRequestedEvent(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agent,
|
||||
ExitPlanModeRequestedEvent request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
ExitPlanModeRequestedData requestData = request.Data
|
||||
?? throw new InvalidOperationException("Exit plan mode request data is required.");
|
||||
|
||||
string exitPlanId = NormalizeOptionalString(requestData.RequestId)
|
||||
?? throw new InvalidOperationException("Exit plan mode request ID is required.");
|
||||
string? normalizedAgentId = NormalizeOptionalString(agent.Id);
|
||||
string? normalizedAgentName = NormalizeOptionalString(agent.Name) ?? normalizedAgentId;
|
||||
|
||||
return new ExitPlanModeRequestedEventDto
|
||||
{
|
||||
Type = "exit-plan-mode-requested",
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
ExitPlanId = exitPlanId,
|
||||
AgentId = normalizedAgentId,
|
||||
AgentName = normalizedAgentName,
|
||||
Summary = NormalizeOptionalString(requestData.Summary) ?? string.Empty,
|
||||
PlanContent = NormalizeOptionalString(requestData.PlanContent) ?? string.Empty,
|
||||
Actions = NormalizeOptionalStringList(requestData.Actions ?? []),
|
||||
RecommendedAction = NormalizeOptionalString(requestData.RecommendedAction),
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? NormalizeOptionalStringList(IEnumerable<string?> values)
|
||||
{
|
||||
List<string> normalized = values
|
||||
.Select(NormalizeOptionalString)
|
||||
.Where(static value => value is not null)
|
||||
.Cast<string>()
|
||||
.ToList();
|
||||
|
||||
return normalized.Count > 0 ? normalized : null;
|
||||
}
|
||||
}
|
||||
@@ -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,58 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class CopilotMcpOAuthCoordinator
|
||||
{
|
||||
public McpOauthRequiredEventDto BuildMcpOauthRequiredEvent(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agent,
|
||||
McpOauthRequiredEvent request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
McpOauthRequiredData requestData = request.Data
|
||||
?? throw new InvalidOperationException("MCP OAuth request data is required.");
|
||||
|
||||
string oauthRequestId = NormalizeOptionalString(requestData.RequestId)
|
||||
?? throw new InvalidOperationException("MCP OAuth request ID is required.");
|
||||
string? normalizedAgentId = NormalizeOptionalString(agent.Id);
|
||||
string? normalizedAgentName = NormalizeOptionalString(agent.Name) ?? normalizedAgentId;
|
||||
|
||||
return new McpOauthRequiredEventDto
|
||||
{
|
||||
Type = "mcp-oauth-required",
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
OauthRequestId = oauthRequestId,
|
||||
AgentId = normalizedAgentId,
|
||||
AgentName = normalizedAgentName,
|
||||
ServerName = NormalizeOptionalString(requestData.ServerName) ?? string.Empty,
|
||||
ServerUrl = NormalizeOptionalString(requestData.ServerUrl) ?? string.Empty,
|
||||
StaticClientConfig = BuildStaticClientConfig(requestData.StaticClientConfig),
|
||||
};
|
||||
}
|
||||
|
||||
private static McpOauthStaticClientConfigDto? BuildStaticClientConfig(
|
||||
McpOauthRequiredDataStaticClientConfig? staticClientConfig)
|
||||
{
|
||||
if (staticClientConfig is null)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new McpOauthStaticClientConfigDto
|
||||
{
|
||||
ClientId = NormalizeOptionalString(staticClientConfig.ClientId) ?? string.Empty,
|
||||
PublicClient = staticClientConfig.PublicClient,
|
||||
};
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed record CopilotMessageOptionsMetadata(string MessageMode);
|
||||
@@ -0,0 +1,328 @@
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
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";
|
||||
private const string DenyDecision = "deny";
|
||||
private static readonly JsonSerializerOptions HookJsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull,
|
||||
};
|
||||
|
||||
public static SessionHooks Create(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agentDefinition,
|
||||
ResolvedHookSet? configuredHooks = null,
|
||||
IHookCommandRunner? hookCommandRunner = null)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
ArgumentNullException.ThrowIfNull(agentDefinition);
|
||||
ResolvedHookSet hooks = configuredHooks ?? ResolvedHookSet.Empty;
|
||||
IHookCommandRunner runner = hookCommandRunner ?? HookCommandRunner.Instance;
|
||||
|
||||
return new SessionHooks
|
||||
{
|
||||
OnPreToolUse = (input, _) => CreatePreToolUseOutputAsync(command, agentDefinition, hooks, runner, input),
|
||||
OnPostToolUse = (input, _) => RunPostToolUseHooksAsync(command, hooks, runner, input),
|
||||
OnUserPromptSubmitted = (input, _) => RunUserPromptSubmittedHooksAsync(command, hooks, runner, input),
|
||||
OnSessionStart = (input, _) => RunSessionStartHooksAsync(command, hooks, runner, input),
|
||||
OnSessionEnd = (input, _) => RunSessionEndHooksAsync(command, hooks, runner, input),
|
||||
OnErrorOccurred = (input, _) => RunErrorOccurredHooksAsync(command, hooks, runner, input),
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<PreToolUseHookOutput?> CreatePreToolUseOutputAsync(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agentDefinition,
|
||||
ResolvedHookSet configuredHooks,
|
||||
IHookCommandRunner hookCommandRunner,
|
||||
PreToolUseHookInput input)
|
||||
{
|
||||
if (configuredHooks.PreToolUse.Count > 0)
|
||||
{
|
||||
string payload = SerializeHookInput(new FilePreToolUseHookInput
|
||||
{
|
||||
Timestamp = input.Timestamp,
|
||||
Cwd = input.Cwd,
|
||||
ToolName = input.ToolName,
|
||||
ToolArgs = SerializeHookValue(input.ToolArgs),
|
||||
});
|
||||
|
||||
foreach (HookCommandDefinition hook in configuredHooks.PreToolUse)
|
||||
{
|
||||
string? hookOutput = await hookCommandRunner.RunAsync(
|
||||
hook,
|
||||
payload,
|
||||
command.ProjectPath,
|
||||
CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
PreToolUseHookOutput? decision = ParsePreToolUseDecision(hookOutput);
|
||||
if (string.Equals(decision?.PermissionDecision, DenyDecision, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return decision;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return CreateApprovalPolicyOutput(command, agentDefinition, input);
|
||||
}
|
||||
|
||||
private static async Task<PostToolUseHookOutput?> RunPostToolUseHooksAsync(
|
||||
RunTurnCommandDto command,
|
||||
ResolvedHookSet configuredHooks,
|
||||
IHookCommandRunner hookCommandRunner,
|
||||
PostToolUseHookInput input)
|
||||
{
|
||||
await RunConfiguredHooksAsync(
|
||||
configuredHooks.PostToolUse,
|
||||
hookCommandRunner,
|
||||
command.ProjectPath,
|
||||
SerializeHookInput(new FilePostToolUseHookInput
|
||||
{
|
||||
Timestamp = input.Timestamp,
|
||||
Cwd = input.Cwd,
|
||||
ToolName = input.ToolName,
|
||||
ToolArgs = SerializeHookValue(input.ToolArgs),
|
||||
ToolResult = input.ToolResult,
|
||||
}))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<UserPromptSubmittedHookOutput?> RunUserPromptSubmittedHooksAsync(
|
||||
RunTurnCommandDto command,
|
||||
ResolvedHookSet configuredHooks,
|
||||
IHookCommandRunner hookCommandRunner,
|
||||
UserPromptSubmittedHookInput input)
|
||||
{
|
||||
await RunConfiguredHooksAsync(
|
||||
configuredHooks.UserPromptSubmitted,
|
||||
hookCommandRunner,
|
||||
command.ProjectPath,
|
||||
SerializeHookInput(new FileUserPromptSubmittedHookInput
|
||||
{
|
||||
Timestamp = input.Timestamp,
|
||||
Cwd = input.Cwd,
|
||||
Prompt = input.Prompt,
|
||||
}))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<SessionStartHookOutput?> RunSessionStartHooksAsync(
|
||||
RunTurnCommandDto command,
|
||||
ResolvedHookSet configuredHooks,
|
||||
IHookCommandRunner hookCommandRunner,
|
||||
SessionStartHookInput input)
|
||||
{
|
||||
await RunConfiguredHooksAsync(
|
||||
configuredHooks.SessionStart,
|
||||
hookCommandRunner,
|
||||
command.ProjectPath,
|
||||
SerializeHookInput(new FileSessionStartHookInput
|
||||
{
|
||||
Timestamp = input.Timestamp,
|
||||
Cwd = input.Cwd,
|
||||
Source = input.Source,
|
||||
InitialPrompt = input.InitialPrompt,
|
||||
}))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<SessionEndHookOutput?> RunSessionEndHooksAsync(
|
||||
RunTurnCommandDto command,
|
||||
ResolvedHookSet configuredHooks,
|
||||
IHookCommandRunner hookCommandRunner,
|
||||
SessionEndHookInput input)
|
||||
{
|
||||
await RunConfiguredHooksAsync(
|
||||
configuredHooks.SessionEnd,
|
||||
hookCommandRunner,
|
||||
command.ProjectPath,
|
||||
SerializeHookInput(new FileSessionEndHookInput
|
||||
{
|
||||
Timestamp = input.Timestamp,
|
||||
Cwd = input.Cwd,
|
||||
Reason = input.Reason,
|
||||
FinalMessage = input.FinalMessage,
|
||||
Error = input.Error,
|
||||
}))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<ErrorOccurredHookOutput?> RunErrorOccurredHooksAsync(
|
||||
RunTurnCommandDto command,
|
||||
ResolvedHookSet configuredHooks,
|
||||
IHookCommandRunner hookCommandRunner,
|
||||
ErrorOccurredHookInput input)
|
||||
{
|
||||
await RunConfiguredHooksAsync(
|
||||
configuredHooks.ErrorOccurred,
|
||||
hookCommandRunner,
|
||||
command.ProjectPath,
|
||||
SerializeHookInput(new FileErrorOccurredHookInput
|
||||
{
|
||||
Timestamp = input.Timestamp,
|
||||
Cwd = input.Cwd,
|
||||
Error = new FileHookError
|
||||
{
|
||||
Message = input.Error,
|
||||
Context = input.ErrorContext,
|
||||
Recoverable = input.Recoverable,
|
||||
},
|
||||
}))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task RunConfiguredHooksAsync(
|
||||
IReadOnlyList<HookCommandDefinition> hooks,
|
||||
IHookCommandRunner hookCommandRunner,
|
||||
string projectPath,
|
||||
string payload)
|
||||
{
|
||||
if (hooks.Count == 0)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (HookCommandDefinition hook in hooks)
|
||||
{
|
||||
await hookCommandRunner.RunAsync(
|
||||
hook,
|
||||
payload,
|
||||
projectPath,
|
||||
CancellationToken.None)
|
||||
.ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static PreToolUseHookOutput CreateApprovalPolicyOutput(
|
||||
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 PreToolUseHookOutput? ParsePreToolUseDecision(string? hookOutput)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(hookOutput))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
FilePreToolUseHookOutput? parsed = JsonSerializer.Deserialize<FilePreToolUseHookOutput>(hookOutput, HookJsonOptions);
|
||||
if (!string.Equals(parsed?.PermissionDecision, DenyDecision, StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new PreToolUseHookOutput
|
||||
{
|
||||
PermissionDecision = DenyDecision,
|
||||
PermissionDecisionReason = Normalize(parsed?.PermissionDecisionReason),
|
||||
};
|
||||
}
|
||||
catch (JsonException exception)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Ignoring invalid preToolUse hook output: {exception.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static string SerializeHookInput<T>(T input)
|
||||
=> JsonSerializer.Serialize(input, HookJsonOptions);
|
||||
|
||||
private static string SerializeHookValue(object? value)
|
||||
=> JsonSerializer.Serialize(value, HookJsonOptions);
|
||||
|
||||
private static string? Normalize(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
|
||||
private sealed class FileSessionStartHookInput
|
||||
{
|
||||
public long Timestamp { get; init; }
|
||||
public string Cwd { get; init; } = string.Empty;
|
||||
public string Source { get; init; } = string.Empty;
|
||||
public string? InitialPrompt { get; init; }
|
||||
}
|
||||
|
||||
private sealed class FileSessionEndHookInput
|
||||
{
|
||||
public long Timestamp { get; init; }
|
||||
public string Cwd { get; init; } = string.Empty;
|
||||
public string Reason { get; init; } = string.Empty;
|
||||
public string? FinalMessage { get; init; }
|
||||
public string? Error { get; init; }
|
||||
}
|
||||
|
||||
private sealed class FileUserPromptSubmittedHookInput
|
||||
{
|
||||
public long Timestamp { get; init; }
|
||||
public string Cwd { get; init; } = string.Empty;
|
||||
public string Prompt { get; init; } = string.Empty;
|
||||
}
|
||||
|
||||
private sealed class FilePreToolUseHookInput
|
||||
{
|
||||
public long Timestamp { get; init; }
|
||||
public string Cwd { get; init; } = string.Empty;
|
||||
public string ToolName { get; init; } = string.Empty;
|
||||
public string ToolArgs { get; init; } = "null";
|
||||
}
|
||||
|
||||
private sealed class FilePostToolUseHookInput
|
||||
{
|
||||
public long Timestamp { get; init; }
|
||||
public string Cwd { get; init; } = string.Empty;
|
||||
public string ToolName { get; init; } = string.Empty;
|
||||
public string ToolArgs { get; init; } = "null";
|
||||
public object? ToolResult { get; init; }
|
||||
}
|
||||
|
||||
private sealed class FileErrorOccurredHookInput
|
||||
{
|
||||
public long Timestamp { get; init; }
|
||||
public string Cwd { get; init; } = string.Empty;
|
||||
public FileHookError Error { get; init; } = new();
|
||||
}
|
||||
|
||||
private sealed class FileHookError
|
||||
{
|
||||
public string Message { get; init; } = string.Empty;
|
||||
public string Context { get; init; } = string.Empty;
|
||||
public bool Recoverable { get; init; }
|
||||
}
|
||||
|
||||
private sealed class FilePreToolUseHookOutput
|
||||
{
|
||||
public string? PermissionDecision { get; init; }
|
||||
public string? PermissionDecisionReason { get; init; }
|
||||
}
|
||||
}
|
||||
@@ -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,6 +9,8 @@ internal sealed class CopilotTurnExecutionState
|
||||
{
|
||||
private readonly RunTurnCommandDto _command;
|
||||
private readonly HashSet<string> _startedAgents = new(StringComparer.OrdinalIgnoreCase);
|
||||
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();
|
||||
private int _fallbackMessageIndex;
|
||||
@@ -24,31 +26,36 @@ internal sealed class CopilotTurnExecutionState
|
||||
|
||||
public List<ChatMessageDto> CompletedMessages { get; private set; } = [];
|
||||
|
||||
public bool HasPendingExitPlanModeRequest { get; private set; }
|
||||
|
||||
public bool SuppressHookLifecycleEvents { get; set; }
|
||||
|
||||
public async Task EmitThinkingIfNeeded(
|
||||
AgentIdentity agent,
|
||||
Func<AgentActivityEventDto, Task> onActivity)
|
||||
Func<SidecarEventDto, Task> onEvent)
|
||||
{
|
||||
ActiveAgent = agent;
|
||||
|
||||
if (!_startedAgents.Add(agent.AgentId))
|
||||
AgentActivityEventDto? thinkingActivity = CreateThinkingActivityIfNeeded(agent);
|
||||
if (thinkingActivity is null)
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
await onActivity(new AgentActivityEventDto
|
||||
{
|
||||
Type = "agent-activity",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
ActivityType = "thinking",
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
}).ConfigureAwait(false);
|
||||
await onEvent(thinkingActivity).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
public void ApplyActivity(AgentActivityEventDto activity)
|
||||
public void QueueThinkingIfNeeded(AgentIdentity agent)
|
||||
{
|
||||
if (string.Equals(activity.ActivityType, "handoff", StringComparison.Ordinal)
|
||||
AgentActivityEventDto? thinkingActivity = CreateThinkingActivityIfNeeded(agent);
|
||||
if (thinkingActivity is not null)
|
||||
{
|
||||
_pendingEvents.Enqueue(thinkingActivity);
|
||||
}
|
||||
}
|
||||
|
||||
public void ApplyEvent(SidecarEventDto evt)
|
||||
{
|
||||
if (evt is AgentActivityEventDto activity
|
||||
&& string.Equals(activity.ActivityType, "handoff", StringComparison.Ordinal)
|
||||
&& !string.IsNullOrWhiteSpace(activity.AgentId)
|
||||
&& !string.IsNullOrWhiteSpace(activity.AgentName))
|
||||
{
|
||||
@@ -67,16 +74,113 @@ internal sealed class CopilotTurnExecutionState
|
||||
{
|
||||
case AssistantMessageDeltaEvent messageDelta when !string.IsNullOrWhiteSpace(messageDelta.Data?.MessageId):
|
||||
RecordObservedAgentForMessage(agent, messageDelta.Data!.MessageId);
|
||||
QueueThinkingIfNeeded(agent);
|
||||
break;
|
||||
case AssistantMessageEvent assistantMessage when !string.IsNullOrWhiteSpace(assistantMessage.Data?.MessageId):
|
||||
RecordObservedAgentForMessage(agent, assistantMessage.Data!.MessageId);
|
||||
QueueThinkingIfNeeded(agent);
|
||||
break;
|
||||
case ToolExecutionStartEvent toolExecutionStart
|
||||
when !string.IsNullOrWhiteSpace(toolExecutionStart.Data?.ToolCallId)
|
||||
&& !string.IsNullOrWhiteSpace(toolExecutionStart.Data?.ToolName):
|
||||
ToolNamesByCallId[toolExecutionStart.Data.ToolCallId.Trim()] = toolExecutionStart.Data.ToolName.Trim();
|
||||
break;
|
||||
case AssistantReasoningDeltaEvent:
|
||||
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;
|
||||
if (!SuppressHookLifecycleEvents)
|
||||
{
|
||||
_pendingEvents.Enqueue(CreateHookLifecycleEvent(agent, "start", hookStart.Data));
|
||||
}
|
||||
break;
|
||||
case HookEndEvent hookEnd:
|
||||
ActiveAgent = agent;
|
||||
if (!SuppressHookLifecycleEvents)
|
||||
{
|
||||
_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;
|
||||
case ExitPlanModeRequestedEvent:
|
||||
HasPendingExitPlanModeRequest = true;
|
||||
ActiveAgent = agent;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
public IReadOnlyList<SidecarEventDto> DrainPendingEvents()
|
||||
{
|
||||
List<SidecarEventDto> pending = [];
|
||||
while (_pendingEvents.TryDequeue(out SidecarEventDto? pendingEvent))
|
||||
{
|
||||
pending.Add(pendingEvent);
|
||||
}
|
||||
|
||||
return pending;
|
||||
}
|
||||
|
||||
public void EnqueuePendingMcpOauthRequest(McpOauthRequiredEventDto request)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
_pendingMcpOauthRequests.Enqueue(request);
|
||||
}
|
||||
|
||||
public IReadOnlyList<McpOauthRequiredEventDto> DrainPendingMcpOauthRequests()
|
||||
{
|
||||
List<McpOauthRequiredEventDto> pending = [];
|
||||
while (_pendingMcpOauthRequests.TryDequeue(out McpOauthRequiredEventDto? request))
|
||||
{
|
||||
pending.Add(request);
|
||||
}
|
||||
|
||||
return pending;
|
||||
}
|
||||
|
||||
public bool TryResolveObservedAgentForMessage(string? messageId, out AgentIdentity agent)
|
||||
{
|
||||
agent = default;
|
||||
@@ -112,6 +216,26 @@ internal sealed class CopilotTurnExecutionState
|
||||
_observedAgentsByMessageId[messageId] = agent;
|
||||
}
|
||||
|
||||
private AgentActivityEventDto? CreateThinkingActivityIfNeeded(AgentIdentity agent)
|
||||
{
|
||||
ActiveAgent = agent;
|
||||
|
||||
if (!_startedAgents.Add(agent.AgentId))
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
return new AgentActivityEventDto
|
||||
{
|
||||
Type = "agent-activity",
|
||||
RequestId = _command.RequestId,
|
||||
SessionId = _command.SessionId,
|
||||
ActivityType = "thinking",
|
||||
AgentId = agent.AgentId,
|
||||
AgentName = agent.AgentName,
|
||||
};
|
||||
}
|
||||
|
||||
public void UpdateCompletedMessages(
|
||||
IReadOnlyList<ChatMessage> allMessages,
|
||||
IReadOnlyList<ChatMessage> inputMessages)
|
||||
@@ -137,4 +261,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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,153 @@
|
||||
using System.Collections.Concurrent;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal sealed class CopilotUserInputCoordinator
|
||||
{
|
||||
private readonly ConcurrentDictionary<string, PendingUserInputRequest> _pendingUserInputs = new(StringComparer.Ordinal);
|
||||
|
||||
public Task ResolveUserInputAsync(
|
||||
ResolveUserInputCommandDto command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
|
||||
string userInputId = RequireUserInputId(command.UserInputId);
|
||||
PendingUserInputRequest pending = GetPendingUserInput(userInputId);
|
||||
UserInputResponse response = new()
|
||||
{
|
||||
Answer = command.Answer ?? string.Empty,
|
||||
WasFreeform = command.WasFreeform,
|
||||
};
|
||||
|
||||
if (!pending.Response.TrySetResult(response))
|
||||
{
|
||||
throw new InvalidOperationException($"User input request \"{userInputId}\" is no longer pending.");
|
||||
}
|
||||
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
public async Task<UserInputResponse> RequestUserInputAsync(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agent,
|
||||
UserInputRequest request,
|
||||
UserInputInvocation invocation,
|
||||
Func<UserInputRequestedEventDto, Task> onUserInput,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
ArgumentNullException.ThrowIfNull(invocation);
|
||||
ArgumentNullException.ThrowIfNull(onUserInput);
|
||||
|
||||
PendingUserInputRequest pending = CreatePendingUserInput(command);
|
||||
if (!_pendingUserInputs.TryAdd(pending.UserInputId, pending))
|
||||
{
|
||||
throw new InvalidOperationException($"User input request \"{pending.UserInputId}\" is already pending.");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
await onUserInput(BuildUserInputRequestedEvent(command, agent, request, pending.UserInputId))
|
||||
.ConfigureAwait(false);
|
||||
|
||||
using CancellationTokenRegistration registration = cancellationToken.Register(
|
||||
static state =>
|
||||
{
|
||||
((TaskCompletionSource<UserInputResponse>)state!)
|
||||
.TrySetCanceled();
|
||||
},
|
||||
pending.Response);
|
||||
|
||||
return await pending.Response.Task.ConfigureAwait(false);
|
||||
}
|
||||
finally
|
||||
{
|
||||
_pendingUserInputs.TryRemove(pending.UserInputId, out _);
|
||||
}
|
||||
}
|
||||
|
||||
internal static UserInputRequestedEventDto BuildUserInputRequestedEvent(
|
||||
RunTurnCommandDto command,
|
||||
PatternAgentDefinitionDto agent,
|
||||
UserInputRequest request,
|
||||
string userInputId)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(command);
|
||||
ArgumentNullException.ThrowIfNull(agent);
|
||||
ArgumentNullException.ThrowIfNull(request);
|
||||
|
||||
string? normalizedAgentId = NormalizeOptionalString(agent.Id);
|
||||
string? normalizedAgentName = NormalizeOptionalString(agent.Name) ?? normalizedAgentId;
|
||||
|
||||
return new UserInputRequestedEventDto
|
||||
{
|
||||
Type = "user-input-requested",
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
UserInputId = userInputId,
|
||||
AgentId = normalizedAgentId,
|
||||
AgentName = normalizedAgentName,
|
||||
Question = NormalizeOptionalString(request.Question) ?? string.Empty,
|
||||
Choices = NormalizeOptionalStringList(request.Choices ?? []),
|
||||
AllowFreeform = request.AllowFreeform,
|
||||
};
|
||||
}
|
||||
|
||||
private static PendingUserInputRequest CreatePendingUserInput(RunTurnCommandDto command)
|
||||
{
|
||||
return new PendingUserInputRequest(
|
||||
command.RequestId,
|
||||
command.SessionId,
|
||||
CreateUserInputRequestId(),
|
||||
new TaskCompletionSource<UserInputResponse>(TaskCreationOptions.RunContinuationsAsynchronously));
|
||||
}
|
||||
|
||||
private PendingUserInputRequest GetPendingUserInput(string userInputId)
|
||||
{
|
||||
if (_pendingUserInputs.TryGetValue(userInputId, out PendingUserInputRequest? pending))
|
||||
{
|
||||
return pending;
|
||||
}
|
||||
|
||||
throw new InvalidOperationException($"User input request \"{userInputId}\" is not pending.");
|
||||
}
|
||||
|
||||
private static string RequireUserInputId(string? userInputId)
|
||||
{
|
||||
string? normalizedUserInputId = NormalizeOptionalString(userInputId);
|
||||
return normalizedUserInputId
|
||||
?? throw new InvalidOperationException("User input ID is required.");
|
||||
}
|
||||
|
||||
private static string CreateUserInputRequestId()
|
||||
{
|
||||
return $"user-input-{Guid.NewGuid():N}";
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
{
|
||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
|
||||
private static IReadOnlyList<string>? NormalizeOptionalStringList(IEnumerable<string?> values)
|
||||
{
|
||||
List<string> normalized = values
|
||||
.Select(NormalizeOptionalString)
|
||||
.Where(static value => value is not null)
|
||||
.Cast<string>()
|
||||
.ToList();
|
||||
|
||||
return normalized.Count > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
private sealed record PendingUserInputRequest(
|
||||
string RequestId,
|
||||
string SessionId,
|
||||
string UserInputId,
|
||||
TaskCompletionSource<UserInputResponse> Response);
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using System.Linq;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
@@ -6,8 +8,12 @@ namespace Aryx.AgentHost.Services;
|
||||
|
||||
public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
{
|
||||
private const string HandoffFunctionPrefix = "handoff_to_";
|
||||
private readonly PatternValidator _patternValidator;
|
||||
private readonly CopilotApprovalCoordinator _approvalCoordinator = new();
|
||||
private readonly CopilotUserInputCoordinator _userInputCoordinator = new();
|
||||
private readonly CopilotMcpOAuthCoordinator _mcpOAuthCoordinator = new();
|
||||
private readonly CopilotExitPlanModeCoordinator _exitPlanModeCoordinator = new();
|
||||
|
||||
public CopilotWorkflowRunner(PatternValidator patternValidator)
|
||||
{
|
||||
@@ -17,8 +23,11 @@ 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,
|
||||
Func<ExitPlanModeRequestedEventDto, Task> onExitPlanMode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
PatternValidationIssueDto? validationError = _patternValidator.Validate(command.Pattern).FirstOrDefault();
|
||||
@@ -28,35 +37,116 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
}
|
||||
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
await using CopilotAgentBundle bundle = await CopilotAgentBundle.CreateAsync(
|
||||
command,
|
||||
(agent, request, invocation) => _approvalCoordinator.RequestApprovalAsync(
|
||||
command,
|
||||
agent,
|
||||
request,
|
||||
invocation,
|
||||
state.ToolNamesByCallId,
|
||||
onApproval,
|
||||
cancellationToken),
|
||||
(agent, sessionEvent) => state.ObserveSessionEvent(agent, sessionEvent),
|
||||
cancellationToken);
|
||||
Workflow workflow = bundle.BuildWorkflow(command.Pattern);
|
||||
List<ChatMessage> inputMessages = command.Messages.Select(WorkflowTranscriptProjector.ToChatMessage).ToList();
|
||||
using CancellationTokenSource runCancellation =
|
||||
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
|
||||
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(cancellationToken).ConfigureAwait(false))
|
||||
try
|
||||
{
|
||||
bool shouldEndTurn = await HandleWorkflowEventAsync(command, evt, inputMessages, state, onDelta, onActivity)
|
||||
.ConfigureAwait(false);
|
||||
if (shouldEndTurn)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
await using CopilotAgentBundle bundle = await CopilotAgentBundle.CreateAsync(
|
||||
command,
|
||||
(agent, request, invocation) => _approvalCoordinator.RequestApprovalAsync(
|
||||
command,
|
||||
agent,
|
||||
request,
|
||||
invocation,
|
||||
state.ToolNamesByCallId,
|
||||
onApproval,
|
||||
runCancellation.Token),
|
||||
(agent, request, invocation) => _userInputCoordinator.RequestUserInputAsync(
|
||||
command,
|
||||
agent,
|
||||
request,
|
||||
invocation,
|
||||
onUserInput,
|
||||
runCancellation.Token),
|
||||
(agent, sessionEvent) =>
|
||||
{
|
||||
state.ObserveSessionEvent(agent, sessionEvent);
|
||||
if (sessionEvent is McpOauthRequiredEvent mcpOauthRequired)
|
||||
{
|
||||
state.EnqueuePendingMcpOauthRequest(
|
||||
_mcpOAuthCoordinator.BuildMcpOauthRequiredEvent(command, agent, mcpOauthRequired));
|
||||
}
|
||||
|
||||
return state.FinalizeCompletedMessages();
|
||||
if (sessionEvent is ExitPlanModeRequestedEvent exitPlanModeRequested)
|
||||
{
|
||||
_exitPlanModeCoordinator.RecordExitPlanModeRequest(command, agent, exitPlanModeRequested);
|
||||
runCancellation.Cancel();
|
||||
}
|
||||
},
|
||||
runCancellation.Token);
|
||||
ConfigureHookLifecycleEventSuppression(state, bundle);
|
||||
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, onEvent)
|
||||
.ConfigureAwait(false);
|
||||
await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false);
|
||||
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
|
||||
if (shouldEndTurn)
|
||||
{
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false);
|
||||
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
|
||||
return state.FinalizeCompletedMessages();
|
||||
}
|
||||
catch (OperationCanceledException) when (runCancellation.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false);
|
||||
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
|
||||
ExitPlanModeRequestedEventDto? exitPlanModeEvent =
|
||||
_exitPlanModeCoordinator.ConsumePendingRequest(command.RequestId);
|
||||
if (exitPlanModeEvent is null || !state.HasPendingExitPlanModeRequest)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
|
||||
await onExitPlanMode(exitPlanModeEvent).ConfigureAwait(false);
|
||||
return state.FinalizeCompletedMessages();
|
||||
}
|
||||
finally
|
||||
{
|
||||
_approvalCoordinator.ClearRequestApprovals(command.RequestId);
|
||||
}
|
||||
}
|
||||
|
||||
internal static void ConfigureHookLifecycleEventSuppression(
|
||||
CopilotTurnExecutionState state,
|
||||
CopilotAgentBundle bundle)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(state);
|
||||
ArgumentNullException.ThrowIfNull(bundle);
|
||||
|
||||
state.SuppressHookLifecycleEvents = !bundle.HasConfiguredHooks;
|
||||
}
|
||||
|
||||
private static async Task EmitPendingEventsAsync(
|
||||
CopilotTurnExecutionState state,
|
||||
Func<SidecarEventDto, Task> onEvent)
|
||||
{
|
||||
foreach (SidecarEventDto pendingEvent in state.DrainPendingEvents())
|
||||
{
|
||||
await onEvent(pendingEvent).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static async Task EmitPendingMcpOauthRequestsAsync(
|
||||
CopilotTurnExecutionState state,
|
||||
Func<McpOauthRequiredEventDto, Task> onMcpOAuthRequired)
|
||||
{
|
||||
foreach (McpOauthRequiredEventDto request in state.DrainPendingMcpOauthRequests())
|
||||
{
|
||||
await onMcpOAuthRequired(request).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
public Task ResolveApprovalAsync(
|
||||
@@ -66,21 +156,36 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
return _approvalCoordinator.ResolveApprovalAsync(command, cancellationToken);
|
||||
}
|
||||
|
||||
public Task ResolveUserInputAsync(
|
||||
ResolveUserInputCommandDto command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return _userInputCoordinator.ResolveUserInputAsync(command, cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<bool> HandleWorkflowEventAsync(
|
||||
RunTurnCommandDto command,
|
||||
WorkflowEvent evt,
|
||||
IReadOnlyList<ChatMessage> inputMessages,
|
||||
CopilotTurnExecutionState state,
|
||||
Func<TurnDeltaEventDto, Task> onDelta,
|
||||
Func<AgentActivityEventDto, Task> onActivity)
|
||||
Func<SidecarEventDto, Task> onEvent)
|
||||
{
|
||||
if (evt is ExecutorInvokedEvent invoked
|
||||
&& AgentIdentityResolver.TryResolveKnownAgentIdentity(
|
||||
if (evt is ExecutorInvokedEvent invoked)
|
||||
{
|
||||
if (AgentIdentityResolver.TryResolveKnownAgentIdentity(
|
||||
command.Pattern,
|
||||
invoked.ExecutorId,
|
||||
out AgentIdentity invokedAgent))
|
||||
{
|
||||
await state.EmitThinkingIfNeeded(invokedAgent, onActivity).ConfigureAwait(false);
|
||||
{
|
||||
TraceHandoff(command, $"Executor invoked: {invoked.ExecutorId} -> {invokedAgent.AgentName} ({invokedAgent.AgentId}).");
|
||||
await state.EmitThinkingIfNeeded(invokedAgent, onEvent).ConfigureAwait(false);
|
||||
}
|
||||
else
|
||||
{
|
||||
TraceHandoff(command, $"Executor invoked without a known agent match: {invoked.ExecutorId}.");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -94,28 +199,39 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
|
||||
if (activity is null)
|
||||
{
|
||||
return WorkflowRequestInfoInterpreter.RequiresUserInputTurnBoundary(command, requestInfo);
|
||||
bool requiresBoundary = WorkflowRequestInfoInterpreter.RequiresUserInputTurnBoundary(command, requestInfo);
|
||||
TraceHandoff(
|
||||
command,
|
||||
$"Request info produced no activity for data type '{requestInfo.Request.Data.TypeId}'. Requires boundary: {requiresBoundary}.");
|
||||
return requiresBoundary;
|
||||
}
|
||||
|
||||
state.ApplyActivity(activity);
|
||||
await onActivity(activity).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;
|
||||
}
|
||||
|
||||
if (evt is ExecutorCompletedEvent completed
|
||||
&& AgentIdentityResolver.TryResolveObservedAgentIdentity(
|
||||
if (evt is ExecutorCompletedEvent completed)
|
||||
{
|
||||
if (AgentIdentityResolver.TryResolveObservedAgentIdentity(
|
||||
command.Pattern,
|
||||
completed.ExecutorId,
|
||||
state.ActiveAgent,
|
||||
out AgentIdentity completedAgent))
|
||||
{
|
||||
state.ClearActiveAgentIfMatching(completedAgent);
|
||||
{
|
||||
TraceHandoff(command, $"Executor completed: {completed.ExecutorId} -> {completedAgent.AgentName} ({completedAgent.AgentId}).");
|
||||
state.ClearActiveAgentIfMatching(completedAgent);
|
||||
}
|
||||
else
|
||||
{
|
||||
TraceHandoff(command, $"Executor completed without a known agent match: {completed.ExecutorId}.");
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -133,10 +249,16 @@ 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;
|
||||
string[] handoffFunctionCalls = update.Update.Contents
|
||||
.OfType<FunctionCallContent>()
|
||||
.Select(content => content.Name)
|
||||
.Where(IsHandoffFunctionName)
|
||||
.Distinct(StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
if (state.TryResolveObservedAgentForMessage(update.Update.MessageId, out AgentIdentity observedMessageAgent))
|
||||
{
|
||||
updateAgent = observedMessageAgent;
|
||||
@@ -154,7 +276,20 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
|
||||
if (updateAgent.HasValue)
|
||||
{
|
||||
await state.EmitThinkingIfNeeded(updateAgent.Value, onActivity).ConfigureAwait(false);
|
||||
if (handoffFunctionCalls.Length > 0)
|
||||
{
|
||||
TraceHandoff(
|
||||
command,
|
||||
$"Agent response update from {updateAgent.Value.AgentName} ({updateAgent.Value.AgentId}) requested handoff via {string.Join(", ", handoffFunctionCalls)}.");
|
||||
}
|
||||
|
||||
await state.EmitThinkingIfNeeded(updateAgent.Value, onEvent).ConfigureAwait(false);
|
||||
}
|
||||
else if (!string.IsNullOrEmpty(update.Update.Text) || handoffFunctionCalls.Length > 0)
|
||||
{
|
||||
TraceHandoff(
|
||||
command,
|
||||
$"Agent response update could not resolve agent for executor '{update.ExecutorId}' and message '{update.Update.MessageId ?? "<none>"}'.");
|
||||
}
|
||||
|
||||
if (string.IsNullOrEmpty(update.Update.Text))
|
||||
@@ -179,4 +314,45 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||
Content = currentContent,
|
||||
}).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static async Task EmitActivityAsync(
|
||||
RunTurnCommandDto command,
|
||||
CopilotTurnExecutionState state,
|
||||
AgentActivityEventDto activity,
|
||||
Func<SidecarEventDto, Task> onEvent)
|
||||
{
|
||||
state.ApplyEvent(activity);
|
||||
TraceHandoff(
|
||||
command,
|
||||
$"Activity emitted: {activity.ActivityType} -> {activity.AgentName ?? activity.AgentId ?? "<unknown>"}.");
|
||||
await onEvent(activity).ConfigureAwait(false);
|
||||
|
||||
if (string.Equals(activity.ActivityType, "handoff", StringComparison.Ordinal)
|
||||
&& !string.IsNullOrWhiteSpace(activity.AgentId)
|
||||
&& !string.IsNullOrWhiteSpace(activity.AgentName))
|
||||
{
|
||||
TraceHandoff(
|
||||
command,
|
||||
$"Promoting handoff target to thinking: {activity.AgentName} ({activity.AgentId}).");
|
||||
await state.EmitThinkingIfNeeded(
|
||||
new AgentIdentity(activity.AgentId, activity.AgentName),
|
||||
onEvent).ConfigureAwait(false);
|
||||
}
|
||||
}
|
||||
|
||||
private static bool IsHandoffFunctionName(string? candidate)
|
||||
{
|
||||
return !string.IsNullOrWhiteSpace(candidate)
|
||||
&& candidate.StartsWith(HandoffFunctionPrefix, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static void TraceHandoff(RunTurnCommandDto command, string message)
|
||||
{
|
||||
if (!string.Equals(command.Pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
Console.Error.WriteLine($"[aryx handoff] {message}");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,220 @@
|
||||
using System.ComponentModel;
|
||||
using System.Diagnostics;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal interface IHookCommandRunner
|
||||
{
|
||||
Task<string?> RunAsync(
|
||||
HookCommandDefinition hook,
|
||||
string inputJson,
|
||||
string projectPath,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
internal sealed class HookCommandRunner : IHookCommandRunner
|
||||
{
|
||||
private const int DefaultTimeoutSeconds = 30;
|
||||
|
||||
public static HookCommandRunner Instance { get; } = new();
|
||||
|
||||
public async Task<string?> RunAsync(
|
||||
HookCommandDefinition hook,
|
||||
string inputJson,
|
||||
string projectPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(hook);
|
||||
ArgumentNullException.ThrowIfNull(inputJson);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(projectPath);
|
||||
|
||||
string? commandText = SelectCommandText(hook);
|
||||
if (commandText is null)
|
||||
{
|
||||
Console.Error.WriteLine("[aryx hooks] Skipping hook because no compatible shell command is configured for this platform.");
|
||||
return null;
|
||||
}
|
||||
|
||||
string workingDirectory = ResolveWorkingDirectory(projectPath, hook.Cwd);
|
||||
ProcessStartInfo startInfo = CreateStartInfo(commandText, workingDirectory);
|
||||
ApplyEnvironment(startInfo, hook.Env);
|
||||
|
||||
using Process process = new()
|
||||
{
|
||||
StartInfo = startInfo,
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
if (!process.Start())
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Failed to start hook command '{commandText}'.");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
catch (Win32Exception exception)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Failed to start hook command '{commandText}': {exception.Message}");
|
||||
return null;
|
||||
}
|
||||
catch (InvalidOperationException exception)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Failed to start hook command '{commandText}': {exception.Message}");
|
||||
return null;
|
||||
}
|
||||
|
||||
Task<string> stdoutTask = process.StandardOutput.ReadToEndAsync();
|
||||
Task<string> stderrTask = process.StandardError.ReadToEndAsync();
|
||||
|
||||
try
|
||||
{
|
||||
await process.StandardInput.WriteAsync(inputJson).ConfigureAwait(false);
|
||||
await process.StandardInput.FlushAsync().ConfigureAwait(false);
|
||||
process.StandardInput.Close();
|
||||
}
|
||||
catch (IOException exception)
|
||||
{
|
||||
TryKillProcess(process);
|
||||
Console.Error.WriteLine($"[aryx hooks] Failed to write hook input for '{commandText}': {exception.Message}");
|
||||
return null;
|
||||
}
|
||||
catch (ObjectDisposedException exception)
|
||||
{
|
||||
TryKillProcess(process);
|
||||
Console.Error.WriteLine($"[aryx hooks] Failed to write hook input for '{commandText}': {exception.Message}");
|
||||
return null;
|
||||
}
|
||||
|
||||
TimeSpan timeout = TimeSpan.FromSeconds(hook.TimeoutSec ?? DefaultTimeoutSeconds);
|
||||
using CancellationTokenSource timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
timeoutCts.CancelAfter(timeout);
|
||||
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(timeoutCts.Token).ConfigureAwait(false);
|
||||
}
|
||||
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
|
||||
{
|
||||
TryKillProcess(process);
|
||||
await DrainOutputAsync(process, stdoutTask, stderrTask).ConfigureAwait(false);
|
||||
Console.Error.WriteLine($"[aryx hooks] Hook command timed out after {(int)timeout.TotalSeconds} seconds: '{commandText}'.");
|
||||
return null;
|
||||
}
|
||||
|
||||
string stdout = await stdoutTask.ConfigureAwait(false);
|
||||
string stderr = await stderrTask.ConfigureAwait(false);
|
||||
if (process.ExitCode != 0)
|
||||
{
|
||||
string detail = string.IsNullOrWhiteSpace(stderr) ? $"exit code {process.ExitCode}" : stderr.Trim();
|
||||
Console.Error.WriteLine($"[aryx hooks] Hook command failed for '{commandText}': {detail}");
|
||||
return null;
|
||||
}
|
||||
|
||||
return stdout;
|
||||
}
|
||||
|
||||
private static async Task DrainOutputAsync(Process process, Task<string> stdoutTask, Task<string> stderrTask)
|
||||
{
|
||||
try
|
||||
{
|
||||
await process.WaitForExitAsync(CancellationToken.None).ConfigureAwait(false);
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Process already exited or could not be waited on.
|
||||
}
|
||||
|
||||
await Task.WhenAll(stdoutTask, stderrTask).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private static ProcessStartInfo CreateStartInfo(string commandText, string workingDirectory)
|
||||
{
|
||||
ProcessStartInfo startInfo = new()
|
||||
{
|
||||
WorkingDirectory = workingDirectory,
|
||||
RedirectStandardInput = true,
|
||||
RedirectStandardOutput = true,
|
||||
RedirectStandardError = true,
|
||||
UseShellExecute = false,
|
||||
CreateNoWindow = true,
|
||||
};
|
||||
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
startInfo.FileName = "powershell.exe";
|
||||
startInfo.ArgumentList.Add("-NoLogo");
|
||||
startInfo.ArgumentList.Add("-NoProfile");
|
||||
startInfo.ArgumentList.Add("-NonInteractive");
|
||||
startInfo.ArgumentList.Add("-ExecutionPolicy");
|
||||
startInfo.ArgumentList.Add("Bypass");
|
||||
startInfo.ArgumentList.Add("-Command");
|
||||
startInfo.ArgumentList.Add(commandText);
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
startInfo.FileName = "bash";
|
||||
startInfo.ArgumentList.Add("-lc");
|
||||
startInfo.ArgumentList.Add(commandText);
|
||||
return startInfo;
|
||||
}
|
||||
|
||||
private static void ApplyEnvironment(ProcessStartInfo startInfo, IReadOnlyDictionary<string, string>? environment)
|
||||
{
|
||||
if (environment is not { Count: > 0 })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ((string key, string value) in environment)
|
||||
{
|
||||
startInfo.Environment[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
private static string ResolveWorkingDirectory(string projectPath, string? configuredCwd)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(configuredCwd))
|
||||
{
|
||||
return Path.GetFullPath(projectPath);
|
||||
}
|
||||
|
||||
string resolved = Path.IsPathRooted(configuredCwd)
|
||||
? configuredCwd
|
||||
: Path.Combine(projectPath, configuredCwd);
|
||||
|
||||
return Path.GetFullPath(resolved);
|
||||
}
|
||||
|
||||
private static string? SelectCommandText(HookCommandDefinition hook)
|
||||
{
|
||||
if (OperatingSystem.IsWindows())
|
||||
{
|
||||
return NormalizeOptionalString(hook.PowerShell);
|
||||
}
|
||||
|
||||
return NormalizeOptionalString(hook.Bash);
|
||||
}
|
||||
|
||||
private static void TryKillProcess(Process process)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (!process.HasExited)
|
||||
{
|
||||
process.Kill(entireProcessTree: true);
|
||||
}
|
||||
}
|
||||
catch (InvalidOperationException)
|
||||
{
|
||||
// Process already exited.
|
||||
}
|
||||
catch (NotSupportedException)
|
||||
{
|
||||
// The platform does not support process tree termination.
|
||||
}
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
@@ -0,0 +1,207 @@
|
||||
using System.Text.Json;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
|
||||
internal static class HookConfigLoader
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
AllowTrailingCommas = true,
|
||||
PropertyNameCaseInsensitive = true,
|
||||
ReadCommentHandling = JsonCommentHandling.Skip,
|
||||
};
|
||||
|
||||
public static async Task<ResolvedHookSet> LoadAsync(string projectPath, CancellationToken cancellationToken)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(projectPath);
|
||||
|
||||
string hooksDirectory = Path.Combine(projectPath, ".github", "hooks");
|
||||
if (!Directory.Exists(hooksDirectory))
|
||||
{
|
||||
return ResolvedHookSet.Empty;
|
||||
}
|
||||
|
||||
string[] hookFiles;
|
||||
try
|
||||
{
|
||||
hookFiles = Directory.GetFiles(hooksDirectory, "*.json", SearchOption.TopDirectoryOnly);
|
||||
}
|
||||
catch (IOException exception)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Failed to enumerate hook files in '{hooksDirectory}': {exception.Message}");
|
||||
return ResolvedHookSet.Empty;
|
||||
}
|
||||
catch (UnauthorizedAccessException exception)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Failed to enumerate hook files in '{hooksDirectory}': {exception.Message}");
|
||||
return ResolvedHookSet.Empty;
|
||||
}
|
||||
|
||||
if (hookFiles.Length == 0)
|
||||
{
|
||||
return ResolvedHookSet.Empty;
|
||||
}
|
||||
|
||||
Array.Sort(hookFiles, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
List<HookCommandDefinition> sessionStart = [];
|
||||
List<HookCommandDefinition> sessionEnd = [];
|
||||
List<HookCommandDefinition> userPromptSubmitted = [];
|
||||
List<HookCommandDefinition> preToolUse = [];
|
||||
List<HookCommandDefinition> postToolUse = [];
|
||||
List<HookCommandDefinition> errorOccurred = [];
|
||||
|
||||
foreach (string hookFile in hookFiles)
|
||||
{
|
||||
HookConfigFile? config = await ReadHookConfigAsync(hookFile, cancellationToken).ConfigureAwait(false);
|
||||
if (config is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (config.Version != 1)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Skipping '{hookFile}' because it declares unsupported version '{config.Version}'.");
|
||||
continue;
|
||||
}
|
||||
|
||||
AddHooks(sessionStart, config.Hooks.SessionStart, HookTypeNames.SessionStart, hookFile);
|
||||
AddHooks(sessionEnd, config.Hooks.SessionEnd, HookTypeNames.SessionEnd, hookFile);
|
||||
AddHooks(userPromptSubmitted, config.Hooks.UserPromptSubmitted, HookTypeNames.UserPromptSubmitted, hookFile);
|
||||
AddHooks(preToolUse, config.Hooks.PreToolUse, HookTypeNames.PreToolUse, hookFile);
|
||||
AddHooks(postToolUse, config.Hooks.PostToolUse, HookTypeNames.PostToolUse, hookFile);
|
||||
AddHooks(errorOccurred, config.Hooks.ErrorOccurred, HookTypeNames.ErrorOccurred, hookFile);
|
||||
}
|
||||
|
||||
if (
|
||||
sessionStart.Count == 0
|
||||
&& sessionEnd.Count == 0
|
||||
&& userPromptSubmitted.Count == 0
|
||||
&& preToolUse.Count == 0
|
||||
&& postToolUse.Count == 0
|
||||
&& errorOccurred.Count == 0)
|
||||
{
|
||||
return ResolvedHookSet.Empty;
|
||||
}
|
||||
|
||||
return new ResolvedHookSet
|
||||
{
|
||||
SessionStart = [.. sessionStart],
|
||||
SessionEnd = [.. sessionEnd],
|
||||
UserPromptSubmitted = [.. userPromptSubmitted],
|
||||
PreToolUse = [.. preToolUse],
|
||||
PostToolUse = [.. postToolUse],
|
||||
ErrorOccurred = [.. errorOccurred],
|
||||
};
|
||||
}
|
||||
|
||||
private static void AddHooks(
|
||||
ICollection<HookCommandDefinition> target,
|
||||
IReadOnlyList<HookCommandDefinition>? definitions,
|
||||
string hookType,
|
||||
string hookFile)
|
||||
{
|
||||
if (definitions is not { Count: > 0 })
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
foreach (HookCommandDefinition definition in definitions)
|
||||
{
|
||||
HookCommandDefinition? normalized = NormalizeDefinition(definition, hookType, hookFile);
|
||||
if (normalized is not null)
|
||||
{
|
||||
target.Add(normalized);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static HookCommandDefinition? NormalizeDefinition(
|
||||
HookCommandDefinition definition,
|
||||
string hookType,
|
||||
string hookFile)
|
||||
{
|
||||
string type = NormalizeOptionalString(definition.Type) ?? string.Empty;
|
||||
if (!string.Equals(type, "command", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Skipping '{hookType}' entry in '{hookFile}' because type '{definition.Type}' is unsupported.");
|
||||
return null;
|
||||
}
|
||||
|
||||
string? bash = NormalizeOptionalString(definition.Bash);
|
||||
string? powerShell = NormalizeOptionalString(definition.PowerShell);
|
||||
if (bash is null && powerShell is null)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Skipping '{hookType}' entry in '{hookFile}' because no shell command is configured.");
|
||||
return null;
|
||||
}
|
||||
|
||||
int? timeoutSec = definition.TimeoutSec;
|
||||
if (timeoutSec is <= 0)
|
||||
{
|
||||
timeoutSec = null;
|
||||
}
|
||||
|
||||
IReadOnlyDictionary<string, string>? env = NormalizeEnvironment(definition.Env);
|
||||
|
||||
return new HookCommandDefinition
|
||||
{
|
||||
Type = "command",
|
||||
Bash = bash,
|
||||
PowerShell = powerShell,
|
||||
Cwd = NormalizeOptionalString(definition.Cwd),
|
||||
Env = env,
|
||||
TimeoutSec = timeoutSec,
|
||||
};
|
||||
}
|
||||
|
||||
private static async Task<HookConfigFile?> ReadHookConfigAsync(string hookFile, CancellationToken cancellationToken)
|
||||
{
|
||||
try
|
||||
{
|
||||
await using FileStream stream = File.OpenRead(hookFile);
|
||||
return await JsonSerializer.DeserializeAsync<HookConfigFile>(stream, JsonOptions, cancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
catch (JsonException exception)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Failed to parse '{hookFile}': {exception.Message}");
|
||||
return null;
|
||||
}
|
||||
catch (IOException exception)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Failed to read '{hookFile}': {exception.Message}");
|
||||
return null;
|
||||
}
|
||||
catch (UnauthorizedAccessException exception)
|
||||
{
|
||||
Console.Error.WriteLine($"[aryx hooks] Failed to read '{hookFile}': {exception.Message}");
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private static IReadOnlyDictionary<string, string>? NormalizeEnvironment(IReadOnlyDictionary<string, string>? environment)
|
||||
{
|
||||
if (environment is not { Count: > 0 })
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
Dictionary<string, string> normalized = new(StringComparer.Ordinal);
|
||||
foreach ((string key, string value) in environment)
|
||||
{
|
||||
string? normalizedKey = NormalizeOptionalString(key);
|
||||
if (normalizedKey is null)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
normalized[normalizedKey] = value;
|
||||
}
|
||||
|
||||
return normalized.Count == 0 ? null : normalized;
|
||||
}
|
||||
|
||||
private static string? NormalizeOptionalString(string? value)
|
||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||
}
|
||||
@@ -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,11 +7,18 @@ 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,
|
||||
Func<ExitPlanModeRequestedEventDto, Task> onExitPlanMode,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task ResolveApprovalAsync(
|
||||
ResolveApprovalCommandDto command,
|
||||
CancellationToken cancellationToken);
|
||||
|
||||
Task ResolveUserInputAsync(
|
||||
ResolveUserInputCommandDto command,
|
||||
CancellationToken cancellationToken);
|
||||
}
|
||||
|
||||
@@ -14,6 +14,17 @@ public sealed class SidecarProtocolHost
|
||||
private const string RunTurnCommandType = "run-turn";
|
||||
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)
|
||||
{
|
||||
AskUserToolName,
|
||||
"report_intent",
|
||||
"task_complete",
|
||||
};
|
||||
|
||||
private static readonly string[] AuthenticationErrorIndicators =
|
||||
[
|
||||
@@ -31,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())
|
||||
@@ -45,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,
|
||||
@@ -62,6 +78,10 @@ public sealed class SidecarProtocolHost
|
||||
[RunTurnCommandType] = HandleRunTurnAsync,
|
||||
[CancelTurnCommandType] = HandleCancelTurnAsync,
|
||||
[ResolveApprovalCommandType] = HandleResolveApprovalAsync,
|
||||
[ResolveUserInputCommandType] = HandleResolveUserInputAsync,
|
||||
[ListSessionsCommandType] = HandleListSessionsAsync,
|
||||
[DeleteSessionCommandType] = HandleDeleteSessionAsync,
|
||||
[DisconnectSessionCommandType] = HandleDisconnectSessionAsync,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -171,13 +191,17 @@ 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),
|
||||
exitPlanMode => WriteAsync(context.Output, exitPlanMode, turnCancellation.Token),
|
||||
turnCancellation.Token)
|
||||
.ConfigureAwait(false);
|
||||
|
||||
@@ -204,6 +228,7 @@ public sealed class SidecarProtocolHost
|
||||
finally
|
||||
{
|
||||
_turnCancellations.TryRemove(context.Envelope.RequestId, out _);
|
||||
UnregisterTurnRequest(command.SessionId, context.Envelope.RequestId);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +256,63 @@ public sealed class SidecarProtocolHost
|
||||
await _workflowRunner.ResolveApprovalAsync(command, context.CancellationToken).ConfigureAwait(false);
|
||||
}
|
||||
|
||||
private async Task HandleResolveUserInputAsync(CommandContext context)
|
||||
{
|
||||
ResolveUserInputCommandDto command = DeserializeCommand<ResolveUserInputCommandDto>(context);
|
||||
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
|
||||
{
|
||||
@@ -291,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
|
||||
@@ -427,7 +570,13 @@ public sealed class SidecarProtocolHost
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ToolsListResult result = await client.Rpc.Tools.ListAsync(null!, cancellationToken).ConfigureAwait(false);
|
||||
return result.Tools
|
||||
return MapRuntimeTools(result.Tools);
|
||||
}
|
||||
|
||||
internal static IReadOnlyList<SidecarRuntimeToolDto> MapRuntimeTools(IEnumerable<Tool> tools)
|
||||
{
|
||||
return tools
|
||||
.Where(ShouldIncludeRuntimeTool)
|
||||
.Where(tool => !string.IsNullOrWhiteSpace(tool.Name))
|
||||
.Select(tool => new SidecarRuntimeToolDto
|
||||
{
|
||||
@@ -440,6 +589,13 @@ public sealed class SidecarProtocolHost
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static bool ShouldIncludeRuntimeTool(Tool tool)
|
||||
{
|
||||
string? toolName = string.IsNullOrWhiteSpace(tool.Name) ? null : tool.Name.Trim();
|
||||
return toolName is not null
|
||||
&& !ExcludedRuntimeToolNames.Contains(toolName);
|
||||
}
|
||||
|
||||
private static bool IsReasoningEffort(string? value)
|
||||
{
|
||||
return value is "low" or "medium" or "high" or "xhigh";
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System.Text;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Services;
|
||||
@@ -26,9 +27,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,
|
||||
@@ -64,7 +86,7 @@ internal static class WorkflowTranscriptProjector
|
||||
assistantMessages.Count - messageIndex,
|
||||
command.Pattern,
|
||||
fallbackAgent);
|
||||
string content = message.Text ?? matchedSegment?.Content ?? string.Empty;
|
||||
string content = ResolveProjectedContent(message, matchedSegment);
|
||||
if (string.IsNullOrWhiteSpace(content))
|
||||
{
|
||||
continue;
|
||||
@@ -106,7 +128,9 @@ internal static class WorkflowTranscriptProjector
|
||||
{
|
||||
return new ChatMessageDto
|
||||
{
|
||||
Id = matchedSegment?.MessageId ?? $"{command.RequestId}-final-{fallbackOutputIndex}",
|
||||
Id = matchedSegment?.MessageId
|
||||
?? message.MessageId
|
||||
?? $"{command.RequestId}-final-{fallbackOutputIndex}",
|
||||
Role = message.Role == ChatRole.System ? "system" : "assistant",
|
||||
AuthorName = ResolveProjectedAuthorName(
|
||||
command.Pattern,
|
||||
@@ -118,6 +142,35 @@ internal static class WorkflowTranscriptProjector
|
||||
};
|
||||
}
|
||||
|
||||
private static string ResolveProjectedContent(
|
||||
ChatMessage message,
|
||||
TranscriptSegment? matchedSegment)
|
||||
{
|
||||
return FirstNonBlank(
|
||||
message.Text,
|
||||
matchedSegment?.Content,
|
||||
TryGetAssistantMessageContent(message))
|
||||
?? string.Empty;
|
||||
}
|
||||
|
||||
private static string? TryGetAssistantMessageContent(ChatMessage message)
|
||||
{
|
||||
if (TryGetAssistantMessageData(message.RawRepresentation, out AssistantMessageData? assistantMessageData))
|
||||
{
|
||||
return assistantMessageData?.Content;
|
||||
}
|
||||
|
||||
foreach (AIContent content in message.Contents)
|
||||
{
|
||||
if (TryGetAssistantMessageData(content.RawRepresentation, out assistantMessageData))
|
||||
{
|
||||
return assistantMessageData?.Content;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private static ChatMessageDto CreateProjectedMessageFromSegment(
|
||||
RunTurnCommandDto command,
|
||||
TranscriptSegment segment,
|
||||
@@ -339,11 +392,57 @@ internal static class WorkflowTranscriptProjector
|
||||
return fallbackAgent.Value.AgentName;
|
||||
}
|
||||
|
||||
if (fallbackAgent.HasValue
|
||||
&& string.IsNullOrWhiteSpace(primaryIdentifier)
|
||||
&& string.IsNullOrWhiteSpace(fallbackIdentifier))
|
||||
{
|
||||
return fallbackAgent.Value.AgentName;
|
||||
}
|
||||
|
||||
if (pattern.Agents.Count == 1
|
||||
&& string.IsNullOrWhiteSpace(primaryIdentifier)
|
||||
&& string.IsNullOrWhiteSpace(fallbackIdentifier))
|
||||
{
|
||||
PatternAgentDefinitionDto singleAgent = pattern.Agents[0];
|
||||
return AgentIdentityResolver.ResolveDisplayAuthorName(pattern, singleAgent.Id, singleAgent.Name);
|
||||
}
|
||||
|
||||
return AgentIdentityResolver.ResolveDisplayAuthorName(
|
||||
pattern,
|
||||
primaryIdentifier,
|
||||
fallbackIdentifier);
|
||||
}
|
||||
|
||||
private static bool TryGetAssistantMessageData(
|
||||
object? rawRepresentation,
|
||||
out AssistantMessageData? assistantMessageData)
|
||||
{
|
||||
switch (rawRepresentation)
|
||||
{
|
||||
case AssistantMessageEvent assistantMessage when !string.IsNullOrWhiteSpace(assistantMessage.Data.Content):
|
||||
assistantMessageData = assistantMessage.Data;
|
||||
return true;
|
||||
case AssistantMessageData data when !string.IsNullOrWhiteSpace(data.Content):
|
||||
assistantMessageData = data;
|
||||
return true;
|
||||
default:
|
||||
assistantMessageData = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
private static string? FirstNonBlank(params string?[] values)
|
||||
{
|
||||
foreach (string? value in values)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(value))
|
||||
{
|
||||
return value;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
internal sealed class StreamingTranscriptBuffer
|
||||
|
||||
@@ -124,6 +124,33 @@ public sealed class AgentInstructionComposerTests
|
||||
Assert.DoesNotContain("Do not inspect, modify, create, or delete files", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Compose_AddsPlanModeGuidanceWhenRequested()
|
||||
{
|
||||
PatternDefinitionDto pattern = new()
|
||||
{
|
||||
Id = "pattern-single",
|
||||
Name = "Single",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
};
|
||||
PatternAgentDefinitionDto agent = CreateAgent(
|
||||
id: "agent-primary",
|
||||
name: "Primary Agent",
|
||||
instructions: "You are a helpful assistant.");
|
||||
|
||||
string instructions = AgentInstructionComposer.Compose(
|
||||
pattern,
|
||||
agent,
|
||||
agentIndex: 0,
|
||||
interactionMode: "plan");
|
||||
|
||||
Assert.Contains("operating in plan mode", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("produce a concrete implementation plan", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("exit_plan_mode", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
Assert.Contains("Do not continue into implementation", instructions, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
|
||||
private static PatternAgentDefinitionDto CreateAgent(string id, string name, string instructions)
|
||||
{
|
||||
return new PatternAgentDefinitionDto
|
||||
|
||||
@@ -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,6 +1,9 @@
|
||||
using System.Reflection;
|
||||
using Aryx.AgentHost.Services;
|
||||
using System.Text.Json;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Aryx.AgentHost.Services;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
@@ -50,6 +53,261 @@ public sealed class CopilotAgentBundleTests
|
||||
Assert.Equal(["glob", "view"], sessionConfig.AvailableTools);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void Constructor_StoresWhetherHooksAreConfigured()
|
||||
{
|
||||
CopilotAgentBundle bundle = new([], hasConfiguredHooks: true);
|
||||
|
||||
Assert.True(bundle.HasConfiguredHooks);
|
||||
Assert.Empty(bundle.Agents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CreateConfiguredSessionConfig_MergesInstructionsAndConvertsHandoffDeclarations()
|
||||
{
|
||||
SessionConfig baseConfig = new()
|
||||
{
|
||||
Model = "gpt-5.4",
|
||||
SystemMessage = new SystemMessageConfig
|
||||
{
|
||||
Content = "Base instructions",
|
||||
},
|
||||
Tools = [CreateTool()],
|
||||
};
|
||||
ChatClientAgentRunOptions options = new(new ChatOptions
|
||||
{
|
||||
Instructions = "Workflow handoff instructions",
|
||||
Tools = [CreateHandoffDeclaration()],
|
||||
});
|
||||
|
||||
SessionConfig effective = AryxCopilotAgent.CreateConfiguredSessionConfig(baseConfig, options);
|
||||
|
||||
Assert.Equal("gpt-5.4", effective.Model);
|
||||
Assert.Equal("Base instructions\n\nWorkflow handoff instructions", effective.SystemMessage?.Content);
|
||||
Assert.Equal("Base instructions", baseConfig.SystemMessage?.Content);
|
||||
|
||||
AIFunction[] tools = Assert.IsAssignableFrom<IEnumerable<AIFunction>>(effective.Tools).ToArray();
|
||||
Assert.Equal(2, tools.Length);
|
||||
AIFunction handoffTool = Assert.Single(tools, tool => tool.Name == "handoff_to_1");
|
||||
Assert.True(handoffTool.AdditionalProperties.TryGetValue("skip_permission", out object? skipPermission));
|
||||
Assert.Equal(true, skipPermission);
|
||||
|
||||
object? result = await handoffTool.InvokeAsync(new AIFunctionArguments
|
||||
{
|
||||
["reasonForHandoff"] = "UI specialist",
|
||||
});
|
||||
|
||||
Assert.Equal("Transferred.", result?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void CreateConfiguredSessionConfig_RejectsUnsupportedRuntimeDeclarations()
|
||||
{
|
||||
ChatClientAgentRunOptions options = new(new ChatOptions
|
||||
{
|
||||
Tools = [AIFunctionFactory.CreateDeclaration("route_elsewhere", "Unsupported declaration", CreateTool().JsonSchema)],
|
||||
});
|
||||
|
||||
Assert.Throws<NotSupportedException>(() => AryxCopilotAgent.CreateConfiguredSessionConfig(new SessionConfig(), options));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToolRequestsToFunctionCalls_MapsCallIdsNamesAndArguments()
|
||||
{
|
||||
AssistantMessageDataToolRequestsItem[] toolRequests =
|
||||
{
|
||||
new()
|
||||
{
|
||||
ToolCallId = "call-123",
|
||||
Name = "handoff_to_1",
|
||||
Arguments = JsonSerializer.SerializeToElement(new Dictionary<string, object?>
|
||||
{
|
||||
["reasonForHandoff"] = "frontend specialist",
|
||||
}),
|
||||
},
|
||||
};
|
||||
|
||||
FunctionCallContent functionCall = Assert.Single(AryxCopilotAgent.ConvertToolRequestsToFunctionCalls(toolRequests));
|
||||
|
||||
Assert.Equal("call-123", functionCall.CallId);
|
||||
Assert.Equal("handoff_to_1", functionCall.Name);
|
||||
Assert.NotNull(functionCall.Arguments);
|
||||
Assert.Equal("frontend specialist", functionCall.Arguments["reasonForHandoff"]?.ToString());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ConvertToolRequestsToFunctionCalls_SkipsNonHandoffToolCalls()
|
||||
{
|
||||
AssistantMessageDataToolRequestsItem[] toolRequests =
|
||||
{
|
||||
new() { ToolCallId = "call-001", Name = "ask_user" },
|
||||
new() { ToolCallId = "call-002", Name = "web_fetch" },
|
||||
new() { ToolCallId = "call-003", Name = "handoff_to_reviewer" },
|
||||
new() { ToolCallId = "call-004", Name = "grep" },
|
||||
};
|
||||
|
||||
IReadOnlyList<FunctionCallContent> result = AryxCopilotAgent.ConvertToolRequestsToFunctionCalls(toolRequests);
|
||||
|
||||
FunctionCallContent single = Assert.Single(result);
|
||||
Assert.Equal("call-003", single.CallId);
|
||||
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 void CreateSessionConfig_DoesNotForceSessionId()
|
||||
{
|
||||
RunTurnCommandDto command = new()
|
||||
{
|
||||
SessionId = "session-1",
|
||||
ProjectPath = @"C:\workspace\project",
|
||||
WorkspaceKind = "project",
|
||||
Mode = "interactive",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
Name = "Pattern",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
new PatternAgentDefinitionDto
|
||||
{
|
||||
Id = "agent-1",
|
||||
Name = "Primary",
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help.",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
SessionConfig sessionConfig = CopilotAgentBundle.CreateSessionConfig(
|
||||
command,
|
||||
command.Pattern.Agents[0],
|
||||
agentIndex: 0);
|
||||
|
||||
Assert.Null(sessionConfig.SessionId);
|
||||
Assert.Equal(@"C:\workspace\project", sessionConfig.WorkingDirectory);
|
||||
Assert.True(sessionConfig.Streaming);
|
||||
Assert.NotNull(sessionConfig.Hooks);
|
||||
}
|
||||
|
||||
[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();
|
||||
@@ -66,6 +324,14 @@ public sealed class CopilotAgentBundleTests
|
||||
});
|
||||
}
|
||||
|
||||
private static AIFunctionDeclaration CreateHandoffDeclaration()
|
||||
{
|
||||
return AIFunctionFactory.CreateDeclaration(
|
||||
"handoff_to_1",
|
||||
"Transfer ownership to a specialist",
|
||||
CreateTool().JsonSchema);
|
||||
}
|
||||
|
||||
private sealed class ToolTarget
|
||||
{
|
||||
public string Echo() => "ok";
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
using GitHub.Copilot.SDK;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
|
||||
public sealed class CopilotExitPlanModeCoordinatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void RecordExitPlanModeRequest_BuildsEventAndMakesItConsumable()
|
||||
{
|
||||
CopilotExitPlanModeCoordinator coordinator = new();
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
|
||||
ExitPlanModeRequestedEventDto exitPlanEvent = coordinator.RecordExitPlanModeRequest(
|
||||
command,
|
||||
command.Pattern.Agents[0],
|
||||
new ExitPlanModeRequestedEvent
|
||||
{
|
||||
Data = new ExitPlanModeRequestedData
|
||||
{
|
||||
RequestId = "exit-plan-1",
|
||||
Summary = "Proposed plan",
|
||||
PlanContent = "1. Investigate\n2. Implement",
|
||||
Actions = ["interactive", "autopilot"],
|
||||
RecommendedAction = "interactive",
|
||||
},
|
||||
});
|
||||
|
||||
Assert.Equal("exit-plan-mode-requested", exitPlanEvent.Type);
|
||||
Assert.Equal("turn-1", exitPlanEvent.RequestId);
|
||||
Assert.Equal("session-1", exitPlanEvent.SessionId);
|
||||
Assert.Equal("exit-plan-1", exitPlanEvent.ExitPlanId);
|
||||
Assert.Equal("agent-1", exitPlanEvent.AgentId);
|
||||
Assert.Equal("Primary", exitPlanEvent.AgentName);
|
||||
Assert.Equal("Proposed plan", exitPlanEvent.Summary);
|
||||
Assert.Equal("1. Investigate\n2. Implement", exitPlanEvent.PlanContent);
|
||||
Assert.Equal(["interactive", "autopilot"], exitPlanEvent.Actions);
|
||||
Assert.Equal("interactive", exitPlanEvent.RecommendedAction);
|
||||
|
||||
ExitPlanModeRequestedEventDto? consumed = coordinator.ConsumePendingRequest(command.RequestId);
|
||||
Assert.NotNull(consumed);
|
||||
Assert.Equal("exit-plan-1", consumed!.ExitPlanId);
|
||||
Assert.Null(coordinator.ConsumePendingRequest(command.RequestId));
|
||||
}
|
||||
|
||||
private static RunTurnCommandDto CreateCommand()
|
||||
{
|
||||
return new RunTurnCommandDto
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
Name = "Plan Mode Pattern",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
new PatternAgentDefinitionDto
|
||||
{
|
||||
Id = "agent-1",
|
||||
Name = "Primary",
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help with the request.",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
using GitHub.Copilot.SDK;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
|
||||
public sealed class CopilotMcpOAuthCoordinatorTests
|
||||
{
|
||||
[Fact]
|
||||
public void BuildMcpOauthRequiredEvent_MapsSdkEventToProtocolEvent()
|
||||
{
|
||||
CopilotMcpOAuthCoordinator coordinator = new();
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
|
||||
McpOauthRequiredEventDto oauthEvent = coordinator.BuildMcpOauthRequiredEvent(
|
||||
command,
|
||||
command.Pattern.Agents[0],
|
||||
new McpOauthRequiredEvent
|
||||
{
|
||||
Data = new McpOauthRequiredData
|
||||
{
|
||||
RequestId = " oauth-request-1 ",
|
||||
ServerName = " Example MCP ",
|
||||
ServerUrl = " https://example.com/mcp ",
|
||||
StaticClientConfig = new McpOauthRequiredDataStaticClientConfig
|
||||
{
|
||||
ClientId = " aryx-client ",
|
||||
PublicClient = true,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
Assert.Equal("mcp-oauth-required", oauthEvent.Type);
|
||||
Assert.Equal("turn-1", oauthEvent.RequestId);
|
||||
Assert.Equal("session-1", oauthEvent.SessionId);
|
||||
Assert.Equal("oauth-request-1", oauthEvent.OauthRequestId);
|
||||
Assert.Equal("agent-1", oauthEvent.AgentId);
|
||||
Assert.Equal("Primary", oauthEvent.AgentName);
|
||||
Assert.Equal("Example MCP", oauthEvent.ServerName);
|
||||
Assert.Equal("https://example.com/mcp", oauthEvent.ServerUrl);
|
||||
Assert.NotNull(oauthEvent.StaticClientConfig);
|
||||
Assert.Equal("aryx-client", oauthEvent.StaticClientConfig!.ClientId);
|
||||
Assert.True(oauthEvent.StaticClientConfig.PublicClient);
|
||||
}
|
||||
|
||||
private static RunTurnCommandDto CreateCommand()
|
||||
{
|
||||
return new RunTurnCommandDto
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
Name = "MCP OAuth Pattern",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
new PatternAgentDefinitionDto
|
||||
{
|
||||
Id = "agent-1",
|
||||
Name = "Primary",
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help with the request.",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,304 @@
|
||||
using System.Text.Json;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
using GitHub.Copilot.SDK;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
|
||||
public sealed class CopilotSessionHooksTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task Create_FileBasedPreToolUseDenyOverridesApprovalPolicy()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommandWithToolApproval();
|
||||
RecordingHookCommandRunner runner = new(
|
||||
[
|
||||
"""{"permissionDecision":"deny","permissionDecisionReason":"Blocked by repository hook"}""",
|
||||
]);
|
||||
ResolvedHookSet configuredHooks = new()
|
||||
{
|
||||
PreToolUse =
|
||||
[
|
||||
CreateHookCommand("deny-pre-tool"),
|
||||
],
|
||||
};
|
||||
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], configuredHooks, runner);
|
||||
|
||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||
new PreToolUseHookInput
|
||||
{
|
||||
Timestamp = 1710000000000,
|
||||
Cwd = command.ProjectPath,
|
||||
ToolName = "view",
|
||||
ToolArgs = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
path = "README.md",
|
||||
}),
|
||||
},
|
||||
null!);
|
||||
|
||||
Assert.Equal("deny", decision?.PermissionDecision);
|
||||
Assert.Equal("Blocked by repository hook", decision?.PermissionDecisionReason);
|
||||
|
||||
RecordedHookInvocation invocation = Assert.Single(runner.Invocations);
|
||||
JsonDocument payload = JsonDocument.Parse(invocation.InputJson);
|
||||
Assert.Equal("view", payload.RootElement.GetProperty("toolName").GetString());
|
||||
Assert.Equal("{\"path\":\"README.md\"}", payload.RootElement.GetProperty("toolArgs").GetString());
|
||||
Assert.Equal(command.ProjectPath, invocation.ProjectPath);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_PreToolUseFallsThroughWhenFileHooksDoNotDeny()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommandWithToolApproval();
|
||||
RecordingHookCommandRunner runner = new(
|
||||
[
|
||||
"""{"permissionDecision":"allow"}""",
|
||||
]);
|
||||
ResolvedHookSet configuredHooks = new()
|
||||
{
|
||||
PreToolUse =
|
||||
[
|
||||
CreateHookCommand("allow-pre-tool"),
|
||||
],
|
||||
};
|
||||
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], configuredHooks, runner);
|
||||
|
||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||
new PreToolUseHookInput
|
||||
{
|
||||
ToolName = "view",
|
||||
},
|
||||
null!);
|
||||
|
||||
Assert.Equal("ask", decision?.PermissionDecision);
|
||||
Assert.Single(runner.Invocations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_PreToolUseIgnoresInvalidHookOutputAndFallsThrough()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommandWithToolApproval();
|
||||
RecordingHookCommandRunner runner = new(
|
||||
[
|
||||
"not-json",
|
||||
]);
|
||||
ResolvedHookSet configuredHooks = new()
|
||||
{
|
||||
PreToolUse =
|
||||
[
|
||||
CreateHookCommand("invalid-pre-tool"),
|
||||
],
|
||||
};
|
||||
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], configuredHooks, runner);
|
||||
|
||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||
new PreToolUseHookInput
|
||||
{
|
||||
ToolName = "view",
|
||||
},
|
||||
null!);
|
||||
|
||||
Assert.Equal("ask", decision?.PermissionDecision);
|
||||
Assert.Single(runner.Invocations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_RunsConfiguredNonPreToolHooks()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommandWithoutApprovalRules();
|
||||
RecordingHookCommandRunner runner = new();
|
||||
ResolvedHookSet configuredHooks = new()
|
||||
{
|
||||
SessionStart = [CreateHookCommand("session-start-hook")],
|
||||
UserPromptSubmitted = [CreateHookCommand("prompt-hook")],
|
||||
PostToolUse = [CreateHookCommand("post-tool-hook")],
|
||||
SessionEnd = [CreateHookCommand("session-end-hook")],
|
||||
ErrorOccurred = [CreateHookCommand("error-hook")],
|
||||
};
|
||||
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], configuredHooks, runner);
|
||||
|
||||
await hooks.OnSessionStart!(
|
||||
new SessionStartHookInput
|
||||
{
|
||||
Timestamp = 1,
|
||||
Cwd = command.ProjectPath,
|
||||
Source = "new",
|
||||
InitialPrompt = "Create the feature",
|
||||
},
|
||||
null!);
|
||||
await hooks.OnUserPromptSubmitted!(
|
||||
new UserPromptSubmittedHookInput
|
||||
{
|
||||
Timestamp = 2,
|
||||
Cwd = command.ProjectPath,
|
||||
Prompt = "Refactor the API",
|
||||
},
|
||||
null!);
|
||||
await hooks.OnPostToolUse!(
|
||||
new PostToolUseHookInput
|
||||
{
|
||||
Timestamp = 3,
|
||||
Cwd = command.ProjectPath,
|
||||
ToolName = "view",
|
||||
ToolArgs = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
path = "README.md",
|
||||
}),
|
||||
ToolResult = JsonSerializer.SerializeToElement(new
|
||||
{
|
||||
resultType = "success",
|
||||
textResultForLlm = "Read 1 file",
|
||||
}),
|
||||
},
|
||||
null!);
|
||||
await hooks.OnSessionEnd!(
|
||||
new SessionEndHookInput
|
||||
{
|
||||
Timestamp = 4,
|
||||
Cwd = command.ProjectPath,
|
||||
Reason = "complete",
|
||||
FinalMessage = "Done",
|
||||
},
|
||||
null!);
|
||||
await hooks.OnErrorOccurred!(
|
||||
new ErrorOccurredHookInput
|
||||
{
|
||||
Timestamp = 5,
|
||||
Cwd = command.ProjectPath,
|
||||
Error = "Network timeout",
|
||||
ErrorContext = "tool_execution",
|
||||
Recoverable = true,
|
||||
},
|
||||
null!);
|
||||
|
||||
Assert.Equal(
|
||||
["session-start-hook", "prompt-hook", "post-tool-hook", "session-end-hook", "error-hook"],
|
||||
runner.Invocations.Select(invocation => GetCommandText(invocation.Hook)).ToArray());
|
||||
|
||||
JsonDocument postToolPayload = JsonDocument.Parse(runner.Invocations[2].InputJson);
|
||||
Assert.Equal("view", postToolPayload.RootElement.GetProperty("toolName").GetString());
|
||||
Assert.Equal("success", postToolPayload.RootElement.GetProperty("toolResult").GetProperty("resultType").GetString());
|
||||
|
||||
JsonDocument errorPayload = JsonDocument.Parse(runner.Invocations[4].InputJson);
|
||||
Assert.Equal("Network timeout", errorPayload.RootElement.GetProperty("error").GetProperty("message").GetString());
|
||||
Assert.Equal("tool_execution", errorPayload.RootElement.GetProperty("error").GetProperty("context").GetString());
|
||||
Assert.True(errorPayload.RootElement.GetProperty("error").GetProperty("recoverable").GetBoolean());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Create_WithoutConfiguredFileHooksPreservesExistingApprovalBehavior()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommandWithoutApprovalRules();
|
||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
|
||||
|
||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||
new PreToolUseHookInput
|
||||
{
|
||||
ToolName = "view",
|
||||
},
|
||||
null!);
|
||||
|
||||
Assert.Equal("allow", decision?.PermissionDecision);
|
||||
}
|
||||
|
||||
private static RunTurnCommandDto CreateCommandWithToolApproval()
|
||||
{
|
||||
return new RunTurnCommandDto
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
ProjectPath = @"C:\workspace\project",
|
||||
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.",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static RunTurnCommandDto CreateCommandWithoutApprovalRules()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommandWithToolApproval();
|
||||
return new RunTurnCommandDto
|
||||
{
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
ProjectPath = command.ProjectPath,
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = command.Pattern.Id,
|
||||
Name = command.Pattern.Name,
|
||||
Mode = command.Pattern.Mode,
|
||||
Availability = command.Pattern.Availability,
|
||||
ApprovalPolicy = new ApprovalPolicyDto(),
|
||||
Agents = command.Pattern.Agents,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static HookCommandDefinition CreateHookCommand(string name)
|
||||
=> new()
|
||||
{
|
||||
Type = "command",
|
||||
Bash = name,
|
||||
PowerShell = name,
|
||||
};
|
||||
|
||||
private static string GetCommandText(HookCommandDefinition hook)
|
||||
=> hook.PowerShell ?? hook.Bash ?? string.Empty;
|
||||
|
||||
private sealed class RecordingHookCommandRunner : IHookCommandRunner
|
||||
{
|
||||
private readonly Queue<string?> _outputs;
|
||||
|
||||
public List<RecordedHookInvocation> Invocations { get; } = [];
|
||||
|
||||
public RecordingHookCommandRunner(IEnumerable<string?>? outputs = null)
|
||||
{
|
||||
_outputs = outputs is null ? new Queue<string?>() : new Queue<string?>(outputs);
|
||||
}
|
||||
|
||||
public Task<string?> RunAsync(
|
||||
HookCommandDefinition hook,
|
||||
string inputJson,
|
||||
string projectPath,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Invocations.Add(new RecordedHookInvocation(hook, inputJson, projectPath));
|
||||
return Task.FromResult(_outputs.Count > 0 ? _outputs.Dequeue() : string.Empty);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed record RecordedHookInvocation(
|
||||
HookCommandDefinition Hook,
|
||||
string InputJson,
|
||||
string ProjectPath);
|
||||
}
|
||||
@@ -0,0 +1,376 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
using GitHub.Copilot.SDK;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
|
||||
public sealed class CopilotTurnExecutionStateTests
|
||||
{
|
||||
[Fact]
|
||||
public void ObserveSessionEvent_McpOauthRequired_SetsActiveAgent()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
new McpOauthRequiredEvent
|
||||
{
|
||||
Data = new McpOauthRequiredData
|
||||
{
|
||||
RequestId = "oauth-request-1",
|
||||
ServerName = "Example MCP",
|
||||
ServerUrl = "https://example.com/mcp",
|
||||
},
|
||||
});
|
||||
|
||||
Assert.True(state.ActiveAgent.HasValue);
|
||||
Assert.Equal("agent-1", state.ActiveAgent.Value.AgentId);
|
||||
Assert.Equal("Primary", state.ActiveAgent.Value.AgentName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObserveSessionEvent_AssistantMessageDelta_QueuesThinkingActivity()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
"type": "assistant.message_delta",
|
||||
"data": {
|
||||
"messageId": "msg-1",
|
||||
"deltaContent": "Hello"
|
||||
},
|
||||
"id": "11111111-1111-1111-1111-111111111111",
|
||||
"timestamp": "2026-03-27T00:00:00Z"
|
||||
}
|
||||
"""));
|
||||
|
||||
AgentActivityEventDto activity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
|
||||
Assert.Equal("thinking", activity.ActivityType);
|
||||
Assert.Equal("agent-1", activity.AgentId);
|
||||
Assert.Equal("Primary", activity.AgentName);
|
||||
Assert.True(state.TryResolveObservedAgentForMessage("msg-1", out AgentIdentity observedAgent));
|
||||
Assert.Equal("agent-1", observedAgent.AgentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObserveSessionEvent_ToolExecutionStart_TracksToolNameByCallId()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
"type": "tool.execution_start",
|
||||
"data": {
|
||||
"toolCallId": "tool-call-1",
|
||||
"toolName": "view"
|
||||
},
|
||||
"id": "33333333-3333-3333-3333-333333333333",
|
||||
"timestamp": "2026-03-27T00:00:00Z"
|
||||
}
|
||||
"""));
|
||||
|
||||
Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-1", out string? toolName));
|
||||
Assert.Equal("view", toolName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task EmitThinkingIfNeeded_DoesNotDuplicateQueuedThinkingActivity()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(
|
||||
command.Pattern.Agents[0],
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
"type": "assistant.reasoning_delta",
|
||||
"data": {
|
||||
"reasoningId": "reasoning-1",
|
||||
"deltaContent": "Planning"
|
||||
},
|
||||
"id": "22222222-2222-2222-2222-222222222222",
|
||||
"timestamp": "2026-03-27T00:00:00Z"
|
||||
}
|
||||
"""));
|
||||
|
||||
List<AgentActivityEventDto> activities = [.. state.DrainPendingEvents().OfType<AgentActivityEventDto>()];
|
||||
|
||||
await state.EmitThinkingIfNeeded(
|
||||
new AgentIdentity("agent-1", "Primary"),
|
||||
sidecarEvent =>
|
||||
{
|
||||
activities.Add(Assert.IsType<AgentActivityEventDto>(sidecarEvent));
|
||||
return Task.CompletedTask;
|
||||
});
|
||||
|
||||
AgentActivityEventDto thinking = Assert.Single(activities);
|
||||
Assert.Equal("thinking", thinking.ActivityType);
|
||||
Assert.Equal("agent-1", thinking.AgentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void DrainPendingMcpOauthRequests_ReturnsQueuedRequestsAndClearsQueue()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
McpOauthRequiredEventDto request = new()
|
||||
{
|
||||
Type = "mcp-oauth-required",
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
OauthRequestId = "oauth-request-1",
|
||||
ServerName = "Example MCP",
|
||||
ServerUrl = "https://example.com/mcp",
|
||||
};
|
||||
|
||||
state.EnqueuePendingMcpOauthRequest(request);
|
||||
|
||||
IReadOnlyList<McpOauthRequiredEventDto> firstDrain = state.DrainPendingMcpOauthRequests();
|
||||
IReadOnlyList<McpOauthRequiredEventDto> secondDrain = state.DrainPendingMcpOauthRequests();
|
||||
|
||||
McpOauthRequiredEventDto drained = Assert.Single(firstDrain);
|
||||
Assert.Equal("oauth-request-1", drained.OauthRequestId);
|
||||
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], CreateHookStartEvent());
|
||||
|
||||
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_HookEnd_QueuesHookLifecycleEvent()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
|
||||
state.ObserveSessionEvent(command.Pattern.Agents[0], CreateHookEndEvent());
|
||||
|
||||
HookLifecycleEventDto evt = Assert.Single(state.DrainPendingEvents().OfType<HookLifecycleEventDto>());
|
||||
Assert.Equal("end", evt.Phase);
|
||||
Assert.Equal("postToolUse", evt.HookType);
|
||||
Assert.Equal("hook-1", evt.HookInvocationId);
|
||||
Assert.True(evt.Success);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ObserveSessionEvent_HookLifecycleEvents_AreSuppressedWhenConfigured()
|
||||
{
|
||||
RunTurnCommandDto command = CreateCommand();
|
||||
CopilotTurnExecutionState state = new(command)
|
||||
{
|
||||
SuppressHookLifecycleEvents = true,
|
||||
};
|
||||
|
||||
state.ObserveSessionEvent(command.Pattern.Agents[0], CreateHookStartEvent());
|
||||
state.ObserveSessionEvent(command.Pattern.Agents[0], CreateHookEndEvent());
|
||||
|
||||
Assert.Empty(state.DrainPendingEvents());
|
||||
}
|
||||
|
||||
[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 SessionEvent CreateHookStartEvent()
|
||||
{
|
||||
return 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"
|
||||
}
|
||||
""");
|
||||
}
|
||||
|
||||
private static SessionEvent CreateHookEndEvent()
|
||||
{
|
||||
return SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
"type": "hook.end",
|
||||
"data": {
|
||||
"hookInvocationId": "hook-1",
|
||||
"hookType": "postToolUse",
|
||||
"success": true,
|
||||
"output": {
|
||||
"status": "ok"
|
||||
}
|
||||
},
|
||||
"id": "99999999-9999-9999-9999-999999999999",
|
||||
"timestamp": "2026-03-27T00:00:00Z"
|
||||
}
|
||||
""");
|
||||
}
|
||||
|
||||
private static RunTurnCommandDto CreateCommand()
|
||||
{
|
||||
return new RunTurnCommandDto
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
Name = "MCP OAuth Pattern",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
new PatternAgentDefinitionDto
|
||||
{
|
||||
Id = "agent-1",
|
||||
Name = "Primary",
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help with the request.",
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
using GitHub.Copilot.SDK;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
|
||||
public sealed class CopilotUserInputCoordinatorTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RequestUserInputAsync_RaisesUserInputEventAndCompletesAfterResolution()
|
||||
{
|
||||
CopilotUserInputCoordinator coordinator = new();
|
||||
UserInputRequestedEventDto? observedEvent = null;
|
||||
RunTurnCommandDto command = CreateUserInputCommand();
|
||||
|
||||
Task<UserInputResponse> pending = coordinator.RequestUserInputAsync(
|
||||
command,
|
||||
command.Pattern.Agents[0],
|
||||
new UserInputRequest
|
||||
{
|
||||
Question = "How should I proceed?",
|
||||
Choices = ["Continue", "Stop"],
|
||||
AllowFreeform = true,
|
||||
},
|
||||
new UserInputInvocation
|
||||
{
|
||||
SessionId = "copilot-session-1",
|
||||
},
|
||||
userInputEvent =>
|
||||
{
|
||||
observedEvent = userInputEvent;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(pending.IsCompleted);
|
||||
Assert.NotNull(observedEvent);
|
||||
Assert.Equal("user-input-requested", observedEvent!.Type);
|
||||
Assert.Equal("turn-1", observedEvent.RequestId);
|
||||
Assert.Equal("session-1", observedEvent.SessionId);
|
||||
Assert.Equal("agent-1", observedEvent.AgentId);
|
||||
Assert.Equal("Primary", observedEvent.AgentName);
|
||||
Assert.Equal("How should I proceed?", observedEvent.Question);
|
||||
Assert.Equal(["Continue", "Stop"], observedEvent.Choices);
|
||||
Assert.True(observedEvent.AllowFreeform);
|
||||
|
||||
await coordinator.ResolveUserInputAsync(
|
||||
new ResolveUserInputCommandDto
|
||||
{
|
||||
UserInputId = observedEvent.UserInputId,
|
||||
Answer = "Continue",
|
||||
WasFreeform = false,
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
UserInputResponse response = await pending;
|
||||
Assert.Equal("Continue", response.Answer);
|
||||
Assert.False(response.WasFreeform);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveUserInputAsync_RejectsUnknownUserInputIds()
|
||||
{
|
||||
CopilotUserInputCoordinator coordinator = new();
|
||||
|
||||
InvalidOperationException error = await Assert.ThrowsAsync<InvalidOperationException>(() =>
|
||||
coordinator.ResolveUserInputAsync(
|
||||
new ResolveUserInputCommandDto
|
||||
{
|
||||
UserInputId = "user-input-missing",
|
||||
Answer = "Continue",
|
||||
WasFreeform = false,
|
||||
},
|
||||
CancellationToken.None));
|
||||
|
||||
Assert.Contains("is not pending", error.Message);
|
||||
}
|
||||
|
||||
private static PatternAgentDefinitionDto CreateAgent(string id, string name)
|
||||
{
|
||||
return new PatternAgentDefinitionDto
|
||||
{
|
||||
Id = id,
|
||||
Name = name,
|
||||
Model = "gpt-5.4",
|
||||
Instructions = "Help with the request.",
|
||||
};
|
||||
}
|
||||
|
||||
private static RunTurnCommandDto CreateUserInputCommand()
|
||||
{
|
||||
return new RunTurnCommandDto
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
Name = "User Input Pattern",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
CreateAgent("agent-1", "Primary"),
|
||||
],
|
||||
},
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,28 @@
|
||||
using System.Reflection;
|
||||
using System.Runtime.CompilerServices;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
using GitHub.Copilot.SDK;
|
||||
using Microsoft.Agents.AI;
|
||||
using Microsoft.Agents.AI.Workflows;
|
||||
using Microsoft.Extensions.AI;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
|
||||
public sealed class CopilotWorkflowRunnerTests
|
||||
{
|
||||
[Fact]
|
||||
public void ConfigureHookLifecycleEventSuppression_SetsStateFromBundle()
|
||||
{
|
||||
RunTurnCommandDto command = CreateApprovalCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
CopilotAgentBundle bundle = new(Array.Empty<AIAgent>(), hasConfiguredHooks: false);
|
||||
|
||||
CopilotWorkflowRunner.ConfigureHookLifecycleEventSuppression(state, bundle);
|
||||
|
||||
Assert.True(state.SuppressHookLifecycleEvents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void SelectNewOutputMessages_SkipsFullTranscriptPrefix()
|
||||
{
|
||||
@@ -156,6 +172,50 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
Assert.Equal("Hello", message.Content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProjectCompletedMessages_UsesFinalAssistantPayloadWhenStreamingTextIsMissing()
|
||||
{
|
||||
RunTurnCommandDto command = new()
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
SessionId = "session-1",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-single",
|
||||
Name = "Single Agent",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
CreateAgent(id: "agent-single-primary", name: "Primary Agent"),
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
||||
command,
|
||||
[
|
||||
new ChatMessage(ChatRole.Assistant, string.Empty)
|
||||
{
|
||||
MessageId = "msg-1",
|
||||
RawRepresentation = new AssistantMessageEvent
|
||||
{
|
||||
Data = new AssistantMessageData
|
||||
{
|
||||
MessageId = "msg-1",
|
||||
Content = "Hello from the final assistant payload.",
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
[]);
|
||||
|
||||
ChatMessageDto message = Assert.Single(messages);
|
||||
Assert.Equal("msg-1", message.Id);
|
||||
Assert.Equal("Primary Agent", message.AuthorName);
|
||||
Assert.Equal("Hello from the final assistant payload.", message.Content);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void ProjectCompletedMessages_PreservesSequentialConversationHistory()
|
||||
{
|
||||
@@ -645,6 +705,9 @@ 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.DrainPendingEvents().OfType<AgentActivityEventDto>());
|
||||
Assert.Equal("thinking", activity.ActivityType);
|
||||
Assert.Equal("agent-handoff-ux", activity.AgentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -668,6 +731,71 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
|
||||
Assert.Equal("agent-handoff-ux", state.ActiveAgent?.AgentId);
|
||||
Assert.Equal("UX Specialist", state.ActiveAgent?.AgentName);
|
||||
AgentActivityEventDto activity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
|
||||
Assert.Equal("thinking", activity.ActivityType);
|
||||
Assert.Equal("agent-handoff-ux", activity.AgentId);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task HandleWorkflowEventAsync_EmitsThinkingForHandoffTargets()
|
||||
{
|
||||
RunTurnCommandDto command = CreateHandoffCommand();
|
||||
CopilotTurnExecutionState state = new(command);
|
||||
state.ObserveSessionEvent(
|
||||
CreateAgent("agent-handoff-triage", "Triage"),
|
||||
SessionEvent.FromJson(
|
||||
"""
|
||||
{
|
||||
"type": "assistant.reasoning_delta",
|
||||
"data": {
|
||||
"reasoningId": "reasoning-1",
|
||||
"deltaContent": "Delegating."
|
||||
},
|
||||
"id": "33333333-3333-3333-3333-333333333333",
|
||||
"timestamp": "2026-03-27T00:00:00Z"
|
||||
}
|
||||
"""));
|
||||
_ = state.DrainPendingEvents();
|
||||
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
|
||||
CreateHandoffTarget("agent-handoff-ux", "UX Specialist"));
|
||||
List<AgentActivityEventDto> activities = [];
|
||||
|
||||
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
|
||||
"HandleWorkflowEventAsync",
|
||||
BindingFlags.NonPublic | BindingFlags.Static)!;
|
||||
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
|
||||
null,
|
||||
[
|
||||
command,
|
||||
requestInfo,
|
||||
Array.Empty<ChatMessage>(),
|
||||
state,
|
||||
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
|
||||
(Func<SidecarEventDto, Task>)(sidecarEvent =>
|
||||
{
|
||||
activities.Add(Assert.IsType<AgentActivityEventDto>(sidecarEvent));
|
||||
return Task.CompletedTask;
|
||||
}),
|
||||
])!;
|
||||
|
||||
bool shouldEndTurn = await handleTask;
|
||||
|
||||
Assert.False(shouldEndTurn);
|
||||
Assert.Collection(
|
||||
activities,
|
||||
handoff =>
|
||||
{
|
||||
Assert.Equal("handoff", handoff.ActivityType);
|
||||
Assert.Equal("agent-handoff-ux", handoff.AgentId);
|
||||
Assert.Equal("UX Specialist", handoff.AgentName);
|
||||
Assert.Equal("agent-handoff-triage", handoff.SourceAgentId);
|
||||
},
|
||||
thinking =>
|
||||
{
|
||||
Assert.Equal("thinking", thinking.ActivityType);
|
||||
Assert.Equal("agent-handoff-ux", thinking.AgentId);
|
||||
Assert.Equal("UX Specialist", thinking.AgentName);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -694,7 +822,29 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetApprovalToolName_ReadsMcpCustomAndHookRequests()
|
||||
public void RequiresToolCallApproval_HonorsRuntimeApprovalAliases()
|
||||
{
|
||||
ApprovalPolicyDto policy = new()
|
||||
{
|
||||
Rules =
|
||||
[
|
||||
new ApprovalCheckpointRuleDto
|
||||
{
|
||||
Kind = "tool-call",
|
||||
AgentIds = ["agent-1"],
|
||||
},
|
||||
],
|
||||
AutoApprovedToolNames = ["read", "store_memory"],
|
||||
};
|
||||
|
||||
Assert.False(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-1", "view", "read"));
|
||||
Assert.False(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-1", "remember_fact", "store_memory"));
|
||||
Assert.True(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-1", "write_file", "write"));
|
||||
Assert.True(CopilotApprovalCoordinator.RequiresToolCallApproval(policy, "agent-1", "git.status"));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetApprovalToolName_ResolvesDirectNamesAndRuntimeFallbacks()
|
||||
{
|
||||
Assert.True(
|
||||
CopilotApprovalCoordinator.TryGetApprovalToolName(
|
||||
@@ -732,7 +882,7 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
out string? hookToolName));
|
||||
Assert.Equal("web_fetch", hookToolName);
|
||||
|
||||
Assert.False(
|
||||
Assert.True(
|
||||
CopilotApprovalCoordinator.TryGetApprovalToolName(
|
||||
new PermissionRequestShell
|
||||
{
|
||||
@@ -746,7 +896,49 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
CanOfferSessionApproval = false,
|
||||
},
|
||||
out string? shellToolName));
|
||||
Assert.Null(shellToolName);
|
||||
Assert.Equal("shell", shellToolName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void TryGetApprovalToolName_FallsBackToRuntimeApprovalAliasesWhenLookupMissing()
|
||||
{
|
||||
Assert.True(
|
||||
CopilotApprovalCoordinator.TryGetApprovalToolName(
|
||||
new PermissionRequestRead
|
||||
{
|
||||
Kind = "read",
|
||||
ToolCallId = "tool-call-read",
|
||||
Intention = "Inspect a file",
|
||||
Path = "README.md",
|
||||
},
|
||||
out string? readToolName));
|
||||
Assert.Equal("read", readToolName);
|
||||
|
||||
Assert.True(
|
||||
CopilotApprovalCoordinator.TryGetApprovalToolName(
|
||||
new PermissionRequestWrite
|
||||
{
|
||||
Kind = "write",
|
||||
ToolCallId = "tool-call-write",
|
||||
Intention = "Update a file",
|
||||
FileName = "README.md",
|
||||
Diff = "@@ -1 +1 @@",
|
||||
},
|
||||
out string? writeToolName));
|
||||
Assert.Equal("write", writeToolName);
|
||||
|
||||
Assert.True(
|
||||
CopilotApprovalCoordinator.TryGetApprovalToolName(
|
||||
new PermissionRequestMemory
|
||||
{
|
||||
Kind = "memory",
|
||||
ToolCallId = "tool-call-memory",
|
||||
Subject = "repo conventions",
|
||||
Fact = "Use Bun for script execution.",
|
||||
Citations = "package.json",
|
||||
},
|
||||
out string? memoryToolName));
|
||||
Assert.Equal("store_memory", memoryToolName);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -876,6 +1068,9 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
Assert.Equal("lsp_ts_hover", approvalEvent.ToolName);
|
||||
Assert.Equal("Approve lsp_ts_hover", approvalEvent.Title);
|
||||
Assert.Contains("tool \"lsp_ts_hover\"", approvalEvent.Detail);
|
||||
Assert.NotNull(approvalEvent.PermissionDetail);
|
||||
Assert.Equal("custom-tool", approvalEvent.PermissionDetail!.Kind);
|
||||
Assert.Equal("Hover information", approvalEvent.PermissionDetail.ToolDescription);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -907,6 +1102,197 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
Assert.Contains("url permission", approvalEvent.Detail);
|
||||
Assert.Contains("tool \"web_fetch\"", approvalEvent.Detail);
|
||||
Assert.Contains("https://example.com/docs", approvalEvent.Detail);
|
||||
Assert.NotNull(approvalEvent.PermissionDetail);
|
||||
Assert.Equal("url", approvalEvent.PermissionDetail!.Kind);
|
||||
Assert.Equal("Fetch the requested page", approvalEvent.PermissionDetail.Intention);
|
||||
Assert.Equal("https://example.com/docs", approvalEvent.PermissionDetail.Url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPermissionDetail_ExtractsShellRequestData()
|
||||
{
|
||||
PermissionDetailDto detail = CopilotApprovalCoordinator.BuildPermissionDetail(
|
||||
new PermissionRequestShell
|
||||
{
|
||||
Kind = "shell",
|
||||
ToolCallId = "tool-call-shell",
|
||||
FullCommandText = "curl https://example.com/docs > docs.json",
|
||||
Intention = "Fetch documentation with curl",
|
||||
Commands =
|
||||
[
|
||||
new PermissionRequestShellCommandsItem
|
||||
{
|
||||
Identifier = "curl",
|
||||
ReadOnly = true,
|
||||
},
|
||||
],
|
||||
PossiblePaths = ["docs.json"],
|
||||
PossibleUrls =
|
||||
[
|
||||
new PermissionRequestShellPossibleUrlsItem
|
||||
{
|
||||
Url = "https://example.com/docs",
|
||||
},
|
||||
],
|
||||
HasWriteFileRedirection = true,
|
||||
CanOfferSessionApproval = false,
|
||||
Warning = "Downloads remote content and writes it to disk.",
|
||||
});
|
||||
|
||||
Assert.Equal("shell", detail.Kind);
|
||||
Assert.Equal("curl https://example.com/docs > docs.json", detail.Command);
|
||||
Assert.Equal("Fetch documentation with curl", detail.Intention);
|
||||
Assert.Equal("Downloads remote content and writes it to disk.", detail.Warning);
|
||||
Assert.Equal(["docs.json"], detail.PossiblePaths);
|
||||
Assert.Equal(["https://example.com/docs"], detail.PossibleUrls);
|
||||
Assert.True(detail.HasWriteFileRedirection);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPermissionDetail_ExtractsWriteRequestData()
|
||||
{
|
||||
PermissionDetailDto detail = CopilotApprovalCoordinator.BuildPermissionDetail(
|
||||
new PermissionRequestWrite
|
||||
{
|
||||
Kind = "write",
|
||||
ToolCallId = "tool-call-write",
|
||||
Intention = "Update README guidance",
|
||||
FileName = "README.md",
|
||||
Diff = "@@ -1 +1 @@\n-Hello\n+Hello world",
|
||||
NewFileContents = "# README",
|
||||
});
|
||||
|
||||
Assert.Equal("write", detail.Kind);
|
||||
Assert.Equal("Update README guidance", detail.Intention);
|
||||
Assert.Equal("README.md", detail.FileName);
|
||||
Assert.Equal("@@ -1 +1 @@\n-Hello\n+Hello world", detail.Diff);
|
||||
Assert.Equal("# README", detail.NewFileContents);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPermissionDetail_ExtractsReadRequestData()
|
||||
{
|
||||
PermissionDetailDto detail = CopilotApprovalCoordinator.BuildPermissionDetail(
|
||||
new PermissionRequestRead
|
||||
{
|
||||
Kind = "read",
|
||||
ToolCallId = "tool-call-read",
|
||||
Intention = "Inspect the README",
|
||||
Path = "README.md",
|
||||
});
|
||||
|
||||
Assert.Equal("read", detail.Kind);
|
||||
Assert.Equal("Inspect the README", detail.Intention);
|
||||
Assert.Equal("README.md", detail.Path);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPermissionDetail_ExtractsMcpRequestData()
|
||||
{
|
||||
PermissionDetailDto detail = CopilotApprovalCoordinator.BuildPermissionDetail(
|
||||
new PermissionRequestMcp
|
||||
{
|
||||
Kind = "mcp",
|
||||
ToolCallId = "tool-call-mcp",
|
||||
ServerName = "Git MCP",
|
||||
ToolName = "git.status",
|
||||
ToolTitle = "Git Status",
|
||||
Args = new Dictionary<string, object?>
|
||||
{
|
||||
["path"] = ".",
|
||||
},
|
||||
ReadOnly = true,
|
||||
});
|
||||
|
||||
Assert.Equal("mcp", detail.Kind);
|
||||
Assert.Equal("Git MCP", detail.ServerName);
|
||||
Assert.Equal("Git Status", detail.ToolTitle);
|
||||
Assert.True(detail.ReadOnly);
|
||||
|
||||
Dictionary<string, object?> args = Assert.IsType<Dictionary<string, object?>>(detail.Args);
|
||||
Assert.Equal(".", args["path"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPermissionDetail_ExtractsUrlRequestData()
|
||||
{
|
||||
PermissionDetailDto detail = CopilotApprovalCoordinator.BuildPermissionDetail(
|
||||
new PermissionRequestUrl
|
||||
{
|
||||
Kind = "url",
|
||||
ToolCallId = "tool-call-url",
|
||||
Intention = "Fetch the requested page",
|
||||
Url = "https://example.com/docs",
|
||||
});
|
||||
|
||||
Assert.Equal("url", detail.Kind);
|
||||
Assert.Equal("Fetch the requested page", detail.Intention);
|
||||
Assert.Equal("https://example.com/docs", detail.Url);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPermissionDetail_ExtractsMemoryRequestData()
|
||||
{
|
||||
PermissionDetailDto detail = CopilotApprovalCoordinator.BuildPermissionDetail(
|
||||
new PermissionRequestMemory
|
||||
{
|
||||
Kind = "memory",
|
||||
ToolCallId = "tool-call-memory",
|
||||
Subject = "repo conventions",
|
||||
Fact = "Use Bun for script execution.",
|
||||
Citations = "package.json",
|
||||
});
|
||||
|
||||
Assert.Equal("memory", detail.Kind);
|
||||
Assert.Equal("repo conventions", detail.Subject);
|
||||
Assert.Equal("Use Bun for script execution.", detail.Fact);
|
||||
Assert.Equal("package.json", detail.Citations);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPermissionDetail_ExtractsCustomToolRequestData()
|
||||
{
|
||||
PermissionDetailDto detail = CopilotApprovalCoordinator.BuildPermissionDetail(
|
||||
new PermissionRequestCustomTool
|
||||
{
|
||||
Kind = "custom tool",
|
||||
ToolName = "lsp_ts_hover",
|
||||
ToolDescription = "Hover information",
|
||||
Args = new Dictionary<string, object?>
|
||||
{
|
||||
["file"] = "src/index.ts",
|
||||
["line"] = 12,
|
||||
},
|
||||
});
|
||||
|
||||
Assert.Equal("custom-tool", detail.Kind);
|
||||
Assert.Equal("Hover information", detail.ToolDescription);
|
||||
|
||||
Dictionary<string, object?> args = Assert.IsType<Dictionary<string, object?>>(detail.Args);
|
||||
Assert.Equal("src/index.ts", args["file"]);
|
||||
Assert.Equal(12, args["line"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void BuildPermissionDetail_ExtractsHookRequestData()
|
||||
{
|
||||
PermissionDetailDto detail = CopilotApprovalCoordinator.BuildPermissionDetail(
|
||||
new PermissionRequestHook
|
||||
{
|
||||
Kind = "hook",
|
||||
ToolName = "web_fetch",
|
||||
ToolArgs = new Dictionary<string, object?>
|
||||
{
|
||||
["url"] = "https://example.com",
|
||||
},
|
||||
HookMessage = "Review required before fetch",
|
||||
});
|
||||
|
||||
Assert.Equal("hook", detail.Kind);
|
||||
Assert.Equal("Review required before fetch", detail.HookMessage);
|
||||
|
||||
Dictionary<string, object?> args = Assert.IsType<Dictionary<string, object?>>(detail.Args);
|
||||
Assert.Equal("https://example.com", args["url"]);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -984,6 +1370,170 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
Assert.Equal(PermissionRequestResultKind.Approved, result.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestApprovalAsync_AlwaysApproveCachesRuntimeApprovalForCurrentTurn()
|
||||
{
|
||||
CopilotApprovalCoordinator coordinator = new();
|
||||
ApprovalRequestedEventDto? firstApproval = null;
|
||||
RunTurnCommandDto command = CreateApprovalCommand();
|
||||
|
||||
Task<PermissionRequestResult> firstPending = coordinator.RequestApprovalAsync(
|
||||
command,
|
||||
command.Pattern.Agents[0],
|
||||
new PermissionRequestRead
|
||||
{
|
||||
Kind = "read",
|
||||
ToolCallId = "tool-call-read-1",
|
||||
Intention = "Inspect README guidance",
|
||||
Path = "README.md",
|
||||
},
|
||||
new PermissionInvocation
|
||||
{
|
||||
SessionId = "copilot-session-1",
|
||||
},
|
||||
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["tool-call-read-1"] = "view",
|
||||
},
|
||||
approval =>
|
||||
{
|
||||
firstApproval = approval;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(firstPending.IsCompleted);
|
||||
Assert.NotNull(firstApproval);
|
||||
|
||||
await coordinator.ResolveApprovalAsync(
|
||||
new ResolveApprovalCommandDto
|
||||
{
|
||||
ApprovalId = firstApproval!.ApprovalId,
|
||||
Decision = "approved",
|
||||
AlwaysApprove = true,
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
PermissionRequestResult firstResult = await firstPending;
|
||||
Assert.Equal(PermissionRequestResultKind.Approved, firstResult.Kind);
|
||||
|
||||
bool sawSecondApproval = false;
|
||||
PermissionRequestResult secondResult = await coordinator.RequestApprovalAsync(
|
||||
command,
|
||||
command.Pattern.Agents[0],
|
||||
new PermissionRequestRead
|
||||
{
|
||||
Kind = "read",
|
||||
ToolCallId = "tool-call-read-2",
|
||||
Intention = "Inspect docs guidance",
|
||||
Path = "docs\\guide.md",
|
||||
},
|
||||
new PermissionInvocation
|
||||
{
|
||||
SessionId = "copilot-session-1",
|
||||
},
|
||||
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["tool-call-read-2"] = "grep",
|
||||
},
|
||||
approval =>
|
||||
{
|
||||
sawSecondApproval = true;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(sawSecondApproval);
|
||||
Assert.Equal(PermissionRequestResultKind.Approved, secondResult.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RequestApprovalAsync_AlwaysApproveCacheDoesNotCarryAcrossTurnRequests()
|
||||
{
|
||||
CopilotApprovalCoordinator coordinator = new();
|
||||
ApprovalRequestedEventDto? firstApproval = null;
|
||||
RunTurnCommandDto firstCommand = CreateApprovalCommand();
|
||||
|
||||
Task<PermissionRequestResult> firstPending = coordinator.RequestApprovalAsync(
|
||||
firstCommand,
|
||||
firstCommand.Pattern.Agents[0],
|
||||
new PermissionRequestRead
|
||||
{
|
||||
Kind = "read",
|
||||
ToolCallId = "tool-call-read-1",
|
||||
Intention = "Inspect README guidance",
|
||||
Path = "README.md",
|
||||
},
|
||||
new PermissionInvocation
|
||||
{
|
||||
SessionId = "copilot-session-1",
|
||||
},
|
||||
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["tool-call-read-1"] = "view",
|
||||
},
|
||||
approval =>
|
||||
{
|
||||
firstApproval = approval;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.NotNull(firstApproval);
|
||||
|
||||
await coordinator.ResolveApprovalAsync(
|
||||
new ResolveApprovalCommandDto
|
||||
{
|
||||
ApprovalId = firstApproval!.ApprovalId,
|
||||
Decision = "approved",
|
||||
AlwaysApprove = true,
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
await firstPending;
|
||||
|
||||
ApprovalRequestedEventDto? secondApproval = null;
|
||||
RunTurnCommandDto secondCommand = CreateApprovalCommand(requestId: "turn-2");
|
||||
Task<PermissionRequestResult> secondPending = coordinator.RequestApprovalAsync(
|
||||
secondCommand,
|
||||
secondCommand.Pattern.Agents[0],
|
||||
new PermissionRequestRead
|
||||
{
|
||||
Kind = "read",
|
||||
ToolCallId = "tool-call-read-2",
|
||||
Intention = "Inspect docs guidance",
|
||||
Path = "docs\\guide.md",
|
||||
},
|
||||
new PermissionInvocation
|
||||
{
|
||||
SessionId = "copilot-session-1",
|
||||
},
|
||||
new Dictionary<string, string>(StringComparer.Ordinal)
|
||||
{
|
||||
["tool-call-read-2"] = "grep",
|
||||
},
|
||||
approval =>
|
||||
{
|
||||
secondApproval = approval;
|
||||
return Task.CompletedTask;
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
Assert.False(secondPending.IsCompleted);
|
||||
Assert.NotNull(secondApproval);
|
||||
|
||||
await coordinator.ResolveApprovalAsync(
|
||||
new ResolveApprovalCommandDto
|
||||
{
|
||||
ApprovalId = secondApproval!.ApprovalId,
|
||||
Decision = "approved",
|
||||
},
|
||||
CancellationToken.None);
|
||||
|
||||
PermissionRequestResult secondResult = await secondPending;
|
||||
Assert.Equal(PermissionRequestResultKind.Approved, secondResult.Kind);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveApprovalAsync_RejectsUnknownApprovalIds()
|
||||
{
|
||||
@@ -1033,11 +1583,38 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
};
|
||||
}
|
||||
|
||||
private static RunTurnCommandDto CreateApprovalCommand()
|
||||
private static RequestInfoEvent CreateRequestInfoEvent(object payload)
|
||||
{
|
||||
RequestPort port = RequestPort.Create<object, object>("test-port");
|
||||
ExternalRequest request = ExternalRequest.Create(port, payload, "request-1");
|
||||
return new RequestInfoEvent(request);
|
||||
}
|
||||
|
||||
private static object CreateHandoffTarget(string id, string name)
|
||||
{
|
||||
Type type = Type.GetType(
|
||||
"Microsoft.Agents.AI.Workflows.Specialized.HandoffTarget, Microsoft.Agents.AI.Workflows",
|
||||
throwOnError: true)!;
|
||||
return Activator.CreateInstance(type, CreateChatClientAgent(id, name), "Handle the UX work.")!;
|
||||
}
|
||||
|
||||
private static ChatClientAgent CreateChatClientAgent(string id, string name)
|
||||
{
|
||||
return new ChatClientAgent(
|
||||
new StubChatClient(),
|
||||
id,
|
||||
name,
|
||||
"Stub agent for handoff tests.",
|
||||
[],
|
||||
null!,
|
||||
null!);
|
||||
}
|
||||
|
||||
private static RunTurnCommandDto CreateApprovalCommand(string requestId = "turn-1")
|
||||
{
|
||||
return new RunTurnCommandDto
|
||||
{
|
||||
RequestId = "turn-1",
|
||||
RequestId = requestId,
|
||||
SessionId = "session-1",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
@@ -1064,4 +1641,33 @@ public sealed class CopilotWorkflowRunnerTests
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class StubChatClient : IChatClient
|
||||
{
|
||||
public Task<ChatResponse> GetResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
throw new NotSupportedException();
|
||||
}
|
||||
|
||||
public async IAsyncEnumerable<ChatResponseUpdate> GetStreamingResponseAsync(
|
||||
IEnumerable<ChatMessage> messages,
|
||||
ChatOptions? options,
|
||||
[EnumeratorCancellation]
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
yield break;
|
||||
}
|
||||
|
||||
public object? GetService(Type serviceType, object? serviceKey)
|
||||
{
|
||||
return null;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,126 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
|
||||
public sealed class HookCommandRunnerTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task RunAsync_PipesJsonIntoHookStandardInput()
|
||||
{
|
||||
HookCommandRunner runner = new();
|
||||
using TestDirectory project = new();
|
||||
HookCommandDefinition hook = CreatePlatformHook(
|
||||
OperatingSystem.IsWindows()
|
||||
? "$payload = [Console]::In.ReadToEnd(); Write-Output $payload"
|
||||
: "payload=$(cat); printf '%s' \"$payload\"");
|
||||
|
||||
string input = """{"toolName":"view","toolArgs":"{\"path\":\"README.md\"}"}""";
|
||||
|
||||
string? output = await runner.RunAsync(hook, input, project.Path, CancellationToken.None);
|
||||
|
||||
Assert.Equal(input, output?.Trim());
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsNullWhenHookTimesOut()
|
||||
{
|
||||
HookCommandRunner runner = new();
|
||||
using TestDirectory project = new();
|
||||
HookCommandDefinition hook = CreatePlatformHook(
|
||||
OperatingSystem.IsWindows() ? "Start-Sleep -Seconds 5" : "sleep 5",
|
||||
timeoutSec: 1);
|
||||
|
||||
string? output = await runner.RunAsync(hook, "{}", project.Path, CancellationToken.None);
|
||||
|
||||
Assert.Null(output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsNullWhenHookFails()
|
||||
{
|
||||
HookCommandRunner runner = new();
|
||||
using TestDirectory project = new();
|
||||
HookCommandDefinition hook = CreatePlatformHook(
|
||||
OperatingSystem.IsWindows() ? "Write-Error 'boom'; exit 1" : "echo boom >&2; exit 1");
|
||||
|
||||
string? output = await runner.RunAsync(hook, "{}", project.Path, CancellationToken.None);
|
||||
|
||||
Assert.Null(output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_ReturnsNullWhenCurrentPlatformCommandIsMissing()
|
||||
{
|
||||
HookCommandRunner runner = new();
|
||||
using TestDirectory project = new();
|
||||
HookCommandDefinition hook = OperatingSystem.IsWindows()
|
||||
? new HookCommandDefinition { Type = "command", Bash = "echo unsupported" }
|
||||
: new HookCommandDefinition { Type = "command", PowerShell = "Write-Output unsupported" };
|
||||
|
||||
string? output = await runner.RunAsync(hook, "{}", project.Path, CancellationToken.None);
|
||||
|
||||
Assert.Null(output);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunAsync_UsesConfiguredWorkingDirectoryAndEnvironment()
|
||||
{
|
||||
HookCommandRunner runner = new();
|
||||
using TestDirectory project = new();
|
||||
string hooksDirectory = Directory.CreateDirectory(Path.Combine(project.Path, "scripts")).FullName;
|
||||
HookCommandDefinition hook = CreatePlatformHook(
|
||||
OperatingSystem.IsWindows()
|
||||
? "Write-Output ((Get-Location).Path + '|' + $env:HOOK_TEST_ENV)"
|
||||
: "printf '%s|%s' \"$(pwd)\" \"$HOOK_TEST_ENV\"",
|
||||
cwd: "scripts",
|
||||
env: new Dictionary<string, string>
|
||||
{
|
||||
["HOOK_TEST_ENV"] = "configured",
|
||||
});
|
||||
|
||||
string? output = await runner.RunAsync(hook, "{}", project.Path, CancellationToken.None);
|
||||
|
||||
Assert.Equal($"{hooksDirectory}|configured", output?.Trim());
|
||||
}
|
||||
|
||||
private static HookCommandDefinition CreatePlatformHook(
|
||||
string command,
|
||||
int? timeoutSec = null,
|
||||
string? cwd = null,
|
||||
IReadOnlyDictionary<string, string>? env = null)
|
||||
{
|
||||
return OperatingSystem.IsWindows()
|
||||
? new HookCommandDefinition
|
||||
{
|
||||
Type = "command",
|
||||
PowerShell = command,
|
||||
TimeoutSec = timeoutSec,
|
||||
Cwd = cwd,
|
||||
Env = env,
|
||||
}
|
||||
: new HookCommandDefinition
|
||||
{
|
||||
Type = "command",
|
||||
Bash = command,
|
||||
TimeoutSec = timeoutSec,
|
||||
Cwd = cwd,
|
||||
Env = env,
|
||||
};
|
||||
}
|
||||
|
||||
private sealed class TestDirectory : IDisposable
|
||||
{
|
||||
private readonly DirectoryInfo _directory = Directory.CreateTempSubdirectory("aryx-hooks-runner-");
|
||||
|
||||
public string Path => _directory.FullName;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_directory.Exists)
|
||||
{
|
||||
_directory.Delete(recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
|
||||
public sealed class HookConfigLoaderTests
|
||||
{
|
||||
[Fact]
|
||||
public async Task LoadAsync_ParsesSupportedHookTypes()
|
||||
{
|
||||
using TestDirectory project = new();
|
||||
string hooksDirectory = Directory.CreateDirectory(Path.Combine(project.Path, ".github", "hooks")).FullName;
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(hooksDirectory, "hooks.json"),
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"sessionStart": [
|
||||
{
|
||||
"type": "command",
|
||||
"bash": "echo session-start",
|
||||
"powershell": "Write-Output session-start",
|
||||
"cwd": ".",
|
||||
"env": { "HOOK_MODE": "audit" },
|
||||
"timeoutSec": 15
|
||||
}
|
||||
],
|
||||
"preToolUse": [
|
||||
{
|
||||
"type": "command",
|
||||
"bash": "echo pre-tool",
|
||||
"powershell": "Write-Output pre-tool"
|
||||
}
|
||||
],
|
||||
"errorOccurred": [
|
||||
{
|
||||
"type": "command",
|
||||
"bash": "echo error-hook",
|
||||
"powershell": "Write-Output error-hook"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
ResolvedHookSet hooks = await HookConfigLoader.LoadAsync(project.Path, CancellationToken.None);
|
||||
|
||||
HookCommandDefinition sessionStart = Assert.Single(hooks.SessionStart);
|
||||
Assert.Equal("command", sessionStart.Type);
|
||||
Assert.Equal("echo session-start", sessionStart.Bash);
|
||||
Assert.Equal("Write-Output session-start", sessionStart.PowerShell);
|
||||
Assert.Equal(".", sessionStart.Cwd);
|
||||
Assert.Equal(15, sessionStart.TimeoutSec);
|
||||
Assert.NotNull(sessionStart.Env);
|
||||
Assert.Equal("audit", sessionStart.Env["HOOK_MODE"]);
|
||||
Assert.Single(hooks.PreToolUse);
|
||||
Assert.Single(hooks.ErrorOccurred);
|
||||
Assert.False(hooks.IsEmpty);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_MergesHookFilesInFileNameOrder()
|
||||
{
|
||||
using TestDirectory project = new();
|
||||
string hooksDirectory = Directory.CreateDirectory(Path.Combine(project.Path, ".github", "hooks")).FullName;
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(hooksDirectory, "20-second.json"),
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"preToolUse": [
|
||||
{
|
||||
"type": "command",
|
||||
"bash": "echo second",
|
||||
"powershell": "Write-Output second"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""");
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(hooksDirectory, "10-first.json"),
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"preToolUse": [
|
||||
{
|
||||
"type": "command",
|
||||
"bash": "echo first",
|
||||
"powershell": "Write-Output first"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
ResolvedHookSet hooks = await HookConfigLoader.LoadAsync(project.Path, CancellationToken.None);
|
||||
|
||||
string[] commands = hooks.PreToolUse.Select(GetCommandText).ToArray();
|
||||
Assert.Equal(2, commands.Length);
|
||||
Assert.Contains("first", commands[0], StringComparison.Ordinal);
|
||||
Assert.Contains("second", commands[1], StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ReturnsEmptyWhenHooksDirectoryIsMissing()
|
||||
{
|
||||
using TestDirectory project = new();
|
||||
|
||||
ResolvedHookSet hooks = await HookConfigLoader.LoadAsync(project.Path, CancellationToken.None);
|
||||
|
||||
Assert.Same(ResolvedHookSet.Empty, hooks);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_SkipsInvalidFilesAndUnsupportedVersions()
|
||||
{
|
||||
using TestDirectory project = new();
|
||||
string hooksDirectory = Directory.CreateDirectory(Path.Combine(project.Path, ".github", "hooks")).FullName;
|
||||
|
||||
await File.WriteAllTextAsync(Path.Combine(hooksDirectory, "00-invalid.json"), "{ not-json");
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(hooksDirectory, "10-unsupported.json"),
|
||||
"""
|
||||
{
|
||||
"version": 2,
|
||||
"hooks": {
|
||||
"sessionStart": [
|
||||
{
|
||||
"type": "command",
|
||||
"bash": "echo unsupported",
|
||||
"powershell": "Write-Output unsupported"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""");
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(hooksDirectory, "20-valid.json"),
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": {
|
||||
"sessionEnd": [
|
||||
{
|
||||
"type": "command",
|
||||
"bash": "echo valid",
|
||||
"powershell": "Write-Output valid"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
""");
|
||||
|
||||
ResolvedHookSet hooks = await HookConfigLoader.LoadAsync(project.Path, CancellationToken.None);
|
||||
|
||||
HookCommandDefinition valid = Assert.Single(hooks.SessionEnd);
|
||||
Assert.Contains("valid", GetCommandText(valid), StringComparison.Ordinal);
|
||||
Assert.Empty(hooks.SessionStart);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task LoadAsync_ReturnsEmptyForEmptyHooksObject()
|
||||
{
|
||||
using TestDirectory project = new();
|
||||
string hooksDirectory = Directory.CreateDirectory(Path.Combine(project.Path, ".github", "hooks")).FullName;
|
||||
|
||||
await File.WriteAllTextAsync(
|
||||
Path.Combine(hooksDirectory, "hooks.json"),
|
||||
"""
|
||||
{
|
||||
"version": 1,
|
||||
"hooks": {}
|
||||
}
|
||||
""");
|
||||
|
||||
ResolvedHookSet hooks = await HookConfigLoader.LoadAsync(project.Path, CancellationToken.None);
|
||||
|
||||
Assert.Same(ResolvedHookSet.Empty, hooks);
|
||||
}
|
||||
|
||||
private static string GetCommandText(HookCommandDefinition hook)
|
||||
=> hook.PowerShell ?? hook.Bash ?? string.Empty;
|
||||
|
||||
private sealed class TestDirectory : IDisposable
|
||||
{
|
||||
private readonly DirectoryInfo _directory = Directory.CreateTempSubdirectory("aryx-hooks-loader-");
|
||||
|
||||
public string Path => _directory.FullName;
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_directory.Exists)
|
||||
{
|
||||
_directory.Delete(recursive: true);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using System.Text.Json;
|
||||
using Aryx.AgentHost.Contracts;
|
||||
using Aryx.AgentHost.Services;
|
||||
using GitHub.Copilot.SDK.Rpc;
|
||||
|
||||
namespace Aryx.AgentHost.Tests;
|
||||
|
||||
@@ -112,7 +113,7 @@ public sealed class SidecarProtocolHostTests
|
||||
{
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, cancellationToken) =>
|
||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
|
||||
{
|
||||
await onActivity(new AgentActivityEventDto
|
||||
{
|
||||
@@ -231,12 +232,49 @@ public sealed class SidecarProtocolHostTests
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunTurnCommand_DeserializesInteractionMode()
|
||||
{
|
||||
string? capturedMode = null;
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
|
||||
{
|
||||
capturedMode = command.Mode;
|
||||
return [];
|
||||
}));
|
||||
|
||||
await RunHostAsync(
|
||||
new RunTurnCommandDto
|
||||
{
|
||||
Type = "run-turn",
|
||||
RequestId = "turn-plan",
|
||||
SessionId = "session-1",
|
||||
ProjectPath = "C:\\workspace\\project",
|
||||
Mode = "plan",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
Name = "Single Agent",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
CreateAgent(name: "Primary"),
|
||||
],
|
||||
},
|
||||
},
|
||||
host);
|
||||
|
||||
Assert.Equal("plan", capturedMode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunTurnCommand_ReturnsApprovalEvents()
|
||||
{
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, cancellationToken) =>
|
||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
|
||||
{
|
||||
await onApproval(new ApprovalRequestedEventDto
|
||||
{
|
||||
@@ -249,6 +287,13 @@ public sealed class SidecarProtocolHostTests
|
||||
AgentName = "Primary",
|
||||
PermissionKind = "tool access",
|
||||
Title = "Approve tool access",
|
||||
PermissionDetail = new PermissionDetailDto
|
||||
{
|
||||
Kind = "shell",
|
||||
Command = "git status",
|
||||
Intention = "Inspect repository state",
|
||||
PossiblePaths = ["README.md"],
|
||||
},
|
||||
});
|
||||
|
||||
return [];
|
||||
@@ -284,6 +329,11 @@ public sealed class SidecarProtocolHostTests
|
||||
Assert.Equal("approval-1", approvalEvent.GetProperty("approvalId").GetString());
|
||||
Assert.Equal("tool-call", approvalEvent.GetProperty("approvalKind").GetString());
|
||||
Assert.Equal("Approve tool access", approvalEvent.GetProperty("title").GetString());
|
||||
JsonElement permissionDetail = approvalEvent.GetProperty("permissionDetail");
|
||||
Assert.Equal("shell", permissionDetail.GetProperty("kind").GetString());
|
||||
Assert.Equal("git status", permissionDetail.GetProperty("command").GetString());
|
||||
Assert.Equal("Inspect repository state", permissionDetail.GetProperty("intention").GetString());
|
||||
Assert.Equal("README.md", Assert.Single(permissionDetail.GetProperty("possiblePaths").EnumerateArray()).GetString());
|
||||
},
|
||||
completionEvent =>
|
||||
{
|
||||
@@ -297,12 +347,218 @@ public sealed class SidecarProtocolHostTests
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunTurnCommand_ReturnsUserInputEvents()
|
||||
{
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
|
||||
{
|
||||
await onUserInput(new UserInputRequestedEventDto
|
||||
{
|
||||
Type = "user-input-requested",
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
UserInputId = "user-input-1",
|
||||
AgentId = "agent-1",
|
||||
AgentName = "Primary",
|
||||
Question = "What should I do next?",
|
||||
Choices = ["Continue", "Stop"],
|
||||
AllowFreeform = true,
|
||||
});
|
||||
|
||||
return [];
|
||||
}));
|
||||
|
||||
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
||||
new RunTurnCommandDto
|
||||
{
|
||||
Type = "run-turn",
|
||||
RequestId = "turn-user-input",
|
||||
SessionId = "session-1",
|
||||
ProjectPath = "C:\\workspace\\project",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
Name = "Single Agent",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
CreateAgent(name: "Primary"),
|
||||
],
|
||||
},
|
||||
},
|
||||
host);
|
||||
|
||||
Assert.Collection(
|
||||
events,
|
||||
userInputEvent =>
|
||||
{
|
||||
Assert.Equal("user-input-requested", userInputEvent.GetProperty("type").GetString());
|
||||
Assert.Equal("turn-user-input", userInputEvent.GetProperty("requestId").GetString());
|
||||
Assert.Equal("session-1", userInputEvent.GetProperty("sessionId").GetString());
|
||||
Assert.Equal("user-input-1", userInputEvent.GetProperty("userInputId").GetString());
|
||||
Assert.Equal("Primary", userInputEvent.GetProperty("agentName").GetString());
|
||||
Assert.Equal("What should I do next?", userInputEvent.GetProperty("question").GetString());
|
||||
string[] choices = userInputEvent.GetProperty("choices")
|
||||
.EnumerateArray()
|
||||
.Select(choice => choice.GetString() ?? string.Empty)
|
||||
.ToArray();
|
||||
Assert.Equal(["Continue", "Stop"], choices);
|
||||
Assert.True(userInputEvent.GetProperty("allowFreeform").GetBoolean());
|
||||
},
|
||||
completionEvent =>
|
||||
{
|
||||
Assert.Equal("turn-complete", completionEvent.GetProperty("type").GetString());
|
||||
Assert.False(completionEvent.GetProperty("cancelled").GetBoolean());
|
||||
},
|
||||
commandCompleteEvent =>
|
||||
{
|
||||
Assert.Equal("command-complete", commandCompleteEvent.GetProperty("type").GetString());
|
||||
Assert.Equal("turn-user-input", commandCompleteEvent.GetProperty("requestId").GetString());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunTurnCommand_ReturnsMcpOauthRequiredEvents()
|
||||
{
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
|
||||
{
|
||||
await onMcpOAuthRequired(new McpOauthRequiredEventDto
|
||||
{
|
||||
Type = "mcp-oauth-required",
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
OauthRequestId = "oauth-request-1",
|
||||
AgentId = "agent-1",
|
||||
AgentName = "Primary",
|
||||
ServerName = "Example MCP",
|
||||
ServerUrl = "https://example.com/mcp",
|
||||
StaticClientConfig = new McpOauthStaticClientConfigDto
|
||||
{
|
||||
ClientId = "aryx-client",
|
||||
PublicClient = true,
|
||||
},
|
||||
});
|
||||
|
||||
return [];
|
||||
}));
|
||||
|
||||
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
||||
CreateRunTurnCommand(requestId: "turn-mcp-oauth"),
|
||||
host);
|
||||
|
||||
Assert.Collection(
|
||||
events,
|
||||
oauthEvent =>
|
||||
{
|
||||
Assert.Equal("mcp-oauth-required", oauthEvent.GetProperty("type").GetString());
|
||||
Assert.Equal("turn-mcp-oauth", oauthEvent.GetProperty("requestId").GetString());
|
||||
Assert.Equal("session-1", oauthEvent.GetProperty("sessionId").GetString());
|
||||
Assert.Equal("oauth-request-1", oauthEvent.GetProperty("oauthRequestId").GetString());
|
||||
Assert.Equal("Primary", oauthEvent.GetProperty("agentName").GetString());
|
||||
Assert.Equal("Example MCP", oauthEvent.GetProperty("serverName").GetString());
|
||||
Assert.Equal("https://example.com/mcp", oauthEvent.GetProperty("serverUrl").GetString());
|
||||
JsonElement staticClientConfig = oauthEvent.GetProperty("staticClientConfig");
|
||||
Assert.Equal("aryx-client", staticClientConfig.GetProperty("clientId").GetString());
|
||||
Assert.True(staticClientConfig.GetProperty("publicClient").GetBoolean());
|
||||
},
|
||||
completionEvent =>
|
||||
{
|
||||
Assert.Equal("turn-complete", completionEvent.GetProperty("type").GetString());
|
||||
Assert.False(completionEvent.GetProperty("cancelled").GetBoolean());
|
||||
},
|
||||
commandCompleteEvent =>
|
||||
{
|
||||
Assert.Equal("command-complete", commandCompleteEvent.GetProperty("type").GetString());
|
||||
Assert.Equal("turn-mcp-oauth", commandCompleteEvent.GetProperty("requestId").GetString());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task RunTurnCommand_ReturnsExitPlanModeEvents()
|
||||
{
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
|
||||
{
|
||||
await onExitPlanMode(new ExitPlanModeRequestedEventDto
|
||||
{
|
||||
Type = "exit-plan-mode-requested",
|
||||
RequestId = command.RequestId,
|
||||
SessionId = command.SessionId,
|
||||
ExitPlanId = "exit-plan-1",
|
||||
AgentId = "agent-1",
|
||||
AgentName = "Primary",
|
||||
Summary = "Proposed implementation plan",
|
||||
PlanContent = "1. Inspect\n2. Change\n3. Validate",
|
||||
Actions = ["interactive", "autopilot"],
|
||||
RecommendedAction = "interactive",
|
||||
});
|
||||
|
||||
return [];
|
||||
}));
|
||||
|
||||
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
||||
new RunTurnCommandDto
|
||||
{
|
||||
Type = "run-turn",
|
||||
RequestId = "turn-plan-mode",
|
||||
SessionId = "session-1",
|
||||
ProjectPath = "C:\\workspace\\project",
|
||||
Mode = "plan",
|
||||
Pattern = new PatternDefinitionDto
|
||||
{
|
||||
Id = "pattern-1",
|
||||
Name = "Single Agent",
|
||||
Mode = "single",
|
||||
Availability = "available",
|
||||
Agents =
|
||||
[
|
||||
CreateAgent(name: "Primary"),
|
||||
],
|
||||
},
|
||||
},
|
||||
host);
|
||||
|
||||
Assert.Collection(
|
||||
events,
|
||||
exitPlanEvent =>
|
||||
{
|
||||
Assert.Equal("exit-plan-mode-requested", exitPlanEvent.GetProperty("type").GetString());
|
||||
Assert.Equal("turn-plan-mode", exitPlanEvent.GetProperty("requestId").GetString());
|
||||
Assert.Equal("exit-plan-1", exitPlanEvent.GetProperty("exitPlanId").GetString());
|
||||
Assert.Equal("Primary", exitPlanEvent.GetProperty("agentName").GetString());
|
||||
Assert.Equal("Proposed implementation plan", exitPlanEvent.GetProperty("summary").GetString());
|
||||
Assert.Equal("1. Inspect\n2. Change\n3. Validate", exitPlanEvent.GetProperty("planContent").GetString());
|
||||
string[] actions = exitPlanEvent.GetProperty("actions")
|
||||
.EnumerateArray()
|
||||
.Select(action => action.GetString() ?? string.Empty)
|
||||
.ToArray();
|
||||
Assert.Equal(["interactive", "autopilot"], actions);
|
||||
Assert.Equal("interactive", exitPlanEvent.GetProperty("recommendedAction").GetString());
|
||||
},
|
||||
completionEvent =>
|
||||
{
|
||||
Assert.Equal("turn-complete", completionEvent.GetProperty("type").GetString());
|
||||
Assert.False(completionEvent.GetProperty("cancelled").GetBoolean());
|
||||
},
|
||||
commandCompleteEvent =>
|
||||
{
|
||||
Assert.Equal("command-complete", commandCompleteEvent.GetProperty("type").GetString());
|
||||
Assert.Equal("turn-plan-mode", commandCompleteEvent.GetProperty("requestId").GetString());
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task CancelTurnCommand_CancelsInProgressTurnAndCompletesBothCommands()
|
||||
{
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, cancellationToken) =>
|
||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
|
||||
{
|
||||
await Task.Delay(Timeout.Infinite, cancellationToken);
|
||||
return [];
|
||||
@@ -350,7 +606,7 @@ public sealed class SidecarProtocolHostTests
|
||||
{
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, cancellationToken) => []));
|
||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) => []));
|
||||
|
||||
await RunHostAsync(CreateRunTurnCommand(requestId: "turn-completed"), host);
|
||||
|
||||
@@ -373,7 +629,7 @@ public sealed class SidecarProtocolHostTests
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
new FakeWorkflowRunner(
|
||||
handler: async (command, onDelta, onActivity, onApproval, cancellationToken) => [],
|
||||
handler: async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) => [],
|
||||
resolveApprovalHandler: (command, cancellationToken) =>
|
||||
{
|
||||
captured = command;
|
||||
@@ -387,6 +643,7 @@ public sealed class SidecarProtocolHostTests
|
||||
RequestId = "approval-command-1",
|
||||
ApprovalId = "approval-1",
|
||||
Decision = "approved",
|
||||
AlwaysApprove = true,
|
||||
},
|
||||
host);
|
||||
|
||||
@@ -395,6 +652,93 @@ public sealed class SidecarProtocolHostTests
|
||||
Assert.Equal("approval-command-1", completionEvent.GetProperty("requestId").GetString());
|
||||
Assert.Equal("approval-1", captured?.ApprovalId);
|
||||
Assert.Equal("approved", captured?.Decision);
|
||||
Assert.True(captured?.AlwaysApprove ?? false);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task ResolveUserInputCommand_DelegatesToWorkflowRunnerAndCompletes()
|
||||
{
|
||||
ResolveUserInputCommandDto? captured = null;
|
||||
SidecarProtocolHost host = new(
|
||||
new PatternValidator(),
|
||||
new FakeWorkflowRunner(
|
||||
handler: async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) => [],
|
||||
resolveUserInputHandler: (command, cancellationToken) =>
|
||||
{
|
||||
captured = command;
|
||||
return Task.CompletedTask;
|
||||
}));
|
||||
|
||||
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
||||
new ResolveUserInputCommandDto
|
||||
{
|
||||
Type = "resolve-user-input",
|
||||
RequestId = "user-input-command-1",
|
||||
UserInputId = "user-input-1",
|
||||
Answer = "Continue",
|
||||
WasFreeform = false,
|
||||
},
|
||||
host);
|
||||
|
||||
JsonElement completionEvent = Assert.Single(events);
|
||||
Assert.Equal("command-complete", completionEvent.GetProperty("type").GetString());
|
||||
Assert.Equal("user-input-command-1", completionEvent.GetProperty("requestId").GetString());
|
||||
Assert.Equal("user-input-1", captured?.UserInputId);
|
||||
Assert.Equal("Continue", captured?.Answer);
|
||||
Assert.False(captured?.WasFreeform);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public void MapRuntimeTools_ExcludesOnlyInternalMetaToolsAndDeduplicatesByName()
|
||||
{
|
||||
IReadOnlyList<SidecarRuntimeToolDto> runtimeTools = SidecarProtocolHost.MapRuntimeTools(
|
||||
[
|
||||
new Tool
|
||||
{
|
||||
Name = "ask_user",
|
||||
Description = "Ask the user a question.",
|
||||
},
|
||||
new Tool
|
||||
{
|
||||
Name = "report_intent",
|
||||
Description = "Report current intent.",
|
||||
},
|
||||
new Tool
|
||||
{
|
||||
Name = "task_complete",
|
||||
Description = "Signal task completion.",
|
||||
},
|
||||
new Tool
|
||||
{
|
||||
Name = "exit_plan_mode",
|
||||
Description = "Exit plan mode.",
|
||||
},
|
||||
new Tool
|
||||
{
|
||||
Name = " web_fetch ",
|
||||
Description = " Fetch content from the web. ",
|
||||
},
|
||||
new Tool
|
||||
{
|
||||
Name = "WEB_FETCH",
|
||||
Description = "Duplicate entry",
|
||||
},
|
||||
]);
|
||||
|
||||
Assert.Collection(
|
||||
runtimeTools,
|
||||
exitPlanTool =>
|
||||
{
|
||||
Assert.Equal("exit_plan_mode", exitPlanTool.Id);
|
||||
Assert.Equal("exit_plan_mode", exitPlanTool.Label);
|
||||
Assert.Equal("Exit plan mode.", exitPlanTool.Description);
|
||||
},
|
||||
runtimeTool =>
|
||||
{
|
||||
Assert.Equal("web_fetch", runtimeTool.Id);
|
||||
Assert.Equal("web_fetch", runtimeTool.Label);
|
||||
Assert.Equal("Fetch content from the web.", runtimeTool.Description);
|
||||
});
|
||||
}
|
||||
|
||||
[Fact]
|
||||
@@ -436,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)
|
||||
@@ -464,9 +911,9 @@ public sealed class SidecarProtocolHostTests
|
||||
string eventType,
|
||||
string requestId)
|
||||
{
|
||||
return Assert.Single(events.Where(evt =>
|
||||
return Assert.Single(events, evt =>
|
||||
evt.GetProperty("type").GetString() == eventType
|
||||
&& evt.GetProperty("requestId").GetString() == requestId));
|
||||
&& evt.GetProperty("requestId").GetString() == requestId);
|
||||
}
|
||||
|
||||
private static SidecarProtocolHost CreateHostForTests()
|
||||
@@ -605,34 +1052,46 @@ 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>,
|
||||
Func<ExitPlanModeRequestedEventDto, Task>,
|
||||
CancellationToken,
|
||||
Task<IReadOnlyList<ChatMessageDto>>> _handler;
|
||||
private readonly Func<ResolveApprovalCommandDto, CancellationToken, Task> _resolveApprovalHandler;
|
||||
private readonly Func<ResolveUserInputCommandDto, CancellationToken, Task> _resolveUserInputHandler;
|
||||
|
||||
public FakeWorkflowRunner(
|
||||
Func<
|
||||
RunTurnCommandDto,
|
||||
Func<TurnDeltaEventDto, Task>,
|
||||
Func<AgentActivityEventDto, Task>,
|
||||
Func<SidecarEventDto, Task>,
|
||||
Func<ApprovalRequestedEventDto, Task>,
|
||||
Func<UserInputRequestedEventDto, Task>,
|
||||
Func<McpOauthRequiredEventDto, Task>,
|
||||
Func<ExitPlanModeRequestedEventDto, Task>,
|
||||
CancellationToken,
|
||||
Task<IReadOnlyList<ChatMessageDto>>> handler,
|
||||
Func<ResolveApprovalCommandDto, CancellationToken, Task>? resolveApprovalHandler = null)
|
||||
Func<ResolveApprovalCommandDto, CancellationToken, Task>? resolveApprovalHandler = null,
|
||||
Func<ResolveUserInputCommandDto, CancellationToken, Task>? resolveUserInputHandler = null)
|
||||
{
|
||||
_handler = handler;
|
||||
_resolveApprovalHandler = resolveApprovalHandler ?? ((_, _) => Task.CompletedTask);
|
||||
_resolveUserInputHandler = resolveUserInputHandler ?? ((_, _) => Task.CompletedTask);
|
||||
}
|
||||
|
||||
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,
|
||||
Func<ExitPlanModeRequestedEventDto, Task> onExitPlanMode,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return _handler(command, onDelta, onActivity, onApproval, cancellationToken);
|
||||
return _handler(command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken);
|
||||
}
|
||||
|
||||
public Task ResolveApprovalAsync(
|
||||
@@ -641,5 +1100,40 @@ public sealed class SidecarProtocolHostTests
|
||||
{
|
||||
return _resolveApprovalHandler(command, cancellationToken);
|
||||
}
|
||||
|
||||
public Task ResolveUserInputAsync(
|
||||
ResolveUserInputCommandDto command,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return _resolveUserInputHandler(command, cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
private sealed class FakeSessionManager : ICopilotSessionManager
|
||||
{
|
||||
public IReadOnlyList<CopilotSessionInfoDto> Sessions { get; init; } = [];
|
||||
|
||||
public IReadOnlyList<CopilotSessionInfoDto> DeletedSessions { get; init; } = [];
|
||||
|
||||
public string? DeletedAryxSessionId { get; private set; }
|
||||
|
||||
public string? DeletedCopilotSessionId { get; private set; }
|
||||
|
||||
public Task<IReadOnlyList<CopilotSessionInfoDto>> ListSessionsAsync(
|
||||
CopilotSessionListFilterDto? filter,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return Task.FromResult(Sessions);
|
||||
}
|
||||
|
||||
public Task<IReadOnlyList<CopilotSessionInfoDto>> DeleteSessionsAsync(
|
||||
string? aryxSessionId,
|
||||
string? copilotSessionId,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
DeletedAryxSessionId = aryxSessionId;
|
||||
DeletedCopilotSessionId = copilotSessionId;
|
||||
return Task.FromResult(DeletedSessions);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+511
-13
@@ -1,5 +1,5 @@
|
||||
import { EventEmitter } from 'node:events';
|
||||
import { rm } from 'node:fs/promises';
|
||||
import { mkdir, rm } from 'node:fs/promises';
|
||||
import { basename, dirname } from 'node:path';
|
||||
|
||||
import electron from 'electron';
|
||||
@@ -7,10 +7,14 @@ import electron from 'electron';
|
||||
import type {
|
||||
AgentActivityEvent,
|
||||
ApprovalRequestedEvent,
|
||||
ExitPlanModeRequestedEvent,
|
||||
McpOauthRequiredEvent,
|
||||
RunTurnToolingConfig,
|
||||
SidecarCapabilities,
|
||||
TurnDeltaEvent,
|
||||
UserInputRequestedEvent,
|
||||
} from '@shared/contracts/sidecar';
|
||||
import type { TurnScopedEvent } from '@main/sidecar/runTurnPending';
|
||||
import {
|
||||
buildAvailableModelCatalog,
|
||||
findModel,
|
||||
@@ -39,6 +43,7 @@ import {
|
||||
normalizeSessionApprovalSettings,
|
||||
pruneApprovalPolicyTools,
|
||||
pruneSessionApprovalSettings,
|
||||
resolveApprovalToolKey,
|
||||
resolvePendingApproval,
|
||||
type ApprovalDecision,
|
||||
type PendingApprovalMessageRecord,
|
||||
@@ -83,6 +88,8 @@ import {
|
||||
type AppearanceTheme,
|
||||
type LspProfileDefinition,
|
||||
type McpServerDefinition,
|
||||
type SessionToolingSelection,
|
||||
type WorkspaceToolingSettings,
|
||||
normalizeLspProfileDefinition,
|
||||
normalizeMcpServerDefinition,
|
||||
normalizeSessionToolingSelection,
|
||||
@@ -94,6 +101,7 @@ import { createId, nowIso } from '@shared/utils/ids';
|
||||
import { mergeStreamingText } from '@shared/utils/streamingText';
|
||||
|
||||
import { WorkspaceRepository } from '@main/persistence/workspaceRepository';
|
||||
import { getScratchpadSessionPath } from '@main/persistence/appPaths';
|
||||
import { SecretStore } from '@main/secrets/secretStore';
|
||||
import { ConfigScannerRegistry } from '@main/services/configScanner';
|
||||
import {
|
||||
@@ -106,6 +114,8 @@ import {
|
||||
buildRunTurnToolingConfig as buildSessionToolingConfig,
|
||||
validateSessionToolingSelectionIds,
|
||||
} from '@main/sessionToolingConfig';
|
||||
import { getStoredToken } from '@main/services/mcpTokenStore';
|
||||
import { performMcpOAuthFlow, requiresOAuth } from '@main/services/mcpOAuthService';
|
||||
|
||||
const { dialog, shell } = electron;
|
||||
|
||||
@@ -117,7 +127,13 @@ type AppServiceEvents = {
|
||||
type PendingApprovalHandle = {
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
resolve: (decision: ApprovalDecision) => void | Promise<void>;
|
||||
resolve: (decision: ApprovalDecision, alwaysApprove?: boolean) => void | Promise<void>;
|
||||
};
|
||||
|
||||
type PendingUserInputHandle = {
|
||||
sessionId: string;
|
||||
requestId: string;
|
||||
resolve: (answer: string, wasFreeform: boolean) => void | Promise<void>;
|
||||
};
|
||||
|
||||
type DiscoveredToolingResolution = 'accept' | 'dismiss';
|
||||
@@ -147,6 +163,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
private readonly gitService = new GitService();
|
||||
private readonly configScanner = new ConfigScannerRegistry();
|
||||
private readonly pendingApprovalHandles = new Map<string, PendingApprovalHandle>();
|
||||
private readonly pendingUserInputHandles = new Map<string, PendingUserInputHandle>();
|
||||
private workspace?: WorkspaceState;
|
||||
private sidecarCapabilities?: SidecarCapabilities;
|
||||
private sidecarCapabilitiesPromise?: Promise<SidecarCapabilities>;
|
||||
@@ -219,7 +236,9 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
this.sidecarCapabilitiesPromise = undefined;
|
||||
this.didScheduleInitialProjectGitRefresh = false;
|
||||
|
||||
return this.loadWorkspace();
|
||||
const workspace = await this.loadWorkspace();
|
||||
this.emit('workspace-updated', workspace);
|
||||
return workspace;
|
||||
}
|
||||
|
||||
async addProject(): Promise<WorkspaceState> {
|
||||
@@ -328,7 +347,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
async savePattern(pattern: PatternDefinition): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const knownApprovalToolNames = await this.listKnownApprovalToolNames(workspace);
|
||||
const synchronizedPattern = syncPatternGraph(pattern);
|
||||
const synchronizedPattern = pattern.graph ? pattern : syncPatternGraph(pattern);
|
||||
const issues = validatePatternDefinition(
|
||||
synchronizedPattern,
|
||||
knownApprovalToolNames,
|
||||
@@ -503,6 +522,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
runs: [],
|
||||
};
|
||||
|
||||
await this.ensureScratchpadSessionDirectory(session);
|
||||
workspace.sessions.unshift(session);
|
||||
workspace.selectedProjectId = project.id;
|
||||
workspace.selectedPatternId = pattern.id;
|
||||
@@ -514,7 +534,11 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
const duplicate = duplicateSessionRecord(session, createId('session'), nowIso());
|
||||
if (isScratchpadProject(duplicate.projectId)) {
|
||||
duplicate.cwd = undefined;
|
||||
}
|
||||
|
||||
await this.ensureScratchpadSessionDirectory(duplicate);
|
||||
workspace.sessions.unshift(duplicate);
|
||||
workspace.selectedProjectId = duplicate.projectId;
|
||||
workspace.selectedPatternId = duplicate.patternId;
|
||||
@@ -547,10 +571,50 @@ 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.`);
|
||||
}
|
||||
|
||||
const session = workspace.sessions[sessionIndex];
|
||||
if (!session) {
|
||||
throw new Error(`Session ${sessionId} not found.`);
|
||||
}
|
||||
|
||||
const scratchpadDirectory = this.resolveScratchpadSessionDirectory(session);
|
||||
if (scratchpadDirectory) {
|
||||
await rm(scratchpadDirectory, { recursive: true, force: true });
|
||||
}
|
||||
|
||||
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);
|
||||
@@ -572,10 +636,13 @@ 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';
|
||||
session.lastError = undefined;
|
||||
session.pendingPlanReview = undefined;
|
||||
session.pendingMcpAuth = undefined;
|
||||
session.updatedAt = occurredAt;
|
||||
session.runs = [
|
||||
createSessionRunRecord({
|
||||
@@ -603,10 +670,13 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
type: 'run-turn',
|
||||
requestId,
|
||||
sessionId: session.id,
|
||||
projectPath: project.path,
|
||||
projectPath: session.cwd ?? 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) => {
|
||||
@@ -616,8 +686,21 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
await this.applyAgentActivity(workspace, session.id, requestId, event);
|
||||
},
|
||||
async (event) => {
|
||||
await this.handleApprovalRequested(workspace, session.id, requestId, event, (decision) =>
|
||||
this.sidecar.resolveApproval(event.approvalId, decision));
|
||||
await this.handleApprovalRequested(workspace, session.id, requestId, event, (decision, alwaysApprove) =>
|
||||
this.sidecar.resolveApproval(event.approvalId, decision, alwaysApprove));
|
||||
},
|
||||
async (event) => {
|
||||
await this.handleUserInputRequested(workspace, session.id, requestId, event, (answer, wasFreeform) =>
|
||||
this.sidecar.resolveUserInput(event.userInputId, answer, wasFreeform));
|
||||
},
|
||||
async (event) => {
|
||||
await this.handleMcpOAuthRequired(workspace, session.id, event);
|
||||
},
|
||||
async (event) => {
|
||||
await this.handleExitPlanModeRequested(workspace, session.id, event);
|
||||
},
|
||||
async (event) => {
|
||||
await this.handleTurnScopedEvent(workspace, session.id, event);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -672,6 +755,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
sessionId: string,
|
||||
approvalId: string,
|
||||
decision: ApprovalDecision,
|
||||
alwaysApprove?: boolean,
|
||||
): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
@@ -699,9 +783,38 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
this.setSessionPendingApprovalState(session, dequeuePendingApprovalState(session, approvalId));
|
||||
session.updatedAt = resolvedAt;
|
||||
|
||||
const approvalKey = resolveApprovalToolKey(approval.toolName, approval.permissionKind);
|
||||
if (decision === 'approved' && alwaysApprove && approvalKey) {
|
||||
const existing = session.approvalSettings?.autoApprovedToolNames ?? [];
|
||||
if (!existing.includes(approvalKey)) {
|
||||
session.approvalSettings = { autoApprovedToolNames: [...existing, approvalKey] };
|
||||
}
|
||||
}
|
||||
|
||||
const updatedRun = this.updateSessionRun(session, handle.requestId, (run) =>
|
||||
upsertRunApprovalEvent(run, resolvedApproval));
|
||||
|
||||
// Auto-resolve queued approvals that share the same category key.
|
||||
// When the user approves "read", all pending view/grep/glob calls resolve too.
|
||||
const cascadeHandles: PendingApprovalHandle[] = [];
|
||||
if (decision === 'approved' && approvalKey && approval.kind === 'tool-call') {
|
||||
for (const queued of listPendingApprovals(session)) {
|
||||
if (queued.id === approvalId) continue;
|
||||
const queuedKey = resolveApprovalToolKey(queued.toolName, queued.permissionKind);
|
||||
if (queuedKey !== approvalKey) continue;
|
||||
|
||||
const queuedHandle = this.pendingApprovalHandles.get(queued.id);
|
||||
if (!queuedHandle || queuedHandle.sessionId !== sessionId) continue;
|
||||
|
||||
const cascadeResolved = resolvePendingApproval(queued, 'approved', resolvedAt);
|
||||
this.setSessionPendingApprovalState(session, dequeuePendingApprovalState(session, queued.id));
|
||||
this.updateSessionRun(session, queuedHandle.requestId, (run) =>
|
||||
upsertRunApprovalEvent(run, cascadeResolved));
|
||||
this.pendingApprovalHandles.delete(queued.id);
|
||||
cascadeHandles.push(queuedHandle);
|
||||
}
|
||||
}
|
||||
|
||||
const result = await this.persistAndBroadcast(workspace);
|
||||
if (updatedRun) {
|
||||
this.emitRunUpdated(sessionId, resolvedAt, updatedRun);
|
||||
@@ -710,7 +823,10 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
this.pendingApprovalHandles.delete(approvalId);
|
||||
|
||||
try {
|
||||
await Promise.resolve(handle.resolve(decision));
|
||||
await Promise.resolve(handle.resolve(decision, alwaysApprove));
|
||||
for (const cascaded of cascadeHandles) {
|
||||
await Promise.resolve(cascaded.resolve('approved', alwaysApprove));
|
||||
}
|
||||
} catch (error) {
|
||||
const failedAt = nowIso();
|
||||
this.rejectPendingApprovals(
|
||||
@@ -742,6 +858,59 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
return result;
|
||||
}
|
||||
|
||||
async resolveSessionUserInput(
|
||||
sessionId: string,
|
||||
userInputId: string,
|
||||
answer: string,
|
||||
wasFreeform: boolean,
|
||||
): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
const pending = session.pendingUserInput;
|
||||
if (!pending || pending.id !== userInputId) {
|
||||
throw new Error(`User input "${userInputId}" is not pending for session "${sessionId}".`);
|
||||
}
|
||||
|
||||
const handle = this.pendingUserInputHandles.get(userInputId);
|
||||
if (!handle || handle.sessionId !== sessionId) {
|
||||
throw new Error(`User input "${userInputId}" is no longer active. Restart the run and try again.`);
|
||||
}
|
||||
|
||||
const answeredAt = nowIso();
|
||||
session.pendingUserInput = {
|
||||
...pending,
|
||||
status: 'answered',
|
||||
answer,
|
||||
answeredAt,
|
||||
};
|
||||
session.updatedAt = answeredAt;
|
||||
|
||||
const result = await this.persistAndBroadcast(workspace);
|
||||
this.pendingUserInputHandles.delete(userInputId);
|
||||
|
||||
try {
|
||||
await Promise.resolve(handle.resolve(answer, wasFreeform));
|
||||
session.pendingUserInput = undefined;
|
||||
await this.persistAndBroadcast(workspace);
|
||||
} catch (error) {
|
||||
session.status = 'error';
|
||||
session.lastError = error instanceof Error ? error.message : String(error);
|
||||
session.updatedAt = nowIso();
|
||||
|
||||
this.emitSessionEvent({
|
||||
sessionId,
|
||||
kind: 'error',
|
||||
occurredAt: session.updatedAt,
|
||||
error: session.lastError,
|
||||
});
|
||||
|
||||
await this.persistAndBroadcast(workspace);
|
||||
throw error;
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async updateSessionModelConfig(
|
||||
sessionId: string,
|
||||
model: string,
|
||||
@@ -780,6 +949,125 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async setSessionInteractionMode(
|
||||
sessionId: string,
|
||||
mode: 'interactive' | 'plan',
|
||||
): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
|
||||
session.interactionMode = mode === 'interactive' ? undefined : mode;
|
||||
session.updatedAt = nowIso();
|
||||
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async dismissSessionPlanReview(sessionId: string): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
|
||||
session.pendingPlanReview = undefined;
|
||||
session.updatedAt = nowIso();
|
||||
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async dismissSessionMcpAuth(sessionId: string): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
|
||||
session.pendingMcpAuth = undefined;
|
||||
session.updatedAt = nowIso();
|
||||
|
||||
return this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
async startSessionMcpAuth(sessionId: string): Promise<WorkspaceState> {
|
||||
const workspace = await this.loadWorkspace();
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
|
||||
if (!session.pendingMcpAuth) {
|
||||
return workspace;
|
||||
}
|
||||
|
||||
session.pendingMcpAuth.status = 'authenticating';
|
||||
session.updatedAt = nowIso();
|
||||
await this.persistAndBroadcast(workspace);
|
||||
|
||||
const result = await performMcpOAuthFlow({
|
||||
serverUrl: session.pendingMcpAuth.serverUrl,
|
||||
staticClientConfig: session.pendingMcpAuth.staticClientConfig,
|
||||
});
|
||||
|
||||
const workspaceAfter = await this.loadWorkspace();
|
||||
const sessionAfter = this.requireSession(workspaceAfter, sessionId);
|
||||
|
||||
if (!sessionAfter.pendingMcpAuth) {
|
||||
return workspaceAfter;
|
||||
}
|
||||
|
||||
if (result.success) {
|
||||
sessionAfter.pendingMcpAuth.status = 'authenticated';
|
||||
sessionAfter.pendingMcpAuth.completedAt = nowIso();
|
||||
} else {
|
||||
sessionAfter.pendingMcpAuth.status = 'failed';
|
||||
sessionAfter.pendingMcpAuth.errorMessage = result.error ?? 'Authentication failed';
|
||||
}
|
||||
|
||||
sessionAfter.updatedAt = nowIso();
|
||||
return this.persistAndBroadcast(workspaceAfter);
|
||||
}
|
||||
|
||||
/**
|
||||
* Proactively probes newly-enabled HTTP MCP servers for OAuth requirements.
|
||||
* If a server returns 401 and has discoverable OAuth metadata, automatically
|
||||
* triggers the OAuth flow (opens browser for consent) without user interaction.
|
||||
*/
|
||||
private async probeAndAuthenticateHttpMcpServers(
|
||||
sessionId: string,
|
||||
tooling: WorkspaceToolingSettings,
|
||||
selection: SessionToolingSelection,
|
||||
): Promise<void> {
|
||||
const httpServers = selection.enabledMcpServerIds
|
||||
.map((id) => tooling.mcpServers.find((s) => s.id === id))
|
||||
.filter((s): s is McpServerDefinition => !!s && s.transport !== 'local')
|
||||
.filter((s) => s.transport === 'http' || s.transport === 'sse');
|
||||
|
||||
if (httpServers.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`[aryx oauth] Probing ${httpServers.length} HTTP MCP server(s) for OAuth requirements…`);
|
||||
|
||||
for (const server of httpServers) {
|
||||
if (server.transport === 'local') continue;
|
||||
const existingToken = getStoredToken(server.url);
|
||||
if (existingToken) {
|
||||
console.log(`[aryx oauth] Skipping ${server.name} — token already stored`);
|
||||
continue;
|
||||
}
|
||||
|
||||
try {
|
||||
const needsAuth = await requiresOAuth(server.url);
|
||||
if (!needsAuth) {
|
||||
console.log(`[aryx oauth] ${server.name} does not require OAuth`);
|
||||
continue;
|
||||
}
|
||||
|
||||
console.log(`[aryx oauth] ${server.name} requires OAuth — starting flow…`);
|
||||
const result = await performMcpOAuthFlow({ serverUrl: server.url });
|
||||
|
||||
if (result.success) {
|
||||
console.log(`[aryx oauth] ${server.name} authenticated successfully`);
|
||||
} else {
|
||||
console.warn(`[aryx oauth] Proactive auth failed for ${server.name}: ${result.error}`);
|
||||
}
|
||||
} catch (err) {
|
||||
console.warn(`[aryx oauth] Proactive auth probe failed for ${server.name}:`, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async updateSessionTooling(
|
||||
sessionId: string,
|
||||
enabledMcpServerIds: string[],
|
||||
@@ -802,9 +1090,26 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
selection,
|
||||
);
|
||||
|
||||
const previousEnabledMcpServerIds = new Set(session.tooling?.enabledMcpServerIds ?? []);
|
||||
session.tooling = selection;
|
||||
session.updatedAt = nowIso();
|
||||
return this.persistAndBroadcast(workspace);
|
||||
const result = await this.persistAndBroadcast(workspace);
|
||||
|
||||
// Proactively authenticate only newly enabled HTTP MCP servers
|
||||
const newlyEnabledIds = selection.enabledMcpServerIds.filter((id) => !previousEnabledMcpServerIds.has(id));
|
||||
if (newlyEnabledIds.length > 0) {
|
||||
const selectionForNewServers = normalizeSessionToolingSelection({
|
||||
enabledMcpServerIds: newlyEnabledIds,
|
||||
enabledLspProfileIds: [],
|
||||
});
|
||||
void this.probeAndAuthenticateHttpMcpServers(
|
||||
sessionId,
|
||||
resolveProjectToolingSettings(workspace.settings, project.discoveredTooling),
|
||||
selectionForNewServers,
|
||||
);
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
async updateSessionApprovalSettings(
|
||||
@@ -936,6 +1241,26 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
return session;
|
||||
}
|
||||
|
||||
private resolveScratchpadSessionDirectory(
|
||||
session: Pick<SessionRecord, 'id' | 'projectId' | 'cwd'>,
|
||||
): string | undefined {
|
||||
if (!isScratchpadProject(session.projectId)) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return session.cwd ?? getScratchpadSessionPath(session.id);
|
||||
}
|
||||
|
||||
private async ensureScratchpadSessionDirectory(session: SessionRecord): Promise<void> {
|
||||
const scratchpadDirectory = this.resolveScratchpadSessionDirectory(session);
|
||||
if (!scratchpadDirectory) {
|
||||
return;
|
||||
}
|
||||
|
||||
await mkdir(scratchpadDirectory, { recursive: true });
|
||||
session.cwd = scratchpadDirectory;
|
||||
}
|
||||
|
||||
private async applyTurnDelta(
|
||||
workspace: WorkspaceState,
|
||||
sessionId: string,
|
||||
@@ -1114,6 +1439,9 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
const completedAt = nowIso();
|
||||
session.status = 'idle';
|
||||
session.lastError = undefined;
|
||||
session.pendingUserInput = undefined;
|
||||
session.pendingPlanReview = undefined;
|
||||
session.pendingMcpAuth = undefined;
|
||||
session.updatedAt = completedAt;
|
||||
const completedRun = this.updateSessionRun(session, requestId, (run) =>
|
||||
completeSessionRunRecord(run, completedAt));
|
||||
@@ -1144,6 +1472,9 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
const cancelledAt = nowIso();
|
||||
session.status = 'idle';
|
||||
session.lastError = undefined;
|
||||
session.pendingUserInput = undefined;
|
||||
session.pendingPlanReview = undefined;
|
||||
session.pendingMcpAuth = undefined;
|
||||
session.updatedAt = cancelledAt;
|
||||
const cancelledRun = this.updateSessionRun(session, requestId, (run) =>
|
||||
cancelSessionRunRecord(run, cancelledAt));
|
||||
@@ -1163,7 +1494,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
sessionId: string,
|
||||
requestId: string,
|
||||
approval: ApprovalRequestedEvent | PendingApprovalRecord,
|
||||
resolve: (decision: ApprovalDecision) => void | Promise<void>,
|
||||
resolve: (decision: ApprovalDecision, alwaysApprove?: boolean) => void | Promise<void>,
|
||||
): Promise<void> {
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
const pendingApproval =
|
||||
@@ -1187,6 +1518,169 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
}
|
||||
}
|
||||
|
||||
private async handleUserInputRequested(
|
||||
workspace: WorkspaceState,
|
||||
sessionId: string,
|
||||
_requestId: string,
|
||||
event: UserInputRequestedEvent,
|
||||
resolve: (answer: string, wasFreeform: boolean) => void | Promise<void>,
|
||||
): Promise<void> {
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
const requestedAt = nowIso();
|
||||
|
||||
session.pendingUserInput = {
|
||||
id: event.userInputId,
|
||||
status: 'pending',
|
||||
agentId: event.agentId,
|
||||
agentName: event.agentName,
|
||||
question: event.question,
|
||||
choices: event.choices,
|
||||
allowFreeform: event.allowFreeform ?? true,
|
||||
requestedAt,
|
||||
};
|
||||
session.updatedAt = requestedAt;
|
||||
|
||||
this.pendingUserInputHandles.set(event.userInputId, {
|
||||
sessionId,
|
||||
requestId: _requestId,
|
||||
resolve,
|
||||
});
|
||||
|
||||
await this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
private async handleExitPlanModeRequested(
|
||||
workspace: WorkspaceState,
|
||||
sessionId: string,
|
||||
event: ExitPlanModeRequestedEvent,
|
||||
): Promise<void> {
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
const requestedAt = nowIso();
|
||||
|
||||
session.pendingPlanReview = {
|
||||
id: event.exitPlanId,
|
||||
status: 'pending',
|
||||
agentId: event.agentId,
|
||||
agentName: event.agentName,
|
||||
summary: event.summary,
|
||||
planContent: event.planContent,
|
||||
actions: event.actions,
|
||||
recommendedAction: event.recommendedAction,
|
||||
requestedAt,
|
||||
};
|
||||
session.updatedAt = requestedAt;
|
||||
|
||||
await this.persistAndBroadcast(workspace);
|
||||
}
|
||||
|
||||
private async handleMcpOAuthRequired(
|
||||
workspace: WorkspaceState,
|
||||
sessionId: string,
|
||||
event: McpOauthRequiredEvent,
|
||||
): Promise<void> {
|
||||
const session = this.requireSession(workspace, sessionId);
|
||||
const requestedAt = nowIso();
|
||||
|
||||
session.pendingMcpAuth = {
|
||||
id: event.oauthRequestId,
|
||||
status: 'pending',
|
||||
agentId: event.agentId,
|
||||
agentName: event.agentName,
|
||||
serverName: event.serverName,
|
||||
serverUrl: event.serverUrl,
|
||||
staticClientConfig: event.staticClientConfig
|
||||
? { clientId: event.staticClientConfig.clientId, publicClient: event.staticClientConfig.publicClient }
|
||||
: undefined,
|
||||
requestedAt,
|
||||
};
|
||||
session.updatedAt = requestedAt;
|
||||
|
||||
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,
|
||||
@@ -1199,6 +1693,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
permissionKind: event.permissionKind,
|
||||
title: event.title,
|
||||
detail: event.detail,
|
||||
permissionDetail: event.permissionDetail,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1404,7 +1899,10 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
||||
const tooling = resolveProjectToolingSettings(workspace.settings, project.discoveredTooling);
|
||||
const selection = resolveSessionToolingSelection(session);
|
||||
validateSessionToolingSelectionIds(tooling, selection);
|
||||
return buildSessionToolingConfig(tooling, selection);
|
||||
return buildSessionToolingConfig(tooling, selection, (serverUrl) => {
|
||||
const token = getStoredToken(serverUrl);
|
||||
return token?.accessToken;
|
||||
});
|
||||
}
|
||||
|
||||
private async syncUserDiscoveredTooling(workspace: WorkspaceState): Promise<boolean> {
|
||||
|
||||
@@ -7,20 +7,26 @@ import type {
|
||||
CreateSessionInput,
|
||||
ResolveProjectDiscoveredToolingInput,
|
||||
ResolveWorkspaceDiscoveredToolingInput,
|
||||
DismissSessionPlanReviewInput,
|
||||
DismissSessionMcpAuthInput,
|
||||
StartSessionMcpAuthInput,
|
||||
DuplicateSessionInput,
|
||||
RenameSessionInput,
|
||||
RescanProjectConfigsInput,
|
||||
ResolveSessionApprovalInput,
|
||||
ResolveSessionUserInputInput,
|
||||
SaveLspProfileInput,
|
||||
SaveMcpServerInput,
|
||||
SavePatternInput,
|
||||
SendSessionMessageInput,
|
||||
SetPatternFavoriteInput,
|
||||
SetSessionArchivedInput,
|
||||
SetSessionInteractionModeInput,
|
||||
SetSessionPinnedInput,
|
||||
UpdateSessionApprovalSettingsInput,
|
||||
UpdateSessionToolingInput,
|
||||
UpdateSessionModelConfigInput,
|
||||
DeleteSessionInput,
|
||||
} from '@shared/contracts/ipc';
|
||||
import type { QuerySessionsInput } from '@shared/domain/sessionLibrary';
|
||||
import type { AppearanceTheme } from '@shared/domain/tooling';
|
||||
@@ -101,14 +107,32 @@ 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),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.resolveSessionApproval, (_event, input: ResolveSessionApprovalInput) =>
|
||||
service.resolveSessionApproval(input.sessionId, input.approvalId, input.decision),
|
||||
service.resolveSessionApproval(input.sessionId, input.approvalId, input.decision, input.alwaysApprove),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.resolveSessionUserInput, (_event, input: ResolveSessionUserInputInput) =>
|
||||
service.resolveSessionUserInput(input.sessionId, input.userInputId, input.answer, input.wasFreeform),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.setSessionInteractionMode, (_event, input: SetSessionInteractionModeInput) =>
|
||||
service.setSessionInteractionMode(input.sessionId, input.mode),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.dismissSessionPlanReview, (_event, input: DismissSessionPlanReviewInput) =>
|
||||
service.dismissSessionPlanReview(input.sessionId),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.dismissSessionMcpAuth, (_event, input: DismissSessionMcpAuthInput) =>
|
||||
service.dismissSessionMcpAuth(input.sessionId),
|
||||
);
|
||||
ipcMain.handle(ipcChannels.startSessionMcpAuth, (_event, input: StartSessionMcpAuthInput) =>
|
||||
service.startSessionMcpAuth(input.sessionId),
|
||||
);
|
||||
ipcMain.handle(
|
||||
ipcChannels.updateSessionModelConfig,
|
||||
|
||||
@@ -10,3 +10,7 @@ export function getWorkspaceFilePath(): string {
|
||||
export function getScratchpadDirectoryPath(): string {
|
||||
return join(app.getPath('userData'), 'scratchpad');
|
||||
}
|
||||
|
||||
export function getScratchpadSessionPath(sessionId: string): string {
|
||||
return join(getScratchpadDirectoryPath(), sessionId);
|
||||
}
|
||||
|
||||
@@ -2,9 +2,10 @@ import { mkdir } from 'node:fs/promises';
|
||||
|
||||
import { createBuiltinPatterns, resolvePatternGraph } from '@shared/domain/pattern';
|
||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
import { mergeScratchpadProject } from '@shared/domain/project';
|
||||
import { isScratchpadProject, mergeScratchpadProject } from '@shared/domain/project';
|
||||
import { normalizeDiscoveredToolingState } from '@shared/domain/discoveredTooling';
|
||||
import { normalizeSessionRunRecords } from '@shared/domain/runTimeline';
|
||||
import type { SessionRecord } from '@shared/domain/session';
|
||||
import {
|
||||
normalizeSessionToolingSelection,
|
||||
normalizeWorkspaceSettings,
|
||||
@@ -17,7 +18,11 @@ import {
|
||||
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
||||
import { nowIso } from '@shared/utils/ids';
|
||||
|
||||
import { getScratchpadDirectoryPath, getWorkspaceFilePath } from '@main/persistence/appPaths';
|
||||
import {
|
||||
getScratchpadDirectoryPath,
|
||||
getScratchpadSessionPath,
|
||||
getWorkspaceFilePath,
|
||||
} from '@main/persistence/appPaths';
|
||||
import { readJsonFile, writeJsonFile } from '@main/persistence/jsonStore';
|
||||
|
||||
function mergePatterns(existingPatterns: PatternDefinition[]): PatternDefinition[] {
|
||||
@@ -71,6 +76,28 @@ export class WorkspaceRepository {
|
||||
})),
|
||||
this.scratchpadPath,
|
||||
);
|
||||
const sessions = await Promise.all((stored.sessions ?? []).map(async (session): Promise<SessionRecord> => {
|
||||
const normalizedSession: SessionRecord = {
|
||||
...session,
|
||||
runs: normalizeSessionRunRecords(session.runs),
|
||||
tooling: normalizeSessionToolingSelection(session.tooling),
|
||||
approvalSettings: normalizeSessionApprovalSettings(session.approvalSettings),
|
||||
...normalizePendingApprovalState({
|
||||
pendingApproval: session.pendingApproval,
|
||||
pendingApprovalQueue: session.pendingApprovalQueue,
|
||||
}),
|
||||
};
|
||||
if (!isScratchpadProject(normalizedSession.projectId)) {
|
||||
return normalizedSession;
|
||||
}
|
||||
|
||||
const cwd = normalizedSession.cwd ?? getScratchpadSessionPath(normalizedSession.id);
|
||||
await mkdir(cwd, { recursive: true });
|
||||
return {
|
||||
...normalizedSession,
|
||||
cwd,
|
||||
};
|
||||
}));
|
||||
const settings = normalizeWorkspaceSettings(stored.settings);
|
||||
|
||||
const workspace: WorkspaceState = {
|
||||
@@ -81,16 +108,7 @@ export class WorkspaceRepository {
|
||||
graph: resolvePatternGraph(pattern),
|
||||
})),
|
||||
projects,
|
||||
sessions: (stored.sessions ?? []).map((session) => ({
|
||||
...session,
|
||||
runs: normalizeSessionRunRecords(session.runs),
|
||||
tooling: normalizeSessionToolingSelection(session.tooling),
|
||||
approvalSettings: normalizeSessionApprovalSettings(session.approvalSettings),
|
||||
...normalizePendingApprovalState({
|
||||
pendingApproval: session.pendingApproval,
|
||||
pendingApprovalQueue: session.pendingApprovalQueue,
|
||||
}),
|
||||
})),
|
||||
sessions,
|
||||
settings,
|
||||
selectedProjectId: projects.some((project) => project.id === stored.selectedProjectId)
|
||||
? stored.selectedProjectId
|
||||
|
||||
@@ -0,0 +1,550 @@
|
||||
import { randomBytes, createHash } from 'node:crypto';
|
||||
import { createServer, type Server, type IncomingMessage, type ServerResponse } from 'node:http';
|
||||
|
||||
import { shell } from 'electron';
|
||||
|
||||
import type { McpOauthStaticClientConfig } from '@shared/domain/mcpAuth';
|
||||
|
||||
import { storeToken, buildWellKnownUrl, buildWellKnownUrlFallback, type McpOAuthToken } from './mcpTokenStore';
|
||||
|
||||
/* ── Public API ──────────────────────────────────────────────── */
|
||||
|
||||
export interface McpOAuthFlowOptions {
|
||||
serverUrl: string;
|
||||
staticClientConfig?: McpOauthStaticClientConfig;
|
||||
onStatusChange?: (status: 'discovering' | 'awaiting-consent' | 'exchanging') => void;
|
||||
}
|
||||
|
||||
export interface McpOAuthFlowResult {
|
||||
success: boolean;
|
||||
token?: McpOAuthToken;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
const VSCODE_REDIRECT_URI = 'https://vscode.dev/redirect';
|
||||
|
||||
interface KnownOAuthProvider {
|
||||
id: 'github' | 'entra';
|
||||
clientId: string;
|
||||
redirectMode: 'vscode-dev';
|
||||
authorizationEndpoint?: string;
|
||||
tokenEndpoint?: string;
|
||||
scopes?: readonly string[];
|
||||
authorizationParams?: Readonly<Record<string, string>>;
|
||||
includeOfflineAccess?: boolean;
|
||||
}
|
||||
|
||||
interface KnownOAuthProviderConfig extends KnownOAuthProvider {
|
||||
matches: (url: URL) => boolean;
|
||||
}
|
||||
|
||||
const GITHUB_PROVIDER_SCOPES = [
|
||||
'codespace',
|
||||
'gist',
|
||||
'notifications',
|
||||
'project',
|
||||
'read:org',
|
||||
'read:packages',
|
||||
'read:project',
|
||||
'read:user',
|
||||
'repo',
|
||||
'user:email',
|
||||
'workflow',
|
||||
'write:packages',
|
||||
] as const;
|
||||
|
||||
const knownOAuthProviders: readonly KnownOAuthProviderConfig[] = [
|
||||
{
|
||||
id: 'github',
|
||||
clientId: '01ab8ac9400c4e429b23',
|
||||
redirectMode: 'vscode-dev',
|
||||
authorizationEndpoint: 'https://github.com/login/oauth/authorize',
|
||||
tokenEndpoint: 'https://github.com/login/oauth/access_token',
|
||||
scopes: GITHUB_PROVIDER_SCOPES,
|
||||
authorizationParams: { prompt: 'select_account' },
|
||||
includeOfflineAccess: false,
|
||||
matches: (url) => url.hostname === 'github.com',
|
||||
},
|
||||
{
|
||||
id: 'entra',
|
||||
clientId: 'aebc6443-996d-45c2-90f0-388ff96faa56',
|
||||
redirectMode: 'vscode-dev',
|
||||
includeOfflineAccess: true,
|
||||
matches: (url) => url.hostname === 'login.microsoftonline.com',
|
||||
},
|
||||
] as const;
|
||||
|
||||
export function resolveKnownProvider(authServerUrl: string): KnownOAuthProvider | undefined {
|
||||
try {
|
||||
const parsed = new URL(authServerUrl);
|
||||
const match = knownOAuthProviders.find((candidate) => candidate.matches(parsed));
|
||||
if (!match) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const { matches: _matches, ...provider } = match;
|
||||
return provider;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Probes an MCP server URL to determine if it requires OAuth authentication.
|
||||
* Returns true if the server responds with 401 and has discoverable OAuth metadata.
|
||||
*/
|
||||
export async function requiresOAuth(serverUrl: string): Promise<boolean> {
|
||||
try {
|
||||
const metadata = await fetchWellKnownMetadata(serverUrl, 'oauth-protected-resource');
|
||||
if (!metadata) {
|
||||
console.log(`[aryx oauth] No PRM found for ${serverUrl}`);
|
||||
return false;
|
||||
}
|
||||
|
||||
const hasAuthServers = Array.isArray(metadata.authorization_servers) && metadata.authorization_servers.length > 0;
|
||||
console.log(`[aryx oauth] PRM found for ${serverUrl}: authorization_servers=${hasAuthServers}`);
|
||||
return hasAuthServers;
|
||||
} catch (err) {
|
||||
console.warn(`[aryx oauth] Probe failed for ${serverUrl}:`, err instanceof Error ? err.message : err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Performs the full MCP OAuth 2.1 + PKCE flow:
|
||||
* 1. Discover protected resource metadata (RFC 9728)
|
||||
* 2. Fetch authorization server metadata (RFC 8414)
|
||||
* 3. Resolve client ID (static config or dynamic registration per RFC 7591)
|
||||
* 4. PKCE code verifier + challenge
|
||||
* 5. Open browser for user consent
|
||||
* 6. Local callback server receives auth code
|
||||
* 7. Exchange code for token
|
||||
*/
|
||||
export async function performMcpOAuthFlow(options: McpOAuthFlowOptions): Promise<McpOAuthFlowResult> {
|
||||
const { serverUrl, staticClientConfig, onStatusChange } = options;
|
||||
|
||||
try {
|
||||
onStatusChange?.('discovering');
|
||||
|
||||
const prm = await discoverProtectedResource(serverUrl);
|
||||
const knownProvider = resolveKnownProvider(prm.authorizationServer);
|
||||
const { verifier, challenge } = generatePkceChallenge();
|
||||
const {
|
||||
localRedirectUri,
|
||||
hostedRedirectState,
|
||||
waitForCallback,
|
||||
close,
|
||||
} = await startCallbackServer();
|
||||
|
||||
try {
|
||||
const metadata = await resolveAuthServerMetadata(prm.authorizationServer, knownProvider);
|
||||
const clientId = staticClientConfig?.clientId
|
||||
?? knownProvider?.clientId
|
||||
?? await dynamicClientRegistration(metadata, localRedirectUri, serverUrl);
|
||||
const usesHostedRedirect = knownProvider?.redirectMode === 'vscode-dev';
|
||||
const scopes = buildScopes(knownProvider, prm.resourceScopes, metadata.scopes_supported);
|
||||
const redirectUri = usesHostedRedirect ? VSCODE_REDIRECT_URI : localRedirectUri;
|
||||
const state = usesHostedRedirect ? hostedRedirectState : randomBytes(16).toString('hex');
|
||||
|
||||
const authUrl = buildAuthorizationUrl(metadata.authorization_endpoint, {
|
||||
clientId,
|
||||
redirectUri,
|
||||
codeChallenge: challenge,
|
||||
scope: scopes,
|
||||
state,
|
||||
extraParams: knownProvider?.authorizationParams,
|
||||
});
|
||||
|
||||
onStatusChange?.('awaiting-consent');
|
||||
await shell.openExternal(authUrl);
|
||||
|
||||
const code = await waitForCallback();
|
||||
|
||||
onStatusChange?.('exchanging');
|
||||
const token = await exchangeCodeForToken(metadata.token_endpoint, {
|
||||
code,
|
||||
clientId,
|
||||
redirectUri,
|
||||
codeVerifier: verifier,
|
||||
});
|
||||
|
||||
storeToken(serverUrl, token);
|
||||
return { success: true, token };
|
||||
} finally {
|
||||
close();
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
return { success: false, error: message };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds the OAuth scope string.
|
||||
* Priority: provider-specific scopes > PRM resource scopes > auth server scopes.
|
||||
* `offline_access` is only appended when the provider supports/needs it.
|
||||
*/
|
||||
export function buildScopes(
|
||||
knownProvider: KnownOAuthProvider | undefined,
|
||||
resourceScopes?: string[],
|
||||
authServerScopes?: string[],
|
||||
): string {
|
||||
const scopes = knownProvider?.scopes ?? resourceScopes ?? authServerScopes ?? [];
|
||||
if (scopes.length === 0) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const set = new Set(scopes);
|
||||
if (knownProvider?.includeOfflineAccess ?? true) {
|
||||
set.add('offline_access');
|
||||
}
|
||||
return [...set].join(' ');
|
||||
}
|
||||
|
||||
/* ── Discovery ───────────────────────────────────────────────── */
|
||||
|
||||
interface ProtectedResourceMetadata {
|
||||
resource: string;
|
||||
authorization_servers?: string[];
|
||||
scopes_supported?: string[];
|
||||
}
|
||||
|
||||
interface AuthServerMetadata {
|
||||
issuer: string;
|
||||
authorization_endpoint: string;
|
||||
token_endpoint: string;
|
||||
registration_endpoint?: string;
|
||||
scopes_supported?: string[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Tries to fetch a well-known metadata document from a base URL.
|
||||
* Attempts the RFC 9728 compliant path first (inserted after origin),
|
||||
* then falls back to the appended path (used by some servers).
|
||||
* Returns the parsed JSON or undefined if neither endpoint responds.
|
||||
*/
|
||||
async function fetchWellKnownMetadata(baseUrl: string, suffix: string): Promise<Record<string, unknown> | undefined> {
|
||||
const rfcUrl = buildWellKnownUrl(baseUrl, suffix);
|
||||
const fallbackUrl = buildWellKnownUrlFallback(baseUrl, suffix);
|
||||
const urls = rfcUrl === fallbackUrl ? [rfcUrl] : [rfcUrl, fallbackUrl];
|
||||
|
||||
for (const url of urls) {
|
||||
try {
|
||||
console.log(`[aryx oauth] Trying well-known at ${url}…`);
|
||||
const response = await fetch(url, { signal: AbortSignal.timeout(5_000) });
|
||||
if (response.ok) {
|
||||
const data = await response.json();
|
||||
console.log(`[aryx oauth] Found well-known metadata at ${url}`);
|
||||
return data;
|
||||
}
|
||||
console.log(`[aryx oauth] ${url} returned ${response.status}`);
|
||||
} catch {
|
||||
console.log(`[aryx oauth] ${url} unreachable`);
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
interface PrmDiscoveryResult {
|
||||
authorizationServer: string;
|
||||
resourceScopes?: string[];
|
||||
}
|
||||
|
||||
async function discoverProtectedResource(serverUrl: string): Promise<PrmDiscoveryResult> {
|
||||
const metadata = await fetchWellKnownMetadata(serverUrl, 'oauth-protected-resource');
|
||||
if (!metadata) {
|
||||
throw new Error('Protected Resource Metadata discovery failed: no well-known endpoint found');
|
||||
}
|
||||
|
||||
const prm = metadata as unknown as ProtectedResourceMetadata;
|
||||
const authServer = prm.authorization_servers?.[0];
|
||||
if (!authServer) {
|
||||
throw new Error('No authorization server found in Protected Resource Metadata');
|
||||
}
|
||||
|
||||
return { authorizationServer: authServer, resourceScopes: prm.scopes_supported };
|
||||
}
|
||||
|
||||
async function fetchAuthServerMetadata(authServerUrl: string): Promise<AuthServerMetadata> {
|
||||
// RFC 8414 suffix first, then OpenID Connect Discovery suffix (used by Entra ID, Google, etc.)
|
||||
const metadata =
|
||||
(await fetchWellKnownMetadata(authServerUrl, 'oauth-authorization-server')) ??
|
||||
(await fetchWellKnownMetadata(authServerUrl, 'openid-configuration'));
|
||||
|
||||
if (!metadata) {
|
||||
throw new Error('Authorization Server Metadata fetch failed: no well-known endpoint found');
|
||||
}
|
||||
|
||||
const asMeta = metadata as unknown as AuthServerMetadata;
|
||||
if (!asMeta.authorization_endpoint || !asMeta.token_endpoint) {
|
||||
throw new Error('Authorization server metadata is missing required endpoints');
|
||||
}
|
||||
|
||||
return asMeta;
|
||||
}
|
||||
|
||||
async function resolveAuthServerMetadata(
|
||||
authServerUrl: string,
|
||||
knownProvider: KnownOAuthProvider | undefined,
|
||||
): Promise<AuthServerMetadata> {
|
||||
if (knownProvider?.authorizationEndpoint && knownProvider?.tokenEndpoint) {
|
||||
return {
|
||||
issuer: authServerUrl,
|
||||
authorization_endpoint: knownProvider.authorizationEndpoint,
|
||||
token_endpoint: knownProvider.tokenEndpoint,
|
||||
scopes_supported: knownProvider.scopes ? [...knownProvider.scopes] : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
return fetchAuthServerMetadata(authServerUrl);
|
||||
}
|
||||
|
||||
/* ── Dynamic Client Registration (RFC 7591) ──────────────────── */
|
||||
|
||||
async function dynamicClientRegistration(
|
||||
metadata: AuthServerMetadata,
|
||||
redirectUri: string,
|
||||
serverUrl: string,
|
||||
): Promise<string> {
|
||||
if (!metadata.registration_endpoint) {
|
||||
throw new Error(
|
||||
'No static client ID provided and the authorization server does not support dynamic client registration',
|
||||
);
|
||||
}
|
||||
|
||||
const response = await fetch(metadata.registration_endpoint, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
client_name: 'Aryx',
|
||||
redirect_uris: [redirectUri],
|
||||
grant_types: ['authorization_code'],
|
||||
response_types: ['code'],
|
||||
token_endpoint_auth_method: 'none',
|
||||
scope: metadata.scopes_supported?.join(' ') ?? '',
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
throw new Error(`Dynamic client registration failed: ${response.status} ${response.statusText}`);
|
||||
}
|
||||
|
||||
const registration = await response.json();
|
||||
if (!registration.client_id) {
|
||||
throw new Error('Dynamic client registration response is missing client_id');
|
||||
}
|
||||
|
||||
return registration.client_id;
|
||||
}
|
||||
|
||||
/* ── PKCE ────────────────────────────────────────────────────── */
|
||||
|
||||
function generatePkceChallenge(): { verifier: string; challenge: string } {
|
||||
const verifier = randomBytes(32).toString('base64url');
|
||||
const challenge = createHash('sha256').update(verifier).digest('base64url');
|
||||
return { verifier, challenge };
|
||||
}
|
||||
|
||||
/* ── Authorization URL ───────────────────────────────────────── */
|
||||
|
||||
function buildAuthorizationUrl(
|
||||
authorizationEndpoint: string,
|
||||
params: {
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
codeChallenge: string;
|
||||
scope: string;
|
||||
state: string;
|
||||
extraParams?: Readonly<Record<string, string>>;
|
||||
},
|
||||
): string {
|
||||
const url = new URL(authorizationEndpoint);
|
||||
url.searchParams.set('response_type', 'code');
|
||||
url.searchParams.set('client_id', params.clientId);
|
||||
url.searchParams.set('redirect_uri', params.redirectUri);
|
||||
url.searchParams.set('code_challenge', params.codeChallenge);
|
||||
url.searchParams.set('code_challenge_method', 'S256');
|
||||
if (params.scope) {
|
||||
url.searchParams.set('scope', params.scope);
|
||||
}
|
||||
url.searchParams.set('state', params.state);
|
||||
for (const [key, value] of Object.entries(params.extraParams ?? {})) {
|
||||
url.searchParams.set(key, value);
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
/* ── Local callback server ───────────────────────────────────── */
|
||||
|
||||
interface CallbackServerHandle {
|
||||
port: number;
|
||||
localRedirectUri: string;
|
||||
hostedRedirectState: string;
|
||||
waitForCallback: () => Promise<string>;
|
||||
close: () => void;
|
||||
}
|
||||
|
||||
function startCallbackServer(): Promise<CallbackServerHandle> {
|
||||
return new Promise((resolve, reject) => {
|
||||
let settled = false;
|
||||
let callbackResolve: (code: string) => void;
|
||||
let callbackReject: (err: Error) => void;
|
||||
|
||||
const callbackPromise = new Promise<string>((res, rej) => {
|
||||
callbackResolve = res;
|
||||
callbackReject = rej;
|
||||
});
|
||||
|
||||
const server: Server = createServer((req: IncomingMessage, res: ServerResponse) => {
|
||||
const url = new URL(req.url ?? '/', `http://127.0.0.1`);
|
||||
const code = url.searchParams.get('code');
|
||||
const error = url.searchParams.get('error');
|
||||
const errorDescription = url.searchParams.get('error_description');
|
||||
|
||||
// Ignore requests without code or error (e.g. favicon)
|
||||
if (!code && !error) {
|
||||
res.writeHead(404);
|
||||
res.end();
|
||||
return;
|
||||
}
|
||||
|
||||
res.writeHead(200, { 'Content-Type': 'text/html' });
|
||||
if (code) {
|
||||
res.end('<html><body><h2>Authentication successful</h2><p>You can close this tab.</p></body></html>');
|
||||
callbackResolve(code);
|
||||
} else {
|
||||
const msg = errorDescription ?? error ?? 'Unknown error';
|
||||
res.end(`<html><body><h2>Authentication failed</h2><p>${escapeHtml(msg)}</p></body></html>`);
|
||||
callbackReject(new Error(`OAuth callback error: ${msg}`));
|
||||
}
|
||||
});
|
||||
|
||||
server.on('error', (err) => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
reject(err);
|
||||
}
|
||||
});
|
||||
|
||||
server.listen(0, '127.0.0.1', () => {
|
||||
settled = true;
|
||||
const addr = server.address();
|
||||
if (!addr || typeof addr === 'string') {
|
||||
reject(new Error('Failed to bind callback server'));
|
||||
return;
|
||||
}
|
||||
|
||||
resolve({
|
||||
port: addr.port,
|
||||
localRedirectUri: `http://127.0.0.1:${addr.port}/callback`,
|
||||
hostedRedirectState: buildHostedRedirectState(`http://127.0.0.1:${addr.port}/callback`),
|
||||
waitForCallback: () => callbackPromise,
|
||||
close: () => server.close(),
|
||||
});
|
||||
});
|
||||
|
||||
setTimeout(() => {
|
||||
if (!settled) {
|
||||
settled = true;
|
||||
server.close();
|
||||
reject(new Error('Callback server start timed out'));
|
||||
}
|
||||
}, 5_000);
|
||||
});
|
||||
}
|
||||
|
||||
function buildHostedRedirectState(localRedirectUri: string): string {
|
||||
const stateUrl = new URL(localRedirectUri);
|
||||
stateUrl.searchParams.set('nonce', randomBytes(16).toString('base64url'));
|
||||
return stateUrl.toString();
|
||||
}
|
||||
|
||||
/* ── Token exchange ──────────────────────────────────────────── */
|
||||
|
||||
async function exchangeCodeForToken(
|
||||
tokenEndpoint: string,
|
||||
params: {
|
||||
code: string;
|
||||
clientId: string;
|
||||
redirectUri: string;
|
||||
codeVerifier: string;
|
||||
},
|
||||
): Promise<McpOAuthToken> {
|
||||
const body = new URLSearchParams({
|
||||
grant_type: 'authorization_code',
|
||||
code: params.code,
|
||||
client_id: params.clientId,
|
||||
redirect_uri: params.redirectUri,
|
||||
code_verifier: params.codeVerifier,
|
||||
});
|
||||
|
||||
const response = await fetch(tokenEndpoint, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
'Content-Type': 'application/x-www-form-urlencoded',
|
||||
},
|
||||
body: body.toString(),
|
||||
});
|
||||
|
||||
const data = parseTokenResponseBody(await response.text(), response.headers.get('content-type'));
|
||||
if (!response.ok) {
|
||||
const errorMessage =
|
||||
typeof data.error_description === 'string'
|
||||
? data.error_description
|
||||
: typeof data.error === 'string'
|
||||
? data.error
|
||||
: `${response.status} ${response.statusText}`;
|
||||
throw new Error(`Token exchange failed: ${errorMessage}`);
|
||||
}
|
||||
|
||||
const accessToken = typeof data.access_token === 'string' ? data.access_token : undefined;
|
||||
const tokenType = typeof data.token_type === 'string' ? data.token_type : 'Bearer';
|
||||
const scope = typeof data.scope === 'string' ? data.scope : undefined;
|
||||
const refreshToken = typeof data.refresh_token === 'string' ? data.refresh_token : undefined;
|
||||
|
||||
if (!accessToken) {
|
||||
throw new Error('Token response is missing access_token');
|
||||
}
|
||||
|
||||
const token: McpOAuthToken = {
|
||||
accessToken,
|
||||
tokenType,
|
||||
scope,
|
||||
};
|
||||
|
||||
if (data.expires_in && typeof data.expires_in === 'number') {
|
||||
token.expiresAt = Date.now() + data.expires_in * 1_000;
|
||||
}
|
||||
|
||||
if (refreshToken) {
|
||||
token.refreshToken = refreshToken;
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
export function parseTokenResponseBody(body: string, contentType?: string | null): Record<string, unknown> {
|
||||
const normalizedContentType = contentType?.toLowerCase() ?? '';
|
||||
if (normalizedContentType.includes('application/json') || body.trim().startsWith('{')) {
|
||||
const parsed = JSON.parse(body) as unknown;
|
||||
if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) {
|
||||
return parsed as Record<string, unknown>;
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
return Object.fromEntries(new URLSearchParams(body).entries());
|
||||
}
|
||||
|
||||
/* ── Utilities ───────────────────────────────────────────────── */
|
||||
|
||||
function escapeHtml(text: string): string {
|
||||
return text
|
||||
.replace(/&/g, '&')
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/"/g, '"');
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
/**
|
||||
* In-memory OAuth token store keyed by MCP server URL.
|
||||
* Tokens are lost on app restart by design (phase 1).
|
||||
*/
|
||||
|
||||
export interface McpOAuthToken {
|
||||
accessToken: string;
|
||||
tokenType: string;
|
||||
expiresAt?: number;
|
||||
refreshToken?: string;
|
||||
scope?: string;
|
||||
}
|
||||
|
||||
const tokens = new Map<string, McpOAuthToken>();
|
||||
|
||||
export function getStoredToken(serverUrl: string): McpOAuthToken | undefined {
|
||||
const token = tokens.get(normalizeUrl(serverUrl));
|
||||
if (!token) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
if (token.expiresAt && Date.now() >= token.expiresAt) {
|
||||
tokens.delete(normalizeUrl(serverUrl));
|
||||
return undefined;
|
||||
}
|
||||
|
||||
return token;
|
||||
}
|
||||
|
||||
export function storeToken(serverUrl: string, token: McpOAuthToken): void {
|
||||
tokens.set(normalizeUrl(serverUrl), token);
|
||||
}
|
||||
|
||||
export function clearToken(serverUrl: string): void {
|
||||
tokens.delete(normalizeUrl(serverUrl));
|
||||
}
|
||||
|
||||
export function clearAllTokens(): void {
|
||||
tokens.clear();
|
||||
}
|
||||
|
||||
function normalizeUrl(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
return parsed.origin + parsed.pathname.replace(/\/+$/, '');
|
||||
} catch {
|
||||
return url.toLowerCase().replace(/\/+$/, '');
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Constructs well-known URL candidates for a given base URL.
|
||||
* Returns the RFC 9728 compliant URL first (inserted after origin),
|
||||
* then the appended fallback (some servers use this instead).
|
||||
*
|
||||
* RFC 9728: `https://example.com/.well-known/oauth-protected-resource/mcp/`
|
||||
* Fallback: `https://example.com/mcp/.well-known/oauth-protected-resource`
|
||||
*/
|
||||
export function buildWellKnownUrl(baseUrl: string, wellKnownSuffix: string): string {
|
||||
const parsed = new URL(baseUrl);
|
||||
const path = parsed.pathname === '/' ? '' : parsed.pathname;
|
||||
return `${parsed.origin}/.well-known/${wellKnownSuffix}${path}`;
|
||||
}
|
||||
|
||||
export function buildWellKnownUrlFallback(baseUrl: string, wellKnownSuffix: string): string {
|
||||
const base = baseUrl.replace(/\/+$/, '');
|
||||
return `${base}/.well-known/${wellKnownSuffix}`;
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export function validateSessionToolingSelectionIds(
|
||||
export function buildRunTurnToolingConfig(
|
||||
tooling: WorkspaceToolingSettings,
|
||||
selection: SessionToolingSelection,
|
||||
tokenLookup?: (serverUrl: string) => string | undefined,
|
||||
): RunTurnToolingConfig | undefined {
|
||||
const mcpServersById = new Map<string, McpServerDefinition>(
|
||||
tooling.mcpServers.map((server) => [server.id, server]),
|
||||
@@ -68,7 +69,7 @@ export function buildRunTurnToolingConfig(
|
||||
tools: [...server.tools],
|
||||
timeoutMs: server.timeoutMs,
|
||||
url: server.url,
|
||||
headers: server.headers ? { ...server.headers } : undefined,
|
||||
headers: mergeAuthorizationHeader(server.url, server.headers, tokenLookup),
|
||||
},
|
||||
];
|
||||
});
|
||||
@@ -100,3 +101,19 @@ export function buildRunTurnToolingConfig(
|
||||
lspProfiles,
|
||||
};
|
||||
}
|
||||
|
||||
function mergeAuthorizationHeader(
|
||||
serverUrl: string,
|
||||
configHeaders: Record<string, string> | undefined,
|
||||
tokenLookup: ((serverUrl: string) => string | undefined) | undefined,
|
||||
): Record<string, string> | undefined {
|
||||
const bearerToken = tokenLookup?.(serverUrl);
|
||||
if (!bearerToken) {
|
||||
return configHeaders ? { ...configHeaders } : undefined;
|
||||
}
|
||||
|
||||
return {
|
||||
...(configHeaders ?? {}),
|
||||
Authorization: `Bearer ${bearerToken}`,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1,6 +1,27 @@
|
||||
import type { AgentActivityEvent, ApprovalRequestedEvent, TurnDeltaEvent } from '@shared/contracts/sidecar';
|
||||
import type {
|
||||
AgentActivityEvent,
|
||||
ApprovalRequestedEvent,
|
||||
ExitPlanModeRequestedEvent,
|
||||
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;
|
||||
@@ -8,6 +29,10 @@ export interface RunTurnPendingCommand {
|
||||
onDelta: (event: TurnDeltaEvent) => void | Promise<void>;
|
||||
onActivity: (event: AgentActivityEvent) => void | Promise<void>;
|
||||
onApproval: (event: ApprovalRequestedEvent) => void | Promise<void>;
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -9,8 +9,13 @@ import type {
|
||||
SidecarCapabilities,
|
||||
SidecarEvent,
|
||||
TurnDeltaEvent,
|
||||
UserInputRequestedEvent,
|
||||
McpOauthRequiredEvent,
|
||||
ExitPlanModeRequestedEvent,
|
||||
ValidatePatternCommand,
|
||||
RunTurnCommand,
|
||||
CopilotSessionListFilter,
|
||||
CopilotSessionInfo,
|
||||
} from '@shared/contracts/sidecar';
|
||||
import type { ApprovalDecision } from '@shared/domain/approval';
|
||||
import type { ChatMessageRecord } from '@shared/domain/session';
|
||||
@@ -19,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';
|
||||
@@ -44,12 +50,36 @@ type PendingCommand =
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
})
|
||||
| ({
|
||||
processId: number;
|
||||
kind: 'resolve-user-input';
|
||||
resolve: () => void;
|
||||
reject: (error: Error) => void;
|
||||
})
|
||||
| ({
|
||||
processId: number;
|
||||
kind: 'cancel-turn';
|
||||
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);
|
||||
@@ -94,16 +124,31 @@ export class SidecarClient {
|
||||
onDelta: (event: TurnDeltaEvent) => void | Promise<void>,
|
||||
onActivity: (event: AgentActivityEvent) => void | Promise<void>,
|
||||
onApproval: (event: ApprovalRequestedEvent) => void | Promise<void>,
|
||||
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);
|
||||
return this.dispatch<ChatMessageRecord[]>(command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, onTurnScopedEvent);
|
||||
}
|
||||
|
||||
async resolveApproval(approvalId: string, decision: ApprovalDecision): Promise<void> {
|
||||
async resolveUserInput(userInputId: string, answer: string, wasFreeform: boolean): Promise<void> {
|
||||
return this.dispatch<void>({
|
||||
type: 'resolve-user-input',
|
||||
requestId: `user-input-${Date.now()}`,
|
||||
userInputId,
|
||||
answer,
|
||||
wasFreeform,
|
||||
});
|
||||
}
|
||||
|
||||
async resolveApproval(approvalId: string, decision: ApprovalDecision, alwaysApprove?: boolean): Promise<void> {
|
||||
return this.dispatch<void>({
|
||||
type: 'resolve-approval',
|
||||
requestId: `approval-${Date.now()}`,
|
||||
approvalId,
|
||||
decision,
|
||||
alwaysApprove: alwaysApprove ?? false,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -115,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) {
|
||||
@@ -199,6 +269,10 @@ export class SidecarClient {
|
||||
onDelta?: (event: TurnDeltaEvent) => void | Promise<void>,
|
||||
onActivity?: (event: AgentActivityEvent) => void | Promise<void>,
|
||||
onApproval?: (event: ApprovalRequestedEvent) => void | Promise<void>,
|
||||
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();
|
||||
|
||||
@@ -212,6 +286,10 @@ export class SidecarClient {
|
||||
onDelta: onDelta ?? (() => undefined),
|
||||
onActivity: onActivity ?? (() => undefined),
|
||||
onApproval: onApproval ?? (() => undefined),
|
||||
onUserInput: onUserInput ?? (() => undefined),
|
||||
onMcpOAuthRequired: onMcpOAuthRequired ?? (() => undefined),
|
||||
onExitPlanMode: onExitPlanMode ?? (() => undefined),
|
||||
onTurnScopedEvent: onTurnScopedEvent ?? (() => undefined),
|
||||
errored: false,
|
||||
});
|
||||
} else if (command.type === 'validate-pattern') {
|
||||
@@ -228,6 +306,13 @@ export class SidecarClient {
|
||||
resolve: resolve as () => void,
|
||||
reject,
|
||||
});
|
||||
} else if (command.type === 'resolve-user-input') {
|
||||
this.pending.set(command.requestId, {
|
||||
processId: state.id,
|
||||
kind: 'resolve-user-input',
|
||||
resolve: resolve as () => void,
|
||||
reject,
|
||||
});
|
||||
} else if (command.type === 'cancel-turn') {
|
||||
this.pending.set(command.requestId, {
|
||||
processId: state.id,
|
||||
@@ -235,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,
|
||||
@@ -297,6 +403,49 @@ export class SidecarClient {
|
||||
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onApproval(event));
|
||||
}
|
||||
return;
|
||||
case 'user-input-requested':
|
||||
if (pending.kind === 'run-turn' && shouldHandleRunTurnEvent(pending)) {
|
||||
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onUserInput(event));
|
||||
}
|
||||
return;
|
||||
case 'mcp-oauth-required':
|
||||
if (pending.kind === 'run-turn' && shouldHandleRunTurnEvent(pending)) {
|
||||
this.invokeRunTurnHandler(event.requestId, pending, () => pending.onMcpOAuthRequired(event));
|
||||
}
|
||||
return;
|
||||
case 'exit-plan-mode-requested':
|
||||
if (pending.kind === 'run-turn' && shouldHandleRunTurnEvent(pending)) {
|
||||
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)) {
|
||||
@@ -318,7 +467,7 @@ export class SidecarClient {
|
||||
this.pending.delete(event.requestId);
|
||||
return;
|
||||
case 'command-complete':
|
||||
if (pending.kind === 'resolve-approval' || pending.kind === 'cancel-turn') {
|
||||
if (pending.kind === 'resolve-approval' || pending.kind === 'resolve-user-input' || pending.kind === 'cancel-turn') {
|
||||
pending.resolve();
|
||||
this.pending.delete(event.requestId);
|
||||
} else if (pending.kind !== 'run-turn' || pending.errored) {
|
||||
|
||||
@@ -33,9 +33,15 @@ 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),
|
||||
resolveSessionUserInput: (input) => ipcRenderer.invoke(ipcChannels.resolveSessionUserInput, input),
|
||||
setSessionInteractionMode: (input) => ipcRenderer.invoke(ipcChannels.setSessionInteractionMode, input),
|
||||
dismissSessionPlanReview: (input) => ipcRenderer.invoke(ipcChannels.dismissSessionPlanReview, input),
|
||||
dismissSessionMcpAuth: (input) => ipcRenderer.invoke(ipcChannels.dismissSessionMcpAuth, input),
|
||||
startSessionMcpAuth: (input) => ipcRenderer.invoke(ipcChannels.startSessionMcpAuth, input),
|
||||
updateSessionModelConfig: (input) =>
|
||||
ipcRenderer.invoke(ipcChannels.updateSessionModelConfig, input),
|
||||
querySessions: (input) => ipcRenderer.invoke(ipcChannels.querySessions, input),
|
||||
|
||||
+62
-4
@@ -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,11 +275,31 @@ 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) =>
|
||||
api.resolveSessionApproval({ sessionId: selectedSession.id, approvalId, decision })
|
||||
onResolveApproval={(approvalId, decision, alwaysApprove) =>
|
||||
api.resolveSessionApproval({ sessionId: selectedSession.id, approvalId, decision, alwaysApprove })
|
||||
}
|
||||
onResolveUserInput={(userInputId, answer, wasFreeform) =>
|
||||
api.resolveSessionUserInput({ sessionId: selectedSession.id, userInputId, answer, wasFreeform })
|
||||
}
|
||||
onSetInteractionMode={(mode) => {
|
||||
void api.setSessionInteractionMode({ sessionId: selectedSession.id, mode });
|
||||
}}
|
||||
onDismissPlanReview={() => {
|
||||
void api.dismissSessionPlanReview({ sessionId: selectedSession.id });
|
||||
}}
|
||||
onDismissMcpAuth={() => {
|
||||
void api.dismissSessionMcpAuth({ sessionId: selectedSession.id });
|
||||
}}
|
||||
onAuthenticateMcp={() => {
|
||||
void api.startSessionMcpAuth({ sessionId: selectedSession.id });
|
||||
}}
|
||||
onUpdateSessionModelConfig={(config) =>
|
||||
api.updateSessionModelConfig({
|
||||
sessionId: selectedSession.id,
|
||||
@@ -275,6 +325,7 @@ export default function App() {
|
||||
project={projectForSession}
|
||||
runtimeTools={sidecarCapabilities?.runtimeTools}
|
||||
session={selectedSession}
|
||||
sessionUsage={usageForSession}
|
||||
toolingSettings={chatToolingSettings ?? workspace.settings.tooling}
|
||||
/>
|
||||
);
|
||||
@@ -284,6 +335,7 @@ export default function App() {
|
||||
onJumpToMessage={jumpToMessage}
|
||||
pattern={patternForSession}
|
||||
session={selectedSession}
|
||||
turnEvents={turnEventsForSession}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
@@ -335,7 +387,10 @@ export default function App() {
|
||||
onSetTheme={(theme) => void api.setTheme(theme)}
|
||||
onOpenAppDataFolder={() => void api.openAppDataFolder()}
|
||||
onResetLocalWorkspace={async () => {
|
||||
await api.resetLocalWorkspace();
|
||||
const fresh = await api.resetLocalWorkspace();
|
||||
setWorkspace(fresh);
|
||||
setSessionActivities({});
|
||||
setShowSettings(false);
|
||||
}}
|
||||
patterns={workspace.patterns}
|
||||
sidecarCapabilities={sidecarCapabilities}
|
||||
@@ -386,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,13 +1,20 @@
|
||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { AlertCircle, ArrowUp, Bot, Circle, GitBranch, Loader2, 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';
|
||||
import { ApprovalBanner, QueuedApprovalsList } from '@renderer/components/chat/ApprovalBanner';
|
||||
import { PlanReviewBanner } from '@renderer/components/chat/PlanReviewBanner';
|
||||
import { McpAuthBanner } from '@renderer/components/chat/McpAuthBanner';
|
||||
import { UserInputBanner } from '@renderer/components/chat/UserInputBanner';
|
||||
import { InlineApprovalPill, InlineModelPill, InlineThinkingPill, InlineToolsPill } from '@renderer/components/chat/InlinePills';
|
||||
import { ThinkingDots } from '@renderer/components/chat/ThinkingDots';
|
||||
import { getAssistantMessagePhase } from '@renderer/lib/messagePhase';
|
||||
import type { ApprovalDecision } from '@shared/domain/approval';
|
||||
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,
|
||||
@@ -33,9 +40,15 @@ 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) => Promise<unknown>;
|
||||
onResolveApproval?: (approvalId: string, decision: ApprovalDecision, alwaysApprove?: boolean) => Promise<unknown>;
|
||||
onResolveUserInput?: (userInputId: string, answer: string, wasFreeform: boolean) => Promise<unknown>;
|
||||
onSetInteractionMode?: (mode: InteractionMode) => void;
|
||||
onDismissPlanReview?: () => void;
|
||||
onDismissMcpAuth?: () => void;
|
||||
onAuthenticateMcp?: () => void;
|
||||
onUpdateSessionModelConfig?: (config: {
|
||||
model: string;
|
||||
reasoningEffort?: ReasoningEffort;
|
||||
@@ -51,9 +64,15 @@ export function ChatPane({
|
||||
availableModels,
|
||||
toolingSettings,
|
||||
runtimeTools,
|
||||
sessionUsage,
|
||||
onSend,
|
||||
onCancelTurn,
|
||||
onResolveApproval,
|
||||
onResolveUserInput,
|
||||
onSetInteractionMode,
|
||||
onDismissPlanReview,
|
||||
onDismissMcpAuth,
|
||||
onAuthenticateMcp,
|
||||
onUpdateSessionModelConfig,
|
||||
onUpdateSessionTooling,
|
||||
onUpdateSessionApprovalSettings,
|
||||
@@ -62,6 +81,7 @@ export function ChatPane({
|
||||
const [configError, setConfigError] = useState<string>();
|
||||
const [approvalError, setApprovalError] = useState<string>();
|
||||
const [isResolvingApproval, setIsResolvingApproval] = useState(false);
|
||||
const [isSubmittingUserInput, setIsSubmittingUserInput] = useState(false);
|
||||
const [isUpdatingSessionModelConfig, setIsUpdatingSessionModelConfig] = useState(false);
|
||||
const transcriptRef = useRef<HTMLDivElement>(null);
|
||||
const composerRef = useRef<MarkdownComposerHandle>(null);
|
||||
@@ -70,14 +90,23 @@ export function ChatPane({
|
||||
const pendingApproval = session.pendingApproval?.status === 'pending' ? session.pendingApproval : undefined;
|
||||
const queuedApprovals = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending');
|
||||
const totalPendingCount = (pendingApproval ? 1 : 0) + queuedApprovals.length;
|
||||
const pendingUserInput = session.pendingUserInput?.status === 'pending' ? session.pendingUserInput : undefined;
|
||||
const pendingPlanReview = session.pendingPlanReview?.status === 'pending' ? session.pendingPlanReview : undefined;
|
||||
const pendingMcpAuth = session.pendingMcpAuth?.status === 'pending' || session.pendingMcpAuth?.status === 'authenticating'
|
||||
|| session.pendingMcpAuth?.status === 'failed'
|
||||
? session.pendingMcpAuth
|
||||
: undefined;
|
||||
const interactionMode: InteractionMode = session.interactionMode ?? 'interactive';
|
||||
const isPlanMode = interactionMode === 'plan';
|
||||
const isScratchpad = isScratchpadProject(project);
|
||||
const isSingleAgent = pattern.agents.length === 1;
|
||||
const primaryAgent = pattern.agents[0];
|
||||
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;
|
||||
@@ -97,6 +126,10 @@ export function ChatPane({
|
||||
),
|
||||
[isApprovalOverridden, session.approvalSettings, pattern.approvalPolicy],
|
||||
);
|
||||
const effectiveAutoApprovedCount = useMemo(
|
||||
() => approvalTools.filter((t) => effectiveAutoApproved.has(t.id)).length,
|
||||
[approvalTools, effectiveAutoApproved],
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
transcriptRef.current?.scrollTo({
|
||||
@@ -113,7 +146,22 @@ 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() {
|
||||
onDismissPlanReview?.();
|
||||
}
|
||||
|
||||
function handleDismissMcpAuth() {
|
||||
onDismissMcpAuth?.();
|
||||
}
|
||||
|
||||
function handleAuthenticateMcp() {
|
||||
onAuthenticateMcp?.();
|
||||
}
|
||||
|
||||
async function handleSessionModelConfigChange(config: {
|
||||
@@ -143,14 +191,14 @@ export function ChatPane({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResolveApproval(decision: ApprovalDecision) {
|
||||
async function handleResolveApproval(decision: ApprovalDecision, alwaysApprove?: boolean) {
|
||||
if (!pendingApproval || !onResolveApproval || isResolvingApproval) return;
|
||||
|
||||
setApprovalError(undefined);
|
||||
setIsResolvingApproval(true);
|
||||
|
||||
try {
|
||||
await onResolveApproval(pendingApproval.id, decision);
|
||||
await onResolveApproval(pendingApproval.id, decision, alwaysApprove);
|
||||
} catch (error) {
|
||||
setApprovalError(error instanceof Error ? error.message : String(error));
|
||||
} finally {
|
||||
@@ -158,6 +206,20 @@ export function ChatPane({
|
||||
}
|
||||
}
|
||||
|
||||
async function handleResolveUserInput(answer: string, wasFreeform: boolean) {
|
||||
if (!pendingUserInput || !onResolveUserInput || isSubmittingUserInput) return;
|
||||
|
||||
setIsSubmittingUserInput(true);
|
||||
|
||||
try {
|
||||
await onResolveUserInput(pendingUserInput.id, answer, wasFreeform);
|
||||
} catch {
|
||||
// User input errors are non-critical; the turn will fail and show the error status
|
||||
} finally {
|
||||
setIsSubmittingUserInput(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex h-full flex-col">
|
||||
{/* Header — extra top padding clears the title bar overlay zone */}
|
||||
@@ -194,14 +256,20 @@ export function ChatPane({
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
{isSessionBusy && !pendingApproval && <span className="size-2 animate-pulse rounded-full bg-blue-400" />}
|
||||
{pendingUserInput && !pendingApproval && (
|
||||
<div className="flex items-center gap-1.5 text-[12px] font-medium text-blue-400">
|
||||
<MessageCircleQuestion className="size-3.5" />
|
||||
Awaiting your input
|
||||
</div>
|
||||
)}
|
||||
{isSessionBusy && !pendingApproval && !pendingUserInput && <span className="size-2 animate-pulse rounded-full bg-blue-400" />}
|
||||
{session.status === 'error' && (
|
||||
<div className="flex items-center gap-1.5 text-[12px] text-red-400">
|
||||
<AlertCircle className="size-3.5" />
|
||||
Error
|
||||
</div>
|
||||
)}
|
||||
{session.status === 'idle' && !pendingApproval && session.messages.length > 0 && (
|
||||
{session.status === 'idle' && !pendingApproval && !pendingUserInput && session.messages.length > 0 && (
|
||||
<span className="text-[12px] text-zinc-600">
|
||||
{session.messages.length} message{session.messages.length === 1 ? '' : 's'}
|
||||
</span>
|
||||
@@ -277,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}
|
||||
@@ -329,7 +420,7 @@ export function ChatPane({
|
||||
<ApprovalBanner
|
||||
approval={pendingApproval}
|
||||
isResolving={isResolvingApproval}
|
||||
onResolve={(decision) => void handleResolveApproval(decision)}
|
||||
onResolve={(decision, alwaysApprove) => void handleResolveApproval(decision, alwaysApprove)}
|
||||
position={totalPendingCount > 1 ? 1 : undefined}
|
||||
total={totalPendingCount > 1 ? totalPendingCount : undefined}
|
||||
/>
|
||||
@@ -339,6 +430,38 @@ export function ChatPane({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Pending user input banner */}
|
||||
{pendingUserInput && (
|
||||
<div className="mb-3">
|
||||
<UserInputBanner
|
||||
isSubmitting={isSubmittingUserInput}
|
||||
onSubmit={(answer, wasFreeform) => void handleResolveUserInput(answer, wasFreeform)}
|
||||
userInput={pendingUserInput}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Plan review banner */}
|
||||
{pendingPlanReview && (
|
||||
<div className="mb-3">
|
||||
<PlanReviewBanner
|
||||
onDismiss={handleDismissPlan}
|
||||
planReview={pendingPlanReview}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* MCP auth required banner */}
|
||||
{pendingMcpAuth && (
|
||||
<div className="mb-3">
|
||||
<McpAuthBanner
|
||||
mcpAuth={pendingMcpAuth}
|
||||
onAuthenticate={handleAuthenticateMcp}
|
||||
onDismiss={handleDismissMcpAuth}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Session config pills — tools/approval left, model/reasoning right */}
|
||||
{isSingleAgent && (
|
||||
<div className="mb-2 flex items-center gap-2">
|
||||
@@ -356,6 +479,7 @@ export function ChatPane({
|
||||
approvalTools={approvalTools}
|
||||
disabled={isComposerDisabled}
|
||||
effectiveAutoApproved={effectiveAutoApproved}
|
||||
effectiveAutoApprovedCount={effectiveAutoApprovedCount}
|
||||
isOverridden={isApprovalOverridden}
|
||||
onUpdate={onUpdateSessionApprovalSettings}
|
||||
/>
|
||||
@@ -410,6 +534,7 @@ export function ChatPane({
|
||||
approvalTools={approvalTools}
|
||||
disabled={isComposerDisabled}
|
||||
effectiveAutoApproved={effectiveAutoApproved}
|
||||
effectiveAutoApprovedCount={effectiveAutoApprovedCount}
|
||||
isOverridden={isApprovalOverridden}
|
||||
onUpdate={onUpdateSessionApprovalSettings}
|
||||
/>
|
||||
@@ -417,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}
|
||||
@@ -426,40 +574,152 @@ export function ChatPane({
|
||||
placeholder={
|
||||
pendingApproval
|
||||
? 'Awaiting approval...'
|
||||
: isSessionBusy
|
||||
? 'Waiting for response...'
|
||||
: isUpdatingSessionModelConfig
|
||||
? 'Saving model settings...'
|
||||
: 'Message...'
|
||||
: pendingUserInput
|
||||
? 'Awaiting your input above...'
|
||||
: pendingPlanReview
|
||||
? 'Review the plan above...'
|
||||
: pendingMcpAuth
|
||||
? 'MCP server requires authentication...'
|
||||
: isSessionBusy
|
||||
? 'Steer the agent (sends immediately)...'
|
||||
: isUpdatingSessionModelConfig
|
||||
? 'Saving model settings...'
|
||||
: isPlanMode
|
||||
? 'Describe what to plan...'
|
||||
: 'Message...'
|
||||
}
|
||||
>
|
||||
<button
|
||||
className={`absolute bottom-2 right-2 flex size-8 items-center justify-center rounded-lg transition ${
|
||||
isSessionBusy
|
||||
? 'bg-red-600/80 text-white hover:bg-red-500'
|
||||
: canSubmitInput
|
||||
? 'bg-indigo-600 text-white hover:bg-indigo-500'
|
||||
: 'bg-zinc-800 text-zinc-600'
|
||||
}`}
|
||||
disabled={!canSubmitInput && !isSessionBusy}
|
||||
onClick={() => {
|
||||
if (isSessionBusy) {
|
||||
onCancelTurn?.();
|
||||
} else {
|
||||
composerRef.current?.submit();
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
aria-label={isSessionBusy ? 'Stop generating' : 'Send message'}
|
||||
>
|
||||
{isSessionBusy ? (
|
||||
<Square className="size-3.5" fill="currentColor" />
|
||||
) : (
|
||||
<ArrowUp className="size-4" />
|
||||
<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
|
||||
aria-label={isPlanMode ? 'Switch to interactive mode' : 'Switch to plan mode'}
|
||||
aria-pressed={isPlanMode}
|
||||
className={`flex size-8 items-center justify-center rounded-lg transition ${
|
||||
isPlanMode
|
||||
? 'bg-emerald-600/20 text-emerald-400 hover:bg-emerald-600/30'
|
||||
: 'text-zinc-500 hover:bg-zinc-800 hover:text-zinc-300'
|
||||
}`}
|
||||
disabled={isComposerDisabled}
|
||||
onClick={() => onSetInteractionMode(isPlanMode ? 'interactive' : 'plan')}
|
||||
type="button"
|
||||
>
|
||||
<ClipboardList className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</button>
|
||||
|
||||
{/* Send / Stop / Steer button */}
|
||||
<button
|
||||
className={`flex size-8 items-center justify-center rounded-lg transition ${
|
||||
isSessionBusy && !hasComposerContent && pendingAttachments.length === 0
|
||||
? 'bg-red-600/80 text-white hover:bg-red-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 && pendingAttachments.length === 0}
|
||||
onClick={() => {
|
||||
if (isSessionBusy && !hasComposerContent && pendingAttachments.length === 0) {
|
||||
onCancelTurn?.();
|
||||
} else {
|
||||
composerRef.current?.submit();
|
||||
}
|
||||
}}
|
||||
type="button"
|
||||
aria-label={
|
||||
isSessionBusy && !hasComposerContent && pendingAttachments.length === 0
|
||||
? 'Stop generating'
|
||||
: isSessionBusy
|
||||
? 'Steer agent'
|
||||
: isPlanMode
|
||||
? 'Send as plan request'
|
||||
: 'Send message'
|
||||
}
|
||||
>
|
||||
{isSessionBusy && !hasComposerContent && pendingAttachments.length === 0 ? (
|
||||
<Square className="size-3.5" fill="currentColor" />
|
||||
) : (
|
||||
<ArrowUp className="size-4" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</MarkdownComposer>
|
||||
{isPlanMode && !isSessionBusy && (
|
||||
<div className="flex items-center gap-1.5 px-3 pb-1.5 pt-0.5">
|
||||
<div className="size-1.5 rounded-full bg-emerald-500" />
|
||||
<span className="text-[10px] font-medium text-emerald-400/80">
|
||||
Plan mode — the agent will propose a plan instead of implementing
|
||||
</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>
|
||||
|
||||
@@ -18,6 +18,8 @@ import {
|
||||
import type { ApprovalCheckpointKind, ApprovalPolicy } from '@shared/domain/approval';
|
||||
import type { ModelDefinition } from '@shared/domain/models';
|
||||
import {
|
||||
addAgentToGraph,
|
||||
removeAgentFromGraph,
|
||||
resolvePatternGraph,
|
||||
syncPatternGraph,
|
||||
validatePatternDefinition,
|
||||
@@ -35,7 +37,6 @@ import {
|
||||
} from '@shared/domain/tooling';
|
||||
|
||||
import { ToggleSwitch } from '@renderer/components/ui';
|
||||
import { addAgentNodeToGraph } from '@renderer/lib/patternGraph';
|
||||
import { PatternGraphCanvas } from './pattern-graph/PatternGraphCanvas';
|
||||
import { PatternGraphInspector } from './pattern-graph/PatternGraphInspector';
|
||||
|
||||
@@ -143,6 +144,10 @@ export function PatternEditor({
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
|
||||
function emitChange(nextPattern: PatternDefinition) {
|
||||
onChange({ ...nextPattern, graph: resolvePatternGraph(nextPattern) });
|
||||
}
|
||||
|
||||
function emitModeChange(nextPattern: PatternDefinition) {
|
||||
onChange(syncPatternGraph(nextPattern));
|
||||
}
|
||||
|
||||
@@ -159,7 +164,7 @@ export function PatternEditor({
|
||||
model: 'gpt-5.4',
|
||||
reasoningEffort: 'high',
|
||||
};
|
||||
const updatedGraph = addAgentNodeToGraph(graph, newAgent);
|
||||
const updatedGraph = addAgentToGraph(graph, pattern.mode, newAgent);
|
||||
onChange({ ...pattern, agents: [...pattern.agents, newAgent], graph: updatedGraph });
|
||||
}
|
||||
|
||||
@@ -175,9 +180,11 @@ export function PatternEditor({
|
||||
return;
|
||||
}
|
||||
|
||||
emitChange({
|
||||
const updatedGraph = removeAgentFromGraph(graph, pattern.mode, agentId);
|
||||
onChange({
|
||||
...pattern,
|
||||
agents: pattern.agents.filter((a) => a.id !== agentId),
|
||||
graph: updatedGraph,
|
||||
});
|
||||
setSelectedNodeId(null);
|
||||
}
|
||||
@@ -374,7 +381,7 @@ export function PatternEditor({
|
||||
}`}
|
||||
disabled={disabled}
|
||||
key={mode}
|
||||
onClick={() => emitChange({ ...pattern, mode })}
|
||||
onClick={() => emitModeChange({ ...pattern, mode })}
|
||||
type="button"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
import { useState } from 'react';
|
||||
import { Bot, Check, ChevronDown, Loader2, ShieldAlert, ShieldCheck, X } from 'lucide-react';
|
||||
import { Bot, Check, ChevronDown, Loader2, ShieldAlert, ShieldBan, ShieldCheck, X } from 'lucide-react';
|
||||
|
||||
import { MarkdownContent } from '@renderer/components/MarkdownContent';
|
||||
import { permissionDetailSummary, PermissionDetailView } from '@renderer/components/chat/PermissionDetailView';
|
||||
import { resolveApprovalToolKey } from '@shared/domain/approval';
|
||||
import type { ApprovalDecision, PendingApprovalRecord } from '@shared/domain/approval';
|
||||
import { resolveToolLabel } from '@shared/domain/tooling';
|
||||
|
||||
/* ── ApprovalBanner ────────────────────────────────────────── */
|
||||
|
||||
@@ -14,7 +17,7 @@ export function ApprovalBanner({
|
||||
total,
|
||||
}: {
|
||||
approval: PendingApprovalRecord;
|
||||
onResolve: (decision: ApprovalDecision) => void;
|
||||
onResolve: (decision: ApprovalDecision, alwaysApprove?: boolean) => void;
|
||||
isResolving: boolean;
|
||||
position?: number;
|
||||
total?: number;
|
||||
@@ -22,6 +25,9 @@ export function ApprovalBanner({
|
||||
const kindLabel = approval.kind === 'final-response' ? 'Final response review' : 'Tool call approval';
|
||||
const hasMessages = approval.messages && approval.messages.length > 0;
|
||||
const showPosition = position !== undefined && total !== undefined && total > 1;
|
||||
const approvalToolKey = resolveApprovalToolKey(approval.toolName, approval.permissionKind);
|
||||
const canAlwaysApprove = approval.kind === 'tool-call' && !!approvalToolKey;
|
||||
const approvalToolLabel = approvalToolKey ? resolveToolLabel(approvalToolKey) : undefined;
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/5 px-4 py-3" role="alert">
|
||||
@@ -47,9 +53,11 @@ export function ApprovalBanner({
|
||||
{approval.permissionKind && <span>Permission: <span className="text-zinc-300">{approval.permissionKind}</span></span>}
|
||||
</div>
|
||||
|
||||
{approval.detail && (
|
||||
<p className="mt-1.5 text-[12px] leading-relaxed text-zinc-400">{approval.detail}</p>
|
||||
)}
|
||||
{approval.permissionDetail
|
||||
? <PermissionDetailView detail={approval.permissionDetail} />
|
||||
: approval.detail && (
|
||||
<p className="mt-1.5 text-[12px] leading-relaxed text-zinc-400">{approval.detail}</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -84,6 +92,19 @@ export function ApprovalBanner({
|
||||
{isResolving ? <Loader2 className="size-3 animate-spin" /> : <Check className="size-3" />}
|
||||
Approve
|
||||
</button>
|
||||
{canAlwaysApprove && (
|
||||
<button
|
||||
aria-label={`Always approve ${approvalToolLabel}`}
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-emerald-600/20 px-3.5 py-1.5 text-[12px] font-medium text-emerald-300 transition hover:bg-emerald-600/30 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={isResolving}
|
||||
onClick={() => onResolve('approved', true)}
|
||||
title={`Auto-approve "${approvalToolLabel}" for the rest of this session`}
|
||||
type="button"
|
||||
>
|
||||
<ShieldBan className="size-3" />
|
||||
Always approve
|
||||
</button>
|
||||
)}
|
||||
<button
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-zinc-800 px-3.5 py-1.5 text-[12px] font-medium text-zinc-300 transition hover:bg-zinc-700 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={isResolving}
|
||||
@@ -134,7 +155,9 @@ export function QueuedApprovalsList({ approvals }: { approvals: PendingApprovalR
|
||||
key={approval.id}
|
||||
>
|
||||
<ShieldAlert className="size-3 shrink-0 text-zinc-600" />
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] text-zinc-400">{approval.title}</span>
|
||||
<span className="min-w-0 flex-1 truncate text-[11px] text-zinc-400">
|
||||
{(approval.permissionDetail && permissionDetailSummary(approval.permissionDetail)) || approval.title}
|
||||
</span>
|
||||
<span className="shrink-0 rounded-full bg-zinc-800 px-1.5 py-0.5 text-[8px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
{kindLabel}
|
||||
</span>
|
||||
|
||||
@@ -348,16 +348,18 @@ const approvalKindLabels: Record<ApprovalToolKind, string> = {
|
||||
export function InlineApprovalPill({
|
||||
approvalTools,
|
||||
effectiveAutoApproved,
|
||||
effectiveAutoApprovedCount,
|
||||
isOverridden,
|
||||
disabled,
|
||||
onUpdate,
|
||||
}: {
|
||||
approvalTools: ApprovalToolDefinition[];
|
||||
effectiveAutoApproved: Set<string>;
|
||||
effectiveAutoApprovedCount: number;
|
||||
isOverridden: boolean;
|
||||
disabled: boolean;
|
||||
onUpdate: (settings: { autoApprovedToolNames?: string[] }) => void;
|
||||
}) {
|
||||
}){
|
||||
const [open, setOpen] = useState(false);
|
||||
const ref = useClickOutside<HTMLDivElement>(() => setOpen(false), open);
|
||||
|
||||
@@ -393,7 +395,7 @@ export function InlineApprovalPill({
|
||||
type="button"
|
||||
>
|
||||
<ShieldCheck className="size-2.5" />
|
||||
<span>{effectiveAutoApproved.size}/{approvalTools.length} auto-approved</span>
|
||||
<span>{effectiveAutoApprovedCount}/{approvalTools.length} auto-approved</span>
|
||||
<ChevronDown className={`size-2.5 transition ${open ? 'rotate-180' : ''}`} />
|
||||
</button>
|
||||
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import { useCallback } from 'react';
|
||||
import { KeyRound, Loader2, X } from 'lucide-react';
|
||||
|
||||
import type { PendingMcpAuthRecord } from '@shared/domain/mcpAuth';
|
||||
|
||||
export function McpAuthBanner({
|
||||
mcpAuth,
|
||||
onAuthenticate,
|
||||
onDismiss,
|
||||
}: {
|
||||
mcpAuth: PendingMcpAuthRecord;
|
||||
onAuthenticate: () => void;
|
||||
onDismiss: () => void;
|
||||
}) {
|
||||
const handleAuthenticate = useCallback(() => {
|
||||
onAuthenticate();
|
||||
}, [onAuthenticate]);
|
||||
|
||||
const handleDismiss = useCallback(() => {
|
||||
onDismiss();
|
||||
}, [onDismiss]);
|
||||
|
||||
const isAuthenticating = mcpAuth.status === 'authenticating';
|
||||
const hasFailed = mcpAuth.status === 'failed';
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/5 px-4 py-3" role="alert">
|
||||
<div className="flex items-start gap-2.5">
|
||||
<KeyRound className="mt-0.5 size-4 shrink-0 text-amber-400" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-semibold text-amber-200">Authentication required</span>
|
||||
<span className="rounded-full bg-amber-500/15 px-2 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-amber-400">
|
||||
MCP
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Dismiss authentication prompt"
|
||||
className="rounded p-0.5 text-zinc-500 transition hover:bg-zinc-700/50 hover:text-zinc-300"
|
||||
onClick={handleDismiss}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p className="mt-2 text-[13px] leading-relaxed text-zinc-200">
|
||||
The MCP server{' '}
|
||||
<span className="font-medium text-amber-200">{mcpAuth.serverName}</span>{' '}
|
||||
requires OAuth authentication to connect.
|
||||
</p>
|
||||
|
||||
<p className="mt-1 text-[11px] text-zinc-500">{mcpAuth.serverUrl}</p>
|
||||
|
||||
{hasFailed && mcpAuth.errorMessage && (
|
||||
<p className="mt-2 text-[12px] text-red-400">{mcpAuth.errorMessage}</p>
|
||||
)}
|
||||
|
||||
<div className="mt-3 flex items-center gap-3">
|
||||
<button
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-amber-500/20 px-3 py-1.5 text-[12px] font-medium text-amber-200 transition hover:bg-amber-500/30 disabled:opacity-50"
|
||||
disabled={isAuthenticating}
|
||||
onClick={handleAuthenticate}
|
||||
type="button"
|
||||
>
|
||||
{isAuthenticating ? (
|
||||
<>
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
Authenticating…
|
||||
</>
|
||||
) : hasFailed ? (
|
||||
'Retry authentication'
|
||||
) : (
|
||||
'Authenticate in browser'
|
||||
)}
|
||||
</button>
|
||||
<span className="text-[11px] text-zinc-500">
|
||||
{isAuthenticating
|
||||
? 'Waiting for consent in the browser…'
|
||||
: 'Opens your browser for OAuth consent. Token is stored for this session only.'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
AlertTriangle,
|
||||
BookOpen,
|
||||
ChevronDown,
|
||||
ExternalLink,
|
||||
FileEdit,
|
||||
FileText,
|
||||
Globe,
|
||||
Server,
|
||||
Terminal,
|
||||
} from 'lucide-react';
|
||||
|
||||
import type { PermissionDetail } from '@shared/contracts/sidecar';
|
||||
|
||||
export function PermissionDetailView({ detail }: { detail: PermissionDetail }) {
|
||||
switch (detail.kind) {
|
||||
case 'shell':
|
||||
return <ShellDetail detail={detail} />;
|
||||
case 'write':
|
||||
return <WriteDetail detail={detail} />;
|
||||
case 'read':
|
||||
return <ReadDetail detail={detail} />;
|
||||
case 'mcp':
|
||||
return <McpDetail detail={detail} />;
|
||||
case 'url':
|
||||
return <UrlDetail detail={detail} />;
|
||||
case 'memory':
|
||||
return <MemoryDetail detail={detail} />;
|
||||
case 'custom-tool':
|
||||
return <CustomToolDetail detail={detail} />;
|
||||
case 'hook':
|
||||
return <HookDetail detail={detail} />;
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function permissionDetailSummary(detail: PermissionDetail): string | undefined {
|
||||
switch (detail.kind) {
|
||||
case 'shell':
|
||||
return detail.command ? truncate(detail.command, 80) : undefined;
|
||||
case 'write':
|
||||
return detail.fileName;
|
||||
case 'read':
|
||||
return detail.path;
|
||||
case 'url':
|
||||
return detail.url;
|
||||
case 'mcp':
|
||||
return detail.serverName
|
||||
? `${detail.serverName} → ${detail.toolTitle ?? ''}`
|
||||
: detail.toolTitle;
|
||||
case 'memory':
|
||||
return detail.subject;
|
||||
case 'custom-tool':
|
||||
return detail.toolDescription ? truncate(detail.toolDescription, 80) : undefined;
|
||||
case 'hook':
|
||||
return detail.hookMessage ? truncate(detail.hookMessage, 80) : undefined;
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/* ── Kind-specific renderers ────────────────────────────────── */
|
||||
|
||||
function ShellDetail({ detail }: { detail: PermissionDetail }) {
|
||||
return (
|
||||
<div className="mt-2.5 space-y-2">
|
||||
{detail.intention && <IntentionLine text={detail.intention} />}
|
||||
{detail.warning && (
|
||||
<div className="flex items-start gap-1.5 rounded-md bg-red-500/10 px-2.5 py-1.5 text-[11px] text-red-300">
|
||||
<AlertTriangle className="mt-0.5 size-3 shrink-0" />
|
||||
<span>{detail.warning}</span>
|
||||
</div>
|
||||
)}
|
||||
{detail.command && <CommandBlock text={detail.command} />}
|
||||
{detail.possiblePaths && detail.possiblePaths.length > 0 && (
|
||||
<MetaList label="Paths" items={detail.possiblePaths} />
|
||||
)}
|
||||
{detail.possibleUrls && detail.possibleUrls.length > 0 && (
|
||||
<MetaList label="URLs" items={detail.possibleUrls} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function WriteDetail({ detail }: { detail: PermissionDetail }) {
|
||||
return (
|
||||
<div className="mt-2.5 space-y-2">
|
||||
{detail.intention && <IntentionLine text={detail.intention} />}
|
||||
{detail.fileName && (
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-zinc-300">
|
||||
<FileEdit className="size-3 shrink-0 text-zinc-500" />
|
||||
<code className="font-mono">{detail.fileName}</code>
|
||||
</div>
|
||||
)}
|
||||
{detail.diff && <DiffBlock text={detail.diff} />}
|
||||
{!detail.diff && detail.newFileContents && (
|
||||
<CollapsibleCode label="New file contents" text={detail.newFileContents} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function ReadDetail({ detail }: { detail: PermissionDetail }) {
|
||||
return (
|
||||
<div className="mt-2.5 space-y-2">
|
||||
{detail.intention && <IntentionLine text={detail.intention} />}
|
||||
{detail.path && (
|
||||
<div className="flex items-center gap-1.5 text-[11px] text-zinc-300">
|
||||
<FileText className="size-3 shrink-0 text-zinc-500" />
|
||||
<code className="font-mono">{detail.path}</code>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function McpDetail({ detail }: { detail: PermissionDetail }) {
|
||||
return (
|
||||
<div className="mt-2.5 space-y-2">
|
||||
<div className="flex flex-wrap items-center gap-2 text-[11px]">
|
||||
{detail.serverName && (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-indigo-500/15 px-2 py-0.5 text-indigo-300">
|
||||
<Server className="size-2.5" />
|
||||
{detail.serverName}
|
||||
</span>
|
||||
)}
|
||||
{detail.toolTitle && <span className="text-zinc-300">{detail.toolTitle}</span>}
|
||||
{detail.readOnly && (
|
||||
<span className="rounded-full bg-emerald-500/15 px-1.5 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-emerald-400">
|
||||
read-only
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{detail.args && Object.keys(detail.args).length > 0 && (
|
||||
<CollapsibleCode label="Arguments" text={JSON.stringify(detail.args, null, 2)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function UrlDetail({ detail }: { detail: PermissionDetail }) {
|
||||
return (
|
||||
<div className="mt-2.5 space-y-2">
|
||||
{detail.intention && <IntentionLine text={detail.intention} />}
|
||||
{detail.url && (
|
||||
<div className="flex items-center gap-1.5 rounded-md bg-zinc-800/60 px-2.5 py-1.5 text-[11px] text-blue-300">
|
||||
<Globe className="size-3 shrink-0" />
|
||||
<code className="min-w-0 flex-1 break-all font-mono">{detail.url}</code>
|
||||
<ExternalLink className="size-3 shrink-0 text-zinc-500" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MemoryDetail({ detail }: { detail: PermissionDetail }) {
|
||||
return (
|
||||
<div className="mt-2.5 space-y-1.5">
|
||||
{detail.subject && (
|
||||
<div className="flex items-center gap-1.5 text-[11px]">
|
||||
<BookOpen className="size-3 shrink-0 text-zinc-500" />
|
||||
<span className="font-medium text-zinc-300">{detail.subject}</span>
|
||||
</div>
|
||||
)}
|
||||
{detail.fact && (
|
||||
<p className="rounded-md bg-zinc-800/60 px-2.5 py-1.5 text-[11px] leading-relaxed text-zinc-300">
|
||||
{detail.fact}
|
||||
</p>
|
||||
)}
|
||||
{detail.citations && (
|
||||
<p className="text-[10px] text-zinc-500">
|
||||
Source: <span className="text-zinc-400">{detail.citations}</span>
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function CustomToolDetail({ detail }: { detail: PermissionDetail }) {
|
||||
return (
|
||||
<div className="mt-2.5 space-y-2">
|
||||
{detail.toolDescription && (
|
||||
<p className="text-[11px] text-zinc-400">{detail.toolDescription}</p>
|
||||
)}
|
||||
{detail.args && Object.keys(detail.args).length > 0 && (
|
||||
<CollapsibleCode label="Arguments" text={JSON.stringify(detail.args, null, 2)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HookDetail({ detail }: { detail: PermissionDetail }) {
|
||||
return (
|
||||
<div className="mt-2.5 space-y-2">
|
||||
{detail.hookMessage && (
|
||||
<div className="flex items-start gap-1.5 rounded-md bg-amber-500/10 px-2.5 py-1.5 text-[11px] text-amber-200">
|
||||
<AlertTriangle className="mt-0.5 size-3 shrink-0" />
|
||||
<span>{detail.hookMessage}</span>
|
||||
</div>
|
||||
)}
|
||||
{detail.args && Object.keys(detail.args).length > 0 && (
|
||||
<CollapsibleCode label="Arguments" text={JSON.stringify(detail.args, null, 2)} />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/* ── Shared primitives ──────────────────────────────────────── */
|
||||
|
||||
function IntentionLine({ text }: { text: string }) {
|
||||
return <p className="text-[11px] italic text-zinc-400">{text}</p>;
|
||||
}
|
||||
|
||||
function CommandBlock({ text }: { text: string }) {
|
||||
return (
|
||||
<pre className="overflow-x-auto rounded-md bg-zinc-900/80 px-3 py-2 font-mono text-[11px] leading-relaxed text-emerald-300">
|
||||
{text}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
function DiffBlock({ text }: { text: string }) {
|
||||
const lines = text.split('\n');
|
||||
return (
|
||||
<CollapsibleCode label="Diff" text={text} defaultExpanded>
|
||||
<pre className="max-h-48 overflow-auto rounded-md bg-zinc-900/80 px-3 py-2 font-mono text-[10px] leading-relaxed">
|
||||
{lines.map((line, i) => {
|
||||
let color = 'text-zinc-400';
|
||||
if (line.startsWith('+')) color = 'text-emerald-400';
|
||||
else if (line.startsWith('-')) color = 'text-red-400';
|
||||
else if (line.startsWith('@@')) color = 'text-blue-400';
|
||||
return (
|
||||
<div className={color} key={i}>
|
||||
{line}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</pre>
|
||||
</CollapsibleCode>
|
||||
);
|
||||
}
|
||||
|
||||
function CollapsibleCode({
|
||||
label,
|
||||
text,
|
||||
children,
|
||||
defaultExpanded = false,
|
||||
}: {
|
||||
label: string;
|
||||
text: string;
|
||||
children?: React.ReactNode;
|
||||
defaultExpanded?: boolean;
|
||||
}) {
|
||||
const [expanded, setExpanded] = useState(defaultExpanded);
|
||||
|
||||
return (
|
||||
<div className="rounded-md border border-zinc-800/60 bg-zinc-900/40">
|
||||
<button
|
||||
aria-expanded={expanded}
|
||||
className="flex w-full items-center gap-1.5 px-2.5 py-1.5 text-left text-[10px] font-medium text-zinc-500 hover:text-zinc-400"
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
type="button"
|
||||
>
|
||||
<ChevronDown
|
||||
className={`size-2.5 transition-transform ${expanded ? 'rotate-180' : ''}`}
|
||||
/>
|
||||
{label}
|
||||
</button>
|
||||
{expanded && (
|
||||
<div className="border-t border-zinc-800/40 px-2.5 py-1.5">
|
||||
{children ?? (
|
||||
<pre className="max-h-48 overflow-auto font-mono text-[10px] leading-relaxed text-zinc-300">
|
||||
{text}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MetaList({ label, items }: { label: string; items: string[] }) {
|
||||
return (
|
||||
<div className="text-[10px] text-zinc-500">
|
||||
<span className="font-medium">{label}:</span>{' '}
|
||||
<span className="text-zinc-400">{items.join(', ')}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function truncate(text: string, maxLength: number): string {
|
||||
return text.length <= maxLength ? text : `${text.slice(0, maxLength)}…`;
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { useCallback } from 'react';
|
||||
import { ClipboardList, X } from 'lucide-react';
|
||||
|
||||
import { MarkdownContent } from '@renderer/components/MarkdownContent';
|
||||
import type { PendingPlanReviewRecord } from '@shared/domain/planReview';
|
||||
|
||||
export function PlanReviewBanner({
|
||||
planReview,
|
||||
onDismiss,
|
||||
}: {
|
||||
planReview: PendingPlanReviewRecord;
|
||||
onDismiss: (planReview: PendingPlanReviewRecord) => void;
|
||||
}) {
|
||||
const handleDismiss = useCallback(() => {
|
||||
onDismiss(planReview);
|
||||
}, [planReview, onDismiss]);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-emerald-500/30 bg-emerald-500/5 px-4 py-3" role="alert">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-2.5">
|
||||
<ClipboardList className="mt-0.5 size-4 shrink-0 text-emerald-400" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-semibold text-emerald-200">Plan ready for review</span>
|
||||
<span className="rounded-full bg-emerald-500/15 px-2 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-emerald-400">
|
||||
Plan mode
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
aria-label="Dismiss plan"
|
||||
className="rounded p-0.5 text-zinc-500 transition hover:bg-zinc-700/50 hover:text-zinc-300"
|
||||
onClick={handleDismiss}
|
||||
type="button"
|
||||
>
|
||||
<X className="size-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{planReview.agentName && (
|
||||
<div className="mt-1 text-[11px] text-zinc-400">
|
||||
Agent: <span className="text-zinc-300">{planReview.agentName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Summary */}
|
||||
{planReview.summary && (
|
||||
<p className="mt-2 text-[13px] leading-relaxed text-zinc-200">
|
||||
{planReview.summary}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Plan content (rendered markdown) */}
|
||||
{planReview.planContent && (
|
||||
<div className="mt-3 max-h-80 overflow-y-auto rounded-lg border border-zinc-700/50 bg-zinc-900/60 p-3">
|
||||
<MarkdownContent content={planReview.planContent} />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Guidance */}
|
||||
<p className="mt-3 text-[12px] leading-relaxed text-zinc-400">
|
||||
Send a follow-up message to proceed — e.g.{' '}
|
||||
<span className="text-zinc-300">"implement the plan"</span>,{' '}
|
||||
<span className="text-zinc-300">"adjust step 3"</span>, or ask for a different approach.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,113 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { Loader2, MessageCircleQuestion, Send } from 'lucide-react';
|
||||
|
||||
import type { PendingUserInputRecord } from '@shared/domain/userInput';
|
||||
|
||||
export function UserInputBanner({
|
||||
userInput,
|
||||
onSubmit,
|
||||
isSubmitting,
|
||||
}: {
|
||||
userInput: PendingUserInputRecord;
|
||||
onSubmit: (answer: string, wasFreeform: boolean) => void;
|
||||
isSubmitting: boolean;
|
||||
}) {
|
||||
const [freeformText, setFreeformText] = useState('');
|
||||
const hasChoices = userInput.choices && userInput.choices.length > 0;
|
||||
|
||||
const handleChoiceClick = useCallback(
|
||||
(choice: string) => {
|
||||
if (!isSubmitting) {
|
||||
onSubmit(choice, false);
|
||||
}
|
||||
},
|
||||
[isSubmitting, onSubmit],
|
||||
);
|
||||
|
||||
const handleFreeformSubmit = useCallback(() => {
|
||||
const trimmed = freeformText.trim();
|
||||
if (trimmed && !isSubmitting) {
|
||||
onSubmit(trimmed, true);
|
||||
}
|
||||
}, [freeformText, isSubmitting, onSubmit]);
|
||||
|
||||
const handleKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleFreeformSubmit();
|
||||
}
|
||||
},
|
||||
[handleFreeformSubmit],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="rounded-xl border border-blue-500/30 bg-blue-500/5 px-4 py-3" role="alert">
|
||||
{/* Header */}
|
||||
<div className="flex items-start gap-2.5">
|
||||
<MessageCircleQuestion className="mt-0.5 size-4 shrink-0 text-blue-400" />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[13px] font-semibold text-blue-200">Agent question</span>
|
||||
<span className="rounded-full bg-blue-500/15 px-2 py-0.5 text-[9px] font-semibold uppercase tracking-wider text-blue-400">
|
||||
User input
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{userInput.agentName && (
|
||||
<div className="mt-1 text-[11px] text-zinc-400">
|
||||
Agent: <span className="text-zinc-300">{userInput.agentName}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-2 text-[13px] leading-relaxed text-zinc-200 whitespace-pre-wrap">
|
||||
{userInput.question}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Choices */}
|
||||
{hasChoices && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{userInput.choices!.map((choice) => (
|
||||
<button
|
||||
className="rounded-lg border border-blue-500/30 bg-blue-500/10 px-3.5 py-1.5 text-[12px] font-medium text-blue-200 transition hover:border-blue-400/50 hover:bg-blue-500/20 hover:text-white disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={isSubmitting}
|
||||
key={choice}
|
||||
onClick={() => handleChoiceClick(choice)}
|
||||
type="button"
|
||||
>
|
||||
{choice}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Freeform input */}
|
||||
{userInput.allowFreeform && (
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<input
|
||||
aria-label="Type your answer"
|
||||
className="min-w-0 flex-1 rounded-lg border border-zinc-700 bg-zinc-900/60 px-3 py-1.5 text-[13px] text-zinc-200 placeholder-zinc-500 outline-none transition focus:border-blue-500/50 focus:ring-1 focus:ring-blue-500/30 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={isSubmitting}
|
||||
onChange={(e) => setFreeformText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder={hasChoices ? 'Or type your own answer…' : 'Type your answer…'}
|
||||
type="text"
|
||||
value={freeformText}
|
||||
/>
|
||||
<button
|
||||
aria-label="Submit answer"
|
||||
className="inline-flex items-center gap-1.5 rounded-lg bg-blue-600 px-3.5 py-1.5 text-[12px] font-medium text-white transition hover:bg-blue-500 disabled:cursor-not-allowed disabled:opacity-50"
|
||||
disabled={isSubmitting || !freeformText.trim()}
|
||||
onClick={handleFreeformSubmit}
|
||||
type="button"
|
||||
>
|
||||
{isSubmitting ? <Loader2 className="size-3 animate-spin" /> : <Send className="size-3" />}
|
||||
Send
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -246,32 +246,6 @@ export function removeEdge(graph: PatternGraph, removeEdgeId: string): PatternGr
|
||||
return { ...graph, edges: graph.edges.filter((e) => e.id !== removeEdgeId) };
|
||||
}
|
||||
|
||||
/* ── Add disconnected agent node ───────────────────────────── */
|
||||
|
||||
export function addAgentNodeToGraph(
|
||||
graph: PatternGraph,
|
||||
agent: PatternAgentDefinition,
|
||||
): PatternGraph {
|
||||
const existingAgentNodes = graph.nodes.filter((n) => n.kind === 'agent');
|
||||
const nextOrder = existingAgentNodes.length;
|
||||
|
||||
// Place new node below existing agent nodes
|
||||
const maxY = existingAgentNodes.reduce((max, n) => Math.max(max, n.position.y), 0);
|
||||
const avgX = existingAgentNodes.length > 0
|
||||
? Math.round(existingAgentNodes.reduce((sum, n) => sum + n.position.x, 0) / existingAgentNodes.length)
|
||||
: 400;
|
||||
|
||||
const newNode: PatternGraphNode = {
|
||||
id: `agent-node-${agent.id}`,
|
||||
kind: 'agent',
|
||||
agentId: agent.id,
|
||||
order: nextOrder,
|
||||
position: { x: avgX, y: maxY + 120 },
|
||||
};
|
||||
|
||||
return { ...graph, nodes: [...graph.nodes, newNode] };
|
||||
}
|
||||
|
||||
/* ── Sequential reorder ────────────────────────────────────── */
|
||||
|
||||
export function swapSequentialOrder(
|
||||
|
||||
@@ -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,147 @@ 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>;
|
||||
|
||||
const hookTypeLabels: Record<string, string> = {
|
||||
sessionStart: 'Session start',
|
||||
sessionEnd: 'Session end',
|
||||
userPromptSubmitted: 'Prompt submitted',
|
||||
preToolUse: 'Pre-tool use',
|
||||
postToolUse: 'Post-tool use',
|
||||
errorOccurred: 'Error occurred',
|
||||
};
|
||||
|
||||
function formatHookType(hookType: string | undefined): string {
|
||||
if (!hookType) return 'Unknown';
|
||||
return hookTypeLabels[hookType] ?? hookType;
|
||||
}
|
||||
|
||||
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': {
|
||||
const hookLabel = formatHookType(event.hookType);
|
||||
const phaseLabel = event.hookPhase === 'start' ? 'started' : event.hookPhase === 'end' ? 'completed' : undefined;
|
||||
return {
|
||||
kind: event.kind,
|
||||
occurredAt: event.occurredAt,
|
||||
label: phaseLabel ? `${hookLabel} hook ${phaseLabel}` : `${hookLabel} hook`,
|
||||
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,9 +23,15 @@ 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',
|
||||
resolveSessionUserInput: 'sessions:resolve-user-input',
|
||||
setSessionInteractionMode: 'sessions:set-interaction-mode',
|
||||
dismissSessionPlanReview: 'sessions:dismiss-plan-review',
|
||||
dismissSessionMcpAuth: 'sessions:dismiss-mcp-auth',
|
||||
startSessionMcpAuth: 'sessions:start-mcp-auth',
|
||||
querySessions: 'sessions:query',
|
||||
updateSessionModelConfig: 'sessions:update-model-config',
|
||||
selectProject: 'selection:project',
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import type { ApprovalDecision } from '@shared/domain/approval';
|
||||
import type { SidecarCapabilities } 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 {
|
||||
@@ -34,6 +37,14 @@ export interface ResolveSessionApprovalInput {
|
||||
sessionId: string;
|
||||
approvalId: string;
|
||||
decision: ApprovalDecision;
|
||||
alwaysApprove?: boolean;
|
||||
}
|
||||
|
||||
export interface ResolveSessionUserInputInput {
|
||||
sessionId: string;
|
||||
userInputId: string;
|
||||
answer: string;
|
||||
wasFreeform: boolean;
|
||||
}
|
||||
|
||||
export interface UpdateSessionModelConfigInput {
|
||||
@@ -100,6 +111,27 @@ export interface UpdateSessionApprovalSettingsInput {
|
||||
autoApprovedToolNames?: string[];
|
||||
}
|
||||
|
||||
export interface SetSessionInteractionModeInput {
|
||||
sessionId: string;
|
||||
mode: InteractionMode;
|
||||
}
|
||||
|
||||
export interface DismissSessionPlanReviewInput {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface DismissSessionMcpAuthInput {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface StartSessionMcpAuthInput {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface DeleteSessionInput {
|
||||
sessionId: string;
|
||||
}
|
||||
|
||||
export interface ElectronApi {
|
||||
describeSidecarCapabilities(): Promise<SidecarCapabilities>;
|
||||
refreshSidecarCapabilities(): Promise<SidecarCapabilities>;
|
||||
@@ -123,9 +155,15 @@ 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>;
|
||||
resolveSessionUserInput(input: ResolveSessionUserInputInput): Promise<WorkspaceState>;
|
||||
setSessionInteractionMode(input: SetSessionInteractionModeInput): Promise<WorkspaceState>;
|
||||
dismissSessionPlanReview(input: DismissSessionPlanReviewInput): Promise<WorkspaceState>;
|
||||
dismissSessionMcpAuth(input: DismissSessionMcpAuthInput): Promise<WorkspaceState>;
|
||||
startSessionMcpAuth(input: StartSessionMcpAuthInput): Promise<WorkspaceState>;
|
||||
updateSessionModelConfig(input: UpdateSessionModelConfigInput): Promise<WorkspaceState>;
|
||||
querySessions(input: QuerySessionsInput): Promise<SessionQueryResult[]>;
|
||||
selectProject(projectId?: string): 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;
|
||||
@@ -68,14 +69,20 @@ export interface ValidatePatternCommand {
|
||||
pattern: PatternDefinition;
|
||||
}
|
||||
|
||||
export type InteractionMode = 'interactive' | 'plan';
|
||||
export type MessageMode = 'enqueue' | 'immediate';
|
||||
|
||||
export interface RunTurnCommand {
|
||||
type: 'run-turn';
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
projectPath: string;
|
||||
workspaceKind?: 'project' | 'scratchpad';
|
||||
mode?: InteractionMode;
|
||||
messageMode?: MessageMode;
|
||||
pattern: PatternDefinition;
|
||||
messages: ChatMessageRecord[];
|
||||
attachments?: ChatMessageAttachment[];
|
||||
tooling?: RunTurnToolingConfig;
|
||||
}
|
||||
|
||||
@@ -90,6 +97,41 @@ export interface ResolveApprovalCommand {
|
||||
requestId: string;
|
||||
approvalId: string;
|
||||
decision: ApprovalDecision;
|
||||
alwaysApprove: boolean;
|
||||
}
|
||||
|
||||
export interface ResolveUserInputCommand {
|
||||
type: 'resolve-user-input';
|
||||
requestId: string;
|
||||
userInputId: string;
|
||||
answer: string;
|
||||
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 =
|
||||
@@ -97,7 +139,11 @@ export type SidecarCommand =
|
||||
| ValidatePatternCommand
|
||||
| RunTurnCommand
|
||||
| CancelTurnCommand
|
||||
| ResolveApprovalCommand;
|
||||
| ResolveApprovalCommand
|
||||
| ResolveUserInputCommand
|
||||
| ListSessionsCommand
|
||||
| DeleteSessionCommand
|
||||
| DisconnectSessionCommand;
|
||||
|
||||
export interface RunTurnLocalMcpServerConfig {
|
||||
id: string;
|
||||
@@ -137,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;
|
||||
@@ -181,6 +251,161 @@ 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;
|
||||
command?: string;
|
||||
warning?: string;
|
||||
possiblePaths?: string[];
|
||||
possibleUrls?: string[];
|
||||
hasWriteFileRedirection?: boolean;
|
||||
fileName?: string;
|
||||
diff?: string;
|
||||
newFileContents?: string;
|
||||
path?: string;
|
||||
serverName?: string;
|
||||
toolTitle?: string;
|
||||
args?: Record<string, unknown>;
|
||||
readOnly?: boolean;
|
||||
url?: string;
|
||||
subject?: string;
|
||||
fact?: string;
|
||||
citations?: string;
|
||||
toolDescription?: string;
|
||||
hookMessage?: string;
|
||||
}
|
||||
|
||||
export interface ApprovalRequestedEvent {
|
||||
type: 'approval-requested';
|
||||
requestId: string;
|
||||
@@ -193,6 +418,49 @@ export interface ApprovalRequestedEvent {
|
||||
permissionKind?: string;
|
||||
title: string;
|
||||
detail?: string;
|
||||
permissionDetail?: PermissionDetail;
|
||||
}
|
||||
|
||||
export interface UserInputRequestedEvent {
|
||||
type: 'user-input-requested';
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
userInputId: string;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
question: string;
|
||||
choices?: string[];
|
||||
allowFreeform?: boolean;
|
||||
}
|
||||
|
||||
export interface McpOauthStaticClientConfigEvent {
|
||||
clientId: string;
|
||||
publicClient?: boolean;
|
||||
}
|
||||
|
||||
export interface McpOauthRequiredEvent {
|
||||
type: 'mcp-oauth-required';
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
oauthRequestId: string;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
serverName: string;
|
||||
serverUrl: string;
|
||||
staticClientConfig?: McpOauthStaticClientConfigEvent;
|
||||
}
|
||||
|
||||
export interface ExitPlanModeRequestedEvent {
|
||||
type: 'exit-plan-mode-requested';
|
||||
requestId: string;
|
||||
sessionId: string;
|
||||
exitPlanId: string;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
summary: string;
|
||||
planContent: string;
|
||||
actions?: string[];
|
||||
recommendedAction?: string;
|
||||
}
|
||||
|
||||
export interface CommandErrorEvent {
|
||||
@@ -212,6 +480,18 @@ export type SidecarEvent =
|
||||
| TurnDeltaEvent
|
||||
| TurnCompleteEvent
|
||||
| AgentActivityEvent
|
||||
| SubagentEvent
|
||||
| SkillInvokedEvent
|
||||
| HookLifecycleEvent
|
||||
| SessionUsageEvent
|
||||
| SessionCompactionEvent
|
||||
| PendingMessagesModifiedEvent
|
||||
| ApprovalRequestedEvent
|
||||
| UserInputRequestedEvent
|
||||
| McpOauthRequiredEvent
|
||||
| ExitPlanModeRequestedEvent
|
||||
| SessionsListedEvent
|
||||
| SessionsDeletedEvent
|
||||
| SessionDisconnectedEvent
|
||||
| CommandErrorEvent
|
||||
| CommandCompleteEvent;
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import type { PermissionDetail } from '@shared/contracts/sidecar';
|
||||
|
||||
export type ApprovalCheckpointKind = 'tool-call' | 'final-response';
|
||||
export type ApprovalStatus = 'pending' | 'approved' | 'rejected';
|
||||
export type ApprovalDecision = Exclude<ApprovalStatus, 'pending'>;
|
||||
@@ -35,6 +37,7 @@ export interface PendingApprovalRecord {
|
||||
title: string;
|
||||
detail?: string;
|
||||
messages?: PendingApprovalMessageRecord[];
|
||||
permissionDetail?: PermissionDetail;
|
||||
}
|
||||
|
||||
export interface PendingApprovalState {
|
||||
@@ -190,6 +193,28 @@ export function approvalPolicyRequiresCheckpoint(
|
||||
return rule.agentIds.includes(normalizedAgentId);
|
||||
}
|
||||
|
||||
const runtimePermissionKinds: ReadonlySet<string> = new Set(['read', 'write', 'shell', 'memory', 'url']);
|
||||
|
||||
/**
|
||||
* Resolves the canonical approval key for a pending approval.
|
||||
*
|
||||
* Runtime tools always use their permission-kind category (`read`, `write`, `shell`)
|
||||
* mapped to the corresponding approval-tool id. MCP/custom/hook tools use their
|
||||
* specific tool name.
|
||||
*/
|
||||
export function resolveApprovalToolKey(
|
||||
toolName: string | undefined,
|
||||
permissionKind: string | undefined,
|
||||
): string | undefined {
|
||||
if (permissionKind && runtimePermissionKinds.has(permissionKind)) {
|
||||
if (permissionKind === 'memory') return 'store_memory';
|
||||
if (permissionKind === 'url') return 'web_fetch';
|
||||
return permissionKind;
|
||||
}
|
||||
|
||||
return toolName;
|
||||
}
|
||||
|
||||
export function approvalPolicyAutoApprovesTool(
|
||||
policy: ApprovalPolicy | undefined,
|
||||
toolName?: string,
|
||||
@@ -290,6 +315,7 @@ export function normalizePendingApproval(
|
||||
title,
|
||||
detail: normalizeOptionalString(approval?.detail),
|
||||
messages: normalizePendingApprovalMessages(approval?.messages),
|
||||
permissionDetail: approval?.permissionDetail,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
export type McpAuthStatus = 'pending' | 'authenticating' | 'authenticated' | 'failed';
|
||||
|
||||
export interface McpOauthStaticClientConfig {
|
||||
clientId: string;
|
||||
publicClient?: boolean;
|
||||
}
|
||||
|
||||
export interface PendingMcpAuthRecord {
|
||||
id: string;
|
||||
status: McpAuthStatus;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
serverName: string;
|
||||
serverUrl: string;
|
||||
staticClientConfig?: McpOauthStaticClientConfig;
|
||||
requestedAt: string;
|
||||
completedAt?: string;
|
||||
errorMessage?: string;
|
||||
}
|
||||
@@ -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 {
|
||||
@@ -311,6 +314,229 @@ export function createDefaultPatternGraph(
|
||||
}
|
||||
}
|
||||
|
||||
function sortAgentNodesByOrder(nodes: ReadonlyArray<PatternGraphNode>): PatternGraphNode[] {
|
||||
return nodes
|
||||
.filter((node) => node.kind === 'agent')
|
||||
.slice()
|
||||
.sort((left, right) =>
|
||||
(left.order ?? Number.MAX_SAFE_INTEGER) - (right.order ?? Number.MAX_SAFE_INTEGER)
|
||||
|| left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
function renumberAgentOrders(nodes: ReadonlyArray<PatternGraphNode>): PatternGraphNode[] {
|
||||
const orders = new Map(sortAgentNodesByOrder(nodes).map((node, index) => [node.id, index]));
|
||||
return nodes.map((node) =>
|
||||
node.kind === 'agent'
|
||||
? { ...node, order: orders.get(node.id) ?? node.order ?? 0 }
|
||||
: node);
|
||||
}
|
||||
|
||||
function getNextAgentNodePosition(graph: PatternGraph): PatternGraphPosition {
|
||||
const existingAgentNodes = getAgentNodes(graph);
|
||||
const maxY = existingAgentNodes.reduce((max, node) => Math.max(max, node.position.y), 0);
|
||||
const avgX = existingAgentNodes.length > 0
|
||||
? Math.round(existingAgentNodes.reduce((sum, node) => sum + node.position.x, 0) / existingAgentNodes.length)
|
||||
: 400;
|
||||
|
||||
return {
|
||||
x: avgX,
|
||||
y: maxY + 120,
|
||||
};
|
||||
}
|
||||
|
||||
function addUniqueEdge(
|
||||
edges: ReadonlyArray<PatternGraphEdge>,
|
||||
source: string | undefined,
|
||||
target: string | undefined,
|
||||
): PatternGraphEdge[] {
|
||||
if (!source || !target || edges.some((edge) => edge.source === source && edge.target === target)) {
|
||||
return [...edges];
|
||||
}
|
||||
|
||||
return [...edges, createEdge(source, target)];
|
||||
}
|
||||
|
||||
function removeEdgesConnectedToNode(
|
||||
edges: ReadonlyArray<PatternGraphEdge>,
|
||||
nodeId: string,
|
||||
): PatternGraphEdge[] {
|
||||
return edges.filter((edge) => edge.source !== nodeId && edge.target !== nodeId);
|
||||
}
|
||||
|
||||
function findAgentNode(graph: PatternGraph, agentId: string): PatternGraphNode | undefined {
|
||||
return graph.nodes.find((node) => node.kind === 'agent' && node.agentId === agentId);
|
||||
}
|
||||
|
||||
function resolveEntryAgentNodeId(graph: PatternGraph): string | undefined {
|
||||
const { outgoing } = buildAdjacency(graph);
|
||||
const agentNodeIds = new Set(getAgentNodes(graph).map((node) => node.id));
|
||||
const directEntry = (outgoing.get(SYSTEM_NODE_IDS.userInput) ?? [])
|
||||
.map((edge) => edge.target)
|
||||
.find((nodeId) => agentNodeIds.has(nodeId));
|
||||
|
||||
return directEntry ?? sortAgentNodesByOrder(graph.nodes)[0]?.id;
|
||||
}
|
||||
|
||||
function hasAgentToAgentRoute(graph: PatternGraph): boolean {
|
||||
const agentNodeIds = new Set(getAgentNodes(graph).map((node) => node.id));
|
||||
return graph.edges.some((edge) =>
|
||||
agentNodeIds.has(edge.source)
|
||||
&& agentNodeIds.has(edge.target)
|
||||
&& edge.source !== edge.target);
|
||||
}
|
||||
|
||||
export function addAgentToGraph(
|
||||
graph: PatternGraph,
|
||||
mode: OrchestrationMode,
|
||||
agent: PatternAgentDefinition,
|
||||
): PatternGraph {
|
||||
if (mode === 'single') {
|
||||
throw new Error('Single-agent chat requires exactly one agent.');
|
||||
}
|
||||
|
||||
if (findAgentNode(graph, agent.id)) {
|
||||
return graph;
|
||||
}
|
||||
|
||||
const existingAgentNodes = sortAgentNodesByOrder(graph.nodes);
|
||||
const newNode = createAgentNode(
|
||||
agent,
|
||||
existingAgentNodes.length,
|
||||
getNextAgentNodePosition(graph),
|
||||
);
|
||||
let nextEdges = [...graph.edges];
|
||||
|
||||
switch (mode) {
|
||||
case 'sequential':
|
||||
case 'magentic': {
|
||||
const outputNode = getNodeByKind(graph, 'user-output');
|
||||
const inputNode = getNodeByKind(graph, 'user-input');
|
||||
const { incoming } = buildAdjacency(graph);
|
||||
const previousNodeId = (outputNode ? incoming.get(outputNode.id)?.[0]?.source : undefined)
|
||||
?? existingAgentNodes[existingAgentNodes.length - 1]?.id
|
||||
?? inputNode?.id;
|
||||
|
||||
if (outputNode && previousNodeId) {
|
||||
nextEdges = nextEdges.filter((edge) => !(edge.source === previousNodeId && edge.target === outputNode.id));
|
||||
nextEdges = addUniqueEdge(nextEdges, previousNodeId, newNode.id);
|
||||
nextEdges = addUniqueEdge(nextEdges, newNode.id, outputNode.id);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'concurrent': {
|
||||
const distributorNode = getNodeByKind(graph, 'distributor');
|
||||
const collectorNode = getNodeByKind(graph, 'collector');
|
||||
nextEdges = addUniqueEdge(nextEdges, distributorNode?.id, newNode.id);
|
||||
nextEdges = addUniqueEdge(nextEdges, newNode.id, collectorNode?.id);
|
||||
break;
|
||||
}
|
||||
case 'handoff': {
|
||||
const inputNode = getNodeByKind(graph, 'user-input');
|
||||
const outputNode = getNodeByKind(graph, 'user-output');
|
||||
const entryNodeId = resolveEntryAgentNodeId(graph);
|
||||
|
||||
if (entryNodeId) {
|
||||
nextEdges = addUniqueEdge(nextEdges, entryNodeId, newNode.id);
|
||||
nextEdges = addUniqueEdge(nextEdges, newNode.id, entryNodeId);
|
||||
} else {
|
||||
nextEdges = addUniqueEdge(nextEdges, inputNode?.id, newNode.id);
|
||||
}
|
||||
|
||||
nextEdges = addUniqueEdge(nextEdges, newNode.id, outputNode?.id);
|
||||
break;
|
||||
}
|
||||
case 'group-chat': {
|
||||
const orchestratorNode = getNodeByKind(graph, 'orchestrator');
|
||||
nextEdges = addUniqueEdge(nextEdges, orchestratorNode?.id, newNode.id);
|
||||
nextEdges = addUniqueEdge(nextEdges, newNode.id, orchestratorNode?.id);
|
||||
break;
|
||||
}
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: renumberAgentOrders([...graph.nodes, newNode]),
|
||||
edges: nextEdges,
|
||||
};
|
||||
}
|
||||
|
||||
export function removeAgentFromGraph(
|
||||
graph: PatternGraph,
|
||||
mode: OrchestrationMode,
|
||||
agentId: string,
|
||||
): PatternGraph {
|
||||
const nodeToRemove = findAgentNode(graph, agentId);
|
||||
if (!nodeToRemove) {
|
||||
return graph;
|
||||
}
|
||||
|
||||
const originalAgentNodes = sortAgentNodesByOrder(graph.nodes);
|
||||
if (mode === 'single' && originalAgentNodes.length <= 1) {
|
||||
throw new Error('Single-agent chat requires exactly one agent.');
|
||||
}
|
||||
|
||||
const { incoming, outgoing } = buildAdjacency(graph);
|
||||
const removedWasEntry = (outgoing.get(SYSTEM_NODE_IDS.userInput) ?? []).some(
|
||||
(edge) => edge.target === nodeToRemove.id,
|
||||
);
|
||||
const remainingNodes = graph.nodes.filter((node) => node.id !== nodeToRemove.id);
|
||||
let nextEdges = removeEdgesConnectedToNode(graph.edges, nodeToRemove.id);
|
||||
|
||||
switch (mode) {
|
||||
case 'sequential':
|
||||
case 'magentic': {
|
||||
const predecessorId = incoming.get(nodeToRemove.id)?.[0]?.source;
|
||||
const successorId = outgoing.get(nodeToRemove.id)?.[0]?.target;
|
||||
nextEdges = addUniqueEdge(
|
||||
nextEdges,
|
||||
predecessorId && predecessorId !== nodeToRemove.id ? predecessorId : undefined,
|
||||
successorId && successorId !== nodeToRemove.id ? successorId : undefined,
|
||||
);
|
||||
break;
|
||||
}
|
||||
case 'handoff': {
|
||||
const remainingGraph: PatternGraph = { nodes: remainingNodes, edges: nextEdges };
|
||||
const remainingAgentNodes = sortAgentNodesByOrder(remainingNodes);
|
||||
const outputNode = getNodeByKind(remainingGraph, 'user-output');
|
||||
const entryNodeId = resolveEntryAgentNodeId(remainingGraph) ?? remainingAgentNodes[0]?.id;
|
||||
|
||||
if (entryNodeId) {
|
||||
const { outgoing: remainingOutgoing, incoming: remainingIncoming } = buildAdjacency(remainingGraph);
|
||||
if ((remainingOutgoing.get(SYSTEM_NODE_IDS.userInput)?.length ?? 0) === 0) {
|
||||
nextEdges = addUniqueEdge(nextEdges, SYSTEM_NODE_IDS.userInput, entryNodeId);
|
||||
}
|
||||
|
||||
if (removedWasEntry || (remainingIncoming.get(outputNode?.id ?? '')?.length ?? 0) === 0) {
|
||||
nextEdges = addUniqueEdge(nextEdges, entryNodeId, outputNode?.id);
|
||||
}
|
||||
|
||||
if (remainingAgentNodes.length > 1 && (removedWasEntry || !hasAgentToAgentRoute(remainingGraph))) {
|
||||
for (const specialistNode of remainingAgentNodes) {
|
||||
if (specialistNode.id === entryNodeId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
nextEdges = addUniqueEdge(nextEdges, entryNodeId, specialistNode.id);
|
||||
nextEdges = addUniqueEdge(nextEdges, specialistNode.id, entryNodeId);
|
||||
nextEdges = addUniqueEdge(nextEdges, specialistNode.id, outputNode?.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case 'concurrent':
|
||||
case 'group-chat':
|
||||
default:
|
||||
break;
|
||||
}
|
||||
|
||||
return {
|
||||
nodes: renumberAgentOrders(remainingNodes),
|
||||
edges: nextEdges,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolvePatternGraph(pattern: PatternDefinition): PatternGraph {
|
||||
return pattern.graph ?? createDefaultPatternGraph(pattern);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export type PlanReviewStatus = 'pending' | 'acted';
|
||||
|
||||
export interface PendingPlanReviewRecord {
|
||||
id: string;
|
||||
status: PlanReviewStatus;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
summary: string;
|
||||
planContent: string;
|
||||
actions?: string[];
|
||||
recommendedAction?: string;
|
||||
requestedAt: string;
|
||||
actedAt?: string;
|
||||
}
|
||||
@@ -11,6 +11,11 @@ import {
|
||||
type SessionApprovalSettings,
|
||||
} from '@shared/domain/approval';
|
||||
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';
|
||||
export type SessionStatus = 'idle' | 'running' | 'error';
|
||||
@@ -28,6 +33,7 @@ export interface ChatMessageRecord {
|
||||
content: string;
|
||||
createdAt: string;
|
||||
pending?: boolean;
|
||||
attachments?: ChatMessageAttachment[];
|
||||
}
|
||||
|
||||
export interface SessionRecord {
|
||||
@@ -41,6 +47,8 @@ export interface SessionRecord {
|
||||
status: SessionStatus;
|
||||
isPinned?: boolean;
|
||||
isArchived?: boolean;
|
||||
interactionMode?: InteractionMode;
|
||||
cwd?: string;
|
||||
messages: ChatMessageRecord[];
|
||||
lastError?: string;
|
||||
sessionModelConfig?: SessionModelConfig;
|
||||
@@ -48,6 +56,9 @@ export interface SessionRecord {
|
||||
approvalSettings?: SessionApprovalSettings;
|
||||
pendingApproval?: PendingApprovalRecord;
|
||||
pendingApprovalQueue?: PendingApprovalRecord[];
|
||||
pendingUserInput?: PendingUserInputRecord;
|
||||
pendingPlanReview?: PendingPlanReviewRecord;
|
||||
pendingMcpAuth?: PendingMcpAuthRecord;
|
||||
runs: SessionRunRecord[];
|
||||
}
|
||||
|
||||
|
||||
@@ -89,15 +89,64 @@ const lspApprovalOperations = [
|
||||
{ suffix: 'references', label: 'References' },
|
||||
] as const;
|
||||
|
||||
// Fallback runtime tools used before sidecar capabilities are loaded or when the
|
||||
// CLI cannot report its built-in tool catalog dynamically.
|
||||
// Human-readable labels for built-in runtime tools.
|
||||
// Both bash and powershell variants are included for forward compatibility.
|
||||
const builtinToolLabels: ReadonlyMap<string, string> = new Map([
|
||||
// Permission-kind approval categories (used by sidecar for runtime tool session approval)
|
||||
['shell', 'Shell commands'],
|
||||
['read', 'Read files'],
|
||||
['write', 'Write files'],
|
||||
// Shell tools
|
||||
['bash', 'Execute shell commands'],
|
||||
['powershell', 'Execute shell commands'],
|
||||
['read_bash', 'Read shell output'],
|
||||
['read_powershell', 'Read shell output'],
|
||||
['write_bash', 'Write shell input'],
|
||||
['write_powershell', 'Write shell input'],
|
||||
['stop_bash', 'Stop shell session'],
|
||||
['stop_powershell', 'Stop shell session'],
|
||||
['list_bash', 'List shell sessions'],
|
||||
['list_powershell', 'List shell sessions'],
|
||||
// File tools
|
||||
['view', 'View files'],
|
||||
['create', 'Create files'],
|
||||
['edit', 'Edit files'],
|
||||
['apply_patch', 'Apply patch'],
|
||||
// Search tools
|
||||
['grep', 'Search file contents'],
|
||||
['rg', 'Search file contents'],
|
||||
['glob', 'Find files by pattern'],
|
||||
// Agent tools
|
||||
['task', 'Run sub-agent'],
|
||||
['read_agent', 'Read agent results'],
|
||||
['list_agents', 'List agents'],
|
||||
// Web tools
|
||||
['web_fetch', 'Fetch web content'],
|
||||
['web_search', 'Search the web'],
|
||||
// Other tools
|
||||
['skill', 'Invoke skill'],
|
||||
['show_file', 'Show file'],
|
||||
['fetch_copilot_cli_documentation', 'Fetch CLI docs'],
|
||||
['update_todo', 'Update checklist'],
|
||||
['store_memory', 'Store memory'],
|
||||
['sql', 'Query session data'],
|
||||
['lsp', 'Language server'],
|
||||
]);
|
||||
|
||||
export function resolveToolLabel(toolId: string): string {
|
||||
return builtinToolLabels.get(toolId) ?? toolId;
|
||||
}
|
||||
|
||||
// Fallback approval tool categories for runtime tools. The approval UI groups
|
||||
// built-in tools by permission category (read, write, shell, web, memory) rather
|
||||
// than listing 20+ individual tools, matching other coding agents (Copilot CLI,
|
||||
// Cline, Roo Code, etc.).
|
||||
const fallbackRuntimeApprovalTools: ReadonlyArray<RuntimeToolDefinition> = [
|
||||
{ id: 'glob', label: 'glob', description: 'Match files by glob pattern.' },
|
||||
{ id: 'lsp', label: 'lsp', description: 'Query configured language servers.' },
|
||||
{ id: 'rg', label: 'rg', description: 'Search file contents with ripgrep.' },
|
||||
{ id: 'view', label: 'view', description: 'Read files and list directories.' },
|
||||
{ id: 'web_fetch', label: 'web_fetch', description: 'Fetch content from a URL.' },
|
||||
{ id: 'web_search', label: 'web_search', description: 'Search the web for current information.' },
|
||||
{ id: 'read', label: 'Read files' },
|
||||
{ id: 'write', label: 'Write files' },
|
||||
{ id: 'shell', label: 'Shell commands' },
|
||||
{ id: 'web_fetch', label: 'Web access' },
|
||||
{ id: 'store_memory', label: 'Store memory' },
|
||||
];
|
||||
|
||||
export function createWorkspaceSettings(): WorkspaceSettings {
|
||||
@@ -163,15 +212,16 @@ export function normalizeSessionToolingSelection(
|
||||
|
||||
export function listApprovalToolDefinitions(
|
||||
tooling: WorkspaceToolingSettings,
|
||||
runtimeTools: ReadonlyArray<RuntimeToolDefinition> = fallbackRuntimeApprovalTools,
|
||||
_runtimeTools: ReadonlyArray<RuntimeToolDefinition> = fallbackRuntimeApprovalTools,
|
||||
): ApprovalToolDefinition[] {
|
||||
const toolsById = new Map<string, ApprovalToolDefinition>();
|
||||
const runtimeApprovalTools = runtimeTools.length > 0 ? runtimeTools : fallbackRuntimeApprovalTools;
|
||||
|
||||
for (const tool of runtimeApprovalTools) {
|
||||
// Always use category-level approval for built-in runtime tools regardless of
|
||||
// what the sidecar reports as individual tools.
|
||||
for (const tool of fallbackRuntimeApprovalTools) {
|
||||
registerApprovalTool(toolsById, {
|
||||
id: tool.id,
|
||||
label: tool.label,
|
||||
label: resolveToolLabel(tool.id),
|
||||
description: tool.description,
|
||||
kind: 'builtin',
|
||||
providerId: `builtin:${tool.id}`,
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
export type UserInputStatus = 'pending' | 'answered';
|
||||
|
||||
export interface PendingUserInputRecord {
|
||||
id: string;
|
||||
status: UserInputStatus;
|
||||
agentId?: string;
|
||||
agentName?: string;
|
||||
question: string;
|
||||
choices?: string[];
|
||||
allowFreeform: boolean;
|
||||
requestedAt: string;
|
||||
answer?: string;
|
||||
answeredAt?: string;
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
import { resolvePatternGraph } from '@shared/domain/pattern';
|
||||
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
||||
|
||||
const TIMESTAMP = '2026-03-27T00:00:00.000Z';
|
||||
|
||||
mock.module('electron', () => {
|
||||
const electronMock = {
|
||||
app: {
|
||||
isPackaged: false,
|
||||
getAppPath: () => 'C:\\workspace\\personal\\repositories\\aryx',
|
||||
getPath: () => 'C:\\workspace\\personal\\repositories\\aryx\\tests\\fixtures',
|
||||
},
|
||||
dialog: {
|
||||
showOpenDialog: async () => ({ canceled: true, filePaths: [] }),
|
||||
},
|
||||
shell: {
|
||||
openPath: async () => '',
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...electronMock,
|
||||
default: electronMock,
|
||||
};
|
||||
});
|
||||
|
||||
mock.module('keytar', () => ({
|
||||
default: {
|
||||
getPassword: async () => null,
|
||||
setPassword: async () => undefined,
|
||||
deletePassword: async () => false,
|
||||
},
|
||||
}));
|
||||
|
||||
const { AryxAppService } = await import('@main/AryxAppService');
|
||||
|
||||
function createService(
|
||||
workspace: WorkspaceState,
|
||||
options?: { knownApprovalToolNames?: string[] },
|
||||
): InstanceType<typeof AryxAppService> {
|
||||
const service = new AryxAppService();
|
||||
const internals = service as unknown as Record<string, unknown>;
|
||||
internals.loadWorkspace = async () => workspace;
|
||||
internals.persistAndBroadcast = async (nextWorkspace: WorkspaceState) => nextWorkspace;
|
||||
internals.listKnownApprovalToolNames = async () => options?.knownApprovalToolNames ?? ['read', 'write', 'shell'];
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
function requirePattern(workspace: WorkspaceState, mode: PatternDefinition['mode']): PatternDefinition {
|
||||
const pattern = workspace.patterns.find((candidate) => candidate.mode === mode);
|
||||
if (!pattern) {
|
||||
throw new Error(`Expected workspace seed to include a ${mode} pattern.`);
|
||||
}
|
||||
|
||||
return pattern;
|
||||
}
|
||||
|
||||
describe('AryxAppService savePattern', () => {
|
||||
test('preserves a provided custom graph instead of re-syncing it', async () => {
|
||||
const workspace = createWorkspaceSeed();
|
||||
const pattern = requirePattern(workspace, 'sequential');
|
||||
const baseGraph = resolvePatternGraph(pattern);
|
||||
const customGraph = {
|
||||
...baseGraph,
|
||||
nodes: baseGraph.nodes.map((node, index) => ({
|
||||
...node,
|
||||
position: {
|
||||
x: node.position.x + 37 + index,
|
||||
y: node.position.y + 19 + index * 7,
|
||||
},
|
||||
})),
|
||||
};
|
||||
const service = createService(workspace);
|
||||
|
||||
const result = await service.savePattern({
|
||||
...pattern,
|
||||
graph: customGraph,
|
||||
updatedAt: TIMESTAMP,
|
||||
});
|
||||
|
||||
const savedPattern = requirePattern(result, 'sequential');
|
||||
expect(savedPattern.graph).toEqual(customGraph);
|
||||
});
|
||||
|
||||
test('still seeds a default graph when the pattern graph is missing', async () => {
|
||||
const workspace = createWorkspaceSeed();
|
||||
const pattern = requirePattern(workspace, 'group-chat');
|
||||
const service = createService(workspace);
|
||||
|
||||
const result = await service.savePattern({
|
||||
...pattern,
|
||||
graph: undefined,
|
||||
updatedAt: TIMESTAMP,
|
||||
});
|
||||
|
||||
const savedPattern = requirePattern(result, 'group-chat');
|
||||
expect(savedPattern.graph).toEqual(resolvePatternGraph(pattern));
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,196 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
import { access, mkdir, rm } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
||||
import { createScratchpadProject } from '@shared/domain/project';
|
||||
import type { SessionRecord } from '@shared/domain/session';
|
||||
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
||||
|
||||
const TIMESTAMP = '2026-03-28T00:00:00.000Z';
|
||||
const USER_DATA_PATH = 'C:\\workspace\\personal\\repositories\\aryx\\tests\\fixtures';
|
||||
|
||||
mock.module('electron', () => {
|
||||
const electronMock = {
|
||||
app: {
|
||||
isPackaged: false,
|
||||
getAppPath: () => 'C:\\workspace\\personal\\repositories\\aryx',
|
||||
getPath: () => USER_DATA_PATH,
|
||||
},
|
||||
dialog: {
|
||||
showOpenDialog: async () => ({ canceled: true, filePaths: [] }),
|
||||
},
|
||||
shell: {
|
||||
openPath: async () => '',
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...electronMock,
|
||||
default: electronMock,
|
||||
};
|
||||
});
|
||||
|
||||
mock.module('keytar', () => ({
|
||||
default: {
|
||||
getPassword: async () => null,
|
||||
setPassword: async () => undefined,
|
||||
deletePassword: async () => false,
|
||||
},
|
||||
}));
|
||||
|
||||
const { AryxAppService } = await import('@main/AryxAppService');
|
||||
|
||||
async function pathExists(path: string): Promise<boolean> {
|
||||
try {
|
||||
await access(path);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function requireSinglePattern(workspace: WorkspaceState): PatternDefinition {
|
||||
const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single');
|
||||
if (!pattern) {
|
||||
throw new Error('Expected the workspace seed to include a single-agent pattern.');
|
||||
}
|
||||
|
||||
return pattern;
|
||||
}
|
||||
|
||||
function createScratchpadSession(patternId: string, overrides?: Partial<SessionRecord>): SessionRecord {
|
||||
return {
|
||||
id: 'session-scratchpad',
|
||||
projectId: 'project-scratchpad',
|
||||
patternId,
|
||||
title: 'Scratchpad',
|
||||
createdAt: TIMESTAMP,
|
||||
updatedAt: TIMESTAMP,
|
||||
status: 'idle',
|
||||
messages: [],
|
||||
runs: [],
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createService(
|
||||
workspace: WorkspaceState,
|
||||
options?: {
|
||||
onDeleteSession?: (sessionId: string) => Promise<void>;
|
||||
},
|
||||
): InstanceType<typeof AryxAppService> {
|
||||
const service = new AryxAppService();
|
||||
const internals = service as unknown as Record<string, unknown>;
|
||||
internals.loadWorkspace = async () => workspace;
|
||||
internals.persistAndBroadcast = async (nextWorkspace: WorkspaceState) => nextWorkspace;
|
||||
internals.loadAvailableModelCatalog = async () => [];
|
||||
|
||||
(
|
||||
service as unknown as {
|
||||
sidecar: {
|
||||
deleteSession: (sessionId: string) => Promise<void>;
|
||||
};
|
||||
}
|
||||
).sidecar = {
|
||||
deleteSession: async (sessionId: string) => {
|
||||
await options?.onDeleteSession?.(sessionId);
|
||||
},
|
||||
};
|
||||
|
||||
return service;
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await rm(join(USER_DATA_PATH, 'scratchpad'), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(join(USER_DATA_PATH, 'scratchpad'), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('AryxAppService scratchpad directories', () => {
|
||||
test('creates a dedicated directory for new scratchpad sessions', async () => {
|
||||
const workspace = createWorkspaceSeed();
|
||||
const pattern = requireSinglePattern(workspace);
|
||||
const scratchpadProject = createScratchpadProject(join(USER_DATA_PATH, 'scratchpad'), TIMESTAMP);
|
||||
workspace.projects = [scratchpadProject];
|
||||
workspace.selectedProjectId = scratchpadProject.id;
|
||||
workspace.selectedPatternId = pattern.id;
|
||||
|
||||
const service = createService(workspace);
|
||||
|
||||
const result = await service.createSession(scratchpadProject.id, pattern.id);
|
||||
const session = result.sessions[0];
|
||||
if (!session) {
|
||||
throw new Error('Expected createSession to prepend a scratchpad session.');
|
||||
}
|
||||
|
||||
expect(session.cwd).toBe(join(USER_DATA_PATH, 'scratchpad', session.id));
|
||||
expect(await pathExists(session.cwd!)).toBe(true);
|
||||
});
|
||||
|
||||
test('creates a new directory when duplicating a scratchpad session', async () => {
|
||||
const workspace = createWorkspaceSeed();
|
||||
const pattern = requireSinglePattern(workspace);
|
||||
const scratchpadProject = createScratchpadProject(join(USER_DATA_PATH, 'scratchpad'), TIMESTAMP);
|
||||
const originalDirectory = join(USER_DATA_PATH, 'scratchpad', 'session-original');
|
||||
const originalSession = createScratchpadSession(pattern.id, {
|
||||
id: 'session-original',
|
||||
cwd: originalDirectory,
|
||||
messages: [
|
||||
{
|
||||
id: 'msg-1',
|
||||
role: 'user',
|
||||
authorName: 'You',
|
||||
content: 'Draft a plan.',
|
||||
createdAt: TIMESTAMP,
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
workspace.projects = [scratchpadProject];
|
||||
workspace.sessions = [originalSession];
|
||||
workspace.selectedProjectId = scratchpadProject.id;
|
||||
workspace.selectedPatternId = pattern.id;
|
||||
workspace.selectedSessionId = originalSession.id;
|
||||
|
||||
const service = createService(workspace);
|
||||
|
||||
const result = await service.duplicateSession(originalSession.id);
|
||||
const duplicate = result.sessions[0];
|
||||
if (!duplicate) {
|
||||
throw new Error('Expected duplicateSession to prepend the duplicate.');
|
||||
}
|
||||
|
||||
expect(duplicate.id).not.toBe(originalSession.id);
|
||||
expect(duplicate.cwd).toBe(join(USER_DATA_PATH, 'scratchpad', duplicate.id));
|
||||
expect(duplicate.cwd).not.toBe(originalSession.cwd);
|
||||
expect(await pathExists(duplicate.cwd!)).toBe(true);
|
||||
});
|
||||
|
||||
test('removes the scratchpad directory when deleting a session', async () => {
|
||||
const workspace = createWorkspaceSeed();
|
||||
const pattern = requireSinglePattern(workspace);
|
||||
const scratchpadProject = createScratchpadProject(join(USER_DATA_PATH, 'scratchpad'), TIMESTAMP);
|
||||
const sessionDirectory = join(USER_DATA_PATH, 'scratchpad', 'session-scratchpad');
|
||||
const session = createScratchpadSession(pattern.id, {
|
||||
cwd: sessionDirectory,
|
||||
});
|
||||
|
||||
workspace.projects = [scratchpadProject];
|
||||
workspace.sessions = [session];
|
||||
workspace.selectedProjectId = scratchpadProject.id;
|
||||
workspace.selectedPatternId = pattern.id;
|
||||
workspace.selectedSessionId = session.id;
|
||||
|
||||
const service = createService(workspace);
|
||||
await mkdir(sessionDirectory, { recursive: true });
|
||||
expect(await pathExists(sessionDirectory)).toBe(true);
|
||||
|
||||
const result = await service.deleteSession(session.id);
|
||||
|
||||
expect(result.sessions).toHaveLength(0);
|
||||
expect(await pathExists(sessionDirectory)).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -8,6 +8,7 @@ import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspa
|
||||
|
||||
const TIMESTAMP = '2026-03-25T00:00:00.000Z';
|
||||
const SCRATCHPAD_PATH = 'C:\\workspace\\personal\\repositories\\aryx\\scratchpad';
|
||||
const SCRATCHPAD_SESSION_PATH = `${SCRATCHPAD_PATH}\\session-scratchpad`;
|
||||
|
||||
mock.module('electron', () => {
|
||||
const electronMock = {
|
||||
@@ -60,6 +61,7 @@ function createWorkspaceFixture(): {
|
||||
createdAt: TIMESTAMP,
|
||||
updatedAt: TIMESTAMP,
|
||||
status: 'idle',
|
||||
cwd: SCRATCHPAD_SESSION_PATH,
|
||||
messages: [],
|
||||
runs: [],
|
||||
};
|
||||
@@ -186,7 +188,7 @@ describe('AryxAppService scratchpad tooling support', () => {
|
||||
|
||||
expect(command).toMatchObject({
|
||||
sessionId: session.id,
|
||||
projectPath: SCRATCHPAD_PATH,
|
||||
projectPath: SCRATCHPAD_SESSION_PATH,
|
||||
workspaceKind: 'scratchpad',
|
||||
tooling: {
|
||||
mcpServers: [
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { buildWellKnownUrl, buildWellKnownUrlFallback } from '@main/services/mcpTokenStore';
|
||||
|
||||
describe('buildWellKnownUrl', () => {
|
||||
test('inserts well-known segment after origin for URL with path', () => {
|
||||
expect(buildWellKnownUrl('https://api.githubcopilot.com/mcp/', 'oauth-protected-resource'))
|
||||
.toBe('https://api.githubcopilot.com/.well-known/oauth-protected-resource/mcp/');
|
||||
});
|
||||
|
||||
test('inserts well-known segment for URL with multi-level path', () => {
|
||||
expect(buildWellKnownUrl('https://example.com/v1/mcp/api', 'oauth-protected-resource'))
|
||||
.toBe('https://example.com/.well-known/oauth-protected-resource/v1/mcp/api');
|
||||
});
|
||||
|
||||
test('handles origin-only URL without path', () => {
|
||||
expect(buildWellKnownUrl('https://auth.example.com', 'oauth-authorization-server'))
|
||||
.toBe('https://auth.example.com/.well-known/oauth-authorization-server');
|
||||
});
|
||||
|
||||
test('handles origin-only URL with trailing slash', () => {
|
||||
expect(buildWellKnownUrl('https://auth.example.com/', 'oauth-authorization-server'))
|
||||
.toBe('https://auth.example.com/.well-known/oauth-authorization-server');
|
||||
});
|
||||
|
||||
test('handles URL with port', () => {
|
||||
expect(buildWellKnownUrl('https://mcp.example.com:8443/v1/', 'oauth-protected-resource'))
|
||||
.toBe('https://mcp.example.com:8443/.well-known/oauth-protected-resource/v1/');
|
||||
});
|
||||
|
||||
test('preserves path without trailing slash', () => {
|
||||
expect(buildWellKnownUrl('https://example.com/mcp', 'oauth-protected-resource'))
|
||||
.toBe('https://example.com/.well-known/oauth-protected-resource/mcp');
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildWellKnownUrlFallback', () => {
|
||||
test('appends well-known segment to the full URL path', () => {
|
||||
expect(buildWellKnownUrlFallback('https://icm-mcp-prod.azure-api.net/v1/', 'oauth-protected-resource'))
|
||||
.toBe('https://icm-mcp-prod.azure-api.net/v1/.well-known/oauth-protected-resource');
|
||||
});
|
||||
|
||||
test('handles URL without trailing slash', () => {
|
||||
expect(buildWellKnownUrlFallback('https://example.com/mcp', 'oauth-protected-resource'))
|
||||
.toBe('https://example.com/mcp/.well-known/oauth-protected-resource');
|
||||
});
|
||||
|
||||
test('handles origin-only URL', () => {
|
||||
expect(buildWellKnownUrlFallback('https://auth.example.com/', 'oauth-authorization-server'))
|
||||
.toBe('https://auth.example.com/.well-known/oauth-authorization-server');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,78 @@
|
||||
import { describe, expect, mock, test } from 'bun:test';
|
||||
|
||||
mock.module('electron', () => {
|
||||
const electronMock = {
|
||||
shell: {
|
||||
openExternal: async () => undefined,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...electronMock,
|
||||
default: electronMock,
|
||||
};
|
||||
});
|
||||
|
||||
const {
|
||||
buildScopes,
|
||||
parseTokenResponseBody,
|
||||
resolveKnownProvider,
|
||||
} = await import('@main/services/mcpOAuthService');
|
||||
|
||||
describe('resolveKnownProvider', () => {
|
||||
test('matches GitHub OAuth provider', () => {
|
||||
const provider = resolveKnownProvider('https://github.com/login/oauth');
|
||||
|
||||
expect(provider?.id).toBe('github');
|
||||
expect(provider?.clientId).toBe('01ab8ac9400c4e429b23');
|
||||
expect(provider?.authorizationEndpoint).toBe('https://github.com/login/oauth/authorize');
|
||||
expect(provider?.tokenEndpoint).toBe('https://github.com/login/oauth/access_token');
|
||||
expect(provider?.authorizationParams).toEqual({ prompt: 'select_account' });
|
||||
});
|
||||
|
||||
test('matches Entra OAuth provider', () => {
|
||||
const provider = resolveKnownProvider('https://login.microsoftonline.com/72f988bf-86f1-41af-91ab-2d7cd011db47/v2.0');
|
||||
|
||||
expect(provider?.id).toBe('entra');
|
||||
expect(provider?.clientId).toBe('aebc6443-996d-45c2-90f0-388ff96faa56');
|
||||
expect(provider?.authorizationEndpoint).toBeUndefined();
|
||||
expect(provider?.tokenEndpoint).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns undefined for unknown provider', () => {
|
||||
expect(resolveKnownProvider('https://auth.example.com')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('buildScopes', () => {
|
||||
test('uses provider-specific GitHub scopes without offline access', () => {
|
||||
const provider = resolveKnownProvider('https://github.com/login/oauth');
|
||||
|
||||
expect(buildScopes(provider, ['ignored'], ['also-ignored']))
|
||||
.toBe('codespace gist notifications project read:org read:packages read:project read:user repo user:email workflow write:packages');
|
||||
});
|
||||
|
||||
test('uses protected resource scopes for Entra and appends offline access', () => {
|
||||
const provider = resolveKnownProvider('https://login.microsoftonline.com/common/v2.0');
|
||||
|
||||
expect(buildScopes(provider, ['api://icmmcpapi-prod/mcp.tools'], ['openid']))
|
||||
.toBe('api://icmmcpapi-prod/mcp.tools offline_access');
|
||||
});
|
||||
|
||||
test('falls back to auth server scopes for unknown providers', () => {
|
||||
expect(buildScopes(undefined, undefined, ['openid', 'profile']))
|
||||
.toBe('openid profile offline_access');
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseTokenResponseBody', () => {
|
||||
test('parses JSON token responses', () => {
|
||||
expect(parseTokenResponseBody('{"access_token":"abc","token_type":"Bearer"}', 'application/json'))
|
||||
.toEqual({ access_token: 'abc', token_type: 'Bearer' });
|
||||
});
|
||||
|
||||
test('parses form-encoded token responses', () => {
|
||||
expect(parseTokenResponseBody('access_token=abc&scope=repo%20user%3Aemail&token_type=bearer', 'application/x-www-form-urlencoded'))
|
||||
.toEqual({ access_token: 'abc', scope: 'repo user:email', token_type: 'bearer' });
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,86 @@
|
||||
import { describe, expect, test, beforeEach } from 'bun:test';
|
||||
|
||||
import {
|
||||
getStoredToken,
|
||||
storeToken,
|
||||
clearToken,
|
||||
clearAllTokens,
|
||||
type McpOAuthToken,
|
||||
} from '@main/services/mcpTokenStore';
|
||||
|
||||
describe('MCP OAuth token store', () => {
|
||||
beforeEach(() => {
|
||||
clearAllTokens();
|
||||
});
|
||||
|
||||
test('stores and retrieves tokens by server URL', () => {
|
||||
const token: McpOAuthToken = {
|
||||
accessToken: 'abc123',
|
||||
tokenType: 'Bearer',
|
||||
};
|
||||
|
||||
storeToken('https://mcp.example.com/api', token);
|
||||
|
||||
expect(getStoredToken('https://mcp.example.com/api')).toEqual(token);
|
||||
});
|
||||
|
||||
test('normalizes trailing slashes and case in server URLs', () => {
|
||||
const token: McpOAuthToken = {
|
||||
accessToken: 'xyz',
|
||||
tokenType: 'Bearer',
|
||||
};
|
||||
|
||||
storeToken('https://MCP.Example.com/api/', token);
|
||||
|
||||
expect(getStoredToken('https://mcp.example.com/api')).toEqual(token);
|
||||
expect(getStoredToken('https://mcp.example.com/api/')).toEqual(token);
|
||||
});
|
||||
|
||||
test('returns undefined for unknown server URLs', () => {
|
||||
expect(getStoredToken('https://unknown.example.com')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('clears a token for a specific server', () => {
|
||||
storeToken('https://a.example.com', { accessToken: 'a', tokenType: 'Bearer' });
|
||||
storeToken('https://b.example.com', { accessToken: 'b', tokenType: 'Bearer' });
|
||||
|
||||
clearToken('https://a.example.com');
|
||||
|
||||
expect(getStoredToken('https://a.example.com')).toBeUndefined();
|
||||
expect(getStoredToken('https://b.example.com')).toBeDefined();
|
||||
});
|
||||
|
||||
test('clears all stored tokens', () => {
|
||||
storeToken('https://a.example.com', { accessToken: 'a', tokenType: 'Bearer' });
|
||||
storeToken('https://b.example.com', { accessToken: 'b', tokenType: 'Bearer' });
|
||||
|
||||
clearAllTokens();
|
||||
|
||||
expect(getStoredToken('https://a.example.com')).toBeUndefined();
|
||||
expect(getStoredToken('https://b.example.com')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns undefined for expired tokens', () => {
|
||||
const token: McpOAuthToken = {
|
||||
accessToken: 'expired',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() - 1_000,
|
||||
};
|
||||
|
||||
storeToken('https://mcp.example.com', token);
|
||||
|
||||
expect(getStoredToken('https://mcp.example.com')).toBeUndefined();
|
||||
});
|
||||
|
||||
test('returns valid tokens that have not expired', () => {
|
||||
const token: McpOAuthToken = {
|
||||
accessToken: 'valid',
|
||||
tokenType: 'Bearer',
|
||||
expiresAt: Date.now() + 60_000,
|
||||
};
|
||||
|
||||
storeToken('https://mcp.example.com', token);
|
||||
|
||||
expect(getStoredToken('https://mcp.example.com')).toEqual(token);
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,10 @@ describe('run turn pending helpers', () => {
|
||||
onDelta: () => undefined,
|
||||
onActivity: () => undefined,
|
||||
onApproval: () => undefined,
|
||||
onUserInput: () => undefined,
|
||||
onExitPlanMode: () => undefined,
|
||||
onMcpOAuthRequired: () => undefined,
|
||||
onTurnScopedEvent: () => undefined,
|
||||
errored: false,
|
||||
};
|
||||
|
||||
@@ -38,6 +42,10 @@ describe('run turn pending helpers', () => {
|
||||
onDelta: () => undefined,
|
||||
onActivity: () => undefined,
|
||||
onApproval: () => undefined,
|
||||
onUserInput: () => undefined,
|
||||
onExitPlanMode: () => undefined,
|
||||
onMcpOAuthRequired: () => undefined,
|
||||
onTurnScopedEvent: () => undefined,
|
||||
errored: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
validateSessionToolingSelectionIds,
|
||||
} from '@main/sessionToolingConfig';
|
||||
import type { SessionToolingSelection, WorkspaceToolingSettings } from '@shared/domain/tooling';
|
||||
import type { RunTurnRemoteMcpServerConfig } from '@shared/contracts/sidecar';
|
||||
|
||||
const TIMESTAMP = '2026-03-25T00:00:00.000Z';
|
||||
|
||||
@@ -117,4 +118,59 @@ describe('session tooling config helpers', () => {
|
||||
}),
|
||||
).toBeUndefined();
|
||||
});
|
||||
|
||||
test('injects OAuth token as Authorization header for remote MCP servers', () => {
|
||||
const tokenLookup = (url: string) =>
|
||||
url === 'https://example.com/mcp' ? 'oauth-access-token' : undefined;
|
||||
|
||||
const config = buildRunTurnToolingConfig(
|
||||
TOOLING,
|
||||
{ enabledMcpServerIds: ['mcp-remote'], enabledLspProfileIds: [] },
|
||||
tokenLookup,
|
||||
);
|
||||
|
||||
expect(config?.mcpServers[0]).toMatchObject({
|
||||
id: 'mcp-remote',
|
||||
headers: { Authorization: 'Bearer oauth-access-token' },
|
||||
});
|
||||
});
|
||||
|
||||
test('preserves existing headers when injecting OAuth token', () => {
|
||||
const toolingWithHeaders: WorkspaceToolingSettings = {
|
||||
...TOOLING,
|
||||
mcpServers: [
|
||||
{
|
||||
id: 'mcp-custom',
|
||||
name: 'Custom MCP',
|
||||
transport: 'http',
|
||||
url: 'https://custom.example.com/mcp',
|
||||
headers: { 'X-Custom': 'value' },
|
||||
tools: [],
|
||||
createdAt: TIMESTAMP,
|
||||
updatedAt: TIMESTAMP,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
const config = buildRunTurnToolingConfig(
|
||||
toolingWithHeaders,
|
||||
{ enabledMcpServerIds: ['mcp-custom'], enabledLspProfileIds: [] },
|
||||
() => 'my-token',
|
||||
);
|
||||
|
||||
expect((config?.mcpServers[0] as RunTurnRemoteMcpServerConfig).headers).toEqual({
|
||||
'X-Custom': 'value',
|
||||
Authorization: 'Bearer my-token',
|
||||
});
|
||||
});
|
||||
|
||||
test('does not inject Authorization header when no token is available', () => {
|
||||
const config = buildRunTurnToolingConfig(
|
||||
TOOLING,
|
||||
{ enabledMcpServerIds: ['mcp-remote'], enabledLspProfileIds: [] },
|
||||
() => undefined,
|
||||
);
|
||||
|
||||
expect((config?.mcpServers[0] as RunTurnRemoteMcpServerConfig).headers).toEqual({ Authorization: 'Bearer token' });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,90 @@
|
||||
import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
||||
import { access, mkdir, readFile, rm, writeFile } from 'node:fs/promises';
|
||||
import { join } from 'node:path';
|
||||
|
||||
import { createScratchpadProject } from '@shared/domain/project';
|
||||
import type { SessionRecord } from '@shared/domain/session';
|
||||
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
||||
|
||||
const TIMESTAMP = '2026-03-28T00:00:00.000Z';
|
||||
const USER_DATA_PATH = 'C:\\workspace\\personal\\repositories\\aryx\\tests\\fixtures';
|
||||
|
||||
mock.module('electron', () => {
|
||||
const electronMock = {
|
||||
app: {
|
||||
getPath: () => USER_DATA_PATH,
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
...electronMock,
|
||||
default: electronMock,
|
||||
};
|
||||
});
|
||||
|
||||
const { WorkspaceRepository } = await import('@main/persistence/workspaceRepository');
|
||||
|
||||
function createStoredWorkspace(): WorkspaceState {
|
||||
const workspace = createWorkspaceSeed();
|
||||
const scratchpadProject = createScratchpadProject('C:\\legacy\\scratchpad', TIMESTAMP);
|
||||
const patternId = workspace.patterns[0]?.id;
|
||||
if (!patternId) {
|
||||
throw new Error('Expected workspace seed to include at least one pattern.');
|
||||
}
|
||||
|
||||
const session: SessionRecord = {
|
||||
id: 'session-scratchpad',
|
||||
projectId: scratchpadProject.id,
|
||||
patternId,
|
||||
title: 'Scratchpad',
|
||||
createdAt: TIMESTAMP,
|
||||
updatedAt: TIMESTAMP,
|
||||
status: 'idle',
|
||||
messages: [],
|
||||
runs: [],
|
||||
};
|
||||
|
||||
return {
|
||||
...workspace,
|
||||
projects: [scratchpadProject],
|
||||
sessions: [session],
|
||||
selectedProjectId: scratchpadProject.id,
|
||||
selectedPatternId: patternId,
|
||||
selectedSessionId: session.id,
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(async () => {
|
||||
await rm(join(USER_DATA_PATH, 'workspace.json'), { force: true });
|
||||
await rm(join(USER_DATA_PATH, 'scratchpad'), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await rm(join(USER_DATA_PATH, 'workspace.json'), { force: true });
|
||||
await rm(join(USER_DATA_PATH, 'scratchpad'), { recursive: true, force: true });
|
||||
});
|
||||
|
||||
describe('WorkspaceRepository scratchpad migration', () => {
|
||||
test('assigns per-session scratchpad directories while loading existing workspace state', async () => {
|
||||
const workspaceFilePath = join(USER_DATA_PATH, 'workspace.json');
|
||||
await mkdir(USER_DATA_PATH, { recursive: true });
|
||||
await writeFile(workspaceFilePath, JSON.stringify(createStoredWorkspace(), null, 2), 'utf8');
|
||||
|
||||
const repository = new WorkspaceRepository();
|
||||
const loaded = await repository.load();
|
||||
const loadedSession = loaded.sessions[0];
|
||||
if (!loadedSession) {
|
||||
throw new Error('Expected load() to return the migrated scratchpad session.');
|
||||
}
|
||||
|
||||
const expectedScratchpadPath = join(USER_DATA_PATH, 'scratchpad');
|
||||
const expectedSessionPath = join(expectedScratchpadPath, loadedSession.id);
|
||||
|
||||
expect(loaded.projects[0]?.path).toBe(expectedScratchpadPath);
|
||||
expect(loadedSession.cwd).toBe(expectedSessionPath);
|
||||
await access(expectedSessionPath);
|
||||
|
||||
const persisted = JSON.parse(await readFile(workspaceFilePath, 'utf8')) as WorkspaceState;
|
||||
expect(persisted.sessions[0]?.cwd).toBe(expectedSessionPath);
|
||||
});
|
||||
});
|
||||
@@ -1,8 +1,7 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { createBuiltinPatterns, resolvePatternGraph, type PatternDefinition } from '@shared/domain/pattern';
|
||||
import { addAgentToGraph, createBuiltinPatterns, resolvePatternGraph, type PatternDefinition } from '@shared/domain/pattern';
|
||||
import {
|
||||
addAgentNodeToGraph,
|
||||
addEdge,
|
||||
autoLayoutGraph,
|
||||
canMoveSequential,
|
||||
@@ -208,20 +207,20 @@ describe('pattern graph mutation helpers', () => {
|
||||
expect(updated.edges.find((e) => e.id === firstEdge.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('addAgentNodeToGraph places a disconnected agent node on the canvas', () => {
|
||||
test('addAgentToGraph inserts a wired agent node for sequential mode', () => {
|
||||
const pattern = findPattern('sequential');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const newAgent = { id: 'new-1', name: 'New Agent', description: '', instructions: '', model: 'gpt-5.4' };
|
||||
|
||||
const updated = addAgentNodeToGraph(graph, newAgent);
|
||||
const updated = addAgentToGraph(graph, pattern.mode, newAgent);
|
||||
expect(updated.nodes.length).toBe(graph.nodes.length + 1);
|
||||
|
||||
const newNode = updated.nodes.find((n) => n.agentId === 'new-1');
|
||||
expect(newNode).toBeDefined();
|
||||
expect(newNode!.kind).toBe('agent');
|
||||
|
||||
// No new edges added — node is disconnected
|
||||
expect(updated.edges.length).toBe(graph.edges.length);
|
||||
// New agent is wired into the chain, so edge count stays at agents+1
|
||||
expect(updated.edges.length).toBe(graph.edges.length + 1);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
normalizePendingApproval,
|
||||
normalizePendingApprovalState,
|
||||
normalizeSessionApprovalSettings,
|
||||
pruneSessionApprovalSettings,
|
||||
resolveApprovalToolKey,
|
||||
dequeuePendingApprovalState,
|
||||
enqueuePendingApprovalState,
|
||||
listPendingApprovals,
|
||||
@@ -209,4 +211,54 @@ describe('approval helpers', () => {
|
||||
pendingApprovalQueue: undefined,
|
||||
});
|
||||
});
|
||||
|
||||
test('prune preserves permission-kind approval entries when they are known tools', () => {
|
||||
const settings = normalizeSessionApprovalSettings({
|
||||
autoApprovedToolNames: ['read', 'write', 'shell', 'git.status', 'unknown_tool'],
|
||||
});
|
||||
const knownToolNames = ['read', 'write', 'shell', 'git.status', 'bash', 'view'];
|
||||
|
||||
const pruned = pruneSessionApprovalSettings(settings, knownToolNames);
|
||||
expect(pruned?.autoApprovedToolNames).toEqual(['read', 'write', 'shell', 'git.status']);
|
||||
});
|
||||
|
||||
test('session approval with permission-kind entries overrides pattern defaults', () => {
|
||||
const effective = resolveEffectiveApprovalPolicy(
|
||||
{
|
||||
rules: [{ kind: 'tool-call' }],
|
||||
autoApprovedToolNames: ['git.status'],
|
||||
},
|
||||
normalizeSessionApprovalSettings({
|
||||
autoApprovedToolNames: ['read', 'shell'],
|
||||
}),
|
||||
);
|
||||
|
||||
expect(effective).toEqual({
|
||||
rules: [{ kind: 'tool-call' }],
|
||||
autoApprovedToolNames: ['read', 'shell'],
|
||||
});
|
||||
expect(approvalPolicyRequiresToolCallApproval(effective, 'agent-1', 'read')).toBe(false);
|
||||
expect(approvalPolicyRequiresToolCallApproval(effective, 'agent-1', 'shell')).toBe(false);
|
||||
expect(approvalPolicyRequiresToolCallApproval(effective, 'agent-1', 'write')).toBe(true);
|
||||
});
|
||||
|
||||
test('resolveApprovalToolKey returns permission category for runtime tools', () => {
|
||||
expect(resolveApprovalToolKey('view', 'read')).toBe('read');
|
||||
expect(resolveApprovalToolKey('edit', 'write')).toBe('write');
|
||||
expect(resolveApprovalToolKey('bash', 'shell')).toBe('shell');
|
||||
expect(resolveApprovalToolKey('web_fetch', 'url')).toBe('web_fetch');
|
||||
expect(resolveApprovalToolKey('store_memory', 'memory')).toBe('store_memory');
|
||||
});
|
||||
|
||||
test('resolveApprovalToolKey returns tool name for non-runtime tools', () => {
|
||||
expect(resolveApprovalToolKey('git.status', 'mcp')).toBe('git.status');
|
||||
expect(resolveApprovalToolKey('lsp_ts_hover', 'custom-tool')).toBe('lsp_ts_hover');
|
||||
expect(resolveApprovalToolKey('web_fetch', 'hook')).toBe('web_fetch');
|
||||
expect(resolveApprovalToolKey('some_tool', undefined)).toBe('some_tool');
|
||||
});
|
||||
|
||||
test('resolveApprovalToolKey returns undefined when both are absent', () => {
|
||||
expect(resolveApprovalToolKey(undefined, undefined)).toBeUndefined();
|
||||
expect(resolveApprovalToolKey(undefined, 'mcp')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,14 +1,28 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
addAgentToGraph,
|
||||
createBuiltinPatterns,
|
||||
removeAgentFromGraph,
|
||||
resolvePatternGraph,
|
||||
syncPatternGraph,
|
||||
type PatternAgentDefinition,
|
||||
validatePatternDefinition,
|
||||
} from '@shared/domain/pattern';
|
||||
|
||||
const BUILTIN_TIMESTAMP = '2026-03-22T00:00:00.000Z';
|
||||
|
||||
function createAgent(id: string, name = `Agent ${id}`): PatternAgentDefinition {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description: `${name} description`,
|
||||
instructions: `${name} instructions`,
|
||||
model: 'gpt-5.4',
|
||||
reasoningEffort: 'medium',
|
||||
};
|
||||
}
|
||||
|
||||
describe('pattern validation', () => {
|
||||
test('builtin patterns are valid except explicitly unavailable modes', () => {
|
||||
const patterns = createBuiltinPatterns(BUILTIN_TIMESTAMP);
|
||||
@@ -242,6 +256,261 @@ describe('pattern validation', () => {
|
||||
);
|
||||
});
|
||||
|
||||
test('addAgentToGraph appends a new sequential agent before user output', () => {
|
||||
const sequential = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
|
||||
(pattern) => pattern.mode === 'sequential',
|
||||
);
|
||||
|
||||
expect(sequential).toBeDefined();
|
||||
|
||||
const updatedGraph = addAgentToGraph(
|
||||
resolvePatternGraph(sequential!),
|
||||
sequential!.mode,
|
||||
createAgent('agent-sequential-final', 'Final Reviewer'),
|
||||
);
|
||||
|
||||
expect(updatedGraph.nodes.filter((node) => node.kind === 'agent')).toHaveLength(4);
|
||||
expect(updatedGraph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'agent-node-agent-sequential-reviewer',
|
||||
target: 'agent-node-agent-sequential-final',
|
||||
}),
|
||||
);
|
||||
expect(updatedGraph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'agent-node-agent-sequential-final',
|
||||
target: 'system-user-output',
|
||||
}),
|
||||
);
|
||||
expect(updatedGraph.edges).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'agent-node-agent-sequential-reviewer',
|
||||
target: 'system-user-output',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('addAgentToGraph wires concurrent agents between the distributor and collector', () => {
|
||||
const concurrent = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
|
||||
(pattern) => pattern.mode === 'concurrent',
|
||||
);
|
||||
|
||||
expect(concurrent).toBeDefined();
|
||||
|
||||
const updatedGraph = addAgentToGraph(
|
||||
resolvePatternGraph(concurrent!),
|
||||
concurrent!.mode,
|
||||
createAgent('agent-concurrent-final', 'Final Implementer'),
|
||||
);
|
||||
|
||||
expect(updatedGraph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'system-distributor',
|
||||
target: 'agent-node-agent-concurrent-final',
|
||||
}),
|
||||
);
|
||||
expect(updatedGraph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'agent-node-agent-concurrent-final',
|
||||
target: 'system-collector',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('addAgentToGraph wires handoff specialists to the entry agent and output', () => {
|
||||
const handoff = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
|
||||
(pattern) => pattern.mode === 'handoff',
|
||||
);
|
||||
|
||||
expect(handoff).toBeDefined();
|
||||
|
||||
const updatedGraph = addAgentToGraph(
|
||||
resolvePatternGraph(handoff!),
|
||||
handoff!.mode,
|
||||
createAgent('agent-handoff-docs', 'Docs Specialist'),
|
||||
);
|
||||
|
||||
expect(updatedGraph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'agent-node-agent-handoff-triage',
|
||||
target: 'agent-node-agent-handoff-docs',
|
||||
}),
|
||||
);
|
||||
expect(updatedGraph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'agent-node-agent-handoff-docs',
|
||||
target: 'agent-node-agent-handoff-triage',
|
||||
}),
|
||||
);
|
||||
expect(updatedGraph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'agent-node-agent-handoff-docs',
|
||||
target: 'system-user-output',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('addAgentToGraph wires group-chat agents to the orchestrator', () => {
|
||||
const groupChat = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
|
||||
(pattern) => pattern.mode === 'group-chat',
|
||||
);
|
||||
|
||||
expect(groupChat).toBeDefined();
|
||||
|
||||
const updatedGraph = addAgentToGraph(
|
||||
resolvePatternGraph(groupChat!),
|
||||
groupChat!.mode,
|
||||
createAgent('agent-group-editor', 'Editor'),
|
||||
);
|
||||
|
||||
expect(updatedGraph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'system-orchestrator',
|
||||
target: 'agent-node-agent-group-editor',
|
||||
}),
|
||||
);
|
||||
expect(updatedGraph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'agent-node-agent-group-editor',
|
||||
target: 'system-orchestrator',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('addAgentToGraph rejects additions in single-agent mode', () => {
|
||||
const single = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
|
||||
(pattern) => pattern.mode === 'single',
|
||||
);
|
||||
|
||||
expect(single).toBeDefined();
|
||||
expect(() =>
|
||||
addAgentToGraph(resolvePatternGraph(single!), single!.mode, createAgent('agent-extra', 'Extra Agent')))
|
||||
.toThrow('Single-agent chat requires exactly one agent.');
|
||||
});
|
||||
|
||||
test('removeAgentFromGraph stitches linear gaps and re-numbers remaining agent orders', () => {
|
||||
const sequential = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
|
||||
(pattern) => pattern.mode === 'sequential',
|
||||
);
|
||||
|
||||
expect(sequential).toBeDefined();
|
||||
|
||||
const updatedGraph = removeAgentFromGraph(
|
||||
resolvePatternGraph(sequential!),
|
||||
sequential!.mode,
|
||||
'agent-sequential-builder',
|
||||
);
|
||||
|
||||
expect(updatedGraph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'agent-node-agent-sequential-analyst',
|
||||
target: 'agent-node-agent-sequential-reviewer',
|
||||
}),
|
||||
);
|
||||
expect(updatedGraph.edges).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'agent-node-agent-sequential-analyst',
|
||||
target: 'agent-node-agent-sequential-builder',
|
||||
}),
|
||||
);
|
||||
expect(updatedGraph.edges).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'agent-node-agent-sequential-builder',
|
||||
target: 'agent-node-agent-sequential-reviewer',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
updatedGraph.nodes
|
||||
.filter((node) => node.kind === 'agent')
|
||||
.map((node) => node.order),
|
||||
).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
test('removeAgentFromGraph cleans up concurrent fan-out and fan-in edges', () => {
|
||||
const concurrent = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
|
||||
(pattern) => pattern.mode === 'concurrent',
|
||||
);
|
||||
|
||||
expect(concurrent).toBeDefined();
|
||||
|
||||
const updatedGraph = removeAgentFromGraph(
|
||||
resolvePatternGraph(concurrent!),
|
||||
concurrent!.mode,
|
||||
'agent-concurrent-product',
|
||||
);
|
||||
|
||||
expect(updatedGraph.nodes.some((node) => node.agentId === 'agent-concurrent-product')).toBe(false);
|
||||
expect(updatedGraph.edges.some((edge) => edge.target === 'agent-node-agent-concurrent-product')).toBe(false);
|
||||
expect(updatedGraph.edges.some((edge) => edge.source === 'agent-node-agent-concurrent-product')).toBe(false);
|
||||
});
|
||||
|
||||
test('removeAgentFromGraph rewires a removed handoff entry agent to the next specialist', () => {
|
||||
const handoff = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
|
||||
(pattern) => pattern.mode === 'handoff',
|
||||
);
|
||||
|
||||
expect(handoff).toBeDefined();
|
||||
|
||||
const updatedGraph = removeAgentFromGraph(
|
||||
resolvePatternGraph(handoff!),
|
||||
handoff!.mode,
|
||||
'agent-handoff-triage',
|
||||
);
|
||||
|
||||
expect(updatedGraph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'system-user-input',
|
||||
target: 'agent-node-agent-handoff-ux',
|
||||
}),
|
||||
);
|
||||
expect(updatedGraph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'agent-node-agent-handoff-ux',
|
||||
target: 'system-user-output',
|
||||
}),
|
||||
);
|
||||
expect(updatedGraph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'agent-node-agent-handoff-ux',
|
||||
target: 'agent-node-agent-handoff-runtime',
|
||||
}),
|
||||
);
|
||||
expect(updatedGraph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'agent-node-agent-handoff-runtime',
|
||||
target: 'agent-node-agent-handoff-ux',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('removeAgentFromGraph preserves orchestrator routes for the remaining group-chat agents', () => {
|
||||
const groupChat = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
|
||||
(pattern) => pattern.mode === 'group-chat',
|
||||
);
|
||||
|
||||
expect(groupChat).toBeDefined();
|
||||
|
||||
const updatedGraph = removeAgentFromGraph(
|
||||
resolvePatternGraph(groupChat!),
|
||||
groupChat!.mode,
|
||||
'agent-group-reviewer',
|
||||
);
|
||||
|
||||
expect(updatedGraph.nodes.some((node) => node.agentId === 'agent-group-reviewer')).toBe(false);
|
||||
expect(updatedGraph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'system-orchestrator',
|
||||
target: 'agent-node-agent-group-writer',
|
||||
}),
|
||||
);
|
||||
expect(updatedGraph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'agent-node-agent-group-writer',
|
||||
target: 'system-orchestrator',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('graph validation rejects branched sequential topology', () => {
|
||||
const sequential = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
|
||||
(pattern) => pattern.mode === 'sequential',
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
listApprovalToolDefinitions,
|
||||
normalizeWorkspaceSettings,
|
||||
resolveProjectToolingSettings,
|
||||
resolveToolLabel,
|
||||
resolveWorkspaceToolingSettings,
|
||||
validateLspProfileDefinition,
|
||||
validateMcpServerDefinition,
|
||||
@@ -206,8 +207,7 @@ describe('tooling settings helpers', () => {
|
||||
|
||||
expect(tools).toContainEqual({
|
||||
id: 'web_fetch',
|
||||
label: 'web_fetch',
|
||||
description: 'Fetch content from a URL.',
|
||||
label: 'Fetch web content',
|
||||
kind: 'builtin',
|
||||
providerIds: ['builtin:web_fetch'],
|
||||
providerNames: ['Built-in'],
|
||||
@@ -228,7 +228,7 @@ describe('tooling settings helpers', () => {
|
||||
});
|
||||
});
|
||||
|
||||
test('prefers dynamically reported runtime tools over the fallback builtin catalog', () => {
|
||||
test('always uses category-level builtins regardless of dynamically reported runtime tools', () => {
|
||||
const tools = listApprovalToolDefinitions(
|
||||
{ mcpServers: [], lspProfiles: [] },
|
||||
[
|
||||
@@ -240,15 +240,61 @@ describe('tooling settings helpers', () => {
|
||||
],
|
||||
);
|
||||
|
||||
expect(tools).toContainEqual({
|
||||
id: 'fetch',
|
||||
label: 'fetch',
|
||||
description: 'Dynamic runtime tool from Copilot CLI.',
|
||||
kind: 'builtin',
|
||||
providerIds: ['builtin:fetch'],
|
||||
providerNames: ['Built-in'],
|
||||
});
|
||||
expect(tools.some((tool) => tool.id === 'web_fetch')).toBe(false);
|
||||
// Category-level builtins must always be present
|
||||
expect(tools.some((tool) => tool.id === 'read')).toBe(true);
|
||||
expect(tools.some((tool) => tool.id === 'write')).toBe(true);
|
||||
expect(tools.some((tool) => tool.id === 'shell')).toBe(true);
|
||||
expect(tools.some((tool) => tool.id === 'web_fetch')).toBe(true);
|
||||
expect(tools.some((tool) => tool.id === 'store_memory')).toBe(true);
|
||||
|
||||
// Dynamic individual tools do NOT appear in the approval list
|
||||
expect(tools.some((tool) => tool.id === 'fetch')).toBe(false);
|
||||
});
|
||||
|
||||
test('resolves human-readable labels from the tool metadata registry', () => {
|
||||
expect(resolveToolLabel('bash')).toBe('Execute shell commands');
|
||||
expect(resolveToolLabel('read_bash')).toBe('Read shell output');
|
||||
expect(resolveToolLabel('web_fetch')).toBe('Fetch web content');
|
||||
expect(resolveToolLabel('fetch_copilot_cli_documentation')).toBe('Fetch CLI docs');
|
||||
expect(resolveToolLabel('glob')).toBe('Find files by pattern');
|
||||
expect(resolveToolLabel('lsp')).toBe('Language server');
|
||||
expect(resolveToolLabel('shell')).toBe('Shell commands');
|
||||
expect(resolveToolLabel('read')).toBe('Read files');
|
||||
expect(resolveToolLabel('write')).toBe('Write files');
|
||||
});
|
||||
|
||||
test('passes through unknown tool IDs as labels', () => {
|
||||
expect(resolveToolLabel('my_custom_tool')).toBe('my_custom_tool');
|
||||
expect(resolveToolLabel('')).toBe('');
|
||||
});
|
||||
|
||||
test('fallback catalog uses 5 permission-category entries for built-in tools', () => {
|
||||
const tools = listApprovalToolDefinitions({ mcpServers: [], lspProfiles: [] });
|
||||
const builtinTools = tools.filter((t) => t.kind === 'builtin');
|
||||
|
||||
expect(builtinTools.length).toBe(5);
|
||||
|
||||
// Permission-kind approval categories
|
||||
expect(builtinTools.some((t) => t.id === 'read')).toBe(true);
|
||||
expect(builtinTools.some((t) => t.id === 'write')).toBe(true);
|
||||
expect(builtinTools.some((t) => t.id === 'shell')).toBe(true);
|
||||
expect(builtinTools.some((t) => t.id === 'web_fetch')).toBe(true);
|
||||
expect(builtinTools.some((t) => t.id === 'store_memory')).toBe(true);
|
||||
|
||||
// Labels should be human-readable, not raw IDs
|
||||
const readTool = builtinTools.find((t) => t.id === 'read');
|
||||
expect(readTool?.label).toBe('Read files');
|
||||
const writeTool = builtinTools.find((t) => t.id === 'write');
|
||||
expect(writeTool?.label).toBe('Write files');
|
||||
const shellTool = builtinTools.find((t) => t.id === 'shell');
|
||||
expect(shellTool?.label).toBe('Shell commands');
|
||||
|
||||
// Individual tools should NOT appear in the approval list
|
||||
expect(builtinTools.some((t) => t.id === 'bash')).toBe(false);
|
||||
expect(builtinTools.some((t) => t.id === 'view')).toBe(false);
|
||||
expect(builtinTools.some((t) => t.id === 'edit')).toBe(false);
|
||||
expect(builtinTools.some((t) => t.id === 'grep')).toBe(false);
|
||||
expect(builtinTools.some((t) => t.id === 'task')).toBe(false);
|
||||
});
|
||||
|
||||
test('resolves workspace and project tooling with accepted discovered MCP servers', () => {
|
||||
|
||||
@@ -114,7 +114,7 @@ import Base from '../layouts/Base.astro';
|
||||
</div>
|
||||
<h3 class="text-base font-semibold">Real Project Context</h3>
|
||||
<p class="mt-2 text-sm leading-relaxed text-zinc-400">
|
||||
Attach local folders and repos so conversations stay grounded in your actual codebase, branch, and work.
|
||||
Attach local folders and repos so conversations stay grounded in your actual codebase. See branch name, dirty state, and ahead/behind status — the agent works with full awareness of your project.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
@@ -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, duplicate, and return to sessions later. Search across all sessions by title, content, or project to find past work instantly.
|
||||
</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">
|
||||
@@ -167,7 +193,73 @@ import Base from '../layouts/Base.astro';
|
||||
</div>
|
||||
<h3 class="text-base font-semibold">Tune Your Workflow</h3>
|
||||
<p class="mt-2 text-sm leading-relaxed text-zinc-400">
|
||||
Choose models, adjust reasoning effort, configure MCP servers and LSP profiles, and manage tool approval policies.
|
||||
Choose models, adjust reasoning effort, and set interaction modes. Fine-tune how each agent works without leaving the session.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature 9: Visual Pattern Editor -->
|
||||
<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-fuchsia-500/10 text-fuchsia-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 5a1 1 0 011-1h14a1 1 0 011 1v2a1 1 0 01-1 1H5a1 1 0 01-1-1V5zM4 13a1 1 0 011-1h6a1 1 0 011 1v6a1 1 0 01-1 1H5a1 1 0 01-1-1v-6zM16 13a1 1 0 011-1h2a1 1 0 011 1v6a1 1 0 01-1 1h-2a1 1 0 01-1-1v-6z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-base font-semibold">Visual Pattern Editor</h3>
|
||||
<p class="mt-2 text-sm leading-relaxed text-zinc-400">
|
||||
Design orchestration patterns visually. Drag agent nodes, draw connections, and inspect each step — all in an interactive graph canvas.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature 10: Extensible Tooling -->
|
||||
<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-teal-500/10 text-teal-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="M10.325 4.317c.426-1.756 2.924-1.756 3.35 0a1.724 1.724 0 002.573 1.066c1.543-.94 3.31.826 2.37 2.37a1.724 1.724 0 001.066 2.573c1.756.426 1.756 2.924 0 3.35a1.724 1.724 0 00-1.066 2.573c.94 1.543-.826 3.31-2.37 2.37a1.724 1.724 0 00-2.573 1.066c-.426 1.756-2.924 1.756-3.35 0a1.724 1.724 0 00-2.573-1.066c-1.543.94-3.31-.826-2.37-2.37a1.724 1.724 0 00-1.066-2.573c-1.756-.426-1.756-2.924 0-3.35a1.724 1.724 0 001.066-2.573c-.94-1.543.826-3.31 2.37-2.37.996.608 2.296.07 2.573-1.066z" />
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-base font-semibold">Extensible Tooling</h3>
|
||||
<p class="mt-2 text-sm leading-relaxed text-zinc-400">
|
||||
Add MCP servers and LSP profiles for code intelligence. Set tool approval policies to keep runs safe, and integrate project hooks for custom automation.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature 11: Tooling Discovery -->
|
||||
<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-cyan-500/10 text-cyan-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="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-base font-semibold">Tooling Discovery</h3>
|
||||
<p class="mt-2 text-sm leading-relaxed text-zinc-400">
|
||||
Aryx automatically discovers MCP servers from your project and user configs. Review, accept, or dismiss discovered tools before they join your sessions.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature 12: Plan Review & Agent Questions -->
|
||||
<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-lime-500/10 text-lime-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="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-base font-semibold">Plan Review & Agent Questions</h3>
|
||||
<p class="mt-2 text-sm leading-relaxed text-zinc-400">
|
||||
Agents can propose plans for your review and ask clarifying questions mid-run. You stay in control of direction without micromanaging every step.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Feature 13: Run Timeline -->
|
||||
<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-orange-500/10 text-orange-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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
<h3 class="text-base font-semibold">Run Timeline</h3>
|
||||
<p class="mt-2 text-sm leading-relaxed text-zinc-400">
|
||||
Every turn is recorded in a structured run timeline — see tool calls, agent delegation, hook execution, and context usage at a glance.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -296,7 +388,7 @@ import Base from '../layouts/Base.astro';
|
||||
<div>
|
||||
<h3 class="text-base font-semibold">Download & Launch</h3>
|
||||
<p class="mt-2 text-sm leading-relaxed text-zinc-400">
|
||||
Grab the latest release from GitHub and open Aryx. Make sure GitHub Copilot CLI is installed and authenticated.
|
||||
Grab the latest release from GitHub and open Aryx. The app shows your Copilot connection status so you'll know right away if authentication is ready.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
@@ -381,6 +473,16 @@ import Base from '../layouts/Base.astro';
|
||||
<span class="text-zinc-600">🛡️</span>
|
||||
Tool Approval Controls
|
||||
</div>
|
||||
<span class="hidden text-zinc-800 md:inline">·</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-zinc-600">🧩</span>
|
||||
React Flow Graph Editor
|
||||
</div>
|
||||
<span class="hidden text-zinc-800 md:inline">·</span>
|
||||
<div class="flex items-center gap-2">
|
||||
<span class="text-zinc-600">📦</span>
|
||||
Open Source
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
Reference in New Issue
Block a user