Compare commits

...
48 Commits
Author SHA1 Message Date
David KayaandCopilot 6321f9192d docs(website): add missing feature cards and expand existing copy
Add 5 new feature cards: Visual Pattern Editor, Extensible Tooling, Tooling
Discovery, Plan Review & Agent Questions, and Run Timeline. Expand Real
Project Context (git awareness), Persistent Sessions (search/duplicate),
and Get Started step 1 (Copilot diagnostics). Enrich tech banner with
React Flow and Open Source. Feature grid goes from 8 to 13 cards.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-28 14:52:48 +01:00
David KayaandCopilot 147b437e36 fix: isolate scratchpad session working directories
Give each scratchpad session its own working directory, migrate existing scratchpad sessions on workspace load, and route sidecar turns through the session-specific cwd. Add regression coverage for create, duplicate, delete, and migration flows, and document the new persistence model.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-28 14:31:39 +01:00
David KayaandCopilot fa5774cbc0 docs: strengthen backend/frontend plan-splitting requirement in AGENTS.md
Make the two-phase planning rule a hard, no-exceptions requirement.
Clarify sequencing, separate-session expectation, and handover
artifact contract coverage. Add when-in-doubt-split directive.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-28 14:08:30 +01:00
David KayaandCopilot ae56d55c85 fix: clean up hook event labels in Activity panel
Replace raw SDK identifiers with human-friendly labels (e.g.
sessionStart → Session start) and drop the meaningless UUID detail.
Labels now read like 'Session start hook started' instead of
'Hook sessionStart started'.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-28 14:06:21 +01:00
David KayaandCopilot 1fbfdbbac4 fix: suppress noisy hook lifecycle events
Stop forwarding hook lifecycle events when a project has no configured
.github/hooks/*.json commands so the UI only receives meaningful hook
activity. Also expose the configured-hook state on the bundle, cover the
suppression wiring with tests, and document the updated event semantics.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-28 14:05:10 +01:00
David KayaandCopilot db9ffe8399 fix: disambiguate hook lifecycle events in Activity panel
Append phase text (started/completed) to hook event labels so
start and end phases are visually distinct instead of appearing
as duplicates.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-28 13:25:35 +01:00
David KayaandCopilot dff97efd58 feat: support project hook commands
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-28 13:23:01 +01:00
David KayaandCopilot f459cc7291 fix: stop forcing copilot session ids
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-28 12:52:52 +01:00
David KayaandCopilot e13e3b818a fix: restore final assistant responses
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-28 12:44:14 +01:00
David KayaandCopilot f1fa52f9c3 feat: full Copilot SDK feature parity — custom agents, hooks, image input, skills, steering, session persistence
Backend (sidecar):
- Extended ProtocolModels with DTOs for custom agents, hooks, skills,
  infinite sessions, session lifecycle, and 9 new event types
- Added CopilotManagedSessionIds for stable SDK session ID mapping
- Added CopilotSessionManager/ICopilotSessionManager for session lifecycle
- Added CopilotSessionHooks for hook registration
- Added CopilotMessageOptionsMetadata for mid-turn steering
- Extended CopilotAgentBundle to wire custom agents, hooks, skills,
  infinite sessions, and stable session IDs
- Extended CopilotTurnExecutionState to project 13 new SDK event types
- Widened ITurnWorkflowRunner callback to accept SidecarEventDto
- Added list/delete/disconnect session commands to SidecarProtocolHost
- Added AryxCopilotAgentMessageOptionsTests (14 new tests, 142 total)

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

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

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-28 12:28:20 +01:00
David Kaya 0c2973c599 fix: add plan to .gitignore 2026-03-28 11:54:32 +01:00
David KayaandCopilot c1dab96bfd fix: wire PatternEditor to mode-aware graph mutations
Replace the old disconnected-node addAgentNodeToGraph with the
mode-aware addAgentToGraph so new agents are automatically wired
into the graph topology. Replace the graph-nuking removeAgent
path with removeAgentFromGraph for surgical node removal. Split
emitChange into graph-preserving and mode-change paths so editing
agent properties no longer discards custom positions and edges.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-27 21:55:54 +01:00
David KayaandCopilot bb7e5d4108 fix: preserve editable pattern graphs
Add mode-aware graph mutation helpers for pattern topology so backend
code can add and remove agents without forcing a full graph rebuild.
Also preserve an existing pattern graph in savePattern instead of
re-syncing it from defaults, and cover the new behavior with shared
and main-process tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-27 21:50:01 +01:00
David KayaandCopilot b5ab92e444 fix: auto-resolve queued approvals of the same category
When multiple tool calls of the same permission category (e.g. three
concurrent view/read calls) arrive simultaneously, approving one now
cascades to all queued approvals sharing the same category key. This
eliminates repeated approval prompts for parallel tool calls.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-27 21:43:21 +01:00
David KayaandCopilot 689b335220 feat: category-based approval for runtime tools
Replace 20+ individual runtime tool toggles with 5 permission categories
(read, write, shell, web_fetch, store_memory) matching how Copilot CLI,
Cline, and other coding agents handle approvals.

- Add resolveApprovalToolKey() to map permission kinds to category IDs
- Simplify fallbackRuntimeApprovalTools to 5 categories
- Thread alwaysApprove through PendingApprovalHandle → sidecar resolve
- Update ApprovalBanner to use category labels for Always Approve button
- Update ResolveApprovalCommand contract with alwaysApprove field
- Update tests for category-based model

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-27 21:36:27 +01:00
David KayaandCopilot f04e3b9dcc feat: cache same-turn approval selections
Add an AlwaysApprove flag to resolve-approval commands and cache
approved tool keys for the current request so repeated runtime
permissions are auto-approved immediately within the same turn.
The cache is cleared when the turn finishes to avoid leaking across
future requests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-27 21:24:55 +01:00
David KayaandCopilot 3ec69d990b feat: enable 'always approve for session' for runtime tools
Runtime tool permission requests (read, write, shell, store_memory) now
resolve stable approval names via fallback in the sidecar approval
coordinator, enabling the 'Always approve' button and session-level
auto-approval for built-in tools.

Backend:
- Add fallback tool names for PermissionRequestRead (read),
  PermissionRequestWrite (write), PermissionRequestShell (shell),
  and PermissionRequestMemory (store_memory)
- Add autoApprovedToolName parameter to RequiresToolCallApproval
  for permission-kind-based session approval matching
- Track tool.execution_start events in ToolNamesByCallId for
  supplementary exact-name resolution

Frontend:
- Use approval.toolName ?? approval.permissionKind as fallback key
  in resolveSessionApproval and ApprovalBanner
- Add shell, read, write permission-kind entries to builtin approval
  tool definitions so pruning preserves session overrides

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-27 21:20:14 +01:00
David KayaandCopilot 154787c336 fix: only project handoff tool calls as FunctionCallContent
AryxCopilotAgent was converting ALL Copilot tool requests (ask_user,
MCP tools, etc.) into FunctionCallContent. The Agent Framework's
AIAgentHostExecutor tracks every FunctionCallContent as an outstanding
request.  Since SDK-handled tools like ask_user never produce a
matching FunctionResultContent, HasOutstandingRequests stayed true and
TurnToken was never emitted — stalling group-chat advancement after
an ask_user interaction.

Now only handoff_to_* tool requests are projected as FunctionCallContent.
All other tool calls remain invisible to the Agent Framework executor,
matching the upstream GitHubCopilotAgent behaviour.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-27 21:12:42 +01:00
David KayaandCopilot 9d65e3d209 fix: restore real Copilot handoffs
Replace the broken forced-tool workaround with a repo-local Copilot
agent adapter that merges runtime handoff instructions and tool
declarations into Copilot sessions and projects Copilot tool requests
back into Agent Framework function-call updates.

Also add regression coverage for the adapter and document the runtime
integration detail in the architecture guide.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-27 21:07:58 +01:00
David KayaandCopilot 7247a68f24 fix: enforce real entry handoffs
Constrain the initial handoff triage agent so it cannot use regular
session tooling and must use a handoff tool on its first invocation.
This prevents the model from narrating delegation in plain text while
never actually transferring control.

Also add regression tests for the new entry-agent constraints.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-27 20:58:31 +01:00
David Kaya 85a9327f65 fix: surface handoff specialist activity\n\nQueue per-agent thinking updates from synchronous Copilot SDK session\nevents, flush them through the workflow runner, and promote handoff\ntargets to thinking immediately after handoff activity is emitted.\nThis fixes the UI case where downstream agents stayed on 'No status\nyet' during handoff patterns.\n\nAlso add targeted handoff diagnostics and regression tests.\n\nCo-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> 2026-03-27 20:49:52 +01:00
David KayaandCopilot 2a952dfebe fix: workspace reset now properly clears UI state
resetLocalWorkspace() deleted workspace.json and the scratchpad
directory but never broadcast the fresh state to the renderer.
The renderer also discarded the IPC return value. This left stale
projects, sessions, and activities visible in the UI after reset.

- Emit workspace-updated after reloading the workspace in the backend
- Use the returned fresh workspace to immediately update renderer state
- Clear sessionActivities and close the settings panel on reset

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-27 20:48:37 +01:00
David KayaandCopilot cf41279ff5 fix: clamp auto-approval pill count to known tools
The auto-approval pill displayed effectiveAutoApproved.size as the
numerator, which could include tool names not present in the approval
tool list (e.g. names added via 'Always approve' for tools that aren't
in runtimeTools yet). This caused impossible counts like '17/14'.

Compute the count by intersecting effectiveAutoApproved with the actual
approvalTools list, so the numerator never exceeds the denominator.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-27 20:40:30 +01:00
David KayaandCopilot c02589f4c0 fix: only probe OAuth for newly enabled MCP servers
Previously, every tooling update re-probed all enabled HTTP MCP servers
for OAuth requirements. Now it compares the new selection against the
previous one and only probes servers that were just enabled.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-27 20:31:29 +01:00
David KayaandCopilot 73595039fc fix: support provider-specific MCP OAuth settings
Use provider-aware OAuth handling instead of a single hardcoded client ID.
GitHub now follows VS Code's GitHub auth setup with the GitHub client ID,
GitHub authorize/token endpoints, prompt=select_account, vscode.dev/redirect,
and JSON-compatible token exchange. Entra keeps the Copilot client ID via a
known provider match, while unknown providers still fall back to metadata
and dynamic registration.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-27 20:23:26 +01:00
David KayaandCopilot 81bddcbd63 fix: align MCP OAuth flow with Copilot client ID requirements
- Use vscode.dev/redirect as redirect URI when using the Copilot client
  ID, with the local callback URL encoded in the state parameter
- Use PRM resource scopes (e.g. api://icmmcpapi-prod/mcp.tools) instead
  of generic OIDC scopes, always include offline_access
- Listen on root path instead of /callback to match vscode.dev/redirect
  behavior
- Extract PRM scopes from protected resource metadata discovery

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-27 20:15:25 +01:00
David KayaandCopilot 53f1167681 feat: use GitHub Copilot client ID as default for MCP OAuth
Use the well-known Copilot client ID (aebc6443-996d-45c2-90f0-388ff96faa56)
when no static client config is provided and the auth server doesn't
support dynamic client registration. This matches how VS Code authenticates
with Entra ID-backed MCP servers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-27 20:08:40 +01:00
David KayaandCopilot 4f7b479996 fix: fall back to openid-configuration for auth server metadata discovery
Microsoft Entra ID and other OIDC providers expose metadata at
.well-known/openid-configuration rather than RFC 8414's
.well-known/oauth-authorization-server. Now tries both suffixes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-27 20:06:02 +01:00
David KayaandCopilot 0fd7a04a51 fix: try both RFC 9728 and appended well-known URL paths for MCP OAuth discovery
Some MCP servers place .well-known metadata at the appended path
(e.g. /v1/.well-known/oauth-protected-resource) instead of the
RFC 9728 compliant inserted path. Now tries the RFC-compliant URL
first and falls back to the appended form.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-27 20:00:50 +01:00
David KayaandCopilot 6c6b49fde4 fix: probe PRM directly instead of GET-ing the MCP server URL
MCP servers use POST/SSE, not GET. A plain GET to the server URL can
return 405, 404, or other codes unrelated to authentication. Instead,
go directly to the .well-known/oauth-protected-resource endpoint and
check whether it returns valid metadata with authorization_servers.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-27 19:48:49 +01:00
David KayaandCopilot d73eaae30b fix: correct well-known URL construction per RFC 9728
The .well-known/{suffix} segment was being appended to the full URL
path instead of inserted after the origin. For example:

  Wrong:   https://api.example.com/mcp/.well-known/oauth-protected-resource
  Correct: https://api.example.com/.well-known/oauth-protected-resource/mcp/

- Extract buildWellKnownUrl() helper in mcpTokenStore (Electron-free)
- Fix all 3 call sites: requiresOAuth probe, PRM discovery, auth
  server metadata
- Add 6 unit tests for URL construction with various path shapes

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-27 19:46:25 +01:00
David KayaandCopilot 1868a79d9a feat: add 'Always approve' button for tool-call approvals
When a tool-call approval banner appears, users can now click 'Always
approve' to both approve the current call AND add the tool to the
session's autoApprovedToolNames. Future calls to that tool are then
auto-approved without showing the banner.

- Add alwaysApprove?: boolean to ResolveSessionApprovalInput
- In resolveSessionApproval, atomically append tool to session
  approvalSettings when alwaysApprove is true (avoids running guard)
- Add 'Always approve' button in ApprovalBanner (tool-call kind only)
- Wire through ChatPane and App.tsx with updated callback signatures

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-27 19:34:50 +01:00
David KayaandCopilot 4f1ae86021 fix: add diagnostic logging to MCP OAuth probing
Add [aryx oauth] log messages throughout the proactive auth flow so
failures are visible in the Electron main process console (DevTools →
Main Process or terminal output). Logs cover: probe start, HTTP status,
PRM discovery, token skip, flow start, and success/failure.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-27 19:31:03 +01:00
David KayaandCopilot f0c2b4982b feat: proactive OAuth when enabling HTTP MCP servers
When a user enables an HTTP MCP server for a session, proactively probe
the server URL. If it returns 401 and has discoverable OAuth metadata
(RFC 9728), automatically trigger the full OAuth 2.1 + PKCE flow and
open the browser for consent — matching VS Code's behavior.

- Add requiresOAuth() probe: GET server URL → check 401 → check PRM
- Add probeAndAuthenticateHttpMcpServers() in AryxAppService
- Call proactive probe from updateSessionTooling (fire-and-forget)
- Skip servers that already have stored tokens

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-27 19:26:59 +01:00
David KayaandCopilot ac48aa58e0 feat: implement MCP OAuth 2.1 flow, token storage, and injection
- Create mcpTokenStore: in-memory token storage keyed by normalized server URL
  with automatic expiry checking
- Create mcpOAuthService: full OAuth 2.1 + PKCE flow implementation
  - Protected Resource Metadata discovery (RFC 9728)
  - Authorization Server Metadata fetch (RFC 8414)
  - Dynamic Client Registration (RFC 7591) when no static client ID
  - PKCE S256 code challenge generation
  - Local HTTP callback server for auth code receipt
  - Browser-based consent via Electron shell.openExternal
  - Authorization code to token exchange
- Add startSessionMcpAuth IPC channel and handler to trigger OAuth flow
- Inject stored OAuth tokens as Authorization headers in buildRunTurnToolingConfig
- Update McpAuthBanner with 'Authenticate in browser' button and loading state
- Add tests for token store (7 tests) and token injection (3 tests)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-27 19:15:37 +01:00
David KayaandCopilot f9757d5ce2 feat: add MCP OAuth authentication frontend wiring and banner
- Add McpOauthRequiredEvent and McpOauthStaticClientConfigEvent to sidecar contracts
- Add PendingMcpAuthRecord domain type with status tracking (pending/authenticating/failed/done)
- Add pendingMcpAuth field to SessionRecord for session-level auth state
- Wire onMcpOAuthRequired callback through sidecarProcess, runTurnPending, and AryxAppService
- Handle mcp-oauth-required events: set session pendingMcpAuth, clear on turn finalize/cancel
- Add dismissSessionMcpAuth IPC channel with preload binding and handler registration
- Create McpAuthBanner component (amber-themed, shows server name/URL, dismiss button)
- Integrate McpAuthBanner into ChatPane with placeholder text and App.tsx callback
- Update test fixtures for new RunTurnPendingCommand.onMcpOAuthRequired field

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-27 19:09:11 +01:00
David KayaandCopilot 192c28f721 feat: surface MCP OAuth sidecar events
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-27 19:01:46 +01:00
David KayaandCopilot b670680a7d refactor: improve plan mode UX with composer-integrated toggle and guidance text
- Move plan mode toggle from pills row to composer send button area
- Plan toggle sits next to the send button as a mode switch icon
- Send button turns emerald when plan mode is active
- Add mode indicator strip below composer when plan mode is on
- Replace 'Implement this plan' auto-send button with instructional
  guidance text that tells the user to send a follow-up message
- Keep Dismiss button to clear the plan review banner

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-26 23:46:04 +01:00
David KayaandCopilot 231be36e6c feat: add plan mode frontend support with mode toggle and plan review UI
- Add InteractionMode type and ExitPlanModeRequestedEvent to sidecar contracts
- Add PendingPlanReviewRecord domain type and interactionMode to SessionRecord
- Wire onExitPlanMode callback through SidecarClient and RunTurnPendingCommand
- Pass session interaction mode to RunTurnCommand for sidecar consumption
- Handle exit-plan-mode-requested events in AryxAppService
- Add setSessionInteractionMode and dismissSessionPlanReview IPC methods
- Create PlanReviewBanner component with summary, markdown content, and actions
- Add plan mode toggle pill in ChatPane composer area
- Wire implement action as follow-up message (graceful degradation)
- Clear pending plan review on new turn, turn completion, and cancellation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-26 23:39:48 +01:00
David KayaandCopilot 380e402512 feat: add backend plan mode checkpoints
Add backend support for plan-mode turn shaping and exit-plan review
checkpoints. Run-turn commands now accept an interaction mode,
plan-mode prompt guidance tells agents to produce a plan and call
exit_plan_mode, and the sidecar emits a structured
exit-plan-mode-requested event when the SDK raises that session event.

For now the backend intentionally treats exit_plan_mode as a turn
boundary and graceful-degradation review checkpoint. The current
Agent Framework GitHubCopilotAgent wrapper does not expose a clean way
to bridge the live CopilotSession back into same-turn exit-plan
resolution, so that follow-up remains documented for the frontend and a
future backend slice.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-26 23:29:32 +01:00
David KayaandCopilot c069b86add docs: add website update guideline to AGENTS.md
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-26 23:24:37 +01:00
David KayaandCopilot 912677fba0 feat: add tool metadata registry with human-readable labels
Add a builtin tool label registry mapping all ~30 official SDK tool IDs
to human-readable display names. Update listApprovalToolDefinitions to
resolve labels from the registry instead of showing raw tool IDs like
read_bash or fetch_copilot_cli_documentation. Expand the fallback
runtime tool catalog from 6 to 23 non-internal standard tools.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-26 19:27:36 +01:00
David KayaandCopilot a1a044e7ca fix: filter internal runtime tools
Exclude internal runtime tools from sidecar capability discovery so they do not surface in the approval tool catalog.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-26 19:23:48 +01:00
David KayaandCopilot f15b1aedb1 feat: add ask_user interactive user input to frontend
Wire the sidecar user-input-requested protocol event through the main
process, IPC layer, and renderer UI so agents can ask the user
interactive questions with choices and freeform input.

- Add UserInputRequestedEvent and ResolveUserInputCommand to sidecar
  protocol types
- Add resolveUserInput method to SidecarClient and onUserInput callback
  to runTurn
- Create PendingUserInputRecord domain type and add pendingUserInput to
  SessionRecord
- Add handleUserInputRequested and resolveSessionUserInput to
  AryxAppService with handle management
- Register sessions:resolve-user-input IPC channel with preload bridge
- Create UserInputBanner component with choice buttons and freeform
  input
- Integrate UserInputBanner into ChatPane with header indicator

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-26 17:45:27 +01:00
David KayaandCopilot 2ae4bd01b4 feat: add ask_user support to sidecar
Implement the Copilot SDK user input round-trip in the backend:
- add user-input-requested and resolve-user-input protocol DTOs
- add a CopilotUserInputCoordinator that mirrors approval flow with
  pending TaskCompletionSource state and explicit resolution
- wire SessionConfig.OnUserInputRequest through CopilotAgentBundle
  and CopilotWorkflowRunner
- extend SidecarProtocolHost to emit user input events, accept
  resolve-user-input commands, and filter ask_user from runtime
  approval tools
- add regression tests for the new coordinator and protocol flow

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-26 17:34:49 +01:00
David KayaandCopilot 4c01d8b1a7 feat: surface structured permission details in approval UI
Wire the sidecar's new permissionDetail payload through the frontend:
- Add PermissionDetail interface to sidecar contract
- Add permissionDetail field to PendingApprovalRecord and thread
  through normalization and AryxAppService mapping
- Create PermissionDetailView component with kind-specific rendering:
  shell (command block + warning), write (filename + diff), read (path),
  mcp (server badge + args), url (prominent URL), memory (subject/fact),
  custom-tool (description + args), hook (message + args)
- ApprovalBanner shows structured detail when available, falls back to
  generic text for backward compatibility
- QueuedApprovalsList shows brief kind-specific summaries

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-26 16:59:15 +01:00
David KayaandCopilot 13c543e2fd feat: add structured tool approval details to sidecar protocol
Expose SDK permission request details on approval-requested events via a
new permissionDetail payload. This preserves the existing title/detail
summary while carrying the command, URL, diff, args, and other
kind-specific data needed for richer approval UI in the renderer.

Also adds sidecar coverage for all supported permission request kinds and
verifies the new payload serializes over the protocol.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-26 16:40:41 +01:00
David KayaandCopilot 69562c19f3 fix: fix Inno Setup preprocessor defines via environment variables
ISCC's /D command-line flag combined with Node.js spawn on Windows
produces broken ISPP defines due to argument quoting conflicts.
Switch to environment variables read via GetEnv() — robust and
avoids all quoting issues. Product name and publisher are now
hardcoded in the ISS (they never change).

Verified: installer shows 'Aryx' and correct version in metadata.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-03-26 16:14:14 +01:00
88 changed files with 10736 additions and 307 deletions
+1
View File
@@ -9,3 +9,4 @@ sidecar/**/obj/
.DS_Store
Thumbs.db
plan/
+8 -3
View File
@@ -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
View File
@@ -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 -4
View File
@@ -17,8 +17,10 @@ It works especially well when you want AI help that stays grounded in an actual
- **Start fast** with a scratchpad conversation for quick questions and ad-hoc work.
- **Work against real projects** by attaching local folders and letting Aryx stay aware of repository context.
- **Go beyond one assistant** with orchestration patterns such as single-agent, sequential, concurrent, handoff, and group-chat flows.
- **See what is happening** with live activity for each agent while a run is in progress.
- **Stay organized** with persistent sessions you can rename, pin, archive, and return to later.
- **See what is happening** with live activity for each agent while a run is in progress, including sub-agent delegations, hook lifecycle, skill invocations, and context compaction.
- **Stay organized** with persistent sessions you can rename, pin, archive, delete, and return to later.
- **Steer while agents work** by sending follow-up messages mid-turn — the agent receives your input immediately.
- **Attach images** to any message for visual context the model can reason about.
- **Tune how you work** by choosing models and reusing saved patterns that fit different tasks.
## What you can do in the app
@@ -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
+21 -20
View File
@@ -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
+7 -13
View File
@@ -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
View File
@@ -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> {
+26 -2
View File
@@ -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,
+4
View File
@@ -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);
}
+30 -12
View File
@@ -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
+550
View File
@@ -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, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;');
}
+68
View File
@@ -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}`;
}
+18 -1
View File
@@ -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}`,
};
}
+26 -1
View File
@@ -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;
}
+152 -3
View File
@@ -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) {
+6
View File
@@ -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
View File
@@ -10,8 +10,14 @@ import { Sidebar } from '@renderer/components/Sidebar';
import { resolveChatToolingSettings } from '@renderer/lib/chatTooling';
import {
applySessionEventActivity,
applySessionUsageEvent,
applyTurnEventLog,
pruneSessionActivities,
pruneSessionUsage,
pruneTurnEventLogs,
type SessionActivityMap,
type SessionUsageMap,
type TurnEventLogMap,
} from '@renderer/lib/sessionActivity';
import { applySessionEventWorkspace } from '@renderer/lib/sessionWorkspace';
import { WelcomePane } from '@renderer/components/WelcomePane';
@@ -91,6 +97,8 @@ export default function App() {
const [error, setError] = useState<string>();
const { capabilities: sidecarCapabilities, isRefreshing: isRefreshingCapabilities, refresh: refreshCapabilities } = useSidecarCapabilities(api);
const [sessionActivities, setSessionActivities] = useState<SessionActivityMap>({});
const [sessionUsage, setSessionUsage] = useState<SessionUsageMap>({});
const [turnEventLogs, setTurnEventLogs] = useState<TurnEventLogMap>({});
const [showSettings, setShowSettings] = useState(false);
const [newSessionProjectId, setNewSessionProjectId] = useState<string>();
@@ -114,11 +122,25 @@ export default function App() {
ws.sessions.map((session) => session.id),
),
);
setSessionUsage((current) =>
pruneSessionUsage(
current,
ws.sessions.map((session) => session.id),
),
);
setTurnEventLogs((current) =>
pruneTurnEventLogs(
current,
ws.sessions.map((session) => session.id),
),
);
});
const offSessionEvent = api.onSessionEvent((event) => {
setWorkspace((current) => applySessionEventWorkspace(current, event));
setSessionActivities((current) => applySessionEventActivity(current, event));
setSessionUsage((current) => applySessionUsageEvent(current, event));
setTurnEventLogs((current) => applyTurnEventLog(current, event));
});
return () => {
@@ -169,6 +191,14 @@ export default function App() {
() => (selectedSession ? sessionActivities[selectedSession.id] : undefined),
[selectedSession, sessionActivities],
);
const usageForSession = useMemo(
() => (selectedSession ? sessionUsage[selectedSession.id] : undefined),
[selectedSession, sessionUsage],
);
const turnEventsForSession = useMemo(
() => (selectedSession ? turnEventLogs[selectedSession.id] : undefined),
[selectedSession, turnEventLogs],
);
const hasUserProjects = useMemo(
() => (workspace?.projects.some((project) => !isScratchpadProject(project)) ?? false),
[workspace?.projects],
@@ -245,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);
}}
+66 -1
View File
@@ -1,5 +1,5 @@
import { useMemo, type ReactNode } from 'react';
import { Activity, Clock, ShieldAlert, Sparkles, Users } from 'lucide-react';
import { Activity, ArrowRight, CheckCircle2, Clock, Cog, ShieldAlert, Sparkles, Users, Zap } from 'lucide-react';
import {
buildAgentActivityRows,
@@ -8,6 +8,7 @@ import {
isAgentActivityCompleted,
type AgentActivityRow,
type SessionActivityState,
type TurnEventLog,
} from '@renderer/lib/sessionActivity';
import { RunTimeline } from '@renderer/components/RunTimeline';
import { inferProvider } from '@shared/domain/models';
@@ -145,6 +146,35 @@ function AgentRow({
);
}
/* ── Turn event helpers ─────────────────────────────────────── */
import type { SessionEventKind } from '@shared/domain/event';
function TurnEventIcon({ kind, phase, success }: { kind: SessionEventKind; phase?: string; success?: boolean }) {
const base = 'size-3';
switch (kind) {
case 'subagent':
return <ArrowRight className={`${base} ${success === false ? 'text-red-400' : 'text-sky-400'}`} />;
case 'hook-lifecycle':
return <Cog className={`${base} ${phase === 'start' ? 'animate-spin text-amber-400' : success === false ? 'text-red-400' : 'text-emerald-400'}`} />;
case 'skill-invoked':
return <Sparkles className={`${base} text-violet-400`} />;
case 'session-compaction':
return <CheckCircle2 className={`${base} ${phase === 'start' ? 'animate-pulse text-amber-400' : 'text-emerald-400'}`} />;
default:
return <Zap className={`${base} text-zinc-500`} />;
}
}
function formatTurnEventTimestamp(iso: string): string {
try {
const d = new Date(iso);
return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' });
} catch {
return '';
}
}
/* ── ActivityPanel ─────────────────────────────────────────── */
interface ActivityPanelProps {
@@ -152,6 +182,7 @@ interface ActivityPanelProps {
onJumpToMessage?: (messageId: string) => void;
pattern: PatternDefinition;
session: SessionRecord;
turnEvents?: TurnEventLog;
}
export function ActivityPanel({
@@ -159,6 +190,7 @@ export function ActivityPanel({
onJumpToMessage,
pattern,
session,
turnEvents,
}: ActivityPanelProps) {
const activityRows = useMemo(
() => buildAgentActivityRows(activity, pattern.agents),
@@ -239,6 +271,39 @@ export function ActivityPanel({
<RunTimeline onJumpToMessage={onJumpToMessage} runs={session.runs} />
</div>
{/* ── Turn events section ─────────────────────────── */}
{turnEvents && turnEvents.length > 0 && (
<div className="mb-4">
<SectionHeader>
<Zap className="size-3" />
<span>Events</span>
<span className="rounded-full bg-zinc-800 px-1.5 py-0.5 text-[9px] tabular-nums text-zinc-500">
{turnEvents.length}
</span>
</SectionHeader>
<div className="space-y-0.5 rounded-lg border border-zinc-800 bg-zinc-900/40 px-3 py-2">
{turnEvents.slice().reverse().map((entry, index) => (
<div key={index} className="flex items-start gap-2 py-1">
<div className="mt-0.5 shrink-0">
<TurnEventIcon kind={entry.kind} phase={entry.phase} success={entry.success} />
</div>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-[11px] font-medium text-zinc-300">{entry.label}</span>
<span className="ml-auto shrink-0 text-[9px] tabular-nums text-zinc-700">
{formatTurnEventTimestamp(entry.occurredAt)}
</span>
</div>
{entry.detail && (
<p className="text-[10px] leading-snug text-zinc-600">{entry.detail}</p>
)}
</div>
</div>
))}
</div>
</div>
)}
</div>
</div>
+299 -39
View File
@@ -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>
+11 -4
View File
@@ -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">
+15 -1
View File
@@ -21,6 +21,7 @@ import {
RefreshCw,
Search,
Settings,
Trash2,
Users,
X,
type LucideIcon,
@@ -45,6 +46,7 @@ interface SidebarProps {
onDuplicateSession: (sessionId: string) => void;
onSetSessionPinned: (sessionId: string, isPinned: boolean) => void;
onSetSessionArchived: (sessionId: string, isArchived: boolean) => void;
onDeleteSession: (sessionId: string) => void;
onRefreshGitContext: (projectId: string) => void;
}
@@ -128,14 +130,16 @@ function ActionMenuItem({
icon: Icon,
label,
onClick,
className,
}: {
icon: LucideIcon;
label: string;
onClick: () => void;
className?: string;
}) {
return (
<button
className="flex w-full items-center gap-2 px-3 py-1.5 text-[12px] text-zinc-300 transition hover:bg-zinc-800"
className={`flex w-full items-center gap-2 px-3 py-1.5 text-[12px] transition hover:bg-zinc-800 ${className ?? 'text-zinc-300'}`}
onClick={onClick}
role="menuitem"
type="button"
@@ -475,6 +479,7 @@ export function Sidebar({
onDuplicateSession,
onSetSessionPinned,
onSetSessionArchived,
onDeleteSession,
onRefreshGitContext,
}: SidebarProps) {
const scratchpadProject = workspace.projects.find((project) => isScratchpadProject(project));
@@ -742,6 +747,15 @@ export function Sidebar({
closeMenu();
}}
/>
<ActionMenuItem
className="text-red-400 hover:bg-red-500/10"
icon={Trash2}
label="Delete"
onClick={() => {
onDeleteSession(menuState.sessionId);
closeMenu();
}}
/>
</div>
</>
)}
@@ -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>
+4 -2
View File
@@ -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">&quot;implement the plan&quot;</span>,{' '}
<span className="text-zinc-300">&quot;adjust step 3&quot;</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>
);
}
-26
View File
@@ -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(
+151
View File
@@ -8,8 +8,15 @@ export interface AgentActivityState {
toolName?: string;
}
export interface SessionUsageState {
tokenLimit: number;
currentTokens: number;
messagesLength: number;
}
export type SessionActivityState = Record<string, AgentActivityState>;
export type SessionActivityMap = Record<string, SessionActivityState | undefined>;
export type SessionUsageMap = Record<string, SessionUsageState | undefined>;
export interface AgentActivityRow {
key: string;
@@ -184,3 +191,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;
}
+6
View File
@@ -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',
+39 -1
View File
@@ -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>;
+281 -1
View File
@@ -2,6 +2,7 @@ import type { PatternDefinition, PatternValidationIssue, ReasoningEffort } from
import type { ApprovalCheckpointKind, ApprovalDecision } from '@shared/domain/approval';
import type { ChatMessageRecord } from '@shared/domain/session';
import type { RuntimeToolDefinition } from '@shared/domain/tooling';
import type { ChatMessageAttachment } from '@shared/domain/attachment';
export interface SidecarModeCapability {
available: boolean;
@@ -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;
+26
View File
@@ -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,
};
}
+42
View File
@@ -0,0 +1,42 @@
export type ChatMessageAttachmentType = 'file' | 'blob';
export interface ChatMessageAttachment {
type: ChatMessageAttachmentType;
path?: string;
data?: string;
mimeType?: string;
displayName?: string;
}
const supportedImageMimeTypes = new Set([
'image/jpeg',
'image/png',
'image/gif',
'image/webp',
]);
export function isImageAttachment(attachment: ChatMessageAttachment): boolean {
if (attachment.mimeType) {
return supportedImageMimeTypes.has(attachment.mimeType);
}
if (attachment.path) {
const ext = attachment.path.split('.').pop()?.toLowerCase();
return ext === 'jpg' || ext === 'jpeg' || ext === 'png' || ext === 'gif' || ext === 'webp';
}
return false;
}
export function getAttachmentDisplayName(attachment: ChatMessageAttachment): string {
if (attachment.displayName) {
return attachment.displayName;
}
if (attachment.path) {
const parts = attachment.path.replace(/\\/g, '/').split('/');
return parts[parts.length - 1] || attachment.path;
}
return attachment.mimeType ?? 'Attachment';
}
+37 -1
View File
@@ -8,7 +8,15 @@ export type SessionEventKind =
| 'message-complete'
| 'agent-activity'
| 'run-updated'
| 'error';
| 'error'
| 'subagent'
| 'skill-invoked'
| 'hook-lifecycle'
| 'session-usage'
| 'session-compaction'
| 'pending-messages-modified';
export type SubagentEventKind = 'started' | 'completed' | 'failed' | 'selected' | 'deselected';
export interface SessionEventRecord {
sessionId: string;
@@ -27,4 +35,32 @@ export interface SessionEventRecord {
toolName?: string;
run?: SessionRunRecord;
error?: string;
// Subagent event fields
subagentEventKind?: SubagentEventKind;
customAgentName?: string;
customAgentDisplayName?: string;
// Skill invoked fields
skillName?: string;
skillPath?: string;
pluginName?: string;
// Hook lifecycle fields
hookInvocationId?: string;
hookType?: string;
hookPhase?: 'start' | 'end';
hookSuccess?: boolean;
// Session usage fields
tokenLimit?: number;
currentTokens?: number;
messagesLength?: number;
// Session compaction fields
compactionPhase?: 'start' | 'complete';
compactionSuccess?: boolean;
preCompactionTokens?: number;
postCompactionTokens?: number;
tokensRemoved?: number;
}
+19
View File
@@ -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;
}
+226
View File
@@ -32,6 +32,8 @@ export const reasoningEffortOptions: ReadonlyArray<{ value: ReasoningEffort; lab
{ value: 'xhigh', label: 'Maximum' },
];
import type { PatternAgentCopilotConfig } from '@shared/contracts/sidecar';
export interface PatternAgentDefinition {
id: string;
name: string;
@@ -39,6 +41,7 @@ export interface PatternAgentDefinition {
instructions: string;
model: string;
reasoningEffort?: ReasoningEffort;
copilot?: PatternAgentCopilotConfig;
}
export interface PatternGraphPosition {
@@ -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);
}
+14
View File
@@ -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
View File
@@ -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[];
}
+62 -12
View File
@@ -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}`,
+14
View File
@@ -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;
}
+103
View File
@@ -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);
});
});
+3 -1
View File
@@ -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: [
+52
View File
@@ -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');
});
});
+78
View File
@@ -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' });
});
});
+86
View File
@@ -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);
});
});
+8
View File
@@ -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,
};
+56
View File
@@ -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);
});
});
+5 -6
View File
@@ -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);
});
});
+52
View File
@@ -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();
});
});
+269
View File
@@ -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',
+58 -12
View File
@@ -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', () => {
+108 -6
View File
@@ -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 &amp; 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 &amp; 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>