Compare commits

...
72 Commits
Author SHA1 Message Date
David Kaya fa355197cb chore: release v0.0.29 2026-04-16 14:48:09 +02:00
David KayaandCopilot 5f263002f0 fix: pass mainWindow argument to showAndFocusWindow in second-instance handler
The showAndFocusWindow function signature was updated to require a
mainWindow parameter, but the call site in the second-instance handler
was not updated, causing a TypeScript compilation error that broke CI.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 14:43:41 +02:00
David Kaya 1376de4148 chore: release v0.0.28 2026-04-16 14:17:21 +02:00
David Kaya 5ce6bb4b0a Merge branch 'agents/tray-icon-main-window-fix' 2026-04-16 14:16:33 +02:00
David KayaandCopilot 47d3fb0a29 fix: tray icon click opens main window reliably after quick prompt use
showAndFocusWindow() used BrowserWindow.getAllWindows()[0] to find the
main window, which breaks once the quick prompt window is created —
the array order is not guaranteed and windows[0] may resolve to the
quick prompt instead, causing the show/focus calls to target a hidden,
frameless window.

Accept the main window reference as an explicit parameter and guard
against destroyed windows with isDestroyed().

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 14:00:30 +02:00
David Kaya 4da31cbdd6 Merge branch 'agents/single-instance-app-launch' 2026-04-16 13:56:19 +02:00
David KayaandCopilot f15323e37e feat: enforce single-instance app launch
Use Electron's requestSingleInstanceLock() to prevent opening
multiple instances. When a second instance is attempted, the
existing window is restored and focused instead.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 13:54:22 +02:00
David KayaandCopilot f8fe9d866e fix: use platform-native paths in release tests for cross-OS compatibility
The release workflow tests hardcoded Windows-style backslash paths
(C:\workspace\...) which caused path.relative() to produce incorrect
results on Linux and macOS, where backslashes are not path separators.

Replace hardcoded paths with resolve()/join() calls that produce
platform-native paths, fixing CI failures on Linux and macOS.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 13:53:49 +02:00
David Kaya 6c24754749 chore: release v0.0.27 2026-04-16 12:57:19 +02:00
David Kaya 2a7c627008 Merge branch 'agents/dotnet-aspire-opentelemetry-explorer' 2026-04-16 11:57:06 +02:00
David KayaandCopilot 3a3e1d5eab fix: correct OTEL instrumentation wiring for agent traces
- Fix ActivitySource name from 'Microsoft.Agents.AI' to
  'Experimental.Microsoft.Agents.AI' matching the Agent Framework default
- Wrap agents with UseOpenTelemetry() via AIAgentBuilder so agent
  invocation spans are emitted to the configured OTLP collector
- Add proper disposal of OpenTelemetryAgent wrappers via
  SyncDisposableAdapter bridging IDisposable to IAsyncDisposable
- Enable workflow-level telemetry on WorkflowRunner custom graph builds
  via WithOpenTelemetry() (HandoffWorkflowBuilder and
  GroupChatWorkflowBuilder do not expose this API — framework limitation)
- Fix settings panel help text to be user-facing instead of dev jargon
- Update test assertion for corrected ActivitySource name

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 11:52:13 +02:00
David Kaya 9a9332369b Merge branch 'agents/dotnet-aspire-opentelemetry-explorer' 2026-04-16 11:25:51 +02:00
David KayaandCopilot 67fb950e61 feat: add OpenTelemetry settings UI and sidecar environment wiring
Add a global Telemetry section in the settings panel with:
- Toggle to enable/disable OTLP export
- Text input for the OTLP endpoint URL (default: http://localhost:4317)
- Guidance note about using \un run aspire\ for local testing

Wire the setting through the full stack:
- OpenTelemetrySettings type in shared domain with normalization
- IPC channel, preload binding, and handler for persistence
- SidecarClient forwards settings to createSidecarEnvironment
- Sidecar environment injects OTEL_EXPORTER_OTLP_ENDPOINT when enabled
- Settings loaded from workspace.json on startup and on change

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 11:10:23 +02:00
David KayaandCopilot f7d376fc41 feat: add Aspire dashboard OTEL workflow
Add OTLP export wiring for the sidecar and dev scripts to launch the standalone Aspire Dashboard during development.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 10:39:36 +02:00
David KayaandCopilot 59ea028a24 feat: add release helper
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-16 09:30:17 +02:00
David KayaandCopilot c7910e6e4b fix: remove streaming text boundary heuristics that injected spurious spaces
The StreamingTextMerger (C#) and mergeStreamingText (TypeScript) contained
custom word-boundary heuristics that inserted spaces between streaming deltas
based on incorrect assumptions. Two bugs caused mid-word spaces:

1. Unconditional space insertion when incoming delta started with uppercase or
   digit characters, breaking acronyms (MDA -> M DA, UAG -> UA G)
2. LooksLikeWordBoundary using global token counts that fired on nearly every
   mid-word token split (writable -> wr itable, fragile -> frag ile)

Both upstream projects (Copilot SDK and Agent Framework) use simple string
concatenation for delta accumulation. LLM streaming tokens natively include
whitespace when needed, so no boundary detection is required.

Removed all boundary heuristic methods and replaced with plain concatenation.
Kept overlap detection and snapshot handling for event redelivery scenarios.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 18:55:34 +02:00
David Kaya 418946b854 chore: bump version to 0.0.26 2026-04-15 11:15:20 +02:00
David KayaandCopilot 10fcaf2b92 fix: use theme-aware background for chat code blocks
Replace hard-coded dark hex color (bg-[#0d0d10]) with the theme-aware
bg-surface-1 token so the code block background correctly adapts to
light mode. The other zinc-based utility classes already respond to
the light mode CSS variable overrides.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 10:57:04 +02:00
David KayaandCopilot 34fb5420a4 fix: show activity panels for all turns, not just the last one
The displayItems logic only injected orphan run panels for the most
recent user message's run. Previous turns whose thinking messages
were not produced or were lost during reclassification silently
dropped their activity panels.

Now iterate all unconsumed runs and splice a turn-activity panel
after each trigger user message, ensuring every turn with a run
gets a visible activity panel in the chat transcript.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 10:29:19 +02:00
David KayaandCopilot 05b3771544 fix: accurate elapsed timing and UX polish for activity panel
- Fix useElapsedTimer to use completedAt instead of Date.now() for
  finished runs, preventing wildly inaccurate durations (e.g. 9802m)
  when reopening old sessions
- Extract formatElapsedMs as a shared pure formatter; reuse in
  SubagentActivityCard
- Standardize icon column alignment across all activity row types
  using consistent w-4 + h-[18px] icon containers
- Redesign collapsed header: status dot with pulse animation, bolder
  status label, tabular-nums on elapsed time, cleaner hierarchy
- Polish expanded stream: timeline spine connecting activity items,
  more prominent intent dividers with pill-style background,
  tightened vertical rhythm
- Add unit tests for formatElapsedMs

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 09:11:38 +02:00
David Kaya 9243ec10a2 Merge branch 'agents/prepared-parrot' 2026-04-15 08:42:58 +02:00
David KayaandCopilot 1f49436d8a fix: serialize sidecar cleanup in batch delete to avoid requestId collision
Multiple concurrent sidecar.deleteSession() calls used
requestId: delete-session-\ which collides when two
fire in the same millisecond. The second overwrites the first in
the pending map, causing the first promise to never resolve and
Promise.allSettled to hang indefinitely — producing Electron's
'reply was never sent' error.

Run cleanup sequentially instead. Also add .catch() to rm() calls
for robustness.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 08:42:55 +02:00
David Kaya 61b5788e78 Merge branch 'agents/confidential-tyrannosaurus' 2026-04-15 08:35:31 +02:00
David KayaandCopilot 875c6a3706 chore: upgrade copilot preview package
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 08:34:46 +02:00
David KayaandCopilot b4aaf62179 chore: upgrade agent-framework to 1.1.0
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 08:31:54 +02:00
David Kaya 4ffd6ad8d4 Merge branch 'agents/prepared-parrot' 2026-04-15 08:27:52 +02:00
David KayaandCopilot c22bd32f97 fix: wire modifier key detection into SessionItem click handler
Ctrl+Click (⌘+Click on Mac) and Shift+Click were not triggering
multi-select mode because modifier key detection in handleSessionClick
was never called from the actual click chain.

Root cause:
- SessionItem.handleClick only checked isSelecting state, never
  inspected modifier keys on the mouse event
- ProjectGroup passed onSessionSelect directly (plain navigation),
  bypassing handleSessionClick entirely
- Search results called handleSessionClick with a hardcoded fake event
  where all modifier keys were false

Fix:
- Move modifier key detection into SessionItem.handleClick, which
  receives the real React.MouseEvent with accurate modifier state
- Add onEnterSelectionMode and onShiftSelect props to SessionItem
- Thread new callbacks through ProjectGroup
- Remove dead handleSessionClick function from Sidebar
- Fix search results to use the new callback-based approach

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 08:27:44 +02:00
David Kaya dad9769fa1 Merge branch 'agents/prepared-parrot' 2026-04-15 08:15:01 +02:00
David Kaya 56e2f8669c Merge branch 'agents/abundant-squirrel'
# Please enter a commit message to explain why this merge is necessary,
# especially if it merges an updated upstream into a topic branch.
#
# Lines starting with '#' will be ignored, and an empty message aborts
# the commit.
2026-04-15 08:14:58 +02:00
David Kaya d6008da0af Merge branch 'main' into agents/prepared-parrot 2026-04-15 08:14:33 +02:00
David KayaandCopilot 575aca360a feat: batch session archive and delete with multi-select UI
Add multi-select mode to the sidebar session list, enabling users to
archive, restore, or delete multiple sessions at once.

UX:
- Ctrl+Click (Cmd+Click on Mac) any session to enter multi-select mode
- Animated checkboxes stagger-reveal across all visible sessions
- Click to toggle, Shift+Click for range selection
- Running sessions are excluded from selection (safety guard)
- Floating action bar slides up with count pill, Archive, Delete, Cancel
- Batch delete shows a confirmation dialog with hold-to-confirm for 3+
  sessions and a scrollable list of session titles
- Batch archive shows an undo toast with 5-second auto-dismiss
- Escape key or Cancel button exits multi-select mode

Backend:
- batchSetSessionsArchived IPC: single load/mutate/persist cycle for N
  sessions instead of N sequential calls
- batchDeleteSessions IPC: parallel cleanup of scratchpad dirs and SDK
  sessions, then single workspace persist

Accessibility:
- Action bar: role=toolbar, aria-label
- Checkboxes: role=checkbox, aria-checked, keyboard-activatable
- Selection count: aria-live=polite
- Delete dialog: role=alertdialog, aria-modal, focus trap

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 07:12:45 +02:00
David KayaandCopilot 649eeddff3 feat: add configurable hotkey for Quick Prompt in settings
Add a HotkeyRecorder UI component that lets users click to enter
recording mode, press a modifier+key combination, and immediately
save it as the new Quick Prompt global shortcut.

- New HotkeyRecorder component in ui/ with live modifier preview,
  animated recording indicator, click-outside/Escape to cancel,
  platform-aware key symbol display, and disabled state support
- Integrate into QuickPromptSettingsSection with separate enable
  toggle and recorder rows
- Recorder is disabled when the global hotkey toggle is off
- Existing GlobalHotkeyService.update() re-registers automatically

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 07:02:27 +02:00
David Kaya 521be0de3c Merge branch 'agents/embarrassing-trout' 2026-04-15 06:59:00 +02:00
David KayaandCopilot fdfa6f3046 fix: remove shadow causing gradient rectangle and add keyboard navigation to quick prompt model picker
Remove shadow-2xl shadow-black/50 from the ModelSelector dropdown. On
transparent Electron windows (Windows), box-shadows paint onto the
transparent canvas and create a visible rectangle behind the UI — the
panel itself already avoided this with a comment explaining why.

Add full arrow-key navigation to the model picker listbox: ArrowUp/Down
cycle through options, Enter/Space selects, Home/End jump to extremes,
Escape closes. The container auto-focuses on mount so keyboard input
works immediately after opening via Tab+Enter. Mouse hover also syncs
the focused index for consistent visual feedback.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-15 06:57:10 +02:00
David KayaandCopilot 0e9d77c745 feat: allow saved workspace agents in the workflow editor
Add the ability to select from saved workspace agents when configuring
agent nodes in the workflow editor. Previously, adding an agent node
always created a new inline agent with empty fields.

Changes:
- Thread workspaceAgents prop through SettingsPanel → WorkflowEditor →
  WorkflowGraphInspector → AgentNodeInspector
- Add Inline/Saved Agent source toggle in the agent node inspector
- Add workspace agent picker dropdown with stale-agent detection
- Add override-aware fields that show inherited values from the saved
  agent with per-field reset buttons and visual indicators
- Support clean bidirectional switching between inline and linked modes
- Show saved workspace agents as directly-addable items in the node
  palette with link icon, separated from the generic New Agent button
- Add handleAddWorkspaceAgentNode to create pre-linked agent nodes

The underlying data model (AgentNodeConfig.workspaceAgentId, overrides)
and resolution logic (resolveWorkflowAgentNode) already existed but were
never exposed in the UI.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-14 16:29:07 +02:00
David KayaandCopilot bed6427dfc fix: eliminate quick prompt panel flash on show
Add animation-fill-mode 'both' to .qp-panel-enter so the from-keyframe
(opacity: 0) applies immediately on mount, preventing the single-frame
flash of the opaque surface background before the entrance animation
begins.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-14 11:17:23 +02:00
David Kaya 6068ee42e1 chore: bump to version 0.0.25 2026-04-13 21:08:30 +02:00
David KayaandCopilot 41d19e007d docs: update ARCHITECTURE.md for streaming ownership and provider adapter layer
- Document StreamingTranscriptBuffer as the single owner of text assembly
- Note provider adapter layer (IProviderEventAdapter.TryAdapt) for event
  normalization and how to add new providers
- Add capability metadata to protocol boundary description
- Update assistant-intent events as provider-neutral (not Copilot-specific)
- Add provider event normalization to sidecar runtime boundary

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 16:24:07 +02:00
David KayaandCopilot cc626a3152 test: add streaming regression tests for turn lifecycle and transitions
Add 5 regression tests covering:
- Thinking-to-response transition (reclassify → new message, no flash)
- Concurrent multi-message streaming independence
- Run-updated replacement semantics (no duplicates)
- Full turn lifecycle (intent → deltas → complete → idle clears intent)
- Message-complete content override of streamed content

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 16:21:44 +02:00
David KayaandCopilot 8d08a1df1a refactor: wire normalized intent into activity UI
- ChatPane header now prefers session.currentIntent over the activity-
  derived label from summarizeSessionActivity for the busy status display
- TurnActivityPanel receives currentIntent via props and prefers it over
  extractLatestIntent (report_intent tool-call scanning) when the panel
  is active; falls back to tool-call scan for completed/inactive runs
- This replaces the report_intent-as-status inference pattern with the
  normalized assistant-intent event data from the backend

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 16:20:04 +02:00
David KayaandCopilot 08886f9078 refactor: consolidate streaming text ownership and forward intent events
- Main process now always emits resolved content in message-delta events,
  making it the single authoritative text assembly point
- Renderer no longer imports or runs mergeStreamingText; uses main-process
  resolved content directly with simple append as defensive fallback
- Forward assistant-intent events through the session event pipeline; track
  currentIntent on SessionRecord, cleared on turn completion/cancellation
- Forward reasoning-delta events through the session event pipeline so
  renderers can consume incremental reasoning text
- Add SessionEventKind entries: assistant-intent, reasoning-delta
- Add SessionEventRecord fields: intent, reasoningId, reasoningDelta
- Remove dead applyTurnDelta, applyMessageReclassified, applyAgentActivity
  methods from AryxAppService (superseded by SessionTurnExecutor)
- Remove stale mergeStreamingText and appendRunActivityEvent imports from
  AryxAppService
- Add 5 regression tests: resolved content path, simple append fallback,
  intent set/clear lifecycle, duplicate suppression, intent preservation
  during status transitions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 16:12:41 +02:00
David KayaandCopilot 1fdb1c27d5 refactor: finalize backend streaming ownership
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 15:47:39 +02:00
David KayaandCopilot 4e34d2abfc refactor: unify tool call stream tracking
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 15:33:27 +02:00
David KayaandCopilot 7b9c4d140c refactor: enrich provider turn stream model
Add turn-stream capability metadata and richer normalized provider events for reasoning blocks, tool lifecycle, and turn boundaries. Track those signals in turn execution state and cover the new Copilot adapter/state behavior with tests.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 15:23:47 +02:00
David KayaandCopilot fde82bef4d fix: rewrite Quick Prompt event handling with message-by-ID tracking
The sidecar's fire-and-forget event handler pattern
(invokeRunTurnHandler) causes events to arrive out of order:
message-complete and status:idle can arrive before all
message-delta events have been emitted. Additionally, deltas
from multiple messages (thinking, response, sub-agent) were
naively concatenated into a single string, producing garbled
output.

Replace the single-string accumulation with a proper message
tracking model (Map<messageId, TrackedMessage>) that mirrors the
main app's SessionRecord.messages approach:

- Track each message independently by messageId
- Use message-complete event's content field as the authoritative
  final text, replacing any garbled intermediate streaming state
- Mark messages as finalized on message-complete to skip late
  arriving deltas that would corrupt the final content
- Derive display from the last non-thinking message's content
- Only transition to 'complete' when all tracked messages are
  not-pending AND at least one non-thinking message has content,
  preventing premature completion from setup/init events

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 14:39:41 +02:00
David KayaandCopilot 6ef44c6689 fix: prevent premature Quick Prompt completion from setup events
The session emits completion signals (agent-activity:completed,
message-complete, status:idle) during initialization before any
response content arrives. These early events were incorrectly
transitioning the popup to 'complete' phase, causing the
Nothing → In Progress → Nothing cycling pattern.

Track whether response content has been received via a ref and
only transition to 'complete' when hasContentRef is true. This
ensures setup/init completion events are ignored and the popup
stays in streaming until actual content has arrived and a
terminal event follows.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 14:14:25 +02:00
David KayaandCopilot 872476b2e3 fix: detect Quick Prompt completion from all terminal events
The popup stayed in 'streaming' phase because it only transitioned
to 'complete' on message-complete or status:idle when prev was
exactly 'streaming'. If other events (agent-activity, run-updated)
arrived between the last delta and idle, the phase guard could miss.

Broaden completion detection:
- status:idle now completes from any non-idle/non-complete phase
- agent-activity:completed also triggers completion
- Covers all paths the sidecar uses to signal turn end

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 12:45:40 +02:00
David KayaandCopilot b02a90a15f fix: sync Quick Prompt popup theme with main app
The popup window has its own document and never received the
data-theme attribute. Pass the current theme setting from the main
process via the show event, resolve 'system' to effective dark/light,
and apply to document.documentElement.dataset.theme so all CSS
variables match the main app's appearance.

- Add getCurrentTheme() to AryxAppService
- Pass theme in toggleQuickPromptWindow / showQuickPromptWindow
- Update onShow listener signature to receive theme string
- Apply theme to documentElement on each popup activation

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 12:36:35 +02:00
David KayaandCopilot d3fb0a64c5 fix: show default model selected when opening Quick Prompt
When no explicit default model is configured in settings, fall back
to the first available model so the input bar always displays a
selected model name instead of 'Select model'. Also always reset
model/reasoning to defaults on each new prompt activation.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 12:30:26 +02:00
David KayaandCopilot efcee8a621 fix: group models by provider and eliminate popup background rectangle
- Rewrite ModelSelector to group models by AI provider (OpenAI,
  Anthropic, Google) using providerMeta and ProviderIcon instead of
  grouping by tier; show tier as compact badge per model row
- Remove all box-shadow from qp-panel and border state variants;
  on Windows transparent BrowserWindows, shadows paint on the
  transparent canvas creating a visible dark rectangle behind the
  rounded panel
- Add transparent background to html element in quickprompt.html
  and CSS override via :has(body[data-quickprompt]) to ensure no
  ancestor paints a visible background

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 12:26:02 +02:00
David KayaandCopilot 543cf677ab fix: resolve Quick Prompt popup visibility and model picker issues
- Replace translucent glass background with solid surface-1 color;
  backdrop-filter does not work with transparent BrowserWindows on
  Windows, leaving the panel nearly invisible
- Add data-quickprompt body attribute to quickprompt.html and CSS
  overrides to ensure body/root are truly transparent, eliminating
  the visible rectangle behind the rounded panel
- Remove overflow-hidden from qp-panel so model selector dropdown
  is no longer clipped; the dropdown was positioned absolute and
  rendered entirely outside the panel bounds
- Change ModelSelector to open downward (top-full) instead of upward
  (bottom-full) for natural command-bar flow
- Update dropdown animation direction to match downward opening
- Increase blur handler delay to 200ms for safer dropdown interactions

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 12:20:17 +02:00
David KayaandCopilot 952e69da9f fix: improve Quick Prompt popup UX and settings design
- Fix window lifecycle: await renderer load before showing, debounce
  blur handler to prevent dropdown-induced dismiss
- Increase window height from 72px to 520px for response display
- Rewrite ModelSelector with tier-grouped upward-opening dropdown,
  reasoning effort controls, and Brain icon for reasoning models
- Rewrite QuickPromptInput with visible model trigger, animated
  chevron, auto-resize textarea, and proper stop button
- Improve QuickPromptResponse with compact thinking block, markdown
  code/pre/link styling, and refined spacing
- Polish QuickPromptActions with consistent sizing and hover states
- Redesign settings QuickPromptSettingsSection: replace flat radio
  list with compact grouped dropdown selector, only show reasoning
  effort when selected model supports it, combine hotkey toggle
  into single row
- Add markdown code block, inline code, and link CSS styles
- Fix dropdown animation direction for upward-opening selector
- Handle message-complete event in QuickPromptApp

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 12:14:29 +02:00
David KayaandCopilot fb5970cfa7 fix: resolve hotkey registration failures and renderer crash
- Change default hotkey from Super+Shift+A to Alt+Shift+A — Win key
  combinations are frequently reserved by the OS and fail to register
- Make quick prompt window creation lazy (first hotkey press) so it
  cannot interfere with main window startup
- Track failed accelerators to prevent error log spam from repeated
  workspace-updated events
- Fix process.platform crash in renderer SettingsPanel — use isMac
  from @renderer/lib/platform instead (renderer has no node globals)

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 11:54:10 +02:00
David KayaandCopilot 6714b93bed fix: resolve Quick Prompt hotkey registration and startup resilience
- Fix accelerator: convert 'Super' to 'Meta' in toElectronAccelerator()
  since Electron uses 'Meta' for the Win/Cmd key, not 'Super'
- Defer quick prompt window creation until after workspace loads so a
  failure cannot block the main window from rendering
- Extract registerQuickPromptIpcHandlers into a separate function called
  after the quick prompt window is created
- Wrap quick prompt window creation in try/catch for resilience

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 11:38:26 +02:00
David KayaandCopilot b038019954 feat: add Quick Prompt global hotkey popup for one-off AI questions
Add a system-wide global hotkey (Win+Shift+A / Cmd+Shift+A) that summons
a floating, frameless popup window for quick AI interactions from any app.

Main process:
- GlobalHotkeyService for registering/unregistering system-wide shortcuts
- Frameless, transparent, always-on-top BrowserWindow factory
- IPC handlers for send, discard, close, continue-in-aryx, cancel
- Session event routing from sidecar to quick prompt window
- Settings persistence for default model, hotkey, and reasoning effort

Renderer (separate lightweight entry):
- QuickPromptApp with session state, streaming, keyboard shortcuts
- QuickPromptInput with model selector trigger and cancel support
- QuickPromptResponse with streamed markdown and thinking blocks
- QuickPromptActions (Discard / Close / Continue in Aryx)
- ModelSelector dropdown with tier badges and reasoning effort
- Glass Command Bar aesthetic with animated gradient border

Settings:
- Quick Prompt section in SettingsPanel with enable toggle, hotkey
  display, default model selector, and reasoning effort picker

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 11:23:14 +02:00
David KayaandCopilot ec92b61663 fix: handle async FSWatcher errors in project customization watcher
On Linux, fs.watch() on a non-existent directory emits an asynchronous
'error' event instead of throwing synchronously. Without an error handler,
this crashes the process. Tests that create AryxAppService with fake
project paths (e.g. C:\workspace\project-one) trigger this on Linux CI
because those paths don't exist.

Attach an 'error' event handler to the FSWatcher that logs a warning and
closes the watcher gracefully. This fixes the Linux CI 'bun test' failure
and also prevents production crashes if a watched directory is deleted
at runtime.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 09:24:24 +02:00
David Kaya 524380e2b5 chore: bump version to 0.0.24 2026-04-13 09:02:20 +02:00
David Kaya 5324a5121d Merge branch 'agents/vital-hyena'
# Conflicts:
#	src/renderer/App.tsx
2026-04-13 08:57:29 +02:00
David KayaandCopilot e3660254df perf: improve application startup performance
Renderer code splitting:
- Lazy-load 15 components with React.lazy() and Suspense (ChatPane,
  ActivityPanel, SettingsPanel, TerminalPanel, WelcomePane, BottomPanel,
  GitPanel, CommandPalette, CommitComposer, WorkflowPicker, BookmarksPanel,
  SessionSearchPanel, KeyboardShortcutsPanel, DiscoveredToolingModal,
  ProjectSettingsPanel)
- This moves ~1.2 MB of optional dependencies (Lexical, @xyflow/react,
  @xterm/xterm, highlight.js, motion) out of the critical bundle
- Critical bundle reduced from ~1.5 MB to ~759 KB
- Defer JetBrains Mono font load to TerminalPanel (saves ~80 KB at startup)

Main process optimizations:
- Use show: false + ready-to-show on BrowserWindow to eliminate blank flash
- Decouple workspace loading from bootstrap — no longer blocks window, tray,
  and auto-update setup
- Defer sidecar-dependent approval tool pruning to run in background after
  workspace is returned to renderer (removes sidecar spawn from critical path)
- Parallelize independent workspace sync operations (user tooling, project
  tooling, project customization) with Promise.all()

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-13 08:52:45 +02:00
David KayaandCopilot e36b00ff1d fix: make sub-workflow resolution tolerant of missing references
Extract TryResolveSubWorkflowDefinition that returns null instead of
throwing when a referenced workflow is missing from the library.

Use the tolerant variant in all graph-walking helpers that build
agent indexes, collect agent nodes, and find sub-workflow nodes.
These operations are used for activity UI enrichment and agent
identity resolution — they should degrade gracefully rather than
crash the turn.

The throwing ResolveSubWorkflowDefinition is now only used by
WorkflowRunner.CreateNodeRoute, which is the actual execution
path and correctly fails with a clear error if a reference is
truly missing.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-08 19:15:58 +02:00
David KayaandCopilot c70a5c6612 feat: show sub-workflow agents and lifecycle in the Activity panel
Deep agent resolution in the sidecar now walks sub-workflow nodes so
nested agents carry subworkflowNodeId and subworkflowName on activity
events. New subworkflow-started / subworkflow-completed activity types
let the frontend track sub-workflow lifecycle.

The Activity panel groups nested agents under collapsible sub-workflow
cards with status badges, accent-colored left borders, and smooth
expand/collapse transitions. Cards auto-expand when a sub-workflow
starts running. Workflows without sub-workflow nodes render identically
to before.

Extracted AgentRow, SubWorkflowGroup, and shared accent constants to
a new components/activity/ feature directory. Added
resolveWorkflowAgentHierarchy and buildGroupedActivityRows for
hierarchical activity grouping with dynamic fallback for unresolved
sub-workflow agents.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-08 18:57:23 +02:00
David KayaandCopilot fa8f6ef4b3 fix: preserve toolArguments through normalization and activity state
- Add toolArguments to normalizeRunTimelineEvent output so persisted
  sessions retain tool argument data across app restarts
- Add toolArguments field to AgentActivityState and propagate it in
  applySessionEventActivity for real-time activity labels
- Add tests for normalization round-trip with and without toolArguments
- Add test for activity state including toolArguments

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-08 12:55:03 +02:00
David KayaandCopilot b9e73831e8 fix: enrich tool activity arguments on dedup
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-08 12:14:03 +02:00
David KayaandCopilot 0e2f9b8ae5 refactor: generalize sidecar workflow runner
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-08 11:35:50 +02:00
David KayaandCopilot e46193ae53 refactor: extract copilot provider module
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-08 11:27:58 +02:00
David KayaandCopilot 008d8c1bd0 refactor: add provider abstraction to sidecar host
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-08 11:18:20 +02:00
David KayaandCopilot e85906669f refactor: decouple turn execution state from Copilot SDK events
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-08 11:12:40 +02:00
David KayaandCopilot 2c165e453f fix: extract tool arguments from ToolExecutionStartEvent in sidecar
The CopilotTurnExecutionState was ignoring ToolExecutionStartData.Arguments
when creating tool-calling activity events, despite the Copilot SDK providing
them. This caused the activity panel to show 'Viewed a file' / 'Used rg'
with no contextual details (file paths, search patterns, etc.).

The fix adds NormalizeRawToolArguments to WorkflowRequestInfoInterpreter for
converting the raw object? payload (typically JsonElement) into the normalized
dictionary, then passes ToolExecutionStartData.Arguments through
CreateToolCallingActivity.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-08 10:47:55 +02:00
David KayaandCopilot 931ec27f42 fix: use fallback args when tool-specific keys are missing in labels
When toolArguments doesn't contain the expected key (e.g. 'path' for
view), fall back to the first usable string value from any argument
key instead of showing misleading placeholders like 'Viewed a file'.
When no arguments exist at all, show just the tool name.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-08 10:14:29 +02:00
David KayaandCopilot 3e71de98e8 feat: redesign activity panel UX with grouped timeline and verb-based labels
Replace the flat, repetitive 'Agent used X' activity list with a
structured narrative timeline:

- Group consecutive same-tool calls into collapsible rows with context
  (e.g. 'Viewed 4 files' with file names listed below)
- Use verb-based labels with arguments: 'Viewed ChatPane.tsx:148-250',
  'Searched for tool-call', 'Edited runTimeline.ts'
- Promote report_intent events to phase dividers that segment the
  timeline into labeled stages of work
- Show latest intent text in the collapsed header summary
- Use category-specific icons (Eye, Search, Pencil, Terminal, etc.)
  instead of the universal wrench
- Render thinking steps as quoted blocks with 'N more' toggle for
  consecutive groups

All data was already available in RunTimelineEventRecord.toolArguments;
this change surfaces it prominently instead of hiding it behind
expandable detail panels.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-08 09:51:34 +02:00
David KayaandCopilot 6d698ca233 chore: update sidecar nuget packages
Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-08 09:16:47 +02:00
David KayaandCopilot 574455729b refactor: extract AryxAppService into focused service delegates
Extract seven responsibility clusters from the 3,466-line AryxAppService
monolith into focused service classes under src/main/services/:

- WorkflowManager: workflow CRUD, templates, validation, resolution
- McpProbeManager: MCP server probing, OAuth pre-auth, probe queuing
- DiscoveredToolingSyncService: tooling/customization scanning, watchers
- GitContextManager: git refresh orchestration, context updates, mutations
- CheckpointRecoveryManager: checkpoint retry/recovery state handling
- ApprovalCoordinator: approval/user-input/plan-review/OAuth state machine
- SessionTurnExecutor: turn execution, streaming deltas, finalization

AryxAppService remains the public facade consumed by IPC handlers but now
delegates internally via constructor-injected service instances. A new
AppServiceDeps type provides a clean dependency injection seam for testing.

The facade is reduced from 3,466 to 2,572 lines. All existing tests pass
with two test files migrated to constructor DI (appServiceGitRefresh,
appServiceMcpProbing). New focused tests added for WorkflowManager and
the DI seam itself.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 22:18:52 +02:00
150 changed files with 15861 additions and 3721 deletions
+36 -12
View File
@@ -37,6 +37,7 @@ flowchart LR
Git[Local git repositories]
Sidecar[.NET sidecar]
Copilot[GitHub Copilot CLI<br/>+ agent runtime]
Telemetry[OTLP collector<br/>Aspire Dashboard]
OS[Native windowing<br/>and desktop integration]
User --> Renderer
@@ -46,6 +47,7 @@ flowchart LR
Main <--> Git
Main <--> Sidecar
Sidecar <--> Copilot
Sidecar --> Telemetry
Main <--> OS
```
@@ -55,9 +57,9 @@ flowchart LR
| --- | --- | --- | --- |
| Renderer | Screens, interaction, local view composition, theme application | Filesystem, process spawning, raw Electron access, Copilot runtime | Typed preload API and pushed events |
| Preload | Narrow bridge between browser context and Electron IPC | Business logic, persistence, orchestration | `ipcRenderer` / `ipcMain` |
| Main process | Workspace mutation, persistence, git inspection/write operations, run change attribution, commit workflow orchestration, session lifecycle, native window state, sidecar lifecycle, PTY-backed terminal lifecycle | UI rendering, LLM orchestration internals | IPC, filesystem, git CLI, stdio with sidecar, native child processes |
| Sidecar | Capability discovery, workflow validation, run execution, streaming deltas and activity | UI, workspace persistence, Electron APIs | Line-delimited JSON over stdio |
| External systems | Git data, Copilot account/model access, OS window chrome | Application state and UI behavior | Controlled adapters owned by main or sidecar |
| Main process | Workspace mutation, persistence, git inspection/write operations, run change attribution, commit workflow orchestration, session lifecycle, native window state, global hotkey registration, sidecar lifecycle, PTY-backed terminal lifecycle | UI rendering, LLM orchestration internals | IPC, filesystem, git CLI, stdio with sidecar, native child processes |
| Sidecar | Capability discovery, workflow validation, run execution, provider event normalization, optional OTLP trace export, streaming deltas and activity, streamed text assembly | UI, workspace persistence, Electron APIs | Line-delimited JSON over stdio, OTLP to external collectors |
| External systems | Git data, Copilot account/model access, telemetry collectors, OS window chrome | Application state and UI behavior | Controlled adapters owned by main or sidecar |
This split is the most important architectural feature in the app. It is what keeps the system understandable as more capabilities are added.
@@ -68,7 +70,7 @@ Aryx runs as a multi-process desktop application:
1. The **renderer** displays the workspace and captures user intent.
2. The **preload bridge** exposes a small, typed API into the browser context.
3. The **main process** validates and mutates application state, persists it, and manages native integrations.
4. The **sidecar** executes Copilot-backed turns and streams structured execution events back.
4. The **sidecar** executes Copilot-backed turns, can export OpenTelemetry traces to an external collector, and streams structured execution events back.
The sidecar is intentionally separate from the Electron main process so that AI runtime concerns stay isolated from UI and persistence concerns.
@@ -141,13 +143,17 @@ Workflows 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 workflow agent 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.
For Copilot-backed agents, Aryx uses a repo-local provider module around the Copilot SDK session layer so workflow agent 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.
The sidecar now keeps that Copilot-specific behavior behind provider seams. Core execution uses shared `IAgentProvider`, `IProviderTurnSupport`, `ProviderSessionEvent`, `ProviderAgentBundle`, `TurnExecutionState`, and `AgentWorkflowTurnRunner` abstractions, while `Services/Providers/Copilot/` owns SDK-specific bundle creation, transcript projection, approvals, user input, MCP OAuth, exit-plan-mode handling, CLI/session management, and event adaptation. Provider adapters implement `IProviderEventAdapter.TryAdapt()` to translate provider-native session events into the normalized `ProviderSessionEvent` records that `TurnExecutionState` consumes. The Copilot adapter is the first concrete implementation; adding a second provider means writing a new adapter without changing the turn-state machine or the main-process contract.
Streamed text assembly is a single-owner responsibility. The sidecar's `StreamingTranscriptBuffer` resolves content deltas and snapshot-mode content from provider events, producing authoritative `content` fields on every message-delta event. The main process forwards that resolved content to the renderer, which trusts it directly without re-running merge logic. This eliminates the duplicate text-assembly that previously caused glitches when the sidecar, main process, and renderer each independently merged streaming text.
Workflows are shared application data, not renderer-only configuration. The same workflow definition now drives validation, persistence, session execution, and sidecar orchestration.
Each workflow persists an explicit graph-backed topology. Agent nodes carry stable ids, ordering, and layout metadata, while start/end, fan-out/fan-in, sub-workflow, function, and request-port nodes make execution structure visible in the saved contract.
That graph remains the execution contract for the sidecar, but orchestration mode is now a first-class backend concept. Graph-based modes (`single`, `sequential`, `concurrent`) still execute directly from saved edges. Builder-based modes (`handoff`, `group-chat`) additionally persist mode-specific `settings.modeSettings` data for handoff filtering, triage selection, return behavior, and group-chat round limits, and the sidecar translates those settings into specialized Agent Framework workflow builders at run time.
That graph remains the execution contract for the sidecar, but orchestration mode is now a first-class backend concept. Graph-based modes (`single`, `sequential`, `concurrent`) still execute directly from saved edges. Builder-based modes (`handoff`, `group-chat`) additionally persist mode-specific `settings.modeSettings` data for handoff filtering, triage selection, return behavior, and group-chat round limits, and the sidecar translates those settings into specialized Agent Framework workflow builders at run time through shared orchestration helpers rather than Copilot-specific workflow code.
Workflow templates remain a first-class shared-domain contract. The shared layer owns workflow definitions, workflow template definitions, and workflow import/export helpers (YAML import/export plus Mermaid and DOT export). Built-in workflows seed workspace state directly, while built-in and custom templates let the main process create additional saved workflows without expanding the sidecar protocol.
@@ -214,14 +220,16 @@ This is a structured stdio protocol used for:
- streaming partial output
- streaming agent activity
This protocol boundary keeps the AI execution runtime replaceable and prevents the Electron main process from becoming overloaded with workflow-specific behavior.
This protocol boundary keeps the AI execution runtime replaceable and prevents the Electron main process from becoming overloaded with workflow-specific behavior. On the sidecar side, raw provider events are first normalized into sidecar-owned provider event records (via `IProviderEventAdapter`) before they become streamed run activity, so future providers can plug into the same transport without reshaping the main-process contract. Each event carries capability metadata so the main process and renderer can degrade gracefully when a provider does not support intent, reasoning, or fine-grained tool progress.
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:
- **Workflow activity events**: agent activity records preserve agent, tool, and optional sub-workflow context (`subworkflowNodeId`, `subworkflowName`) so the UI can distinguish root-level activity from nested execution without rebuilding workflow ancestry in Electron
- **Sub-workflow lifecycle events**: `subworkflow-started` and `subworkflow-completed` are emitted when nested workflow executors begin and finish, so the Activity panel can surface sub-workflow groups as first-class runtime activity
- **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
- **Message reclassification events**: let the sidecar retroactively mark a streamed assistant message as `thinking` once the SDK confirms that message requested tool work, so the UI can separate intermediate planning chatter from the final response without sacrificing live streaming
- **Assistant intent and reasoning-delta events**: optional Copilot SDK metadata that exposes short "what I'm doing" labels plus incremental reasoning text for richer thinking-process surfaces
- **Assistant intent and reasoning-delta events**: optional provider metadata that exposes short "what I'm doing" labels plus incremental reasoning text for richer thinking-process surfaces; normalized through the provider adapter layer so the UI consumes them uniformly regardless of provider origin
- **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
- **Assistant usage events**: per-LLM-call tokens, cost, AIU, and quota snapshots from the Copilot SDK's `assistant.usage` stream
- **Session compaction events**: start and complete, with token-reduction metrics when infinite sessions trigger context trimming
@@ -316,8 +324,8 @@ For git-backed projects, the renderer surfaces three specialized components. `Ru
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
- agent activity is surfaced with optional sub-workflow context
- turn-scoped lifecycle events (sub-agent, sub-workflow, hook, skill, compaction, usage) are streamed
- runs are surfaced inline as collapsible turn activity panels
- failures are represented explicitly
@@ -346,7 +354,8 @@ That gives the system:
The main process owns desktop concerns such as:
- native window creation
- native window creation (main window and quick prompt overlay)
- global hotkey registration
- title bar behavior
- background process management
- filesystem access
@@ -354,13 +363,28 @@ The main process owns desktop concerns such as:
This keeps those concerns out of the renderer while still letting the UI feel native.
### Multi-window setup and Quick Prompt
Aryx runs two `BrowserWindow` instances:
- the **main window** — the full workspace UI
- the **quick prompt window** — a frameless, transparent, always-on-top overlay for one-off AI questions
The quick prompt window loads a separate, lightweight renderer entry (`quickprompt.html` / `quickprompt.tsx`) with its own preload script (`preload/quickprompt.ts`). This keeps its bundle small and avoids loading the full workspace renderer. It communicates with the main process through dedicated IPC channels prefixed with `quick-prompt:`.
A **global hotkey service** (`src/main/services/globalHotkey.ts`) registers a system-wide keyboard shortcut (default `Super+Shift+A`, configurable in settings) using Electron's `globalShortcut` API. Pressing the hotkey toggles the quick prompt window. The service re-registers the shortcut when the configured hotkey changes and unregisters on app quit.
Quick prompt sessions are real `SessionRecord` instances created on the scratchpad project. The main process routes matching session events from the sidecar to the quick prompt window's `webContents`. After a response completes, the user can discard the session, close the window (preserving the session for later access in the main UI), or continue the conversation in the main window.
The `window-all-closed` handler excludes the quick prompt window so the app does not stay alive solely because the hidden popup exists.
## Build and release architecture
Aryx ships as an Electron application bundled together with a self-contained .NET sidecar.
The build pipeline is organized around three layers:
- building the Electron renderer and main process assets
- building the Electron renderer entries (main workspace and quick prompt) and main process assets
- publishing the sidecar for the target runtime
- packaging platform artifacts with electron-builder
+10
View File
@@ -45,6 +45,7 @@ Aryx is a desktop app that turns GitHub Copilot into a full workspace. Connect r
| Message actions | Copy, pin, edit-and-resend, and regenerate individual messages |
| Bookmarks | Browse all pinned messages across sessions in one panel (`Ctrl+Shift+B`) |
| System tray | Minimize to tray, quick-launch scratchpads, and see running session count |
| Quick Prompt | System-wide hotkey (`Win+Shift+A` / `Cmd+Shift+A`) summons a floating popup for instant AI questions from any app |
| Desktop notifications | Native OS alerts when runs complete, fail, or need approval |
| Onboarding | First-launch walkthrough, interactive tooltips, and a "try it" quickstart |
@@ -91,12 +92,21 @@ bun run test # typecheck + unit tests
bun run sidecar:test # backend tests
bun run build # full build (electron + sidecar)
bun run aspire # start the standalone Aspire Dashboard in Docker
bun run dev:otel # start Aspire + Aryx dev with OTLP export enabled
bun run package # package for current platform → release/
bun run installer # create installable artifact
bun run publish-release # publish to GitHub Releases
bun run release # bump patch version, commit, tag, and push
bun run release minor # bump minor version instead (use major for breaking releases)
```
For local observability, `bun run dev:otel` starts the Aspire Dashboard UI at `http://localhost:18888`
and configures the sidecar to export OpenTelemetry traces to `http://localhost:4317` over OTLP/gRPC.
This dev workflow requires Docker.
Tagged releases use GitHub Actions to build and publish Windows (NSIS), macOS (DMG, signed + notarized), and Linux (AppImage) artifacts. The app uses `electron-updater` for in-app updates.
The release helper expects a clean git worktree, an upstream branch configured for the current branch, and permission to push commits and tags.
## Trademarks
+12
View File
@@ -20,6 +20,12 @@ export default defineConfig({
preload: {
build: {
outDir: 'dist-electron/preload',
rollupOptions: {
input: {
index: resolve(__dirname, 'src/preload/index.ts'),
quickprompt: resolve(__dirname, 'src/preload/quickprompt.ts'),
},
},
},
plugins: [externalizeDepsPlugin()],
resolve: {
@@ -32,6 +38,12 @@ export default defineConfig({
root: 'src/renderer',
build: {
outDir: 'dist/renderer',
rollupOptions: {
input: {
index: resolve(__dirname, 'src/renderer/index.html'),
quickprompt: resolve(__dirname, 'src/renderer/quickprompt.html'),
},
},
},
plugins: [react(), tailwindcss()],
resolve: {
+4 -1
View File
@@ -1,16 +1,19 @@
{
"name": "aryx",
"version": "0.0.23",
"version": "0.0.29",
"description": "Orchestrator for Copilot-powered agent workflows across multiple projects.",
"private": true,
"main": "dist-electron/main/index.js",
"scripts": {
"dev": "electron-vite dev",
"dev:otel": "bun run scripts/dev-with-otel.ts",
"aspire": "bun run scripts/launch-aspire-dashboard.ts",
"build:electron": "electron-vite build",
"build": "bun run build:electron && bun run sidecar:build",
"package": "bun run scripts/clean-release.ts && bun run build:electron && bun run sidecar:publish && electron-builder --dir --publish never",
"installer": "bun run scripts/clean-release.ts && bun run build:electron && bun run sidecar:publish && electron-builder --publish never",
"publish-release": "bun run scripts/clean-release.ts && bun run build:electron && bun run sidecar:publish && electron-builder --publish always",
"release": "bun run scripts/release.ts",
"preview": "electron-vite preview",
"lsp:typescript": "typescript-language-server --stdio",
"typecheck": "tsc --noEmit -p tsconfig.json",
+233
View File
@@ -0,0 +1,233 @@
import { spawn } from 'node:child_process';
import net from 'node:net';
import { setTimeout as delay } from 'node:timers/promises';
export const aspireDashboardContainerName = 'aryx-aspire-dashboard';
export const aspireDashboardImage = 'mcr.microsoft.com/dotnet/aspire-dashboard:latest';
export const aspireDashboardUrls = {
dashboard: 'http://localhost:18888',
otlpGrpc: 'http://localhost:4317',
otlpHttp: 'http://localhost:4318',
} as const;
type ContainerState = 'missing' | 'running' | 'stopped';
export interface AspireDashboardHandle {
readonly dashboardUrl: string;
readonly otlpGrpcEndpoint: string;
readonly otlpHttpEndpoint: string;
readonly startedByScript: boolean;
}
export function createAspireDashboardRunArgs(
containerName = aspireDashboardContainerName,
image = aspireDashboardImage,
): string[] {
return [
'run',
'--rm',
'-d',
'--name',
containerName,
'-p',
'18888:18888',
'-p',
'4317:18889',
'-p',
'4318:18890',
'-e',
'ASPIRE_DASHBOARD_UNSECURED_ALLOW_ANONYMOUS=true',
image,
];
}
export function createOpenTelemetryEnvironment(
baseEnvironment: NodeJS.ProcessEnv,
endpoint: string = aspireDashboardUrls.otlpGrpc,
): NodeJS.ProcessEnv {
return {
...baseEnvironment,
OTEL_EXPORTER_OTLP_ENDPOINT: endpoint,
OTEL_EXPORTER_OTLP_PROTOCOL: 'grpc',
};
}
export async function startAspireDashboard(): Promise<AspireDashboardHandle> {
await ensureDockerAvailable();
let startedByScript = false;
const containerState = await getContainerState(aspireDashboardContainerName);
if (containerState !== 'running') {
if (containerState === 'stopped') {
await runCommand('docker', ['rm', '-f', aspireDashboardContainerName], 'pipe');
}
await runCommand('docker', createAspireDashboardRunArgs(), 'pipe');
startedByScript = true;
}
await waitForTcpPort(18888);
await waitForTcpPort(4317);
return {
dashboardUrl: aspireDashboardUrls.dashboard,
otlpGrpcEndpoint: aspireDashboardUrls.otlpGrpc,
otlpHttpEndpoint: aspireDashboardUrls.otlpHttp,
startedByScript,
};
}
export async function stopAspireDashboard(): Promise<void> {
const containerState = await getContainerState(aspireDashboardContainerName);
if (containerState === 'missing') {
return;
}
await runCommand('docker', ['rm', '-f', aspireDashboardContainerName], 'pipe');
}
async function ensureDockerAvailable(): Promise<void> {
const cliVersion = await runCommandCapture('docker', ['--version']);
if (cliVersion.exitCode !== 0) {
throw new Error(
'Docker is required to run the standalone Aspire Dashboard. Install Docker Desktop and ensure `docker` is available on PATH.',
);
}
if (await isDockerDaemonAvailable()) {
return;
}
if (await hasDockerDesktopCli()) {
console.log('Docker Desktop is not running. Starting it now...');
await runCommand('docker', ['desktop', 'start', '--detach'], 'pipe');
await waitForDockerDaemon();
return;
}
throw new Error(
'Docker is installed but the daemon is not running. Start Docker Desktop and rerun the Aspire command.',
);
}
async function getContainerState(containerName: string): Promise<ContainerState> {
const result = await runCommandCapture('docker', [
'container',
'inspect',
'--format',
'{{.State.Status}}',
containerName,
]);
if (result.exitCode !== 0) {
return 'missing';
}
return result.stdout.trim() === 'running' ? 'running' : 'stopped';
}
async function waitForTcpPort(port: number, timeoutMs = 30_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await canConnect(port)) {
return;
}
await delay(250);
}
throw new Error(`Timed out waiting for localhost:${port} to accept connections.`);
}
async function isDockerDaemonAvailable(): Promise<boolean> {
const result = await runCommandCapture('docker', ['info', '--format', '{{.ServerVersion}}']);
return result.exitCode === 0;
}
async function hasDockerDesktopCli(): Promise<boolean> {
const result = await runCommandCapture('docker', ['desktop', 'version']);
return result.exitCode === 0;
}
async function waitForDockerDaemon(timeoutMs = 120_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
if (await isDockerDaemonAvailable()) {
return;
}
await delay(1_000);
}
throw new Error('Timed out waiting for Docker Desktop to start and accept API connections.');
}
async function canConnect(port: number): Promise<boolean> {
return new Promise<boolean>((resolve) => {
const socket = net.createConnection({ host: '127.0.0.1', port });
const finalize = (connected: boolean) => {
socket.removeAllListeners();
socket.destroy();
resolve(connected);
};
socket.once('connect', () => finalize(true));
socket.once('error', () => finalize(false));
});
}
async function runCommand(command: string, args: string[], stdio: 'inherit' | 'pipe'): Promise<void> {
const result = await spawnProcess(command, args, stdio);
if (result.exitCode === 0) {
return;
}
if (result.stderr.trim().length > 0) {
throw new Error(result.stderr.trim());
}
throw new Error(`${command} exited with code ${result.exitCode}.`);
}
async function runCommandCapture(command: string, args: string[]): Promise<{ exitCode: number; stdout: string; stderr: string }> {
return spawnProcess(command, args, 'pipe');
}
async function spawnProcess(
command: string,
args: string[],
stdio: 'inherit' | 'pipe',
): Promise<{ exitCode: number; stdout: string; stderr: string }> {
return new Promise((resolve, reject) => {
const child = spawn(command, args, {
stdio: stdio === 'inherit' ? 'inherit' : ['ignore', 'pipe', 'pipe'],
windowsHide: true,
});
let stdout = '';
let stderr = '';
if (stdio === 'pipe') {
child.stdout?.on('data', (chunk: Buffer | string) => {
stdout += chunk.toString();
});
child.stderr?.on('data', (chunk: Buffer | string) => {
stderr += chunk.toString();
});
}
child.on('error', reject);
child.on('exit', (code, signal) => {
if (signal) {
reject(new Error(`${command} exited because of signal ${signal}.`));
return;
}
resolve({
exitCode: code ?? 1,
stdout,
stderr,
});
});
});
}
+57
View File
@@ -0,0 +1,57 @@
import { spawn } from 'node:child_process';
import { dirname, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
import {
createOpenTelemetryEnvironment,
startAspireDashboard,
stopAspireDashboard,
} from './aspireDashboard';
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
const repositoryRoot = resolve(scriptDirectory, '..');
const signalExitCodes: Partial<Record<NodeJS.Signals, number>> = {
SIGINT: 130,
SIGTERM: 143,
};
const dashboard = await startAspireDashboard();
console.log(`Aspire Dashboard: ${dashboard.dashboardUrl}`);
console.log(`Aryx sidecar OTLP endpoint: ${dashboard.otlpGrpcEndpoint}`);
const child = spawn(process.execPath, ['run', 'dev'], {
cwd: repositoryRoot,
env: createOpenTelemetryEnvironment(process.env, dashboard.otlpGrpcEndpoint),
stdio: 'inherit',
windowsHide: true,
});
for (const signal of ['SIGINT', 'SIGTERM'] as const) {
process.on(signal, () => {
if (child.exitCode === null && child.signalCode === null) {
child.kill(signal);
}
});
}
let exitResult: { code: number | null; signal: NodeJS.Signals | null };
try {
exitResult = await new Promise((resolve, reject) => {
child.on('error', reject);
child.on('exit', (code, signal) => {
resolve({ code, signal });
});
});
} finally {
if (dashboard.startedByScript) {
await stopAspireDashboard();
}
}
if (exitResult.signal) {
process.exitCode = signalExitCodes[exitResult.signal] ?? 1;
} else {
process.exitCode = exitResult.code ?? 1;
}
+9
View File
@@ -0,0 +1,9 @@
import { startAspireDashboard } from './aspireDashboard';
const dashboard = await startAspireDashboard();
console.log(
`${dashboard.startedByScript ? 'Started' : 'Reusing'} Aspire Dashboard at ${dashboard.dashboardUrl}`,
);
console.log(`OTLP/gRPC receiver: ${dashboard.otlpGrpcEndpoint}`);
console.log(`OTLP/HTTP receiver: ${dashboard.otlpHttpEndpoint}`);
+313
View File
@@ -0,0 +1,313 @@
import { spawn } from 'node:child_process';
import { readFile, writeFile } from 'node:fs/promises';
import { dirname, relative, resolve } from 'node:path';
import { fileURLToPath } from 'node:url';
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
const repositoryRoot = resolve(scriptDirectory, '..');
const packageJsonPath = resolve(repositoryRoot, 'package.json');
export type ReleaseBump = 'patch' | 'minor' | 'major';
interface PackageJsonShape {
version?: unknown;
[key: string]: unknown;
}
export interface GitCommandResult {
exitCode: number;
stdout: string;
stderr: string;
}
export type GitRunner = (args: string[]) => Promise<GitCommandResult>;
export interface ReleaseDependencies {
readText(path: string): Promise<string>;
writeText(path: string, content: string): Promise<void>;
runGit: GitRunner;
log(message: string): void;
}
export interface ReleaseWorkflowOptions {
repositoryRoot: string;
packageJsonPath: string;
bumpArg?: string;
}
export interface ReleaseResult {
bump: ReleaseBump;
currentVersion: string;
nextVersion: string;
tagName: string;
localBranch: string;
remote: string;
upstreamBranch: string;
}
interface UpstreamTarget {
remote: string;
branch: string;
}
function createGitRunner(cwd: string): GitRunner {
return async (args) =>
new Promise((resolvePromise, rejectPromise) => {
const child = spawn('git', args, {
cwd,
stdio: ['ignore', 'pipe', 'pipe'],
});
let stdout = '';
let stderr = '';
child.stdout?.on('data', (chunk: Buffer | string) => {
stdout += chunk.toString();
});
child.stderr?.on('data', (chunk: Buffer | string) => {
stderr += chunk.toString();
});
child.on('error', rejectPromise);
child.on('close', (code, signal) => {
if (signal) {
rejectPromise(new Error(`git ${args.join(' ')} exited because of signal ${signal}.`));
return;
}
resolvePromise({
exitCode: code ?? 1,
stdout,
stderr,
});
});
});
}
function createReleaseDependencies(cwd: string): ReleaseDependencies {
return {
readText: (path) => readFile(path, 'utf8'),
writeText: (path, content) => writeFile(path, content, 'utf8'),
runGit: createGitRunner(cwd),
log: console.log,
};
}
function parsePackageJson(packageJsonText: string): PackageJsonShape {
try {
return JSON.parse(packageJsonText) as PackageJsonShape;
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
throw new Error(`Could not parse package.json: ${message}`);
}
}
export function parseReleaseBump(value?: string): ReleaseBump {
if (value === undefined) {
return 'patch';
}
if (value === 'patch' || value === 'minor' || value === 'major') {
return value;
}
throw new Error(`Unsupported release bump "${value}". Use patch, minor, or major.`);
}
export function readPackageVersion(packageJsonText: string): string {
const packageJson = parsePackageJson(packageJsonText);
if (typeof packageJson.version !== 'string') {
throw new Error('package.json is missing a string version field.');
}
return packageJson.version;
}
function parseSemver(version: string): [number, number, number] {
const match = /^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)$/.exec(version);
if (!match) {
throw new Error(`Unsupported version "${version}". Expected a simple x.y.z semantic version.`);
}
return [Number(match[1]), Number(match[2]), Number(match[3])];
}
export function incrementVersion(version: string, bump: ReleaseBump): string {
const [major, minor, patch] = parseSemver(version);
switch (bump) {
case 'major':
return `${major + 1}.0.0`;
case 'minor':
return `${major}.${minor + 1}.0`;
case 'patch':
return `${major}.${minor}.${patch + 1}`;
}
}
function detectNewline(packageJsonText: string): '\r\n' | '\n' {
return packageJsonText.includes('\r\n') ? '\r\n' : '\n';
}
export function updatePackageJsonVersion(packageJsonText: string, nextVersion: string): string {
const newline = detectNewline(packageJsonText);
const packageJson = parsePackageJson(packageJsonText);
if (typeof packageJson.version !== 'string') {
throw new Error('package.json is missing a string version field.');
}
packageJson.version = nextVersion;
return `${JSON.stringify(packageJson, null, 2).replace(/\n/g, newline)}${newline}`;
}
function formatGitFailure(args: string[], result: GitCommandResult): string {
const detail = result.stderr.trim() || result.stdout.trim();
if (detail.length > 0) {
return `git ${args.join(' ')} failed: ${detail}`;
}
return `git ${args.join(' ')} failed with exit code ${result.exitCode}.`;
}
async function runGitOrThrow(runGit: GitRunner, args: string[], context: string): Promise<GitCommandResult> {
const result = await runGit(args);
if (result.exitCode !== 0) {
throw new Error(`${context} ${formatGitFailure(args, result)}`);
}
return result;
}
async function ensureCleanWorktree(runGit: GitRunner): Promise<void> {
const result = await runGitOrThrow(runGit, ['status', '--porcelain'], 'Could not inspect the current git worktree.');
if (result.stdout.trim().length > 0) {
throw new Error('Release requires a clean git worktree. Commit, stash, or discard changes first.');
}
}
async function resolveLocalBranch(runGit: GitRunner): Promise<string> {
const result = await runGit(['symbolic-ref', '--quiet', '--short', 'HEAD']);
if (result.exitCode !== 0) {
throw new Error('Release requires a checked-out branch. Detached HEAD is not supported.');
}
const branch = result.stdout.trim();
if (branch.length === 0) {
throw new Error('Release could not determine the current branch name.');
}
return branch;
}
function normalizeUpstreamBranch(mergeRef: string): string {
const prefix = 'refs/heads/';
if (!mergeRef.startsWith(prefix)) {
throw new Error(`Release expected an upstream branch ref, received "${mergeRef}".`);
}
return mergeRef.slice(prefix.length);
}
async function resolveUpstreamTarget(runGit: GitRunner, localBranch: string): Promise<UpstreamTarget> {
const remoteResult = await runGit(['config', '--get', `branch.${localBranch}.remote`]);
const mergeResult = await runGit(['config', '--get', `branch.${localBranch}.merge`]);
if (remoteResult.exitCode !== 0 || mergeResult.exitCode !== 0) {
throw new Error(`Release requires an upstream branch for ${localBranch}. Configure tracking before running the release helper.`);
}
const remote = remoteResult.stdout.trim();
const mergeRef = mergeResult.stdout.trim();
if (remote.length === 0 || mergeRef.length === 0) {
throw new Error(`Release requires an upstream branch for ${localBranch}. Configure tracking before running the release helper.`);
}
return {
remote,
branch: normalizeUpstreamBranch(mergeRef),
};
}
async function ensureTagDoesNotExist(runGit: GitRunner, tagName: string): Promise<void> {
const result = await runGitOrThrow(runGit, ['tag', '--list', tagName], 'Could not inspect existing git tags.');
if (result.stdout.trim().length > 0) {
throw new Error(`Tag ${tagName} already exists. Choose a different release version.`);
}
}
export async function runReleaseWorkflow(
options: ReleaseWorkflowOptions,
dependencies: ReleaseDependencies,
): Promise<ReleaseResult> {
const bump = parseReleaseBump(options.bumpArg);
const packageJsonText = await dependencies.readText(options.packageJsonPath);
const currentVersion = readPackageVersion(packageJsonText);
const nextVersion = incrementVersion(currentVersion, bump);
const tagName = `v${nextVersion}`;
const packageJsonFile = relative(options.repositoryRoot, options.packageJsonPath);
await ensureCleanWorktree(dependencies.runGit);
const localBranch = await resolveLocalBranch(dependencies.runGit);
const upstreamTarget = await resolveUpstreamTarget(dependencies.runGit, localBranch);
await ensureTagDoesNotExist(dependencies.runGit, tagName);
const updatedPackageJson = updatePackageJsonVersion(packageJsonText, nextVersion);
const commitMessage = `chore: release ${tagName}`;
const tagMessage = `Release ${tagName}`;
await dependencies.writeText(options.packageJsonPath, updatedPackageJson);
await runGitOrThrow(dependencies.runGit, ['add', packageJsonFile], `Could not stage ${packageJsonFile}.`);
await runGitOrThrow(dependencies.runGit, ['commit', '-m', commitMessage], 'Could not create the release commit.');
await runGitOrThrow(dependencies.runGit, ['tag', '-a', tagName, '-m', tagMessage], 'Could not create the annotated release tag.');
await runGitOrThrow(
dependencies.runGit,
['push', '--follow-tags', upstreamTarget.remote, `HEAD:${upstreamTarget.branch}`],
'Could not push the release commit and tag.',
);
dependencies.log(`Released ${tagName} from ${localBranch} to ${upstreamTarget.remote}/${upstreamTarget.branch}.`);
return {
bump,
currentVersion,
nextVersion,
tagName,
localBranch,
remote: upstreamTarget.remote,
upstreamBranch: upstreamTarget.branch,
};
}
export async function main(argv: string[]): Promise<void> {
await runReleaseWorkflow(
{
repositoryRoot,
packageJsonPath,
bumpArg: argv[0],
},
createReleaseDependencies(repositoryRoot),
);
}
if (import.meta.main) {
await main(process.argv.slice(2)).catch((error: unknown) => {
const message = error instanceof Error ? error.message : String(error);
console.error(message);
process.exitCode = 1;
});
}
@@ -9,10 +9,12 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="GitHub.Copilot.SDK" Version="0.2.0" />
<PackageReference Include="Microsoft.Agents.AI" Version="1.0.0-rc4" />
<PackageReference Include="Microsoft.Agents.AI.GitHub.Copilot" Version="1.0.0-preview.260311.1" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.0.0-rc4" />
<PackageReference Include="GitHub.Copilot.SDK" Version="0.2.1" />
<PackageReference Include="Microsoft.Agents.AI" Version="1.1.0" />
<PackageReference Include="Microsoft.Agents.AI.GitHub.Copilot" Version="1.1.0-preview.260410.1" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.1.0" />
<PackageReference Include="OpenTelemetry" Version="1.13.1" />
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.13.1" />
</ItemGroup>
</Project>
@@ -452,6 +452,8 @@ public sealed class AgentActivityEventDto : SidecarEventDto
public string ActivityType { get; init; } = string.Empty;
public string? AgentId { get; init; }
public string? AgentName { get; init; }
public string? SubworkflowNodeId { get; init; }
public string? SubworkflowName { get; init; }
public string? SourceAgentId { get; init; }
public string? SourceAgentName { get; init; }
public string? ToolName { get; init; }
@@ -0,0 +1,136 @@
namespace Aryx.AgentHost.Contracts;
internal abstract record ProviderSessionEvent;
internal sealed record ProviderAssistantMessageDeltaEvent(
string MessageId,
string? DeltaContent) : ProviderSessionEvent;
internal sealed record ProviderAssistantMessageEvent(
string MessageId,
string? Content,
bool HasToolRequests) : ProviderSessionEvent;
internal sealed record ProviderToolExecutionStartEvent(
string ToolCallId,
string ToolName,
IReadOnlyDictionary<string, object?>? ToolArguments) : ProviderSessionEvent;
internal sealed record ProviderToolExecutionProgressEvent(
string ToolCallId,
string? ProgressMessage) : ProviderSessionEvent;
internal sealed record ProviderToolExecutionPartialResultEvent(
string ToolCallId,
string? PartialOutput) : ProviderSessionEvent;
internal sealed record ProviderToolExecutionCompleteEvent(
string ToolCallId,
bool Success,
string? ResultContent,
string? DetailedResultContent,
string? Error) : ProviderSessionEvent;
internal sealed record ProviderAssistantIntentEvent(string? Intent) : ProviderSessionEvent;
internal sealed record ProviderAssistantReasoningDeltaEvent(
string? ReasoningId,
string? DeltaContent) : ProviderSessionEvent;
internal sealed record ProviderAssistantReasoningEvent(
string? ReasoningId,
string? Content) : ProviderSessionEvent;
internal sealed record ProviderAssistantTurnStartEvent(string? TurnId) : ProviderSessionEvent;
internal sealed record ProviderAssistantTurnEndEvent(string? TurnId) : ProviderSessionEvent;
internal sealed record ProviderSubagentStartedEvent(
string? ToolCallId,
string? AgentName,
string? AgentDisplayName,
string? AgentDescription) : ProviderSessionEvent;
internal sealed record ProviderSubagentCompletedEvent(
string? ToolCallId,
string? AgentName,
string? AgentDisplayName) : ProviderSessionEvent;
internal sealed record ProviderSubagentFailedEvent(
string? ToolCallId,
string? AgentName,
string? AgentDisplayName,
string? Error) : ProviderSessionEvent;
internal sealed record ProviderSubagentSelectedEvent(
string? AgentName,
string? AgentDisplayName,
IReadOnlyList<string>? Tools) : ProviderSessionEvent;
internal sealed record ProviderSubagentDeselectedEvent() : ProviderSessionEvent;
internal sealed record ProviderSkillInvokedEvent(
string SkillName,
string Path,
string Content,
IReadOnlyList<string>? AllowedTools,
string? PluginName,
string? PluginVersion) : ProviderSessionEvent;
internal sealed record ProviderHookStartEvent(
string HookInvocationId,
string HookType,
object? Input) : ProviderSessionEvent;
internal sealed record ProviderHookEndEvent(
string HookInvocationId,
string HookType,
bool? Success,
object? Output,
string? Error) : ProviderSessionEvent;
internal sealed record ProviderAssistantUsageEvent(
string Model,
double? InputTokens,
double? OutputTokens,
double? CacheReadTokens,
double? CacheWriteTokens,
double? Cost,
double? Duration,
double? TotalNanoAiu,
Dictionary<string, QuotaSnapshotDto>? QuotaSnapshots) : ProviderSessionEvent;
internal sealed record ProviderSessionUsageEvent(
double TokenLimit,
double CurrentTokens,
double MessagesLength,
double? SystemTokens,
double? ConversationTokens,
double? ToolDefinitionsTokens,
bool? IsInitial) : ProviderSessionEvent;
internal sealed record ProviderSessionCompactionStartEvent(
double? SystemTokens,
double? ConversationTokens,
double? ToolDefinitionsTokens) : ProviderSessionEvent;
internal sealed record ProviderSessionCompactionCompleteEvent(
bool? Success,
string? Error,
double? SystemTokens,
double? ConversationTokens,
double? ToolDefinitionsTokens,
double? PreCompactionTokens,
double? PostCompactionTokens,
double? PreCompactionMessagesLength,
double? MessagesRemoved,
double? TokensRemoved,
string? SummaryContent,
double? CheckpointNumber,
string? CheckpointPath) : ProviderSessionEvent;
internal sealed record ProviderPendingMessagesModifiedEvent() : ProviderSessionEvent;
internal sealed record ProviderMcpOauthRequiredEvent() : ProviderSessionEvent;
internal sealed record ProviderExitPlanModeRequestedEvent() : ProviderSessionEvent;
@@ -0,0 +1,28 @@
namespace Aryx.AgentHost.Contracts;
internal sealed record ProviderTurnStreamCapabilities
{
public static ProviderTurnStreamCapabilities None { get; } = new();
public bool SupportsIntent { get; init; }
public bool SupportsReasoningDelta { get; init; }
public bool SupportsReasoningBlock { get; init; }
public bool SupportsToolExecutionProgress { get; init; }
public bool SupportsToolExecutionPartialResult { get; init; }
public bool SupportsToolExecutionCompletion { get; init; }
public bool SupportsSubagentLifecycle { get; init; }
public bool SupportsHookLifecycle { get; init; }
public bool SupportsSessionCompaction { get; init; }
public bool SupportsPendingMessagesMutation { get; init; }
public bool SupportsSessionTurnBoundaries { get; init; }
}
@@ -0,0 +1,38 @@
namespace Aryx.AgentHost.Contracts;
internal enum ProviderToolExecutionStatus
{
Running,
Completed,
Failed,
}
internal sealed record ProviderToolExecutionSnapshot
{
public string ToolCallId { get; init; } = string.Empty;
public string? ToolName { get; init; }
public IReadOnlyDictionary<string, object?>? ToolArguments { get; init; }
public ProviderToolExecutionStatus Status { get; init; }
public string? LatestProgressMessage { get; init; }
public string PartialOutput { get; init; } = string.Empty;
public string? ResultContent { get; init; }
public string? DetailedResultContent { get; init; }
public string? Error { get; init; }
}
internal sealed record ProviderReasoningSnapshot
{
public string ReasoningId { get; init; } = string.Empty;
public string Content { get; init; } = string.Empty;
public bool IsComplete { get; init; }
}
+3
View File
@@ -1,4 +1,5 @@
using Aryx.AgentHost.Services;
using OpenTelemetry.Trace;
if (!args.Contains("--stdio", StringComparer.Ordinal))
{
@@ -6,5 +7,7 @@ if (!args.Contains("--stdio", StringComparer.Ordinal))
return;
}
using TracerProvider? tracerProvider = OpenTelemetrySetup.CreateTracerProviderFromEnvironment();
SidecarProtocolHost host = new();
await host.RunAsync(Console.In, Console.Out, CancellationToken.None);
@@ -0,0 +1,19 @@
using Microsoft.Agents.AI.Workflows;
namespace Aryx.AgentHost.Services;
internal static class AgentHostOptionsFactory
{
public static AIAgentHostOptions CreateDefault()
{
return new AIAgentHostOptions
{
EmitAgentUpdateEvents = null,
EmitAgentResponseEvents = false,
InterceptUserInputRequests = false,
InterceptUnterminatedFunctionCalls = false,
ReassignOtherAgentsAsUsers = true,
ForwardIncomingMessages = true,
};
}
}
@@ -3,7 +3,12 @@ using Aryx.AgentHost.Contracts;
namespace Aryx.AgentHost.Services;
internal readonly record struct AgentIdentity(string AgentId, string AgentName);
internal readonly record struct SubworkflowContext(string SubworkflowNodeId, string SubworkflowName);
internal readonly record struct AgentIdentity(
string AgentId,
string AgentName,
SubworkflowContext? Subworkflow = null);
internal static class AgentIdentityResolver
{
@@ -13,17 +18,69 @@ internal static class AgentIdentityResolver
WorkflowDefinitionDto workflow,
string? agentIdentifier,
out AgentIdentity agent)
{
return TryResolveKnownAgentIdentity(
workflow,
WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(null),
agentIdentifier,
agentSubworkflowIndex: null,
out agent);
}
public static bool TryResolveKnownAgentIdentity(
WorkflowDefinitionDto workflow,
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary,
string? agentIdentifier,
out AgentIdentity agent)
{
return TryResolveKnownAgentIdentity(
workflow,
WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(workflowLibrary),
agentIdentifier,
agentSubworkflowIndex: null,
out agent);
}
internal static bool TryResolveKnownAgentIdentity(
WorkflowDefinitionDto workflow,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
string? agentIdentifier,
IReadOnlyDictionary<string, SubworkflowContext>? agentSubworkflowIndex,
out AgentIdentity agent)
{
agent = default;
WorkflowNodeDto? match = FindKnownAgent(workflow, agentIdentifier)
?? ResolveSingleAgentAssistantAlias(workflow, agentIdentifier);
if (match is null)
WorkflowNodeDto? shallowMatch = FindKnownAgent(workflow.GetAgentNodes(), agentIdentifier);
if (shallowMatch is not null)
{
agent = ToAgentIdentity(shallowMatch);
return true;
}
WorkflowNodeDto? deepMatch = FindKnownAgent(workflow.GetAllAgentNodes(workflowLibrary), agentIdentifier);
if (deepMatch is not null)
{
IReadOnlyDictionary<string, SubworkflowContext> subworkflowIndex = agentSubworkflowIndex
?? BuildAgentSubworkflowIndex(workflow, workflowLibrary);
agent = ToAgentIdentity(deepMatch, subworkflowIndex);
return true;
}
WorkflowNodeDto? aliasMatch = ResolveSingleAgentAssistantAlias(workflow, workflowLibrary, agentIdentifier);
if (aliasMatch is null)
{
return false;
}
agent = ToAgentIdentity(match);
if (workflow.GetAgentNodes().Contains(aliasMatch))
{
agent = ToAgentIdentity(aliasMatch);
return true;
}
IReadOnlyDictionary<string, SubworkflowContext> aliasSubworkflowIndex = agentSubworkflowIndex
?? BuildAgentSubworkflowIndex(workflow, workflowLibrary);
agent = ToAgentIdentity(aliasMatch, aliasSubworkflowIndex);
return true;
}
@@ -33,7 +90,24 @@ internal static class AgentIdentityResolver
AgentIdentity? fallbackAgent,
out AgentIdentity agent)
{
if (TryResolveKnownAgentIdentity(workflow, agentIdentifier, out agent))
return TryResolveObservedAgentIdentity(
workflow,
WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(null),
agentIdentifier,
fallbackAgent,
agentSubworkflowIndex: null,
out agent);
}
internal static bool TryResolveObservedAgentIdentity(
WorkflowDefinitionDto workflow,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
string? agentIdentifier,
AgentIdentity? fallbackAgent,
IReadOnlyDictionary<string, SubworkflowContext>? agentSubworkflowIndex,
out AgentIdentity agent)
{
if (TryResolveKnownAgentIdentity(workflow, workflowLibrary, agentIdentifier, agentSubworkflowIndex, out agent))
{
return true;
}
@@ -53,13 +127,46 @@ internal static class AgentIdentityResolver
string? agentId,
string? agentName)
{
WorkflowNodeDto? match = FindKnownAgent(workflow, agentId)
?? FindKnownAgent(workflow, agentName)
?? ResolveSingleAgentAssistantAlias(workflow, agentId, agentName);
return ResolveAgentIdentity(
workflow,
WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(null),
agentId,
agentName,
agentSubworkflowIndex: null);
}
return match is not null
? ToAgentIdentity(match)
: CreateFallbackIdentity(agentId, agentName);
public static AgentIdentity ResolveAgentIdentity(
WorkflowDefinitionDto workflow,
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary,
string? agentId,
string? agentName)
{
return ResolveAgentIdentity(
workflow,
WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(workflowLibrary),
agentId,
agentName,
agentSubworkflowIndex: null);
}
internal static AgentIdentity ResolveAgentIdentity(
WorkflowDefinitionDto workflow,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
string? agentId,
string? agentName,
IReadOnlyDictionary<string, SubworkflowContext>? agentSubworkflowIndex)
{
if (TryResolveKnownAgentIdentity(workflow, workflowLibrary, agentId, agentSubworkflowIndex, out AgentIdentity resolvedById))
{
return resolvedById;
}
if (TryResolveKnownAgentIdentity(workflow, workflowLibrary, agentName, agentSubworkflowIndex, out AgentIdentity resolvedByName))
{
return resolvedByName;
}
return CreateFallbackIdentity(agentId, agentName, agentSubworkflowIndex);
}
public static string ResolveDisplayAuthorName(
@@ -90,6 +197,53 @@ internal static class AgentIdentityResolver
return GenericAssistantIdentifier;
}
public static IReadOnlyDictionary<string, SubworkflowContext> BuildAgentSubworkflowIndex(
WorkflowDefinitionDto workflow,
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null)
{
return BuildAgentSubworkflowIndex(
workflow,
WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(workflowLibrary));
}
internal static IReadOnlyDictionary<string, SubworkflowContext> BuildAgentSubworkflowIndex(
WorkflowDefinitionDto workflow,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary)
{
ArgumentNullException.ThrowIfNull(workflow);
ArgumentNullException.ThrowIfNull(workflowLibrary);
Dictionary<string, SubworkflowContext> index = new(StringComparer.Ordinal);
CollectAgentSubworkflowContexts(
workflow,
workflowLibrary,
currentSubworkflow: null,
index,
new HashSet<string>(StringComparer.Ordinal),
new HashSet<WorkflowDefinitionDto>(ReferenceEqualityComparer.Instance));
return index;
}
internal static bool TryResolveSubworkflowContext(
WorkflowDefinitionDto workflow,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
string? subworkflowNodeId,
out SubworkflowContext context)
{
ArgumentNullException.ThrowIfNull(workflow);
ArgumentNullException.ThrowIfNull(workflowLibrary);
context = default;
WorkflowNodeDto? node = workflow.FindSubWorkflowNode(subworkflowNodeId, workflowLibrary);
if (node is null)
{
return false;
}
context = CreateSubworkflowContext(node, workflowLibrary);
return true;
}
internal static bool IsGenericAssistantIdentifier(string? candidate)
{
return string.Equals(
@@ -100,34 +254,125 @@ internal static class AgentIdentityResolver
private static WorkflowNodeDto? ResolveSingleAgentAssistantAlias(
WorkflowDefinitionDto workflow,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
params string?[] agentIdentifiers)
{
IReadOnlyList<WorkflowNodeDto> agentNodes = workflow.GetAgentNodes();
return agentNodes.Count == 1 && agentIdentifiers.Any(IsGenericAssistantIdentifier)
? agentNodes[0]
: null;
if (!agentIdentifiers.Any(IsGenericAssistantIdentifier))
{
return null;
}
IReadOnlyList<WorkflowNodeDto> topLevelAgents = workflow.GetAgentNodes();
if (topLevelAgents.Count == 1)
{
return topLevelAgents[0];
}
IReadOnlyList<WorkflowNodeDto> allAgents = workflow.GetAllAgentNodes(workflowLibrary);
return allAgents.Count == 1 ? allAgents[0] : null;
}
private static WorkflowNodeDto? FindKnownAgent(WorkflowDefinitionDto workflow, string? candidate)
private static WorkflowNodeDto? FindKnownAgent(
IEnumerable<WorkflowNodeDto> agents,
string? candidate)
{
return workflow.GetAgentNodes().FirstOrDefault(agent => MatchesAgent(agent, candidate));
return agents.FirstOrDefault(agent => MatchesAgent(agent, candidate));
}
private static AgentIdentity ToAgentIdentity(WorkflowNodeDto agent)
=> new(agent.GetAgentId(), agent.GetAgentName());
private static AgentIdentity CreateFallbackIdentity(string? agentId, string? agentName)
private static AgentIdentity ToAgentIdentity(
WorkflowNodeDto agent,
IReadOnlyDictionary<string, SubworkflowContext> agentSubworkflowIndex)
{
string resolvedAgentId = !string.IsNullOrWhiteSpace(agentId)
? agentId
: agentName ?? "agent";
string resolvedAgentName = !string.IsNullOrWhiteSpace(agentName)
? agentName
: resolvedAgentId;
string agentId = agent.GetAgentId();
return agentSubworkflowIndex.TryGetValue(agentId, out SubworkflowContext subworkflow)
? new AgentIdentity(agentId, agent.GetAgentName(), subworkflow)
: new AgentIdentity(agentId, agent.GetAgentName());
}
private static AgentIdentity CreateFallbackIdentity(
string? agentId,
string? agentName,
IReadOnlyDictionary<string, SubworkflowContext>? agentSubworkflowIndex)
{
string resolvedAgentId = NormalizeOptionalString(agentId)
?? NormalizeOptionalString(agentName)
?? "agent";
string resolvedAgentName = NormalizeOptionalString(agentName)
?? resolvedAgentId;
if (agentSubworkflowIndex is not null
&& agentSubworkflowIndex.TryGetValue(resolvedAgentId, out SubworkflowContext subworkflow))
{
return new AgentIdentity(resolvedAgentId, resolvedAgentName, subworkflow);
}
return new AgentIdentity(resolvedAgentId, resolvedAgentName);
}
private static void CollectAgentSubworkflowContexts(
WorkflowDefinitionDto workflowDefinition,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
SubworkflowContext? currentSubworkflow,
Dictionary<string, SubworkflowContext> index,
ISet<string> visitedWorkflowIds,
ISet<WorkflowDefinitionDto> visitedAnonymousWorkflows)
{
string? workflowId = NormalizeOptionalString(workflowDefinition.Id);
if (workflowId is not null)
{
if (!visitedWorkflowIds.Add(workflowId))
{
return;
}
}
else if (!visitedAnonymousWorkflows.Add(workflowDefinition))
{
return;
}
foreach (WorkflowNodeDto node in workflowDefinition.Graph.Nodes)
{
if (node.IsAgentNode())
{
if (currentSubworkflow.HasValue)
{
index[node.GetAgentId()] = currentSubworkflow.Value;
}
continue;
}
if (!node.IsSubWorkflowNode())
{
continue;
}
WorkflowDefinitionDto? subWorkflow = node.TryResolveSubWorkflowDefinition(workflowLibrary);
if (subWorkflow is null)
{
continue;
}
CollectAgentSubworkflowContexts(
subWorkflow,
workflowLibrary,
CreateSubworkflowContext(node, workflowLibrary),
index,
visitedWorkflowIds,
visitedAnonymousWorkflows);
}
}
private static SubworkflowContext CreateSubworkflowContext(
WorkflowNodeDto node,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary)
{
return new SubworkflowContext(node.Id, node.GetSubworkflowDisplayName(workflowLibrary));
}
private static bool MatchesAgent(WorkflowNodeDto agent, string? candidate)
{
if (string.IsNullOrWhiteSpace(candidate))
@@ -164,6 +409,9 @@ internal static class AgentIdentityResolver
&& normalizedCandidate.Contains(normalizedName, StringComparison.Ordinal));
}
private static string? NormalizeOptionalString(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string NormalizeComparisonKey(string? value)
{
if (string.IsNullOrWhiteSpace(value))
@@ -12,18 +12,18 @@ using Microsoft.Extensions.AI;
namespace Aryx.AgentHost.Services;
public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
public class AgentWorkflowTurnRunner : ITurnWorkflowRunner
{
private const string HandoffFunctionPrefix = "handoff_to_";
private readonly WorkflowValidator _workflowValidator;
private readonly WorkflowRunner _workflowRunner = new();
private readonly CopilotApprovalCoordinator _approvalCoordinator = new();
private readonly CopilotUserInputCoordinator _userInputCoordinator = new();
private readonly CopilotMcpOAuthCoordinator _mcpOAuthCoordinator = new();
private readonly CopilotExitPlanModeCoordinator _exitPlanModeCoordinator = new();
private readonly IProviderTurnSupport _providerTurnSupport;
public CopilotWorkflowRunner(WorkflowValidator? workflowValidator = null)
internal AgentWorkflowTurnRunner(
IProviderTurnSupport providerTurnSupport,
WorkflowValidator? workflowValidator = null)
{
_providerTurnSupport = providerTurnSupport ?? throw new ArgumentNullException(nameof(providerTurnSupport));
_workflowValidator = workflowValidator ?? new WorkflowValidator();
}
@@ -44,50 +44,27 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
throw new InvalidOperationException(validationError);
}
CopilotTurnExecutionState state = new(command);
TurnExecutionState state = new(command);
using CancellationTokenSource runCancellation =
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
IProviderTranscriptProjector? transcriptProjector = null;
try
{
await using CopilotAgentBundle bundle = await CopilotAgentBundle.CreateAsync(
command,
(agent, request, invocation) => _approvalCoordinator.RequestApprovalAsync(
await using ProviderAgentBundle bundle = await _providerTurnSupport.CreateAgentBundleAsync(
command,
agent,
request,
invocation,
state.ToolNamesByCallId,
activity => EmitActivityAsync(command, state, activity, onEvent),
state,
onEvent,
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));
}
if (sessionEvent is ExitPlanModeRequestedEvent exitPlanModeRequested)
{
_exitPlanModeCoordinator.RecordExitPlanModeRequest(command, agent, exitPlanModeRequested);
runCancellation.Cancel();
}
},
runCancellation.Token);
runCancellation,
runCancellation.Token)
.ConfigureAwait(false);
transcriptProjector = bundle.TranscriptProjector;
ConfigureHookLifecycleEventSuppression(state, bundle);
Workflow workflow = BuildWorkflowForCommand(command, bundle.Agents, _workflowRunner);
List<ChatMessage> inputMessages = command.Messages.Select(WorkflowTranscriptProjector.ToChatMessage).ToList();
WorkflowTranscriptProjector.AttachMessageMode(inputMessages, command.MessageMode);
List<ChatMessage> inputMessages = command.Messages.Select(transcriptProjector.ToChatMessage).ToList();
transcriptProjector.AttachMessageMode(inputMessages, command.MessageMode);
using FileSystemJsonCheckpointStore? checkpointStore = CreateCheckpointStore(command);
CheckpointManager? checkpointManager = checkpointStore is not null
@@ -114,9 +91,16 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
continue;
}
bool shouldEndTurn = await HandleWorkflowEventAsync(command, evt, inputMessages, state, onDelta, onEvent)
bool shouldEndTurn = await HandleWorkflowEventAsync(
command,
evt,
inputMessages,
state,
transcriptProjector,
onDelta,
onEvent)
.ConfigureAwait(false);
await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false);
await EmitPendingEventsAsync(state, onDelta, onEvent).ConfigureAwait(false);
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
if (shouldEndTurn)
{
@@ -124,27 +108,28 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
}
}
await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false);
await EmitPendingEventsAsync(state, onDelta, onEvent).ConfigureAwait(false);
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
return state.FinalizeCompletedMessages();
return state.FinalizeCompletedMessages(transcriptProjector);
}
catch (OperationCanceledException) when (runCancellation.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
{
await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false);
await EmitPendingEventsAsync(state, onDelta, onEvent).ConfigureAwait(false);
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
ExitPlanModeRequestedEventDto? exitPlanModeEvent =
_exitPlanModeCoordinator.ConsumePendingRequest(command.RequestId);
_providerTurnSupport.ConsumePendingExitPlanModeRequest(command.RequestId);
if (exitPlanModeEvent is null || !state.HasPendingExitPlanModeRequest)
{
throw;
}
await onExitPlanMode(exitPlanModeEvent).ConfigureAwait(false);
return state.FinalizeCompletedMessages();
return state.FinalizeCompletedMessages(
transcriptProjector ?? throw new InvalidOperationException("Provider transcript projector was not initialized."));
}
finally
{
_approvalCoordinator.ClearRequestApprovals(command.RequestId);
_providerTurnSupport.ClearRequestState(command.RequestId);
}
}
@@ -158,8 +143,8 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
return NormalizeOrchestrationMode(command.Workflow.Settings.OrchestrationMode) switch
{
"handoff" => CopilotAgentBundle.CreateHandoffWorkflow(command.Workflow, agents),
"group-chat" => CopilotAgentBundle.CreateGroupChatWorkflow(command.Workflow, agents),
"handoff" => WorkflowOrchestrationFactory.CreateHandoffWorkflow(command.Workflow, agents),
"group-chat" => WorkflowOrchestrationFactory.CreateGroupChatWorkflow(command.Workflow, agents),
_ => (workflowRunner ?? new WorkflowRunner()).BuildWorkflow(command.Workflow, agents, command.WorkflowLibrary),
};
}
@@ -234,8 +219,8 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
}
internal static void ConfigureHookLifecycleEventSuppression(
CopilotTurnExecutionState state,
CopilotAgentBundle bundle)
TurnExecutionState state,
ProviderAgentBundle bundle)
{
ArgumentNullException.ThrowIfNull(state);
ArgumentNullException.ThrowIfNull(bundle);
@@ -244,17 +229,24 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
}
private static async Task EmitPendingEventsAsync(
CopilotTurnExecutionState state,
TurnExecutionState state,
Func<TurnDeltaEventDto, Task> onDelta,
Func<SidecarEventDto, Task> onEvent)
{
foreach (SidecarEventDto pendingEvent in state.DrainPendingEvents())
{
if (pendingEvent is TurnDeltaEventDto delta)
{
await onDelta(delta).ConfigureAwait(false);
continue;
}
await onEvent(pendingEvent).ConfigureAwait(false);
}
}
private static async Task EmitPendingMcpOauthRequestsAsync(
CopilotTurnExecutionState state,
TurnExecutionState state,
Func<McpOauthRequiredEventDto, Task> onMcpOAuthRequired)
{
foreach (McpOauthRequiredEventDto request in state.DrainPendingMcpOauthRequests())
@@ -267,14 +259,14 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
ResolveApprovalCommandDto command,
CancellationToken cancellationToken)
{
return _approvalCoordinator.ResolveApprovalAsync(command, cancellationToken);
return _providerTurnSupport.ResolveApprovalAsync(command, cancellationToken);
}
public Task ResolveUserInputAsync(
ResolveUserInputCommandDto command,
CancellationToken cancellationToken)
{
return _userInputCoordinator.ResolveUserInputAsync(command, cancellationToken);
return _providerTurnSupport.ResolveUserInputAsync(command, cancellationToken);
}
private async Task<bool> TryHandleRequestPortRequestAsync(
@@ -290,7 +282,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
}
UserInputRequest userInputRequest = CreateRequestPortUserInputRequest(metadata!, requestInfo);
UserInputResponse response = await _userInputCoordinator.RequestUserInputAsync(
UserInputResponse response = await _providerTurnSupport.RequestRequestPortUserInputAsync(
command,
userInputRequest,
onUserInput,
@@ -301,24 +293,32 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
return true;
}
private static async Task<bool> HandleWorkflowEventAsync(
internal static async Task<bool> HandleWorkflowEventAsync(
RunTurnCommandDto command,
WorkflowEvent evt,
IReadOnlyList<ChatMessage> inputMessages,
CopilotTurnExecutionState state,
TurnExecutionState state,
IProviderTranscriptProjector transcriptProjector,
Func<TurnDeltaEventDto, Task> onDelta,
Func<SidecarEventDto, Task> onEvent)
{
if (evt is ExecutorInvokedEvent invoked)
{
if (AgentIdentityResolver.TryResolveKnownAgentIdentity(
command.Workflow,
invoked.ExecutorId,
out AgentIdentity invokedAgent))
if (state.TryResolveKnownAgentIdentity(invoked.ExecutorId, out AgentIdentity invokedAgent))
{
TraceHandoff(command, $"Executor invoked: {invoked.ExecutorId} -> {invokedAgent.AgentName} ({invokedAgent.AgentId}).");
await state.EmitThinkingIfNeeded(invokedAgent, onEvent).ConfigureAwait(false);
}
else if (state.TryCreateSubworkflowLifecycleActivity(
"subworkflow-started",
invoked.ExecutorId,
out AgentActivityEventDto subworkflowStarted))
{
TraceHandoff(
command,
$"Sub-workflow executor invoked: {invoked.ExecutorId} -> {subworkflowStarted.SubworkflowName ?? subworkflowStarted.SubworkflowNodeId ?? "<unknown>"}.");
await EmitActivityAsync(command, state, subworkflowStarted, onEvent).ConfigureAwait(false);
}
else
{
TraceHandoff(command, $"Executor invoked without a known agent match: {invoked.ExecutorId}.");
@@ -333,7 +333,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
command,
requestInfo,
state.ActiveAgent,
state.ToolNamesByCallId);
state.ToolCalls);
if (activity is null)
{
@@ -368,16 +368,22 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
if (evt is ExecutorCompletedEvent completed)
{
if (AgentIdentityResolver.TryResolveObservedAgentIdentity(
command.Workflow,
completed.ExecutorId,
state.ActiveAgent,
out AgentIdentity completedAgent))
if (state.TryResolveObservedAgentIdentity(completed.ExecutorId, state.ActiveAgent, out AgentIdentity completedAgent))
{
TraceHandoff(command, $"Executor completed: {completed.ExecutorId} -> {completedAgent.AgentName} ({completedAgent.AgentId}).");
state.QueueCompletedActivity(completedAgent);
state.ClearActiveAgentIfMatching(completedAgent);
}
else if (state.TryCreateSubworkflowLifecycleActivity(
"subworkflow-completed",
completed.ExecutorId,
out AgentActivityEventDto subworkflowCompleted))
{
TraceHandoff(
command,
$"Sub-workflow executor completed: {completed.ExecutorId} -> {subworkflowCompleted.SubworkflowName ?? subworkflowCompleted.SubworkflowNodeId ?? "<unknown>"}.");
await EmitActivityAsync(command, state, subworkflowCompleted, onEvent).ConfigureAwait(false);
}
else
{
TraceHandoff(command, $"Executor completed without a known agent match: {completed.ExecutorId}.");
@@ -389,7 +395,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
if (evt is WorkflowOutputEvent outputEvent)
{
List<ChatMessage> allMessages = outputEvent.As<List<ChatMessage>>() ?? [];
state.UpdateCompletedMessages(allMessages, inputMessages);
state.UpdateCompletedMessages(allMessages, inputMessages, transcriptProjector);
}
return false;
@@ -466,7 +472,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
private static async Task HandleAgentResponseUpdateAsync(
RunTurnCommandDto command,
AgentResponseUpdateEvent update,
CopilotTurnExecutionState state,
TurnExecutionState state,
Func<TurnDeltaEventDto, Task> onDelta,
Func<SidecarEventDto, Task> onEvent)
{
@@ -483,11 +489,10 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
updateAgent = observedMessageAgent;
authorName = observedMessageAgent.AgentName;
}
else if (AgentIdentityResolver.TryResolveObservedAgentIdentity(
command.Workflow,
update.ExecutorId,
state.ActiveAgent,
out AgentIdentity resolvedUpdateAgent))
else if (state.TryResolveObservedAgentIdentity(
update.ExecutorId,
state.ActiveAgent,
out AgentIdentity resolvedUpdateAgent))
{
updateAgent = resolvedUpdateAgent;
authorName = resolvedUpdateAgent.AgentName;
@@ -525,10 +530,14 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
}
string messageId = state.CreateMessageId(update.Update.MessageId);
(string _, string currentAuthorName, string currentContent) = state.AppendDelta(
if (!state.TryAppendDelta(
messageId,
authorName,
update.Update.Text);
update.Update.Text,
out TranscriptSegment currentSegment))
{
return;
}
await onDelta(new TurnDeltaEventDto
{
@@ -536,15 +545,15 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
RequestId = command.RequestId,
SessionId = command.SessionId,
MessageId = messageId,
AuthorName = currentAuthorName,
AuthorName = currentSegment.AuthorName,
ContentDelta = update.Update.Text,
Content = currentContent,
Content = currentSegment.Content,
}).ConfigureAwait(false);
}
private static async Task EmitActivityAsync(
internal static async Task EmitActivityAsync(
RunTurnCommandDto command,
CopilotTurnExecutionState state,
TurnExecutionState state,
AgentActivityEventDto activity,
Func<SidecarEventDto, Task> onEvent)
{
@@ -558,11 +567,12 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
&& !string.IsNullOrWhiteSpace(activity.AgentId)
&& !string.IsNullOrWhiteSpace(activity.AgentName))
{
AgentIdentity promotedAgent = state.ResolveAgentIdentity(activity.AgentId, activity.AgentName);
TraceHandoff(
command,
$"Promoting handoff target to thinking: {activity.AgentName} ({activity.AgentId}).");
$"Promoting handoff target to thinking: {promotedAgent.AgentName} ({promotedAgent.AgentId}).");
await state.EmitThinkingIfNeeded(
new AgentIdentity(activity.AgentId, activity.AgentName),
promotedAgent,
onEvent).ConfigureAwait(false);
}
}
@@ -597,7 +607,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
private static bool TryCreateWorkflowDiagnosticEvent(
RunTurnCommandDto command,
WorkflowEvent evt,
CopilotTurnExecutionState state,
TurnExecutionState state,
out WorkflowDiagnosticEventDto diagnostic)
{
diagnostic = default!;
@@ -606,8 +616,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
{
case ExecutorFailedEvent executorFailed:
{
AgentIdentity? agent = AgentIdentityResolver.TryResolveObservedAgentIdentity(
command.Workflow,
AgentIdentity? agent = state.TryResolveObservedAgentIdentity(
executorFailed.ExecutorId,
state.ActiveAgent,
out AgentIdentity resolvedAgent)
@@ -1,668 +1,24 @@
using System.Collections.Concurrent;
using Aryx.AgentHost.Contracts;
using GitHub.Copilot.SDK;
using Microsoft.Extensions.AI;
namespace Aryx.AgentHost.Services;
internal sealed class CopilotTurnExecutionState
internal sealed class CopilotTurnExecutionState : TurnExecutionState
{
private readonly RunTurnCommandDto _command;
private readonly HashSet<string> _startedAgents = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _reclassifiedMessageIds = new(StringComparer.Ordinal);
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;
private string? _lastObservedMessageId;
public CopilotTurnExecutionState(RunTurnCommandDto command)
: base(command)
{
_command = command;
}
public ConcurrentDictionary<string, string> ToolNamesByCallId { get; } = new(StringComparer.Ordinal);
public AgentIdentity? ActiveAgent { get; private set; }
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<SidecarEventDto, Task> onEvent)
{
AgentActivityEventDto? thinkingActivity = CreateThinkingActivityIfNeeded(agent);
if (thinkingActivity is null)
{
return;
}
await onEvent(thinkingActivity).ConfigureAwait(false);
}
public void QueueThinkingIfNeeded(AgentIdentity agent)
{
AgentActivityEventDto? thinkingActivity = CreateThinkingActivityIfNeeded(agent);
if (thinkingActivity is not null)
{
_pendingEvents.Enqueue(thinkingActivity);
}
}
public void QueueCompletedActivity(AgentIdentity agent)
{
_pendingEvents.Enqueue(CreateCompletedActivity(agent));
}
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))
{
ActiveAgent = new AgentIdentity(activity.AgentId, activity.AgentName);
}
}
public void ObserveSessionEvent(WorkflowNodeDto agentDefinition, SessionEvent sessionEvent)
{
AgentIdentity agent = AgentIdentityResolver.ResolveAgentIdentity(
_command.Workflow,
agentDefinition.GetAgentId(),
agentDefinition.GetAgentName());
switch (sessionEvent)
{
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);
if (assistantMessage.Data?.ToolRequests is { Length: > 0 })
{
QueueMessageReclassifiedIfNeeded(assistantMessage.Data.MessageId);
}
break;
case ToolExecutionStartEvent toolExecutionStart
when !string.IsNullOrWhiteSpace(toolExecutionStart.Data?.ToolCallId)
&& !string.IsNullOrWhiteSpace(toolExecutionStart.Data?.ToolName):
string toolCallId = toolExecutionStart.Data.ToolCallId.Trim();
string toolName = toolExecutionStart.Data.ToolName.Trim();
ToolNamesByCallId[toolCallId] = toolName;
ActiveAgent = agent;
AgentActivityEventDto? toolActivity = CreateToolCallingActivity(agent, toolName, toolCallId);
if (toolActivity is not null)
{
_pendingEvents.Enqueue(toolActivity);
}
QueueMessageReclassifiedIfNeeded(_lastObservedMessageId);
break;
case AssistantIntentEvent intentEvent:
ActiveAgent = agent;
QueueThinkingIfNeeded(agent);
AssistantIntentEventDto? assistantIntent = CreateAssistantIntentEvent(agent, intentEvent.Data);
if (assistantIntent is not null)
{
_pendingEvents.Enqueue(assistantIntent);
}
break;
case AssistantReasoningDeltaEvent reasoningDelta:
ActiveAgent = agent;
QueueThinkingIfNeeded(agent);
ReasoningDeltaEventDto? reasoningDeltaEvent = CreateReasoningDeltaEvent(agent, reasoningDelta.Data);
if (reasoningDeltaEvent is not null)
{
_pendingEvents.Enqueue(reasoningDeltaEvent);
}
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 AssistantUsageEvent assistantUsage:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateAssistantUsageEvent(agent, assistantUsage.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;
return !string.IsNullOrWhiteSpace(messageId)
&& _observedAgentsByMessageId.TryGetValue(messageId, out agent);
}
public string CreateMessageId(string? messageId)
{
return messageId ?? $"{_command.RequestId}-delta-{_fallbackMessageIndex++}";
}
public TranscriptSegment AppendDelta(
string messageId,
string authorName,
string delta)
{
return _transcriptBuffer.AppendDelta(messageId, authorName, delta);
}
public void ClearActiveAgentIfMatching(AgentIdentity completedAgent)
{
if (ActiveAgent.HasValue
&& string.Equals(ActiveAgent.Value.AgentId, completedAgent.AgentId, StringComparison.Ordinal))
{
ActiveAgent = null;
}
}
private void RecordObservedAgentForMessage(AgentIdentity agent, string messageId)
{
ActiveAgent = agent;
_observedAgentsByMessageId[messageId] = agent;
_lastObservedMessageId = messageId;
}
private void QueueMessageReclassifiedIfNeeded(string? messageId)
{
if (string.IsNullOrWhiteSpace(messageId))
{
return;
}
string normalizedMessageId = messageId.Trim();
if (!_reclassifiedMessageIds.Add(normalizedMessageId))
{
return;
}
_pendingEvents.Enqueue(CreateMessageReclassifiedEvent(normalizedMessageId));
}
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,
};
}
private AgentActivityEventDto? CreateToolCallingActivity(
AgentIdentity agent,
string toolName,
string toolCallId)
{
if (toolName.StartsWith("handoff_to_", StringComparison.Ordinal))
{
return null;
}
return new AgentActivityEventDto
{
Type = "agent-activity",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
ActivityType = "tool-calling",
AgentId = agent.AgentId,
AgentName = agent.AgentName,
ToolName = toolName,
ToolCallId = toolCallId,
};
}
private AgentActivityEventDto CreateCompletedActivity(AgentIdentity agent)
{
return new AgentActivityEventDto
{
Type = "agent-activity",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
ActivityType = "completed",
AgentId = agent.AgentId,
AgentName = agent.AgentName,
};
}
private MessageReclassifiedEventDto CreateMessageReclassifiedEvent(string messageId)
{
return new MessageReclassifiedEventDto
{
Type = "message-reclassified",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
MessageId = messageId,
NewKind = "thinking",
};
}
public void UpdateCompletedMessages(
IReadOnlyList<ChatMessage> allMessages,
IReadOnlyList<ChatMessage> inputMessages)
{
List<ChatMessage> newMessages = WorkflowTranscriptProjector.SelectNewOutputMessages(allMessages, inputMessages);
CompletedMessages = WorkflowTranscriptProjector.ProjectCompletedMessagesFromSegments(
_command,
newMessages,
_transcriptBuffer.Snapshot(),
ActiveAgent);
base.UpdateCompletedMessages(allMessages, inputMessages, CopilotTranscriptProjector.Instance);
}
public IReadOnlyList<ChatMessageDto> FinalizeCompletedMessages()
{
if (CompletedMessages.Count == 0 && _transcriptBuffer.Count > 0)
{
CompletedMessages = WorkflowTranscriptProjector.ProjectCompletedMessagesFromSegments(
_command,
[],
_transcriptBuffer.Snapshot(),
ActiveAgent);
}
foreach (ChatMessageDto message in CompletedMessages)
{
if (_reclassifiedMessageIds.Contains(message.Id))
{
message.MessageKind = "thinking";
}
}
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 AssistantIntentEventDto? CreateAssistantIntentEvent(
AgentIdentity agent,
AssistantIntentData? data)
{
string? intent = data?.Intent?.Trim();
if (string.IsNullOrWhiteSpace(intent))
{
return null;
}
return new AssistantIntentEventDto
{
Type = "assistant-intent",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
Intent = intent,
};
}
private ReasoningDeltaEventDto? CreateReasoningDeltaEvent(
AgentIdentity agent,
AssistantReasoningDeltaData? data)
{
if (data is null
|| string.IsNullOrWhiteSpace(data.ReasoningId)
|| string.IsNullOrEmpty(data.DeltaContent))
{
return null;
}
return new ReasoningDeltaEventDto
{
Type = "reasoning-delta",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
ReasoningId = data.ReasoningId,
ContentDelta = data.DeltaContent,
};
}
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 AssistantUsageEventDto CreateAssistantUsageEvent(
AgentIdentity agent,
AssistantUsageData? data)
{
return new AssistantUsageEventDto
{
Type = "assistant-usage",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
Model = data?.Model ?? string.Empty,
InputTokens = data?.InputTokens,
OutputTokens = data?.OutputTokens,
CacheReadTokens = data?.CacheReadTokens,
CacheWriteTokens = data?.CacheWriteTokens,
Cost = data?.Cost,
Duration = data?.Duration,
TotalNanoAiu = data?.CopilotUsage?.TotalNanoAiu,
QuotaSnapshots = QuotaSnapshotMapper.MapOrNull(data?.QuotaSnapshots),
};
}
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,
};
return base.FinalizeCompletedMessages(CopilotTranscriptProjector.Instance);
}
}
@@ -0,0 +1,12 @@
using Aryx.AgentHost.Contracts;
namespace Aryx.AgentHost.Services;
internal interface IAgentProvider
{
ITurnWorkflowRunner CreateWorkflowRunner(WorkflowValidator workflowValidator);
Task<SidecarCapabilitiesDto> GetCapabilitiesAsync(CancellationToken cancellationToken);
IProviderSessionManager CreateSessionManager();
}
@@ -0,0 +1,10 @@
using Aryx.AgentHost.Contracts;
namespace Aryx.AgentHost.Services;
internal interface IProviderEventAdapter
{
ProviderTurnStreamCapabilities Capabilities { get; }
ProviderSessionEvent? TryAdapt(object rawEvent);
}
@@ -0,0 +1,21 @@
using Aryx.AgentHost.Contracts;
using Microsoft.Extensions.AI;
namespace Aryx.AgentHost.Services;
internal interface IProviderTranscriptProjector
{
ChatMessage ToChatMessage(ChatMessageDto message);
void AttachMessageMode(IList<ChatMessage> messages, string? messageMode);
List<ChatMessage> SelectNewOutputMessages(
IReadOnlyList<ChatMessage> outputMessages,
IReadOnlyList<ChatMessage> inputMessages);
List<ChatMessageDto> ProjectCompletedMessagesFromSegments(
RunTurnCommandDto command,
IReadOnlyList<ChatMessage> newMessages,
IReadOnlyList<TranscriptSegment> segments,
AgentIdentity? fallbackAgent = null);
}
@@ -0,0 +1,34 @@
using Aryx.AgentHost.Contracts;
using GitHub.Copilot.SDK;
namespace Aryx.AgentHost.Services;
internal interface IProviderTurnSupport
{
Task<ProviderAgentBundle> CreateAgentBundleAsync(
RunTurnCommandDto command,
TurnExecutionState state,
Func<SidecarEventDto, Task> onEvent,
Func<ApprovalRequestedEventDto, Task> onApproval,
Func<UserInputRequestedEventDto, Task> onUserInput,
CancellationTokenSource runCancellation,
CancellationToken cancellationToken);
Task ResolveApprovalAsync(
ResolveApprovalCommandDto command,
CancellationToken cancellationToken);
Task ResolveUserInputAsync(
ResolveUserInputCommandDto command,
CancellationToken cancellationToken);
Task<UserInputResponse> RequestRequestPortUserInputAsync(
RunTurnCommandDto command,
UserInputRequest request,
Func<UserInputRequestedEventDto, Task> onUserInput,
CancellationToken cancellationToken);
ExitPlanModeRequestedEventDto? ConsumePendingExitPlanModeRequest(string requestId);
void ClearRequestState(string requestId);
}
@@ -0,0 +1,137 @@
using System.Collections;
using OpenTelemetry;
using OpenTelemetry.Exporter;
using OpenTelemetry.Resources;
using OpenTelemetry.Trace;
namespace Aryx.AgentHost.Services;
internal static class OpenTelemetrySetup
{
private const string ServiceName = "Aryx.AgentHost";
private const string EndpointEnvironmentVariableName = "OTEL_EXPORTER_OTLP_ENDPOINT";
private const string ProtocolEnvironmentVariableName = "OTEL_EXPORTER_OTLP_PROTOCOL";
private static readonly string[] ActivitySourceNames =
[
"Experimental.Microsoft.Agents.AI",
"Microsoft.Agents.AI.Workflows",
];
public static TracerProvider? CreateTracerProviderFromEnvironment(TextWriter? diagnosticsWriter = null)
{
return CreateTracerProvider(ReadEnvironmentVariables(), diagnosticsWriter ?? Console.Error);
}
internal static TracerProvider? CreateTracerProvider(
IEnumerable<KeyValuePair<string, string?>> environmentVariables,
TextWriter diagnosticsWriter)
{
ArgumentNullException.ThrowIfNull(environmentVariables);
ArgumentNullException.ThrowIfNull(diagnosticsWriter);
OpenTelemetryTracingConfiguration? configuration = ResolveTracingConfiguration(environmentVariables);
if (configuration is null)
{
return null;
}
diagnosticsWriter.WriteLine(
$"Aryx.AgentHost OpenTelemetry tracing enabled ({configuration.ProtocolLabel}) -> {configuration.Endpoint}.");
return Sdk.CreateTracerProviderBuilder()
.SetResourceBuilder(
ResourceBuilder.CreateDefault()
.AddService(ServiceName, serviceVersion: ResolveServiceVersion()))
.AddSource(configuration.ActivitySourceNames.ToArray())
.AddOtlpExporter(options =>
{
options.Endpoint = configuration.Endpoint;
options.Protocol = configuration.Protocol;
})
.Build();
}
internal static OpenTelemetryTracingConfiguration? ResolveTracingConfiguration(
IEnumerable<KeyValuePair<string, string?>> environmentVariables)
{
ArgumentNullException.ThrowIfNull(environmentVariables);
string? endpointValue = ReadSetting(environmentVariables, EndpointEnvironmentVariableName);
if (string.IsNullOrWhiteSpace(endpointValue))
{
return null;
}
if (!Uri.TryCreate(endpointValue, UriKind.Absolute, out Uri? endpoint)
|| (endpoint.Scheme != Uri.UriSchemeHttp && endpoint.Scheme != Uri.UriSchemeHttps))
{
throw new InvalidOperationException(
$"{EndpointEnvironmentVariableName} must be an absolute http or https URL. Received '{endpointValue}'.");
}
OtlpExportProtocol protocol = ResolveProtocol(ReadSetting(environmentVariables, ProtocolEnvironmentVariableName));
return new OpenTelemetryTracingConfiguration(endpoint, protocol, ActivitySourceNames);
}
private static string? ResolveServiceVersion()
{
return typeof(OpenTelemetrySetup).Assembly.GetName().Version?.ToString();
}
private static IEnumerable<KeyValuePair<string, string?>> ReadEnvironmentVariables()
{
return Environment.GetEnvironmentVariables()
.Cast<DictionaryEntry>()
.Select(entry => new KeyValuePair<string, string?>(
entry.Key?.ToString() ?? string.Empty,
entry.Value?.ToString()));
}
private static string? ReadSetting(
IEnumerable<KeyValuePair<string, string?>> environmentVariables,
string name)
{
foreach (KeyValuePair<string, string?> entry in environmentVariables)
{
if (!string.Equals(entry.Key, name, StringComparison.OrdinalIgnoreCase))
{
continue;
}
return string.IsNullOrWhiteSpace(entry.Value)
? null
: entry.Value.Trim();
}
return null;
}
private static OtlpExportProtocol ResolveProtocol(string? protocolValue)
{
if (string.IsNullOrWhiteSpace(protocolValue))
{
return OtlpExportProtocol.Grpc;
}
return protocolValue.Trim().ToLowerInvariant() switch
{
"grpc" => OtlpExportProtocol.Grpc,
"http/protobuf" => OtlpExportProtocol.HttpProtobuf,
_ => throw new InvalidOperationException(
$"{ProtocolEnvironmentVariableName} must be 'grpc' or 'http/protobuf'. Received '{protocolValue}'."),
};
}
}
internal sealed record OpenTelemetryTracingConfiguration(
Uri Endpoint,
OtlpExportProtocol Protocol,
IReadOnlyList<string> ActivitySourceNames)
{
public string ProtocolLabel => Protocol switch
{
OtlpExportProtocol.HttpProtobuf => "http/protobuf",
_ => "grpc",
};
}
@@ -0,0 +1,14 @@
using Microsoft.Agents.AI;
namespace Aryx.AgentHost.Services;
internal abstract class ProviderAgentBundle : IAsyncDisposable
{
public abstract IReadOnlyList<AIAgent> Agents { get; }
public abstract bool HasConfiguredHooks { get; }
public abstract IProviderTranscriptProjector TranscriptProjector { get; }
public abstract ValueTask DisposeAsync();
}
@@ -8,7 +8,7 @@ using Microsoft.Extensions.AI;
namespace Aryx.AgentHost.Services;
internal sealed class CopilotAgentBundle : IAsyncDisposable
internal sealed class CopilotAgentBundle : ProviderAgentBundle
{
private static readonly string[] RequiredPromptTools =
[
@@ -25,9 +25,11 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
HasConfiguredHooks = hasConfiguredHooks;
}
public IReadOnlyList<AIAgent> Agents { get; }
public override IReadOnlyList<AIAgent> Agents { get; }
public bool HasConfiguredHooks { get; }
public override bool HasConfiguredHooks { get; }
public override IProviderTranscriptProjector TranscriptProjector { get; } = CopilotTranscriptProjector.Instance;
public static async Task<CopilotAgentBundle> CreateAsync(
RunTurnCommandDto command,
@@ -85,8 +87,13 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
name: definition.GetAgentName(),
description: NormalizeOptionalString(definition.Config.Description));
agents.Add(agent);
AIAgent instrumentedAgent = new AIAgentBuilder(agent).UseOpenTelemetry().Build();
agents.Add(instrumentedAgent);
disposables.Add(agent);
if (instrumentedAgent is IDisposable instrumentedDisposable)
{
disposables.Add(new SyncDisposableAdapter(instrumentedDisposable));
}
}
// The bundle owns the shared client — disposed after all agents.
@@ -215,116 +222,38 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
internal static AIAgentHostOptions CreateAgentHostOptions()
{
return new AIAgentHostOptions
{
EmitAgentUpdateEvents = null,
EmitAgentResponseEvents = false,
InterceptUserInputRequests = false,
InterceptUnterminatedFunctionCalls = false,
ReassignOtherAgentsAsUsers = true,
ForwardIncomingMessages = true,
};
return AgentHostOptionsFactory.CreateDefault();
}
internal static HandoffsWorkflowBuilder CreateHandoffWorkflowBuilder(
internal static HandoffWorkflowBuilder CreateHandoffWorkflowBuilder(
AIAgent entryAgent,
HandoffModeSettingsDto? settings = null)
{
HandoffModeSettingsDto effectiveSettings = settings ?? new HandoffModeSettingsDto();
HandoffsWorkflowBuilder builder = AgentWorkflowBuilder.CreateHandoffBuilderWith(entryAgent)
.WithToolCallFilteringBehavior(MapHandoffToolCallFiltering(effectiveSettings.ToolCallFiltering))
.WithHandoffInstructions(NormalizeOptionalString(effectiveSettings.HandoffInstructions)
?? HandoffWorkflowGuidance.CreateWorkflowInstructions());
if (effectiveSettings.ReturnToPrevious)
{
TryEnableReturnToPrevious(builder);
}
return builder;
return WorkflowOrchestrationFactory.CreateHandoffWorkflowBuilder(entryAgent, settings);
}
internal static Workflow CreateHandoffWorkflow(
WorkflowDefinitionDto workflowDefinition,
IReadOnlyList<AIAgent> agents)
{
ArgumentNullException.ThrowIfNull(workflowDefinition);
ArgumentNullException.ThrowIfNull(agents);
IReadOnlyList<WorkflowNodeDto> agentNodes = workflowDefinition.GetAgentNodes();
Dictionary<string, AIAgent> agentsById = CreateAgentMap(agents);
WorkflowNodeDto triageNode = ResolveTriageAgentNode(workflowDefinition, agentNodes);
AIAgent triageAgent = ResolveAgentForNode(triageNode, agentsById);
HandoffModeSettingsDto? settings = workflowDefinition.Settings.ModeSettings?.Handoff;
HandoffsWorkflowBuilder builder = CreateHandoffWorkflowBuilder(triageAgent, settings);
List<WorkflowNodeDto> specialistNodes = agentNodes
.Where(node => !string.Equals(node.Id, triageNode.Id, StringComparison.Ordinal))
.ToList();
if (specialistNodes.Count == 0)
{
throw new InvalidOperationException("Handoff workflows require at least one specialist agent in addition to the triage agent.");
}
foreach (WorkflowNodeDto specialistNode in specialistNodes)
{
AIAgent specialistAgent = ResolveAgentForNode(specialistNode, agentsById);
builder.WithHandoff(
triageAgent,
specialistAgent,
HandoffWorkflowGuidance.CreateForwardReason(specialistNode));
if (settings?.ReturnToPrevious != true)
{
builder.WithHandoff(
specialistAgent,
triageAgent,
HandoffWorkflowGuidance.CreateReturnReason(triageNode));
}
}
return builder.Build();
return WorkflowOrchestrationFactory.CreateHandoffWorkflow(workflowDefinition, agents);
}
internal static GroupChatWorkflowBuilder CreateGroupChatWorkflowBuilder(
WorkflowDefinitionDto workflowDefinition,
IReadOnlyList<AIAgent> agents)
{
ArgumentNullException.ThrowIfNull(workflowDefinition);
ArgumentNullException.ThrowIfNull(agents);
int maxRounds = ResolveGroupChatMaxRounds(workflowDefinition);
GroupChatWorkflowBuilder builder = AgentWorkflowBuilder.CreateGroupChatBuilderWith(
participants => new RoundRobinGroupChatManager(participants)
{
MaximumIterationCount = maxRounds,
})
.AddParticipants(agents);
string? name = NormalizeOptionalString(workflowDefinition.Name);
if (name is not null)
{
builder.WithName(name);
}
string? description = NormalizeOptionalString(workflowDefinition.Description);
if (description is not null)
{
builder.WithDescription(description);
}
return builder;
return WorkflowOrchestrationFactory.CreateGroupChatWorkflowBuilder(workflowDefinition, agents);
}
internal static Workflow CreateGroupChatWorkflow(
WorkflowDefinitionDto workflowDefinition,
IReadOnlyList<AIAgent> agents)
{
return CreateGroupChatWorkflowBuilder(workflowDefinition, agents).Build();
return WorkflowOrchestrationFactory.CreateGroupChatWorkflow(workflowDefinition, agents);
}
public async ValueTask DisposeAsync()
public override async ValueTask DisposeAsync()
{
foreach (IAsyncDisposable disposable in _disposables)
{
@@ -380,97 +309,6 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
.ToList();
}
private static Dictionary<string, AIAgent> CreateAgentMap(IReadOnlyList<AIAgent> agents)
{
Dictionary<string, AIAgent> agentMap = new(StringComparer.OrdinalIgnoreCase);
foreach (AIAgent agent in agents)
{
if (!string.IsNullOrWhiteSpace(agent.Id))
{
agentMap[agent.Id] = agent;
}
if (!string.IsNullOrWhiteSpace(agent.Name))
{
agentMap[agent.Name] = agent;
}
}
return agentMap;
}
private static AIAgent ResolveAgentForNode(
WorkflowNodeDto node,
IReadOnlyDictionary<string, AIAgent> agentsById)
{
string agentId = node.GetAgentId();
if (agentsById.TryGetValue(agentId, out AIAgent? agent))
{
return agent;
}
string agentName = node.GetAgentName();
if (agentsById.TryGetValue(agentName, out agent))
{
return agent;
}
throw new InvalidOperationException($"Workflow agent \"{agentId}\" could not be resolved from the constructed Copilot agents.");
}
private static WorkflowNodeDto ResolveTriageAgentNode(
WorkflowDefinitionDto workflowDefinition,
IReadOnlyList<WorkflowNodeDto> agentNodes)
{
if (agentNodes.Count == 0)
{
throw new InvalidOperationException("Handoff workflows require at least one agent node.");
}
string? triageAgentNodeId = NormalizeOptionalString(workflowDefinition.Settings.ModeSettings?.Handoff?.TriageAgentNodeId);
if (triageAgentNodeId is null)
{
return agentNodes[0];
}
WorkflowNodeDto? triageNode = agentNodes.FirstOrDefault(node => string.Equals(node.Id, triageAgentNodeId, StringComparison.Ordinal));
return triageNode ?? throw new InvalidOperationException(
$"Handoff workflow triage agent node \"{triageAgentNodeId}\" was not found in the workflow graph.");
}
private static HandoffToolCallFilteringBehavior MapHandoffToolCallFiltering(string? value)
{
return value?.Trim().ToLowerInvariant() switch
{
"none" => HandoffToolCallFilteringBehavior.None,
"all" => HandoffToolCallFilteringBehavior.All,
_ => HandoffToolCallFilteringBehavior.HandoffOnly,
};
}
private static void TryEnableReturnToPrevious(HandoffsWorkflowBuilder builder)
{
builder.GetType()
.GetMethod("EnableReturnToPrevious", Type.EmptyTypes)?
.Invoke(builder, null);
}
private static int ResolveGroupChatMaxRounds(WorkflowDefinitionDto workflowDefinition)
{
int? configuredMaxRounds = workflowDefinition.Settings.ModeSettings?.GroupChat?.MaxRounds;
if (configuredMaxRounds is > 0)
{
return configuredMaxRounds.Value;
}
if (workflowDefinition.Settings.MaxIterations is > 0)
{
return workflowDefinition.Settings.MaxIterations.Value;
}
return 5;
}
private static string? ResolveEffectiveAgent(
string? defaultAgent,
RunTurnPromptInvocationDto? promptInvocation)
@@ -495,4 +333,17 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
/// <summary>
/// Adapts a synchronous <see cref="IDisposable"/> to <see cref="IAsyncDisposable"/>
/// so it can be tracked in the async disposal pipeline.
/// </summary>
private sealed class SyncDisposableAdapter(IDisposable inner) : IAsyncDisposable
{
public ValueTask DisposeAsync()
{
inner.Dispose();
return ValueTask.CompletedTask;
}
}
}
@@ -0,0 +1,288 @@
using GitHub.Copilot.SDK;
using GitHub.Copilot.SDK.Rpc;
using Aryx.AgentHost.Contracts;
namespace Aryx.AgentHost.Services;
internal sealed class CopilotAgentProvider : IAgentProvider
{
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 =
[
"login",
"log in",
"sign in",
"authenticate",
"authentication",
"not signed in",
"not logged in",
"reauth",
"credential",
];
public ITurnWorkflowRunner CreateWorkflowRunner(WorkflowValidator workflowValidator)
{
ArgumentNullException.ThrowIfNull(workflowValidator);
return new AgentWorkflowTurnRunner(new CopilotTurnRunnerSupport(), workflowValidator);
}
public Task<SidecarCapabilitiesDto> GetCapabilitiesAsync(CancellationToken cancellationToken)
{
return BuildCapabilitiesAsync(cancellationToken);
}
public IProviderSessionManager CreateSessionManager()
{
return new CopilotSessionManager();
}
private static async Task<SidecarCapabilitiesDto> BuildCapabilitiesAsync(CancellationToken cancellationToken)
{
try
{
CopilotCliContext cliContext = CopilotCliPathResolver.ResolveCliContext();
CapabilityProbeResult probe = await ProbeCapabilitiesAsync(cliContext, cancellationToken).ConfigureAwait(false);
return CreateCapabilities(probe.Models, probe.RuntimeTools, probe.Connection);
}
catch (Exception exception)
{
SidecarConnectionDiagnosticsDto connection = CreateMissingCliDiagnostics(exception);
Console.Error.WriteLine($"[aryx sidecar] {connection.Summary} {exception.Message}");
return CreateCapabilities([], [], connection);
}
}
private static async Task<CapabilityProbeResult> ProbeCapabilitiesAsync(
CopilotCliContext cliContext,
CancellationToken cancellationToken)
{
IReadOnlyList<SidecarModelCapabilityDto> models = [];
IReadOnlyList<SidecarRuntimeToolDto> runtimeTools = [];
SidecarCopilotAccountDiagnosticsDto? account = null;
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null;
Task<SidecarCopilotCliVersionDiagnosticsDto> cliVersionTask =
CopilotConnectionMetadataResolver.GetCliVersionDiagnosticsAsync(cliContext, cancellationToken);
try
{
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions(cliContext);
await using CopilotClient client = new(clientOptions);
await client.StartAsync(cancellationToken).ConfigureAwait(false);
GetAuthStatusResponse? authStatus =
await CopilotConnectionMetadataResolver.TryGetAuthStatusAsync(client, cancellationToken).ConfigureAwait(false);
account = await CopilotConnectionMetadataResolver.CreateAccountDiagnosticsAsync(
authStatus,
cliContext.Environment,
cancellationToken)
.ConfigureAwait(false);
models = await ListAvailableModelsAsync(client, cancellationToken).ConfigureAwait(false);
runtimeTools = await TryListAvailableRuntimeToolsAsync(client, cancellationToken).ConfigureAwait(false);
cliVersion = await cliVersionTask.ConfigureAwait(false);
return new CapabilityProbeResult(
models,
runtimeTools,
CreateReadyConnectionDiagnostics(cliContext.CliPath, models.Count, cliVersion, account));
}
catch (Exception exception)
{
cliVersion = await cliVersionTask.ConfigureAwait(false);
Console.Error.WriteLine($"[aryx sidecar] Failed to list available Copilot models: {exception.Message}");
return new CapabilityProbeResult(
models,
runtimeTools,
CreateFailureConnectionDiagnostics(cliContext.CliPath, exception, cliVersion, account));
}
}
private static SidecarCapabilitiesDto CreateCapabilities(
IReadOnlyList<SidecarModelCapabilityDto> models,
IReadOnlyList<SidecarRuntimeToolDto> runtimeTools,
SidecarConnectionDiagnosticsDto connection)
{
return new SidecarCapabilitiesDto
{
Modes = BuildModeCapabilities(),
Models = models,
RuntimeTools = runtimeTools,
Connection = connection,
};
}
private static Dictionary<string, SidecarModeCapabilityDto> BuildModeCapabilities()
{
return new Dictionary<string, SidecarModeCapabilityDto>(StringComparer.OrdinalIgnoreCase)
{
["single"] = new() { Available = true },
["sequential"] = new() { Available = true },
["concurrent"] = new() { Available = true },
["handoff"] = new() { Available = true },
["group-chat"] = new() { Available = true },
["magentic"] = new()
{
Available = false,
Reason = "Microsoft Agent Framework currently documents Magentic orchestration as unsupported in C#.",
},
};
}
private static async Task<IReadOnlyList<SidecarModelCapabilityDto>> ListAvailableModelsAsync(
CopilotClient client,
CancellationToken cancellationToken)
{
List<ModelInfo> models = await client.ListModelsAsync(cancellationToken).ConfigureAwait(false);
return models
.Select(model => new SidecarModelCapabilityDto
{
Id = model.Id,
Name = model.Name,
SupportedReasoningEfforts = (model.SupportedReasoningEfforts ?? [])
.Where(IsReasoningEffort)
.Distinct(StringComparer.Ordinal)
.ToList(),
DefaultReasoningEffort = IsReasoningEffort(model.DefaultReasoningEffort)
? model.DefaultReasoningEffort
: null,
})
.OrderBy(model => model.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static async Task<IReadOnlyList<SidecarRuntimeToolDto>> TryListAvailableRuntimeToolsAsync(
CopilotClient client,
CancellationToken cancellationToken)
{
try
{
return await ListAvailableRuntimeToolsAsync(client, cancellationToken).ConfigureAwait(false);
}
catch (Exception exception)
{
Console.Error.WriteLine($"[aryx sidecar] Failed to list available Copilot runtime tools: {exception.Message}");
return [];
}
}
private static async Task<IReadOnlyList<SidecarRuntimeToolDto>> ListAvailableRuntimeToolsAsync(
CopilotClient client,
CancellationToken cancellationToken)
{
ToolsListResult result = await client.Rpc.Tools.ListAsync(null!, cancellationToken).ConfigureAwait(false);
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
{
Id = tool.Name.Trim(),
Label = tool.Name.Trim(),
Description = string.IsNullOrWhiteSpace(tool.Description) ? null : tool.Description.Trim(),
})
.DistinctBy(tool => tool.Id, StringComparer.OrdinalIgnoreCase)
.OrderBy(tool => tool.Label, StringComparer.OrdinalIgnoreCase)
.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";
}
internal static SidecarConnectionDiagnosticsDto CreateMissingCliDiagnostics(Exception exception)
{
return new SidecarConnectionDiagnosticsDto
{
Status = "copilot-cli-missing",
Summary = "GitHub Copilot CLI is not installed or is not available on PATH.",
Detail = exception.Message,
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
};
}
internal static SidecarConnectionDiagnosticsDto CreateReadyConnectionDiagnostics(
string cliPath,
int modelCount,
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null,
SidecarCopilotAccountDiagnosticsDto? account = null)
{
string summary = modelCount switch
{
0 => "Connected to GitHub Copilot, but no models were reported.",
1 => "Connected to GitHub Copilot. 1 model is available.",
_ => $"Connected to GitHub Copilot. {modelCount} models are available.",
};
return new SidecarConnectionDiagnosticsDto
{
Status = "ready",
Summary = summary,
Detail = $"Using Copilot CLI at {cliPath}.",
CopilotCliPath = cliPath,
CopilotCliVersion = cliVersion,
Account = account,
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
};
}
internal static SidecarConnectionDiagnosticsDto CreateFailureConnectionDiagnostics(
string? cliPath,
Exception exception,
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null,
SidecarCopilotAccountDiagnosticsDto? account = null)
{
string status = ClassifyConnectionStatus(exception);
string summary = status == "copilot-auth-required"
? "GitHub Copilot requires authentication before Aryx can load models."
: "GitHub Copilot was found, but Aryx could not load its model list.";
return new SidecarConnectionDiagnosticsDto
{
Status = status,
Summary = summary,
Detail = exception.Message,
CopilotCliPath = cliPath,
CopilotCliVersion = cliVersion,
Account = account,
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
};
}
internal static string ClassifyConnectionStatus(Exception exception)
{
string message = exception.Message;
if (AuthenticationErrorIndicators.Any(indicator =>
message.Contains(indicator, StringComparison.OrdinalIgnoreCase)))
{
return "copilot-auth-required";
}
return "copilot-error";
}
private sealed record CapabilityProbeResult(
IReadOnlyList<SidecarModelCapabilityDto> Models,
IReadOnlyList<SidecarRuntimeToolDto> RuntimeTools,
SidecarConnectionDiagnosticsDto Connection);
}
@@ -70,7 +70,7 @@ internal sealed class CopilotApprovalCoordinator
WorkflowNodeDto agent,
PermissionRequest request,
PermissionInvocation invocation,
IReadOnlyDictionary<string, string> toolNamesByCallId,
ToolCallRegistry toolCalls,
Func<ApprovalRequestedEventDto, Task> onApproval,
CancellationToken cancellationToken)
{
@@ -79,7 +79,7 @@ internal sealed class CopilotApprovalCoordinator
agent,
request,
invocation,
toolNamesByCallId,
toolCalls,
onActivity: null,
onApproval,
cancellationToken)
@@ -91,12 +91,12 @@ internal sealed class CopilotApprovalCoordinator
WorkflowNodeDto agent,
PermissionRequest request,
PermissionInvocation invocation,
IReadOnlyDictionary<string, string> toolNamesByCallId,
ToolCallRegistry toolCalls,
Func<AgentActivityEventDto, Task>? onActivity,
Func<ApprovalRequestedEventDto, Task> onApproval,
CancellationToken cancellationToken)
{
string? toolName = ResolveApprovalToolName(request, toolNamesByCallId);
string? toolName = ResolveApprovalToolName(request, toolCalls);
string? autoApprovedToolName = ResolveAutoApprovedToolName(request);
string? mcpServerApprovalKey = ResolveMcpServerApprovalKey(request, command.Tooling?.McpServers);
string? approvalCacheKey = ResolveApprovalCacheKey(toolName, autoApprovedToolName);
@@ -339,15 +339,15 @@ internal sealed class CopilotApprovalCoordinator
internal static bool TryGetApprovalToolName(
PermissionRequest request,
IReadOnlyDictionary<string, string>? toolNamesByCallId,
ToolCallRegistry? toolCalls,
out string? toolName)
{
toolName = ResolveApprovalToolName(request, toolNamesByCallId);
toolName = ResolveApprovalToolName(request, toolCalls);
return toolName is not null;
}
internal static bool TryGetApprovalToolName(PermissionRequest request, out string? toolName)
=> TryGetApprovalToolName(request, toolNamesByCallId: null, out toolName);
=> TryGetApprovalToolName(request, toolCalls: null, out toolName);
internal void ClearRequestApprovals(string requestId)
{
@@ -404,10 +404,10 @@ internal sealed class CopilotApprovalCoordinator
private static string? ResolveApprovalToolName(
PermissionRequest request,
IReadOnlyDictionary<string, string>? toolNamesByCallId)
ToolCallRegistry? toolCalls)
{
return GetDirectToolName(request)
?? ResolveToolNameFromLookup(request, toolNamesByCallId)
?? ResolveToolNameFromLookup(request, toolCalls)
?? GetFallbackToolName(request);
}
@@ -480,16 +480,16 @@ internal sealed class CopilotApprovalCoordinator
private static string? ResolveToolNameFromLookup(
PermissionRequest request,
IReadOnlyDictionary<string, string>? toolNamesByCallId)
ToolCallRegistry? toolCalls)
{
if (toolNamesByCallId is null)
if (toolCalls is null)
{
return null;
}
string? toolCallId = GetToolCallId(request);
if (toolCallId is null
|| !toolNamesByCallId.TryGetValue(toolCallId, out string? resolvedToolName))
|| !toolCalls.TryGetToolName(toolCallId, out string? resolvedToolName))
{
return null;
}
@@ -0,0 +1,205 @@
using Aryx.AgentHost.Contracts;
using GitHub.Copilot.SDK;
namespace Aryx.AgentHost.Services;
internal sealed class CopilotEventAdapter : IProviderEventAdapter
{
public ProviderTurnStreamCapabilities Capabilities { get; } = new()
{
SupportsIntent = true,
SupportsReasoningDelta = true,
SupportsReasoningBlock = true,
SupportsToolExecutionProgress = true,
SupportsToolExecutionPartialResult = true,
SupportsToolExecutionCompletion = true,
SupportsSubagentLifecycle = true,
SupportsHookLifecycle = true,
SupportsSessionCompaction = true,
SupportsPendingMessagesMutation = true,
SupportsSessionTurnBoundaries = true,
};
public ProviderSessionEvent? TryAdapt(object rawEvent)
{
return rawEvent switch
{
AssistantMessageDeltaEvent messageDelta
when NormalizeRequiredString(messageDelta.Data?.MessageId) is { } messageId =>
new ProviderAssistantMessageDeltaEvent(messageId, messageDelta.Data?.DeltaContent),
AssistantMessageEvent assistantMessage
when NormalizeRequiredString(assistantMessage.Data?.MessageId) is { } messageId =>
new ProviderAssistantMessageEvent(
messageId,
assistantMessage.Data?.Content,
assistantMessage.Data?.ToolRequests is { Length: > 0 }),
ToolExecutionStartEvent toolExecutionStart
when NormalizeRequiredString(toolExecutionStart.Data?.ToolCallId) is { } toolCallId
&& NormalizeRequiredString(toolExecutionStart.Data?.ToolName) is { } toolName =>
new ProviderToolExecutionStartEvent(
toolCallId,
toolName,
WorkflowRequestInfoInterpreter.NormalizeRawToolArguments(toolExecutionStart.Data?.Arguments)),
ToolExecutionProgressEvent toolExecutionProgress
when NormalizeRequiredString(toolExecutionProgress.Data?.ToolCallId) is { } toolCallId =>
new ProviderToolExecutionProgressEvent(
toolCallId,
NormalizeOptionalString(toolExecutionProgress.Data?.ProgressMessage)),
ToolExecutionPartialResultEvent toolExecutionPartialResult
when NormalizeRequiredString(toolExecutionPartialResult.Data?.ToolCallId) is { } toolCallId =>
new ProviderToolExecutionPartialResultEvent(
toolCallId,
toolExecutionPartialResult.Data?.PartialOutput),
ToolExecutionCompleteEvent toolExecutionComplete
when NormalizeRequiredString(toolExecutionComplete.Data?.ToolCallId) is { } toolCallId =>
new ProviderToolExecutionCompleteEvent(
toolCallId,
toolExecutionComplete.Data?.Success ?? false,
NormalizeOptionalString(toolExecutionComplete.Data?.Result?.Content),
NormalizeOptionalString(toolExecutionComplete.Data?.Result?.DetailedContent),
NormalizeOptionalString(toolExecutionComplete.Data?.Error?.Message)),
AssistantIntentEvent intentEvent =>
new ProviderAssistantIntentEvent(NormalizeOptionalString(intentEvent.Data?.Intent)),
AssistantReasoningDeltaEvent reasoningDelta =>
new ProviderAssistantReasoningDeltaEvent(
NormalizeOptionalString(reasoningDelta.Data?.ReasoningId),
reasoningDelta.Data?.DeltaContent),
AssistantReasoningEvent reasoning =>
new ProviderAssistantReasoningEvent(
NormalizeOptionalString(reasoning.Data?.ReasoningId),
reasoning.Data?.Content),
AssistantTurnStartEvent turnStart =>
new ProviderAssistantTurnStartEvent(NormalizeOptionalString(turnStart.Data?.TurnId)),
AssistantTurnEndEvent turnEnd =>
new ProviderAssistantTurnEndEvent(NormalizeOptionalString(turnEnd.Data?.TurnId)),
SubagentStartedEvent started =>
new ProviderSubagentStartedEvent(
started.Data?.ToolCallId,
started.Data?.AgentName,
started.Data?.AgentDisplayName,
started.Data?.AgentDescription),
SubagentCompletedEvent completed =>
new ProviderSubagentCompletedEvent(
completed.Data?.ToolCallId,
completed.Data?.AgentName,
completed.Data?.AgentDisplayName),
SubagentFailedEvent failed =>
new ProviderSubagentFailedEvent(
failed.Data?.ToolCallId,
failed.Data?.AgentName,
failed.Data?.AgentDisplayName,
failed.Data?.Error),
SubagentSelectedEvent selected =>
new ProviderSubagentSelectedEvent(
selected.Data?.AgentName,
selected.Data?.AgentDisplayName,
selected.Data?.Tools),
SubagentDeselectedEvent =>
new ProviderSubagentDeselectedEvent(),
SkillInvokedEvent skillInvoked =>
new ProviderSkillInvokedEvent(
skillInvoked.Data?.Name ?? string.Empty,
skillInvoked.Data?.Path ?? string.Empty,
skillInvoked.Data?.Content ?? string.Empty,
skillInvoked.Data?.AllowedTools,
skillInvoked.Data?.PluginName,
skillInvoked.Data?.PluginVersion),
HookStartEvent hookStart =>
new ProviderHookStartEvent(
hookStart.Data?.HookInvocationId ?? string.Empty,
hookStart.Data?.HookType ?? string.Empty,
hookStart.Data?.Input),
HookEndEvent hookEnd =>
new ProviderHookEndEvent(
hookEnd.Data?.HookInvocationId ?? string.Empty,
hookEnd.Data?.HookType ?? string.Empty,
hookEnd.Data?.Success,
hookEnd.Data?.Output,
hookEnd.Data?.Error?.Message),
AssistantUsageEvent assistantUsage =>
new ProviderAssistantUsageEvent(
assistantUsage.Data?.Model ?? string.Empty,
assistantUsage.Data?.InputTokens,
assistantUsage.Data?.OutputTokens,
assistantUsage.Data?.CacheReadTokens,
assistantUsage.Data?.CacheWriteTokens,
assistantUsage.Data?.Cost,
assistantUsage.Data?.Duration,
assistantUsage.Data?.CopilotUsage?.TotalNanoAiu,
QuotaSnapshotMapper.MapOrNull(assistantUsage.Data?.QuotaSnapshots)),
SessionUsageInfoEvent usageInfo =>
new ProviderSessionUsageEvent(
usageInfo.Data?.TokenLimit ?? 0,
usageInfo.Data?.CurrentTokens ?? 0,
usageInfo.Data?.MessagesLength ?? 0,
usageInfo.Data?.SystemTokens,
usageInfo.Data?.ConversationTokens,
usageInfo.Data?.ToolDefinitionsTokens,
usageInfo.Data?.IsInitial),
SessionCompactionStartEvent compactionStart =>
new ProviderSessionCompactionStartEvent(
compactionStart.Data?.SystemTokens,
compactionStart.Data?.ConversationTokens,
compactionStart.Data?.ToolDefinitionsTokens),
SessionCompactionCompleteEvent compactionComplete =>
new ProviderSessionCompactionCompleteEvent(
compactionComplete.Data?.Success,
compactionComplete.Data?.Error,
compactionComplete.Data?.SystemTokens,
compactionComplete.Data?.ConversationTokens,
compactionComplete.Data?.ToolDefinitionsTokens,
compactionComplete.Data?.PreCompactionTokens,
compactionComplete.Data?.PostCompactionTokens,
compactionComplete.Data?.PreCompactionMessagesLength,
compactionComplete.Data?.MessagesRemoved,
compactionComplete.Data?.TokensRemoved,
compactionComplete.Data?.SummaryContent,
compactionComplete.Data?.CheckpointNumber,
compactionComplete.Data?.CheckpointPath),
PendingMessagesModifiedEvent =>
new ProviderPendingMessagesModifiedEvent(),
McpOauthRequiredEvent =>
new ProviderMcpOauthRequiredEvent(),
ExitPlanModeRequestedEvent =>
new ProviderExitPlanModeRequestedEvent(),
_ => null,
};
}
private static string? NormalizeRequiredString(string? value)
{
string? normalized = NormalizeOptionalString(value);
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
}
private static string? NormalizeOptionalString(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
}
@@ -0,0 +1,35 @@
using Aryx.AgentHost.Contracts;
using Microsoft.Extensions.AI;
namespace Aryx.AgentHost.Services;
internal sealed class CopilotTranscriptProjector : IProviderTranscriptProjector
{
public static CopilotTranscriptProjector Instance { get; } = new();
private CopilotTranscriptProjector()
{
}
public ChatMessage ToChatMessage(ChatMessageDto message)
=> WorkflowTranscriptProjector.ToChatMessage(message);
public void AttachMessageMode(IList<ChatMessage> messages, string? messageMode)
=> WorkflowTranscriptProjector.AttachMessageMode(messages, messageMode);
public List<ChatMessage> SelectNewOutputMessages(
IReadOnlyList<ChatMessage> outputMessages,
IReadOnlyList<ChatMessage> inputMessages)
=> WorkflowTranscriptProjector.SelectNewOutputMessages(outputMessages, inputMessages);
public List<ChatMessageDto> ProjectCompletedMessagesFromSegments(
RunTurnCommandDto command,
IReadOnlyList<ChatMessage> newMessages,
IReadOnlyList<TranscriptSegment> segments,
AgentIdentity? fallbackAgent = null)
=> WorkflowTranscriptProjector.ProjectCompletedMessagesFromSegments(
command,
newMessages,
segments,
fallbackAgent);
}
@@ -0,0 +1,109 @@
using Aryx.AgentHost.Contracts;
using GitHub.Copilot.SDK;
namespace Aryx.AgentHost.Services;
internal sealed class CopilotTurnRunnerSupport : IProviderTurnSupport
{
private readonly CopilotApprovalCoordinator _approvalCoordinator = new();
private readonly CopilotUserInputCoordinator _userInputCoordinator = new();
private readonly CopilotMcpOAuthCoordinator _mcpOAuthCoordinator = new();
private readonly CopilotExitPlanModeCoordinator _exitPlanModeCoordinator = new();
private readonly IProviderEventAdapter _providerEventAdapter = new CopilotEventAdapter();
public async Task<ProviderAgentBundle> CreateAgentBundleAsync(
RunTurnCommandDto command,
TurnExecutionState state,
Func<SidecarEventDto, Task> onEvent,
Func<ApprovalRequestedEventDto, Task> onApproval,
Func<UserInputRequestedEventDto, Task> onUserInput,
CancellationTokenSource runCancellation,
CancellationToken cancellationToken)
{
state.SetStreamCapabilities(_providerEventAdapter.Capabilities);
return await CopilotAgentBundle.CreateAsync(
command,
(agent, request, invocation) => _approvalCoordinator.RequestApprovalAsync(
command,
agent,
request,
invocation,
state.ToolCalls,
activity => AgentWorkflowTurnRunner.EmitActivityAsync(command, state, activity, onEvent),
onApproval,
runCancellation.Token),
(agent, request, invocation) => _userInputCoordinator.RequestUserInputAsync(
command,
agent,
request,
invocation,
onUserInput,
runCancellation.Token),
(agent, sessionEvent) => ObserveSessionEvent(command, state, runCancellation, agent, sessionEvent),
cancellationToken)
.ConfigureAwait(false);
}
public Task ResolveApprovalAsync(
ResolveApprovalCommandDto command,
CancellationToken cancellationToken)
{
return _approvalCoordinator.ResolveApprovalAsync(command, cancellationToken);
}
public Task ResolveUserInputAsync(
ResolveUserInputCommandDto command,
CancellationToken cancellationToken)
{
return _userInputCoordinator.ResolveUserInputAsync(command, cancellationToken);
}
public Task<UserInputResponse> RequestRequestPortUserInputAsync(
RunTurnCommandDto command,
UserInputRequest request,
Func<UserInputRequestedEventDto, Task> onUserInput,
CancellationToken cancellationToken)
{
return _userInputCoordinator.RequestUserInputAsync(
command,
request,
onUserInput,
cancellationToken);
}
public ExitPlanModeRequestedEventDto? ConsumePendingExitPlanModeRequest(string requestId)
{
return _exitPlanModeCoordinator.ConsumePendingRequest(requestId);
}
public void ClearRequestState(string requestId)
{
_approvalCoordinator.ClearRequestApprovals(requestId);
}
private void ObserveSessionEvent(
RunTurnCommandDto command,
TurnExecutionState state,
CancellationTokenSource runCancellation,
WorkflowNodeDto agent,
SessionEvent sessionEvent)
{
if (_providerEventAdapter.TryAdapt(sessionEvent) is { } providerEvent)
{
state.ObserveSessionEvent(agent, providerEvent);
}
if (sessionEvent is McpOauthRequiredEvent mcpOauthRequired)
{
state.EnqueuePendingMcpOauthRequest(
_mcpOAuthCoordinator.BuildMcpOauthRequiredEvent(command, agent, mcpOauthRequired));
}
if (sessionEvent is ExitPlanModeRequestedEvent exitPlanModeRequested)
{
_exitPlanModeCoordinator.RecordExitPlanModeRequest(command, agent, exitPlanModeRequested);
runCancellation.Cancel();
}
}
}
@@ -0,0 +1,86 @@
using System.IO;
using System.Text.Json;
using Aryx.AgentHost.Contracts;
using GitHub.Copilot.SDK;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Agents.AI.Workflows.InProc;
using Microsoft.Extensions.AI;
namespace Aryx.AgentHost.Services;
public sealed class CopilotWorkflowRunner : AgentWorkflowTurnRunner
{
public CopilotWorkflowRunner(WorkflowValidator? workflowValidator = null)
: base(new CopilotTurnRunnerSupport(), workflowValidator)
{
}
internal new static Workflow BuildWorkflowForCommand(
RunTurnCommandDto command,
IReadOnlyList<AIAgent> agents,
WorkflowRunner? workflowRunner = null)
{
return AgentWorkflowTurnRunner.BuildWorkflowForCommand(command, agents, workflowRunner);
}
internal new static FileSystemJsonCheckpointStore? CreateCheckpointStore(RunTurnCommandDto command)
{
return AgentWorkflowTurnRunner.CreateCheckpointStore(command);
}
internal new static bool ShouldEnableWorkflowCheckpointing(RunTurnCommandDto command)
{
return AgentWorkflowTurnRunner.ShouldEnableWorkflowCheckpointing(command);
}
internal new static string GetCheckpointStorePath(RunTurnCommandDto command)
{
return AgentWorkflowTurnRunner.GetCheckpointStorePath(command);
}
internal new static InProcessExecutionEnvironment CreateExecutionEnvironment(
RunTurnCommandDto command,
CheckpointManager? checkpointManager)
{
return AgentWorkflowTurnRunner.CreateExecutionEnvironment(command, checkpointManager);
}
internal static void ConfigureHookLifecycleEventSuppression(
CopilotTurnExecutionState state,
CopilotAgentBundle bundle)
{
AgentWorkflowTurnRunner.ConfigureHookLifecycleEventSuppression(state, bundle);
}
internal new static UserInputRequest CreateRequestPortUserInputRequest(
AgentWorkflowTurnRunner.WorkflowRequestPortMetadata metadata,
RequestInfoEvent requestInfo)
{
return AgentWorkflowTurnRunner.CreateRequestPortUserInputRequest(metadata, requestInfo);
}
internal new static object CoerceRequestPortResponse(string responseType, string? answer)
{
return AgentWorkflowTurnRunner.CoerceRequestPortResponse(responseType, answer);
}
private static Task<bool> HandleWorkflowEventAsync(
RunTurnCommandDto command,
WorkflowEvent evt,
IReadOnlyList<ChatMessage> inputMessages,
CopilotTurnExecutionState state,
Func<TurnDeltaEventDto, Task> onDelta,
Func<SidecarEventDto, Task> onEvent)
{
return AgentWorkflowTurnRunner.HandleWorkflowEventAsync(
command,
evt,
inputMessages,
state,
CopilotTranscriptProjector.Instance,
onDelta,
onEvent);
}
}
@@ -2,7 +2,7 @@ using Aryx.AgentHost.Contracts;
namespace Aryx.AgentHost.Services;
public interface ICopilotSessionManager
public interface IProviderSessionManager
{
Task<IReadOnlyList<CopilotSessionInfoDto>> ListSessionsAsync(
CopilotSessionListFilterDto? filter,
@@ -17,3 +17,5 @@ public interface ICopilotSessionManager
CancellationToken cancellationToken);
}
public interface ICopilotSessionManager : IProviderSessionManager;
@@ -1,16 +1,9 @@
using System.Text;
using Aryx.AgentHost.Contracts;
using GitHub.Copilot.SDK;
using Microsoft.Extensions.AI;
namespace Aryx.AgentHost.Services;
internal readonly record struct TranscriptSegment(string MessageId, string AuthorName, string Content)
{
public static TranscriptSegment FromTuple((string MessageId, string AuthorName, string Content) segment)
=> new(segment.MessageId, segment.AuthorName, segment.Content);
}
internal static class WorkflowTranscriptProjector
{
public static ChatMessage ToChatMessage(ChatMessageDto message)
@@ -146,10 +139,16 @@ internal static class WorkflowTranscriptProjector
ChatMessage message,
TranscriptSegment? matchedSegment)
{
if (matchedSegment is { IsFinalized: true } finalizedSegment
&& !string.IsNullOrWhiteSpace(finalizedSegment.Content))
{
return finalizedSegment.Content;
}
return FirstNonBlank(
message.Text,
matchedSegment?.Content,
TryGetAssistantMessageContent(message))
TryGetAssistantMessageContent(message),
matchedSegment?.Content)
?? string.Empty;
}
@@ -227,6 +226,16 @@ internal static class WorkflowTranscriptProjector
return null;
}
string? messageId = FirstNonBlank(message.MessageId);
if (messageId is not null
&& TryFindSegment(
remainingSegments,
segment => string.Equals(segment.MessageId, messageId, StringComparison.Ordinal),
out TranscriptSegment messageIdMatchedSegment))
{
return messageIdMatchedSegment;
}
string? messageText = string.IsNullOrWhiteSpace(message.Text) ? null : message.Text;
if (messageText is not null)
{
@@ -445,70 +454,3 @@ internal static class WorkflowTranscriptProjector
return null;
}
}
internal sealed class StreamingTranscriptBuffer
{
private readonly List<BufferedTranscriptSegment> _segments = [];
public int Count => _segments.Count;
public TranscriptSegment AppendDelta(
string messageId,
string authorName,
string delta)
{
BufferedTranscriptSegment segment = GetOrCreateSegment(messageId, authorName);
segment.SetContent(StreamingTextMerger.Merge(segment.Content.ToString(), delta));
segment.SetAuthorName(authorName);
return segment.ToSnapshot();
}
public IReadOnlyList<TranscriptSegment> Snapshot()
{
return _segments.Select(segment => segment.ToSnapshot()).ToList();
}
private BufferedTranscriptSegment GetOrCreateSegment(string messageId, string authorName)
{
BufferedTranscriptSegment? existing = _segments.LastOrDefault(segment => segment.MessageId == messageId);
if (existing is not null)
{
return existing;
}
BufferedTranscriptSegment created = new(messageId, authorName);
_segments.Add(created);
return created;
}
private sealed class BufferedTranscriptSegment
{
public BufferedTranscriptSegment(string messageId, string authorName)
{
MessageId = messageId;
AuthorName = authorName;
}
public string MessageId { get; }
public string AuthorName { get; private set; }
public StringBuilder Content { get; } = new();
public void SetContent(string value)
{
Content.Clear();
Content.Append(value);
}
public void SetAuthorName(string value)
{
AuthorName = value;
}
public TranscriptSegment ToSnapshot()
{
return new TranscriptSegment(MessageId, AuthorName, Content.ToString());
}
}
}
@@ -1,8 +1,6 @@
using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.Json.Serialization;
using GitHub.Copilot.SDK;
using GitHub.Copilot.SDK.Rpc;
using Aryx.AgentHost.Contracts;
namespace Aryx.AgentHost.Services;
@@ -19,31 +17,11 @@ public sealed class SidecarProtocolHost
private const string DeleteSessionCommandType = "delete-session";
private const string DisconnectSessionCommandType = "disconnect-session";
private const string GetQuotaCommandType = "get-quota";
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 =
[
"login",
"log in",
"sign in",
"authenticate",
"authentication",
"not signed in",
"not logged in",
"reauth",
"credential",
];
private readonly Func<CancellationToken, Task<SidecarCapabilitiesDto>> _capabilitiesProvider;
private readonly WorkflowValidator _workflowValidator;
private readonly ITurnWorkflowRunner _workflowRunner;
private readonly ICopilotSessionManager _sessionManager;
private readonly IProviderSessionManager _sessionManager;
private readonly JsonSerializerOptions _jsonOptions;
private readonly IReadOnlyDictionary<string, Func<CommandContext, Task>> _commandHandlers;
private readonly SemaphoreSlim _writeLock = new(1, 1);
@@ -60,7 +38,7 @@ public sealed class SidecarProtocolHost
public SidecarProtocolHost(
ITurnWorkflowRunner? workflowRunner = null,
Func<CancellationToken, Task<SidecarCapabilitiesDto>>? capabilitiesProvider = null,
ICopilotSessionManager? sessionManager = null)
IProviderSessionManager? sessionManager = null)
: this(new WorkflowValidator(), workflowRunner, capabilitiesProvider, sessionManager)
{
}
@@ -69,12 +47,25 @@ public sealed class SidecarProtocolHost
WorkflowValidator workflowValidator,
ITurnWorkflowRunner? workflowRunner = null,
Func<CancellationToken, Task<SidecarCapabilitiesDto>>? capabilitiesProvider = null,
ICopilotSessionManager? sessionManager = null)
IProviderSessionManager? sessionManager = null)
: this(workflowValidator, new CopilotAgentProvider(), workflowRunner, capabilitiesProvider, sessionManager)
{
}
internal SidecarProtocolHost(
WorkflowValidator workflowValidator,
IAgentProvider agentProvider,
ITurnWorkflowRunner? workflowRunner = null,
Func<CancellationToken, Task<SidecarCapabilitiesDto>>? capabilitiesProvider = null,
IProviderSessionManager? sessionManager = null)
{
ArgumentNullException.ThrowIfNull(workflowValidator);
ArgumentNullException.ThrowIfNull(agentProvider);
_workflowValidator = workflowValidator;
_workflowRunner = workflowRunner ?? new CopilotWorkflowRunner(_workflowValidator);
_capabilitiesProvider = capabilitiesProvider ?? BuildCapabilitiesAsync;
_sessionManager = sessionManager ?? new CopilotSessionManager();
_workflowRunner = workflowRunner ?? agentProvider.CreateWorkflowRunner(_workflowValidator);
_capabilitiesProvider = capabilitiesProvider ?? agentProvider.GetCapabilitiesAsync;
_sessionManager = sessionManager ?? agentProvider.CreateSessionManager();
_jsonOptions = JsonSerialization.CreateWebOptions();
_jsonOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
_jsonOptions.PropertyNameCaseInsensitive = true;
@@ -459,252 +450,9 @@ public sealed class SidecarProtocolHost
return cancelledRequestIds;
}
private static async Task<SidecarCapabilitiesDto> BuildCapabilitiesAsync(CancellationToken cancellationToken)
{
try
{
CopilotCliContext cliContext = CopilotCliPathResolver.ResolveCliContext();
CapabilityProbeResult probe = await ProbeCapabilitiesAsync(cliContext, cancellationToken).ConfigureAwait(false);
return CreateCapabilities(probe.Models, probe.RuntimeTools, probe.Connection);
}
catch (Exception exception)
{
SidecarConnectionDiagnosticsDto connection = CreateMissingCliDiagnostics(exception);
Console.Error.WriteLine($"[aryx sidecar] {connection.Summary} {exception.Message}");
return CreateCapabilities([], [], connection);
}
}
private static async Task<CapabilityProbeResult> ProbeCapabilitiesAsync(
CopilotCliContext cliContext,
CancellationToken cancellationToken)
{
IReadOnlyList<SidecarModelCapabilityDto> models = [];
IReadOnlyList<SidecarRuntimeToolDto> runtimeTools = [];
SidecarCopilotAccountDiagnosticsDto? account = null;
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null;
Task<SidecarCopilotCliVersionDiagnosticsDto> cliVersionTask =
CopilotConnectionMetadataResolver.GetCliVersionDiagnosticsAsync(cliContext, cancellationToken);
try
{
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions(cliContext);
await using CopilotClient client = new(clientOptions);
await client.StartAsync(cancellationToken).ConfigureAwait(false);
GetAuthStatusResponse? authStatus =
await CopilotConnectionMetadataResolver.TryGetAuthStatusAsync(client, cancellationToken).ConfigureAwait(false);
account = await CopilotConnectionMetadataResolver.CreateAccountDiagnosticsAsync(
authStatus,
cliContext.Environment,
cancellationToken)
.ConfigureAwait(false);
models = await ListAvailableModelsAsync(client, cancellationToken).ConfigureAwait(false);
runtimeTools = await TryListAvailableRuntimeToolsAsync(client, cancellationToken).ConfigureAwait(false);
cliVersion = await cliVersionTask.ConfigureAwait(false);
return new CapabilityProbeResult(
models,
runtimeTools,
CreateReadyConnectionDiagnostics(cliContext.CliPath, models.Count, cliVersion, account));
}
catch (Exception exception)
{
cliVersion = await cliVersionTask.ConfigureAwait(false);
Console.Error.WriteLine($"[aryx sidecar] Failed to list available Copilot models: {exception.Message}");
return new CapabilityProbeResult(
models,
runtimeTools,
CreateFailureConnectionDiagnostics(cliContext.CliPath, exception, cliVersion, account));
}
}
private static SidecarCapabilitiesDto CreateCapabilities(
IReadOnlyList<SidecarModelCapabilityDto> models,
IReadOnlyList<SidecarRuntimeToolDto> runtimeTools,
SidecarConnectionDiagnosticsDto connection)
{
return new SidecarCapabilitiesDto
{
Modes = BuildModeCapabilities(),
Models = models,
RuntimeTools = runtimeTools,
Connection = connection,
};
}
private static Dictionary<string, SidecarModeCapabilityDto> BuildModeCapabilities()
{
return new Dictionary<string, SidecarModeCapabilityDto>(StringComparer.OrdinalIgnoreCase)
{
["single"] = new() { Available = true },
["sequential"] = new() { Available = true },
["concurrent"] = new() { Available = true },
["handoff"] = new() { Available = true },
["group-chat"] = new() { Available = true },
["magentic"] = new()
{
Available = false,
Reason = "Microsoft Agent Framework currently documents Magentic orchestration as unsupported in C#.",
},
};
}
private static async Task<IReadOnlyList<SidecarModelCapabilityDto>> ListAvailableModelsAsync(
CopilotClient client,
CancellationToken cancellationToken)
{
List<ModelInfo> models = await client.ListModelsAsync(cancellationToken).ConfigureAwait(false);
return models
.Select(model => new SidecarModelCapabilityDto
{
Id = model.Id,
Name = model.Name,
SupportedReasoningEfforts = (model.SupportedReasoningEfforts ?? [])
.Where(IsReasoningEffort)
.Distinct(StringComparer.Ordinal)
.ToList(),
DefaultReasoningEffort = IsReasoningEffort(model.DefaultReasoningEffort)
? model.DefaultReasoningEffort
: null,
})
.OrderBy(model => model.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static async Task<IReadOnlyList<SidecarRuntimeToolDto>> TryListAvailableRuntimeToolsAsync(
CopilotClient client,
CancellationToken cancellationToken)
{
try
{
return await ListAvailableRuntimeToolsAsync(client, cancellationToken).ConfigureAwait(false);
}
catch (Exception exception)
{
Console.Error.WriteLine($"[aryx sidecar] Failed to list available Copilot runtime tools: {exception.Message}");
return [];
}
}
private static async Task<IReadOnlyList<SidecarRuntimeToolDto>> ListAvailableRuntimeToolsAsync(
CopilotClient client,
CancellationToken cancellationToken)
{
ToolsListResult result = await client.Rpc.Tools.ListAsync(null!, cancellationToken).ConfigureAwait(false);
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
{
Id = tool.Name.Trim(),
Label = tool.Name.Trim(),
Description = string.IsNullOrWhiteSpace(tool.Description) ? null : tool.Description.Trim(),
})
.DistinctBy(tool => tool.Id, StringComparer.OrdinalIgnoreCase)
.OrderBy(tool => tool.Label, StringComparer.OrdinalIgnoreCase)
.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";
}
internal static SidecarConnectionDiagnosticsDto CreateMissingCliDiagnostics(Exception exception)
{
return new SidecarConnectionDiagnosticsDto
{
Status = "copilot-cli-missing",
Summary = "GitHub Copilot CLI is not installed or is not available on PATH.",
Detail = exception.Message,
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
};
}
internal static SidecarConnectionDiagnosticsDto CreateReadyConnectionDiagnostics(
string cliPath,
int modelCount,
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null,
SidecarCopilotAccountDiagnosticsDto? account = null)
{
string summary = modelCount switch
{
0 => "Connected to GitHub Copilot, but no models were reported.",
1 => "Connected to GitHub Copilot. 1 model is available.",
_ => $"Connected to GitHub Copilot. {modelCount} models are available.",
};
return new SidecarConnectionDiagnosticsDto
{
Status = "ready",
Summary = summary,
Detail = $"Using Copilot CLI at {cliPath}.",
CopilotCliPath = cliPath,
CopilotCliVersion = cliVersion,
Account = account,
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
};
}
internal static SidecarConnectionDiagnosticsDto CreateFailureConnectionDiagnostics(
string? cliPath,
Exception exception,
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null,
SidecarCopilotAccountDiagnosticsDto? account = null)
{
string status = ClassifyConnectionStatus(exception);
string summary = status == "copilot-auth-required"
? "GitHub Copilot requires authentication before Aryx can load models."
: "GitHub Copilot was found, but Aryx could not load its model list.";
return new SidecarConnectionDiagnosticsDto
{
Status = status,
Summary = summary,
Detail = exception.Message,
CopilotCliPath = cliPath,
CopilotCliVersion = cliVersion,
Account = account,
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
};
}
internal static string ClassifyConnectionStatus(Exception exception)
{
string message = exception.Message;
if (AuthenticationErrorIndicators.Any(indicator =>
message.Contains(indicator, StringComparison.OrdinalIgnoreCase)))
{
return "copilot-auth-required";
}
return "copilot-error";
}
private sealed record CommandContext(
string RawCommand,
SidecarCommandEnvelope Envelope,
TextWriter Output,
CancellationToken CancellationToken);
private sealed record CapabilityProbeResult(
IReadOnlyList<SidecarModelCapabilityDto> Models,
IReadOnlyList<SidecarRuntimeToolDto> RuntimeTools,
SidecarConnectionDiagnosticsDto Connection);
}
@@ -1,5 +1,3 @@
using System.Text.RegularExpressions;
namespace Aryx.AgentHost.Services;
internal static partial class StreamingTextMerger
@@ -7,7 +5,6 @@ internal static partial class StreamingTextMerger
private const double SnapshotReplacementMinLengthRatio = 0.6;
private const int SnapshotReplacementMinTokenCount = 3;
private const double SnapshotReplacementSharedTokenRatio = 0.5;
private const string CharactersThatDoNotNeedLeadingSpace = "([{/\"'`";
public static string Merge(string current, string incoming)
{
@@ -32,51 +29,7 @@ internal static partial class StreamingTextMerger
return incoming;
}
return current + ResolveBoundarySeparator(current, incoming) + incoming;
}
private static bool TryMergeSnapshotVariants(string current, string incoming, out string merged)
{
if (incoming.StartsWith(current, StringComparison.Ordinal)
|| incoming.Contains(current, StringComparison.Ordinal))
{
merged = incoming;
return true;
}
if (current.Contains(incoming, StringComparison.Ordinal))
{
merged = current;
return true;
}
merged = string.Empty;
return false;
}
private static bool TryMergeByOverlap(string current, string incoming, out string merged)
{
int overlapLength = ComputeSuffixPrefixOverlap(current, incoming);
if (overlapLength == 0)
{
merged = string.Empty;
return false;
}
merged = current + incoming[overlapLength..];
return true;
}
private static string ResolveBoundarySeparator(string current, string incoming)
{
if (ShouldInsertNewlineBoundary(current, incoming))
{
return "\n";
}
return ShouldInsertSpaceBoundary(current, incoming)
? " "
: string.Empty;
return current + incoming;
}
private static int ComputeSuffixPrefixOverlap(string current, string incoming)
@@ -125,54 +78,6 @@ internal static partial class StreamingTextMerger
&& incomingTokens.Count >= SnapshotReplacementMinTokenCount;
}
private static bool ShouldInsertNewlineBoundary(string current, string incoming)
{
return !current.EndsWith('\n')
&& MarkdownBlockPrefixRegex().IsMatch(incoming.TrimStart());
}
private static bool ShouldInsertSpaceBoundary(string current, string incoming)
{
char lastCharacter = current[^1];
char firstCharacter = incoming[0];
if (HasExistingBoundary(lastCharacter, firstCharacter)
|| CharactersThatDoNotNeedLeadingSpace.Contains(lastCharacter))
{
return false;
}
if (ClosingPunctuationRegex().IsMatch(incoming))
{
return false;
}
return StartsLikeASeparatedInlineFragment(firstCharacter, incoming)
|| LooksLikeWordBoundary(current, incoming);
}
private static bool HasExistingBoundary(char lastCharacter, char firstCharacter)
{
return char.IsWhiteSpace(lastCharacter) || char.IsWhiteSpace(firstCharacter);
}
private static bool StartsLikeASeparatedInlineFragment(char firstCharacter, string incoming)
{
return MarkdownInlinePrefixRegex().IsMatch(incoming)
|| char.IsUpper(firstCharacter)
|| char.IsDigit(firstCharacter);
}
private static bool LooksLikeWordBoundary(string current, string incoming)
{
string[] currentTokens = Tokenize(current).ToArray();
string[] incomingTokens = Tokenize(incoming).ToArray();
string firstIncomingToken = incomingTokens.FirstOrDefault() ?? string.Empty;
return currentTokens.Length >= 2
&& incomingTokens.Length >= 2
&& firstIncomingToken.Length >= 2;
}
private static IEnumerable<string> Tokenize(string value)
{
return TokenRegex()
@@ -181,15 +86,38 @@ internal static partial class StreamingTextMerger
.Where(token => token.Length > 0);
}
[GeneratedRegex("[a-z0-9]+", RegexOptions.IgnoreCase)]
private static partial Regex TokenRegex();
private static bool TryMergeSnapshotVariants(string current, string incoming, out string merged)
{
if (incoming.StartsWith(current, StringComparison.Ordinal)
|| incoming.Contains(current, StringComparison.Ordinal))
{
merged = incoming;
return true;
}
[GeneratedRegex(@"^[.,!?;:%)\]}]")]
private static partial Regex ClosingPunctuationRegex();
if (current.Contains(incoming, StringComparison.Ordinal))
{
merged = current;
return true;
}
[GeneratedRegex(@"^[*_`~\[]")]
private static partial Regex MarkdownInlinePrefixRegex();
merged = string.Empty;
return false;
}
[GeneratedRegex(@"^(?:#{1,6}\s|[-*+]\s|\d+\.\s|>\s|```)", RegexOptions.Singleline)]
private static partial Regex MarkdownBlockPrefixRegex();
private static bool TryMergeByOverlap(string current, string incoming, out string merged)
{
int overlapLength = ComputeSuffixPrefixOverlap(current, incoming);
if (overlapLength == 0)
{
merged = string.Empty;
return false;
}
merged = current + incoming[overlapLength..];
return true;
}
[System.Text.RegularExpressions.GeneratedRegex("[a-z0-9]+", System.Text.RegularExpressions.RegexOptions.IgnoreCase)]
private static partial System.Text.RegularExpressions.Regex TokenRegex();
}
@@ -0,0 +1,142 @@
using System.Text;
namespace Aryx.AgentHost.Services;
internal readonly record struct TranscriptSegment(
string MessageId,
string AuthorName,
string Content,
bool IsFinalized = false)
{
public static TranscriptSegment FromTuple((string MessageId, string AuthorName, string Content) segment)
=> new(segment.MessageId, segment.AuthorName, segment.Content);
public void Deconstruct(out string messageId, out string authorName, out string content)
{
messageId = MessageId;
authorName = AuthorName;
content = Content;
}
}
internal sealed class StreamingTranscriptBuffer
{
private readonly List<BufferedTranscriptSegment> _segments = [];
public int Count => _segments.Count;
public TranscriptSegment AppendDelta(
string messageId,
string authorName,
string delta)
{
_ = TryAppendDelta(messageId, authorName, delta, out TranscriptSegment segment);
return segment;
}
public bool TryAppendDelta(
string messageId,
string authorName,
string delta,
out TranscriptSegment segment)
{
BufferedTranscriptSegment bufferedSegment = GetOrCreateSegment(messageId, authorName);
bool contentChanged = bufferedSegment.TryAppendDelta(authorName, delta);
segment = bufferedSegment.ToSnapshot();
return contentChanged;
}
public bool TryApplySnapshot(
string messageId,
string authorName,
string content,
out TranscriptSegment segment)
{
BufferedTranscriptSegment bufferedSegment = GetOrCreateSegment(messageId, authorName);
bool visibleContentChanged = bufferedSegment.TryApplySnapshot(authorName, content);
segment = bufferedSegment.ToSnapshot();
return visibleContentChanged;
}
public IReadOnlyList<TranscriptSegment> Snapshot()
{
return _segments.Select(segment => segment.ToSnapshot()).ToList();
}
private BufferedTranscriptSegment GetOrCreateSegment(string messageId, string authorName)
{
BufferedTranscriptSegment? existing = _segments.LastOrDefault(segment => segment.MessageId == messageId);
if (existing is not null)
{
return existing;
}
BufferedTranscriptSegment created = new(messageId, authorName);
_segments.Add(created);
return created;
}
private sealed class BufferedTranscriptSegment
{
public BufferedTranscriptSegment(string messageId, string authorName)
{
MessageId = messageId;
AuthorName = authorName;
}
public string MessageId { get; }
public string AuthorName { get; private set; }
public bool IsFinalized { get; private set; }
public StringBuilder Content { get; } = new();
public bool TryAppendDelta(string authorName, string delta)
{
SetAuthorName(authorName);
if (IsFinalized || string.IsNullOrEmpty(delta))
{
return false;
}
string currentContent = Content.ToString();
string mergedContent = StreamingTextMerger.Merge(currentContent, delta);
if (string.Equals(currentContent, mergedContent, StringComparison.Ordinal))
{
return false;
}
SetContent(mergedContent);
return true;
}
public bool TryApplySnapshot(string authorName, string content)
{
SetAuthorName(authorName);
string normalizedContent = content ?? string.Empty;
string currentContent = Content.ToString();
bool visibleContentChanged = !string.Equals(currentContent, normalizedContent, StringComparison.Ordinal);
SetContent(normalizedContent);
IsFinalized = true;
return visibleContentChanged;
}
private void SetContent(string value)
{
Content.Clear();
Content.Append(value);
}
private void SetAuthorName(string value)
{
AuthorName = value;
}
public TranscriptSegment ToSnapshot()
{
return new TranscriptSegment(MessageId, AuthorName, Content.ToString(), IsFinalized);
}
}
}
@@ -0,0 +1,165 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using Aryx.AgentHost.Contracts;
namespace Aryx.AgentHost.Services;
internal sealed class ToolCallRegistry
{
private readonly ConcurrentDictionary<string, ProviderToolExecutionSnapshot> _toolExecutionsByCallId = new(StringComparer.Ordinal);
public bool TryGetExecution(string? toolCallId, [NotNullWhen(true)] out ProviderToolExecutionSnapshot? snapshot)
{
snapshot = null;
return !string.IsNullOrWhiteSpace(toolCallId)
&& _toolExecutionsByCallId.TryGetValue(toolCallId, out snapshot);
}
public bool TryGetToolName(string? toolCallId, [NotNullWhen(true)] out string? toolName)
{
toolName = null;
return TryGetExecution(toolCallId, out ProviderToolExecutionSnapshot? snapshot)
&& !string.IsNullOrWhiteSpace(snapshot.ToolName)
&& (toolName = snapshot.ToolName) is not null;
}
public bool HasTrackedArguments(string? toolCallId)
{
return TryGetExecution(toolCallId, out ProviderToolExecutionSnapshot? snapshot)
&& snapshot.ToolArguments is { Count: > 0 };
}
public void RecordToolStart(
string toolCallId,
string toolName,
IReadOnlyDictionary<string, object?>? toolArguments)
{
_toolExecutionsByCallId.AddOrUpdate(
toolCallId,
static (id, state) => new ProviderToolExecutionSnapshot
{
ToolCallId = id,
ToolName = state.ToolName,
ToolArguments = state.ToolArguments,
Status = ProviderToolExecutionStatus.Running,
},
static (_, existing, state) => existing with
{
ToolName = state.ToolName,
ToolArguments = state.ToolArguments,
Status = ProviderToolExecutionStatus.Running,
},
(ToolName: toolName, ToolArguments: toolArguments));
}
public bool TryRecordToolRequest(
string? toolCallId,
string toolName,
IReadOnlyDictionary<string, object?>? toolArguments)
{
bool hasToolArguments = toolArguments is { Count: > 0 };
string? normalizedToolCallId = NormalizeOptionalString(toolCallId);
if (normalizedToolCallId is null)
{
return true;
}
if (_toolExecutionsByCallId.TryGetValue(normalizedToolCallId, out ProviderToolExecutionSnapshot? existing))
{
bool trackedHasArguments = existing.ToolArguments is { Count: > 0 };
if (trackedHasArguments || !hasToolArguments)
{
return false;
}
}
_toolExecutionsByCallId.AddOrUpdate(
normalizedToolCallId,
id => new ProviderToolExecutionSnapshot
{
ToolCallId = id,
ToolName = toolName,
ToolArguments = toolArguments,
Status = ProviderToolExecutionStatus.Running,
},
(_, existing) => existing with
{
ToolName = toolName,
ToolArguments = toolArguments,
Status = existing.Status is ProviderToolExecutionStatus.Completed or ProviderToolExecutionStatus.Failed
? existing.Status
: ProviderToolExecutionStatus.Running,
});
return true;
}
public void RecordProgress(string toolCallId, string? progressMessage)
{
string? normalizedProgress = NormalizeOptionalString(progressMessage);
_toolExecutionsByCallId.AddOrUpdate(
toolCallId,
id => new ProviderToolExecutionSnapshot
{
ToolCallId = id,
Status = ProviderToolExecutionStatus.Running,
LatestProgressMessage = normalizedProgress,
},
(_, existing) => existing with
{
Status = existing.Status is ProviderToolExecutionStatus.Completed or ProviderToolExecutionStatus.Failed
? existing.Status
: ProviderToolExecutionStatus.Running,
LatestProgressMessage = normalizedProgress ?? existing.LatestProgressMessage,
});
}
public void RecordPartialResult(string toolCallId, string? partialOutput)
{
if (string.IsNullOrEmpty(partialOutput))
{
return;
}
_toolExecutionsByCallId.AddOrUpdate(
toolCallId,
id => new ProviderToolExecutionSnapshot
{
ToolCallId = id,
Status = ProviderToolExecutionStatus.Running,
PartialOutput = partialOutput,
},
(_, existing) => existing with
{
Status = existing.Status is ProviderToolExecutionStatus.Completed or ProviderToolExecutionStatus.Failed
? existing.Status
: ProviderToolExecutionStatus.Running,
PartialOutput = string.Concat(existing.PartialOutput, partialOutput),
});
}
public void RecordCompletion(ProviderToolExecutionCompleteEvent toolExecution)
{
_toolExecutionsByCallId.AddOrUpdate(
toolExecution.ToolCallId,
id => new ProviderToolExecutionSnapshot
{
ToolCallId = id,
Status = toolExecution.Success ? ProviderToolExecutionStatus.Completed : ProviderToolExecutionStatus.Failed,
ResultContent = toolExecution.ResultContent,
DetailedResultContent = toolExecution.DetailedResultContent,
Error = toolExecution.Error,
},
(_, existing) => existing with
{
Status = toolExecution.Success ? ProviderToolExecutionStatus.Completed : ProviderToolExecutionStatus.Failed,
ResultContent = toolExecution.ResultContent ?? existing.ResultContent,
DetailedResultContent = toolExecution.DetailedResultContent ?? existing.DetailedResultContent,
Error = toolExecution.Error ?? existing.Error,
});
}
private static string? NormalizeOptionalString(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
}
@@ -0,0 +1,929 @@
using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using Aryx.AgentHost.Contracts;
using Microsoft.Extensions.AI;
namespace Aryx.AgentHost.Services;
internal class TurnExecutionState
{
private readonly RunTurnCommandDto _command;
private readonly IReadOnlyDictionary<string, WorkflowDefinitionDto> _workflowLibrary;
private readonly IReadOnlyDictionary<string, SubworkflowContext> _agentSubworkflowIndex;
private readonly HashSet<string> _startedAgents = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _reclassifiedMessageIds = new(StringComparer.Ordinal);
private readonly ConcurrentQueue<SidecarEventDto> _pendingEvents = new();
private readonly ConcurrentQueue<McpOauthRequiredEventDto> _pendingMcpOauthRequests = new();
private readonly ConcurrentDictionary<string, AgentIdentity> _observedAgentsByMessageId = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, ProviderReasoningSnapshot> _reasoningById = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, string> _latestIntentByAgentId = new(StringComparer.Ordinal);
private readonly StreamingTranscriptBuffer _transcriptBuffer = new();
private int _fallbackMessageIndex;
private string? _lastObservedMessageId;
public TurnExecutionState(RunTurnCommandDto command)
{
_command = command;
_workflowLibrary = WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(command.WorkflowLibrary);
_agentSubworkflowIndex = AgentIdentityResolver.BuildAgentSubworkflowIndex(command.Workflow, _workflowLibrary);
}
public ToolCallRegistry ToolCalls { get; } = new();
public AgentIdentity? ActiveAgent { get; private set; }
public List<ChatMessageDto> CompletedMessages { get; private set; } = [];
public bool HasPendingExitPlanModeRequest { get; private set; }
public ProviderTurnStreamCapabilities StreamCapabilities { get; private set; } = ProviderTurnStreamCapabilities.None;
public string? CurrentProviderTurnId { get; private set; }
public string? LatestCompletedProviderTurnId { get; private set; }
public bool SuppressHookLifecycleEvents { get; set; }
public void SetStreamCapabilities(ProviderTurnStreamCapabilities capabilities)
{
StreamCapabilities = capabilities ?? throw new ArgumentNullException(nameof(capabilities));
}
public AgentIdentity ResolveAgentIdentity(string? agentId, string? agentName)
{
return AgentIdentityResolver.ResolveAgentIdentity(
_command.Workflow,
_workflowLibrary,
agentId,
agentName,
_agentSubworkflowIndex);
}
public bool TryResolveKnownAgentIdentity(string? agentIdentifier, out AgentIdentity agent)
{
return AgentIdentityResolver.TryResolveKnownAgentIdentity(
_command.Workflow,
_workflowLibrary,
agentIdentifier,
_agentSubworkflowIndex,
out agent);
}
public bool TryResolveObservedAgentIdentity(
string? agentIdentifier,
AgentIdentity? fallbackAgent,
out AgentIdentity agent)
{
return AgentIdentityResolver.TryResolveObservedAgentIdentity(
_command.Workflow,
_workflowLibrary,
agentIdentifier,
fallbackAgent,
_agentSubworkflowIndex,
out agent);
}
public bool TryCreateSubworkflowLifecycleActivity(
string activityType,
string? executorId,
out AgentActivityEventDto activity)
{
activity = default!;
if (!AgentIdentityResolver.TryResolveSubworkflowContext(
_command.Workflow,
_workflowLibrary,
executorId,
out SubworkflowContext subworkflow))
{
return false;
}
activity = CreateSubworkflowActivity(activityType, subworkflow);
return true;
}
public async Task EmitThinkingIfNeeded(
AgentIdentity agent,
Func<SidecarEventDto, Task> onEvent)
{
AgentActivityEventDto? thinkingActivity = CreateThinkingActivityIfNeeded(agent);
if (thinkingActivity is null)
{
return;
}
await onEvent(thinkingActivity).ConfigureAwait(false);
}
public void QueueThinkingIfNeeded(AgentIdentity agent)
{
AgentActivityEventDto? thinkingActivity = CreateThinkingActivityIfNeeded(agent);
if (thinkingActivity is not null)
{
_pendingEvents.Enqueue(thinkingActivity);
}
}
public void QueueCompletedActivity(AgentIdentity agent)
{
_pendingEvents.Enqueue(CreateCompletedActivity(agent));
}
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))
{
ActiveAgent = ResolveAgentIdentity(activity.AgentId, activity.AgentName);
}
}
public void ObserveSessionEvent(WorkflowNodeDto agentDefinition, ProviderSessionEvent sessionEvent)
{
AgentIdentity agent = ResolveAgentIdentity(
agentDefinition.GetAgentId(),
agentDefinition.GetAgentName());
switch (sessionEvent)
{
case ProviderAssistantMessageDeltaEvent messageDelta:
RecordObservedAgentForMessage(agent, messageDelta.MessageId);
QueueThinkingIfNeeded(agent);
break;
case ProviderAssistantMessageEvent assistantMessage:
RecordObservedAgentForMessage(agent, assistantMessage.MessageId);
QueueThinkingIfNeeded(agent);
if (!string.IsNullOrWhiteSpace(assistantMessage.Content)
&& TryFinalizeTranscriptMessage(
assistantMessage.MessageId,
agent.AgentName,
assistantMessage.Content,
out TranscriptSegment finalizedSegment))
{
_pendingEvents.Enqueue(CreateTurnDeltaEvent(
finalizedSegment.MessageId,
finalizedSegment.AuthorName,
string.Empty,
finalizedSegment.Content));
}
if (assistantMessage.HasToolRequests)
{
QueueMessageReclassifiedIfNeeded(assistantMessage.MessageId);
}
break;
case ProviderToolExecutionStartEvent toolExecutionStart:
string toolCallId = toolExecutionStart.ToolCallId;
string toolName = toolExecutionStart.ToolName;
bool shouldQueueToolActivity = TrackToolCall(toolCallId, toolName, toolExecutionStart.ToolArguments);
ActiveAgent = agent;
if (shouldQueueToolActivity)
{
AgentActivityEventDto? toolActivity = CreateToolCallingActivity(
agent, toolName, toolCallId, toolExecutionStart.ToolArguments);
if (toolActivity is not null)
{
_pendingEvents.Enqueue(toolActivity);
}
}
QueueMessageReclassifiedIfNeeded(_lastObservedMessageId);
break;
case ProviderToolExecutionProgressEvent toolExecutionProgress:
ActiveAgent = agent;
TrackToolExecutionProgress(toolExecutionProgress.ToolCallId, toolExecutionProgress.ProgressMessage);
break;
case ProviderToolExecutionPartialResultEvent toolExecutionPartialResult:
ActiveAgent = agent;
TrackToolExecutionPartialResult(toolExecutionPartialResult.ToolCallId, toolExecutionPartialResult.PartialOutput);
break;
case ProviderToolExecutionCompleteEvent toolExecutionComplete:
ActiveAgent = agent;
TrackToolExecutionComplete(toolExecutionComplete);
break;
case ProviderAssistantIntentEvent intentEvent:
ActiveAgent = agent;
QueueThinkingIfNeeded(agent);
TrackLatestIntent(agent.AgentId, intentEvent.Intent);
AssistantIntentEventDto? assistantIntent = CreateAssistantIntentEvent(agent, intentEvent.Intent);
if (assistantIntent is not null)
{
_pendingEvents.Enqueue(assistantIntent);
}
break;
case ProviderAssistantReasoningDeltaEvent reasoningDelta:
ActiveAgent = agent;
QueueThinkingIfNeeded(agent);
TrackReasoningContent(reasoningDelta.ReasoningId, reasoningDelta.DeltaContent, isComplete: false);
ReasoningDeltaEventDto? reasoningDeltaEvent = CreateReasoningDeltaEvent(
agent,
reasoningDelta.ReasoningId,
reasoningDelta.DeltaContent);
if (reasoningDeltaEvent is not null)
{
_pendingEvents.Enqueue(reasoningDeltaEvent);
}
break;
case ProviderAssistantReasoningEvent reasoning:
ActiveAgent = agent;
TrackReasoningContent(reasoning.ReasoningId, reasoning.Content, isComplete: true);
break;
case ProviderAssistantTurnStartEvent turnStart:
ActiveAgent = agent;
CurrentProviderTurnId = turnStart.TurnId;
break;
case ProviderAssistantTurnEndEvent turnEnd:
ActiveAgent = agent;
LatestCompletedProviderTurnId = turnEnd.TurnId;
if (string.Equals(CurrentProviderTurnId, turnEnd.TurnId, StringComparison.Ordinal))
{
CurrentProviderTurnId = null;
}
break;
case ProviderSubagentStartedEvent started:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateSubagentStartedEvent(agent, started));
break;
case ProviderSubagentCompletedEvent completed:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateSubagentCompletedEvent(agent, completed));
break;
case ProviderSubagentFailedEvent failed:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateSubagentFailedEvent(agent, failed));
break;
case ProviderSubagentSelectedEvent selected:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateSubagentSelectedEvent(agent, selected));
break;
case ProviderSubagentDeselectedEvent:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateSubagentDeselectedEvent(agent));
break;
case ProviderSkillInvokedEvent skillInvoked:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateSkillInvokedEvent(agent, skillInvoked));
break;
case ProviderHookStartEvent hookStart:
ActiveAgent = agent;
if (!SuppressHookLifecycleEvents)
{
_pendingEvents.Enqueue(CreateHookLifecycleEvent(
agent,
"start",
hookStart.HookInvocationId,
hookStart.HookType,
input: hookStart.Input));
}
break;
case ProviderHookEndEvent hookEnd:
ActiveAgent = agent;
if (!SuppressHookLifecycleEvents)
{
_pendingEvents.Enqueue(CreateHookLifecycleEvent(
agent,
"end",
hookEnd.HookInvocationId,
hookEnd.HookType,
success: hookEnd.Success,
output: hookEnd.Output,
error: hookEnd.Error));
}
break;
case ProviderAssistantUsageEvent assistantUsage:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateAssistantUsageEvent(agent, assistantUsage));
break;
case ProviderSessionUsageEvent usageInfo:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateUsageEvent(agent, usageInfo));
break;
case ProviderSessionCompactionStartEvent compactionStart:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateCompactionStartEvent(agent, compactionStart));
break;
case ProviderSessionCompactionCompleteEvent compactionComplete:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateCompactionCompleteEvent(agent, compactionComplete));
break;
case ProviderPendingMessagesModifiedEvent:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreatePendingMessagesModifiedEvent(agent));
break;
case ProviderMcpOauthRequiredEvent:
ActiveAgent = agent;
break;
case ProviderExitPlanModeRequestedEvent:
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 TryGetToolExecution(string? toolCallId, [NotNullWhen(true)] out ProviderToolExecutionSnapshot? snapshot)
{
return ToolCalls.TryGetExecution(toolCallId, out snapshot);
}
public bool TryGetReasoning(string? reasoningId, [NotNullWhen(true)] out ProviderReasoningSnapshot? snapshot)
{
snapshot = null;
return !string.IsNullOrWhiteSpace(reasoningId)
&& _reasoningById.TryGetValue(reasoningId, out snapshot);
}
public bool TryGetLatestIntent(string? agentId, [NotNullWhen(true)] out string? intent)
{
intent = null;
return !string.IsNullOrWhiteSpace(agentId)
&& _latestIntentByAgentId.TryGetValue(agentId, out intent);
}
public bool TryResolveObservedAgentForMessage(string? messageId, out AgentIdentity agent)
{
agent = default;
return !string.IsNullOrWhiteSpace(messageId)
&& _observedAgentsByMessageId.TryGetValue(messageId, out agent);
}
public string CreateMessageId(string? messageId)
{
return messageId ?? $"{_command.RequestId}-delta-{_fallbackMessageIndex++}";
}
public TranscriptSegment AppendDelta(
string messageId,
string authorName,
string delta)
{
return _transcriptBuffer.AppendDelta(messageId, authorName, delta);
}
public bool TryAppendDelta(
string messageId,
string authorName,
string delta,
out TranscriptSegment segment)
{
return _transcriptBuffer.TryAppendDelta(messageId, authorName, delta, out segment);
}
public bool TryFinalizeTranscriptMessage(
string messageId,
string authorName,
string content,
out TranscriptSegment segment)
{
return _transcriptBuffer.TryApplySnapshot(messageId, authorName, content, out segment);
}
public void ClearActiveAgentIfMatching(AgentIdentity completedAgent)
{
if (ActiveAgent.HasValue
&& string.Equals(ActiveAgent.Value.AgentId, completedAgent.AgentId, StringComparison.Ordinal))
{
ActiveAgent = null;
}
}
private void RecordObservedAgentForMessage(AgentIdentity agent, string messageId)
{
ActiveAgent = agent;
_observedAgentsByMessageId[messageId] = agent;
_lastObservedMessageId = messageId;
}
private bool TrackToolCall(
string toolCallId,
string toolName,
IReadOnlyDictionary<string, object?>? toolArguments)
{
return ToolCalls.TryRecordToolRequest(toolCallId, toolName, toolArguments);
}
private void TrackToolExecutionProgress(string toolCallId, string? progressMessage)
{
ToolCalls.RecordProgress(toolCallId, progressMessage);
}
private void TrackToolExecutionPartialResult(string toolCallId, string? partialOutput)
{
ToolCalls.RecordPartialResult(toolCallId, partialOutput);
}
private void TrackToolExecutionComplete(ProviderToolExecutionCompleteEvent toolExecution)
{
ToolCalls.RecordCompletion(toolExecution);
}
private void TrackLatestIntent(string agentId, string? intent)
{
string? normalizedIntent = NormalizeOptionalString(intent);
if (normalizedIntent is null)
{
return;
}
_latestIntentByAgentId[agentId] = normalizedIntent;
}
private void TrackReasoningContent(string? reasoningId, string? content, bool isComplete)
{
string? normalizedReasoningId = NormalizeOptionalString(reasoningId);
if (normalizedReasoningId is null || content is null)
{
return;
}
_reasoningById.AddOrUpdate(
normalizedReasoningId,
id => new ProviderReasoningSnapshot
{
ReasoningId = id,
Content = content,
IsComplete = isComplete,
},
(_, existing) => existing with
{
Content = isComplete ? content : string.Concat(existing.Content, content),
IsComplete = isComplete || existing.IsComplete,
});
}
private void QueueMessageReclassifiedIfNeeded(string? messageId)
{
if (string.IsNullOrWhiteSpace(messageId))
{
return;
}
string normalizedMessageId = messageId.Trim();
if (!_reclassifiedMessageIds.Add(normalizedMessageId))
{
return;
}
_pendingEvents.Enqueue(CreateMessageReclassifiedEvent(normalizedMessageId));
}
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,
SubworkflowNodeId = agent.Subworkflow?.SubworkflowNodeId,
SubworkflowName = agent.Subworkflow?.SubworkflowName,
};
}
private AgentActivityEventDto? CreateToolCallingActivity(
AgentIdentity agent,
string toolName,
string toolCallId,
IReadOnlyDictionary<string, object?>? toolArguments = null)
{
if (toolName.StartsWith("handoff_to_", StringComparison.Ordinal))
{
return null;
}
return new AgentActivityEventDto
{
Type = "agent-activity",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
ActivityType = "tool-calling",
AgentId = agent.AgentId,
AgentName = agent.AgentName,
SubworkflowNodeId = agent.Subworkflow?.SubworkflowNodeId,
SubworkflowName = agent.Subworkflow?.SubworkflowName,
ToolName = toolName,
ToolCallId = toolCallId,
ToolArguments = toolArguments,
};
}
private AgentActivityEventDto CreateCompletedActivity(AgentIdentity agent)
{
return new AgentActivityEventDto
{
Type = "agent-activity",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
ActivityType = "completed",
AgentId = agent.AgentId,
AgentName = agent.AgentName,
SubworkflowNodeId = agent.Subworkflow?.SubworkflowNodeId,
SubworkflowName = agent.Subworkflow?.SubworkflowName,
};
}
private AgentActivityEventDto CreateSubworkflowActivity(
string activityType,
SubworkflowContext subworkflow)
{
return new AgentActivityEventDto
{
Type = "agent-activity",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
ActivityType = activityType,
SubworkflowNodeId = subworkflow.SubworkflowNodeId,
SubworkflowName = subworkflow.SubworkflowName,
};
}
private MessageReclassifiedEventDto CreateMessageReclassifiedEvent(string messageId)
{
return new MessageReclassifiedEventDto
{
Type = "message-reclassified",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
MessageId = messageId,
NewKind = "thinking",
};
}
public void UpdateCompletedMessages(
IReadOnlyList<ChatMessage> allMessages,
IReadOnlyList<ChatMessage> inputMessages,
IProviderTranscriptProjector transcriptProjector)
{
ArgumentNullException.ThrowIfNull(transcriptProjector);
List<ChatMessage> newMessages = transcriptProjector.SelectNewOutputMessages(allMessages, inputMessages);
CompletedMessages = transcriptProjector.ProjectCompletedMessagesFromSegments(
_command,
newMessages,
_transcriptBuffer.Snapshot(),
ActiveAgent);
}
public IReadOnlyList<ChatMessageDto> FinalizeCompletedMessages(IProviderTranscriptProjector transcriptProjector)
{
ArgumentNullException.ThrowIfNull(transcriptProjector);
if (CompletedMessages.Count == 0 && _transcriptBuffer.Count > 0)
{
CompletedMessages = transcriptProjector.ProjectCompletedMessagesFromSegments(
_command,
[],
_transcriptBuffer.Snapshot(),
ActiveAgent);
}
foreach (ChatMessageDto message in CompletedMessages)
{
if (_reclassifiedMessageIds.Contains(message.Id))
{
message.MessageKind = "thinking";
}
}
return CompletedMessages;
}
private SubagentEventDto CreateSubagentStartedEvent(
AgentIdentity agent,
ProviderSubagentStartedEvent data)
{
return new SubagentEventDto
{
Type = "subagent-event",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
EventKind = "started",
AgentId = agent.AgentId,
AgentName = agent.AgentName,
ToolCallId = data.ToolCallId,
CustomAgentName = data.AgentName,
CustomAgentDisplayName = data.AgentDisplayName,
CustomAgentDescription = data.AgentDescription,
};
}
private SubagentEventDto CreateSubagentCompletedEvent(
AgentIdentity agent,
ProviderSubagentCompletedEvent 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,
ProviderSubagentFailedEvent 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,
ProviderSubagentSelectedEvent 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 AssistantIntentEventDto? CreateAssistantIntentEvent(
AgentIdentity agent,
string? intent)
{
string? normalizedIntent = intent?.Trim();
if (string.IsNullOrWhiteSpace(normalizedIntent))
{
return null;
}
return new AssistantIntentEventDto
{
Type = "assistant-intent",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
Intent = normalizedIntent,
};
}
private ReasoningDeltaEventDto? CreateReasoningDeltaEvent(
AgentIdentity agent,
string? reasoningId,
string? deltaContent)
{
if (string.IsNullOrWhiteSpace(reasoningId)
|| string.IsNullOrEmpty(deltaContent))
{
return null;
}
return new ReasoningDeltaEventDto
{
Type = "reasoning-delta",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
ReasoningId = reasoningId,
ContentDelta = deltaContent,
};
}
private TurnDeltaEventDto CreateTurnDeltaEvent(
string messageId,
string authorName,
string contentDelta,
string? content)
{
return new TurnDeltaEventDto
{
Type = "turn-delta",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
MessageId = messageId,
AuthorName = authorName,
ContentDelta = contentDelta,
Content = content,
};
}
private SkillInvokedEventDto CreateSkillInvokedEvent(
AgentIdentity agent,
ProviderSkillInvokedEvent data)
{
return new SkillInvokedEventDto
{
Type = "skill-invoked",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
SkillName = data.SkillName,
Path = data.Path,
Content = data.Content,
AllowedTools = data.AllowedTools,
PluginName = data.PluginName,
PluginVersion = data.PluginVersion,
};
}
private HookLifecycleEventDto CreateHookLifecycleEvent(
AgentIdentity agent,
string phase,
string hookInvocationId,
string hookType,
object? input = null,
bool? success = null,
object? output = null,
string? error = null)
{
return new HookLifecycleEventDto
{
Type = "hook-lifecycle",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
HookInvocationId = hookInvocationId,
HookType = hookType,
Phase = phase,
Input = input,
Success = success,
Output = output,
Error = error,
};
}
private AssistantUsageEventDto CreateAssistantUsageEvent(
AgentIdentity agent,
ProviderAssistantUsageEvent data)
{
return new AssistantUsageEventDto
{
Type = "assistant-usage",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
Model = data.Model,
InputTokens = data.InputTokens,
OutputTokens = data.OutputTokens,
CacheReadTokens = data.CacheReadTokens,
CacheWriteTokens = data.CacheWriteTokens,
Cost = data.Cost,
Duration = data.Duration,
TotalNanoAiu = data.TotalNanoAiu,
QuotaSnapshots = data.QuotaSnapshots,
};
}
private SessionUsageEventDto CreateUsageEvent(AgentIdentity agent, ProviderSessionUsageEvent data)
{
return new SessionUsageEventDto
{
Type = "session-usage",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
TokenLimit = data.TokenLimit,
CurrentTokens = data.CurrentTokens,
MessagesLength = data.MessagesLength,
SystemTokens = data.SystemTokens,
ConversationTokens = data.ConversationTokens,
ToolDefinitionsTokens = data.ToolDefinitionsTokens,
IsInitial = data.IsInitial,
};
}
private SessionCompactionEventDto CreateCompactionStartEvent(
AgentIdentity agent,
ProviderSessionCompactionStartEvent 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,
ProviderSessionCompactionCompleteEvent 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,
};
}
private static string? NormalizeOptionalString(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
}
@@ -45,6 +45,12 @@ internal static class WorkflowDefinitionExtensions
return string.Equals(node.Kind, "agent", StringComparison.OrdinalIgnoreCase);
}
public static bool IsSubWorkflowNode(this WorkflowNodeDto node)
{
ArgumentNullException.ThrowIfNull(node);
return string.Equals(node.Kind, "sub-workflow", StringComparison.OrdinalIgnoreCase);
}
public static string GetAgentId(this WorkflowNodeDto node)
{
ArgumentNullException.ThrowIfNull(node);
@@ -60,6 +66,15 @@ internal static class WorkflowDefinitionExtensions
public static WorkflowDefinitionDto ResolveSubWorkflowDefinition(
this WorkflowNodeDto node,
IReadOnlyDictionary<string, WorkflowDefinitionDto>? workflowLibrary)
{
return node.TryResolveSubWorkflowDefinition(workflowLibrary)
?? throw new InvalidOperationException(
$"Sub-workflow node \"{node.Id}\" references unknown workflow \"{node.Config.WorkflowId}\".");
}
public static WorkflowDefinitionDto? TryResolveSubWorkflowDefinition(
this WorkflowNodeDto node,
IReadOnlyDictionary<string, WorkflowDefinitionDto>? workflowLibrary)
{
ArgumentNullException.ThrowIfNull(node);
@@ -75,8 +90,7 @@ internal static class WorkflowDefinitionExtensions
return workflow;
}
throw new InvalidOperationException(
$"Sub-workflow node \"{node.Id}\" references unknown workflow \"{node.Config.WorkflowId}\".");
return null;
}
public static bool IsOrchestrationMode(this WorkflowDefinitionDto workflow, string mode)
@@ -87,6 +101,67 @@ internal static class WorkflowDefinitionExtensions
return string.Equals(workflow.Settings.OrchestrationMode, mode, StringComparison.OrdinalIgnoreCase);
}
public static WorkflowNodeDto? FindSubWorkflowNode(
this WorkflowDefinitionDto workflow,
string? nodeId,
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null)
{
ArgumentNullException.ThrowIfNull(workflow);
string? normalizedNodeId = NormalizeOptionalString(nodeId);
if (normalizedNodeId is null)
{
return null;
}
return FindSubWorkflowNode(
workflow,
normalizedNodeId,
CreateWorkflowLibraryMap(workflowLibrary),
new HashSet<string>(StringComparer.Ordinal),
new HashSet<WorkflowDefinitionDto>(ReferenceEqualityComparer.Instance));
}
internal static WorkflowNodeDto? FindSubWorkflowNode(
this WorkflowDefinitionDto workflow,
string? nodeId,
IReadOnlyDictionary<string, WorkflowDefinitionDto>? workflowLibrary)
{
ArgumentNullException.ThrowIfNull(workflow);
string? normalizedNodeId = NormalizeOptionalString(nodeId);
if (normalizedNodeId is null)
{
return null;
}
return FindSubWorkflowNode(
workflow,
normalizedNodeId,
workflowLibrary ?? EmptyWorkflowLibrary,
new HashSet<string>(StringComparer.Ordinal),
new HashSet<WorkflowDefinitionDto>(ReferenceEqualityComparer.Instance));
}
internal static string GetSubworkflowDisplayName(
this WorkflowNodeDto node,
IReadOnlyDictionary<string, WorkflowDefinitionDto>? workflowLibrary)
{
ArgumentNullException.ThrowIfNull(node);
WorkflowDefinitionDto? resolvedWorkflow = null;
if (node.Config.InlineWorkflow is not null)
{
resolvedWorkflow = node.Config.InlineWorkflow;
}
else if (!string.IsNullOrWhiteSpace(node.Config.WorkflowId)
&& workflowLibrary is not null
&& workflowLibrary.TryGetValue(node.Config.WorkflowId, out WorkflowDefinitionDto? workflow))
{
resolvedWorkflow = workflow;
}
return FirstNonBlank(node.Label, resolvedWorkflow?.Name, node.Config.WorkflowId, node.Id) ?? "sub-workflow";
}
private static readonly IReadOnlyDictionary<string, WorkflowDefinitionDto> EmptyWorkflowLibrary =
new Dictionary<string, WorkflowDefinitionDto>(StringComparer.Ordinal);
@@ -123,12 +198,68 @@ internal static class WorkflowDefinitionExtensions
continue;
}
WorkflowDefinitionDto subWorkflow = node.ResolveSubWorkflowDefinition(workflowLibrary);
CollectAgentNodes(subWorkflow, workflowLibrary, agentNodes, visitedWorkflowIds, visitedAnonymousWorkflows);
WorkflowDefinitionDto? subWorkflow = node.TryResolveSubWorkflowDefinition(workflowLibrary);
if (subWorkflow is not null)
{
CollectAgentNodes(subWorkflow, workflowLibrary, agentNodes, visitedWorkflowIds, visitedAnonymousWorkflows);
}
}
}
private static Dictionary<string, WorkflowDefinitionDto> CreateWorkflowLibraryMap(
private static WorkflowNodeDto? FindSubWorkflowNode(
WorkflowDefinitionDto workflowDefinition,
string nodeId,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
ISet<string> visitedWorkflowIds,
ISet<WorkflowDefinitionDto> visitedAnonymousWorkflows)
{
string? workflowId = NormalizeOptionalString(workflowDefinition.Id);
if (workflowId is not null)
{
if (!visitedWorkflowIds.Add(workflowId))
{
return null;
}
}
else if (!visitedAnonymousWorkflows.Add(workflowDefinition))
{
return null;
}
foreach (WorkflowNodeDto node in workflowDefinition.Graph.Nodes)
{
if (!node.IsSubWorkflowNode())
{
continue;
}
if (string.Equals(node.Id, nodeId, StringComparison.OrdinalIgnoreCase))
{
return node;
}
WorkflowDefinitionDto? subWorkflow = node.TryResolveSubWorkflowDefinition(workflowLibrary);
if (subWorkflow is null)
{
continue;
}
WorkflowNodeDto? match = FindSubWorkflowNode(
subWorkflow,
nodeId,
workflowLibrary,
visitedWorkflowIds,
visitedAnonymousWorkflows);
if (match is not null)
{
return match;
}
}
return null;
}
internal static Dictionary<string, WorkflowDefinitionDto> CreateWorkflowLibraryMap(
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary)
{
return workflowLibrary?
@@ -0,0 +1,196 @@
using System.Linq;
using Aryx.AgentHost.Contracts;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
namespace Aryx.AgentHost.Services;
internal static class WorkflowOrchestrationFactory
{
public static HandoffWorkflowBuilder CreateHandoffWorkflowBuilder(
AIAgent entryAgent,
HandoffModeSettingsDto? settings = null)
{
HandoffModeSettingsDto effectiveSettings = settings ?? new HandoffModeSettingsDto();
HandoffWorkflowBuilder builder = AgentWorkflowBuilder.CreateHandoffBuilderWith(entryAgent)
.WithToolCallFilteringBehavior(MapHandoffToolCallFiltering(effectiveSettings.ToolCallFiltering))
.WithHandoffInstructions(NormalizeOptionalString(effectiveSettings.HandoffInstructions)
?? HandoffWorkflowGuidance.CreateWorkflowInstructions());
if (effectiveSettings.ReturnToPrevious)
{
builder = builder.EnableReturnToPrevious();
}
return builder;
}
public static Workflow CreateHandoffWorkflow(
WorkflowDefinitionDto workflowDefinition,
IReadOnlyList<AIAgent> agents)
{
ArgumentNullException.ThrowIfNull(workflowDefinition);
ArgumentNullException.ThrowIfNull(agents);
IReadOnlyList<WorkflowNodeDto> agentNodes = workflowDefinition.GetAgentNodes();
Dictionary<string, AIAgent> agentsById = CreateAgentMap(agents);
WorkflowNodeDto triageNode = ResolveTriageAgentNode(workflowDefinition, agentNodes);
AIAgent triageAgent = ResolveAgentForNode(triageNode, agentsById);
HandoffModeSettingsDto? settings = workflowDefinition.Settings.ModeSettings?.Handoff;
HandoffWorkflowBuilder builder = CreateHandoffWorkflowBuilder(triageAgent, settings);
List<WorkflowNodeDto> specialistNodes = agentNodes
.Where(node => !string.Equals(node.Id, triageNode.Id, StringComparison.Ordinal))
.ToList();
if (specialistNodes.Count == 0)
{
throw new InvalidOperationException("Handoff workflows require at least one specialist agent in addition to the triage agent.");
}
foreach (WorkflowNodeDto specialistNode in specialistNodes)
{
AIAgent specialistAgent = ResolveAgentForNode(specialistNode, agentsById);
builder.WithHandoff(
triageAgent,
specialistAgent,
HandoffWorkflowGuidance.CreateForwardReason(specialistNode));
if (settings?.ReturnToPrevious != true)
{
builder.WithHandoff(
specialistAgent,
triageAgent,
HandoffWorkflowGuidance.CreateReturnReason(triageNode));
}
}
return builder.Build();
}
public static GroupChatWorkflowBuilder CreateGroupChatWorkflowBuilder(
WorkflowDefinitionDto workflowDefinition,
IReadOnlyList<AIAgent> agents)
{
ArgumentNullException.ThrowIfNull(workflowDefinition);
ArgumentNullException.ThrowIfNull(agents);
int maxRounds = ResolveGroupChatMaxRounds(workflowDefinition);
GroupChatWorkflowBuilder builder = AgentWorkflowBuilder.CreateGroupChatBuilderWith(
participants => new RoundRobinGroupChatManager(participants)
{
MaximumIterationCount = maxRounds,
})
.AddParticipants(agents);
string? name = NormalizeOptionalString(workflowDefinition.Name);
if (name is not null)
{
builder.WithName(name);
}
string? description = NormalizeOptionalString(workflowDefinition.Description);
if (description is not null)
{
builder.WithDescription(description);
}
return builder;
}
public static Workflow CreateGroupChatWorkflow(
WorkflowDefinitionDto workflowDefinition,
IReadOnlyList<AIAgent> agents)
{
return CreateGroupChatWorkflowBuilder(workflowDefinition, agents).Build();
}
private static Dictionary<string, AIAgent> CreateAgentMap(IReadOnlyList<AIAgent> agents)
{
Dictionary<string, AIAgent> agentMap = new(StringComparer.OrdinalIgnoreCase);
foreach (AIAgent agent in agents)
{
if (!string.IsNullOrWhiteSpace(agent.Id))
{
agentMap[agent.Id] = agent;
}
if (!string.IsNullOrWhiteSpace(agent.Name))
{
agentMap[agent.Name] = agent;
}
}
return agentMap;
}
private static AIAgent ResolveAgentForNode(
WorkflowNodeDto node,
IReadOnlyDictionary<string, AIAgent> agentsById)
{
string agentId = node.GetAgentId();
if (agentsById.TryGetValue(agentId, out AIAgent? agent))
{
return agent;
}
string agentName = node.GetAgentName();
if (agentsById.TryGetValue(agentName, out agent))
{
return agent;
}
throw new InvalidOperationException($"Workflow agent \"{agentId}\" could not be resolved from the constructed agents.");
}
private static WorkflowNodeDto ResolveTriageAgentNode(
WorkflowDefinitionDto workflowDefinition,
IReadOnlyList<WorkflowNodeDto> agentNodes)
{
if (agentNodes.Count == 0)
{
throw new InvalidOperationException("Handoff workflows require at least one agent node.");
}
string? triageAgentNodeId = NormalizeOptionalString(workflowDefinition.Settings.ModeSettings?.Handoff?.TriageAgentNodeId);
if (triageAgentNodeId is null)
{
return agentNodes[0];
}
WorkflowNodeDto? triageNode = agentNodes.FirstOrDefault(node => string.Equals(node.Id, triageAgentNodeId, StringComparison.Ordinal));
return triageNode ?? throw new InvalidOperationException(
$"Handoff workflow triage agent node \"{triageAgentNodeId}\" was not found in the workflow graph.");
}
private static HandoffToolCallFilteringBehavior MapHandoffToolCallFiltering(string? value)
{
return value?.Trim().ToLowerInvariant() switch
{
"none" => HandoffToolCallFilteringBehavior.None,
"all" => HandoffToolCallFilteringBehavior.All,
_ => HandoffToolCallFilteringBehavior.HandoffOnly,
};
}
private static int ResolveGroupChatMaxRounds(WorkflowDefinitionDto workflowDefinition)
{
int? configuredMaxRounds = workflowDefinition.Settings.ModeSettings?.GroupChat?.MaxRounds;
if (configuredMaxRounds is > 0)
{
return configuredMaxRounds.Value;
}
if (workflowDefinition.Settings.MaxIterations is > 0)
{
return workflowDefinition.Settings.MaxIterations.Value;
}
return 5;
}
private static string? NormalizeOptionalString(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
}
@@ -1,4 +1,3 @@
using System.Collections.Concurrent;
using System.Text.Json;
using Aryx.AgentHost.Contracts;
using Microsoft.Agents.AI.Workflows;
@@ -20,15 +19,15 @@ internal static class WorkflowRequestInfoInterpreter
RunTurnCommandDto command,
RequestInfoEvent requestInfo,
AgentIdentity? activeAgent,
ConcurrentDictionary<string, string> toolNamesByCallId)
ToolCallRegistry toolCalls)
{
RequestInterpretation interpretation = InterpretRequest(command.Workflow, requestInfo);
RequestInterpretation interpretation = InterpretRequest(command, requestInfo);
return interpretation switch
{
HandoffRequestInterpretation handoff =>
CreateHandoffActivity(command, handoff.TargetAgent, activeAgent),
ToolRequestInterpretation tool when activeAgent.HasValue =>
CreateToolCallingActivity(command, activeAgent.Value, tool, toolNamesByCallId),
CreateToolCallingActivity(command, activeAgent.Value, tool, toolCalls),
_ => null,
};
}
@@ -38,7 +37,7 @@ internal static class WorkflowRequestInfoInterpreter
RequestInfoEvent requestInfo)
{
return command.Workflow.IsOrchestrationMode("handoff")
&& InterpretRequest(command.Workflow, requestInfo) is UnknownRequestInterpretation;
&& InterpretRequest(command, requestInfo) is UnknownRequestInterpretation;
}
private static AgentActivityEventDto CreateHandoffActivity(
@@ -54,6 +53,8 @@ internal static class WorkflowRequestInfoInterpreter
ActivityType = HandoffActivityType,
AgentId = handoffAgent.AgentId,
AgentName = handoffAgent.AgentName,
SubworkflowNodeId = handoffAgent.Subworkflow?.SubworkflowNodeId,
SubworkflowName = handoffAgent.Subworkflow?.SubworkflowName,
SourceAgentId = activeAgent?.AgentId,
SourceAgentName = activeAgent?.AgentName,
};
@@ -63,15 +64,13 @@ internal static class WorkflowRequestInfoInterpreter
RunTurnCommandDto command,
AgentIdentity activeAgent,
ToolRequestInterpretation tool,
ConcurrentDictionary<string, string> toolNamesByCallId)
ToolCallRegistry toolCalls)
{
if (tool.ToolCallId is not null && toolNamesByCallId.ContainsKey(tool.ToolCallId))
if (!toolCalls.TryRecordToolRequest(tool.ToolCallId, tool.ToolName, tool.ToolArguments))
{
return null;
}
TrackToolCallId(toolNamesByCallId, tool.ToolCallId, tool.ToolName);
return new AgentActivityEventDto
{
Type = "agent-activity",
@@ -80,28 +79,19 @@ internal static class WorkflowRequestInfoInterpreter
ActivityType = ToolCallingActivityType,
AgentId = activeAgent.AgentId,
AgentName = activeAgent.AgentName,
SubworkflowNodeId = activeAgent.Subworkflow?.SubworkflowNodeId,
SubworkflowName = activeAgent.Subworkflow?.SubworkflowName,
ToolName = tool.ToolName,
ToolCallId = tool.ToolCallId,
ToolArguments = tool.ToolArguments,
};
}
private static void TrackToolCallId(
ConcurrentDictionary<string, string> toolNamesByCallId,
string? toolCallId,
string toolName)
{
if (toolCallId is not null)
{
toolNamesByCallId[toolCallId] = toolName;
}
}
private static RequestInterpretation InterpretRequest(
WorkflowDefinitionDto workflow,
RunTurnCommandDto command,
RequestInfoEvent requestInfo)
{
if (TryGetHandoffTarget(workflow, requestInfo, out AgentIdentity handoffAgent))
if (TryGetHandoffTarget(command, requestInfo, out AgentIdentity handoffAgent))
{
return new HandoffRequestInterpretation(handoffAgent);
}
@@ -112,7 +102,7 @@ internal static class WorkflowRequestInfoInterpreter
}
private static bool TryGetHandoffTarget(
WorkflowDefinitionDto workflow,
RunTurnCommandDto command,
RequestInfoEvent requestInfo,
out AgentIdentity agent)
{
@@ -131,7 +121,8 @@ internal static class WorkflowRequestInfoInterpreter
}
agent = AgentIdentityResolver.ResolveAgentIdentity(
workflow,
command.Workflow,
command.WorkflowLibrary,
target.Id,
target.Name);
return !string.IsNullOrWhiteSpace(agent.AgentName);
@@ -175,7 +166,7 @@ internal static class WorkflowRequestInfoInterpreter
{
if (requestData.Is<McpServerToolCallContent>(out McpServerToolCallContent? mcpToolCall))
{
toolName = NormalizeOptionalString(mcpToolCall.ToolName)
toolName = NormalizeOptionalString(mcpToolCall.Name)
?? NormalizeOptionalString(mcpToolCall.ServerName)
?? string.Empty;
toolCallId = NormalizeOptionalString(mcpToolCall.CallId);
@@ -205,6 +196,26 @@ internal static class WorkflowRequestInfoInterpreter
return false;
}
public static IReadOnlyDictionary<string, object?>? NormalizeRawToolArguments(object? rawArguments)
{
return rawArguments switch
{
null => null,
JsonElement { ValueKind: JsonValueKind.Object } element => NormalizeToolArgumentObject(element),
IEnumerable<KeyValuePair<string, object?>> dictionary => NormalizeToolArguments(dictionary),
_ => NormalizeRawToolArgumentsViaJson(rawArguments),
};
}
private static IReadOnlyDictionary<string, object?>? NormalizeRawToolArgumentsViaJson(object value)
{
string json = JsonSerializer.Serialize(value, value.GetType(), JsonOptions);
using JsonDocument document = JsonDocument.Parse(json);
return document.RootElement.ValueKind == JsonValueKind.Object
? NormalizeToolArgumentObject(document.RootElement)
: null;
}
private static IReadOnlyDictionary<string, object?>? NormalizeToolArguments(
IEnumerable<KeyValuePair<string, object?>>? arguments)
{
@@ -112,7 +112,7 @@ internal sealed class WorkflowRunner
builder = builder.WithName(workflowDefinition.Name);
}
return builder.WithOutputFrom(routes[endNode.Id].Exit).Build();
return builder.WithOutputFrom(routes[endNode.Id].Exit).WithOpenTelemetry().Build();
}
private WorkflowNodeRoute CreateNodeRoute(
@@ -141,7 +141,7 @@ internal sealed class WorkflowRunner
throw new InvalidOperationException($"Workflow node \"{node.Id}\" references unknown agent \"{agentId}\".");
}
return new WorkflowNodeRoute(agent.BindAsExecutor(CopilotAgentBundle.CreateAgentHostOptions()));
return new WorkflowNodeRoute(agent.BindAsExecutor(AgentHostOptionsFactory.CreateDefault()));
}
if (string.Equals(node.Kind, "code-executor", StringComparison.OrdinalIgnoreCase))
@@ -115,19 +115,103 @@ public sealed class AgentIdentityResolverTests
Assert.Equal("UX Specialist", agent.AgentName);
}
[Fact]
public void TryResolveKnownAgentIdentity_ResolvesReferencedSubworkflowAgentWithContext()
{
WorkflowDefinitionDto nestedWorkflow = CreateWorkflow(
"nested-review-workflow",
[
CreateAgent("agent-reviewer", "Reviewer"),
],
orchestrationMode: "single");
WorkflowDefinitionDto workflow = CreateWorkflow(
"parent-workflow",
[
CreateSubworkflow("subworkflow-review", "Review Lane", workflowId: nestedWorkflow.Id),
],
orchestrationMode: "single");
bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity(
workflow,
[nestedWorkflow],
"Reviewer_agent_reviewer",
out AgentIdentity agent);
Assert.True(resolved);
Assert.Equal("agent-reviewer", agent.AgentId);
Assert.Equal("Reviewer", agent.AgentName);
Assert.Equal("subworkflow-review", agent.Subworkflow?.SubworkflowNodeId);
Assert.Equal("Review Lane", agent.Subworkflow?.SubworkflowName);
}
[Fact]
public void BuildAgentSubworkflowIndex_UsesImmediateNestedSubworkflowContext()
{
WorkflowDefinitionDto innerWorkflow = CreateWorkflow(
"inner-workflow",
[
CreateAgent("agent-inner-reviewer", "Inner Reviewer"),
],
orchestrationMode: "single");
WorkflowDefinitionDto outerWorkflow = CreateWorkflow(
"outer-workflow",
[
CreateSubworkflow("subworkflow-inner", "Inner Review", inlineWorkflow: innerWorkflow),
],
orchestrationMode: "single");
WorkflowDefinitionDto workflow = CreateWorkflow(
"parent-workflow",
[
CreateSubworkflow("subworkflow-outer", "Outer Review", inlineWorkflow: outerWorkflow),
],
orchestrationMode: "single");
IReadOnlyDictionary<string, SubworkflowContext> index =
AgentIdentityResolver.BuildAgentSubworkflowIndex(workflow);
Assert.True(index.TryGetValue("agent-inner-reviewer", out SubworkflowContext subworkflow));
Assert.Equal("subworkflow-inner", subworkflow.SubworkflowNodeId);
Assert.Equal("Inner Review", subworkflow.SubworkflowName);
}
[Fact]
public void BuildAgentSubworkflowIndex_SkipsUnresolvableSubWorkflowReferences()
{
WorkflowDefinitionDto workflow = CreateWorkflow(
"parent-workflow",
[
CreateAgent("agent-top-level", "Top Level"),
CreateSubworkflow("subworkflow-missing", "Missing Pipeline", workflowId: "nonexistent-workflow"),
],
orchestrationMode: "concurrent");
IReadOnlyDictionary<string, SubworkflowContext> index =
AgentIdentityResolver.BuildAgentSubworkflowIndex(workflow);
Assert.Empty(index);
}
private static WorkflowDefinitionDto CreateWorkflow(
IReadOnlyList<WorkflowNodeDto> agents,
IReadOnlyList<WorkflowNodeDto> nodes,
string orchestrationMode = "concurrent")
{
return CreateWorkflow($"{orchestrationMode}-workflow", nodes, orchestrationMode);
}
private static WorkflowDefinitionDto CreateWorkflow(
string id,
IReadOnlyList<WorkflowNodeDto> nodes,
string orchestrationMode = "concurrent")
{
return new WorkflowDefinitionDto
{
Id = $"{orchestrationMode}-workflow",
Id = id,
Name = "Workflow",
Graph = new WorkflowGraphDto
{
Nodes =
[
.. agents,
.. nodes,
],
},
Settings = new WorkflowSettingsDto
@@ -154,4 +238,24 @@ public sealed class AgentIdentityResolverTests
},
};
}
private static WorkflowNodeDto CreateSubworkflow(
string id,
string label,
string? workflowId = null,
WorkflowDefinitionDto? inlineWorkflow = null)
{
return new WorkflowNodeDto
{
Id = id,
Kind = "sub-workflow",
Label = label,
Config = new WorkflowNodeConfigDto
{
Kind = "sub-workflow",
WorkflowId = workflowId,
InlineWorkflow = inlineWorkflow,
},
};
}
}
@@ -8,10 +8,16 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="coverlet.collector" Version="8.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
@@ -163,12 +163,12 @@ public sealed class CopilotAgentBundleTests
{
ChatClientAgent entryAgent = CreateChatClientAgent("agent-1", "Primary");
HandoffsWorkflowBuilder builder = CopilotAgentBundle.CreateHandoffWorkflowBuilder(entryAgent);
HandoffWorkflowBuilder builder = CopilotAgentBundle.CreateHandoffWorkflowBuilder(entryAgent);
FieldInfo field = typeof(HandoffsWorkflowBuilder).GetField(
FieldInfo field = GetInstanceField(
typeof(HandoffWorkflowBuilder),
"_toolCallFilteringBehavior",
BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException("Expected HandoffsWorkflowBuilder to expose a filtering field.");
"Expected HandoffWorkflowBuilder to expose a filtering field.");
HandoffToolCallFilteringBehavior behavior = Assert.IsType<HandoffToolCallFilteringBehavior>(field.GetValue(builder));
@@ -181,7 +181,7 @@ public sealed class CopilotAgentBundleTests
{
ChatClientAgent entryAgent = CreateChatClientAgent("agent-1", "Primary");
HandoffsWorkflowBuilder builder = CopilotAgentBundle.CreateHandoffWorkflowBuilder(
HandoffWorkflowBuilder builder = CopilotAgentBundle.CreateHandoffWorkflowBuilder(
entryAgent,
new HandoffModeSettingsDto
{
@@ -190,12 +190,17 @@ public sealed class CopilotAgentBundleTests
HandoffInstructions = "Use custom delegation guidance.",
});
FieldInfo filteringField = typeof(HandoffsWorkflowBuilder).GetField(
FieldInfo filteringField = GetInstanceField(
typeof(HandoffWorkflowBuilder),
"_toolCallFilteringBehavior",
BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException("Expected HandoffsWorkflowBuilder to expose a filtering field.");
"Expected HandoffWorkflowBuilder to expose a filtering field.");
FieldInfo returnToPreviousField = GetInstanceField(
typeof(HandoffWorkflowBuilder),
"_returnToPrevious",
"Expected HandoffWorkflowBuilder to expose a return-to-previous field.");
Assert.Equal(HandoffToolCallFilteringBehavior.All, filteringField.GetValue(builder));
Assert.Equal(true, returnToPreviousField.GetValue(builder));
Assert.Equal("Use custom delegation guidance.", builder.HandoffInstructions);
}
@@ -631,6 +636,20 @@ public sealed class CopilotAgentBundleTests
});
}
private static FieldInfo GetInstanceField(Type type, string name, string errorMessage)
{
for (Type? current = type; current is not null; current = current.BaseType)
{
FieldInfo? field = current.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
if (field is not null)
{
return field;
}
}
throw new InvalidOperationException(errorMessage);
}
private static AIFunctionDeclaration CreateHandoffDeclaration()
{
return AIFunctionFactory.CreateDeclaration(
@@ -0,0 +1,77 @@
using Aryx.AgentHost.Contracts;
using Aryx.AgentHost.Services;
using GitHub.Copilot.SDK;
namespace Aryx.AgentHost.Tests;
public sealed class CopilotEventAdapterTests
{
private static readonly CopilotEventAdapter Adapter = new();
[Fact]
public void Capabilities_AdvertiseRichTurnStreamSupport()
{
ProviderTurnStreamCapabilities capabilities = Adapter.Capabilities;
Assert.True(capabilities.SupportsIntent);
Assert.True(capabilities.SupportsReasoningDelta);
Assert.True(capabilities.SupportsReasoningBlock);
Assert.True(capabilities.SupportsToolExecutionProgress);
Assert.True(capabilities.SupportsToolExecutionPartialResult);
Assert.True(capabilities.SupportsToolExecutionCompletion);
Assert.True(capabilities.SupportsSubagentLifecycle);
Assert.True(capabilities.SupportsHookLifecycle);
Assert.True(capabilities.SupportsSessionCompaction);
Assert.True(capabilities.SupportsPendingMessagesMutation);
Assert.True(capabilities.SupportsSessionTurnBoundaries);
}
[Fact]
public void TryAdapt_ToolExecutionComplete_MapsNormalizedResult()
{
ProviderToolExecutionCompleteEvent evt = Assert.IsType<ProviderToolExecutionCompleteEvent>(
Adapter.TryAdapt(SessionEvent.FromJson(
"""
{
"type": "tool.execution_complete",
"data": {
"toolCallId": "tool-call-1",
"success": true,
"result": {
"content": "summary",
"detailedContent": "summary\nfull"
}
},
"id": "11111111-2222-3333-4444-555555555555",
"timestamp": "2026-03-27T00:00:00Z"
}
""")));
Assert.Equal("tool-call-1", evt.ToolCallId);
Assert.True(evt.Success);
Assert.Equal("summary", evt.ResultContent);
Assert.Equal("summary\nfull", evt.DetailedResultContent);
Assert.Null(evt.Error);
}
[Fact]
public void TryAdapt_AssistantReasoning_MapsCompletedReasoningBlock()
{
ProviderAssistantReasoningEvent evt = Assert.IsType<ProviderAssistantReasoningEvent>(
Adapter.TryAdapt(SessionEvent.FromJson(
"""
{
"type": "assistant.reasoning",
"data": {
"reasoningId": "reasoning-1",
"content": "Planning the next step."
},
"id": "66666666-7777-8888-9999-aaaaaaaaaaaa",
"timestamp": "2026-03-27T00:00:00Z"
}
""")));
Assert.Equal("reasoning-1", evt.ReasoningId);
Assert.Equal("Planning the next step.", evt.Content);
}
}
@@ -59,23 +59,184 @@ public sealed class CopilotTurnExecutionStateTests
Assert.Equal("agent-1", observedAgent.AgentId);
}
[Fact]
public void ObserveSessionEvent_AssistantMessageDelta_ForNestedAgent_IncludesSubworkflowContext()
{
RunTurnCommandDto command = CreateCommandWithReferencedSubworkflow();
CopilotTurnExecutionState state = new(command);
WorkflowDefinitionDto nestedWorkflow = Assert.Single(command.WorkflowLibrary!);
WorkflowNodeDto nestedAgent = Assert.Single(nestedWorkflow.GetAgentNodes());
state.ObserveSessionEvent(
nestedAgent,
SessionEvent.FromJson(
"""
{
"type": "assistant.message_delta",
"data": {
"messageId": "msg-nested-1",
"deltaContent": "Reviewing"
},
"id": "7ef95d90-7ee7-45e2-ac38-cf749caf4f69",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
AgentActivityEventDto activity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
Assert.Equal("thinking", activity.ActivityType);
Assert.Equal("agent-reviewer", activity.AgentId);
Assert.Equal("Reviewer", activity.AgentName);
Assert.Equal("subworkflow-review", activity.SubworkflowNodeId);
Assert.Equal("Review Lane", activity.SubworkflowName);
Assert.True(state.ActiveAgent.HasValue);
Assert.Equal("subworkflow-review", state.ActiveAgent.Value.Subworkflow?.SubworkflowNodeId);
Assert.Equal("Review Lane", state.ActiveAgent.Value.Subworkflow?.SubworkflowName);
}
[Fact]
public void ObserveSessionEvent_ToolExecutionStart_TracksToolNameByCallIdAndQueuesToolActivity()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""{"type":"tool.execution_start","data":{"toolCallId":"tool-call-1","toolName":"view","arguments":{"path":"/src/main.ts","view_range":[10,20]}},"id":"33333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:00Z"}"""));
AgentActivityEventDto toolActivity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
Assert.Equal("tool-calling", toolActivity.ActivityType);
Assert.Equal("view", toolActivity.ToolName);
Assert.Equal("tool-call-1", toolActivity.ToolCallId);
Assert.NotNull(toolActivity.ToolArguments);
Assert.Equal("/src/main.ts", toolActivity.ToolArguments["path"]);
Assert.True(state.ToolCalls.TryGetToolName("tool-call-1", out string? toolName));
Assert.Equal("view", toolName);
Assert.True(state.ToolCalls.HasTrackedArguments("tool-call-1"));
}
[Fact]
public void ObserveSessionEvent_ToolExecutionStart_WithoutArguments_SetsToolArgumentsToNull()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[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"}"""));
AgentActivityEventDto toolActivity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
Assert.Equal("tool-calling", toolActivity.ActivityType);
Assert.Equal("view", toolActivity.ToolName);
Assert.Equal("tool-call-1", toolActivity.ToolCallId);
Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-1", out string? toolName));
Assert.Equal("view", toolName);
Assert.Null(toolActivity.ToolArguments);
Assert.False(state.ToolCalls.HasTrackedArguments("tool-call-1"));
}
[Fact]
public void ObserveSessionEvent_ToolExecutionProgress_TracksLatestProgressMessage()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[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"}"""));
_ = state.DrainPendingEvents();
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""{"type":"tool.execution_progress","data":{"toolCallId":"tool-call-1","progressMessage":"Scanning repository"},"id":"43333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:01Z"}"""));
Assert.True(state.TryGetToolExecution("tool-call-1", out ProviderToolExecutionSnapshot? toolExecution));
Assert.NotNull(toolExecution);
Assert.Equal(ProviderToolExecutionStatus.Running, toolExecution.Status);
Assert.Equal("view", toolExecution.ToolName);
Assert.Equal("Scanning repository", toolExecution.LatestProgressMessage);
}
[Fact]
public void ObserveSessionEvent_ToolExecutionPartialResult_AppendsPartialOutput()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""{"type":"tool.execution_start","data":{"toolCallId":"tool-call-1","toolName":"bash"},"id":"53333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:00Z"}"""));
_ = state.DrainPendingEvents();
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""{"type":"tool.execution_partial_result","data":{"toolCallId":"tool-call-1","partialOutput":"first line\n"},"id":"63333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:01Z"}"""));
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""{"type":"tool.execution_partial_result","data":{"toolCallId":"tool-call-1","partialOutput":"second line"},"id":"73333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:02Z"}"""));
Assert.True(state.TryGetToolExecution("tool-call-1", out ProviderToolExecutionSnapshot? toolExecution));
Assert.NotNull(toolExecution);
Assert.Equal("first line\nsecond line", toolExecution.PartialOutput);
}
[Fact]
public void ObserveSessionEvent_ToolExecutionComplete_TracksFinalToolState()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""{"type":"tool.execution_start","data":{"toolCallId":"tool-call-1","toolName":"view","arguments":{"path":"README.md"}},"id":"83333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:00Z"}"""));
_ = state.DrainPendingEvents();
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
"type": "tool.execution_complete",
"data": {
"toolCallId": "tool-call-1",
"success": true,
"result": {
"content": "README excerpt",
"detailedContent": "README excerpt\nwith more detail"
}
},
"id": "93333333-3333-3333-3333-333333333333",
"timestamp": "2026-03-27T00:00:01Z"
}
"""));
Assert.True(state.TryGetToolExecution("tool-call-1", out ProviderToolExecutionSnapshot? toolExecution));
Assert.NotNull(toolExecution);
Assert.Equal(ProviderToolExecutionStatus.Completed, toolExecution.Status);
Assert.Equal("view", toolExecution.ToolName);
Assert.NotNull(toolExecution.ToolArguments);
Assert.Equal("README excerpt", toolExecution.ResultContent);
Assert.Equal("README excerpt\nwith more detail", toolExecution.DetailedResultContent);
Assert.Null(toolExecution.Error);
}
[Fact]
public void ObserveSessionEvent_ToolExecutionCompleteFailure_TracksErrorState()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""{"type":"tool.execution_complete","data":{"toolCallId":"tool-call-err","success":false,"error":{"message":"permission denied"}},"id":"a3333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:01Z"}"""));
Assert.True(state.TryGetToolExecution("tool-call-err", out ProviderToolExecutionSnapshot? toolExecution));
Assert.NotNull(toolExecution);
Assert.Equal(ProviderToolExecutionStatus.Failed, toolExecution.Status);
Assert.Equal("permission denied", toolExecution.Error);
}
[Fact]
@@ -90,8 +251,9 @@ public sealed class CopilotTurnExecutionStateTests
"""{"type":"tool.execution_start","data":{"toolCallId":"tool-call-1","toolName":"handoff_to_specialist"},"id":"1ce9d1dc-68f1-4df5-9728-f97017233279","timestamp":"2026-03-27T00:00:00Z"}"""));
Assert.Empty(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-1", out string? toolName));
Assert.True(state.ToolCalls.TryGetToolName("tool-call-1", out string? toolName));
Assert.Equal("handoff_to_specialist", toolName);
Assert.False(state.ToolCalls.HasTrackedArguments("tool-call-1"));
}
[Fact]
@@ -210,10 +372,12 @@ public sealed class CopilotTurnExecutionStateTests
Assert.Contains(toolActivities, activity => activity.ToolCallId == "tool-call-2" && activity.ToolName == "view");
MessageReclassifiedEventDto reclassified = Assert.Single(pending.OfType<MessageReclassifiedEventDto>());
Assert.Equal("msg-3", reclassified.MessageId);
Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-1", out string? firstToolName));
Assert.True(state.ToolCalls.TryGetToolName("tool-call-1", out string? firstToolName));
Assert.Equal("rg", firstToolName);
Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-2", out string? secondToolName));
Assert.True(state.ToolCalls.TryGetToolName("tool-call-2", out string? secondToolName));
Assert.Equal("view", secondToolName);
Assert.False(state.ToolCalls.HasTrackedArguments("tool-call-1"));
Assert.False(state.ToolCalls.HasTrackedArguments("tool-call-2"));
}
[Fact]
@@ -305,6 +469,9 @@ public sealed class CopilotTurnExecutionStateTests
Assert.Equal("session-1", intent.SessionId);
Assert.Equal("agent-1", intent.AgentId);
Assert.Equal("Searching incident playbooks", intent.Intent);
Assert.True(state.TryGetLatestIntent("agent-1", out string? latestIntent));
Assert.NotNull(latestIntent);
Assert.Equal("Searching incident playbooks", latestIntent);
}
[Fact]
@@ -338,6 +505,93 @@ public sealed class CopilotTurnExecutionStateTests
Assert.Equal("agent-1", reasoning.AgentId);
Assert.Equal("reasoning-2", reasoning.ReasoningId);
Assert.Equal("Searching logs.", reasoning.ContentDelta);
Assert.True(state.TryGetReasoning("reasoning-2", out ProviderReasoningSnapshot? reasoningState));
Assert.NotNull(reasoningState);
Assert.Equal("Searching logs.", reasoningState.Content);
Assert.False(reasoningState.IsComplete);
}
[Fact]
public void ObserveSessionEvent_AssistantReasoning_TracksCompletedReasoningBlock()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
"type": "assistant.reasoning_delta",
"data": {
"reasoningId": "reasoning-3",
"deltaContent": "Planning."
},
"id": "cd269258-5e5d-46b6-bf3f-bd8cba793b1a",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
_ = state.DrainPendingEvents();
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
"type": "assistant.reasoning",
"data": {
"reasoningId": "reasoning-3",
"content": "Planning. Checking logs."
},
"id": "dd269258-5e5d-46b6-bf3f-bd8cba793b1a",
"timestamp": "2026-03-27T00:00:01Z"
}
"""));
Assert.True(state.TryGetReasoning("reasoning-3", out ProviderReasoningSnapshot? reasoning));
Assert.NotNull(reasoning);
Assert.Equal("Planning. Checking logs.", reasoning.Content);
Assert.True(reasoning.IsComplete);
}
[Fact]
public void ObserveSessionEvent_AssistantTurnBoundaries_TrackProviderTurnIds()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
"type": "assistant.turn_start",
"data": {
"turnId": "turn-sdk-1"
},
"id": "ed269258-5e5d-46b6-bf3f-bd8cba793b1a",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
Assert.Equal("turn-sdk-1", state.CurrentProviderTurnId);
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
"type": "assistant.turn_end",
"data": {
"turnId": "turn-sdk-1"
},
"id": "fd269258-5e5d-46b6-bf3f-bd8cba793b1a",
"timestamp": "2026-03-27T00:00:01Z"
}
"""));
Assert.Null(state.CurrentProviderTurnId);
Assert.Equal("turn-sdk-1", state.LatestCompletedProviderTurnId);
}
[Fact]
@@ -593,6 +847,53 @@ public sealed class CopilotTurnExecutionStateTests
Assert.Equal("agent-1", evt.AgentId);
}
[Fact]
public void ObserveSessionEvent_AssistantMessage_FinalizesTranscriptAndSuppressesLateDeltas()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
Assert.True(state.TryAppendDelta("msg-final", "Primary", "Draft response", out TranscriptSegment streamed));
Assert.False(streamed.IsFinalized);
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
"type": "assistant.message",
"data": {
"messageId": "msg-final",
"content": "Provider final response"
},
"id": "12121212-1212-1212-1212-121212121212",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
TurnDeltaEventDto correction = Assert.Single(state.DrainPendingEvents().OfType<TurnDeltaEventDto>());
Assert.Equal("msg-final", correction.MessageId);
Assert.Equal("Primary", correction.AuthorName);
Assert.Equal(string.Empty, correction.ContentDelta);
Assert.Equal("Provider final response", correction.Content);
Assert.False(state.TryAppendDelta("msg-final", "Primary", " late delta", out TranscriptSegment unchanged));
Assert.True(unchanged.IsFinalized);
Assert.Equal("Provider final response", unchanged.Content);
ChatMessage output = new(ChatRole.Assistant, "Workflow wording")
{
MessageId = "msg-final",
AuthorName = "assistant",
};
state.UpdateCompletedMessages([output], []);
ChatMessageDto completed = Assert.Single(state.FinalizeCompletedMessages());
Assert.Equal("msg-final", completed.Id);
Assert.Equal("Primary", completed.AuthorName);
Assert.Equal("Provider final response", completed.Content);
}
private static SessionEvent CreateHookStartEvent()
{
return SessionEvent.FromJson(
@@ -719,5 +1020,79 @@ public sealed class CopilotTurnExecutionStateTests
},
};
}
private static RunTurnCommandDto CreateCommandWithReferencedSubworkflow()
{
WorkflowDefinitionDto nestedWorkflow = CreateWorkflow(
"nested-review-workflow",
[
CreateAgent("agent-reviewer", "Reviewer"),
]);
return new RunTurnCommandDto
{
RequestId = "turn-1",
SessionId = "session-1",
WorkflowLibrary = [nestedWorkflow],
Workflow = CreateWorkflow(
"workflow-parent",
[
CreateSubworkflow("subworkflow-review", "Review Lane", workflowId: nestedWorkflow.Id),
]),
};
}
private static WorkflowDefinitionDto CreateWorkflow(string id, IReadOnlyList<WorkflowNodeDto> nodes)
{
return new WorkflowDefinitionDto
{
Id = id,
Name = "Execution State Workflow",
Graph = new WorkflowGraphDto
{
Nodes = [.. nodes],
},
Settings = new WorkflowSettingsDto
{
OrchestrationMode = "single",
},
};
}
private static WorkflowNodeDto CreateAgent(string id, string name)
{
return new WorkflowNodeDto
{
Id = id,
Kind = "agent",
Label = name,
Config = new WorkflowNodeConfigDto
{
Kind = "agent",
Id = id,
Name = name,
Model = "gpt-5.4",
Instructions = "Help with the request.",
},
};
}
private static WorkflowNodeDto CreateSubworkflow(
string id,
string label,
string? workflowId = null)
{
return new WorkflowNodeDto
{
Id = id,
Kind = "sub-workflow",
Label = label,
Config = new WorkflowNodeConfigDto
{
Kind = "sub-workflow",
WorkflowId = workflowId,
},
};
}
}
@@ -451,6 +451,50 @@ public sealed class CopilotWorkflowRunnerTests
});
}
[Fact]
public void ProjectCompletedMessages_UsesExactMessageIdMatchBeforeFallbackHeuristics()
{
RunTurnCommandDto command = CreateCommand(
"group-chat",
CreateAgent(id: "agent-group-writer", name: "Writer"),
CreateAgent(id: "agent-group-reviewer", name: "Reviewer"));
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessagesFromSegments(
command,
[
new ChatMessage(ChatRole.Assistant, "Revised draft with cleaner wording.")
{
AuthorName = "assistant",
MessageId = "msg-writer-2",
},
new ChatMessage(ChatRole.Assistant, "Review feedback with cleaner wording.")
{
AuthorName = "assistant",
MessageId = "msg-reviewer-1",
},
],
[
new TranscriptSegment("msg-writer-1", "Writer", "Initial draft."),
new TranscriptSegment("msg-reviewer-1", "Reviewer", "Review feedback with draft wording."),
new TranscriptSegment("msg-writer-2", "Writer", "Revised draft with draft wording."),
]);
Assert.Collection(
messages,
writer =>
{
Assert.Equal("msg-writer-2", writer.Id);
Assert.Equal("Writer", writer.AuthorName);
Assert.Equal("Revised draft with cleaner wording.", writer.Content);
},
reviewer =>
{
Assert.Equal("msg-reviewer-1", reviewer.Id);
Assert.Equal("Reviewer", reviewer.AuthorName);
Assert.Equal("Review feedback with cleaner wording.", reviewer.Content);
});
}
[Fact]
public void ProjectCompletedMessages_UsesFallbackAgentForGenericAssistantOutput()
{
@@ -531,6 +575,36 @@ public sealed class CopilotWorkflowRunnerTests
Assert.Equal("Done — GoogleClient.cs now contains the requested class with cleaned formatting.", message.Content);
}
[Fact]
public void ProjectCompletedMessages_PrefersFinalizedSegmentContentOverWorkflowOutput()
{
RunTurnCommandDto command = CreateCommand(
"single",
CreateAgent(id: "agent-single-primary", name: "Primary Agent"));
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessagesFromSegments(
command,
[
new ChatMessage(ChatRole.Assistant, "Workflow wording that arrived later.")
{
AuthorName = "assistant",
MessageId = "msg-1",
},
],
[
new TranscriptSegment(
"msg-1",
"Primary Agent",
"Provider final wording that should win.",
IsFinalized: true),
]);
ChatMessageDto message = Assert.Single(messages);
Assert.Equal("msg-1", message.Id);
Assert.Equal("Primary Agent", message.AuthorName);
Assert.Equal("Provider final wording that should win.", message.Content);
}
[Fact]
public void ProjectCompletedMessages_DropsBlankAssistantOutputMessages()
{
@@ -602,7 +676,22 @@ public sealed class CopilotWorkflowRunnerTests
Assert.Equal("msg-2", second.MessageId);
Assert.Equal("Implementer", second.AuthorName);
Assert.Equal("B", second.Content);
});
});
}
[Fact]
public void StreamingTranscriptBuffer_IgnoresLateDeltasAfterFinalSnapshot()
{
StreamingTranscriptBuffer buffer = new();
buffer.AppendDelta("msg-1", "Architect", "Draft");
Assert.True(buffer.TryApplySnapshot("msg-1", "Architect", "Final", out TranscriptSegment finalized));
Assert.True(finalized.IsFinalized);
Assert.Equal("Final", finalized.Content);
Assert.False(buffer.TryAppendDelta("msg-1", "Architect", " late delta", out TranscriptSegment unchanged));
Assert.True(unchanged.IsFinalized);
Assert.Equal("Final", unchanged.Content);
}
[Fact]
@@ -721,6 +810,222 @@ public sealed class CopilotWorkflowRunnerTests
});
}
[Fact]
public async Task HandleWorkflowEventAsync_EmitsToolActivityEnrichmentWhenRequestInfoAddsMissingArguments()
{
RunTurnCommandDto command = CreateApprovalCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
CreateAgent("agent-1", "Primary"),
SessionEvent.FromJson(
"""
{
"type": "tool.execution_start",
"data": {
"toolCallId": "tool-call-1",
"toolName": "view"
},
"id": "f61652d1-120e-4a9f-8f0e-1dbf04fb18da",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
_ = state.DrainPendingEvents();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("tool-call-1", "view", new Dictionary<string, object?>
{
["path"] = @"C:\workspace\README.md",
}));
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);
AgentActivityEventDto activity = Assert.Single(activities);
Assert.Equal("tool-calling", activity.ActivityType);
Assert.Equal("view", activity.ToolName);
Assert.Equal("tool-call-1", activity.ToolCallId);
Assert.NotNull(activity.ToolArguments);
Assert.Equal(@"C:\workspace\README.md", activity.ToolArguments["path"]);
Assert.True(state.ToolCalls.HasTrackedArguments("tool-call-1"));
}
[Fact]
public async Task ObserveSessionEvent_ToolExecutionStart_DoesNotDuplicateTrackedRequestInfoActivity()
{
RunTurnCommandDto command = CreateApprovalCommand();
CopilotTurnExecutionState state = new(command);
WorkflowNodeDto agent = CreateAgent("agent-1", "Primary");
state.ObserveSessionEvent(
agent,
SessionEvent.FromJson(
"""
{
"type": "assistant.message_delta",
"data": {
"messageId": "msg-1",
"deltaContent": "Inspecting"
},
"id": "b61652d1-120e-4a9f-8f0e-1dbf04fb18da",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
_ = state.DrainPendingEvents();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("tool-call-1", "view", new Dictionary<string, object?>
{
["path"] = @"C:\workspace\README.md",
}));
List<AgentActivityEventDto> requestActivities = [];
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 =>
{
requestActivities.Add(Assert.IsType<AgentActivityEventDto>(sidecarEvent));
return Task.CompletedTask;
}),
])!;
bool shouldEndTurn = await handleTask;
Assert.False(shouldEndTurn);
AgentActivityEventDto requestActivity = Assert.Single(requestActivities);
Assert.Equal("tool-calling", requestActivity.ActivityType);
Assert.True(state.ToolCalls.HasTrackedArguments("tool-call-1"));
state.ObserveSessionEvent(
agent,
SessionEvent.FromJson(
"""
{
"type": "tool.execution_start",
"data": {
"toolCallId": "tool-call-1",
"toolName": "view",
"arguments": {
"path": "C:\\workspace\\README.md"
}
},
"id": "c61652d1-120e-4a9f-8f0e-1dbf04fb18da",
"timestamp": "2026-03-27T00:00:01Z"
}
"""));
IReadOnlyList<SidecarEventDto> pending = state.DrainPendingEvents();
Assert.DoesNotContain(
pending.OfType<AgentActivityEventDto>(),
activity => activity.ActivityType == "tool-calling");
MessageReclassifiedEventDto reclassified = Assert.Single(pending.OfType<MessageReclassifiedEventDto>());
Assert.Equal("msg-1", reclassified.MessageId);
}
[Fact]
public async Task ObserveSessionEvent_ToolExecutionStart_EmitsEnrichmentWhenRequestInfoWasMissingArguments()
{
RunTurnCommandDto command = CreateApprovalCommand();
CopilotTurnExecutionState state = new(command);
WorkflowNodeDto agent = CreateAgent("agent-1", "Primary");
state.ObserveSessionEvent(
agent,
SessionEvent.FromJson(
"""
{
"type": "assistant.message_delta",
"data": {
"messageId": "msg-2",
"deltaContent": "Inspecting"
},
"id": "d61652d1-120e-4a9f-8f0e-1dbf04fb18da",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
_ = state.DrainPendingEvents();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("tool-call-1", "view", new Dictionary<string, object?>()));
List<AgentActivityEventDto> requestActivities = [];
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 =>
{
requestActivities.Add(Assert.IsType<AgentActivityEventDto>(sidecarEvent));
return Task.CompletedTask;
}),
])!;
bool shouldEndTurn = await handleTask;
Assert.False(shouldEndTurn);
AgentActivityEventDto requestActivity = Assert.Single(requestActivities);
Assert.Null(requestActivity.ToolArguments);
Assert.False(state.ToolCalls.HasTrackedArguments("tool-call-1"));
state.ObserveSessionEvent(
agent,
SessionEvent.FromJson(
"""
{
"type": "tool.execution_start",
"data": {
"toolCallId": "tool-call-1",
"toolName": "view",
"arguments": {
"path": "C:\\workspace\\README.md"
}
},
"id": "e61652d1-120e-4a9f-8f0e-1dbf04fb18da",
"timestamp": "2026-03-27T00:00:01Z"
}
"""));
AgentActivityEventDto enrichment = Assert.Single(
state.DrainPendingEvents().OfType<AgentActivityEventDto>(),
activity => activity.ActivityType == "tool-calling");
Assert.NotNull(enrichment.ToolArguments);
Assert.Equal(@"C:\workspace\README.md", enrichment.ToolArguments["path"]);
Assert.True(state.ToolCalls.HasTrackedArguments("tool-call-1"));
}
[Fact]
public void CreateExecutionEnvironment_UsesLockstepWhenRequested()
{
@@ -993,6 +1298,78 @@ public sealed class CopilotWorkflowRunnerTests
Assert.Equal("Primary", completed.AgentName);
}
[Fact]
public async Task HandleWorkflowEventAsync_EmitsSubworkflowStartedActivityForSubworkflowExecutor()
{
RunTurnCommandDto command = CreateReferencedSubworkflowCommand();
CopilotTurnExecutionState state = new(command);
List<AgentActivityEventDto> activities = [];
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
"HandleWorkflowEventAsync",
BindingFlags.NonPublic | BindingFlags.Static)!;
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
null,
[
command,
new ExecutorInvokedEvent("subworkflow-review", null!),
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);
AgentActivityEventDto activity = Assert.Single(activities);
Assert.Equal("subworkflow-started", activity.ActivityType);
Assert.Null(activity.AgentId);
Assert.Null(activity.AgentName);
Assert.Equal("subworkflow-review", activity.SubworkflowNodeId);
Assert.Equal("Review Lane", activity.SubworkflowName);
}
[Fact]
public async Task HandleWorkflowEventAsync_EmitsSubworkflowCompletedActivityForSubworkflowExecutor()
{
RunTurnCommandDto command = CreateReferencedSubworkflowCommand();
CopilotTurnExecutionState state = new(command);
List<AgentActivityEventDto> activities = [];
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
"HandleWorkflowEventAsync",
BindingFlags.NonPublic | BindingFlags.Static)!;
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
null,
[
command,
new ExecutorCompletedEvent("subworkflow-review", null),
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);
AgentActivityEventDto activity = Assert.Single(activities);
Assert.Equal("subworkflow-completed", activity.ActivityType);
Assert.Null(activity.AgentId);
Assert.Null(activity.AgentName);
Assert.Equal("subworkflow-review", activity.SubworkflowNodeId);
Assert.Equal("Review Lane", activity.SubworkflowName);
}
[Fact]
public async Task HandleWorkflowEventAsync_EmitsWorkflowWarningDiagnostic()
{
@@ -1241,14 +1618,12 @@ public sealed class CopilotWorkflowRunnerTests
[Fact]
public void TryGetApprovalToolName_UsesToolCallLookupForPermissionCategoriesWithoutDirectToolNames()
{
Dictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal)
{
["tool-call-url"] = "web_fetch",
["tool-call-shell"] = "shell",
["tool-call-read"] = "view",
["tool-call-write"] = "write_file",
["tool-call-memory"] = "store_memory",
};
ToolCallRegistry toolCalls = CreateToolCallRegistry(
("tool-call-url", "web_fetch"),
("tool-call-shell", "shell"),
("tool-call-read", "view"),
("tool-call-write", "write_file"),
("tool-call-memory", "store_memory"));
Assert.True(
CopilotApprovalCoordinator.TryGetApprovalToolName(
@@ -1259,7 +1634,7 @@ public sealed class CopilotWorkflowRunnerTests
Intention = "Fetch the requested page",
Url = "https://example.com/docs",
},
toolNamesByCallId,
toolCalls,
out string? urlToolName));
Assert.Equal("web_fetch", urlToolName);
@@ -1277,7 +1652,7 @@ public sealed class CopilotWorkflowRunnerTests
HasWriteFileRedirection = false,
CanOfferSessionApproval = false,
},
toolNamesByCallId,
toolCalls,
out string? shellToolName));
Assert.Equal("shell", shellToolName);
@@ -1290,7 +1665,7 @@ public sealed class CopilotWorkflowRunnerTests
Intention = "Inspect a file",
Path = "README.md",
},
toolNamesByCallId,
toolCalls,
out string? readToolName));
Assert.Equal("view", readToolName);
@@ -1304,7 +1679,7 @@ public sealed class CopilotWorkflowRunnerTests
FileName = "README.md",
Diff = "@@ -1 +1 @@",
},
toolNamesByCallId,
toolCalls,
out string? writeToolName));
Assert.Equal("write_file", writeToolName);
@@ -1318,7 +1693,7 @@ public sealed class CopilotWorkflowRunnerTests
Fact = "Use Bun for script execution.",
Citations = "package.json",
},
toolNamesByCallId,
toolCalls,
out string? memoryToolName));
Assert.Equal("store_memory", memoryToolName);
}
@@ -1829,7 +2204,7 @@ public sealed class CopilotWorkflowRunnerTests
{
SessionId = "copilot-session-1",
},
new Dictionary<string, string>(StringComparer.Ordinal),
CreateToolCallRegistry(),
approval =>
{
observedApproval = approval;
@@ -1876,10 +2251,7 @@ public sealed class CopilotWorkflowRunnerTests
{
SessionId = "copilot-session-1",
},
new Dictionary<string, string>(StringComparer.Ordinal)
{
["tool-call-write-1"] = "apply_patch",
},
CreateToolCallRegistry(("tool-call-write-1", "apply_patch")),
activity =>
{
observedActivity = activity;
@@ -1936,7 +2308,7 @@ public sealed class CopilotWorkflowRunnerTests
{
SessionId = "copilot-session-1",
},
new Dictionary<string, string>(StringComparer.Ordinal),
CreateToolCallRegistry(),
approval =>
{
sawApproval = true;
@@ -1973,7 +2345,7 @@ public sealed class CopilotWorkflowRunnerTests
{
SessionId = "copilot-session-1",
},
new Dictionary<string, string>(StringComparer.Ordinal),
CreateToolCallRegistry(),
approval =>
{
sawApproval = true;
@@ -2006,10 +2378,7 @@ public sealed class CopilotWorkflowRunnerTests
{
SessionId = "copilot-session-1",
},
new Dictionary<string, string>(StringComparer.Ordinal)
{
["tool-call-read-1"] = "view",
},
CreateToolCallRegistry(("tool-call-read-1", "view")),
approval =>
{
firstApproval = approval;
@@ -2047,10 +2416,7 @@ public sealed class CopilotWorkflowRunnerTests
{
SessionId = "copilot-session-1",
},
new Dictionary<string, string>(StringComparer.Ordinal)
{
["tool-call-read-2"] = "grep",
},
CreateToolCallRegistry(("tool-call-read-2", "grep")),
approval =>
{
sawSecondApproval = true;
@@ -2083,10 +2449,7 @@ public sealed class CopilotWorkflowRunnerTests
{
SessionId = "copilot-session-1",
},
new Dictionary<string, string>(StringComparer.Ordinal)
{
["tool-call-read-1"] = "view",
},
CreateToolCallRegistry(("tool-call-read-1", "view")),
approval =>
{
firstApproval = approval;
@@ -2123,10 +2486,7 @@ public sealed class CopilotWorkflowRunnerTests
{
SessionId = "copilot-session-1",
},
new Dictionary<string, string>(StringComparer.Ordinal)
{
["tool-call-read-2"] = "grep",
},
CreateToolCallRegistry(("tool-call-read-2", "grep")),
approval =>
{
secondApproval = approval;
@@ -2184,11 +2544,37 @@ public sealed class CopilotWorkflowRunnerTests
};
}
private static WorkflowNodeDto CreateSubworkflow(
string id,
string label,
string? workflowId = null,
WorkflowDefinitionDto? inlineWorkflow = null)
{
return new WorkflowNodeDto
{
Id = id,
Kind = "sub-workflow",
Label = label,
Config = new WorkflowNodeConfigDto
{
Kind = "sub-workflow",
WorkflowId = workflowId,
InlineWorkflow = inlineWorkflow,
},
};
}
private static RunTurnCommandDto CreateCommand(
string orchestrationMode,
params WorkflowNodeDto[] agents)
{
return CreateCommand(orchestrationMode, modeSettings: null, workflowName: null, workflowDescription: null, agents);
return CreateCommand(
orchestrationMode,
modeSettings: null,
workflowName: null,
workflowDescription: null,
workflowLibrary: null,
agents: agents);
}
private static RunTurnCommandDto CreateCommand(
@@ -2196,12 +2582,14 @@ public sealed class CopilotWorkflowRunnerTests
OrchestrationModeSettingsDto? modeSettings = null,
string? workflowName = null,
string? workflowDescription = null,
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null,
params WorkflowNodeDto[] agents)
{
return new RunTurnCommandDto
{
RequestId = "turn-1",
SessionId = "session-1",
WorkflowLibrary = workflowLibrary ?? [],
Workflow = new WorkflowDefinitionDto
{
Id = $"workflow-{orchestrationMode}",
@@ -2277,6 +2665,35 @@ public sealed class CopilotWorkflowRunnerTests
};
}
private static RunTurnCommandDto CreateReferencedSubworkflowCommand()
{
WorkflowDefinitionDto nestedWorkflow = new()
{
Id = "nested-review-workflow",
Name = "Nested Review Workflow",
Graph = new WorkflowGraphDto
{
Nodes =
[
CreateAgent("agent-reviewer", "Reviewer"),
],
},
Settings = new WorkflowSettingsDto
{
OrchestrationMode = "single",
},
};
return CreateCommand(
"single",
workflowName: "Parent Workflow",
workflowLibrary: [nestedWorkflow],
agents:
[
CreateSubworkflow("subworkflow-review", "Review Lane", workflowId: nestedWorkflow.Id),
]);
}
private static RunTurnCommandDto CreateHandoffCommand()
{
return CreateCommand(
@@ -2360,6 +2777,17 @@ public sealed class CopilotWorkflowRunnerTests
};
}
private static ToolCallRegistry CreateToolCallRegistry(params (string ToolCallId, string ToolName)[] toolCalls)
{
ToolCallRegistry registry = new();
foreach ((string toolCallId, string toolName) in toolCalls)
{
registry.RecordToolStart(toolCallId, toolName, toolArguments: null);
}
return registry;
}
private static RunTurnCommandDto CreateRequestPortCommand()
{
return new RunTurnCommandDto
@@ -0,0 +1,87 @@
using Aryx.AgentHost.Services;
using OpenTelemetry.Exporter;
using OpenTelemetry.Trace;
namespace Aryx.AgentHost.Tests;
public sealed class OpenTelemetrySetupTests
{
[Fact]
public void ResolveTracingConfiguration_ReturnsNullWhenEndpointMissing()
{
OpenTelemetryTracingConfiguration? configuration = OpenTelemetrySetup.ResolveTracingConfiguration([]);
Assert.Null(configuration);
}
[Fact]
public void ResolveTracingConfiguration_UsesGrpcByDefault()
{
OpenTelemetryTracingConfiguration? configuration = OpenTelemetrySetup.ResolveTracingConfiguration(
[
new KeyValuePair<string, string?>("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"),
]);
Assert.NotNull(configuration);
Assert.Equal(new Uri("http://localhost:4317"), configuration.Endpoint);
Assert.Equal(OtlpExportProtocol.Grpc, configuration.Protocol);
Assert.Collection(
configuration.ActivitySourceNames,
source => Assert.Equal("Experimental.Microsoft.Agents.AI", source),
source => Assert.Equal("Microsoft.Agents.AI.Workflows", source));
}
[Fact]
public void ResolveTracingConfiguration_UsesHttpProtobufWhenRequested()
{
OpenTelemetryTracingConfiguration? configuration = OpenTelemetrySetup.ResolveTracingConfiguration(
[
new KeyValuePair<string, string?>("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4318"),
new KeyValuePair<string, string?>("OTEL_EXPORTER_OTLP_PROTOCOL", "http/protobuf"),
]);
Assert.NotNull(configuration);
Assert.Equal(new Uri("http://localhost:4318"), configuration.Endpoint);
Assert.Equal(OtlpExportProtocol.HttpProtobuf, configuration.Protocol);
}
[Fact]
public void ResolveTracingConfiguration_ThrowsWhenEndpointInvalid()
{
InvalidOperationException error = Assert.Throws<InvalidOperationException>(() =>
OpenTelemetrySetup.ResolveTracingConfiguration(
[
new KeyValuePair<string, string?>("OTEL_EXPORTER_OTLP_ENDPOINT", "localhost:4317"),
]));
Assert.Contains("OTEL_EXPORTER_OTLP_ENDPOINT", error.Message, StringComparison.Ordinal);
}
[Fact]
public void ResolveTracingConfiguration_ThrowsWhenProtocolUnsupported()
{
InvalidOperationException error = Assert.Throws<InvalidOperationException>(() =>
OpenTelemetrySetup.ResolveTracingConfiguration(
[
new KeyValuePair<string, string?>("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"),
new KeyValuePair<string, string?>("OTEL_EXPORTER_OTLP_PROTOCOL", "http/json"),
]));
Assert.Contains("OTEL_EXPORTER_OTLP_PROTOCOL", error.Message, StringComparison.Ordinal);
}
[Fact]
public void CreateTracerProvider_CreatesProviderAndWritesDiagnosticsWhenConfigured()
{
StringWriter diagnosticsWriter = new();
using TracerProvider? tracerProvider = OpenTelemetrySetup.CreateTracerProvider(
[
new KeyValuePair<string, string?>("OTEL_EXPORTER_OTLP_ENDPOINT", "http://localhost:4317"),
],
diagnosticsWriter);
Assert.NotNull(tracerProvider);
Assert.Contains("Aryx.AgentHost OpenTelemetry tracing enabled", diagnosticsWriter.ToString(), StringComparison.Ordinal);
}
}
@@ -0,0 +1,25 @@
using Aryx.AgentHost.Contracts;
using Aryx.AgentHost.Services;
using GitHub.Copilot.SDK;
namespace Aryx.AgentHost.Tests;
internal static class SessionEventTestExtensions
{
private static readonly IProviderEventAdapter ProviderEventAdapter = new CopilotEventAdapter();
public static void ObserveSessionEvent(
this CopilotTurnExecutionState state,
WorkflowNodeDto agentDefinition,
SessionEvent sessionEvent)
{
ArgumentNullException.ThrowIfNull(state);
ArgumentNullException.ThrowIfNull(agentDefinition);
ArgumentNullException.ThrowIfNull(sessionEvent);
ProviderSessionEvent providerEvent = Assert.IsAssignableFrom<ProviderSessionEvent>(
ProviderEventAdapter.TryAdapt(sessionEvent));
state.ObserveSessionEvent(agentDefinition, providerEvent);
}
}
@@ -62,6 +62,103 @@ public sealed class SidecarProtocolHostTests
});
}
[Fact]
public async Task InternalConstructor_UsesAgentProviderDefaults()
{
FakeWorkflowRunner workflowRunner = new(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
{
await onActivity(new AgentActivityEventDto
{
Type = "agent-activity",
RequestId = command.RequestId,
SessionId = command.SessionId,
ActivityType = "thinking",
AgentId = "agent-provider",
AgentName = "Provider Agent",
});
return
[
new ChatMessageDto
{
Id = "assistant-provider",
Role = "assistant",
AuthorName = "Provider Agent",
Content = "Hello from the provider.",
CreatedAt = "2026-01-01T00:00:00.0000000Z",
},
];
});
FakeSessionManager sessionManager = new()
{
Sessions =
[
new CopilotSessionInfoDto
{
CopilotSessionId = "aryx::provider-session::agent-provider",
ManagedByAryx = true,
SessionId = "provider-session",
AgentId = "agent-provider",
},
],
};
SidecarCapabilitiesDto capabilities = new()
{
Modes = new Dictionary<string, SidecarModeCapabilityDto>(StringComparer.OrdinalIgnoreCase)
{
["single"] = new() { Available = true },
},
Models =
[
new SidecarModelCapabilityDto
{
Id = "provider-model",
Name = "Provider Model",
},
],
RuntimeTools = [],
Connection = new SidecarConnectionDiagnosticsDto
{
Status = "ready",
Summary = "Provider is ready.",
CheckedAt = "2026-01-01T00:00:00.0000000Z",
},
};
SidecarProtocolHost host = new(
new WorkflowValidator(),
new FakeAgentProvider(workflowRunner, sessionManager, capabilities));
IReadOnlyList<JsonElement> capabilityEvents = await RunHostAsync(
new DescribeCapabilitiesCommandDto
{
Type = "describe-capabilities",
RequestId = "provider-capabilities",
},
host);
IReadOnlyList<JsonElement> sessionEvents = await RunHostAsync(
new ListSessionsCommandDto
{
Type = "list-sessions",
RequestId = "provider-sessions",
},
host);
IReadOnlyList<JsonElement> turnEvents = await RunHostAsync(
CreateRunTurnCommand(requestId: "provider-turn"),
host);
JsonElement capabilityEvent = AssertSingleEvent(capabilityEvents, "capabilities", "provider-capabilities");
JsonElement model = Assert.Single(capabilityEvent.GetProperty("capabilities").GetProperty("models").EnumerateArray());
Assert.Equal("provider-model", model.GetProperty("id").GetString());
JsonElement listedEvent = AssertSingleEvent(sessionEvents, "sessions-listed", "provider-sessions");
JsonElement session = Assert.Single(listedEvent.GetProperty("sessions").EnumerateArray());
Assert.Equal("provider-session", session.GetProperty("sessionId").GetString());
JsonElement turnComplete = AssertSingleEvent(turnEvents, "turn-complete", "provider-turn");
JsonElement message = Assert.Single(turnComplete.GetProperty("messages").EnumerateArray());
Assert.Equal("Hello from the provider.", message.GetProperty("content").GetString());
}
[Fact]
public async Task ValidateWorkflowCommand_ReturnsIssuesAndCompletion()
{
@@ -668,7 +765,7 @@ public sealed class SidecarProtocolHostTests
[Fact]
public void MapRuntimeTools_ExcludesOnlyInternalMetaToolsAndDeduplicatesByName()
{
IReadOnlyList<SidecarRuntimeToolDto> runtimeTools = SidecarProtocolHost.MapRuntimeTools(
IReadOnlyList<SidecarRuntimeToolDto> runtimeTools = CopilotAgentProvider.MapRuntimeTools(
[
new Tool
{
@@ -721,7 +818,7 @@ public sealed class SidecarProtocolHostTests
[Fact]
public void ClassifyConnectionStatus_ReturnsAuthRequiredForLoginFailures()
{
string status = SidecarProtocolHost.ClassifyConnectionStatus(
string status = CopilotAgentProvider.ClassifyConnectionStatus(
new InvalidOperationException("Please run copilot auth login to continue."));
Assert.Equal("copilot-auth-required", status);
@@ -731,7 +828,7 @@ public sealed class SidecarProtocolHostTests
public void CreateReadyConnectionDiagnostics_ReportsCliPathAndModelCount()
{
SidecarConnectionDiagnosticsDto diagnostics =
SidecarProtocolHost.CreateReadyConnectionDiagnostics(
CopilotAgentProvider.CreateReadyConnectionDiagnostics(
@"C:\tools\copilot\copilot.exe",
2,
new SidecarCopilotCliVersionDiagnosticsDto
@@ -1145,6 +1242,38 @@ public sealed class SidecarProtocolHostTests
}
}
private sealed class FakeAgentProvider : IAgentProvider
{
private readonly ITurnWorkflowRunner _workflowRunner;
private readonly IProviderSessionManager _sessionManager;
private readonly SidecarCapabilitiesDto _capabilities;
public FakeAgentProvider(
ITurnWorkflowRunner workflowRunner,
IProviderSessionManager sessionManager,
SidecarCapabilitiesDto capabilities)
{
_workflowRunner = workflowRunner;
_sessionManager = sessionManager;
_capabilities = capabilities;
}
public ITurnWorkflowRunner CreateWorkflowRunner(WorkflowValidator workflowValidator)
{
return _workflowRunner;
}
public Task<SidecarCapabilitiesDto> GetCapabilitiesAsync(CancellationToken cancellationToken)
{
return Task.FromResult(_capabilities);
}
public IProviderSessionManager CreateSessionManager()
{
return _sessionManager;
}
}
private sealed class FakeSessionManager : ICopilotSessionManager
{
public IReadOnlyList<CopilotSessionInfoDto> Sessions { get; init; } = [];
@@ -37,22 +37,36 @@ public sealed class StreamingTextMergerTests
Assert.Equal(incoming, StreamingTextMerger.Merge(current, incoming));
}
[Fact]
public void Merge_InsertsWhitespaceWhenSnapshotLikeUpdatesWouldOtherwiseGlueWordsTogether()
[Theory]
[InlineData("requires all wr", "itable fields", "requires all writable fields")]
[InlineData("becomes frag", "ile for clients", "becomes fragile for clients")]
[InlineData("Endpoint (domain) uniqu", "eness across tenants", "Endpoint (domain) uniqueness across tenants")]
[InlineData("The doc says \"wildc", "ards are allowed\"", "The doc says \"wildcards are allowed\"")]
[InlineData("What wildcard syntax supported (*.cont", "oso.com? contoso.* ?)", "What wildcard syntax supported (*.contoso.com? contoso.* ?)")]
[InlineData("How does Pur", "view match traffic", "How does Purview match traffic")]
[InlineData("more M", "DA properties", "more MDA properties")]
[InlineData("does UA", "G normalize them?", "does UAG normalize them?")]
public void Merge_DoesNotInjectSpacesIntoSplitWords(string current, string incoming, string expected)
{
Assert.Equal(
"How about The **Ashen Crown** feels",
StreamingTextMerger.Merge("How about", "The **Ashen Crown** feels"));
Assert.Equal(
"The **Ashen Crown** feels classic and timeless.",
StreamingTextMerger.Merge("The **Ashen Crown** feels", "classic and timeless."));
Assert.Equal(expected, StreamingTextMerger.Merge(current, incoming));
}
[Fact]
public void Merge_InsertsNewlineBeforeStreamedMarkdownBlockMarkers()
public void Merge_PreservesWhitespaceAlreadyPresentInDelta()
{
Assert.Equal(
"How about The **Ashen Crown** feels",
StreamingTextMerger.Merge("How about", " The **Ashen Crown** feels"));
Assert.Equal(
"The **Ashen Crown** feels classic and timeless.",
StreamingTextMerger.Merge("The **Ashen Crown** feels", " classic and timeless."));
}
[Fact]
public void Merge_PreservesNewlineAlreadyPresentInDelta()
{
Assert.Equal(
"If you want, I can also give you\n- darker titles",
StreamingTextMerger.Merge("If you want, I can also give you", "- darker titles"));
StreamingTextMerger.Merge("If you want, I can also give you", "\n- darker titles"));
}
}
@@ -1,6 +1,6 @@
using System.Collections;
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using System.Text.Json;
using Aryx.AgentHost.Contracts;
using Aryx.AgentHost.Services;
using Microsoft.Agents.AI;
@@ -14,7 +14,7 @@ public sealed class WorkflowRequestInfoInterpreterTests
[Fact]
public void TryCreateActivityFromRequest_ReturnsToolCallingActivityForFunctionCalls()
{
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
var tracking = CreateToolTracking();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>
{
@@ -26,7 +26,7 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity("agent-1", "Primary"),
toolNamesByCallId);
tracking);
Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType);
@@ -36,13 +36,15 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.NotNull(activity.ToolArguments);
Assert.Equal(@"C:\workspace\file.txt", activity.ToolArguments["path"]);
Assert.Equal([10, 25], Assert.IsAssignableFrom<IReadOnlyList<object?>>(activity.ToolArguments["viewRange"]));
Assert.Equal("view", toolNamesByCallId["call-1"]);
Assert.True(tracking.TryGetToolName("call-1", out string? toolName));
Assert.Equal("view", toolName);
Assert.True(tracking.HasTrackedArguments("call-1"));
}
[Fact]
public void TryCreateActivityFromRequest_MapsMcpToolCalls()
{
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
var tracking = CreateToolTracking();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
CreateMcpToolCall(
"call-1",
@@ -58,7 +60,7 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity("agent-1", "Primary"),
toolNamesByCallId);
tracking);
Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType);
@@ -66,13 +68,15 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.NotNull(activity.ToolArguments);
Assert.Equal(@"C:\workspace", activity.ToolArguments["path"]);
Assert.Equal(true, activity.ToolArguments["includeIgnored"]);
Assert.Equal("git.status", toolNamesByCallId["call-1"]);
Assert.True(tracking.TryGetToolName("call-1", out string? toolName));
Assert.Equal("git.status", toolName);
Assert.True(tracking.HasTrackedArguments("call-1"));
}
[Fact]
public void TryCreateActivityFromRequest_MapsCodeInterpreterCallsToSyntheticToolName()
{
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
var tracking = CreateToolTracking();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
CreateCodeInterpreterToolCall("call-1", "print('hello')"));
@@ -80,7 +84,7 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity("agent-1", "Primary"),
toolNamesByCallId);
tracking);
Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType);
@@ -89,32 +93,35 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.Equal(
["print('hello')"],
Assert.IsAssignableFrom<IReadOnlyList<object?>>(activity.ToolArguments["inputs"]));
Assert.Equal("code interpreter", toolNamesByCallId["call-1"]);
Assert.True(tracking.TryGetToolName("call-1", out string? toolName));
Assert.Equal("code interpreter", toolName);
Assert.True(tracking.HasTrackedArguments("call-1"));
}
[Fact]
public void TryCreateActivityFromRequest_MapsImageGenerationCallsWithoutTrackingCallId()
{
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
var tracking = CreateToolTracking();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(CreateImageGenerationToolCall());
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity("agent-1", "Primary"),
toolNamesByCallId);
tracking);
Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType);
Assert.Equal("image generation", activity.ToolName);
Assert.Null(activity.ToolArguments);
Assert.Empty(toolNamesByCallId);
Assert.False(tracking.TryGetToolName("call-1", out _));
Assert.False(tracking.HasTrackedArguments("call-1"));
}
[Fact]
public void TryCreateActivityFromRequest_LeavesToolArgumentsNullWhenFunctionCallHasNoUsableArguments()
{
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
var tracking = CreateToolTracking();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>
{
@@ -126,16 +133,17 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity("agent-1", "Primary"),
toolNamesByCallId);
tracking);
Assert.NotNull(activity);
Assert.Null(activity.ToolArguments);
Assert.False(tracking.HasTrackedArguments("call-1"));
}
[Fact]
public void TryCreateActivityFromRequest_TruncatesOversizedToolArgumentValues()
{
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
var tracking = CreateToolTracking();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent(
"call-1",
@@ -149,37 +157,74 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity("agent-1", "Primary"),
toolNamesByCallId);
tracking);
Assert.NotNull(activity);
Assert.NotNull(activity.ToolArguments);
Assert.Equal("[truncated]", activity.ToolArguments["command"]);
Assert.True(tracking.HasTrackedArguments("call-1"));
}
[Fact]
public void TryCreateActivityFromRequest_SkipsDuplicateTrackedToolCallIds()
public void TryCreateActivityFromRequest_SkipsDuplicateTrackedToolCallIdsThatAlreadyHaveArguments()
{
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal)
{
["call-1"] = "view",
};
var tracking = CreateToolTracking();
tracking.RecordToolStart(
"call-1",
"view",
new Dictionary<string, object?>
{
["path"] = @"C:\workspace\seed.txt",
});
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>()));
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>
{
["path"] = @"C:\workspace\file.txt",
}));
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity("agent-1", "Primary"),
toolNamesByCallId);
tracking);
Assert.Null(activity);
Assert.Equal("view", toolNamesByCallId["call-1"]);
Assert.True(tracking.TryGetToolName("call-1", out string? toolName));
Assert.Equal("view", toolName);
Assert.True(tracking.HasTrackedArguments("call-1"));
}
[Fact]
public void TryCreateActivityFromRequest_EmitsEnrichmentWhenTrackedToolCallWasMissingArguments()
{
var tracking = CreateToolTracking();
tracking.RecordToolStart("call-1", "view", toolArguments: null);
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>
{
["path"] = @"C:\workspace\file.txt",
}));
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity("agent-1", "Primary"),
tracking);
Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType);
Assert.Equal("call-1", activity.ToolCallId);
Assert.NotNull(activity.ToolArguments);
Assert.Equal(@"C:\workspace\file.txt", activity.ToolArguments["path"]);
Assert.True(tracking.TryGetToolName("call-1", out string? toolName));
Assert.Equal("view", toolName);
Assert.True(tracking.HasTrackedArguments("call-1"));
}
[Fact]
public void TryCreateActivityFromRequest_ReturnsHandoffActivityForKnownTargets()
{
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
var tracking = CreateToolTracking();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
CreateHandoffTarget("agent-handoff-ux", "UX Specialist"));
@@ -187,7 +232,7 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateHandoffCommand(),
requestInfo,
new AgentIdentity("agent-handoff-triage", "Triage"),
toolNamesByCallId);
tracking);
Assert.NotNull(activity);
Assert.Equal("handoff", activity.ActivityType);
@@ -196,7 +241,54 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.Equal("agent-handoff-triage", activity.SourceAgentId);
Assert.Equal("Triage", activity.SourceAgentName);
Assert.Null(activity.ToolName);
Assert.Empty(toolNamesByCallId);
Assert.False(tracking.TryGetToolName("call-1", out _));
Assert.False(tracking.HasTrackedArguments("call-1"));
}
[Fact]
public void TryCreateActivityFromRequest_IncludesSubworkflowContextForToolCallingAgent()
{
var tracking = CreateToolTracking();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>
{
["path"] = @"C:\workspace\file.txt",
}));
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity(
"agent-reviewer",
"Reviewer",
new SubworkflowContext("subworkflow-review", "Review Lane")),
tracking);
Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType);
Assert.Equal("subworkflow-review", activity.SubworkflowNodeId);
Assert.Equal("Review Lane", activity.SubworkflowName);
}
[Fact]
public void TryCreateActivityFromRequest_ResolvesReferencedSubworkflowContextForHandoffTargets()
{
var tracking = CreateToolTracking();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
CreateHandoffTarget("agent-handoff-ux", "UX Specialist"));
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
CreateHandoffCommandWithReferencedSubworkflow(),
requestInfo,
new AgentIdentity("agent-handoff-triage", "Triage"),
tracking);
Assert.NotNull(activity);
Assert.Equal("handoff", activity.ActivityType);
Assert.Equal("agent-handoff-ux", activity.AgentId);
Assert.Equal("UX Specialist", activity.AgentName);
Assert.Equal("subworkflow-review", activity.SubworkflowNodeId);
Assert.Equal("Review Lane", activity.SubworkflowName);
}
[Fact]
@@ -255,6 +347,35 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.False(requiresBoundary);
}
[Fact]
public void NormalizeRawToolArguments_JsonElement_ExtractsArguments()
{
using JsonDocument doc = JsonDocument.Parse("""{"path":"/src/main.ts","view_range":[10,20]}""");
JsonElement element = doc.RootElement.Clone();
IReadOnlyDictionary<string, object?>? result = WorkflowRequestInfoInterpreter.NormalizeRawToolArguments(element);
Assert.NotNull(result);
Assert.Equal("/src/main.ts", result["path"]);
IReadOnlyList<object?> viewRange = Assert.IsAssignableFrom<IReadOnlyList<object?>>(result["view_range"]);
Assert.Equal(2, viewRange.Count);
}
[Fact]
public void NormalizeRawToolArguments_Null_ReturnsNull()
{
Assert.Null(WorkflowRequestInfoInterpreter.NormalizeRawToolArguments(null));
}
[Fact]
public void NormalizeRawToolArguments_EmptyObject_ReturnsNull()
{
using JsonDocument doc = JsonDocument.Parse("{}");
JsonElement element = doc.RootElement.Clone();
Assert.Null(WorkflowRequestInfoInterpreter.NormalizeRawToolArguments(element));
}
private static RunTurnCommandDto CreateSingleAgentCommand()
=> CreateCommand("single", [CreateAgent("agent-1", "Primary")]);
@@ -265,19 +386,53 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateAgent("agent-handoff-ux", "UX Specialist"),
]);
private static RunTurnCommandDto CreateCommand(string orchestrationMode, IReadOnlyList<WorkflowNodeDto> agents)
private static RunTurnCommandDto CreateHandoffCommandWithReferencedSubworkflow()
{
WorkflowDefinitionDto nestedWorkflow = new()
{
Id = "nested-review-workflow",
Name = "Nested Review Workflow",
Graph = new WorkflowGraphDto
{
Nodes =
[
CreateAgent("agent-handoff-ux", "UX Specialist"),
],
},
Settings = new WorkflowSettingsDto
{
OrchestrationMode = "single",
},
};
return CreateCommand(
"handoff",
[
CreateAgent("agent-handoff-triage", "Triage"),
CreateSubworkflow("subworkflow-review", "Review Lane", workflowId: nestedWorkflow.Id),
],
workflowLibrary: [nestedWorkflow]);
}
private static ToolCallRegistry CreateToolTracking() => new();
private static RunTurnCommandDto CreateCommand(
string orchestrationMode,
IReadOnlyList<WorkflowNodeDto> nodes,
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null)
{
return new RunTurnCommandDto
{
RequestId = "turn-1",
SessionId = "session-1",
WorkflowLibrary = workflowLibrary ?? [],
Workflow = new WorkflowDefinitionDto
{
Id = $"{orchestrationMode}-workflow",
Name = "Workflow",
Graph = new WorkflowGraphDto
{
Nodes = [.. agents],
Nodes = [.. nodes],
},
Settings = new WorkflowSettingsDto
{
@@ -305,6 +460,26 @@ public sealed class WorkflowRequestInfoInterpreterTests
};
}
private static WorkflowNodeDto CreateSubworkflow(
string id,
string label,
string? workflowId = null,
WorkflowDefinitionDto? inlineWorkflow = null)
{
return new WorkflowNodeDto
{
Id = id,
Kind = "sub-workflow",
Label = label,
Config = new WorkflowNodeConfigDto
{
Kind = "sub-workflow",
WorkflowId = workflowId,
InlineWorkflow = inlineWorkflow,
},
};
}
private static RequestInfoEvent CreateRequestInfoEvent(object payload)
{
RequestPort port = RequestPort.Create<object, object>("test-port");
@@ -317,8 +492,7 @@ public sealed class WorkflowRequestInfoInterpreterTests
Type type = Type.GetType(
"Microsoft.Extensions.AI.CodeInterpreterToolCallContent, Microsoft.Extensions.AI.Abstractions",
throwOnError: true)!;
object instance = Activator.CreateInstance(type)!;
type.GetProperty("CallId")!.SetValue(instance, callId);
object instance = Activator.CreateInstance(type, callId)!;
if (inputs.Length > 0)
{
Type aiContentType = Type.GetType(
@@ -363,7 +537,7 @@ public sealed class WorkflowRequestInfoInterpreterTests
Type type = Type.GetType(
"Microsoft.Extensions.AI.ImageGenerationToolCallContent, Microsoft.Extensions.AI.Abstractions",
throwOnError: true)!;
return Activator.CreateInstance(type)!;
return Activator.CreateInstance(type, "image-call-1")!;
}
private static object CreateHandoffTarget(string id, string name)
+424 -1510
View File
File diff suppressed because it is too large Load Diff
+90 -23
View File
@@ -1,19 +1,53 @@
import electron from 'electron';
import type { BrowserWindow as BrowserWindowType } from 'electron';
import { registerIpcHandlers } from '@main/ipc/registerIpcHandlers';
import { registerIpcHandlers, registerQuickPromptIpcHandlers } from '@main/ipc/registerIpcHandlers';
import { AryxAppService } from '@main/AryxAppService';
import { AutoUpdateService } from '@main/services/autoUpdater';
import { GlobalHotkeyService } from '@main/services/globalHotkey';
import { createMainWindow } from '@main/windows/createMainWindow';
import {
createQuickPromptWindow,
toggleQuickPromptWindow,
} from '@main/windows/createQuickPromptWindow';
import { applyTitleBarTheme } from '@main/windows/titleBarTheme';
import { SystemTray, setupCloseToTray, showAndFocusWindow } from '@main/services/systemTray';
import { createDefaultQuickPromptSettings } from '@shared/domain/tooling';
const { app, BrowserWindow } = electron;
// Enforce single instance — quit immediately if another instance already holds the lock.
const gotTheLock = app.requestSingleInstanceLock();
if (!gotTheLock) {
app.quit();
}
let mainWindow: BrowserWindowType | undefined;
let quickPromptWindow: BrowserWindowType | undefined;
let appService: AryxAppService | undefined;
let systemTray: SystemTray | undefined;
let autoUpdateService: AutoUpdateService | undefined;
let globalHotkeyService: GlobalHotkeyService | undefined;
let quickPromptInitialized = false;
/** Lazily creates the quick prompt window on first use. */
function ensureQuickPromptWindow(): BrowserWindowType | undefined {
if (quickPromptWindow && !quickPromptWindow.isDestroyed()) return quickPromptWindow;
if (quickPromptInitialized) return undefined;
if (!mainWindow || !appService) return undefined;
try {
quickPromptWindow = createQuickPromptWindow();
registerQuickPromptIpcHandlers(mainWindow, appService, quickPromptWindow);
quickPromptInitialized = true;
} catch (err) {
console.error('[aryx] Failed to create quick prompt window:', err);
quickPromptInitialized = true;
}
return quickPromptWindow;
}
async function bootstrap(): Promise<void> {
appService = new AryxAppService();
@@ -21,23 +55,52 @@ async function bootstrap(): Promise<void> {
autoUpdateService = new AutoUpdateService({ isPackaged: app.isPackaged });
mainWindow = createMainWindow();
registerIpcHandlers(mainWindow, appService, autoUpdateService);
// Apply persisted theme to the title bar overlay
const workspace = await appService.loadWorkspace();
applyTitleBarTheme(mainWindow, workspace.settings.theme);
// Start workspace loading in parallel — don't block window from showing.
// The renderer fetches the workspace via its own IPC call after mount.
const workspaceReady = appService.loadWorkspace();
// Set up system tray
systemTray = new SystemTray({
onShowWindow: showAndFocusWindow,
onCreateScratchpad: () => {
showAndFocusWindow();
mainWindow?.webContents.send('tray:create-scratchpad');
},
onQuit: () => app.quit(),
});
systemTray.create();
systemTray.updateRunningCount(workspace);
// Apply theme, set up tray, and register global hotkey once workspace is available
workspaceReady
.then((workspace) => {
if (!mainWindow) return;
applyTitleBarTheme(mainWindow, workspace.settings.theme);
systemTray = new SystemTray({
onShowWindow: () => showAndFocusWindow(mainWindow!),
onCreateScratchpad: () => {
showAndFocusWindow(mainWindow!);
mainWindow?.webContents.send('tray:create-scratchpad');
},
onQuit: () => app.quit(),
});
systemTray.create();
systemTray.updateRunningCount(workspace);
appService!.on('workspace-updated', (updatedWorkspace) => {
systemTray?.updateRunningCount(updatedWorkspace);
});
// Register global hotkey — the quick prompt window is created lazily on
// first press so it cannot interfere with main window startup.
globalHotkeyService = new GlobalHotkeyService();
const hotkeySettings = workspace.settings.quickPrompt ?? createDefaultQuickPromptSettings();
globalHotkeyService.register(hotkeySettings, () => {
const win = ensureQuickPromptWindow();
if (win) void toggleQuickPromptWindow(win, appService!.getCurrentTheme());
});
// Re-register hotkey when settings change
appService!.on('workspace-updated', (updatedWorkspace) => {
const updatedSettings = updatedWorkspace.settings.quickPrompt ?? createDefaultQuickPromptSettings();
globalHotkeyService?.update(updatedSettings);
});
})
.catch((error) => {
console.error('[aryx bootstrap] workspace load failed', error);
});
// Intercept close to hide to tray when the setting is enabled
setupCloseToTray(mainWindow, () => {
@@ -45,11 +108,6 @@ async function bootstrap(): Promise<void> {
return currentWorkspace?.settings.minimizeToTray === true;
});
// Keep tray status in sync when workspace changes
appService.on('workspace-updated', (updatedWorkspace) => {
systemTray?.updateRunningCount(updatedWorkspace);
});
if (!app.isPackaged) {
mainWindow.webContents.openDevTools({ mode: 'detach' });
}
@@ -57,6 +115,12 @@ async function bootstrap(): Promise<void> {
autoUpdateService.start();
}
app.on('second-instance', () => {
if (mainWindow && !mainWindow.isDestroyed()) {
showAndFocusWindow(mainWindow);
}
});
app.whenReady().then(bootstrap);
app.on('window-all-closed', () => {
@@ -64,7 +128,9 @@ app.on('window-all-closed', () => {
if (process.platform === 'darwin') return;
const windows = BrowserWindow.getAllWindows();
const allHidden = windows.length > 0 && windows.every((w) => !w.isVisible());
// Ignore the quick prompt window (it's always hidden, never truly closed)
const visibleWindows = windows.filter((w) => w !== quickPromptWindow);
const allHidden = visibleWindows.length > 0 && visibleWindows.every((w) => !w.isVisible());
if (allHidden) return;
app.quit();
@@ -73,12 +139,13 @@ app.on('window-all-closed', () => {
app.on('activate', async () => {
if (BrowserWindow.getAllWindows().length === 0) {
await bootstrap();
} else {
showAndFocusWindow();
} else if (mainWindow && !mainWindow.isDestroyed()) {
showAndFocusWindow(mainWindow);
}
});
app.on('before-quit', async () => {
globalHotkeyService?.dispose();
autoUpdateService?.dispose();
autoUpdateService = undefined;
systemTray?.dispose();
+114 -1
View File
@@ -4,6 +4,8 @@ import type { BrowserWindow } from 'electron';
import { ipcChannels } from '@shared/contracts/channels';
import type {
BranchSessionInput,
BatchDeleteSessionsInput,
BatchSetSessionsArchivedInput,
CancelSessionTurnInput,
CommitProjectGitChangesInput,
CreateSessionInput,
@@ -51,14 +53,18 @@ import type {
UpdateSessionModelConfigInput,
UpdateSessionApprovalSettingsInput,
UpdateSessionToolingInput,
QuickPromptSendInput,
} from '@shared/contracts/ipc';
import type { QuerySessionsInput } from '@shared/domain/sessionLibrary';
import type { AppearanceTheme } from '@shared/domain/tooling';
import type { AppearanceTheme, OpenTelemetrySettings, QuickPromptSettings } from '@shared/domain/tooling';
import { AryxAppService } from '@main/AryxAppService';
import { AutoUpdateService } from '@main/services/autoUpdater';
import { createDesktopNotificationHandler } from '@main/services/desktopNotifications';
import { applyTitleBarTheme } from '@main/windows/titleBarTheme';
import { hideQuickPromptWindow } from '@main/windows/createQuickPromptWindow';
import { buildAvailableModelCatalog } from '@shared/domain/models';
import { SCRATCHPAD_PROJECT_ID } from '@shared/domain/project';
import type { UpdateStatus } from '@shared/contracts/ipc';
const { ipcMain } = electron;
@@ -149,6 +155,10 @@ export function registerIpcHandlers(
ipcChannels.setGitAutoRefreshEnabled,
(_event, enabled: boolean) => service.setGitAutoRefreshEnabled(enabled),
);
ipcMain.handle(
ipcChannels.setOpenTelemetry,
(_event, settings: OpenTelemetrySettings) => service.setOpenTelemetry(settings),
);
ipcMain.handle(ipcChannels.checkForUpdates, () => autoUpdateService.checkForUpdates());
ipcMain.handle(ipcChannels.installUpdate, () => {
autoUpdateService.installUpdate();
@@ -220,6 +230,12 @@ export function registerIpcHandlers(
ipcMain.handle(ipcChannels.deleteSession, (_event, input: DeleteSessionInput) =>
service.deleteSession(input.sessionId),
);
ipcMain.handle(ipcChannels.batchSetSessionsArchived, (_event, input: BatchSetSessionsArchivedInput) =>
service.batchSetSessionsArchived(input.sessionIds, input.isArchived),
);
ipcMain.handle(ipcChannels.batchDeleteSessions, (_event, input: BatchDeleteSessionsInput) =>
service.batchDeleteSessions(input.sessionIds),
);
ipcMain.handle(ipcChannels.regenerateSessionMessage, (_event, input: RegenerateSessionMessageInput) =>
service.regenerateSessionMessage(input.sessionId, input.messageId),
);
@@ -348,4 +364,101 @@ export function registerIpcHandlers(
service.on('terminal-exit', (info) => {
window.webContents.send(ipcChannels.terminalExit, info);
});
// --- Quick Prompt IPC (window-independent) ---
ipcMain.handle(ipcChannels.quickPromptGetCapabilities, async () => {
const capabilities = await service.describeSidecarCapabilities();
const settings = service.getQuickPromptSettings();
const models = buildAvailableModelCatalog(capabilities.models);
return {
models,
defaultModel: settings.defaultModel,
defaultReasoningEffort: settings.defaultReasoningEffort,
};
});
ipcMain.handle(
ipcChannels.quickPromptSetSettings,
(_event, settings: Partial<QuickPromptSettings>) => service.setQuickPromptSettings(settings),
);
ipcMain.handle(
ipcChannels.quickPromptGetSettings,
() => service.getQuickPromptSettings(),
);
}
/**
* Register IPC handlers that depend on the quick prompt BrowserWindow.
* Called separately after the quick prompt window is created (deferred from bootstrap).
*/
export function registerQuickPromptIpcHandlers(
mainWindow: BrowserWindow,
service: AryxAppService,
quickPromptWindow: BrowserWindow,
): void {
let quickPromptSessionId: string | undefined;
ipcMain.handle(ipcChannels.quickPromptSend, async (_event, input: QuickPromptSendInput) => {
const workspace = await service.loadWorkspace();
const workflowId = workspace.selectedWorkflowId ?? workspace.workflows[0]?.id;
if (!workflowId) throw new Error('No workflow available');
const created = await service.createSession(SCRATCHPAD_PROJECT_ID, workflowId);
const session = created.sessions[0];
if (!session) throw new Error('Failed to create quick prompt session');
quickPromptSessionId = session.id;
// Apply model override if provided
if (input.model) {
await service.updateSessionModelConfig(session.id, input.model, input.reasoningEffort);
}
// Send the message (fire-and-forget — results arrive via session events)
void service.sendSessionMessage(session.id, input.content);
return { sessionId: session.id };
});
ipcMain.handle(ipcChannels.quickPromptCancelTurn, async () => {
if (quickPromptSessionId) {
await service.cancelSessionTurn(quickPromptSessionId);
}
});
ipcMain.handle(ipcChannels.quickPromptDiscard, async () => {
if (quickPromptSessionId) {
await service.deleteSession(quickPromptSessionId);
quickPromptSessionId = undefined;
}
hideQuickPromptWindow(quickPromptWindow);
});
ipcMain.handle(ipcChannels.quickPromptClose, async () => {
quickPromptSessionId = undefined;
hideQuickPromptWindow(quickPromptWindow);
});
ipcMain.handle(ipcChannels.quickPromptContinueInAryx, async () => {
if (quickPromptSessionId) {
await service.selectSession(quickPromptSessionId);
quickPromptSessionId = undefined;
}
hideQuickPromptWindow(quickPromptWindow);
// Show and focus the main window
if (!mainWindow.isDestroyed()) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
}
});
// Route session events to the quick prompt window
service.on('session-event', (event) => {
if (event.sessionId === quickPromptSessionId && !quickPromptWindow.isDestroyed()) {
quickPromptWindow.webContents.send(ipcChannels.quickPromptSessionEvent, event);
}
});
}
+414
View File
@@ -0,0 +1,414 @@
import type {
ApprovalRequestedEvent,
ExitPlanModeRequestedEvent,
McpOauthRequiredEvent,
UserInputRequestedEvent,
} from '@shared/contracts/sidecar';
import {
dequeuePendingApprovalState,
enqueuePendingApprovalState,
listPendingApprovals,
resolvePendingApproval,
resolveApprovalToolKey,
type ApprovalDecision,
type PendingApprovalRecord,
} from '@shared/domain/approval';
import type { SessionRecord } from '@shared/domain/session';
import type { SessionEventRecord } from '@shared/domain/event';
import type { SessionRunRecord } from '@shared/domain/runTimeline';
import type { WorkspaceState } from '@shared/domain/workspace';
import { nowIso } from '@shared/utils/ids';
type PendingApprovalHandle = {
sessionId: string;
requestId: string;
resolve: (decision: ApprovalDecision, alwaysApprove?: boolean) => void | Promise<void>;
};
type PendingUserInputHandle = {
sessionId: string;
requestId: string;
resolve: (answer: string, wasFreeform: boolean) => void | Promise<void>;
};
type ApprovalCoordinatorDeps = {
requireSession: (workspace: WorkspaceState, sessionId: string) => SessionRecord;
persistWorkspace: (workspace: WorkspaceState) => Promise<WorkspaceState>;
updateSessionRun: (
session: SessionRecord,
requestId: string,
updater: (run: SessionRunRecord) => SessionRunRecord,
) => SessionRunRecord | undefined;
emitRunUpdated: (sessionId: string, occurredAt: string, run: SessionRunRecord) => void;
emitSessionEvent: (event: SessionEventRecord) => void;
failSessionRunRecord: (run: SessionRunRecord, failedAt: string, error: string) => SessionRunRecord;
upsertRunApprovalEvent: (
run: SessionRunRecord,
approval: PendingApprovalRecord,
) => SessionRunRecord;
};
export class ApprovalCoordinator {
readonly pendingApprovalHandles = new Map<string, PendingApprovalHandle>();
readonly pendingUserInputHandles = new Map<string, PendingUserInputHandle>();
private readonly requireSession: ApprovalCoordinatorDeps['requireSession'];
private readonly persistWorkspace: ApprovalCoordinatorDeps['persistWorkspace'];
private readonly updateSessionRun: ApprovalCoordinatorDeps['updateSessionRun'];
private readonly emitRunUpdated: ApprovalCoordinatorDeps['emitRunUpdated'];
private readonly emitSessionEvent: ApprovalCoordinatorDeps['emitSessionEvent'];
private readonly failSessionRunRecord: ApprovalCoordinatorDeps['failSessionRunRecord'];
private readonly upsertRunApprovalEvent: ApprovalCoordinatorDeps['upsertRunApprovalEvent'];
constructor(deps: ApprovalCoordinatorDeps) {
this.requireSession = deps.requireSession;
this.persistWorkspace = deps.persistWorkspace;
this.updateSessionRun = deps.updateSessionRun;
this.emitRunUpdated = deps.emitRunUpdated;
this.emitSessionEvent = deps.emitSessionEvent;
this.failSessionRunRecord = deps.failSessionRunRecord;
this.upsertRunApprovalEvent = deps.upsertRunApprovalEvent;
}
async resolveSessionApproval(
workspace: WorkspaceState,
sessionId: string,
approvalId: string,
decision: ApprovalDecision,
alwaysApprove?: boolean,
): Promise<WorkspaceState> {
const session = this.requireSession(workspace, sessionId);
const approval = session.pendingApproval;
if (!approval || approval.id !== approvalId) {
const queuedApproval = session.pendingApprovalQueue?.some((candidate) => candidate.id === approvalId);
if (queuedApproval) {
throw new Error(
approval
? `Approval "${approvalId}" is queued behind "${approval.id}" for session "${sessionId}". Resolve the active approval first.`
: `Approval "${approvalId}" is queued but not active for session "${sessionId}".`,
);
}
throw new Error(`Approval "${approvalId}" is not pending for session "${sessionId}".`);
}
const handle = this.pendingApprovalHandles.get(approvalId);
if (!handle || handle.sessionId !== sessionId) {
throw new Error(`Approval "${approvalId}" is no longer active. Restart the run and try again.`);
}
const resolvedAt = nowIso();
const resolvedApproval = resolvePendingApproval(approval, decision, resolvedAt);
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) =>
this.upsertRunApprovalEvent(run, resolvedApproval));
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) =>
this.upsertRunApprovalEvent(run, cascadeResolved));
this.pendingApprovalHandles.delete(queued.id);
cascadeHandles.push(queuedHandle);
}
}
const result = await this.persistWorkspace(workspace);
if (updatedRun) {
this.emitRunUpdated(sessionId, resolvedAt, updatedRun);
}
this.pendingApprovalHandles.delete(approvalId);
try {
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(
session,
failedAt,
'Queued approval was cancelled because the run failed before it could resume.',
);
session.status = 'error';
session.lastError = error instanceof Error ? error.message : String(error);
session.updatedAt = failedAt;
const failedRun = this.updateSessionRun(session, handle.requestId, (run) =>
this.failSessionRunRecord(run, failedAt, session.lastError ?? 'Unknown error.'));
this.emitSessionEvent({
sessionId,
kind: 'error',
occurredAt: failedAt,
error: session.lastError,
});
if (failedRun) {
this.emitRunUpdated(sessionId, failedAt, failedRun);
}
await this.persistWorkspace(workspace);
throw error;
}
return result;
}
async resolveSessionUserInput(
workspace: WorkspaceState,
sessionId: string,
userInputId: string,
answer: string,
wasFreeform: boolean,
): Promise<WorkspaceState> {
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.persistWorkspace(workspace);
this.pendingUserInputHandles.delete(userInputId);
try {
await Promise.resolve(handle.resolve(answer, wasFreeform));
session.pendingUserInput = undefined;
await this.persistWorkspace(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.persistWorkspace(workspace);
throw error;
}
return result;
}
async handleApprovalRequested(
workspace: WorkspaceState,
sessionId: string,
requestId: string,
approval: ApprovalRequestedEvent | PendingApprovalRecord,
resolve: (decision: ApprovalDecision, alwaysApprove?: boolean) => void | Promise<void>,
): Promise<void> {
const session = this.requireSession(workspace, sessionId);
const pendingApproval =
'type' in approval ? this.createPendingApprovalFromSidecarEvent(approval) : approval;
this.setSessionPendingApprovalState(session, enqueuePendingApprovalState(session, pendingApproval));
session.updatedAt = pendingApproval.requestedAt;
const updatedRun = this.updateSessionRun(session, requestId, (run) =>
this.upsertRunApprovalEvent(run, pendingApproval));
this.pendingApprovalHandles.set(pendingApproval.id, {
sessionId,
requestId,
resolve,
});
await this.persistWorkspace(workspace);
if (updatedRun) {
this.emitRunUpdated(sessionId, pendingApproval.requestedAt, updatedRun);
}
}
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,
resolve,
});
await this.persistWorkspace(workspace);
}
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.persistWorkspace(workspace);
}
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.persistWorkspace(workspace);
}
createPendingApprovalFromSidecarEvent(event: ApprovalRequestedEvent): PendingApprovalRecord {
return {
id: event.approvalId,
kind: event.approvalKind,
status: 'pending',
requestedAt: nowIso(),
agentId: event.agentId,
agentName: event.agentName,
toolName: event.toolName,
permissionKind: event.permissionKind,
title: event.title,
detail: event.detail,
permissionDetail: event.permissionDetail,
};
}
setSessionPendingApprovalState(
session: SessionRecord,
state: {
pendingApproval?: PendingApprovalRecord;
pendingApprovalQueue?: PendingApprovalRecord[];
},
): void {
session.pendingApproval = state.pendingApproval;
session.pendingApprovalQueue = state.pendingApprovalQueue;
}
rejectPendingApprovals(
session: SessionRecord,
failedAt: string,
error: string,
): string[] {
const requestIds = new Set<string>();
for (const pendingApproval of listPendingApprovals(session)) {
const requestId = this.findApprovalRequestId(session, pendingApproval.id);
const rejectedApproval = resolvePendingApproval(pendingApproval, 'rejected', failedAt, error);
if (requestId) {
requestIds.add(requestId);
this.updateSessionRun(session, requestId, (run) =>
this.upsertRunApprovalEvent(run, rejectedApproval));
}
this.pendingApprovalHandles.delete(pendingApproval.id);
}
this.setSessionPendingApprovalState(session, {});
return [...requestIds];
}
findApprovalRequestId(session: SessionRecord, approvalId: string): string | undefined {
const matchingRun = session.runs.find((run) =>
run.events.some((event) => event.kind === 'approval' && event.approvalId === approvalId));
if (matchingRun) {
return matchingRun.requestId;
}
return session.runs.find((run) => run.status === 'running')?.requestId;
}
}
@@ -0,0 +1,171 @@
import { rm } from 'node:fs/promises';
import type {
AgentActivityEvent,
ApprovalRequestedEvent,
ExitPlanModeRequestedEvent,
McpOauthRequiredEvent,
MessageReclassifiedEvent,
RunTurnCommand,
UserInputRequestedEvent,
TurnDeltaEvent,
WorkflowCheckpointResume,
WorkflowCheckpointSavedEvent,
} from '@shared/contracts/sidecar';
import type { ChatMessageRecord, SessionRecord } from '@shared/domain/session';
import type { SessionRunRecord } from '@shared/domain/runTimeline';
import { nowIso } from '@shared/utils/ids';
import type { TurnScopedEvent } from '@main/sidecar/runTurnPending';
export type PendingApprovalHandleLike = {
sessionId: string;
requestId: string;
};
export type PendingUserInputHandleLike = {
sessionId: string;
requestId: string;
};
export type WorkflowCheckpointRecoveryState = {
workflowSessionId: string;
checkpointId: string;
storePath: string;
stepNumber: number;
sessionMessages: ChatMessageRecord[];
runEvents: import('@shared/domain/runTimeline').RunTimelineEventRecord[];
};
type CheckpointRecoveryManagerDeps = {
persistWorkspace: (workspace: import('@shared/domain/workspace').WorkspaceState) => Promise<void>;
emitRunUpdated: (sessionId: string, occurredAt: string, run: SessionRunRecord) => void;
updateSessionRun: (
session: SessionRecord,
requestId: string,
updater: (run: SessionRunRecord) => SessionRunRecord,
) => SessionRunRecord | undefined;
setSessionPendingApprovalState: (
session: SessionRecord,
state: {
pendingApproval?: import('@shared/domain/approval').PendingApprovalRecord;
pendingApprovalQueue?: import('@shared/domain/approval').PendingApprovalRecord[];
},
) => void;
pendingApprovalHandles: Map<string, PendingApprovalHandleLike>;
pendingUserInputHandles: Map<string, PendingUserInputHandleLike>;
};
export class CheckpointRecoveryManager {
readonly recoveries = new Map<string, WorkflowCheckpointRecoveryState>();
private readonly persistWorkspace: (workspace: import('@shared/domain/workspace').WorkspaceState) => Promise<void>;
private readonly emitRunUpdated: (sessionId: string, occurredAt: string, run: SessionRunRecord) => void;
private readonly updateSessionRun: CheckpointRecoveryManagerDeps['updateSessionRun'];
private readonly setSessionPendingApprovalState: CheckpointRecoveryManagerDeps['setSessionPendingApprovalState'];
private readonly pendingApprovalHandles: Map<string, PendingApprovalHandleLike>;
private readonly pendingUserInputHandles: Map<string, PendingUserInputHandleLike>;
constructor(deps: CheckpointRecoveryManagerDeps) {
this.persistWorkspace = deps.persistWorkspace;
this.emitRunUpdated = deps.emitRunUpdated;
this.updateSessionRun = deps.updateSessionRun;
this.setSessionPendingApprovalState = deps.setSessionPendingApprovalState;
this.pendingApprovalHandles = deps.pendingApprovalHandles;
this.pendingUserInputHandles = deps.pendingUserInputHandles;
}
async runSidecarTurnWithCheckpointRecovery(
workspace: import('@shared/domain/workspace').WorkspaceState,
session: SessionRecord,
requestId: string,
invokeTurn: (resumeFromCheckpoint?: WorkflowCheckpointResume) => Promise<ChatMessageRecord[]>,
isUnexpectedSidecarTerminationError: (error: unknown) => boolean,
): Promise<ChatMessageRecord[]> {
try {
return await invokeTurn();
} catch (error) {
const recovery = this.recoveries.get(requestId);
if (!isUnexpectedSidecarTerminationError(error) || !recovery) {
throw error;
}
const restoredRun = this.restoreWorkflowCheckpointRecovery(session, requestId, recovery);
await this.persistWorkspace(workspace);
if (restoredRun) {
this.emitRunUpdated(session.id, session.updatedAt, restoredRun);
}
return invokeTurn({
workflowSessionId: recovery.workflowSessionId,
checkpointId: recovery.checkpointId,
storePath: recovery.storePath,
});
}
}
recordWorkflowCheckpointRecovery(
session: SessionRecord,
run: SessionRunRecord,
event: WorkflowCheckpointSavedEvent,
): void {
this.recoveries.set(event.requestId, {
workflowSessionId: event.workflowSessionId,
checkpointId: event.checkpointId,
storePath: event.storePath,
stepNumber: event.stepNumber,
sessionMessages: structuredClone(session.messages),
runEvents: structuredClone(run.events),
});
}
restoreWorkflowCheckpointRecovery(
session: SessionRecord,
requestId: string,
recovery: WorkflowCheckpointRecoveryState,
): SessionRunRecord | undefined {
session.messages = structuredClone(recovery.sessionMessages);
session.status = 'running';
session.lastError = undefined;
session.updatedAt = nowIso();
this.clearPendingRunState(session, requestId);
return this.updateSessionRun(session, requestId, (run) => ({
...run,
events: structuredClone(recovery.runEvents),
}));
}
clearPendingRunState(session: SessionRecord, requestId: string): void {
this.setSessionPendingApprovalState(session, {});
session.pendingUserInput = undefined;
session.pendingPlanReview = undefined;
session.pendingMcpAuth = undefined;
for (const [approvalId, handle] of this.pendingApprovalHandles.entries()) {
if (handle.sessionId === session.id && handle.requestId === requestId) {
this.pendingApprovalHandles.delete(approvalId);
}
}
for (const [userInputId, handle] of this.pendingUserInputHandles.entries()) {
if (handle.sessionId === session.id && handle.requestId === requestId) {
this.pendingUserInputHandles.delete(userInputId);
}
}
}
async cleanupWorkflowCheckpointRecovery(requestId: string): Promise<void> {
const recovery = this.recoveries.get(requestId);
this.recoveries.delete(requestId);
if (!recovery) {
return;
}
try {
await rm(recovery.storePath, { recursive: true, force: true });
} catch (error) {
console.warn('[aryx workflow-checkpoint] Failed to clean checkpoint store:', error);
}
}
}
@@ -0,0 +1,195 @@
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
import {
applyDiscoveredMcpServerStatus,
normalizeDiscoveredToolingState,
type DiscoveredMcpServer,
type DiscoveredToolingState,
type DiscoveredToolingStatus,
} from '@shared/domain/discoveredTooling';
import {
normalizeProjectCustomizationState,
type ProjectCustomizationState,
} from '@shared/domain/projectCustomization';
import type { WorkspaceState } from '@shared/domain/workspace';
import { ConfigScannerRegistry } from '@main/services/configScanner';
import { ProjectCustomizationScanner } from '@main/services/customizationScanner';
import { ProjectCustomizationWatcher } from '@main/services/projectCustomizationWatcher';
export type DiscoveredToolingResolution = 'accept' | 'dismiss';
type DiscoveredToolingSyncServiceDeps = {
configScanner: ConfigScannerRegistry;
customizationScanner: ProjectCustomizationScanner;
projectCustomizationWatcher: ProjectCustomizationWatcher;
loadWorkspace: () => Promise<WorkspaceState>;
persistWorkspace: (workspace: WorkspaceState) => Promise<void>;
};
export class DiscoveredToolingSyncService {
private customizationWatcherUpdateQueue = Promise.resolve();
private readonly configScanner: ConfigScannerRegistry;
private readonly customizationScanner: ProjectCustomizationScanner;
private readonly projectCustomizationWatcher: ProjectCustomizationWatcher;
private readonly loadWorkspace: () => Promise<WorkspaceState>;
private readonly persistWorkspace: (workspace: WorkspaceState) => Promise<void>;
constructor(deps: DiscoveredToolingSyncServiceDeps) {
this.configScanner = deps.configScanner;
this.customizationScanner = deps.customizationScanner;
this.projectCustomizationWatcher = deps.projectCustomizationWatcher;
this.loadWorkspace = deps.loadWorkspace;
this.persistWorkspace = deps.persistWorkspace;
}
async syncUserDiscoveredTooling(workspace: WorkspaceState): Promise<boolean> {
const nextState = await this.configScanner.scanUser(workspace.settings.discoveredUserTooling);
if (this.equalDiscoveredToolingState(workspace.settings.discoveredUserTooling, nextState)) {
return false;
}
workspace.settings.discoveredUserTooling = nextState;
return true;
}
async syncProjectCustomizationWatchers(workspace: WorkspaceState): Promise<void> {
await this.projectCustomizationWatcher.syncProjects(
workspace.projects
.filter((project) => !isScratchpadProject(project))
.map((project) => ({
id: project.id,
path: project.path,
})),
);
}
async handleProjectCustomizationWatcherChange(projectId: string): Promise<void> {
await this.enqueueCustomizationWatcherUpdate(async () => {
const workspace = await this.loadWorkspace();
const project = workspace.projects.find((candidate) => candidate.id === projectId);
await this.syncProjectCustomizationWatchers(workspace);
if (!project || isScratchpadProject(project)) {
return;
}
const didSyncProjectCustomization = await this.syncProjectCustomization(project);
await this.syncProjectCustomizationWatchers(workspace);
if (didSyncProjectCustomization) {
await this.persistWorkspace(workspace);
}
});
}
async syncProjectCustomization(project: ProjectRecord): Promise<boolean> {
if (isScratchpadProject(project)) {
if (!project.customization || this.equalProjectCustomizationState(project.customization, undefined)) {
return false;
}
project.customization = undefined;
return true;
}
const nextState = await this.customizationScanner.scanProject(project.path, project.customization);
if (this.equalProjectCustomizationState(project.customization, nextState)) {
return false;
}
project.customization = nextState;
return true;
}
async syncProjectDiscoveredTooling(
workspace: WorkspaceState,
project: ProjectRecord,
): Promise<boolean> {
if (isScratchpadProject(project)) {
if (!project.discoveredTooling || this.equalDiscoveredToolingState(project.discoveredTooling, undefined)) {
return false;
}
project.discoveredTooling = undefined;
return true;
}
const nextState = await this.configScanner.scanProject(
project.id,
project.path,
project.discoveredTooling,
);
if (this.equalDiscoveredToolingState(project.discoveredTooling, nextState)) {
return false;
}
project.discoveredTooling = nextState;
return true;
}
resolveDiscoveredToolingStatus(
resolution: DiscoveredToolingResolution,
): Exclude<DiscoveredToolingStatus, 'pending'> {
return resolution === 'accept' ? 'accepted' : 'dismissed';
}
resolveWorkspaceDiscoveredTooling(
workspace: WorkspaceState,
serverIds: string[],
resolution: DiscoveredToolingResolution,
): void {
workspace.settings.discoveredUserTooling = applyDiscoveredMcpServerStatus(
workspace.settings.discoveredUserTooling,
serverIds,
this.resolveDiscoveredToolingStatus(resolution),
);
}
resolveProjectDiscoveredTooling(
project: ProjectRecord,
serverIds: string[],
resolution: DiscoveredToolingResolution,
): void {
project.discoveredTooling = applyDiscoveredMcpServerStatus(
project.discoveredTooling,
serverIds,
this.resolveDiscoveredToolingStatus(resolution),
);
}
equalDiscoveredToolingState(
left?: DiscoveredToolingState,
right?: DiscoveredToolingState,
): boolean {
const stripRuntime = (servers: DiscoveredMcpServer[]) =>
servers.map(({ probedTools: _, ...rest }) => rest);
return JSON.stringify(stripRuntime(normalizeDiscoveredToolingState(left).mcpServers))
=== JSON.stringify(stripRuntime(normalizeDiscoveredToolingState(right).mcpServers));
}
equalProjectCustomizationState(
left?: ProjectCustomizationState,
right?: ProjectCustomizationState,
): boolean {
const normalizedLeft = normalizeProjectCustomizationState(left);
const normalizedRight = normalizeProjectCustomizationState(right);
return JSON.stringify({
instructions: normalizedLeft.instructions,
agentProfiles: normalizedLeft.agentProfiles,
promptFiles: normalizedLeft.promptFiles,
}) === JSON.stringify({
instructions: normalizedRight.instructions,
agentProfiles: normalizedRight.agentProfiles,
promptFiles: normalizedRight.promptFiles,
});
}
private enqueueCustomizationWatcherUpdate(task: () => Promise<void>): Promise<void> {
const scheduledTask = this.customizationWatcherUpdateQueue.then(task, task);
this.customizationWatcherUpdateQueue = scheduledTask.then(
() => undefined,
() => undefined,
);
return scheduledTask;
}
}
+306
View File
@@ -0,0 +1,306 @@
import {
isScratchpadProject,
type ProjectGitDetails,
type ProjectGitDiffPreview,
type ProjectGitFileReference,
type ProjectRecord,
} from '@shared/domain/project';
import type { SessionRecord } from '@shared/domain/session';
import { setSessionRunGitSummary, type SessionRunRecord } from '@shared/domain/runTimeline';
import type { WorkspaceState } from '@shared/domain/workspace';
import { nowIso } from '@shared/utils/ids';
import { GitService } from '@main/git/gitService';
const GIT_REFRESH_DEBOUNCE_MS = 750;
const GIT_REFRESH_INTERVAL_MS = 60_000;
type GitContextManagerDeps = {
gitService: GitService;
loadWorkspace: () => Promise<WorkspaceState>;
persistWorkspace: (workspace: WorkspaceState) => Promise<WorkspaceState>;
requireProject: (workspace: WorkspaceState, projectId: string) => ProjectRecord;
requireSession: (workspace: WorkspaceState, sessionId: string) => SessionRecord;
requireSessionRun: (session: SessionRecord, runId: string) => SessionRunRecord;
syncProjectDiscoveredTooling: (workspace: WorkspaceState, project: ProjectRecord) => Promise<boolean>;
syncProjectCustomization: (project: ProjectRecord) => Promise<boolean>;
pruneUnavailableSessionToolingSelections: (workspace: WorkspaceState) => boolean;
pruneUnavailableApprovalTools: (workspace: WorkspaceState) => Promise<boolean>;
updateSessionRun: (
session: SessionRecord,
requestId: string,
updater: (run: SessionRunRecord) => SessionRunRecord,
) => SessionRunRecord | undefined;
emitRunUpdated: (sessionId: string, occurredAt: string, run: SessionRunRecord) => void;
};
export class GitContextManager {
private readonly gitService: GitService;
private readonly loadWorkspace: () => Promise<WorkspaceState>;
private readonly persistWorkspace: (workspace: WorkspaceState) => Promise<WorkspaceState>;
private readonly requireProject: (workspace: WorkspaceState, projectId: string) => ProjectRecord;
private readonly requireSession: (workspace: WorkspaceState, sessionId: string) => SessionRecord;
private readonly requireSessionRun: (session: SessionRecord, runId: string) => SessionRunRecord;
private readonly syncProjectDiscoveredTooling: (workspace: WorkspaceState, project: ProjectRecord) => Promise<boolean>;
private readonly syncProjectCustomization: (project: ProjectRecord) => Promise<boolean>;
private readonly pruneUnavailableSessionToolingSelections: (workspace: WorkspaceState) => boolean;
private readonly pruneUnavailableApprovalTools: (workspace: WorkspaceState) => Promise<boolean>;
private readonly updateSessionRun: GitContextManagerDeps['updateSessionRun'];
private readonly emitRunUpdated: GitContextManagerDeps['emitRunUpdated'];
private didStartPeriodicProjectGitRefresh = false;
private pendingProjectGitRefreshIds = new Set<string>();
private pendingRefreshAllProjects = false;
private projectGitRefreshTimer?: ReturnType<typeof setTimeout>;
private periodicProjectGitRefreshTimer?: ReturnType<typeof setInterval>;
private runningProjectGitRefresh?: Promise<void>;
constructor(deps: GitContextManagerDeps) {
this.gitService = deps.gitService;
this.loadWorkspace = deps.loadWorkspace;
this.persistWorkspace = deps.persistWorkspace;
this.requireProject = deps.requireProject;
this.requireSession = deps.requireSession;
this.requireSessionRun = deps.requireSessionRun;
this.syncProjectDiscoveredTooling = deps.syncProjectDiscoveredTooling;
this.syncProjectCustomization = deps.syncProjectCustomization;
this.pruneUnavailableSessionToolingSelections = deps.pruneUnavailableSessionToolingSelections;
this.pruneUnavailableApprovalTools = deps.pruneUnavailableApprovalTools;
this.updateSessionRun = deps.updateSessionRun;
this.emitRunUpdated = deps.emitRunUpdated;
}
dispose(): void {
if (this.projectGitRefreshTimer) {
clearTimeout(this.projectGitRefreshTimer);
this.projectGitRefreshTimer = undefined;
}
if (this.periodicProjectGitRefreshTimer) {
clearInterval(this.periodicProjectGitRefreshTimer);
this.periodicProjectGitRefreshTimer = undefined;
}
this.didStartPeriodicProjectGitRefresh = false;
}
scheduleProjectGitRefresh(projectId?: string): void {
if (projectId) {
this.pendingProjectGitRefreshIds.add(projectId);
} else {
this.pendingRefreshAllProjects = true;
this.pendingProjectGitRefreshIds.clear();
}
if (this.projectGitRefreshTimer) {
clearTimeout(this.projectGitRefreshTimer);
}
this.projectGitRefreshTimer = setTimeout(() => {
this.projectGitRefreshTimer = undefined;
void this.flushScheduledProjectGitRefresh();
}, GIT_REFRESH_DEBOUNCE_MS);
this.projectGitRefreshTimer.unref?.();
}
async refreshProjectGitContext(projectId?: string): Promise<WorkspaceState> {
return this.refreshProjectGitContexts(projectId ? [projectId] : undefined);
}
async getProjectGitDetails(projectId: string, commitLimit = 20): Promise<ProjectGitDetails> {
const workspace = await this.loadWorkspace();
const project = this.requireProject(workspace, projectId);
return this.gitService.describeProjectGitDetails(project.path, nowIso(), commitLimit);
}
async getProjectGitFilePreview(
projectId: string,
file: ProjectGitFileReference,
): Promise<ProjectGitDiffPreview | undefined> {
const workspace = await this.loadWorkspace();
const project = this.requireProject(workspace, projectId);
return this.gitService.getWorkingTreeFilePreview(project.path, file);
}
async discardSessionRunGitChanges(
sessionId: string,
runId: string,
files?: ProjectGitFileReference[],
): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const session = this.requireSession(workspace, sessionId);
const project = this.requireProject(workspace, session.projectId);
const run = this.requireSessionRun(session, runId);
if (run.workspaceKind !== 'project') {
throw new Error('Run change review is only available for project-backed sessions.');
}
if (!run.postRunGitSummary) {
throw new Error('This run does not have any tracked git changes to discard.');
}
await this.gitService.discardRunChanges(
this.resolveRunWorkingDirectory(session, project, run),
{
summary: run.postRunGitSummary,
preRunBaselineFiles: run.preRunGitBaselineFiles,
files,
},
);
await this.refreshProjectGitContexts([project.id]);
const refreshedWorkspace = await this.loadWorkspace();
const refreshedSession = this.requireSession(refreshedWorkspace, sessionId);
const refreshedProject = this.requireProject(refreshedWorkspace, refreshedSession.projectId);
const nextRun = await this.refreshSessionRunGitSummary(
refreshedSession,
refreshedProject,
run.requestId,
nowIso(),
);
if (nextRun) {
this.emitRunUpdated(refreshedSession.id, nowIso(), nextRun);
}
return this.persistWorkspace(refreshedWorkspace);
}
async runProjectGitMutation(
projectId: string,
mutation: (project: ProjectRecord) => Promise<void>,
): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const project = this.requireProject(workspace, projectId);
if (isScratchpadProject(project)) {
throw new Error('Git operations are not available for the Scratchpad project.');
}
await mutation(project);
return this.refreshProjectGitContexts([project.id]);
}
resolveRunWorkingDirectory(
session: SessionRecord,
project: ProjectRecord,
run: SessionRunRecord,
): string {
return run.workingDirectory ?? session.cwd ?? run.projectPath ?? project.path;
}
async refreshSessionRunGitSummary(
session: SessionRecord,
project: ProjectRecord,
requestId: string,
occurredAt: string,
): Promise<SessionRunRecord | undefined> {
const run = session.runs.find((candidate) => candidate.requestId === requestId);
if (!run || run.workspaceKind !== 'project' || !run.preRunGitSnapshot) {
return undefined;
}
const summary = await this.gitService.computeRunChangeSummary(
this.resolveRunWorkingDirectory(session, project, run),
{
generatedAt: occurredAt,
preRunSnapshot: run.preRunGitSnapshot,
preRunBaselineFiles: run.preRunGitBaselineFiles,
},
);
return this.updateSessionRun(session, requestId, (currentRun) =>
setSessionRunGitSummary(currentRun, summary));
}
async refreshProjectGitContexts(projectIds?: readonly string[]): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const projects = projectIds?.length
? projectIds.map((currentProjectId) => this.requireProject(workspace, currentProjectId))
: workspace.projects;
let didRefreshGit = false;
let didSyncProjectTooling = false;
let didSyncProjectCustomization = false;
for (const project of projects) {
didRefreshGit = await this.refreshGitContextForProject(project) || didRefreshGit;
didSyncProjectTooling = await this.syncProjectDiscoveredTooling(workspace, project) || didSyncProjectTooling;
didSyncProjectCustomization = await this.syncProjectCustomization(project) || didSyncProjectCustomization;
}
const didPruneSelections = didSyncProjectTooling
? this.pruneUnavailableSessionToolingSelections(workspace)
: false;
const didPruneApprovalTools = didSyncProjectTooling
? await this.pruneUnavailableApprovalTools(workspace)
: false;
return (
didRefreshGit
|| didSyncProjectTooling
|| didSyncProjectCustomization
|| didPruneSelections
|| didPruneApprovalTools
)
? this.persistWorkspace(workspace)
: workspace;
}
startPeriodicProjectGitRefresh(): void {
if (this.didStartPeriodicProjectGitRefresh) {
return;
}
this.didStartPeriodicProjectGitRefresh = true;
this.periodicProjectGitRefreshTimer = setInterval(() => {
this.scheduleProjectGitRefresh();
}, GIT_REFRESH_INTERVAL_MS);
this.periodicProjectGitRefreshTimer.unref?.();
}
stopPeriodicProjectGitRefresh(): void {
if (this.periodicProjectGitRefreshTimer) {
clearInterval(this.periodicProjectGitRefreshTimer);
this.periodicProjectGitRefreshTimer = undefined;
}
this.didStartPeriodicProjectGitRefresh = false;
}
async flushScheduledProjectGitRefresh(): Promise<void> {
if (this.runningProjectGitRefresh) {
return;
}
const projectIds = this.pendingRefreshAllProjects
? undefined
: [...this.pendingProjectGitRefreshIds];
this.pendingRefreshAllProjects = false;
this.pendingProjectGitRefreshIds.clear();
this.runningProjectGitRefresh = this.refreshProjectGitContexts(projectIds).then(
() => undefined,
(error) => {
console.error('[aryx git]', error);
},
);
try {
await this.runningProjectGitRefresh;
} finally {
this.runningProjectGitRefresh = undefined;
if (this.pendingRefreshAllProjects || this.pendingProjectGitRefreshIds.size > 0) {
this.scheduleProjectGitRefresh();
}
}
}
private async refreshGitContextForProject(project: ProjectRecord): Promise<boolean> {
if (isScratchpadProject(project)) {
if (!project.git) {
return false;
}
project.git = undefined;
return true;
}
project.git = await this.gitService.describeProject(project.path);
return true;
}
}
+90
View File
@@ -0,0 +1,90 @@
import electron from 'electron';
import type { QuickPromptSettings } from '@shared/domain/tooling';
export class GlobalHotkeyService {
private currentAccelerator: string | undefined;
private lastFailedAccelerator: string | undefined;
private callback: (() => void) | undefined;
register(settings: QuickPromptSettings, callback: () => void): void {
this.callback = callback;
if (!settings.enabled) {
this.unregister();
return;
}
const accelerator = toElectronAccelerator(settings.hotkey);
// Already registered this accelerator — nothing to do
if (accelerator === this.currentAccelerator) return;
// Don't retry an accelerator that already failed (avoids log spam on
// repeated workspace-updated events during startup)
if (accelerator === this.lastFailedAccelerator) return;
this.unregister();
// Access globalShortcut lazily to ensure electron is fully initialised
const { globalShortcut } = electron;
if (!globalShortcut) {
console.error('[globalHotkey] electron.globalShortcut is not available');
return;
}
try {
const registered = globalShortcut.register(accelerator, callback);
if (registered) {
console.info(`[globalHotkey] Registered: ${accelerator}`);
this.currentAccelerator = accelerator;
this.lastFailedAccelerator = undefined;
} else {
console.warn(
`[globalHotkey] Failed to register accelerator: ${accelerator}` +
' — it may be reserved by the OS or another application',
);
this.currentAccelerator = undefined;
this.lastFailedAccelerator = accelerator;
}
} catch (err) {
console.error(`[globalHotkey] Error registering ${accelerator}:`, err);
this.currentAccelerator = undefined;
this.lastFailedAccelerator = accelerator;
}
}
/** Re-registers with updated settings (e.g. hotkey string changed). */
update(settings: QuickPromptSettings): void {
if (!this.callback) return;
this.register(settings, this.callback);
}
unregister(): void {
if (this.currentAccelerator) {
try {
const { globalShortcut } = electron;
globalShortcut?.unregister(this.currentAccelerator);
} catch {
// best-effort
}
this.currentAccelerator = undefined;
}
this.lastFailedAccelerator = undefined;
}
dispose(): void {
this.unregister();
this.callback = undefined;
}
}
/**
* Convert a portable hotkey string to an Electron accelerator.
*
* - "Super" → "Meta" (Win key on Windows, Cmd on macOS)
* - Strings already using Electron-native names pass through unchanged.
*/
function toElectronAccelerator(hotkey: string): string {
return hotkey.replace(/\bSuper\b/gi, 'Meta');
}
+309
View File
@@ -0,0 +1,309 @@
import type { SessionToolingSelection, WorkspaceToolingSettings, McpServerDefinition } from '@shared/domain/tooling';
import {
listAcceptedDiscoveredMcpServers,
type DiscoveredMcpServer,
type DiscoveredToolingState,
} from '@shared/domain/discoveredTooling';
import type { WorkspaceState } from '@shared/domain/workspace';
import { nowIso } from '@shared/utils/ids';
import { probeServers, type McpProbeResult } from '@main/services/mcpToolProber';
import { getStoredToken } from '@main/services/mcpTokenStore';
import { performMcpOAuthFlow, requiresOAuth } from '@main/services/mcpOAuthService';
type McpProbeManagerDeps = {
loadWorkspace: () => Promise<WorkspaceState>;
persistWorkspace: (workspace: WorkspaceState) => Promise<void>;
probeMcpServers?: typeof probeServers;
tokenLookup?: (serverUrl: string) => string | undefined;
performMcpOAuthFlow?: typeof performMcpOAuthFlow;
requiresOAuth?: typeof requiresOAuth;
};
export class McpProbeManager {
private mcpProbeUpdateQueue = Promise.resolve();
private readonly loadWorkspace: () => Promise<WorkspaceState>;
private readonly persistWorkspace: (workspace: WorkspaceState) => Promise<void>;
private readonly probeMcpServers: typeof probeServers;
private readonly tokenLookup: (serverUrl: string) => string | undefined;
private readonly performMcpOAuthFlow: typeof performMcpOAuthFlow;
private readonly requiresOAuth: typeof requiresOAuth;
constructor(deps: McpProbeManagerDeps) {
this.loadWorkspace = deps.loadWorkspace;
this.persistWorkspace = deps.persistWorkspace;
this.probeMcpServers = deps.probeMcpServers ?? probeServers;
this.tokenLookup = deps.tokenLookup ?? ((serverUrl) => getStoredToken(serverUrl)?.accessToken);
this.performMcpOAuthFlow = deps.performMcpOAuthFlow ?? performMcpOAuthFlow;
this.requiresOAuth = deps.requiresOAuth ?? requiresOAuth;
}
async probeAndAuthenticateHttpMcpServers(
tooling: WorkspaceToolingSettings,
selection: SessionToolingSelection,
): Promise<void> {
const httpServers = selection.enabledMcpServerIds
.map((id) => tooling.mcpServers.find((server) => server.id === id))
.filter((server): server is McpServerDefinition => !!server && server.transport !== 'local')
.filter((server) => server.transport === 'http' || server.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 = this.tokenLookup(server.url);
if (existingToken) {
console.log(`[aryx oauth] Skipping ${server.name} — token already stored`);
continue;
}
try {
const needsAuth = await this.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 this.performMcpOAuthFlow({ serverUrl: server.url });
if (result.success) {
console.log(`[aryx oauth] ${server.name} authenticated successfully`);
void this.reprobeServerByUrl(server.url).catch((error) => {
console.error('[aryx mcp-probe] re-probe after auth failed:', error);
});
} else {
console.warn(`[aryx oauth] Proactive auth failed for ${server.name}: ${result.error}`);
}
} catch (error) {
console.warn(`[aryx oauth] Proactive auth probe failed for ${server.name}:`, error);
}
}
}
async probeAllAcceptedMcpServers(workspace: WorkspaceState): Promise<void> {
const targets = [
...this.listAcceptedDiscoveredServerDefinitions(
workspace,
(server) => !server.probedTools || server.probedTools.length === 0,
),
...workspace.settings.tooling.mcpServers.filter(
(server) => server.tools.length === 0 && (!server.probedTools || server.probedTools.length === 0),
),
];
await this.probeWorkspaceMcpServers(workspace, targets);
}
async probeDiscoveredMcpServersFromState(
workspace: WorkspaceState,
state?: DiscoveredToolingState,
): Promise<void> {
const targets = listAcceptedDiscoveredMcpServers(state)
.filter((server) => !server.probedTools || server.probedTools.length === 0)
.map((server) => this.discoveredServerToDefinition(server));
await this.probeWorkspaceMcpServers(workspace, targets);
}
async probeDiscoveredMcpServers(
workspace: WorkspaceState,
state: DiscoveredToolingState | undefined,
serverIds: ReadonlyArray<string>,
): Promise<void> {
const targets = listAcceptedDiscoveredMcpServers(state)
.filter((server) => serverIds.includes(server.id))
.map((server) => this.discoveredServerToDefinition(server));
await this.probeWorkspaceMcpServers(workspace, targets);
}
async probeWorkspaceMcpServers(
workspace: WorkspaceState,
targets: ReadonlyArray<McpServerDefinition>,
): Promise<void> {
const uniqueTargets = [...new Map(targets.map((server) => [server.id, server])).values()];
if (uniqueTargets.length === 0) {
return;
}
const targetIds = uniqueTargets.map((server) => server.id);
await this.enqueueMcpProbeUpdate(async () => {
if (this.addMcpProbingServerIds(workspace, targetIds)) {
await this.persistWorkspace(workspace);
}
});
try {
await this.probeMcpServers(uniqueTargets, this.tokenLookup, (result) =>
this.enqueueMcpProbeUpdate(async () => {
const didUpdateProbing = this.removeMcpProbingServerIds(workspace, [result.serverId]);
const didApplyResult = this.applyMcpProbeResult(workspace, result);
if (didUpdateProbing || didApplyResult) {
await this.persistWorkspace(workspace);
}
}));
} finally {
await this.enqueueMcpProbeUpdate(async () => {
if (this.removeMcpProbingServerIds(workspace, targetIds)) {
await this.persistWorkspace(workspace);
}
});
}
}
async reprobeServerByUrl(serverUrl: string): Promise<void> {
const workspace = await this.loadWorkspace();
const targets: McpServerDefinition[] = [];
for (const server of workspace.settings.tooling.mcpServers) {
if (server.transport !== 'local' && server.url === serverUrl) {
targets.push(server);
}
}
const allDiscovered = [
...(workspace.settings.discoveredUserTooling?.mcpServers ?? []),
...workspace.projects.flatMap((project) => project.discoveredTooling?.mcpServers ?? []),
];
for (const server of allDiscovered) {
if (server.status === 'accepted' && server.transport !== 'local' && server.url === serverUrl) {
targets.push(this.discoveredServerToDefinition(server));
}
}
await this.probeWorkspaceMcpServers(workspace, targets);
}
listAcceptedDiscoveredServerDefinitions(
workspace: WorkspaceState,
predicate?: (server: DiscoveredMcpServer) => boolean,
): McpServerDefinition[] {
const definitions: McpServerDefinition[] = [];
for (const state of this.listDiscoveredToolingStates(workspace)) {
for (const server of listAcceptedDiscoveredMcpServers(state)) {
if (predicate && !predicate(server)) {
continue;
}
definitions.push(this.discoveredServerToDefinition(server));
}
}
return definitions;
}
listDiscoveredToolingStates(workspace: WorkspaceState): Array<DiscoveredToolingState | undefined> {
return [
workspace.settings.discoveredUserTooling,
...workspace.projects.map((project) => project.discoveredTooling),
];
}
addMcpProbingServerIds(workspace: WorkspaceState, serverIds: ReadonlyArray<string>): boolean {
return this.updateMcpProbingServerIds(workspace, serverIds, 'add');
}
removeMcpProbingServerIds(workspace: WorkspaceState, serverIds: ReadonlyArray<string>): boolean {
return this.updateMcpProbingServerIds(workspace, serverIds, 'remove');
}
updateMcpProbingServerIds(
workspace: WorkspaceState,
serverIds: ReadonlyArray<string>,
operation: 'add' | 'remove',
): boolean {
const next = new Set(workspace.mcpProbingServerIds ?? []);
const before = next.size;
for (const serverId of serverIds) {
if (operation === 'add') {
next.add(serverId);
} else {
next.delete(serverId);
}
}
if (next.size === before) {
return false;
}
if (next.size === 0) {
delete workspace.mcpProbingServerIds;
} else {
workspace.mcpProbingServerIds = [...next];
}
return true;
}
applyMcpProbeResult(workspace: WorkspaceState, result: McpProbeResult): boolean {
if (result.status !== 'success' || result.tools.length === 0) {
return false;
}
let changed = false;
for (const server of workspace.settings.tooling.mcpServers) {
if (server.id !== result.serverId) {
continue;
}
server.probedTools = result.tools;
changed = true;
}
for (const state of this.listDiscoveredToolingStates(workspace)) {
for (const server of state?.mcpServers ?? []) {
if (server.id !== result.serverId) {
continue;
}
server.probedTools = result.tools;
changed = true;
}
}
return changed;
}
discoveredServerToDefinition(server: DiscoveredMcpServer): McpServerDefinition {
if (server.transport === 'local') {
return {
id: server.id,
name: server.name,
transport: 'local',
command: server.command,
args: [...server.args],
cwd: server.cwd,
env: server.env ? { ...server.env } : undefined,
tools: [...server.tools],
timeoutMs: server.timeoutMs,
createdAt: nowIso(),
updatedAt: nowIso(),
};
}
return {
id: server.id,
name: server.name,
transport: server.transport,
url: server.url,
headers: server.headers ? { ...server.headers } : undefined,
tools: [...server.tools],
timeoutMs: server.timeoutMs,
createdAt: nowIso(),
updatedAt: nowIso(),
};
}
private async enqueueMcpProbeUpdate(update: () => Promise<void>): Promise<void> {
const next = this.mcpProbeUpdateQueue.then(update, update);
this.mcpProbeUpdateQueue = next.catch(() => undefined);
await next;
}
}
@@ -169,7 +169,14 @@ async function collectExistingDirectories(rootPath: string): Promise<string[]> {
}
function createProjectWatchHandle(directoryPath: string, onChange: () => void): ProjectWatchHandle {
return watch(directoryPath, { persistent: false }, () => {
const watcher = watch(directoryPath, { persistent: false }, () => {
onChange();
});
watcher.on('error', (error) => {
console.warn(`[aryx customization] Watcher error for ${directoryPath}:`, error);
watcher.close();
});
return watcher;
}
+956
View File
@@ -0,0 +1,956 @@
import type {
AgentActivityEvent,
ApprovalRequestedEvent,
ExitPlanModeRequestedEvent,
InteractionMode,
McpOauthRequiredEvent,
MessageMode,
MessageReclassifiedEvent,
RunTurnCommand,
RunTurnCustomAgentConfig,
RunTurnToolingConfig,
TurnDeltaEvent,
UserInputRequestedEvent,
WorkflowCheckpointResume,
} from '@shared/contracts/sidecar';
import {
buildAvailableModelCatalog,
findModelByReference,
normalizeWorkflowModels,
resolveReasoningEffort,
} from '@shared/domain/models';
import {
approvalPolicyRequiresCheckpoint,
type ApprovalDecision,
type PendingApprovalMessageRecord,
type PendingApprovalRecord,
} from '@shared/domain/approval';
import {
listEnabledProjectAgentProfiles,
normalizeProjectPromptInvocation,
type ProjectAgentProfile,
type ProjectPromptInvocation,
type ProjectCustomizationState,
} from '@shared/domain/projectCustomization';
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
import {
applySessionApprovalSettings,
applySessionModelConfig,
resolveSessionTitle,
type ChatMessageRecord,
type SessionRecord,
} from '@shared/domain/session';
import {
appendRunActivityEvent,
cancelSessionRunRecord,
completeSessionRunRecord,
createSessionRunRecord,
failSessionRunRecord,
upsertRunMessageEvent,
type SessionRunRecord,
} from '@shared/domain/runTimeline';
import type { ChatMessageAttachment } from '@shared/domain/attachment';
import type { SessionEventRecord } from '@shared/domain/event';
import type { WorkspaceState } from '@shared/domain/workspace';
import {
resolveWorkflowAgentNodes,
type ReasoningEffort,
type WorkflowDefinition,
} from '@shared/domain/workflow';
import {
resolveWorkflowAgents as resolveWorkspaceWorkflowAgents,
type WorkspaceAgentDefinition,
} from '@shared/domain/workspaceAgent';
import { nowIso } from '@shared/utils/ids';
import { mergeStreamingText } from '@shared/utils/streamingText';
import type { TurnScopedEvent } from '@main/sidecar/runTurnPending';
import { TurnCancelledError } from '@main/sidecar/turnCancelledError';
function isPlanPromptInvocation(promptInvocation?: ProjectPromptInvocation): boolean {
return promptInvocation?.agent?.trim().toLowerCase() === 'plan';
}
type SessionTurnExecutorDeps = {
saveWorkspace: (workspace: WorkspaceState) => Promise<void>;
persistWorkspace: (workspace: WorkspaceState) => Promise<WorkspaceState>;
requireSession: (workspace: WorkspaceState, sessionId: string) => SessionRecord;
resolveSessionWorkflow: (workspace: WorkspaceState, session: SessionRecord) => WorkflowDefinition;
updateSessionRun: (
session: SessionRecord,
requestId: string,
updater: (run: SessionRunRecord) => SessionRunRecord,
) => SessionRunRecord | undefined;
emitRunUpdated: (sessionId: string, occurredAt: string, run: SessionRunRecord) => void;
emitSessionEvent: (event: SessionEventRecord) => void;
rejectPendingApprovals: (session: SessionRecord, failedAt: string, error: string) => string[];
buildRunTurnToolingConfig: (
workspace: WorkspaceState,
session: SessionRecord,
) => RunTurnToolingConfig | undefined;
runSidecarTurnWithCheckpointRecovery: (
workspace: WorkspaceState,
session: SessionRecord,
requestId: string,
createCommand: (resumeFromCheckpoint?: WorkflowCheckpointResume) => RunTurnCommand,
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>,
onMessageReclassified: (event: MessageReclassifiedEvent) => void | Promise<void>,
onTurnScopedEvent: (event: TurnScopedEvent) => void | Promise<void>,
) => Promise<ChatMessageRecord[]>;
handleApprovalRequested: (
workspace: WorkspaceState,
sessionId: string,
requestId: string,
approval: ApprovalRequestedEvent | PendingApprovalRecord,
resolve: (decision: ApprovalDecision, alwaysApprove?: boolean) => void | Promise<void>,
) => Promise<void>;
handleUserInputRequested: (
workspace: WorkspaceState,
sessionId: string,
requestId: string,
event: UserInputRequestedEvent,
resolve: (answer: string, wasFreeform: boolean) => void | Promise<void>,
) => Promise<void>;
handleMcpOAuthRequired: (
workspace: WorkspaceState,
sessionId: string,
event: McpOauthRequiredEvent,
) => Promise<void>;
handleExitPlanModeRequested: (
workspace: WorkspaceState,
sessionId: string,
event: ExitPlanModeRequestedEvent,
) => Promise<void>;
handleTurnScopedEvent: (
workspace: WorkspaceState,
sessionId: string,
event: TurnScopedEvent,
) => void | Promise<void>;
sidecarResolveApproval: (
approvalId: string,
decision: ApprovalDecision,
alwaysApprove?: boolean,
) => Promise<void>;
sidecarResolveUserInput: (
userInputId: string,
answer: string,
wasFreeform: boolean,
) => Promise<void>;
captureWorkingTreeSnapshot: (
projectPath: string,
scannedAt: string,
) => Promise<import('@shared/domain/project').ProjectGitWorkingTreeSnapshot | undefined>;
captureWorkingTreeBaseline: (
projectPath: string,
snapshot: import('@shared/domain/project').ProjectGitWorkingTreeSnapshot,
) => Promise<import('@shared/domain/project').ProjectGitBaselineFile[]>;
refreshSessionRunGitSummary: (
session: SessionRecord,
project: ProjectRecord,
requestId: string,
occurredAt: string,
) => Promise<SessionRunRecord | undefined>;
cleanupWorkflowCheckpointRecovery: (requestId: string) => Promise<void>;
scheduleProjectGitRefresh: (projectId: string) => void;
loadAvailableModelCatalog: () => Promise<ReturnType<typeof buildAvailableModelCatalog>>;
};
export class SessionTurnExecutor {
private readonly saveWorkspace: SessionTurnExecutorDeps['saveWorkspace'];
private readonly persistWorkspace: SessionTurnExecutorDeps['persistWorkspace'];
private readonly requireSession: SessionTurnExecutorDeps['requireSession'];
private readonly resolveSessionWorkflow: SessionTurnExecutorDeps['resolveSessionWorkflow'];
private readonly updateSessionRun: SessionTurnExecutorDeps['updateSessionRun'];
private readonly emitRunUpdated: SessionTurnExecutorDeps['emitRunUpdated'];
private readonly emitSessionEvent: SessionTurnExecutorDeps['emitSessionEvent'];
private readonly rejectPendingApprovals: SessionTurnExecutorDeps['rejectPendingApprovals'];
private readonly buildRunTurnToolingConfig: SessionTurnExecutorDeps['buildRunTurnToolingConfig'];
private readonly runSidecarTurnWithCheckpointRecovery: SessionTurnExecutorDeps['runSidecarTurnWithCheckpointRecovery'];
private readonly handleApprovalRequested: SessionTurnExecutorDeps['handleApprovalRequested'];
private readonly handleUserInputRequested: SessionTurnExecutorDeps['handleUserInputRequested'];
private readonly handleMcpOAuthRequired: SessionTurnExecutorDeps['handleMcpOAuthRequired'];
private readonly handleExitPlanModeRequested: SessionTurnExecutorDeps['handleExitPlanModeRequested'];
private readonly handleTurnScopedEvent: SessionTurnExecutorDeps['handleTurnScopedEvent'];
private readonly sidecarResolveApproval: SessionTurnExecutorDeps['sidecarResolveApproval'];
private readonly sidecarResolveUserInput: SessionTurnExecutorDeps['sidecarResolveUserInput'];
private readonly captureWorkingTreeSnapshot: SessionTurnExecutorDeps['captureWorkingTreeSnapshot'];
private readonly captureWorkingTreeBaseline: SessionTurnExecutorDeps['captureWorkingTreeBaseline'];
private readonly refreshSessionRunGitSummary: SessionTurnExecutorDeps['refreshSessionRunGitSummary'];
private readonly cleanupWorkflowCheckpointRecovery: SessionTurnExecutorDeps['cleanupWorkflowCheckpointRecovery'];
private readonly scheduleProjectGitRefresh: SessionTurnExecutorDeps['scheduleProjectGitRefresh'];
private readonly loadAvailableModelCatalog: SessionTurnExecutorDeps['loadAvailableModelCatalog'];
constructor(deps: SessionTurnExecutorDeps) {
this.saveWorkspace = deps.saveWorkspace;
this.persistWorkspace = deps.persistWorkspace;
this.requireSession = deps.requireSession;
this.resolveSessionWorkflow = deps.resolveSessionWorkflow;
this.updateSessionRun = deps.updateSessionRun;
this.emitRunUpdated = deps.emitRunUpdated;
this.emitSessionEvent = deps.emitSessionEvent;
this.rejectPendingApprovals = deps.rejectPendingApprovals;
this.buildRunTurnToolingConfig = deps.buildRunTurnToolingConfig;
this.runSidecarTurnWithCheckpointRecovery = deps.runSidecarTurnWithCheckpointRecovery;
this.handleApprovalRequested = deps.handleApprovalRequested;
this.handleUserInputRequested = deps.handleUserInputRequested;
this.handleMcpOAuthRequired = deps.handleMcpOAuthRequired;
this.handleExitPlanModeRequested = deps.handleExitPlanModeRequested;
this.handleTurnScopedEvent = deps.handleTurnScopedEvent;
this.sidecarResolveApproval = deps.sidecarResolveApproval;
this.sidecarResolveUserInput = deps.sidecarResolveUserInput;
this.captureWorkingTreeSnapshot = deps.captureWorkingTreeSnapshot;
this.captureWorkingTreeBaseline = deps.captureWorkingTreeBaseline;
this.refreshSessionRunGitSummary = deps.refreshSessionRunGitSummary;
this.cleanupWorkflowCheckpointRecovery = deps.cleanupWorkflowCheckpointRecovery;
this.scheduleProjectGitRefresh = deps.scheduleProjectGitRefresh;
this.loadAvailableModelCatalog = deps.loadAvailableModelCatalog;
}
async runPreparedSessionTurn(
workspace: WorkspaceState,
session: SessionRecord,
project: ProjectRecord,
effectiveWorkflow: WorkflowDefinition,
projectInstructions: string | undefined,
options: {
occurredAt: string;
requestId: string;
triggerMessageId: string;
messageMode?: MessageMode;
attachments?: ChatMessageAttachment[];
},
): Promise<void> {
const workspaceKind = isScratchpadProject(project) ? 'scratchpad' : 'project';
const { occurredAt, requestId, triggerMessageId, messageMode, attachments } = options;
const promptInvocation = this.resolveRunTurnPromptInvocation(session, triggerMessageId);
const workflowForTurn = await this.applyPromptInvocationToWorkflow(effectiveWorkflow, promptInvocation);
const interactionMode: InteractionMode = isPlanPromptInvocation(promptInvocation)
? 'plan'
: session.interactionMode ?? 'interactive';
const runWorkingDirectory = session.cwd ?? project.path;
const preRunGitSnapshot = workspaceKind === 'project'
? await this.captureWorkingTreeSnapshot(runWorkingDirectory, occurredAt)
: undefined;
const preRunGitBaselineFiles = workspaceKind === 'project' && preRunGitSnapshot
? await this.captureWorkingTreeBaseline(runWorkingDirectory, preRunGitSnapshot)
: undefined;
if (workspaceKind === 'project' && project.git?.status === 'ready' && !preRunGitSnapshot) {
console.warn(`[aryx git] Failed to capture pre-run git snapshot for project "${project.id}".`);
}
session.title = resolveSessionTitle(session, workflowForTurn, session.messages);
session.status = 'running';
session.lastError = undefined;
session.pendingPlanReview = undefined;
session.pendingMcpAuth = undefined;
session.updatedAt = occurredAt;
session.runs = [
createSessionRunRecord({
requestId,
project,
workingDirectory: runWorkingDirectory,
workspaceKind,
workflow: workflowForTurn,
triggerMessageId,
startedAt: occurredAt,
preRunGitSnapshot,
preRunGitBaselineFiles,
}),
...session.runs,
];
await this.persistWorkspace(workspace);
this.emitSessionEvent({
sessionId: session.id,
kind: 'status',
status: 'running',
occurredAt,
});
try {
const createRunTurnCommand = (
resumeFromCheckpoint?: WorkflowCheckpointResume,
): RunTurnCommand => ({
type: 'run-turn',
requestId,
sessionId: session.id,
projectPath: runWorkingDirectory,
workspaceKind,
mode: interactionMode,
messageMode,
projectInstructions,
workflow: workflowForTurn,
workflowLibrary: workspace.workflows,
messages: session.messages,
attachments: attachments?.length ? attachments : undefined,
promptInvocation,
tooling: this.buildRunTurnToolingConfig(workspace, session),
resumeFromCheckpoint,
});
const responseMessages = await this.runSidecarTurnWithCheckpointRecovery(
workspace,
session,
requestId,
createRunTurnCommand,
async (event) => {
await this.applyTurnDelta(workspace, session.id, requestId, event);
},
async (event) => {
await this.applyAgentActivity(workspace, session.id, requestId, event);
},
async (event) => {
await this.handleApprovalRequested(workspace, session.id, requestId, event, (decision, alwaysApprove) =>
this.sidecarResolveApproval(event.approvalId, decision, alwaysApprove));
},
async (event) => {
await this.handleUserInputRequested(workspace, session.id, requestId, event, (answer, wasFreeform) =>
this.sidecarResolveUserInput(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.applyMessageReclassified(workspace, session.id, event);
},
async (event) => {
await this.handleTurnScopedEvent(workspace, session.id, event);
},
);
await this.awaitFinalResponseApproval(workspace, session.id, requestId, workflowForTurn, responseMessages);
this.finalizeTurn(workspace, session.id, requestId, responseMessages);
if (workspaceKind === 'project') {
const completedRun = await this.refreshSessionRunGitSummary(session, project, requestId, nowIso());
if (completedRun) {
this.emitRunUpdated(session.id, nowIso(), completedRun);
}
}
await this.persistWorkspace(workspace);
await this.cleanupWorkflowCheckpointRecovery(requestId);
if (workspaceKind === 'project') {
this.scheduleProjectGitRefresh(project.id);
}
} catch (error) {
if (error instanceof TurnCancelledError) {
this.finalizeCancelledTurn(session, requestId);
if (workspaceKind === 'project') {
const cancelledRun = await this.refreshSessionRunGitSummary(session, project, requestId, nowIso());
if (cancelledRun) {
this.emitRunUpdated(session.id, nowIso(), cancelledRun);
}
}
await this.persistWorkspace(workspace);
await this.cleanupWorkflowCheckpointRecovery(requestId);
if (workspaceKind === 'project') {
this.scheduleProjectGitRefresh(project.id);
}
return;
}
const failedAt = nowIso();
session.status = 'error';
session.lastError = error instanceof Error ? error.message : String(error);
session.updatedAt = failedAt;
const failedRun = this.updateSessionRun(session, requestId, (run) =>
failSessionRunRecord(run, failedAt, session.lastError ?? 'Unknown error.'));
this.emitSessionEvent({
sessionId: session.id,
kind: 'error',
occurredAt: failedAt,
error: session.lastError,
});
if (failedRun) {
this.emitRunUpdated(session.id, failedAt, failedRun);
}
if (workspaceKind === 'project') {
const summarizedRun = await this.refreshSessionRunGitSummary(session, project, requestId, failedAt);
if (summarizedRun) {
this.emitRunUpdated(session.id, failedAt, summarizedRun);
}
}
await this.persistWorkspace(workspace);
await this.cleanupWorkflowCheckpointRecovery(requestId);
if (workspaceKind === 'project') {
this.scheduleProjectGitRefresh(project.id);
}
}
}
async buildEffectiveWorkflow(
workflow: WorkflowDefinition,
session: SessionRecord,
workspaceAgents: ReadonlyArray<WorkspaceAgentDefinition>,
): Promise<WorkflowDefinition> {
const resolvedWorkflow = resolveWorkspaceWorkflowAgents(workflow, workspaceAgents);
const workflowWithSessionConfig = session.sessionModelConfig
? applySessionModelConfig(resolvedWorkflow, session)
: resolvedWorkflow;
const workflowWithApprovalSettings = applySessionApprovalSettings(workflowWithSessionConfig, session);
const modelCatalog = await this.loadAvailableModelCatalog();
return normalizeWorkflowModels(workflowWithApprovalSettings, modelCatalog);
}
applyProjectCustomizationToWorkflow(
workflow: WorkflowDefinition,
project: ProjectRecord,
): WorkflowDefinition {
if (isScratchpadProject(project)) {
return workflow;
}
const projectCustomAgents = this.buildProjectCustomAgents(project.customization);
if (projectCustomAgents.length === 0) {
return workflow;
}
const primaryAgentNode = resolveWorkflowAgentNodes(workflow)[0];
if (!primaryAgentNode || primaryAgentNode.config.kind !== 'agent') {
return workflow;
}
const existingCustomAgents = primaryAgentNode.config.copilot?.customAgents ?? [];
const existingAgentNames = new Set(existingCustomAgents.map((agent) => agent.name.toLowerCase()));
const mergedCustomAgents = [
...existingCustomAgents,
...projectCustomAgents.filter((agent) => !existingAgentNames.has(agent.name.toLowerCase())),
];
return {
...workflow,
graph: {
...workflow.graph,
nodes: workflow.graph.nodes.map((node) => {
if (node.id !== primaryAgentNode.id || node.kind !== 'agent' || node.config.kind !== 'agent') {
return node;
}
return {
...node,
config: {
...node.config,
copilot: {
...node.config.copilot,
customAgents: mergedCustomAgents,
},
},
};
}),
},
};
}
resolveRunTurnPromptInvocation(
session: SessionRecord,
triggerMessageId: string,
): ProjectPromptInvocation | undefined {
const triggerMessage = session.messages.find((message) => message.id === triggerMessageId);
return normalizeProjectPromptInvocation(triggerMessage?.promptInvocation);
}
async applyPromptInvocationToWorkflow(
workflow: WorkflowDefinition,
promptInvocation?: ProjectPromptInvocation,
): Promise<WorkflowDefinition> {
const requestedModel = promptInvocation?.model?.trim();
if (!requestedModel) {
return workflow;
}
const modelCatalog = await this.loadAvailableModelCatalog();
const resolvedModel = findModelByReference(requestedModel, modelCatalog);
const effectiveModelId = resolvedModel?.id ?? requestedModel;
let didChange = false;
const nodes = workflow.graph.nodes.map((node) => {
if (node.kind !== 'agent' || node.config.kind !== 'agent') {
return node;
}
const agent = node.config;
const reasoningEffort: ReasoningEffort | undefined = resolvedModel?.supportedReasoningEfforts
? resolveReasoningEffort(resolvedModel, agent.reasoningEffort)
: undefined;
if (agent.model === effectiveModelId && agent.reasoningEffort === reasoningEffort) {
return node;
}
didChange = true;
return {
...node,
config: {
...agent,
model: effectiveModelId,
reasoningEffort,
},
};
});
return didChange
? {
...workflow,
graph: {
...workflow.graph,
nodes,
},
}
: workflow;
}
buildProjectCustomAgents(
customization?: ProjectCustomizationState,
): RunTurnCustomAgentConfig[] {
return listEnabledProjectAgentProfiles(customization).map((profile) => this.mapProjectAgentProfile(profile));
}
mapProjectAgentProfile(profile: ProjectAgentProfile): RunTurnCustomAgentConfig {
const customAgent: RunTurnCustomAgentConfig = {
name: profile.name,
prompt: profile.prompt,
};
if (profile.displayName) {
customAgent.displayName = profile.displayName;
}
if (profile.description) {
customAgent.description = profile.description;
}
if (profile.tools) {
customAgent.tools = profile.tools;
}
if (profile.infer !== undefined) {
customAgent.infer = profile.infer;
}
return customAgent;
}
private async applyTurnDelta(
workspace: WorkspaceState,
sessionId: string,
requestId: string,
event: TurnDeltaEvent,
): Promise<void> {
if (event.content === undefined && event.contentDelta === undefined) {
return;
}
const occurredAt = nowIso();
const session = this.requireSession(workspace, sessionId);
const existing = session.messages.find((message) => message.id === event.messageId);
const content =
existing && event.content === undefined
? mergeStreamingText(existing.content, event.contentDelta)
: (event.content ?? event.contentDelta);
const completedMessages: ChatMessageRecord[] = [];
if (existing) {
existing.content = content;
existing.pending = true;
existing.authorName = event.authorName;
} else {
for (const message of session.messages) {
if (message.pending && message.role === 'assistant') {
message.pending = false;
completedMessages.push(message);
}
}
session.messages.push({
id: event.messageId,
role: 'assistant',
authorName: event.authorName,
content,
createdAt: occurredAt,
pending: true,
});
}
const nextRun = this.updateSessionRun(session, requestId, (run) =>
upsertRunMessageEvent(run, {
messageId: event.messageId,
occurredAt,
authorName: event.authorName,
content,
status: 'running',
}));
session.updatedAt = occurredAt;
await this.saveWorkspace(workspace);
for (const completed of completedMessages) {
this.emitSessionEvent({
sessionId,
kind: 'message-complete',
occurredAt,
messageId: completed.id,
authorName: completed.authorName,
content: completed.content,
});
}
this.emitSessionEvent({
sessionId,
kind: 'message-delta',
occurredAt,
messageId: event.messageId,
authorName: event.authorName,
contentDelta: event.contentDelta,
content,
});
if (nextRun) {
this.emitRunUpdated(sessionId, occurredAt, nextRun);
}
}
private async applyMessageReclassified(
workspace: WorkspaceState,
sessionId: string,
event: MessageReclassifiedEvent,
): Promise<void> {
const session = this.requireSession(workspace, sessionId);
const message = session.messages.find((m) => m.id === event.messageId);
if (!message || message.messageKind === 'thinking') {
return;
}
message.messageKind = 'thinking';
const occurredAt = nowIso();
session.updatedAt = occurredAt;
await this.saveWorkspace(workspace);
this.emitSessionEvent({
sessionId,
kind: 'message-reclassified',
occurredAt,
messageId: event.messageId,
messageKind: 'thinking',
});
}
private async applyAgentActivity(
workspace: WorkspaceState,
sessionId: string,
requestId: string,
event: AgentActivityEvent,
): Promise<void> {
const occurredAt = nowIso();
const session = this.requireSession(workspace, sessionId);
const activityType = event.activityType;
let nextRun: SessionRunRecord | undefined;
if (activityType === 'thinking' || activityType === 'tool-calling' || activityType === 'handoff') {
nextRun = this.updateSessionRun(session, requestId, (run) =>
appendRunActivityEvent(run, {
activityType,
occurredAt,
agentId: event.agentId,
agentName: event.agentName,
sourceAgentId: event.sourceAgentId,
sourceAgentName: event.sourceAgentName,
toolName: event.toolName,
toolCallId: event.toolCallId,
toolArguments: event.toolArguments,
fileChanges: event.fileChanges,
}));
}
if (nextRun) {
session.updatedAt = occurredAt;
await this.saveWorkspace(workspace);
this.emitRunUpdated(sessionId, occurredAt, nextRun);
}
this.emitSessionEvent({
sessionId,
kind: 'agent-activity',
occurredAt,
activityType: event.activityType,
agentId: event.agentId,
agentName: event.agentName,
subworkflowNodeId: event.subworkflowNodeId,
subworkflowName: event.subworkflowName,
sourceAgentId: event.sourceAgentId,
sourceAgentName: event.sourceAgentName,
toolName: event.toolName,
toolCallId: event.toolCallId,
toolArguments: event.toolArguments,
fileChanges: event.fileChanges,
});
}
private emitCompletedActivity(
sessionId: string,
workflow: WorkflowDefinition,
message: ChatMessageRecord,
): void {
if (message.role !== 'assistant') {
return;
}
const agentNode = resolveWorkflowAgentNodes(workflow)
.find((candidate) =>
candidate.config.kind === 'agent'
&& (candidate.config.id === message.authorName || candidate.config.name === message.authorName))
;
const agent = agentNode?.config.kind === 'agent' ? agentNode.config : undefined;
if (!agent) {
return;
}
this.emitSessionEvent({
sessionId,
kind: 'agent-activity',
occurredAt: nowIso(),
activityType: 'completed',
agentId: agent.id,
agentName: agent.name,
});
}
private finalizeTurn(
workspace: WorkspaceState,
sessionId: string,
requestId: string,
messages: ChatMessageRecord[],
): void {
const session = this.requireSession(workspace, sessionId);
const workflow = this.resolveSessionWorkflow(workspace, session);
const incomingIds = new Set(messages.map((message) => message.id));
const existingIds = new Set(session.messages.map((message) => message.id));
const hasVisibleResponse = session.messages.some(
(message) => message.role === 'assistant' && message.messageKind !== 'thinking',
);
for (const message of messages) {
const occurredAt = nowIso();
const existing = session.messages.find((current) => current.id === message.id);
if (existing) {
existing.authorName = message.authorName;
existing.content = message.content;
existing.pending = false;
} else {
const isUnstreamedIntermediate =
message.role === 'assistant'
&& hasVisibleResponse
&& !message.messageKind;
session.messages.push({
...message,
pending: false,
messageKind: message.messageKind ?? (isUnstreamedIntermediate ? 'thinking' : undefined),
});
}
const reclassifiedAsThinking =
!existingIds.has(message.id)
&& (message.messageKind === 'thinking'
|| (message.role === 'assistant' && hasVisibleResponse && !message.messageKind));
const nextRun = this.updateSessionRun(session, requestId, (run) =>
upsertRunMessageEvent(run, {
messageId: message.id,
occurredAt,
authorName: message.authorName,
content: message.content,
status: 'completed',
}));
this.emitSessionEvent({
sessionId,
kind: 'message-complete',
occurredAt,
messageId: message.id,
authorName: message.authorName,
content: message.content,
});
if (reclassifiedAsThinking) {
this.emitSessionEvent({
sessionId,
kind: 'message-reclassified',
occurredAt,
messageId: message.id,
messageKind: 'thinking',
});
}
if (nextRun) {
this.emitRunUpdated(sessionId, occurredAt, nextRun);
}
this.emitCompletedActivity(sessionId, workflow, message);
}
for (const message of session.messages) {
if (message.pending && incomingIds.has(message.id)) {
message.pending = false;
}
}
const completedAt = nowIso();
session.status = 'idle';
session.lastError = undefined;
session.currentIntent = undefined;
session.pendingUserInput = undefined;
session.pendingPlanReview = undefined;
session.pendingMcpAuth = undefined;
session.updatedAt = completedAt;
const completedRun = this.updateSessionRun(session, requestId, (run) =>
completeSessionRunRecord(run, completedAt));
this.emitSessionEvent({
sessionId,
kind: 'status',
occurredAt: completedAt,
status: 'idle',
});
if (completedRun) {
this.emitRunUpdated(sessionId, completedAt, completedRun);
}
}
private finalizeCancelledTurn(
session: SessionRecord,
requestId: string,
): void {
for (const message of session.messages) {
if (message.pending) {
message.pending = false;
}
}
this.rejectPendingApprovals(session, nowIso(), 'The turn was cancelled.');
const cancelledAt = nowIso();
session.status = 'idle';
session.lastError = undefined;
session.currentIntent = undefined;
session.pendingUserInput = undefined;
session.pendingPlanReview = undefined;
session.pendingMcpAuth = undefined;
session.updatedAt = cancelledAt;
const cancelledRun = this.updateSessionRun(session, requestId, (run) =>
cancelSessionRunRecord(run, cancelledAt));
this.emitSessionEvent({
sessionId: session.id,
kind: 'status',
occurredAt: cancelledAt,
status: 'idle',
});
if (cancelledRun) {
this.emitRunUpdated(session.id, cancelledAt, cancelledRun);
}
}
private async awaitFinalResponseApproval(
workspace: WorkspaceState,
sessionId: string,
requestId: string,
workflow: WorkflowDefinition,
messages: ChatMessageRecord[],
): Promise<void> {
const pendingApproval = this.buildFinalResponseApproval(workflow, messages);
if (!pendingApproval) {
return;
}
let resolveDecision: ((decision: ApprovalDecision) => void) | undefined;
const decisionPromise = new Promise<ApprovalDecision>((resolve) => {
resolveDecision = resolve;
});
await this.handleApprovalRequested(
workspace,
sessionId,
requestId,
pendingApproval,
(decision) => {
resolveDecision?.(decision);
},
);
const decision = await decisionPromise;
if (decision === 'rejected') {
throw new Error('Final response approval was rejected.');
}
}
private buildFinalResponseApproval(
workflow: WorkflowDefinition,
messages: ChatMessageRecord[],
): PendingApprovalRecord | undefined {
const assistantMessages = messages.filter((message) => message.role === 'assistant');
if (assistantMessages.length === 0) {
return undefined;
}
const previewMessages: PendingApprovalMessageRecord[] = assistantMessages.map((message) => ({
id: message.id,
authorName: message.authorName,
content: message.content,
}));
for (let index = assistantMessages.length - 1; index >= 0; index -= 1) {
const message = assistantMessages[index];
if (!message) {
continue;
}
const agentNode = resolveWorkflowAgentNodes(workflow)
.find((candidate) =>
candidate.config.kind === 'agent'
&& (candidate.config.id === message.authorName || candidate.config.name === message.authorName))
;
const agent = agentNode?.config.kind === 'agent' ? agentNode.config : undefined;
if (!approvalPolicyRequiresCheckpoint(workflow.settings.approvalPolicy, 'final-response', agent?.id)) {
continue;
}
const agentName = agent?.name ?? message.authorName;
return {
id: `approval-${crypto.randomUUID()}`,
kind: 'final-response',
status: 'pending',
requestedAt: nowIso(),
agentId: agent?.id,
agentName,
title: agentName ? `Approve final response from ${agentName}` : 'Approve final response',
detail: 'Review the pending assistant response before it is added to the session transcript.',
messages: previewMessages,
};
}
return undefined;
}
}
+3 -5
View File
@@ -3,7 +3,7 @@ import { join } from 'node:path';
import type { WorkspaceState } from '@shared/domain/workspace';
const { app, Menu, Tray, nativeImage, BrowserWindow } = electron;
const { app, Menu, Tray, nativeImage } = electron;
type TrayType = InstanceType<typeof Tray>;
type NativeImageType = ReturnType<typeof nativeImage.createFromPath>;
@@ -123,10 +123,8 @@ export function setupCloseToTray(
/**
* Show and focus the main window, restoring from tray if hidden.
*/
export function showAndFocusWindow(): void {
const windows = BrowserWindow.getAllWindows();
const mainWindow = windows[0];
if (!mainWindow) return;
export function showAndFocusWindow(mainWindow: Electron.BrowserWindow): void {
if (mainWindow.isDestroyed()) return;
// On macOS, show the dock icon again
if (process.platform === 'darwin') {
+299
View File
@@ -0,0 +1,299 @@
import {
buildWorkflowExecutionDefinition,
normalizeWorkflowDefinition,
resolveWorkflowAgentNodes,
validateWorkflowDefinition,
type WorkflowDefinition,
type WorkflowReference,
} from '@shared/domain/workflow';
import {
exportWorkflowDefinition,
importWorkflowDefinition,
type WorkflowExportFormat,
type WorkflowExportResult,
} from '@shared/domain/workflowSerialization';
import {
applyWorkflowTemplate,
createWorkflowTemplateFromWorkflow,
normalizeWorkflowTemplateDefinition,
type WorkflowTemplateCategory,
type WorkflowTemplateDefinition,
} from '@shared/domain/workflowTemplate';
import { applyDefaultToolApprovalPolicy } from '@shared/domain/approval';
import type { SessionRecord } from '@shared/domain/session';
import type { WorkspaceState } from '@shared/domain/workspace';
import { createId, nowIso } from '@shared/utils/ids';
export class WorkflowManager {
saveWorkflow(workspace: WorkspaceState, workflow: WorkflowDefinition): WorkspaceState {
const normalizedWorkflow = normalizeWorkflowDefinition(workflow);
const issues = validateWorkflowDefinition(normalizedWorkflow).filter((issue) => issue.level === 'error');
if (issues.length > 0) {
throw new Error(issues[0].message);
}
const existingIndex = workspace.workflows.findIndex((current) => current.id === workflow.id);
const candidate: WorkflowDefinition = {
...normalizedWorkflow,
isFavorite: workflow.isFavorite ?? workspace.workflows[existingIndex]?.isFavorite,
createdAt: existingIndex >= 0 ? workspace.workflows[existingIndex].createdAt : nowIso(),
updatedAt: nowIso(),
};
this.validateWorkflowReferences(workspace, candidate);
if (existingIndex >= 0) {
workspace.workflows[existingIndex] = candidate;
} else {
workspace.workflows.push(candidate);
}
workspace.selectedWorkflowId = candidate.id;
return workspace;
}
saveWorkflowTemplate(
workspace: WorkspaceState,
workflowId: string,
options?: {
templateId?: string;
name?: string;
description?: string;
category?: WorkflowTemplateCategory;
},
): WorkspaceState {
const workflow = this.requireWorkflow(workspace, workflowId);
const candidate = createWorkflowTemplateFromWorkflow(workflow, options);
const existingIndex = workspace.workflowTemplates.findIndex((template) => template.id === candidate.id);
const existingTemplate = existingIndex >= 0 ? workspace.workflowTemplates[existingIndex] : undefined;
if (existingTemplate?.source === 'builtin') {
throw new Error(`Workflow template "${candidate.id}" is reserved by a built-in template.`);
}
const normalizedCandidate: WorkflowTemplateDefinition = normalizeWorkflowTemplateDefinition({
...candidate,
createdAt: existingTemplate?.createdAt ?? candidate.createdAt,
updatedAt: nowIso(),
});
if (existingIndex >= 0) {
workspace.workflowTemplates[existingIndex] = normalizedCandidate;
} else {
workspace.workflowTemplates.push(normalizedCandidate);
}
return workspace;
}
createWorkflowFromTemplate(
workspace: WorkspaceState,
templateId: string,
options?: {
workflowId?: string;
name?: string;
description?: string;
},
): WorkspaceState {
const template = this.requireWorkflowTemplate(workspace, templateId);
const workflowId = options?.workflowId?.trim()
|| this.createUniqueWorkflowId(workspace, template.workflow.id);
const workflow = applyWorkflowTemplate(template, {
...options,
workflowId,
});
return this.saveWorkflow(workspace, workflow);
}
deleteWorkflow(workspace: WorkspaceState, workflowId: string): WorkspaceState {
const workflow = this.requireWorkflow(workspace, workflowId);
const references = this.listWorkflowReferencesInWorkspace(workspace, workflowId)
.filter((reference) => reference.referencingWorkflowId !== workflowId);
if (references.length > 0) {
const blockingReference = references[0];
throw new Error(
`Workflow "${workflow.name}" cannot be deleted because workflow "${blockingReference.referencingWorkflowName}" references it from node "${blockingReference.nodeLabel}".`,
);
}
workspace.workflows = workspace.workflows.filter((candidate) => candidate.id !== workflowId);
if (workspace.selectedWorkflowId === workflowId) {
workspace.selectedWorkflowId = workspace.workflows[0]?.id;
}
return workspace;
}
listWorkflowReferences(workspace: WorkspaceState, workflowId: string): WorkflowReference[] {
this.requireWorkflow(workspace, workflowId);
return this.listWorkflowReferencesInWorkspace(workspace, workflowId);
}
exportWorkflow(workspace: WorkspaceState, workflowId: string, format: WorkflowExportFormat): WorkflowExportResult {
const workflow = this.requireWorkflow(workspace, workflowId);
return exportWorkflowDefinition(workflow, format);
}
importWorkflow(content: string, format: 'yaml' | 'json'): WorkflowDefinition {
return importWorkflowDefinition(content, format);
}
requireWorkflowTemplate(workspace: WorkspaceState, templateId: string): WorkflowTemplateDefinition {
const template = workspace.workflowTemplates.find((current) => current.id === templateId);
if (!template) {
throw new Error(`Workflow template "${templateId}" was not found.`);
}
return template;
}
requireWorkflow(workspace: WorkspaceState, workflowId: string): WorkflowDefinition {
const workflow = workspace.workflows.find((current) => current.id === workflowId);
if (!workflow) {
throw new Error(`Workflow "${workflowId}" was not found.`);
}
return workflow;
}
createUniqueWorkflowId(workspace: WorkspaceState, sourceId: string): string {
const normalizedSourceId = this.normalizeIdentifier(sourceId, 'workflow');
const existingIds = new Set(workspace.workflows.map((workflow) => workflow.id));
if (!existingIds.has(normalizedSourceId)) {
return normalizedSourceId;
}
let suffix = 2;
while (existingIds.has(`${normalizedSourceId}-${suffix}`)) {
suffix += 1;
}
return `${normalizedSourceId}-${suffix}`;
}
normalizeIdentifier(value: string, fallbackPrefix: string): string {
const normalized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return normalized || createId(fallbackPrefix);
}
resolveSessionWorkflow(workspace: WorkspaceState, session: SessionRecord): WorkflowDefinition {
return this.requireWorkflow(workspace, session.workflowId);
}
buildResolvedExecutionWorkflow(workspace: WorkspaceState, workflow: WorkflowDefinition): WorkflowDefinition {
return normalizeWorkflowDefinition({
...workflow,
settings: {
...workflow.settings,
approvalPolicy: applyDefaultToolApprovalPolicy(workflow.settings.approvalPolicy),
},
});
}
createWorkflowResolutionOptions(workspace: WorkspaceState) {
return {
resolveWorkflow: (workflowId: string) => workspace.workflows.find((candidate) => candidate.id === workflowId),
};
}
validateWorkflowReferences(workspace: WorkspaceState, workflow: WorkflowDefinition): void {
const workflowLibrary = new Map<string, WorkflowDefinition>();
for (const candidate of workspace.workflows) {
if (candidate.id !== workflow.id) {
workflowLibrary.set(candidate.id, candidate);
}
}
workflowLibrary.set(workflow.id, workflow);
const visitWorkflow = (
currentWorkflow: WorkflowDefinition,
path: string[],
visitedInlineWorkflows: Set<WorkflowDefinition>,
): void => {
for (const node of currentWorkflow.graph.nodes) {
if (node.kind !== 'sub-workflow' || node.config.kind !== 'sub-workflow') {
continue;
}
const { inlineWorkflow, workflowId } = node.config;
if (workflowId) {
const referencedWorkflow = workflowLibrary.get(workflowId);
if (!referencedWorkflow) {
throw new Error(
`Sub-workflow node "${node.label || node.id}" references unknown workflow "${workflowId}".`,
);
}
if (path.includes(workflowId)) {
throw new Error(
`Saving workflow "${workflow.name}" would create a circular sub-workflow reference: ${[...path, workflowId].join(' -> ')}.`,
);
}
visitWorkflow(referencedWorkflow, [...path, workflowId], visitedInlineWorkflows);
}
if (inlineWorkflow && !visitedInlineWorkflows.has(inlineWorkflow)) {
visitedInlineWorkflows.add(inlineWorkflow);
visitWorkflow(inlineWorkflow, path, visitedInlineWorkflows);
}
}
};
visitWorkflow(workflow, [workflow.id], new Set<WorkflowDefinition>());
}
listWorkflowReferencesInWorkspace(workspace: WorkspaceState, workflowId: string): WorkflowReference[] {
const references: WorkflowReference[] = [];
const visitWorkflow = (
referencingWorkflow: WorkflowDefinition,
currentWorkflow: WorkflowDefinition,
visitedInlineWorkflows: Set<WorkflowDefinition>,
): void => {
for (const node of currentWorkflow.graph.nodes) {
if (node.kind !== 'sub-workflow' || node.config.kind !== 'sub-workflow') {
continue;
}
const { inlineWorkflow, workflowId: referencedWorkflowId } = node.config;
if (referencedWorkflowId === workflowId) {
references.push({
referencingWorkflowId: referencingWorkflow.id,
referencingWorkflowName: referencingWorkflow.name,
nodeId: node.id,
nodeLabel: node.label || node.id,
});
}
if (inlineWorkflow && !visitedInlineWorkflows.has(inlineWorkflow)) {
visitedInlineWorkflows.add(inlineWorkflow);
visitWorkflow(referencingWorkflow, inlineWorkflow, visitedInlineWorkflows);
}
}
};
for (const referencingWorkflow of workspace.workflows) {
visitWorkflow(referencingWorkflow, referencingWorkflow, new Set<WorkflowDefinition>());
}
return references;
}
buildWorkflowExecutionDefinition(workspace: WorkspaceState, workflow: WorkflowDefinition) {
return buildWorkflowExecutionDefinition(
this.buildResolvedExecutionWorkflow(workspace, workflow),
this.createWorkflowResolutionOptions(workspace),
);
}
resolveWorkflowAgentNodes(workspace: WorkspaceState, workflow: WorkflowDefinition) {
void workspace;
return resolveWorkflowAgentNodes(this.buildResolvedExecutionWorkflow(workspace, workflow));
}
}
+10 -1
View File
@@ -1,6 +1,11 @@
import type { OpenTelemetrySettings } from '@shared/domain/tooling';
const blockedEnvironmentPrefixes = ['BUN_', 'COPILOT_', 'ELECTRON_', 'NODE_', 'NPM_'];
export function createSidecarEnvironment(baseEnvironment: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
export function createSidecarEnvironment(
baseEnvironment: NodeJS.ProcessEnv,
openTelemetry?: OpenTelemetrySettings,
): NodeJS.ProcessEnv {
const sanitizedEnvironment: NodeJS.ProcessEnv = {};
for (const [name, value] of Object.entries(baseEnvironment)) {
@@ -13,5 +18,9 @@ export function createSidecarEnvironment(baseEnvironment: NodeJS.ProcessEnv): No
sanitizedEnvironment[name] = value;
}
if (openTelemetry?.enabled && openTelemetry.endpoint) {
sanitizedEnvironment.OTEL_EXPORTER_OTLP_ENDPOINT = openTelemetry.endpoint;
}
return sanitizedEnvironment;
}
+7 -1
View File
@@ -21,6 +21,7 @@ import type {
} from '@shared/contracts/sidecar';
import type { ApprovalDecision } from '@shared/domain/approval';
import type { ChatMessageRecord } from '@shared/domain/session';
import type { OpenTelemetrySettings } from '@shared/domain/tooling';
import { createSidecarEnvironment } from '@main/sidecar/sidecarEnvironment';
import {
markRunTurnPendingErrored,
@@ -109,6 +110,11 @@ export class SidecarClient {
private processState?: ManagedSidecarProcess;
private nextProcessId = 0;
private readonly pending = new Map<string, PendingCommand>();
private openTelemetrySettings?: OpenTelemetrySettings;
setOpenTelemetrySettings(settings?: OpenTelemetrySettings): void {
this.openTelemetrySettings = settings;
}
async describeCapabilities(): Promise<SidecarCapabilities> {
const command = await this.dispatch<SidecarCapabilities>({
@@ -240,7 +246,7 @@ export class SidecarClient {
});
const childProcess = spawn(sidecar.command, sidecar.args, {
cwd: sidecar.cwd,
env: createSidecarEnvironment(process.env),
env: createSidecarEnvironment(process.env, this.openTelemetrySettings),
stdio: 'pipe',
windowsHide: true,
});
+5
View File
@@ -14,6 +14,7 @@ export function createMainWindow(): BrowserWindowType {
height: 960,
minWidth: 1120,
minHeight: 720,
show: false,
title: 'aryx',
icon: resolveWindowIconPath({
appPath: app.getAppPath(),
@@ -36,6 +37,10 @@ export function createMainWindow(): BrowserWindowType {
},
});
window.once('ready-to-show', () => {
window.show();
});
const rendererUrl = process.env.ELECTRON_RENDERER_URL;
if (rendererUrl) {
+105
View File
@@ -0,0 +1,105 @@
import electron from 'electron';
import type { BrowserWindow as BrowserWindowType } from 'electron';
import { join } from 'node:path';
import { resolveWindowIconPath } from '@main/windows/appIcon';
const { app, BrowserWindow, screen } = electron;
export function createQuickPromptWindow(): BrowserWindowType {
const window = new BrowserWindow({
width: 680,
height: 520,
minHeight: 72,
show: false,
frame: false,
transparent: true,
resizable: false,
skipTaskbar: true,
alwaysOnTop: true,
maximizable: false,
minimizable: false,
fullscreenable: false,
focusable: true,
title: 'Aryx Quick Prompt',
icon: resolveWindowIconPath({
appPath: app.getAppPath(),
platform: process.platform,
}),
webPreferences: {
preload: join(__dirname, '../preload/quickprompt.js'),
contextIsolation: true,
nodeIntegration: false,
},
});
const rendererUrl = process.env.ELECTRON_RENDERER_URL;
if (rendererUrl) {
void window.loadURL(`${rendererUrl}/quickprompt.html`);
} else {
void window.loadFile(join(__dirname, '../../dist/renderer/quickprompt.html'));
}
// Hide on blur — but only if the window itself loses focus (not from
// devtools or transient focus changes during show/hide).
window.on('blur', () => {
// Longer delay: avoid hiding during transient focus shifts when
// interacting with model selector dropdown or other UI elements.
setTimeout(() => {
if (window.isVisible() && !window.isFocused()) {
window.webContents.send('quick-prompt:hide');
window.hide();
}
}, 200);
});
return window;
}
export async function toggleQuickPromptWindow(window: BrowserWindowType, theme?: string): Promise<void> {
if (window.isVisible()) {
window.webContents.send('quick-prompt:hide');
window.hide();
return;
}
// Wait for the renderer to finish loading before showing — on the very
// first activation the page may still be loading from disk/dev-server.
if (window.webContents.isLoading()) {
await new Promise<void>((resolve) => {
window.webContents.once('did-finish-load', () => resolve());
});
}
centerOnActiveDisplay(window);
window.webContents.send('quick-prompt:show', theme ?? 'dark');
window.show();
window.focus();
}
export function showQuickPromptWindow(window: BrowserWindowType, theme?: string): void {
centerOnActiveDisplay(window);
window.webContents.send('quick-prompt:show', theme ?? 'dark');
window.show();
window.focus();
}
export function hideQuickPromptWindow(window: BrowserWindowType): void {
if (!window.isVisible()) return;
window.webContents.send('quick-prompt:hide');
window.hide();
}
function centerOnActiveDisplay(window: BrowserWindowType): void {
const cursorPoint = screen.getCursorScreenPoint();
const activeDisplay = screen.getDisplayNearestPoint(cursorPoint);
const { x, y, width, height } = activeDisplay.workArea;
const [windowWidth] = window.getSize();
const windowX = Math.round(x + (width - windowWidth) / 2);
// Position in the upper-third of the screen for command-bar feel
const windowY = Math.round(y + height * 0.25);
window.setPosition(windowX, windowY);
}
+5
View File
@@ -36,6 +36,9 @@ const api: ElectronApi = {
setNotificationsEnabled: (enabled) => ipcRenderer.invoke(ipcChannels.setNotificationsEnabled, enabled),
setMinimizeToTray: (enabled) => ipcRenderer.invoke(ipcChannels.setMinimizeToTray, enabled),
setGitAutoRefreshEnabled: (enabled) => ipcRenderer.invoke(ipcChannels.setGitAutoRefreshEnabled, enabled),
setOpenTelemetry: (settings) => ipcRenderer.invoke(ipcChannels.setOpenTelemetry, settings),
getQuickPromptSettings: () => ipcRenderer.invoke(ipcChannels.quickPromptGetSettings),
setQuickPromptSettings: (settings) => ipcRenderer.invoke(ipcChannels.quickPromptSetSettings, settings),
checkForUpdates: () => ipcRenderer.invoke(ipcChannels.checkForUpdates),
installUpdate: () => ipcRenderer.invoke(ipcChannels.installUpdate),
saveMcpServer: (input) => ipcRenderer.invoke(ipcChannels.saveMcpServer, input),
@@ -65,6 +68,8 @@ const api: ElectronApi = {
setSessionPinned: (input) => ipcRenderer.invoke(ipcChannels.setSessionPinned, input),
setSessionArchived: (input) => ipcRenderer.invoke(ipcChannels.setSessionArchived, input),
deleteSession: (input) => ipcRenderer.invoke(ipcChannels.deleteSession, input),
batchSetSessionsArchived: (input) => ipcRenderer.invoke(ipcChannels.batchSetSessionsArchived, input),
batchDeleteSessions: (input) => ipcRenderer.invoke(ipcChannels.batchDeleteSessions, input),
regenerateSessionMessage: (input) => ipcRenderer.invoke(ipcChannels.regenerateSessionMessage, input),
editAndResendSessionMessage: (input) => ipcRenderer.invoke(ipcChannels.editAndResendSessionMessage, input),
sendSessionMessage: (input) => ipcRenderer.invoke(ipcChannels.sendSessionMessage, input),
+50
View File
@@ -0,0 +1,50 @@
import electron from 'electron';
import type { QuickPromptElectronApi } from '@shared/contracts/ipc';
const { contextBridge, ipcRenderer } = electron;
// Channel constants are inlined here (not imported from @shared/contracts/channels)
// to avoid Rollup code-splitting a shared chunk that Electron's sandboxed preload
// environment cannot resolve at runtime.
const ch = {
send: 'quick-prompt:send',
discard: 'quick-prompt:discard',
close: 'quick-prompt:close',
continueInAryx: 'quick-prompt:continue-in-aryx',
cancelTurn: 'quick-prompt:cancel-turn',
getCapabilities: 'quick-prompt:get-capabilities',
setSettings: 'quick-prompt:set-settings',
sessionEvent: 'quick-prompt:session-event',
show: 'quick-prompt:show',
hide: 'quick-prompt:hide',
} as const;
const api: QuickPromptElectronApi = {
send: (input) => ipcRenderer.invoke(ch.send, input),
discard: () => ipcRenderer.invoke(ch.discard),
close: () => ipcRenderer.invoke(ch.close),
continueInAryx: () => ipcRenderer.invoke(ch.continueInAryx),
cancelTurn: () => ipcRenderer.invoke(ch.cancelTurn),
getCapabilities: () => ipcRenderer.invoke(ch.getCapabilities),
setSettings: (settings) => ipcRenderer.invoke(ch.setSettings, settings),
onSessionEvent: (listener) => {
const handler = (_event: Electron.IpcRendererEvent, sessionEvent: Parameters<typeof listener>[0]) =>
listener(sessionEvent);
ipcRenderer.on(ch.sessionEvent, handler);
return () => ipcRenderer.off(ch.sessionEvent, handler);
},
onShow: (listener) => {
const handler = (_event: Electron.IpcRendererEvent, theme: string) => listener(theme);
ipcRenderer.on(ch.show, handler);
return () => ipcRenderer.off(ch.show, handler);
},
onHide: (listener) => {
const handler = () => listener();
ipcRenderer.on(ch.hide, handler);
return () => ipcRenderer.off(ch.hide, handler);
},
};
contextBridge.exposeInMainWorld('quickPromptApi', api);
+211 -146
View File
@@ -1,20 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { CommitComposer } from '@renderer/components/chat/CommitComposer';
import { AppShell } from '@renderer/components/AppShell';
import { ActivityPanel } from '@renderer/components/ActivityPanel';
import { ChatPane } from '@renderer/components/ChatPane';
import { CommandPalette } from '@renderer/components/CommandPalette';
import { DiscoveredToolingModal } from '@renderer/components/DiscoveredToolingModal';
import { KeyboardShortcutsPanel } from '@renderer/components/KeyboardShortcutsPanel';
import { ProjectSettingsPanel } from '@renderer/components/ProjectSettingsPanel';
import { BookmarksPanel } from '@renderer/components/BookmarksPanel';
import { SessionSearchPanel } from '@renderer/components/SessionSearchPanel';
import { SettingsPanel, type SettingsSection } from '@renderer/components/SettingsPanel';
import { Sidebar } from '@renderer/components/Sidebar';
import { BottomPanel, DEFAULT_HEIGHT as DEFAULT_BOTTOM_HEIGHT, MIN_HEIGHT as MIN_BOTTOM_HEIGHT, type BottomPanelTab } from '@renderer/components/BottomPanel';
import { GitPanel } from '@renderer/components/GitPanel';
import { TerminalPanel } from '@renderer/components/TerminalPanel';
import { resolveChatToolingSettings } from '@renderer/lib/chatTooling';
import {
applySessionEventActivity,
@@ -33,7 +20,6 @@ import {
} from '@renderer/lib/sessionActivity';
import { applySubagentEvent, pruneSubagentMap, type ActiveSubagentMap } from '@renderer/lib/subagentTracker';
import { applySessionEventWorkspace } from '@renderer/lib/sessionWorkspace';
import { WelcomePane } from '@renderer/components/WelcomePane';
import { getElectronApi } from '@renderer/lib/electronApi';
import { useTheme, useSidecarCapabilities } from '@renderer/hooks/useAppHooks';
import {
@@ -48,11 +34,38 @@ import { isScratchpadProject, SCRATCHPAD_PROJECT_ID } from '@shared/domain/proje
import type { ProjectGitFileReference } from '@shared/domain/project';
import { applySessionModelConfig } from '@shared/domain/session';
import type { AppearanceTheme, LspProfileDefinition, McpServerDefinition } from '@shared/domain/tooling';
import { createDefaultQuickPromptSettings, type QuickPromptSettings } from '@shared/domain/tooling';
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
import type { WorkspaceState } from '@shared/domain/workspace';
import type { UpdateStatus } from '@shared/contracts/ipc';
import { createId, nowIso } from '@shared/utils/ids';
import { WorkflowPicker } from '@renderer/components/workflow/WorkflowPicker';
// Lazy-loaded components — kept off the critical startup bundle.
// These pull in heavy dependencies (Lexical, @xyflow/react, @xterm/xterm, motion, etc.)
// that are not needed until the user interacts with the corresponding feature.
const ActivityPanel = lazy(() => import('@renderer/components/ActivityPanel').then((m) => ({ default: m.ActivityPanel })));
const BookmarksPanel = lazy(() => import('@renderer/components/BookmarksPanel').then((m) => ({ default: m.BookmarksPanel })));
const BottomPanel = lazy(() => import('@renderer/components/BottomPanel').then((m) => ({ default: m.BottomPanel })));
const ChatPane = lazy(() => import('@renderer/components/ChatPane').then((m) => ({ default: m.ChatPane })));
const CommandPalette = lazy(() => import('@renderer/components/CommandPalette').then((m) => ({ default: m.CommandPalette })));
const CommitComposer = lazy(() => import('@renderer/components/chat/CommitComposer').then((m) => ({ default: m.CommitComposer })));
const DiscoveredToolingModal = lazy(() => import('@renderer/components/DiscoveredToolingModal').then((m) => ({ default: m.DiscoveredToolingModal })));
const GitPanel = lazy(() => import('@renderer/components/GitPanel').then((m) => ({ default: m.GitPanel })));
const KeyboardShortcutsPanel = lazy(() => import('@renderer/components/KeyboardShortcutsPanel').then((m) => ({ default: m.KeyboardShortcutsPanel })));
const ProjectSettingsPanel = lazy(() => import('@renderer/components/ProjectSettingsPanel').then((m) => ({ default: m.ProjectSettingsPanel })));
const SessionSearchPanel = lazy(() => import('@renderer/components/SessionSearchPanel').then((m) => ({ default: m.SessionSearchPanel })));
const SettingsPanel = lazy(() => import('@renderer/components/SettingsPanel').then((m) => ({ default: m.SettingsPanel })));
const TerminalPanel = lazy(() => import('@renderer/components/TerminalPanel').then((m) => ({ default: m.TerminalPanel })));
const WelcomePane = lazy(() => import('@renderer/components/WelcomePane').then((m) => ({ default: m.WelcomePane })));
const WorkflowPicker = lazy(() => import('@renderer/components/workflow/WorkflowPicker').then((m) => ({ default: m.WorkflowPicker })));
// Re-export type-only imports from lazy modules so they're available without pulling in the bundle
type SettingsSection = 'appearance' | 'connection' | 'workflows' | 'agents' | 'mcp-servers' | 'lsp-profiles' | 'quick-prompt' | 'troubleshooting';
type BottomPanelTab = 'terminal' | 'git';
// Constants duplicated from BottomPanel to avoid importing the full module at startup
const DEFAULT_BOTTOM_HEIGHT = 280;
const MIN_BOTTOM_HEIGHT = 120;
function createDraftMcpServer(): McpServerDefinition {
const timestamp = nowIso();
@@ -168,6 +181,9 @@ export default function App() {
// Commit composer state
const [commitComposerCtx, setCommitComposerCtx] = useState<{ projectId: string; sessionId: string; runId?: string }>();
// Quick prompt settings
const [quickPromptSettings, setQuickPromptSettings] = useState<QuickPromptSettings>(createDefaultQuickPromptSettings);
// Bottom panel state (terminal + git)
const [bottomPanelOpen, setBottomPanelOpen] = useState(false);
const [bottomPanelTab, setBottomPanelTab] = useState<BottomPanelTab>('terminal');
@@ -186,6 +202,11 @@ export default function App() {
.then((ws) => !disposed && setWorkspace(ws))
.catch((e) => !disposed && setError(e instanceof Error ? e.message : String(e)));
void api
.getQuickPromptSettings()
.then((s) => !disposed && setQuickPromptSettings(s))
.catch(() => { /* quick prompt settings unavailable, use defaults */ });
const offWorkspace = api.onWorkspaceUpdated((ws) => {
setWorkspace(ws);
setError(undefined);
@@ -643,6 +664,9 @@ export default function App() {
);
}
// Suspense fallback for lazy-loaded panels — intentionally blank to avoid layout flash
const lazyFallback = null;
// Determine main content
let content: React.ReactNode;
let detailPanel: React.ReactNode | undefined;
@@ -657,6 +681,7 @@ export default function App() {
);
} else if (selectedSession && workflowForSession && projectForSession) {
content = (
<Suspense fallback={lazyFallback}>
<ChatPane
onSend={(c, attachments, messageMode, promptInvocation) => api.sendSessionMessage({
sessionId: selectedSession.id,
@@ -740,30 +765,37 @@ export default function App() {
onDiscardRunChanges={handleDiscardRunChanges}
onOpenCommitComposer={handleOpenCommitComposer}
/>
</Suspense>
);
detailPanel = (
<ActivityPanel
activity={activityForSession}
workflow={workflowForSession}
session={selectedSession}
sessionRequestUsage={requestUsageForSession}
turnEvents={turnEventsForSession}
/>
<Suspense fallback={lazyFallback}>
<ActivityPanel
activity={activityForSession}
workflow={workflowForSession}
workflows={workspace?.workflows}
session={selectedSession}
sessionRequestUsage={requestUsageForSession}
turnEvents={turnEventsForSession}
/>
</Suspense>
);
} else {
content = (
<WelcomePane
hasProjects={hasUserProjects}
connectionStatus={sidecarCapabilities?.connection.status}
onAddProject={() => void api.addProject()}
onNewScratchpad={() => handleCreateScratchpad()}
onOpenSettings={() => setShowSettings(true)}
/>
<Suspense fallback={lazyFallback}>
<WelcomePane
hasProjects={hasUserProjects}
connectionStatus={sidecarCapabilities?.connection.status}
onAddProject={() => void api.addProject()}
onNewScratchpad={() => handleCreateScratchpad()}
onOpenSettings={() => setShowSettings(true)}
/>
</Suspense>
);
}
// Settings overlay
const overlay = showSettings ? (
<Suspense fallback={lazyFallback}>
<SettingsPanel
availableModels={availableModels}
initialSection={settingsSection}
@@ -835,7 +867,16 @@ export default function App() {
void api.resolveWorkspaceDiscoveredTooling({ serverIds, resolution });
}}
onGetQuota={() => api.getQuota()}
quickPromptSettings={quickPromptSettings}
onSetQuickPromptSettings={(patch) => {
const updated = { ...quickPromptSettings, ...patch };
setQuickPromptSettings(updated);
void api.setQuickPromptSettings(patch);
}}
openTelemetry={workspace.settings.openTelemetry}
onSetOpenTelemetry={(settings) => void api.setOpenTelemetry(settings)}
/>
</Suspense>
) : null;
return (
@@ -846,29 +887,31 @@ export default function App() {
overlay={overlay}
bottomPanel={
bottomPanelOpen ? (
<BottomPanel
activeTab={bottomPanelTab}
gitContent={
selectedSession && !isScratchpadProject(selectedSession.projectId) ? (
<GitPanel
onDirtyChange={setGitDirty}
projectId={selectedSession.projectId}
/>
) : (
<div className="flex items-center justify-center py-8 text-[11px] text-[var(--color-text-muted)]">
Git is not available for scratchpad sessions
</div>
)
}
gitDirty={gitDirty}
height={bottomPanelHeight}
onClose={handleBottomPanelClose}
onHeightChange={handleBottomPanelHeightChange}
onTabChange={setBottomPanelTab}
showGitTab={!!selectedSession && !isScratchpadProject(selectedSession.projectId)}
terminalContent={<TerminalPanel onRunningChange={setTerminalRunning} />}
terminalRunning={terminalRunning}
/>
<Suspense fallback={lazyFallback}>
<BottomPanel
activeTab={bottomPanelTab}
gitContent={
selectedSession && !isScratchpadProject(selectedSession.projectId) ? (
<GitPanel
onDirtyChange={setGitDirty}
projectId={selectedSession.projectId}
/>
) : (
<div className="flex items-center justify-center py-8 text-[11px] text-[var(--color-text-muted)]">
Git is not available for scratchpad sessions
</div>
)
}
gitDirty={gitDirty}
height={bottomPanelHeight}
onClose={handleBottomPanelClose}
onHeightChange={handleBottomPanelHeightChange}
onTabChange={setBottomPanelTab}
showGitTab={!!selectedSession && !isScratchpadProject(selectedSession.projectId)}
terminalContent={<TerminalPanel onRunningChange={setTerminalRunning} />}
terminalRunning={terminalRunning}
/>
</Suspense>
) : undefined
}
sidebar={
@@ -899,6 +942,12 @@ export default function App() {
onDeleteSession={(sessionId) => {
void api.deleteSession({ sessionId });
}}
onBatchArchiveSessions={(sessionIds, isArchived) => {
void api.batchSetSessionsArchived({ sessionIds, isArchived });
}}
onBatchDeleteSessions={(sessionIds) => {
void api.batchDeleteSessions({ sessionIds });
}}
onRefreshGitContext={(projectId) => {
void api.refreshProjectGitContext(projectId);
}}
@@ -911,120 +960,136 @@ export default function App() {
/>
{showDiscoveryModal && (
<DiscoveredToolingModal
onClose={() => setShowDiscoveryModal(false)}
onResolveProjectServers={(serverIds, resolution) => {
if (selectedProject) {
void api.resolveProjectDiscoveredTooling({ projectId: selectedProject.id, serverIds, resolution });
}
}}
onResolveUserServers={(serverIds, resolution) => {
void api.resolveWorkspaceDiscoveredTooling({ serverIds, resolution });
}}
projectDiscoveredTooling={selectedProject?.discoveredTooling}
projectName={selectedProject?.name}
userDiscoveredTooling={workspace.settings.discoveredUserTooling}
/>
<Suspense fallback={lazyFallback}>
<DiscoveredToolingModal
onClose={() => setShowDiscoveryModal(false)}
onResolveProjectServers={(serverIds, resolution) => {
if (selectedProject) {
void api.resolveProjectDiscoveredTooling({ projectId: selectedProject.id, serverIds, resolution });
}
}}
onResolveUserServers={(serverIds, resolution) => {
void api.resolveWorkspaceDiscoveredTooling({ serverIds, resolution });
}}
projectDiscoveredTooling={selectedProject?.discoveredTooling}
projectName={selectedProject?.name}
userDiscoveredTooling={workspace.settings.discoveredUserTooling}
/>
</Suspense>
)}
{projectForSettings && (
<ProjectSettingsPanel
project={projectForSettings}
onClose={() => setProjectSettingsId(undefined)}
onRescanConfigs={() => {
void api.rescanProjectConfigs({ projectId: projectForSettings.id });
}}
onRescanCustomization={() => {
void api.rescanProjectCustomization({ projectId: projectForSettings.id });
}}
onResolveDiscoveredTooling={(serverIds, resolution) => {
void api.resolveProjectDiscoveredTooling({ projectId: projectForSettings.id, serverIds, resolution });
}}
onSetAgentProfileEnabled={(agentProfileId, enabled) => {
void api.setProjectAgentProfileEnabled({ projectId: projectForSettings.id, agentProfileId, enabled });
}}
onRemoveProject={() => {
void api.removeProject(projectForSettings.id);
setProjectSettingsId(undefined);
}}
/>
<Suspense fallback={lazyFallback}>
<ProjectSettingsPanel
project={projectForSettings}
onClose={() => setProjectSettingsId(undefined)}
onRescanConfigs={() => {
void api.rescanProjectConfigs({ projectId: projectForSettings.id });
}}
onRescanCustomization={() => {
void api.rescanProjectCustomization({ projectId: projectForSettings.id });
}}
onResolveDiscoveredTooling={(serverIds, resolution) => {
void api.resolveProjectDiscoveredTooling({ projectId: projectForSettings.id, serverIds, resolution });
}}
onSetAgentProfileEnabled={(agentProfileId, enabled) => {
void api.setProjectAgentProfileEnabled({ projectId: projectForSettings.id, agentProfileId, enabled });
}}
onRemoveProject={() => {
void api.removeProject(projectForSettings.id);
setProjectSettingsId(undefined);
}}
/>
</Suspense>
)}
{commandPaletteOpen && workspace && (
<CommandPalette
workspace={workspace}
onClose={() => setCommandPaletteOpen(false)}
onSelectSession={(sessionId) => {
void api.selectSession(sessionId);
}}
onSelectProject={(projectId) => {
void api.selectProject(projectId);
}}
onNewSession={(projectId) => handleNewSession(projectId)}
onCreateScratchpad={handleCreateScratchpad}
onOpenSettings={() => setShowSettings(true)}
onOpenProjectSettings={(projectId) => setProjectSettingsId(projectId)}
onToggleTerminal={handleTerminalToggle}
onSetTheme={(theme) => void api.setTheme(theme)}
onDuplicateSession={(sessionId) => {
void api.duplicateSession({ sessionId });
}}
onPinSession={(sessionId, isPinned) => {
void api.setSessionPinned({ sessionId, isPinned });
}}
onArchiveSession={(sessionId, isArchived) => {
void api.setSessionArchived({ sessionId, isArchived });
}}
onAddProject={() => void api.addProject()}
onOpenAppDataFolder={() => void api.openAppDataFolder()}
onShowShortcuts={() => setShowShortcuts(true)}
onShowSearch={() => setShowSearch(true)}
onShowBookmarks={() => setShowBookmarks(true)}
/>
<Suspense fallback={lazyFallback}>
<CommandPalette
workspace={workspace}
onClose={() => setCommandPaletteOpen(false)}
onSelectSession={(sessionId) => {
void api.selectSession(sessionId);
}}
onSelectProject={(projectId) => {
void api.selectProject(projectId);
}}
onNewSession={(projectId) => handleNewSession(projectId)}
onCreateScratchpad={handleCreateScratchpad}
onOpenSettings={() => setShowSettings(true)}
onOpenProjectSettings={(projectId) => setProjectSettingsId(projectId)}
onToggleTerminal={handleTerminalToggle}
onSetTheme={(theme) => void api.setTheme(theme)}
onDuplicateSession={(sessionId) => {
void api.duplicateSession({ sessionId });
}}
onPinSession={(sessionId, isPinned) => {
void api.setSessionPinned({ sessionId, isPinned });
}}
onArchiveSession={(sessionId, isArchived) => {
void api.setSessionArchived({ sessionId, isArchived });
}}
onAddProject={() => void api.addProject()}
onOpenAppDataFolder={() => void api.openAppDataFolder()}
onShowShortcuts={() => setShowShortcuts(true)}
onShowSearch={() => setShowSearch(true)}
onShowBookmarks={() => setShowBookmarks(true)}
/>
</Suspense>
)}
{showShortcuts && (
<KeyboardShortcutsPanel onClose={() => setShowShortcuts(false)} />
<Suspense fallback={lazyFallback}>
<KeyboardShortcutsPanel onClose={() => setShowShortcuts(false)} />
</Suspense>
)}
{showSearch && workspace && (
<SessionSearchPanel
workspace={workspace}
onClose={() => setShowSearch(false)}
onSelectSession={(sessionId) => {
void api.selectSession(sessionId);
}}
/>
<Suspense fallback={lazyFallback}>
<SessionSearchPanel
workspace={workspace}
onClose={() => setShowSearch(false)}
onSelectSession={(sessionId) => {
void api.selectSession(sessionId);
}}
/>
</Suspense>
)}
{showBookmarks && workspace && (
<BookmarksPanel
workspace={workspace}
onClose={() => setShowBookmarks(false)}
onSelectSession={(sessionId) => {
void api.selectSession(sessionId);
}}
onUnpinMessage={(sessionId, messageId) => {
void api.setSessionMessagePinned({ sessionId, messageId, isPinned: false });
}}
/>
<Suspense fallback={lazyFallback}>
<BookmarksPanel
workspace={workspace}
onClose={() => setShowBookmarks(false)}
onSelectSession={(sessionId) => {
void api.selectSession(sessionId);
}}
onUnpinMessage={(sessionId, messageId) => {
void api.setSessionMessagePinned({ sessionId, messageId, isPinned: false });
}}
/>
</Suspense>
)}
{commitComposerCtx && (
<CommitComposer
onClose={() => setCommitComposerCtx(undefined)}
projectId={commitComposerCtx.projectId}
runId={commitComposerCtx.runId}
sessionId={commitComposerCtx.sessionId}
/>
<Suspense fallback={lazyFallback}>
<CommitComposer
onClose={() => setCommitComposerCtx(undefined)}
projectId={commitComposerCtx.projectId}
runId={commitComposerCtx.runId}
sessionId={commitComposerCtx.sessionId}
/>
</Suspense>
)}
{workflowPickerProjectId && workspace && (
<WorkflowPicker
workflows={workspace.workflows}
onSelect={handleWorkflowPicked}
onClose={() => setWorkflowPickerProjectId(null)}
/>
<Suspense fallback={lazyFallback}>
<WorkflowPicker
workflows={workspace.workflows}
onSelect={handleWorkflowPicked}
onClose={() => setWorkflowPickerProjectId(null)}
/>
</Suspense>
)}
</>
);
+57 -171
View File
@@ -1,59 +1,21 @@
import { useMemo, type ReactNode } from 'react';
import { Activity, AlertTriangle, ArrowRight, BarChart3, CheckCircle2, Cog, ShieldAlert, Sparkles, Users, Zap } from 'lucide-react';
import { Activity, AlertTriangle, ArrowRight, BarChart3, CheckCircle2, Cog, GitBranch, ShieldAlert, Sparkles, Users, Zap } from 'lucide-react';
import {
buildAgentActivityRows,
formatAgentActivityLabel,
buildGroupedActivityRows,
formatDuration,
formatNanoAiu,
formatTokenCount,
isAgentActivityActive,
isAgentActivityCompleted,
type AgentActivityRow,
type AgentUsageAccumulator,
type SessionActivityState,
type SessionRequestUsageState,
type TurnEventLog,
} from '@renderer/lib/sessionActivity';
import { inferProvider } from '@shared/domain/models';
import { resolveWorkflowAgentNodes, type AgentNodeConfig, type WorkflowDefinition, type WorkflowOrchestrationMode } from '@shared/domain/workflow';
import { resolveWorkflowAgentHierarchy, type AgentNodeConfig, type WorkflowDefinition } from '@shared/domain/workflow';
import type { SessionRecord } from '@shared/domain/session';
import { ProviderIcon } from './ProviderIcons';
/* ── Mode accent colours ───────────────────────────────────── */
const modeAccent: Record<WorkflowOrchestrationMode, { dot: string; bar: string; label: string }> = {
single: { dot: 'bg-[#245CF9]', bar: 'bg-[#245CF9] opacity-60', label: 'text-[#245CF9]' },
sequential: { dot: 'bg-[var(--color-status-warning)]', bar: 'bg-[var(--color-status-warning)] opacity-60', label: 'text-[var(--color-status-warning)]' },
concurrent: { dot: 'bg-[var(--color-status-success)]', bar: 'bg-[var(--color-status-success)] opacity-60', label: 'text-[var(--color-status-success)]' },
handoff: { dot: 'bg-[var(--color-accent-sky)]', bar: 'bg-[var(--color-accent-sky)] opacity-60', label: 'text-[var(--color-accent-sky)]' },
'group-chat': { dot: 'bg-[var(--color-accent-purple)]', bar: 'bg-[var(--color-accent-purple)] opacity-60', label: 'text-[var(--color-accent-purple)]' },
};
/* ── Helpers ───────────────────────────────────────────────── */
function formatModel(model: string): string {
return model.replace(/-/g, '\u2011');
}
function formatEffort(effort: string | undefined): string | undefined {
if (!effort) return undefined;
const labels: Record<string, string> = {
low: 'Low',
medium: 'Medium',
high: 'High',
xhigh: 'Max',
};
return labels[effort] ?? effort;
}
const modeLabels: Record<WorkflowOrchestrationMode, string> = {
single: 'Single agent',
sequential: 'Sequential',
concurrent: 'Concurrent',
handoff: 'Handoff',
'group-chat': 'Group chat',
};
import { AgentRow } from './activity/AgentRow';
import { SubWorkflowGroup } from './activity/SubWorkflowGroup';
import { modeAccent, modeLabels } from './activity/constants';
/* ── Section header ────────────────────────────────────────── */
@@ -65,112 +27,6 @@ function SectionHeader({ children }: { children: ReactNode }) {
);
}
/* ── Agent row ─────────────────────────────────────────────── */
function AgentRow({
row,
agent,
accent,
isLast,
agentUsage,
}: {
row: AgentActivityRow;
agent?: AgentNodeConfig;
accent: (typeof modeAccent)[WorkflowOrchestrationMode];
isLast: boolean;
agentUsage?: AgentUsageAccumulator;
}) {
const isActive = isAgentActivityActive(row.activity);
const isCompleted = isAgentActivityCompleted(row.activity);
return (
<div className={`relative flex gap-2.5 py-2.5 ${isLast ? '' : 'border-b border-[var(--color-border-subtle)]'}`}>
{/* Left accent bar — visible only when this agent is actively working */}
{isActive && (
<div className={`absolute -left-3 bottom-2 top-2 w-[3px] rounded-full ${accent.bar}`} />
)}
{/* Status dot */}
<div className="flex shrink-0 pt-0.5">
<span
className={`size-2 rounded-full transition-all duration-200 ${
isActive
? `animate-pulse ${accent.dot} ring-2 ring-[var(--color-border-glow)]`
: isCompleted
? 'bg-[var(--color-status-success)]'
: 'bg-[var(--color-surface-3)]'
}`}
/>
</div>
{/* Content */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-[12px] font-medium text-[var(--color-text-primary)]">{row.agentName}</span>
</div>
{/* Model + effort inline */}
{agent && (
<div className="mt-1 flex flex-wrap items-center gap-1">
<span className="inline-flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
{(() => {
const prov = inferProvider(agent.model);
return prov ? <ProviderIcon provider={prov} className="size-2.5" /> : null;
})()}
{formatModel(agent.model)}
</span>
{agent.reasoningEffort && (
<>
<span className="text-[10px] text-[var(--color-text-muted)]">·</span>
<span className="inline-flex items-center gap-0.5 text-[10px] text-[var(--color-text-muted)]">
<Sparkles className="size-2" />
{formatEffort(agent.reasoningEffort)}
</span>
</>
)}
</div>
)}
{/* Activity label */}
<div className="mt-1 flex items-center gap-1">
<span
className={`text-[10px] ${
isActive
? accent.label
: isCompleted
? 'text-[var(--color-status-success)]'
: 'text-[var(--color-text-muted)]'
}`}
>
{formatAgentActivityLabel(row.activity)}
</span>
</div>
{/* Per-agent usage summary */}
{agentUsage && agentUsage.requestCount > 0 && (
<div className="mt-0.5 flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
<span className="font-mono tabular-nums">{formatTokenCount(agentUsage.inputTokens)} in</span>
<span className="text-[var(--color-text-muted)]">·</span>
<span className="font-mono tabular-nums">{formatTokenCount(agentUsage.outputTokens)} out</span>
{agentUsage.cost > 0 && (
<>
<span className="text-[var(--color-text-muted)]">·</span>
<span className="font-mono tabular-nums">{agentUsage.cost.toFixed(2)} cost</span>
</>
)}
{agentUsage.durationMs > 0 && (
<>
<span className="text-[var(--color-text-muted)]">·</span>
<span className="font-mono tabular-nums">{formatDuration(agentUsage.durationMs)}</span>
</>
)}
</div>
)}
</div>
</div>
);
}
/* ── Turn event helpers ─────────────────────────────────────── */
import type { SessionEventKind } from '@shared/domain/event';
@@ -178,6 +34,8 @@ 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 'agent-activity':
return <GitBranch className={`${base} ${phase === 'start' ? 'text-[var(--color-accent-sky)]' : 'text-[var(--color-status-success)]'}`} />;
case 'subagent':
return <ArrowRight className={`${base} ${success === false ? 'text-[var(--color-status-error)]' : 'text-[var(--color-accent-sky)]'}`} />;
case 'hook-lifecycle':
@@ -207,6 +65,7 @@ function formatTurnEventTimestamp(iso: string): string {
interface ActivityPanelProps {
activity?: SessionActivityState;
workflow: WorkflowDefinition;
workflows?: ReadonlyArray<WorkflowDefinition>;
session: SessionRecord;
sessionRequestUsage?: SessionRequestUsageState;
turnEvents?: TurnEventLog;
@@ -215,32 +74,40 @@ interface ActivityPanelProps {
export function ActivityPanel({
activity,
workflow,
workflows,
session,
sessionRequestUsage,
turnEvents,
}: ActivityPanelProps) {
const workflowAgents = useMemo(
() => resolveWorkflowAgentNodes(workflow)
.map((n) => n.config)
.filter((c): c is AgentNodeConfig => c.kind === 'agent'),
[workflow],
);
const workflowMode = workflow.settings.orchestrationMode ?? 'single';
const resolveOptions = useMemo(() => ({
resolveWorkflow: (id: string) => workflows?.find((w) => w.id === id),
}), [workflows]);
const activityRows = useMemo(
() => buildAgentActivityRows(activity, workflowAgents),
[activity, workflowAgents],
const hierarchy = useMemo(
() => resolveWorkflowAgentHierarchy(workflow, resolveOptions),
[workflow, resolveOptions],
);
const groupedRows = useMemo(
() => buildGroupedActivityRows(activity, hierarchy),
[activity, hierarchy],
);
const workflowMode = workflow.settings.orchestrationMode ?? 'single';
const totalAgentCount = hierarchy.topLevelAgents.length
+ hierarchy.subWorkflows.reduce((sum, sw) => sum + sw.agents.length, 0);
const isBusy = session.status === 'running';
const hasPendingApproval = session.pendingApproval?.status === 'pending';
const queuedCount = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending').length;
const totalApprovalCount = (hasPendingApproval ? 1 : 0) + queuedCount;
const accent = modeAccent[workflowMode] ?? modeAccent.single;
const hasSubWorkflows = groupedRows.subWorkflows.length > 0;
return (
<div className="flex h-full flex-col">
{/* Header — top padding clears the title bar overlay zone */}
{/* Header */}
<div className="drag-region border-b border-[var(--color-border)] px-4 pb-3 pt-3">
<div className="flex min-h-8 items-center gap-2">
<Activity className="size-4 text-[var(--color-text-muted)]" />
@@ -267,32 +134,53 @@ export function ActivityPanel({
<Users className="size-3" />
<span>Agents</span>
<span className="font-mono rounded-full bg-[var(--color-surface-2)] px-1.5 py-0.5 text-[9px] tabular-nums text-[var(--color-text-muted)]">
{activityRows.length}
{totalAgentCount}
</span>
<span className={`ml-auto text-[9px] font-medium normal-case tracking-normal ${accent.label}`}>
{modeLabels[workflowMode]}
</span>
</SectionHeader>
{activityRows.length > 0 ? (
{/* Top-level agents */}
{groupedRows.topLevelAgents.length > 0 && (
<div className="glass-surface rounded-lg px-3">
{activityRows.map((row, index) => {
{groupedRows.topLevelAgents.map((row, index) => {
const agent = hierarchy.topLevelAgents.find((a) => a.id === row.key || a.name === row.agentName);
const agentKey = row.activity?.agentId ?? row.key;
const agentUsage = sessionRequestUsage?.perAgent[agentKey]
?? sessionRequestUsage?.perAgent[row.agentName];
return (
<AgentRow
accent={accent}
agent={workflowAgents[index]}
agentUsage={agentUsage}
isLast={index === activityRows.length - 1}
key={row.key}
row={row}
agent={agent}
accent={accent}
isLast={!hasSubWorkflows && index === groupedRows.topLevelAgents.length - 1}
agentUsage={agentUsage}
/>
);
})}
</div>
) : (
)}
{/* Sub-workflow groups */}
{hasSubWorkflows && (
<div className={`space-y-2 ${groupedRows.topLevelAgents.length > 0 ? 'mt-2' : ''}`}>
{groupedRows.subWorkflows.map((group) => {
const subDef = hierarchy.subWorkflows.find((sw) => sw.nodeId === group.nodeId);
return (
<SubWorkflowGroup
key={group.nodeId}
group={group}
agentConfigs={subDef?.agents ?? []}
agentUsage={sessionRequestUsage?.perAgent}
/>
);
})}
</div>
)}
{totalAgentCount === 0 && !hasSubWorkflows && (
<p className="py-4 text-center text-[11px] text-[var(--color-text-muted)]">No agents configured</p>
)}
</div>
@@ -376,5 +264,3 @@ export function ActivityPanel({
</div>
);
}
+23 -1
View File
@@ -228,9 +228,30 @@ export function ChatPane({
const activeRun = runsByTrigger.get(lastUserMessageId);
if (activeRun && !consumedRunIds.has(activeRun.id)) {
items.push({ type: 'turn-activity', thinkingMessages: [], run: activeRun, turnStartedAt: activeRun.startedAt });
consumedRunIds.add(activeRun.id);
}
}
// Inject activity panels for any remaining unconsumed runs from
// previous turns (e.g. turns where thinking messages were not
// produced or were lost). Place each panel right after the trigger
// user message so it appears in the correct chronological position.
for (const run of session.runs) {
if (consumedRunIds.has(run.id)) continue;
const triggerIndex = items.findIndex(
(it) => it.type === 'message' && it.message.role === 'user' && it.message.id === run.triggerMessageId,
);
if (triggerIndex === -1) continue;
const panel: DisplayItem = {
type: 'turn-activity',
thinkingMessages: [],
run,
turnStartedAt: run.startedAt,
};
items.splice(triggerIndex + 1, 0, panel);
consumedRunIds.add(run.id);
}
// Tag the last turn-activity panel for each run so only it shows
// run-level metadata (git summary, discard button, etc.).
const lastPanelIndexByRunId = new Map<string, number>();
@@ -530,7 +551,7 @@ export function ChatPane({
</div>
)}
{isSessionBusy && !pendingApproval && !pendingUserInput && (() => {
const label = summarizeSessionActivity(sessionActivity);
const label = session.currentIntent ?? summarizeSessionActivity(sessionActivity);
return (
<div className="flex items-center gap-1.5 text-[12px] text-[var(--color-accent-sky)]">
<span className="size-2 animate-pulse rounded-full bg-[var(--color-accent-sky)]" />
@@ -593,6 +614,7 @@ export function ChatPane({
isActive={isTurnActive(item, itemIndex)}
turnStartedAt={item.turnStartedAt}
sessionId={session.id}
currentIntent={session.currentIntent}
agentNames={item.agentNames}
isLastRunPanel={item.isLastRunPanel}
onDiscard={onDiscardRunChanges}
+309 -3
View File
@@ -1,9 +1,9 @@
import { useEffect, useState, type ReactNode } from 'react';
import { ChevronLeft, ChevronRight, CircleCheck, Code, Cpu, FolderOpen, GitBranch, Palette, Plus, RefreshCw, Server, TriangleAlert, UserCircle, Wrench } from 'lucide-react';
import { ChevronLeft, ChevronRight, CircleCheck, Code, Cpu, FolderOpen, GitBranch, Palette, Plus, RefreshCw, Server, Sparkles, TriangleAlert, UserCircle, Wrench, Activity } from 'lucide-react';
import { CopilotStatusCard } from '@renderer/components/CopilotStatusCard';
import { WorkflowEditor } from '@renderer/components/WorkflowEditor';
import { ToggleSwitch } from '@renderer/components/ui';
import { HotkeyRecorder, TextInput, ToggleSwitch } from '@renderer/components/ui';
import { LspProfileEditor } from '@renderer/components/settings/LspProfileEditor';
import { McpServerEditor } from '@renderer/components/settings/McpServerEditor';
import { WorkspaceAgentEditor } from '@renderer/components/settings/WorkspaceAgentEditor';
@@ -18,9 +18,12 @@ import type { WorkflowTemplateCategory, WorkflowTemplateDefinition } from '@shar
import {
normalizeLspProfileDefinition,
normalizeMcpServerDefinition,
DEFAULT_OTEL_ENDPOINT,
type AppearanceTheme,
type LspProfileDefinition,
type McpServerDefinition,
type OpenTelemetrySettings,
type QuickPromptSettings,
type WorkspaceToolingSettings,
} from '@shared/domain/tooling';
import { normalizeWorkspaceAgentDefinition, findWorkspaceAgentUsages, type WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
@@ -62,9 +65,13 @@ interface SettingsPanelProps {
onGetQuota?: () => Promise<Record<string, QuotaSnapshot>>;
workflowTemplates?: WorkflowTemplateDefinition[];
onCreateWorkflowFromTemplate?: (templateId: string, name?: string) => Promise<void>;
quickPromptSettings?: QuickPromptSettings;
onSetQuickPromptSettings?: (patch: Partial<QuickPromptSettings>) => void;
openTelemetry?: OpenTelemetrySettings;
onSetOpenTelemetry?: (settings: OpenTelemetrySettings) => void;
}
export type SettingsSection = 'appearance' | 'connection' | 'workflows' | 'agents' | 'mcp-servers' | 'lsp-profiles' | 'troubleshooting';
export type SettingsSection = 'appearance' | 'connection' | 'workflows' | 'agents' | 'mcp-servers' | 'lsp-profiles' | 'quick-prompt' | 'telemetry' | 'troubleshooting';
interface NavItem {
id: SettingsSection;
@@ -82,6 +89,8 @@ const navGroups: NavGroup[] = [
label: 'General',
items: [
{ id: 'appearance', label: 'Appearance', icon: <Palette className="size-3.5" /> },
{ id: 'quick-prompt', label: 'Quick Prompt', icon: <Sparkles className="size-3.5" /> },
{ id: 'telemetry', label: 'Telemetry', icon: <Activity className="size-3.5" /> },
],
},
{
@@ -149,6 +158,10 @@ export function SettingsPanel({
onGetQuota,
workflowTemplates,
onCreateWorkflowFromTemplate,
quickPromptSettings,
onSetQuickPromptSettings,
openTelemetry,
onSetOpenTelemetry,
}: SettingsPanelProps) {
const [activeSection, setActiveSection] = useState<SettingsSection>(initialSection ?? 'appearance');
const [editingWorkflow, setEditingWorkflow] = useState<WorkflowDefinition | null>(null);
@@ -192,6 +205,7 @@ export function SettingsPanel({
}}
workflow={editingWorkflow}
workflows={workflows}
workspaceAgents={workspaceAgents}
/>
</div>
);
@@ -380,6 +394,19 @@ export function SettingsPanel({
profiles={toolingSettings.lspProfiles}
/>
)}
{activeSection === 'quick-prompt' && (
<QuickPromptSettingsSection
settings={quickPromptSettings}
availableModels={availableModels}
onUpdate={onSetQuickPromptSettings}
/>
)}
{activeSection === 'telemetry' && (
<TelemetrySection
openTelemetry={openTelemetry}
onSetOpenTelemetry={onSetOpenTelemetry}
/>
)}
{activeSection === 'troubleshooting' && (
<TroubleshootingSection
onOpenAppDataFolder={onOpenAppDataFolder}
@@ -534,6 +561,67 @@ function AppearanceSection({
);
}
function TelemetrySection({
openTelemetry,
onSetOpenTelemetry,
}: {
openTelemetry?: OpenTelemetrySettings;
onSetOpenTelemetry?: (settings: OpenTelemetrySettings) => void;
}) {
const enabled = openTelemetry?.enabled ?? false;
const endpoint = openTelemetry?.endpoint ?? DEFAULT_OTEL_ENDPOINT;
const handleToggle = () => {
onSetOpenTelemetry?.({ enabled: !enabled, endpoint });
};
const handleEndpointChange = (value: string) => {
onSetOpenTelemetry?.({ enabled, endpoint: value });
};
return (
<div>
<div className="mb-1">
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">OpenTelemetry</h3>
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">
Export traces from the sidecar to an OTLP-compatible collector
</p>
</div>
<button
className="mt-4 flex w-full items-center justify-between rounded-lg border border-[var(--color-border)] px-4 py-3 text-left transition hover:bg-[var(--color-surface-3)]/40"
onClick={handleToggle}
type="button"
>
<div>
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">
Enable OTLP export
</span>
<p className="text-[12px] text-[var(--color-text-muted)]">
Send traces to the configured OTLP endpoint when a new sidecar session starts
</p>
</div>
<ToggleSwitch enabled={enabled} />
</button>
<div className="mt-5">
<label className="mb-1.5 block text-[12px] font-medium text-[var(--color-text-secondary)]">
OTLP endpoint
</label>
<TextInput
value={endpoint}
onChange={handleEndpointChange}
placeholder={DEFAULT_OTEL_ENDPOINT}
/>
<p className="mt-1.5 text-[11px] text-[var(--color-text-muted)]">
The URL of any OTLP-compatible collector (e.g. Jaeger, Aspire Dashboard, Grafana Alloy).
Changes take effect on the next session.
</p>
</div>
</div>
);
}
function ConnectionSection({
connection,
modelCount,
@@ -1304,3 +1392,221 @@ function TroubleshootingAction({
</button>
);
}
function QuickPromptSettingsSection({
settings,
availableModels,
onUpdate,
}: {
settings?: QuickPromptSettings;
availableModels: ReadonlyArray<ModelDefinition>;
onUpdate?: (patch: Partial<QuickPromptSettings>) => void;
}) {
const [modelDropdownOpen, setModelDropdownOpen] = useState(false);
const enabled = settings?.enabled ?? true;
const hotkey = settings?.hotkey ?? 'Alt+Shift+C';
const defaultModel = settings?.defaultModel;
const defaultReasoning = settings?.defaultReasoningEffort;
const resolvedModel = defaultModel ? availableModels.find((m) => m.id === defaultModel) : undefined;
const modelSupportsReasoning = resolvedModel?.supportedReasoningEfforts?.length;
// Group models by tier for the dropdown
const tierOrder = ['premium', 'standard', 'fast'] as const;
const tierLabels: Record<string, string> = { premium: 'Premium', standard: 'Standard', fast: 'Fast' };
const groupedModels = tierOrder
.map((tier) => ({
tier,
label: tierLabels[tier],
models: availableModels.filter((m) => m.tier === tier),
}))
.filter((g) => g.models.length > 0);
// Models without a tier
const untypedModels = availableModels.filter((m) => !m.tier);
if (untypedModels.length > 0) {
groupedModels.push({ tier: 'other' as never, label: 'Other', models: untypedModels });
}
return (
<div>
<div className="mb-1">
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">Quick Prompt</h3>
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">
Press a global hotkey to ask the AI a quick question from anywhere
</p>
</div>
{/* Enable / Disable toggle */}
<div className="mt-5 flex items-center gap-3 rounded-lg border border-[var(--color-border)] px-4 py-3">
<button
className="flex flex-1 items-start gap-0 text-left"
onClick={() => onUpdate?.({ enabled: !enabled })}
type="button"
>
<div className="flex-1">
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">
Enable global hotkey
</span>
<p className="mt-0.5 text-[11px] text-[var(--color-text-muted)]">
Summon Quick Prompt from any app
</p>
</div>
</button>
<button onClick={() => onUpdate?.({ enabled: !enabled })} type="button">
<ToggleSwitch enabled={enabled} />
</button>
</div>
{/* Hotkey Recorder */}
<div className="mt-4">
<div className="mb-2 flex items-center justify-between">
<span className="text-[12px] font-medium text-[var(--color-text-secondary)]">
Keyboard shortcut
</span>
</div>
<HotkeyRecorder
value={hotkey}
disabled={!enabled}
onChange={(newHotkey) => onUpdate?.({ hotkey: newHotkey })}
/>
</div>
{/* Default Model — compact dropdown selector */}
<div className="mt-6">
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">Default Model</h3>
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">
Override the workflow model for Quick Prompt sessions
</p>
<div className="relative mt-3">
{/* Trigger button */}
<button
onClick={() => setModelDropdownOpen((prev) => !prev)}
className={`flex w-full items-center gap-3 rounded-lg border px-4 py-3 text-left transition ${
modelDropdownOpen
? 'border-[var(--color-accent)]/40 bg-[var(--color-accent-muted)]'
: 'border-[var(--color-border)] hover:bg-[var(--color-surface-3)]/40'
}`}
type="button"
aria-haspopup="listbox"
aria-expanded={modelDropdownOpen}
>
<div className="flex-1">
<span className="text-[13px] font-medium text-[var(--color-text-primary)]">
{resolvedModel?.name ?? 'Workflow default'}
</span>
{resolvedModel?.tier && (
<span className={`ml-2 inline-flex items-center gap-1 rounded-[4px] px-1.5 py-px text-[10px] font-medium ${
resolvedModel.tier === 'premium' ? 'bg-amber-400/10 text-amber-400' :
resolvedModel.tier === 'fast' ? 'bg-emerald-400/10 text-emerald-400' :
'bg-[var(--color-accent-muted)] text-[var(--color-text-accent)]'
}`}>
{resolvedModel.tier}
</span>
)}
</div>
<ChevronRight className={`size-4 text-[var(--color-text-muted)] transition-transform ${modelDropdownOpen ? 'rotate-90' : ''}`} />
</button>
{/* Dropdown */}
{modelDropdownOpen && (
<div className="absolute top-full left-0 right-0 z-10 mt-1 overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-1)] shadow-xl shadow-black/40">
<div className="max-h-[280px] overflow-y-auto p-1.5">
{/* Workflow default option */}
<button
onClick={() => { onUpdate?.({ defaultModel: undefined }); setModelDropdownOpen(false); }}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-2 text-left transition ${
!defaultModel
? 'bg-[var(--color-accent-muted)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-2)] hover:text-[var(--color-text-primary)]'
}`}
type="button"
role="option"
aria-selected={!defaultModel}
>
<span className="flex-1 text-[12px] font-medium">Workflow default</span>
{!defaultModel && <CircleCheck className="size-3.5 text-[var(--color-accent)]" />}
</button>
{/* Grouped models */}
{groupedModels.map((group) => (
<div key={group.tier}>
<div className="mx-2 mt-2 mb-1 border-t border-[var(--color-border-subtle)]/50" />
<div className="px-2.5 pt-1.5 pb-0.5 text-[10px] font-semibold tracking-wider text-[var(--color-text-muted)] uppercase">
{group.label}
</div>
{group.models.map((model) => {
const isSelected = defaultModel === model.id;
return (
<button
key={model.id}
onClick={() => { onUpdate?.({ defaultModel: model.id }); setModelDropdownOpen(false); }}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-[7px] text-left transition ${
isSelected
? 'bg-[var(--color-accent-muted)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)] hover:bg-[var(--color-surface-2)] hover:text-[var(--color-text-primary)]'
}`}
type="button"
role="option"
aria-selected={isSelected}
>
<span className="flex-1 truncate text-[12px] font-medium">{model.name}</span>
{model.supportedReasoningEfforts?.length ? (
<span className="text-[9px] font-semibold tracking-wide text-[var(--color-text-muted)] uppercase">reasoning</span>
) : null}
{isSelected && <CircleCheck className="size-3.5 flex-none text-[var(--color-accent)]" />}
</button>
);
})}
</div>
))}
</div>
</div>
)}
</div>
</div>
{/* Reasoning Effort — only shown when selected model supports it */}
{modelSupportsReasoning ? (
<div className="mt-6">
<h3 className="font-display text-[13px] font-semibold text-[var(--color-text-primary)]">Reasoning Effort</h3>
<p className="mt-0.5 text-[12px] text-[var(--color-text-muted)]">
Higher effort produces more thorough answers but takes longer
</p>
<div className="mt-3 flex gap-1.5">
{([undefined, 'low', 'medium', 'high'] as const).map((effort) => {
const isActive = defaultReasoning === effort;
const label = effort ?? 'Auto';
const displayLabel = label.charAt(0).toUpperCase() + label.slice(1);
// Only show efforts the model actually supports (plus "Auto")
if (effort && !resolvedModel?.supportedReasoningEfforts?.includes(effort)) return null;
return (
<button
key={displayLabel}
onClick={() => onUpdate?.({ defaultReasoningEffort: effort })}
className={`flex-1 rounded-lg border py-2 text-[12px] font-medium transition-all duration-150 ${
isActive
? 'border-[var(--color-accent)]/40 bg-[var(--color-accent)]/12 text-[var(--color-text-accent)]'
: 'border-[var(--color-border)] text-[var(--color-text-muted)] hover:border-[var(--color-border-glow)] hover:text-[var(--color-text-secondary)]'
}`}
type="button"
>
{displayLabel}
</button>
);
})}
</div>
</div>
) : defaultModel ? (
<p className="mt-4 text-[11px] text-[var(--color-text-muted)]">
{resolvedModel?.name ?? 'Selected model'} does not support configurable reasoning effort.
</p>
) : null}
</div>
);
}
+267 -22
View File
@@ -1,10 +1,11 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import appIconUrl from '../../../assets/icons/icon.png';
import { isMac } from '@renderer/lib/platform';
import {
AlertTriangle,
Archive,
ArrowLeftRight,
Check,
ChevronDown,
ChevronRight,
Circle,
@@ -35,6 +36,10 @@ import { querySessions } from '@shared/domain/sessionLibrary';
import type { UpdateStatus } from '@shared/contracts/ipc';
import type { WorkspaceState } from '@shared/domain/workspace';
import { UpdateBanner } from '@renderer/components/ui';
import { useSessionSelection } from '@renderer/hooks/useSessionSelection';
import { BatchActionBar } from '@renderer/components/sidebar/BatchActionBar';
import { BatchDeleteConfirmDialog } from '@renderer/components/sidebar/BatchDeleteConfirmDialog';
import { UndoToast } from '@renderer/components/sidebar/UndoToast';
interface SidebarProps {
workspace: WorkspaceState;
@@ -50,6 +55,8 @@ interface SidebarProps {
onSetSessionPinned: (sessionId: string, isPinned: boolean) => void;
onSetSessionArchived: (sessionId: string, isArchived: boolean) => void;
onDeleteSession: (sessionId: string) => void;
onBatchArchiveSessions: (sessionIds: string[], isArchived: boolean) => void;
onBatchDeleteSessions: (sessionIds: string[]) => void;
onRefreshGitContext: (projectId: string) => void;
updateStatus?: UpdateStatus;
onViewUpdateDetails?: () => void;
@@ -183,6 +190,12 @@ function SessionItem({
onOpenMenu,
onRenameSubmit,
onRenameCancel,
isSelecting,
isSelected,
selectionIndex,
onToggleSelection,
onEnterSelectionMode,
onShiftSelect,
}: {
session: SessionRecord;
workflow?: WorkflowDefinition;
@@ -192,6 +205,12 @@ function SessionItem({
onOpenMenu: (e: React.MouseEvent) => void;
onRenameSubmit: (title: string) => void;
onRenameCancel: () => void;
isSelecting?: boolean;
isSelected?: boolean;
selectionIndex?: number;
onToggleSelection?: () => void;
onEnterSelectionMode?: () => void;
onShiftSelect?: () => void;
}) {
const isRunning = session.status === 'running';
const isError = session.status === 'error';
@@ -201,6 +220,7 @@ function SessionItem({
const visual = modeVisuals[mode];
const ModeIcon = visual.icon;
const agentCount = workflow ? resolveWorkflowAgentNodes(workflow).length : 1;
const isSelectDisabled = isRunning;
const [renameText, setRenameText] = useState(session.title);
const inputRef = useRef<HTMLInputElement>(null);
@@ -232,34 +252,96 @@ function SessionItem({
else onRenameCancel();
}
function handleClick(e: React.MouseEvent) {
if (isRenaming) return;
// Already in selection mode — toggle or range-select
if (isSelecting) {
if (isSelectDisabled) return;
if (e.shiftKey) {
onShiftSelect?.();
} else {
onToggleSelection?.();
}
return;
}
// Not in selection mode — check for modifier key to enter it
const modKey = isMac ? e.metaKey : e.ctrlKey;
if (modKey && !isSelectDisabled) {
onEnterSelectionMode?.();
return;
}
onSelect();
}
function handleKeyDown(e: React.KeyboardEvent) {
if ((e.key === 'Enter' || e.key === ' ') && !isRenaming) {
e.preventDefault();
if (isSelecting) {
if (!isSelectDisabled) onToggleSelection?.();
} else {
onSelect();
}
}
}
return (
<div
className={`session-item-enter group relative flex w-full cursor-pointer items-start gap-2.5 rounded-lg px-2.5 py-2 text-left transition-all duration-200 ${
isActive
isSelecting && isSelected
? 'bg-[var(--color-accent-muted)] ring-1 ring-[var(--color-border-glow)]'
: 'hover:bg-[var(--color-surface-2)]/60'
} ${isRunning ? 'sidebar-running' : ''} ${session.isArchived ? 'opacity-50' : ''}`}
onClick={isRenaming ? undefined : onSelect}
role="button"
: isActive && !isSelecting
? 'bg-[var(--color-accent-muted)] ring-1 ring-[var(--color-border-glow)]'
: 'hover:bg-[var(--color-surface-2)]/60'
} ${isRunning ? 'sidebar-running' : ''} ${session.isArchived ? 'opacity-50' : ''} ${isSelectDisabled ? 'cursor-not-allowed opacity-40' : ''}`}
onClick={handleClick}
role={isSelecting ? 'checkbox' : 'button'}
aria-checked={isSelecting ? isSelected : undefined}
tabIndex={0}
onKeyDown={(e) => { if ((e.key === 'Enter' || e.key === ' ') && !isRenaming) { e.preventDefault(); onSelect(); } }}
onKeyDown={handleKeyDown}
>
{/* Running/approval left accent bar */}
{isRunning && !hasPendingApproval && (
{isRunning && !hasPendingApproval && !isSelecting && (
<span className="absolute inset-y-1.5 left-0 w-[3px] rounded-full accent-flow" />
)}
{hasPendingApproval && (
{hasPendingApproval && !isSelecting && (
<span className="absolute inset-y-1.5 left-0 w-[3px] rounded-full bg-[var(--color-status-warning)]" />
)}
{/* Selection accent bar */}
{isSelecting && isSelected && (
<span className="absolute inset-y-1.5 left-0 w-[3px] rounded-full bg-[var(--color-accent)]" />
)}
{/* Mode icon */}
<span
className={`mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-md ${
isActive ? 'bg-[var(--color-accent-muted)]' : 'bg-[var(--color-surface-2)]'
}`}
>
<ModeIcon className={`size-3.5 ${isActive ? 'text-[var(--color-accent)]' : visual.color}`} />
</span>
{/* Mode icon or selection checkbox */}
{isSelecting ? (
<span
className="selection-checkbox-enter mt-0.5 flex size-6 shrink-0 items-center justify-center"
style={{ animationDelay: `${(selectionIndex ?? 0) * 30}ms` }}
title={isSelectDisabled ? "Can't select running sessions" : undefined}
>
<span
className={`flex size-4 items-center justify-center rounded border transition-all duration-150 ${
isSelected
? 'checkbox-check border-[var(--color-accent)] bg-[var(--color-accent)]'
: isSelectDisabled
? 'border-[var(--color-text-muted)]/30 bg-transparent'
: 'border-[var(--color-text-muted)]/50 bg-transparent hover:border-[var(--color-accent)]/50'
}`}
>
{isSelected && <Check className="size-3 text-white" strokeWidth={3} />}
</span>
</span>
) : (
<span
className={`mt-0.5 flex size-6 shrink-0 items-center justify-center rounded-md ${
isActive ? 'bg-[var(--color-accent-muted)]' : 'bg-[var(--color-surface-2)]'
}`}
>
<ModeIcon className={`size-3.5 ${isActive ? 'text-[var(--color-accent)]' : visual.color}`} />
</span>
)}
{/* Content */}
<div className="min-w-0 flex-1">
@@ -342,8 +424,8 @@ function SessionItem({
</div>
</div>
{/* Actions button (hidden during rename) */}
{!isRenaming && (
{/* Actions button (hidden during rename and selection mode) */}
{!isRenaming && !isSelecting && (
<button
className="absolute right-1.5 top-1.5 flex size-6 items-center justify-center rounded-md text-[var(--color-text-muted)] opacity-0 transition-all duration-150 hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)] group-hover:opacity-100"
onClick={(e) => { e.stopPropagation(); onOpenMenu(e); }}
@@ -372,6 +454,11 @@ function ProjectGroup({
onOpenProjectSettings,
onNewSession,
newSessionLabel,
isSelecting,
isSelected,
onToggleSelection,
onEnterSelectionMode,
onShiftSelect,
}: {
project: ProjectRecord;
sessions: SessionRecord[];
@@ -386,6 +473,11 @@ function ProjectGroup({
onOpenProjectSettings?: (projectId: string) => void;
onNewSession?: () => void;
newSessionLabel?: string;
isSelecting?: boolean;
isSelected?: (sessionId: string) => boolean;
onToggleSelection?: (sessionId: string) => void;
onEnterSelectionMode?: (sessionId: string) => void;
onShiftSelect?: (sessionId: string) => void;
}){
const [expanded, setExpanded] = useState(true);
const isScratchpad = isScratchpadProject(project);
@@ -508,7 +600,7 @@ function ProjectGroup({
{expanded && (
<div className="ml-2 mt-0.5 space-y-0.5 border-l border-[var(--color-border-subtle)] pl-2">
{visibleSessions.length > 0 &&
visibleSessions.map((session) => (
visibleSessions.map((session, index) => (
<SessionItem
isActive={selectedSessionId === session.id}
isRenaming={renamingSessionId === session.id}
@@ -519,6 +611,12 @@ function ProjectGroup({
onRenameCancel={onRenameCancel}
workflow={workflowMap.get(session.workflowId)}
session={session}
isSelecting={isSelecting}
isSelected={isSelected?.(session.id)}
selectionIndex={index}
onToggleSelection={() => onToggleSelection?.(session.id)}
onEnterSelectionMode={() => onEnterSelectionMode?.(session.id)}
onShiftSelect={() => onShiftSelect?.(session.id)}
/>
))}
{onNewSession ? (
@@ -559,6 +657,8 @@ export function Sidebar({
onSetSessionPinned,
onSetSessionArchived,
onDeleteSession,
onBatchArchiveSessions,
onBatchDeleteSessions,
onRefreshGitContext,
updateStatus,
onViewUpdateDetails,
@@ -600,6 +700,7 @@ export function Sidebar({
const scrollRef = useRef<HTMLDivElement>(null);
function handleOpenMenu(sessionId: string, e: React.MouseEvent) {
if (selection.isSelecting) return;
const rect = (e.currentTarget as HTMLElement).getBoundingClientRect();
setMenuState({
sessionId,
@@ -621,6 +722,102 @@ export function Sidebar({
? workspace.sessions.find((s) => s.id === menuState.sessionId)
: undefined;
/* ── Multi-select state ────────────────────────────────────── */
const selection = useSessionSelection();
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
const [undoToast, setUndoToast] = useState<{ message: string; sessionIds: string[]; isArchived: boolean } | null>(null);
// All selectable (non-running) session IDs across the visible list
const allSelectableIds = useMemo(() => {
const sessions = workspace.sessions.filter((s) => !s.isArchived && s.status !== 'running');
return sessions.map((s) => s.id);
}, [workspace.sessions]);
// All visible session IDs (for range select and "select all")
const allVisibleIds = useMemo(() => {
if (isQueryActive) return queryResults.map((s) => s.id);
return workspace.sessions.filter((s) => !s.isArchived).map((s) => s.id);
}, [isQueryActive, queryResults, workspace.sessions]);
const allSelectedArchived = useMemo(() => {
if (selection.selectedIds.size === 0) return false;
return [...selection.selectedIds].every((id) => {
const session = workspace.sessions.find((s) => s.id === id);
return session?.isArchived;
});
}, [selection.selectedIds, workspace.sessions]);
const selectedSessions = useMemo(
() => workspace.sessions.filter((s) => selection.selectedIds.has(s.id)),
[selection.selectedIds, workspace.sessions],
);
function handleEnterSelectionMode(sessionId: string) {
const session = workspace.sessions.find((s) => s.id === sessionId);
if (session?.status === 'running') return;
selection.enterSelectionMode(sessionId);
}
function handleToggleSelection(sessionId: string) {
const session = workspace.sessions.find((s) => s.id === sessionId);
if (session?.status === 'running') return;
selection.toggle(sessionId);
}
function handleShiftSelect(sessionId: string) {
const session = workspace.sessions.find((s) => s.id === sessionId);
if (session?.status === 'running') return;
selection.rangeSelect(sessionId, allVisibleIds);
}
const handleBatchArchive = useCallback(() => {
const ids = [...selection.selectedIds];
const isArchived = !allSelectedArchived;
onBatchArchiveSessions(ids, isArchived);
selection.exitSelectionMode();
setUndoToast({
message: `${ids.length} session${ids.length === 1 ? '' : 's'} ${isArchived ? 'archived' : 'restored'}`,
sessionIds: ids,
isArchived,
});
}, [selection, allSelectedArchived, onBatchArchiveSessions]);
const handleBatchDeleteConfirm = useCallback(() => {
const ids = [...selection.selectedIds];
onBatchDeleteSessions(ids);
selection.exitSelectionMode();
setShowDeleteConfirm(false);
}, [selection, onBatchDeleteSessions]);
const handleUndoArchive = useCallback(() => {
if (!undoToast) return;
onBatchArchiveSessions(undoToast.sessionIds, !undoToast.isArchived);
setUndoToast(null);
}, [undoToast, onBatchArchiveSessions]);
// Exit selection mode on Escape
useEffect(() => {
if (!selection.isSelecting) return;
function handleKeyDown(e: KeyboardEvent) {
if (e.key === 'Escape') {
selection.exitSelectionMode();
}
}
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [selection.isSelecting, selection.exitSelectionMode]);
// Clean up selection when sessions are removed from workspace
useEffect(() => {
if (!selection.isSelecting) return;
const sessionIdSet = new Set(workspace.sessions.map((s) => s.id));
const stale = [...selection.selectedIds].filter((id) => !sessionIdSet.has(id));
if (stale.length > 0) {
for (const id of stale) selection.toggle(id);
}
}, [workspace.sessions, selection]);
return (
<div className="flex h-full flex-col">
{/* Header — extra top padding clears the title bar overlay zone */}
@@ -685,7 +882,7 @@ export function Sidebar({
No sessions match your search
</div>
) : (
queryResults.map((session) => (
queryResults.map((session, index) => (
<SessionItem
isActive={workspace.selectedSessionId === session.id}
isRenaming={renamingSessionId === session.id}
@@ -696,6 +893,12 @@ export function Sidebar({
onRenameCancel={() => setRenamingSessionId(undefined)}
workflow={workflowMap.get(session.workflowId)}
session={session}
isSelecting={selection.isSelecting}
isSelected={selection.isSelected(session.id)}
selectionIndex={index}
onToggleSelection={() => handleToggleSelection(session.id)}
onEnterSelectionMode={() => handleEnterSelectionMode(session.id)}
onShiftSelect={() => handleShiftSelect(session.id)}
/>
))
)}
@@ -721,6 +924,11 @@ export function Sidebar({
sessions={workspace.sessions.filter((session) => session.projectId === scratchpadProject.id)}
onNewSession={onCreateScratchpad}
newSessionLabel="New Scratchpad"
isSelecting={selection.isSelecting}
isSelected={selection.isSelected}
onToggleSelection={handleToggleSelection}
onEnterSelectionMode={handleEnterSelectionMode}
onShiftSelect={handleShiftSelect}
/>
</div>
)}
@@ -769,6 +977,11 @@ export function Sidebar({
selectedSessionId={workspace.selectedSessionId}
sessions={workspace.sessions.filter((session) => session.projectId === project.id)}
onNewSession={() => onNewProjectSession(project.id)}
isSelecting={selection.isSelecting}
isSelected={selection.isSelected}
onToggleSelection={handleToggleSelection}
onEnterSelectionMode={handleEnterSelectionMode}
onShiftSelect={handleShiftSelect}
/>
))}
</div>
@@ -801,7 +1014,7 @@ export function Sidebar({
)}
{/* Context menu overlay */}
{menuState && menuSession && (
{menuState && menuSession && !selection.isSelecting && (
<>
<div className="fixed inset-0 z-40" onClick={closeMenu} onKeyDown={(e) => { if (e.key === 'Escape') closeMenu(); }} />
<div
@@ -853,6 +1066,38 @@ export function Sidebar({
</div>
</>
)}
{/* Batch action bar */}
{selection.isSelecting && selection.selectedIds.size > 0 && (
<BatchActionBar
selectedCount={selection.selectedIds.size}
allSelectedArchived={allSelectedArchived}
allSelected={allSelectableIds.length > 0 && allSelectableIds.every((id) => selection.selectedIds.has(id))}
onArchive={handleBatchArchive}
onDelete={() => setShowDeleteConfirm(true)}
onSelectAll={() => selection.selectAll(allSelectableIds)}
onDeselectAll={selection.deselectAll}
onCancel={selection.exitSelectionMode}
/>
)}
{/* Undo toast */}
{undoToast && (
<UndoToast
message={undoToast.message}
onUndo={handleUndoArchive}
onDismiss={() => setUndoToast(null)}
/>
)}
{/* Batch delete confirmation */}
{showDeleteConfirm && selectedSessions.length > 0 && (
<BatchDeleteConfirmDialog
sessions={selectedSessions}
onConfirm={handleBatchDeleteConfirm}
onCancel={() => setShowDeleteConfirm(false)}
/>
)}
</div>
);
}
@@ -3,6 +3,7 @@ import { RotateCcw } from 'lucide-react';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import '@xterm/xterm/css/xterm.css';
import '@fontsource-variable/jetbrains-mono';
import { getElectronApi } from '@renderer/lib/electronApi';
import type { TerminalSnapshot } from '@shared/domain/terminal';
+40 -1
View File
@@ -13,6 +13,7 @@ import type {
SubWorkflowConfig,
WorkflowStateScope,
} from '@shared/domain/workflow';
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
import { validateWorkflowDefinition, isBuilderBasedMode, syncBuilderModeEdgeIterations } from '@shared/domain/workflow';
import { createId } from '@shared/utils/ids';
import { ToggleSwitch } from '@renderer/components/ui';
@@ -25,6 +26,7 @@ import { OrchestrationModePanel } from './workflow/OrchestrationModePanel';
interface WorkflowEditorProps {
availableModels: ReadonlyArray<ModelDefinition>;
workspaceAgents: ReadonlyArray<WorkspaceAgentDefinition>;
workflow: WorkflowDefinition;
workflows: ReadonlyArray<WorkflowDefinition>;
onChange: (workflow: WorkflowDefinition) => void;
@@ -152,6 +154,7 @@ function createMinimalInlineWorkflow(): WorkflowDefinition {
export function WorkflowEditor({
availableModels,
workspaceAgents,
workflow,
workflows,
onChange,
@@ -251,6 +254,36 @@ export function WorkflowEditor({
setSelectedEdgeId(null);
}
function handleAddWorkspaceAgentNode(agentId: string) {
const agent = workspaceAgents.find((a) => a.id === agentId);
if (!agent) return;
const nodeId = createId('wf-agent');
const config: AgentNodeConfig = {
kind: 'agent',
id: createId('agent'),
name: agent.name,
description: agent.description,
instructions: agent.instructions,
model: agent.model,
reasoningEffort: agent.reasoningEffort,
copilot: agent.copilot,
workspaceAgentId: agent.id,
};
const newNode: WorkflowNode = {
id: nodeId,
kind: 'agent',
label: agent.name,
position: { x: 300, y: 200 },
config,
};
emitGraphChange({
...activeWorkflow.graph,
nodes: [...activeWorkflow.graph.nodes, newNode],
});
setSelectedNodeId(nodeId);
setSelectedEdgeId(null);
}
function handleNodeChange(nodeId: string, patch: Partial<WorkflowNode>) {
emitGraphChange({
...activeWorkflow.graph,
@@ -486,7 +519,12 @@ export function WorkflowEditor({
<div className="flex min-h-0 flex-1">
{/* Left palette */}
<div className="w-40 shrink-0 overflow-y-auto border-r border-[var(--color-border)] bg-[var(--color-surface-1)]">
<WorkflowNodePalette disabledKinds={disabledPaletteKinds} onAddNode={handleAddNode} />
<WorkflowNodePalette
disabledKinds={disabledPaletteKinds}
onAddNode={handleAddNode}
onAddWorkspaceAgentNode={handleAddWorkspaceAgentNode}
workspaceAgents={workspaceAgents}
/>
</div>
{/* Center column: validation + canvas + settings */}
@@ -551,6 +589,7 @@ export function WorkflowEditor({
validationIssues={issues}
workflow={activeWorkflow}
workflows={workflows}
workspaceAgents={workspaceAgents}
/>
</div>
</div>
@@ -0,0 +1,111 @@
import { Sparkles } from 'lucide-react';
import type { AgentNodeConfig } from '@shared/domain/workflow';
import { inferProvider } from '@shared/domain/models';
import {
formatAgentActivityLabel,
formatDuration,
formatTokenCount,
isAgentActivityActive,
isAgentActivityCompleted,
type AgentActivityRow,
type AgentUsageAccumulator,
} from '@renderer/lib/sessionActivity';
import { ProviderIcon } from '@renderer/components/ProviderIcons';
import { type ModeAccent, formatEffort, formatModel } from './constants';
interface AgentRowProps {
row: AgentActivityRow;
agent?: AgentNodeConfig;
accent: ModeAccent;
isLast: boolean;
agentUsage?: AgentUsageAccumulator;
}
export function AgentRow({ row, agent, accent, isLast, agentUsage }: AgentRowProps) {
const isActive = isAgentActivityActive(row.activity);
const isCompleted = isAgentActivityCompleted(row.activity);
return (
<div className={`relative flex gap-2.5 py-2.5 ${isLast ? '' : 'border-b border-[var(--color-border-subtle)]'}`}>
{isActive && (
<div className={`absolute -left-3 bottom-2 top-2 w-[3px] rounded-full ${accent.bar}`} />
)}
{/* Status dot */}
<div className="flex shrink-0 pt-0.5">
<span
className={`size-2 rounded-full transition-all duration-200 ${
isActive
? `animate-pulse ${accent.dot} ring-2 ring-[var(--color-border-glow)]`
: isCompleted
? 'bg-[var(--color-status-success)]'
: 'bg-[var(--color-surface-3)]'
}`}
/>
</div>
{/* Content */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-[12px] font-medium text-[var(--color-text-primary)]">{row.agentName}</span>
</div>
{agent && (
<div className="mt-1 flex flex-wrap items-center gap-1">
<span className="inline-flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
{(() => {
const prov = inferProvider(agent.model);
return prov ? <ProviderIcon provider={prov} className="size-2.5" /> : null;
})()}
{formatModel(agent.model)}
</span>
{agent.reasoningEffort && (
<>
<span className="text-[10px] text-[var(--color-text-muted)]">·</span>
<span className="inline-flex items-center gap-0.5 text-[10px] text-[var(--color-text-muted)]">
<Sparkles className="size-2" />
{formatEffort(agent.reasoningEffort)}
</span>
</>
)}
</div>
)}
<div className="mt-1 flex items-center gap-1">
<span
className={`text-[10px] ${
isActive
? accent.label
: isCompleted
? 'text-[var(--color-status-success)]'
: 'text-[var(--color-text-muted)]'
}`}
>
{formatAgentActivityLabel(row.activity)}
</span>
</div>
{agentUsage && agentUsage.requestCount > 0 && (
<div className="mt-0.5 flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
<span className="font-mono tabular-nums">{formatTokenCount(agentUsage.inputTokens)} in</span>
<span className="text-[var(--color-text-muted)]">·</span>
<span className="font-mono tabular-nums">{formatTokenCount(agentUsage.outputTokens)} out</span>
{agentUsage.cost > 0 && (
<>
<span className="text-[var(--color-text-muted)]">·</span>
<span className="font-mono tabular-nums">{agentUsage.cost.toFixed(2)} cost</span>
</>
)}
{agentUsage.durationMs > 0 && (
<>
<span className="text-[var(--color-text-muted)]">·</span>
<span className="font-mono tabular-nums">{formatDuration(agentUsage.durationMs)}</span>
</>
)}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,145 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ChevronDown, GitBranch } from 'lucide-react';
import type { AgentNodeConfig } from '@shared/domain/workflow';
import {
isAgentActivityActive,
type AgentUsageAccumulator,
type SubWorkflowActivityGroup,
} from '@renderer/lib/sessionActivity';
import { AgentRow } from './AgentRow';
import { modeAccent, modeLabels } from './constants';
interface SubWorkflowGroupProps {
group: SubWorkflowActivityGroup;
agentConfigs: ReadonlyArray<AgentNodeConfig>;
agentUsage?: Record<string, AgentUsageAccumulator>;
}
const statusPresentation = {
idle: {
dot: 'bg-[var(--color-surface-3)]',
text: 'text-[var(--color-text-muted)]',
label: 'Idle',
},
running: {
dot: 'animate-pulse',
text: '',
label: 'Running',
},
completed: {
dot: 'bg-[var(--color-status-success)]',
text: 'text-[var(--color-status-success)]',
label: 'Done',
},
} as const;
export function SubWorkflowGroup({ group, agentConfigs, agentUsage }: SubWorkflowGroupProps) {
const [isExpanded, setIsExpanded] = useState(false);
const prevStatusRef = useRef(group.status);
useEffect(() => {
if (prevStatusRef.current !== 'running' && group.status === 'running') {
setIsExpanded(true);
}
prevStatusRef.current = group.status;
}, [group.status]);
const toggle = useCallback(() => setIsExpanded((prev) => !prev), []);
const accent = modeAccent[group.orchestrationMode] ?? modeAccent.single;
const status = statusPresentation[group.status];
const hasActiveAgent = group.agents.some((a) => isAgentActivityActive(a.activity));
return (
<div
className="overflow-hidden rounded-lg border border-[var(--color-border-subtle)] border-l-[3px] bg-[var(--color-surface-1)]"
style={{ borderLeftColor: accent.color }}
role="group"
aria-label={`Sub-workflow: ${group.name}`}
>
{/* Collapsible header */}
<button
className="flex w-full items-center gap-2 px-3 py-2.5 text-left transition-colors hover:bg-[var(--color-surface-2)]"
onClick={toggle}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggle();
}
}}
aria-expanded={isExpanded}
type="button"
>
<GitBranch className="size-3.5 shrink-0 text-[var(--color-text-muted)]" />
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-[var(--color-text-primary)]">
{group.name}
</span>
{/* Status badge */}
<span className="flex items-center gap-1" aria-live="polite">
<span
className={`size-1.5 rounded-full ${
group.status === 'running'
? `${accent.dot} ${status.dot} ring-1 ring-[var(--color-border-glow)]`
: status.dot
}`}
/>
<span className={`text-[9px] font-medium ${group.status === 'running' ? accent.label : status.text}`}>
{status.label}
</span>
</span>
{/* Agent count pill */}
<span className="font-mono rounded-full bg-[var(--color-surface-2)] px-1.5 py-0.5 text-[9px] tabular-nums text-[var(--color-text-muted)]">
{group.agents.length}
</span>
<ChevronDown
className={`size-3 shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${
isExpanded ? 'rotate-180' : ''
}`}
/>
</button>
{/* Expandable agent list */}
<div
className="grid transition-[grid-template-rows] duration-150 ease-out"
style={{ gridTemplateRows: isExpanded ? '1fr' : '0fr' }}
>
<div className="overflow-hidden">
<div className="relative border-t border-[var(--color-border-subtle)] py-1 pl-5 pr-3">
{/* Connecting vertical accent line */}
{hasActiveAgent && (
<div className={`absolute bottom-3 left-[11px] top-3 w-px ${accent.bar}`} />
)}
{group.agents.map((row, index) => {
const agent = agentConfigs.find((c) => c.id === row.key || c.name === row.agentName);
const usage = agentUsage?.[row.activity?.agentId ?? row.key] ?? agentUsage?.[row.agentName];
return (
<AgentRow
key={row.key}
row={row}
agent={agent}
accent={accent}
isLast={index === group.agents.length - 1}
agentUsage={usage}
/>
);
})}
{/* Mode label */}
<div className="flex items-center gap-1 pb-1 pt-0.5">
<span className={`text-[9px] font-medium ${accent.label}`}>
{modeLabels[group.orchestrationMode]}
</span>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,34 @@
import type { WorkflowOrchestrationMode } from '@shared/domain/workflow';
export interface ModeAccent {
dot: string;
bar: string;
label: string;
color: string;
}
export const modeAccent: Record<WorkflowOrchestrationMode, ModeAccent> = {
single: { dot: 'bg-[#245CF9]', bar: 'bg-[#245CF9] opacity-60', label: 'text-[#245CF9]', color: '#245CF9' },
sequential: { dot: 'bg-[var(--color-status-warning)]', bar: 'bg-[var(--color-status-warning)] opacity-60', label: 'text-[var(--color-status-warning)]', color: 'var(--color-status-warning)' },
concurrent: { dot: 'bg-[var(--color-status-success)]', bar: 'bg-[var(--color-status-success)] opacity-60', label: 'text-[var(--color-status-success)]', color: 'var(--color-status-success)' },
handoff: { dot: 'bg-[var(--color-accent-sky)]', bar: 'bg-[var(--color-accent-sky)] opacity-60', label: 'text-[var(--color-accent-sky)]', color: 'var(--color-accent-sky)' },
'group-chat': { dot: 'bg-[var(--color-accent-purple)]', bar: 'bg-[var(--color-accent-purple)] opacity-60', label: 'text-[var(--color-accent-purple)]', color: 'var(--color-accent-purple)' },
};
export const modeLabels: Record<WorkflowOrchestrationMode, string> = {
single: 'Single agent',
sequential: 'Sequential',
concurrent: 'Concurrent',
handoff: 'Handoff',
'group-chat': 'Group chat',
};
export function formatModel(model: string): string {
return model.replace(/-/g, '\u2011');
}
export function formatEffort(effort: string | undefined): string | undefined {
if (!effort) return undefined;
const labels: Record<string, string> = { low: 'Low', medium: 'Medium', high: 'High', xhigh: 'Max' };
return labels[effort] ?? effort;
}
@@ -5,12 +5,12 @@ import type { ActiveSubagent } from '@renderer/lib/subagentTracker';
const COMPLETION_GRACE_MS = 3000;
function formatElapsed(startedAt: string): string {
const seconds = Math.floor((Date.now() - new Date(startedAt).getTime()) / 1000);
if (seconds < 60) return `${seconds}s`;
const minutes = Math.floor(seconds / 60);
const remainder = seconds % 60;
return `${minutes}m ${remainder}s`;
import { formatElapsedMs } from '@renderer/hooks/useElapsedTimer';
function formatElapsed(startedAt: string, endedAt?: string): string {
const endMs = endedAt ? new Date(endedAt).getTime() : Date.now();
const durationMs = endMs - new Date(startedAt).getTime();
return formatElapsedMs(durationMs);
}
function StatusIcon({ status }: { status: ActiveSubagent['status'] }) {
@@ -6,8 +6,17 @@ import {
CheckCircle2,
ChevronDown,
ChevronRight,
Database,
Eye,
ExternalLink,
FileSearch,
Github,
MessageSquare,
Pencil,
Search,
ShieldAlert,
Terminal,
Users,
Wrench,
XCircle,
Zap,
@@ -18,21 +27,12 @@ import { FileChangePreview } from '@renderer/components/chat/FileChangePreview';
import { ToolCallDetailPanel } from '@renderer/components/chat/ToolCallDetailPanel';
import { RunChangeSummaryCard } from '@renderer/components/chat/RunChangeSummaryCard';
import { formatEventLabel, truncateContent, filterEventsByAgent, summarizeActivity, type ActivitySummary } from '@renderer/lib/runTimelineFormatting';
import { formatToolGroupLabel, extractToolCallSnippet, formatToolCallPrimaryLabel } from '@renderer/lib/toolCallSummary';
import { buildActivityStream, groupActivityStream, extractLatestIntent, generateActivitySummary, type GroupedActivityItem } from '@renderer/lib/activityGrouping';
import type { ChatMessageRecord } from '@shared/domain/session';
import type { ProjectGitFileReference } from '@shared/domain/project';
import type { RunTimelineEventRecord, SessionRunRecord } from '@shared/domain/runTimeline';
/* ── Types ─────────────────────────────────────────────────── */
/** A unified activity stream item, merging chat thinking messages
* and run timeline events into a single chronological list. */
type ActivityStreamItem =
| { kind: 'thinking-step'; message: ChatMessageRecord }
| { kind: 'timeline-event'; event: RunTimelineEventRecord };
/** Events to skip in the inline panel (redundant or implicit). */
const SKIP_EVENT_KINDS = new Set(['run-started', 'thinking']);
/* ── Props ─────────────────────────────────────────────────── */
export interface TurnActivityPanelProps {
@@ -41,9 +41,8 @@ export interface TurnActivityPanelProps {
isActive: boolean;
turnStartedAt?: string;
sessionId: string;
/** Agent names in this turn group — used to scope run events in multi-agent runs. */
currentIntent?: string;
agentNames?: ReadonlySet<string>;
/** True when this panel is the last one sharing a given run (controls git summary / discard). */
isLastRunPanel?: boolean;
onDiscard?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise<unknown>;
onOpenCommitComposer?: () => void;
@@ -58,35 +57,10 @@ function truncatePreview(text: string, maxLength: number): string {
return `${cleaned.slice(0, maxLength)}`;
}
function buildActivityStream(
thinkingMessages: ChatMessageRecord[],
events: readonly RunTimelineEventRecord[],
): ActivityStreamItem[] {
const items: ActivityStreamItem[] = [];
for (const msg of thinkingMessages) {
items.push({ kind: 'thinking-step', message: msg });
}
for (const event of events) {
if (SKIP_EVENT_KINDS.has(event.kind)) continue;
items.push({ kind: 'timeline-event', event });
}
// Sort chronologically by timestamp
items.sort((a, b) => {
const tsA = a.kind === 'thinking-step' ? a.message.createdAt : a.event.occurredAt;
const tsB = b.kind === 'thinking-step' ? b.message.createdAt : b.event.occurredAt;
return new Date(tsA).getTime() - new Date(tsB).getTime();
});
return items;
}
function formatSummaryParts(summary: ActivitySummary): string[] {
const parts: string[] = [];
if (summary.toolCalls > 0) {
parts.push(`${summary.toolCalls} tool ${summary.toolCalls === 1 ? 'call' : 'calls'}`);
parts.push(`${summary.toolCalls} ${summary.toolCalls === 1 ? 'action' : 'actions'}`);
}
if (summary.handoffs > 0) {
parts.push(`${summary.handoffs} ${summary.handoffs === 1 ? 'handoff' : 'handoffs'}`);
@@ -94,20 +68,57 @@ function formatSummaryParts(summary: ActivitySummary): string[] {
if (summary.approvals > 0) {
parts.push(`${summary.approvals} ${summary.approvals === 1 ? 'approval' : 'approvals'}`);
}
if (summary.thinkingSteps > 0) {
parts.push(`${summary.thinkingSteps} thinking ${summary.thinkingSteps === 1 ? 'step' : 'steps'}`);
}
return parts;
}
/* ── Event icon ────────────────────────────────────────────── */
/* ── Tool-category icon ────────────────────────────────────── */
function ActivityEventIcon({ kind, status }: { kind: RunTimelineEventRecord['kind']; status: RunTimelineEventRecord['status'] }) {
function ToolCategoryIcon({ toolName, className }: { toolName?: string; className?: string }) {
const base = className ?? 'size-3 shrink-0';
if (!toolName) return <Wrench className={`${base} text-[var(--color-text-muted)]`} />;
if (toolName.startsWith('github-')) {
return <Github className={`${base} text-[var(--color-text-secondary)]`} />;
}
switch (toolName) {
case 'view':
return <Eye className={`${base} text-[var(--color-accent-sky)]`} />;
case 'grep':
case 'glob':
return <Search className={`${base} text-[var(--color-accent-purple)]`} />;
case 'lsp':
return <FileSearch className={`${base} text-[var(--color-accent-purple)]`} />;
case 'edit':
case 'create':
return <Pencil className={`${base} text-[var(--color-status-warning)]`} />;
case 'powershell':
return <Terminal className={`${base} text-[var(--color-text-secondary)]`} />;
case 'web_fetch':
case 'web_search':
return <ExternalLink className={`${base} text-[var(--color-accent-sky)]`} />;
case 'sql':
return <Database className={`${base} text-[var(--color-accent-purple)]`} />;
case 'task':
return <Users className={`${base} text-[var(--color-accent-sky)]`} />;
default:
return <Wrench className={`${base} text-[var(--color-text-muted)]`} />;
}
}
/* ── Event icon (for non-tool events) ──────────────────────── */
function ActivityEventIcon({ kind, status, toolName }: {
kind: RunTimelineEventRecord['kind'];
status: RunTimelineEventRecord['status'];
toolName?: string;
}) {
const base = 'size-3 shrink-0';
switch (kind) {
case 'tool-call':
return <Wrench className={`${base} text-[var(--color-accent-purple)]`} />;
return <ToolCategoryIcon toolName={toolName} className={base} />;
case 'approval':
return (
<ShieldAlert
@@ -135,19 +146,19 @@ function ActivityEventIcon({ kind, status }: { kind: RunTimelineEventRecord['kin
}
}
/* ── Activity event row ────────────────────────────────────── */
/* ── Single event row ──────────────────────────────────────── */
function ActivityTimelineEventRow({ event }: { event: RunTimelineEventRecord }) {
const label = formatEventLabel(event);
const isTerminal = event.kind === 'run-completed' || event.kind === 'run-cancelled' || event.kind === 'run-failed';
return (
<div className="turn-activity-row flex gap-2 py-1">
<div className="mt-0.5 flex shrink-0 items-start">
<ActivityEventIcon kind={event.kind} status={event.status} />
<div className="turn-activity-row flex items-start gap-2 py-1">
<div className="flex h-[18px] w-4 shrink-0 items-center justify-center">
<ActivityEventIcon kind={event.kind} status={event.status} toolName={event.toolName} />
</div>
<div className="min-w-0 flex-1">
<span className={`text-[12px] font-medium ${isTerminal ? 'text-[var(--color-text-muted)]' : 'text-[var(--color-text-secondary)]'}`}>
<span className={`text-[12px] leading-[18px] font-medium ${isTerminal ? 'text-[var(--color-text-muted)]' : 'text-[var(--color-text-secondary)]'}`}>
{label}
</span>
@@ -203,6 +214,106 @@ function ActivityTimelineEventRow({ event }: { event: RunTimelineEventRecord })
);
}
/* ── Grouped tool-call row ─────────────────────────────────── */
function GroupedToolCallRow({ toolName, events }: { toolName: string; events: RunTimelineEventRecord[] }) {
const [expanded, setExpanded] = useState(false);
const label = formatToolGroupLabel(toolName, events.length);
const snippets = useMemo(
() => events.map((e) => extractToolCallSnippet(toolName, e.toolArguments)).filter(Boolean) as string[],
[toolName, events],
);
const hasFileChanges = events.some((e) => e.fileChanges && e.fileChanges.length > 0);
return (
<div className="turn-activity-row py-0.5">
<button
type="button"
className="flex w-full items-start gap-2 rounded px-1 -mx-1 py-1 text-left transition-colors hover:bg-[var(--color-surface-2)]/30"
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
>
<div className="flex h-[18px] w-4 shrink-0 items-center justify-center">
<ToolCategoryIcon toolName={toolName} />
</div>
<span className="min-w-0 flex-1 text-[12px] leading-[18px] font-medium text-[var(--color-text-secondary)]">
{label}
</span>
<ChevronRight
className={`mt-0.5 size-3 shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${
expanded ? 'rotate-90' : ''
}`}
/>
</button>
{/* Collapsed preview: show snippets inline */}
{!expanded && snippets.length > 0 && (
<div className="ml-6 flex flex-wrap gap-x-2 gap-y-0.5 pb-0.5">
{snippets.slice(0, 6).map((s, i) => (
<span key={i} className="truncate font-mono text-[10px] text-[var(--color-text-muted)]">
{s}
</span>
))}
{snippets.length > 6 && (
<span className="text-[10px] text-[var(--color-text-muted)]">
+{snippets.length - 6} more
</span>
)}
</div>
)}
{/* Expanded: full per-event rows */}
{expanded && (
<div className="ml-6 border-l border-[var(--color-border)]/30 pl-2">
{events.map((event) => (
<div key={event.id} className="py-0.5">
<span className="text-[11px] text-[var(--color-text-secondary)]">
{formatToolCallPrimaryLabel(event.toolName, event.toolArguments)}
</span>
<ToolCallDetailPanel toolName={event.toolName} toolArguments={event.toolArguments} />
{event.fileChanges && event.fileChanges.length > 0 && (
<div className="mt-0.5">
<FileChangePreview fileChanges={event.fileChanges} />
</div>
)}
</div>
))}
</div>
)}
{/* Aggregate file changes when collapsed */}
{!expanded && hasFileChanges && (
<div className="ml-6 mt-0.5">
{events
.filter((e) => e.fileChanges && e.fileChanges.length > 0)
.flatMap((e) => e.fileChanges!)
.length > 0 && (
<FileChangePreview
fileChanges={events.flatMap((e) => e.fileChanges ?? [])}
/>
)}
</div>
)}
</div>
);
}
/* ── Intent divider ────────────────────────────────────────── */
function IntentDividerRow({ text }: { text: string }) {
return (
<div className="turn-activity-row flex items-center gap-2 py-2" role="separator">
<div className="h-px flex-1 bg-[var(--color-border)]/40" />
<span className="shrink-0 rounded-full bg-[var(--color-surface-2)]/60 px-2 py-0.5 text-[10px] font-semibold tracking-wide text-[var(--color-text-muted)]">
{text}
</span>
<div className="h-px flex-1 bg-[var(--color-border)]/40" />
</div>
);
}
/* ── Thinking step row ─────────────────────────────────────── */
function ThinkingStepRow({ message }: { message: ChatMessageRecord }) {
@@ -211,22 +322,66 @@ function ThinkingStepRow({ message }: { message: ChatMessageRecord }) {
if (message.pending && !message.content) return null;
return (
<div className="turn-activity-row flex gap-2 py-1">
<div className="mt-0.5 flex shrink-0 items-start">
<div className="turn-activity-row flex items-start gap-2 py-1">
<div className="flex h-[18px] w-4 shrink-0 items-center justify-center">
<Brain className="size-3 text-[var(--color-accent-purple)]" />
</div>
<div className="min-w-0 flex-1">
{message.authorName && (
<span className="mr-1.5 text-[12px] font-medium text-[var(--color-text-secondary)]">
{message.authorName}
</span>
)}
<span className="text-[12px] text-[var(--color-text-muted)]">{preview}</span>
<p className="border-l-2 border-[var(--color-accent-purple)]/20 pl-2 text-[11px] italic leading-snug text-[var(--color-text-muted)]">
"{preview}"
</p>
</div>
</div>
);
}
/* ── Thinking group (multiple consecutive) ─────────────────── */
function ThinkingGroupRow({ messages }: { messages: ChatMessageRecord[] }) {
const [expanded, setExpanded] = useState(false);
const visibleMessages = messages.filter((m) => !m.pending || m.content);
if (visibleMessages.length === 0) return null;
const latest = visibleMessages[visibleMessages.length - 1];
const preview = truncatePreview(latest.content, 180);
const hiddenCount = visibleMessages.length - 1;
return (
<div className="turn-activity-row py-0.5">
<div className="flex items-start gap-2 py-1">
<div className="flex h-[18px] w-4 shrink-0 items-center justify-center">
<Brain className="size-3 text-[var(--color-accent-purple)]" />
</div>
<div className="min-w-0 flex-1">
<p className="border-l-2 border-[var(--color-accent-purple)]/20 pl-2 text-[11px] italic leading-snug text-[var(--color-text-muted)]">
"{preview}"
</p>
{hiddenCount > 0 && (
<button
type="button"
className="mt-0.5 pl-2 text-[10px] text-[var(--color-accent)] hover:underline"
onClick={() => setExpanded((prev) => !prev)}
>
{expanded ? 'Hide' : `${hiddenCount} earlier ${hiddenCount === 1 ? 'thought' : 'thoughts'}`}
</button>
)}
</div>
</div>
{expanded && (
<div className="ml-6 space-y-0.5 border-l border-[var(--color-border)]/30 pl-2">
{visibleMessages.slice(0, -1).map((msg) => (
<p key={msg.id} className="text-[10px] italic leading-snug text-[var(--color-text-muted)]">
"{truncatePreview(msg.content, 140)}"
</p>
))}
</div>
)}
</div>
);
}
/* ── Active pulse dots ─────────────────────────────────────── */
function ActivityPulse() {
@@ -239,6 +394,23 @@ function ActivityPulse() {
);
}
/* ── Grouped item renderer ─────────────────────────────────── */
function GroupedItemRow({ item }: { item: GroupedActivityItem }) {
switch (item.kind) {
case 'intent-divider':
return <IntentDividerRow text={item.intentText} />;
case 'single-event':
return <ActivityTimelineEventRow event={item.event} />;
case 'tool-group':
return <GroupedToolCallRow toolName={item.toolName} events={item.events} />;
case 'single-thinking':
return <ThinkingStepRow message={item.message} />;
case 'thinking-group':
return <ThinkingGroupRow messages={item.messages} />;
}
}
/* ── Main component ────────────────────────────────────────── */
export function TurnActivityPanel({
@@ -247,6 +419,7 @@ export function TurnActivityPanel({
isActive,
turnStartedAt,
sessionId,
currentIntent,
agentNames,
isLastRunPanel,
onDiscard,
@@ -255,8 +428,6 @@ export function TurnActivityPanel({
const [expanded, setExpanded] = useState(false);
const wasActiveRef = useRef(isActive);
// Auto-expand when the turn is active (run exists or thinking arrives).
// Auto-collapse once the turn finishes.
useEffect(() => {
if (isActive && (thinkingMessages.length > 0 || run)) {
setExpanded(true);
@@ -268,25 +439,20 @@ export function TurnActivityPanel({
const toggle = useCallback(() => setExpanded((prev) => !prev), []);
// When the run is shared across multiple panels (multi-agent sequential),
// scope events to only those belonging to this panel's agents.
const scopedEvents = useMemo(
() => filterEventsByAgent(run?.events ?? [], agentNames),
[run?.events, agentNames],
);
// Derive per-agent timing from the scoped events when agent names are set
// (multi-agent run). For single-agent runs, use the run-level start time.
const effectiveTurnStartedAt = useMemo(() => {
if (!agentNames || agentNames.size === 0 || scopedEvents.length === 0) {
return turnStartedAt;
}
// Use the earliest scoped event as the start time for this agent's panel.
let earliest = turnStartedAt;
for (const e of scopedEvents) {
if (!earliest || e.occurredAt < earliest) {
earliest = e.occurredAt;
break; // events are already in insertion order (chronological)
break;
}
}
return earliest;
@@ -295,6 +461,7 @@ export function TurnActivityPanel({
const elapsed = useElapsedTimer(
thinkingMessages.length > 0 || run ? effectiveTurnStartedAt : undefined,
isActive,
run?.completedAt,
);
const summary = useMemo(
@@ -302,12 +469,22 @@ export function TurnActivityPanel({
[thinkingMessages, scopedEvents],
);
const activityStream = useMemo(
() => buildActivityStream(thinkingMessages, scopedEvents),
[thinkingMessages, scopedEvents],
const groupedItems = useMemo(() => {
const stream = buildActivityStream(thinkingMessages, scopedEvents);
return groupActivityStream(stream);
}, [thinkingMessages, scopedEvents]);
// Prefer the session-level normalized intent when active; fall back to
// scanning run timeline for report_intent tool calls (backward compat).
const intentText = useMemo(
() => (isActive ? currentIntent : undefined) ?? extractLatestIntent(scopedEvents),
[isActive, currentIntent, scopedEvents],
);
const fallbackSummary = useMemo(
() => !intentText ? generateActivitySummary(scopedEvents) : undefined,
[intentText, scopedEvents],
);
// Nothing to show — no thinking messages, no run, and not active
if (thinkingMessages.length === 0 && !run) {
return null;
}
@@ -318,10 +495,8 @@ export function TurnActivityPanel({
const isFailed = runStatus === 'error';
const isCancelled = runStatus === 'cancelled';
const isTerminated = isCompleted || isFailed || isCancelled;
// Only show git summary and discard on the last panel for a given run
const showGitSummary = run && isTerminated && run.postRunGitSummary && onDiscard && (isLastRunPanel !== false);
// Build the summary label
let summaryLabel: string;
if (isActive) {
summaryLabel = 'Working';
@@ -335,22 +510,32 @@ export function TurnActivityPanel({
summaryLabel = 'Completed';
}
const headerDetail = intentText ?? fallbackSummary;
const statusColorClass = isFailed
? 'text-[var(--color-status-error)]'
: isCancelled
? 'text-[var(--color-text-muted)]'
: isActive
? 'text-[var(--color-text-secondary)]'
? 'text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)]';
const statusDotClass = isFailed
? 'bg-[var(--color-status-error)]'
: isCancelled
? 'bg-[var(--color-text-muted)]'
: isActive
? 'bg-[var(--color-accent)]'
: 'bg-[var(--color-status-success)]';
return (
<div
className={`turn-activity-enter overflow-hidden rounded-lg border bg-[var(--color-surface-1)]/60 transition-colors duration-200 ${
className={`turn-activity-enter overflow-hidden rounded-lg border transition-colors duration-200 ${
isActive
? 'border-[var(--color-accent)]/30'
? 'border-[var(--color-accent)]/30 bg-[var(--color-accent)]/[0.03]'
: isFailed
? 'border-[var(--color-status-error)]/20'
: 'border-[var(--color-border)]/50'
? 'border-[var(--color-status-error)]/20 bg-[var(--color-surface-1)]/60'
: 'border-[var(--color-border)]/50 bg-[var(--color-surface-1)]/60'
}`}
>
{/* Summary header */}
@@ -359,24 +544,35 @@ export function TurnActivityPanel({
onClick={toggle}
onKeyDown={(e) => { if (e.key === ' ') { e.preventDefault(); toggle(); } }}
aria-expanded={expanded}
className={`flex w-full items-center gap-2 px-3 py-2 text-left text-[12px] transition-colors hover:bg-[var(--color-surface-2)]/50 ${
isActive ? 'bg-[var(--color-accent)]/[0.04]' : ''
}`}
className="flex w-full items-center gap-2.5 px-3 py-2 text-left transition-colors hover:bg-[var(--color-surface-2)]/50"
>
<Zap className={`size-3.5 shrink-0 ${isActive ? 'text-[var(--color-accent)]' : 'text-[var(--color-text-muted)]'}`} />
{/* Status dot */}
<span className={`size-2 shrink-0 rounded-full ${statusDotClass} ${isActive ? 'animate-pulse' : ''}`} />
{isActive ? (
<span className="flex items-center gap-1.5">
<span className={statusColorClass}>{summaryLabel}</span>
<ActivityPulse />
{/* Status label + elapsed */}
<span className="flex items-center gap-1.5">
<span className={`text-[12px] font-semibold ${statusColorClass}`}>
{summaryLabel}
</span>
{isActive && <ActivityPulse />}
{elapsed && (
<span className="tabular-nums text-[11px] text-[var(--color-text-muted)]">
{isActive ? elapsed : ''}
</span>
)}
</span>
{/* Intent / generated summary */}
{headerDetail && (
<span className="min-w-0 truncate text-[11px] text-[var(--color-text-muted)]">
{'· '}
{intentText ? `"${headerDetail}"` : headerDetail}
</span>
) : (
<span className={statusColorClass}>{summaryLabel}</span>
)}
{/* Inline counters */}
{summaryParts.length > 0 && (
<span className="font-mono text-[10px] text-[var(--color-text-muted)]">
<span className="shrink-0 tabular-nums text-[10px] text-[var(--color-text-muted)]">
{'· '}
{summaryParts.join(' · ')}
</span>
@@ -389,16 +585,13 @@ export function TurnActivityPanel({
</span>
</button>
{/* Expanded activity stream */}
{/* Expanded activity stream — grouped */}
{expanded && (
<div className="border-t border-[var(--color-border)]/30 px-3 py-2">
<div className="space-y-0.5">
{activityStream.map((item) => {
if (item.kind === 'thinking-step') {
return <ThinkingStepRow key={item.message.id} message={item.message} />;
}
return <ActivityTimelineEventRow key={item.event.id} event={item.event} />;
})}
<div className="activity-timeline-spine relative space-y-px">
{groupedItems.map((item, index) => (
<GroupedItemRow key={index} item={item} />
))}
</div>
{/* Post-run git changes */}
@@ -0,0 +1,265 @@
import { useCallback, useEffect, useRef, useMemo, useState } from 'react';
import { Check, Brain } from 'lucide-react';
import { ProviderIcon } from '@renderer/components/ProviderIcons';
import type { ModelDefinition, ModelProvider } from '@shared/domain/models';
import { providerMeta } from '@shared/domain/models';
import type { ReasoningEffort } from '@shared/domain/workflow';
interface ModelSelectorProps {
models: ReadonlyArray<ModelDefinition>;
selectedModelId?: string;
selectedReasoning?: ReasoningEffort;
onSelect: (model: ModelDefinition) => void;
onReasoningChange: (effort: ReasoningEffort | undefined) => void;
onClose: () => void;
}
const reasoningLevels: { value: ReasoningEffort; label: string; description: string }[] = [
{ value: 'low', label: 'Low', description: 'Fast, concise' },
{ value: 'medium', label: 'Med', description: 'Balanced' },
{ value: 'high', label: 'High', description: 'Thorough' },
{ value: 'xhigh', label: 'Max', description: 'Exhaustive' },
];
interface ProviderGroup {
provider: ModelProvider | 'other';
label: string;
models: ModelDefinition[];
}
export function ModelSelector({
models,
selectedModelId,
selectedReasoning,
onSelect,
onReasoningChange,
onClose,
}: ModelSelectorProps) {
const containerRef = useRef<HTMLDivElement>(null);
const flatModels = useMemo(() => [...models], [models]);
const initialIndex = flatModels.findIndex((m) => m.id === selectedModelId);
const [focusedIndex, setFocusedIndex] = useState(initialIndex >= 0 ? initialIndex : 0);
const optionRefs = useRef<Map<number, HTMLButtonElement>>(new Map());
// Scroll the focused option into view whenever it changes
useEffect(() => {
optionRefs.current.get(focusedIndex)?.scrollIntoView({ block: 'nearest' });
}, [focusedIndex]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
switch (e.key) {
case 'ArrowDown': {
e.preventDefault();
setFocusedIndex((prev) => (prev + 1) % flatModels.length);
break;
}
case 'ArrowUp': {
e.preventDefault();
setFocusedIndex((prev) => (prev - 1 + flatModels.length) % flatModels.length);
break;
}
case 'Home': {
e.preventDefault();
setFocusedIndex(0);
break;
}
case 'End': {
e.preventDefault();
setFocusedIndex(flatModels.length - 1);
break;
}
case 'Enter':
case ' ': {
e.preventDefault();
const model = flatModels[focusedIndex];
if (model) onSelect(model);
break;
}
case 'Escape': {
e.stopPropagation();
onClose();
break;
}
}
},
[flatModels, focusedIndex, onSelect, onClose],
);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
};
}, [onClose]);
// Focus the container on mount so keyboard events are captured immediately
useEffect(() => {
containerRef.current?.focus();
}, []);
const selectedModel = models.find((m) => m.id === selectedModelId);
const supportedEfforts = selectedModel?.supportedReasoningEfforts;
// Group models by provider
const groups = useMemo((): ProviderGroup[] => {
const providerOrder = providerMeta.map((p) => p.id);
const providerLabels = new Map(providerMeta.map((p) => [p.id, p.label]));
const grouped = new Map<string, ModelDefinition[]>();
for (const model of models) {
const key = model.provider ?? 'other';
const list = grouped.get(key) ?? [];
list.push(model);
grouped.set(key, list);
}
const result: ProviderGroup[] = [];
for (const providerId of providerOrder) {
const providerModels = grouped.get(providerId);
if (providerModels) {
result.push({
provider: providerId,
label: providerLabels.get(providerId) ?? providerId,
models: providerModels,
});
}
}
// Any models without a known provider
const other = grouped.get('other');
if (other) {
result.push({ provider: 'other', label: 'Other', models: other });
}
return result;
}, [models]);
// Build a flat index for each model so we can map group-based rendering
// back to the flat focusedIndex.
let flatIndex = -1;
return (
<div
ref={containerRef}
className="qp-dropdown-enter absolute top-full left-3 right-3 z-10 mt-1 overflow-hidden rounded-xl border border-[var(--color-border)] bg-[var(--color-surface-1)] outline-none"
role="listbox"
aria-label="Select model"
aria-activedescendant={flatModels[focusedIndex] ? `model-option-${flatModels[focusedIndex].id}` : undefined}
tabIndex={0}
onKeyDown={handleKeyDown}
>
{/* Model list — grouped by provider */}
<div className="max-h-[280px] overflow-y-auto overscroll-contain p-1.5">
{groups.map((group, gi) => (
<div key={group.provider}>
{gi > 0 && <div className="mx-2 my-1 border-t border-[var(--color-border-subtle)]/50" />}
{/* Provider header */}
<div className="flex items-center gap-1.5 px-2.5 pt-2 pb-1">
{group.provider !== 'other' && (
<ProviderIcon provider={group.provider} className="size-3" />
)}
<span className="text-[10px] font-semibold tracking-wider text-[var(--color-text-muted)] uppercase">
{group.label}
</span>
</div>
{/* Models in this provider group */}
{group.models.map((model) => {
flatIndex++;
const modelIndex = flatIndex;
const isSelected = model.id === selectedModelId;
const isFocused = modelIndex === focusedIndex;
const tierLabel = model.tier === 'premium' ? 'PRO' : model.tier === 'fast' ? 'FAST' : undefined;
const tierColor = model.tier === 'premium'
? 'text-amber-400 bg-amber-400/10'
: model.tier === 'fast'
? 'text-emerald-400 bg-emerald-400/10'
: '';
return (
<button
key={model.id}
id={`model-option-${model.id}`}
ref={(el) => {
if (el) optionRefs.current.set(modelIndex, el);
else optionRefs.current.delete(modelIndex);
}}
onClick={() => onSelect(model)}
onMouseEnter={() => setFocusedIndex(modelIndex)}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-[7px] text-left transition-colors ${
isFocused
? 'bg-[var(--color-surface-2)] text-[var(--color-text-primary)]'
: isSelected
? 'bg-[var(--color-accent-muted)] text-[var(--color-text-primary)]'
: 'text-[var(--color-text-secondary)]'
}`}
type="button"
role="option"
aria-selected={isSelected}
tabIndex={-1}
>
<span className="flex-1 truncate text-[12px] font-medium">{model.name}</span>
{tierLabel && (
<span className={`rounded-[4px] px-1.5 py-px text-[8px] font-bold tracking-wider ${tierColor}`}>
{tierLabel}
</span>
)}
{model.supportedReasoningEfforts?.length ? (
<Brain className="size-3 flex-none text-[var(--color-text-muted)]/60" aria-label="Supports reasoning" />
) : null}
{isSelected && <Check className="size-3.5 flex-none text-[var(--color-accent)]" />}
</button>
);
})}
</div>
))}
</div>
{/* Reasoning effort — only shown when selected model supports it */}
{supportedEfforts && supportedEfforts.length > 0 && (
<div className="border-t border-[var(--color-border-subtle)] px-3 py-2.5">
<div className="mb-2 flex items-center gap-1.5">
<Brain className="size-3 text-[var(--color-text-muted)]" />
<span className="text-[10px] font-semibold tracking-wider text-[var(--color-text-muted)] uppercase">
Reasoning Effort
</span>
</div>
<div className="flex gap-1">
{reasoningLevels
.filter((lvl) => supportedEfforts.includes(lvl.value))
.map((lvl) => {
const isActive = selectedReasoning === lvl.value;
return (
<button
key={lvl.value}
onClick={() => onReasoningChange(isActive ? undefined : lvl.value)}
className={`group flex-1 rounded-lg py-1.5 text-center transition-all ${
isActive
? 'bg-[var(--color-accent)] text-white shadow-md shadow-[var(--color-accent)]/20'
: 'bg-[var(--color-surface-2)] text-[var(--color-text-muted)] hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]'
}`}
type="button"
title={lvl.description}
>
<span className="text-[11px] font-semibold">{lvl.label}</span>
</button>
);
})}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,44 @@
import { ArrowRight, Trash2, X } from 'lucide-react';
interface QuickPromptActionsProps {
onDiscard: () => void;
onClose: () => void;
onContinueInAryx: () => void;
}
export function QuickPromptActions({ onDiscard, onClose, onContinueInAryx }: QuickPromptActionsProps) {
return (
<div className="qp-actions-enter flex items-center gap-1.5 border-t border-[var(--color-border-subtle)]/60 px-4 py-2.5">
<button
onClick={onDiscard}
className="flex items-center gap-1.5 rounded-lg px-3 py-[6px] text-[11px] font-medium text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-status-error)]/8 hover:text-[var(--color-status-error)]"
type="button"
title="Delete this session permanently"
>
<Trash2 className="size-3" />
Discard
</button>
<button
onClick={onClose}
className="flex items-center gap-1.5 rounded-lg px-3 py-[6px] text-[11px] font-medium text-[var(--color-text-muted)] transition-colors hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-primary)]"
type="button"
title="Close and keep session for later"
>
<X className="size-3" />
Close
</button>
<div className="flex-1" />
<button
onClick={onContinueInAryx}
className="brand-gradient-bg flex items-center gap-1.5 rounded-lg px-4 py-[6px] text-[11px] font-semibold text-white shadow-sm shadow-[var(--color-accent)]/15 transition-all hover:shadow-md hover:shadow-[var(--color-accent)]/25 hover:brightness-110"
type="button"
>
Continue in Aryx
<ArrowRight className="size-3" />
</button>
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More