Compare commits

...
19 Commits
Author SHA1 Message Date
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
49 changed files with 3537 additions and 418 deletions
+25 -7
View File
@@ -55,8 +55,8 @@ flowchart LR
| --- | --- | --- | --- | | --- | --- | --- | --- |
| Renderer | Screens, interaction, local view composition, theme application | Filesystem, process spawning, raw Electron access, Copilot runtime | Typed preload API and pushed events | | 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` | | 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 | | 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, streaming deltas and activity | UI, workspace persistence, Electron APIs | Line-delimited JSON over stdio | | Sidecar | Capability discovery, workflow validation, run execution, provider event normalization, streaming deltas and activity, streamed text assembly | 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 | | External systems | Git data, Copilot account/model access, 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. This split is the most important architectural feature in the app. It is what keeps the system understandable as more capabilities are added.
@@ -143,7 +143,9 @@ Their runtime semantics follow the Agent Framework orchestration model: sequenti
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. 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. 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. Workflows are shared application data, not renderer-only configuration. The same workflow definition now drives validation, persistence, session execution, and sidecar orchestration.
@@ -216,7 +218,7 @@ This is a structured stdio protocol used for:
- streaming partial output - streaming partial output
- streaming agent activity - 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. On the sidecar side, raw provider events are first normalized into sidecar-owned provider event records before they become streamed run activity, so future providers can plug into the same transport without reshaping the main-process contract. 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: 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:
@@ -225,7 +227,7 @@ The protocol also carries **turn-scoped lifecycle events** alongside output delt
- **Sub-agent events**: started, completed, failed, selected, deselected — surfaced when custom agents are defined - **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 - **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 - **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 - **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 - **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 - **Session compaction events**: start and complete, with token-reduction metrics when infinite sessions trigger context trimming
@@ -350,7 +352,8 @@ That gives the system:
The main process owns desktop concerns such as: 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 - title bar behavior
- background process management - background process management
- filesystem access - filesystem access
@@ -358,13 +361,28 @@ The main process owns desktop concerns such as:
This keeps those concerns out of the renderer while still letting the UI feel native. 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 ## Build and release architecture
Aryx ships as an Electron application bundled together with a self-contained .NET sidecar. Aryx ships as an Electron application bundled together with a self-contained .NET sidecar.
The build pipeline is organized around three layers: 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 - publishing the sidecar for the target runtime
- packaging platform artifacts with electron-builder - packaging platform artifacts with electron-builder
+1
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 | | Message actions | Copy, pin, edit-and-resend, and regenerate individual messages |
| Bookmarks | Browse all pinned messages across sessions in one panel (`Ctrl+Shift+B`) | | 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 | | 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 | | Desktop notifications | Native OS alerts when runs complete, fail, or need approval |
| Onboarding | First-launch walkthrough, interactive tooltips, and a "try it" quickstart | | Onboarding | First-launch walkthrough, interactive tooltips, and a "try it" quickstart |
+12
View File
@@ -20,6 +20,12 @@ export default defineConfig({
preload: { preload: {
build: { build: {
outDir: 'dist-electron/preload', outDir: 'dist-electron/preload',
rollupOptions: {
input: {
index: resolve(__dirname, 'src/preload/index.ts'),
quickprompt: resolve(__dirname, 'src/preload/quickprompt.ts'),
},
},
}, },
plugins: [externalizeDepsPlugin()], plugins: [externalizeDepsPlugin()],
resolve: { resolve: {
@@ -32,6 +38,12 @@ export default defineConfig({
root: 'src/renderer', root: 'src/renderer',
build: { build: {
outDir: 'dist/renderer', outDir: 'dist/renderer',
rollupOptions: {
input: {
index: resolve(__dirname, 'src/renderer/index.html'),
quickprompt: resolve(__dirname, 'src/renderer/quickprompt.html'),
},
},
}, },
plugins: [react(), tailwindcss()], plugins: [react(), tailwindcss()],
resolve: { resolve: {
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "aryx", "name": "aryx",
"version": "0.0.24", "version": "0.0.25",
"description": "Orchestrator for Copilot-powered agent workflows across multiple projects.", "description": "Orchestrator for Copilot-powered agent workflows across multiple projects.",
"private": true, "private": true,
"main": "dist-electron/main/index.js", "main": "dist-electron/main/index.js",
@@ -2,10 +2,13 @@ namespace Aryx.AgentHost.Contracts;
internal abstract record ProviderSessionEvent; internal abstract record ProviderSessionEvent;
internal sealed record ProviderAssistantMessageDeltaEvent(string MessageId) : ProviderSessionEvent; internal sealed record ProviderAssistantMessageDeltaEvent(
string MessageId,
string? DeltaContent) : ProviderSessionEvent;
internal sealed record ProviderAssistantMessageEvent( internal sealed record ProviderAssistantMessageEvent(
string MessageId, string MessageId,
string? Content,
bool HasToolRequests) : ProviderSessionEvent; bool HasToolRequests) : ProviderSessionEvent;
internal sealed record ProviderToolExecutionStartEvent( internal sealed record ProviderToolExecutionStartEvent(
@@ -13,12 +16,35 @@ internal sealed record ProviderToolExecutionStartEvent(
string ToolName, string ToolName,
IReadOnlyDictionary<string, object?>? ToolArguments) : ProviderSessionEvent; 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 ProviderAssistantIntentEvent(string? Intent) : ProviderSessionEvent;
internal sealed record ProviderAssistantReasoningDeltaEvent( internal sealed record ProviderAssistantReasoningDeltaEvent(
string? ReasoningId, string? ReasoningId,
string? DeltaContent) : ProviderSessionEvent; 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( internal sealed record ProviderSubagentStartedEvent(
string? ToolCallId, string? ToolCallId,
string? AgentName, string? AgentName,
@@ -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; }
}
@@ -100,7 +100,7 @@ public class AgentWorkflowTurnRunner : ITurnWorkflowRunner
onDelta, onDelta,
onEvent) onEvent)
.ConfigureAwait(false); .ConfigureAwait(false);
await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false); await EmitPendingEventsAsync(state, onDelta, onEvent).ConfigureAwait(false);
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false); await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
if (shouldEndTurn) if (shouldEndTurn)
{ {
@@ -108,13 +108,13 @@ public class AgentWorkflowTurnRunner : ITurnWorkflowRunner
} }
} }
await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false); await EmitPendingEventsAsync(state, onDelta, onEvent).ConfigureAwait(false);
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false); await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
return state.FinalizeCompletedMessages(transcriptProjector); return state.FinalizeCompletedMessages(transcriptProjector);
} }
catch (OperationCanceledException) when (runCancellation.IsCancellationRequested && !cancellationToken.IsCancellationRequested) 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); await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
ExitPlanModeRequestedEventDto? exitPlanModeEvent = ExitPlanModeRequestedEventDto? exitPlanModeEvent =
_providerTurnSupport.ConsumePendingExitPlanModeRequest(command.RequestId); _providerTurnSupport.ConsumePendingExitPlanModeRequest(command.RequestId);
@@ -230,10 +230,17 @@ public class AgentWorkflowTurnRunner : ITurnWorkflowRunner
private static async Task EmitPendingEventsAsync( private static async Task EmitPendingEventsAsync(
TurnExecutionState state, TurnExecutionState state,
Func<TurnDeltaEventDto, Task> onDelta,
Func<SidecarEventDto, Task> onEvent) Func<SidecarEventDto, Task> onEvent)
{ {
foreach (SidecarEventDto pendingEvent in state.DrainPendingEvents()) foreach (SidecarEventDto pendingEvent in state.DrainPendingEvents())
{ {
if (pendingEvent is TurnDeltaEventDto delta)
{
await onDelta(delta).ConfigureAwait(false);
continue;
}
await onEvent(pendingEvent).ConfigureAwait(false); await onEvent(pendingEvent).ConfigureAwait(false);
} }
} }
@@ -326,8 +333,7 @@ public class AgentWorkflowTurnRunner : ITurnWorkflowRunner
command, command,
requestInfo, requestInfo,
state.ActiveAgent, state.ActiveAgent,
state.ToolNamesByCallId, state.ToolCalls);
state.ToolCallHasArgumentsById);
if (activity is null) if (activity is null)
{ {
@@ -524,10 +530,14 @@ public class AgentWorkflowTurnRunner : ITurnWorkflowRunner
} }
string messageId = state.CreateMessageId(update.Update.MessageId); string messageId = state.CreateMessageId(update.Update.MessageId);
(string _, string currentAuthorName, string currentContent) = state.AppendDelta( if (!state.TryAppendDelta(
messageId, messageId,
authorName, authorName,
update.Update.Text); update.Update.Text,
out TranscriptSegment currentSegment))
{
return;
}
await onDelta(new TurnDeltaEventDto await onDelta(new TurnDeltaEventDto
{ {
@@ -535,9 +545,9 @@ public class AgentWorkflowTurnRunner : ITurnWorkflowRunner
RequestId = command.RequestId, RequestId = command.RequestId,
SessionId = command.SessionId, SessionId = command.SessionId,
MessageId = messageId, MessageId = messageId,
AuthorName = currentAuthorName, AuthorName = currentSegment.AuthorName,
ContentDelta = update.Update.Text, ContentDelta = update.Update.Text,
Content = currentContent, Content = currentSegment.Content,
}).ConfigureAwait(false); }).ConfigureAwait(false);
} }
@@ -4,5 +4,7 @@ namespace Aryx.AgentHost.Services;
internal interface IProviderEventAdapter internal interface IProviderEventAdapter
{ {
ProviderTurnStreamCapabilities Capabilities { get; }
ProviderSessionEvent? TryAdapt(object rawEvent); ProviderSessionEvent? TryAdapt(object rawEvent);
} }
@@ -70,7 +70,7 @@ internal sealed class CopilotApprovalCoordinator
WorkflowNodeDto agent, WorkflowNodeDto agent,
PermissionRequest request, PermissionRequest request,
PermissionInvocation invocation, PermissionInvocation invocation,
IReadOnlyDictionary<string, string> toolNamesByCallId, ToolCallRegistry toolCalls,
Func<ApprovalRequestedEventDto, Task> onApproval, Func<ApprovalRequestedEventDto, Task> onApproval,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
@@ -79,7 +79,7 @@ internal sealed class CopilotApprovalCoordinator
agent, agent,
request, request,
invocation, invocation,
toolNamesByCallId, toolCalls,
onActivity: null, onActivity: null,
onApproval, onApproval,
cancellationToken) cancellationToken)
@@ -91,12 +91,12 @@ internal sealed class CopilotApprovalCoordinator
WorkflowNodeDto agent, WorkflowNodeDto agent,
PermissionRequest request, PermissionRequest request,
PermissionInvocation invocation, PermissionInvocation invocation,
IReadOnlyDictionary<string, string> toolNamesByCallId, ToolCallRegistry toolCalls,
Func<AgentActivityEventDto, Task>? onActivity, Func<AgentActivityEventDto, Task>? onActivity,
Func<ApprovalRequestedEventDto, Task> onApproval, Func<ApprovalRequestedEventDto, Task> onApproval,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
string? toolName = ResolveApprovalToolName(request, toolNamesByCallId); string? toolName = ResolveApprovalToolName(request, toolCalls);
string? autoApprovedToolName = ResolveAutoApprovedToolName(request); string? autoApprovedToolName = ResolveAutoApprovedToolName(request);
string? mcpServerApprovalKey = ResolveMcpServerApprovalKey(request, command.Tooling?.McpServers); string? mcpServerApprovalKey = ResolveMcpServerApprovalKey(request, command.Tooling?.McpServers);
string? approvalCacheKey = ResolveApprovalCacheKey(toolName, autoApprovedToolName); string? approvalCacheKey = ResolveApprovalCacheKey(toolName, autoApprovedToolName);
@@ -339,15 +339,15 @@ internal sealed class CopilotApprovalCoordinator
internal static bool TryGetApprovalToolName( internal static bool TryGetApprovalToolName(
PermissionRequest request, PermissionRequest request,
IReadOnlyDictionary<string, string>? toolNamesByCallId, ToolCallRegistry? toolCalls,
out string? toolName) out string? toolName)
{ {
toolName = ResolveApprovalToolName(request, toolNamesByCallId); toolName = ResolveApprovalToolName(request, toolCalls);
return toolName is not null; return toolName is not null;
} }
internal static bool TryGetApprovalToolName(PermissionRequest request, out string? toolName) 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) internal void ClearRequestApprovals(string requestId)
{ {
@@ -404,10 +404,10 @@ internal sealed class CopilotApprovalCoordinator
private static string? ResolveApprovalToolName( private static string? ResolveApprovalToolName(
PermissionRequest request, PermissionRequest request,
IReadOnlyDictionary<string, string>? toolNamesByCallId) ToolCallRegistry? toolCalls)
{ {
return GetDirectToolName(request) return GetDirectToolName(request)
?? ResolveToolNameFromLookup(request, toolNamesByCallId) ?? ResolveToolNameFromLookup(request, toolCalls)
?? GetFallbackToolName(request); ?? GetFallbackToolName(request);
} }
@@ -480,16 +480,16 @@ internal sealed class CopilotApprovalCoordinator
private static string? ResolveToolNameFromLookup( private static string? ResolveToolNameFromLookup(
PermissionRequest request, PermissionRequest request,
IReadOnlyDictionary<string, string>? toolNamesByCallId) ToolCallRegistry? toolCalls)
{ {
if (toolNamesByCallId is null) if (toolCalls is null)
{ {
return null; return null;
} }
string? toolCallId = GetToolCallId(request); string? toolCallId = GetToolCallId(request);
if (toolCallId is null if (toolCallId is null
|| !toolNamesByCallId.TryGetValue(toolCallId, out string? resolvedToolName)) || !toolCalls.TryGetToolName(toolCallId, out string? resolvedToolName))
{ {
return null; return null;
} }
@@ -5,18 +5,34 @@ namespace Aryx.AgentHost.Services;
internal sealed class CopilotEventAdapter : IProviderEventAdapter 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) public ProviderSessionEvent? TryAdapt(object rawEvent)
{ {
return rawEvent switch return rawEvent switch
{ {
AssistantMessageDeltaEvent messageDelta AssistantMessageDeltaEvent messageDelta
when NormalizeRequiredString(messageDelta.Data?.MessageId) is { } messageId => when NormalizeRequiredString(messageDelta.Data?.MessageId) is { } messageId =>
new ProviderAssistantMessageDeltaEvent(messageId), new ProviderAssistantMessageDeltaEvent(messageId, messageDelta.Data?.DeltaContent),
AssistantMessageEvent assistantMessage AssistantMessageEvent assistantMessage
when NormalizeRequiredString(assistantMessage.Data?.MessageId) is { } messageId => when NormalizeRequiredString(assistantMessage.Data?.MessageId) is { } messageId =>
new ProviderAssistantMessageEvent( new ProviderAssistantMessageEvent(
messageId, messageId,
assistantMessage.Data?.Content,
assistantMessage.Data?.ToolRequests is { Length: > 0 }), assistantMessage.Data?.ToolRequests is { Length: > 0 }),
ToolExecutionStartEvent toolExecutionStart ToolExecutionStartEvent toolExecutionStart
@@ -27,6 +43,27 @@ internal sealed class CopilotEventAdapter : IProviderEventAdapter
toolName, toolName,
WorkflowRequestInfoInterpreter.NormalizeRawToolArguments(toolExecutionStart.Data?.Arguments)), 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 => AssistantIntentEvent intentEvent =>
new ProviderAssistantIntentEvent(NormalizeOptionalString(intentEvent.Data?.Intent)), new ProviderAssistantIntentEvent(NormalizeOptionalString(intentEvent.Data?.Intent)),
@@ -35,6 +72,17 @@ internal sealed class CopilotEventAdapter : IProviderEventAdapter
NormalizeOptionalString(reasoningDelta.Data?.ReasoningId), NormalizeOptionalString(reasoningDelta.Data?.ReasoningId),
reasoningDelta.Data?.DeltaContent), 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 => SubagentStartedEvent started =>
new ProviderSubagentStartedEvent( new ProviderSubagentStartedEvent(
started.Data?.ToolCallId, started.Data?.ToolCallId,
@@ -20,6 +20,8 @@ internal sealed class CopilotTurnRunnerSupport : IProviderTurnSupport
CancellationTokenSource runCancellation, CancellationTokenSource runCancellation,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
state.SetStreamCapabilities(_providerEventAdapter.Capabilities);
return await CopilotAgentBundle.CreateAsync( return await CopilotAgentBundle.CreateAsync(
command, command,
(agent, request, invocation) => _approvalCoordinator.RequestApprovalAsync( (agent, request, invocation) => _approvalCoordinator.RequestApprovalAsync(
@@ -27,7 +29,7 @@ internal sealed class CopilotTurnRunnerSupport : IProviderTurnSupport
agent, agent,
request, request,
invocation, invocation,
state.ToolNamesByCallId, state.ToolCalls,
activity => AgentWorkflowTurnRunner.EmitActivityAsync(command, state, activity, onEvent), activity => AgentWorkflowTurnRunner.EmitActivityAsync(command, state, activity, onEvent),
onApproval, onApproval,
runCancellation.Token), runCancellation.Token),
@@ -1,16 +1,9 @@
using System.Text;
using Aryx.AgentHost.Contracts; using Aryx.AgentHost.Contracts;
using GitHub.Copilot.SDK; using GitHub.Copilot.SDK;
using Microsoft.Extensions.AI; using Microsoft.Extensions.AI;
namespace Aryx.AgentHost.Services; 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 internal static class WorkflowTranscriptProjector
{ {
public static ChatMessage ToChatMessage(ChatMessageDto message) public static ChatMessage ToChatMessage(ChatMessageDto message)
@@ -146,10 +139,16 @@ internal static class WorkflowTranscriptProjector
ChatMessage message, ChatMessage message,
TranscriptSegment? matchedSegment) TranscriptSegment? matchedSegment)
{ {
if (matchedSegment is { IsFinalized: true } finalizedSegment
&& !string.IsNullOrWhiteSpace(finalizedSegment.Content))
{
return finalizedSegment.Content;
}
return FirstNonBlank( return FirstNonBlank(
message.Text, message.Text,
matchedSegment?.Content, TryGetAssistantMessageContent(message),
TryGetAssistantMessageContent(message)) matchedSegment?.Content)
?? string.Empty; ?? string.Empty;
} }
@@ -227,6 +226,16 @@ internal static class WorkflowTranscriptProjector
return null; 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; string? messageText = string.IsNullOrWhiteSpace(message.Text) ? null : message.Text;
if (messageText is not null) if (messageText is not null)
{ {
@@ -445,70 +454,3 @@ internal static class WorkflowTranscriptProjector
return null; 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());
}
}
}
@@ -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();
}
}
@@ -1,4 +1,5 @@
using System.Collections.Concurrent; using System.Collections.Concurrent;
using System.Diagnostics.CodeAnalysis;
using Aryx.AgentHost.Contracts; using Aryx.AgentHost.Contracts;
using Microsoft.Extensions.AI; using Microsoft.Extensions.AI;
@@ -14,6 +15,8 @@ internal class TurnExecutionState
private readonly ConcurrentQueue<SidecarEventDto> _pendingEvents = new(); private readonly ConcurrentQueue<SidecarEventDto> _pendingEvents = new();
private readonly ConcurrentQueue<McpOauthRequiredEventDto> _pendingMcpOauthRequests = new(); private readonly ConcurrentQueue<McpOauthRequiredEventDto> _pendingMcpOauthRequests = new();
private readonly ConcurrentDictionary<string, AgentIdentity> _observedAgentsByMessageId = new(StringComparer.Ordinal); private readonly ConcurrentDictionary<string, AgentIdentity> _observedAgentsByMessageId = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, ProviderReasoningSnapshot> _reasoningById = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, string> _latestIntentByAgentId = new(StringComparer.Ordinal);
private readonly StreamingTranscriptBuffer _transcriptBuffer = new(); private readonly StreamingTranscriptBuffer _transcriptBuffer = new();
private int _fallbackMessageIndex; private int _fallbackMessageIndex;
private string? _lastObservedMessageId; private string? _lastObservedMessageId;
@@ -25,9 +28,7 @@ internal class TurnExecutionState
_agentSubworkflowIndex = AgentIdentityResolver.BuildAgentSubworkflowIndex(command.Workflow, _workflowLibrary); _agentSubworkflowIndex = AgentIdentityResolver.BuildAgentSubworkflowIndex(command.Workflow, _workflowLibrary);
} }
public ConcurrentDictionary<string, string> ToolNamesByCallId { get; } = new(StringComparer.Ordinal); public ToolCallRegistry ToolCalls { get; } = new();
public ConcurrentDictionary<string, bool> ToolCallHasArgumentsById { get; } = new(StringComparer.Ordinal);
public AgentIdentity? ActiveAgent { get; private set; } public AgentIdentity? ActiveAgent { get; private set; }
@@ -35,8 +36,19 @@ internal class TurnExecutionState
public bool HasPendingExitPlanModeRequest { 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 bool SuppressHookLifecycleEvents { get; set; }
public void SetStreamCapabilities(ProviderTurnStreamCapabilities capabilities)
{
StreamCapabilities = capabilities ?? throw new ArgumentNullException(nameof(capabilities));
}
public AgentIdentity ResolveAgentIdentity(string? agentId, string? agentName) public AgentIdentity ResolveAgentIdentity(string? agentId, string? agentName)
{ {
return AgentIdentityResolver.ResolveAgentIdentity( return AgentIdentityResolver.ResolveAgentIdentity(
@@ -143,6 +155,20 @@ internal class TurnExecutionState
case ProviderAssistantMessageEvent assistantMessage: case ProviderAssistantMessageEvent assistantMessage:
RecordObservedAgentForMessage(agent, assistantMessage.MessageId); RecordObservedAgentForMessage(agent, assistantMessage.MessageId);
QueueThinkingIfNeeded(agent); 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) if (assistantMessage.HasToolRequests)
{ {
QueueMessageReclassifiedIfNeeded(assistantMessage.MessageId); QueueMessageReclassifiedIfNeeded(assistantMessage.MessageId);
@@ -151,20 +177,36 @@ internal class TurnExecutionState
case ProviderToolExecutionStartEvent toolExecutionStart: case ProviderToolExecutionStartEvent toolExecutionStart:
string toolCallId = toolExecutionStart.ToolCallId; string toolCallId = toolExecutionStart.ToolCallId;
string toolName = toolExecutionStart.ToolName; string toolName = toolExecutionStart.ToolName;
TrackToolCall(toolCallId, toolName, toolExecutionStart.ToolArguments); bool shouldQueueToolActivity = TrackToolCall(toolCallId, toolName, toolExecutionStart.ToolArguments);
ActiveAgent = agent; ActiveAgent = agent;
AgentActivityEventDto? toolActivity = CreateToolCallingActivity( if (shouldQueueToolActivity)
agent, toolName, toolCallId, toolExecutionStart.ToolArguments);
if (toolActivity is not null)
{ {
_pendingEvents.Enqueue(toolActivity); AgentActivityEventDto? toolActivity = CreateToolCallingActivity(
agent, toolName, toolCallId, toolExecutionStart.ToolArguments);
if (toolActivity is not null)
{
_pendingEvents.Enqueue(toolActivity);
}
} }
QueueMessageReclassifiedIfNeeded(_lastObservedMessageId); QueueMessageReclassifiedIfNeeded(_lastObservedMessageId);
break; 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: case ProviderAssistantIntentEvent intentEvent:
ActiveAgent = agent; ActiveAgent = agent;
QueueThinkingIfNeeded(agent); QueueThinkingIfNeeded(agent);
TrackLatestIntent(agent.AgentId, intentEvent.Intent);
AssistantIntentEventDto? assistantIntent = CreateAssistantIntentEvent(agent, intentEvent.Intent); AssistantIntentEventDto? assistantIntent = CreateAssistantIntentEvent(agent, intentEvent.Intent);
if (assistantIntent is not null) if (assistantIntent is not null)
{ {
@@ -174,6 +216,7 @@ internal class TurnExecutionState
case ProviderAssistantReasoningDeltaEvent reasoningDelta: case ProviderAssistantReasoningDeltaEvent reasoningDelta:
ActiveAgent = agent; ActiveAgent = agent;
QueueThinkingIfNeeded(agent); QueueThinkingIfNeeded(agent);
TrackReasoningContent(reasoningDelta.ReasoningId, reasoningDelta.DeltaContent, isComplete: false);
ReasoningDeltaEventDto? reasoningDeltaEvent = CreateReasoningDeltaEvent( ReasoningDeltaEventDto? reasoningDeltaEvent = CreateReasoningDeltaEvent(
agent, agent,
reasoningDelta.ReasoningId, reasoningDelta.ReasoningId,
@@ -183,6 +226,22 @@ internal class TurnExecutionState
_pendingEvents.Enqueue(reasoningDeltaEvent); _pendingEvents.Enqueue(reasoningDeltaEvent);
} }
break; 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: case ProviderSubagentStartedEvent started:
ActiveAgent = agent; ActiveAgent = agent;
_pendingEvents.Enqueue(CreateSubagentStartedEvent(agent, started)); _pendingEvents.Enqueue(CreateSubagentStartedEvent(agent, started));
@@ -291,6 +350,25 @@ internal class TurnExecutionState
return pending; 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) public bool TryResolveObservedAgentForMessage(string? messageId, out AgentIdentity agent)
{ {
agent = default; agent = default;
@@ -311,6 +389,24 @@ internal class TurnExecutionState
return _transcriptBuffer.AppendDelta(messageId, authorName, 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) public void ClearActiveAgentIfMatching(AgentIdentity completedAgent)
{ {
if (ActiveAgent.HasValue if (ActiveAgent.HasValue
@@ -327,13 +423,61 @@ internal class TurnExecutionState
_lastObservedMessageId = messageId; _lastObservedMessageId = messageId;
} }
private void TrackToolCall( private bool TrackToolCall(
string toolCallId, string toolCallId,
string toolName, string toolName,
IReadOnlyDictionary<string, object?>? toolArguments) IReadOnlyDictionary<string, object?>? toolArguments)
{ {
ToolNamesByCallId[toolCallId] = toolName; return ToolCalls.TryRecordToolRequest(toolCallId, toolName, toolArguments);
ToolCallHasArgumentsById[toolCallId] = toolArguments is { Count: > 0 }; }
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) private void QueueMessageReclassifiedIfNeeded(string? messageId)
@@ -613,6 +757,24 @@ internal class TurnExecutionState
}; };
} }
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( private SkillInvokedEventDto CreateSkillInvokedEvent(
AgentIdentity agent, AgentIdentity agent,
ProviderSkillInvokedEvent data) ProviderSkillInvokedEvent data)
@@ -759,4 +921,9 @@ internal class TurnExecutionState
AgentName = agent.AgentName, AgentName = agent.AgentName,
}; };
} }
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 System.Text.Json;
using Aryx.AgentHost.Contracts; using Aryx.AgentHost.Contracts;
using Microsoft.Agents.AI.Workflows; using Microsoft.Agents.AI.Workflows;
@@ -20,8 +19,7 @@ internal static class WorkflowRequestInfoInterpreter
RunTurnCommandDto command, RunTurnCommandDto command,
RequestInfoEvent requestInfo, RequestInfoEvent requestInfo,
AgentIdentity? activeAgent, AgentIdentity? activeAgent,
ConcurrentDictionary<string, string> toolNamesByCallId, ToolCallRegistry toolCalls)
ConcurrentDictionary<string, bool> toolCallHasArgumentsById)
{ {
RequestInterpretation interpretation = InterpretRequest(command, requestInfo); RequestInterpretation interpretation = InterpretRequest(command, requestInfo);
return interpretation switch return interpretation switch
@@ -29,7 +27,7 @@ internal static class WorkflowRequestInfoInterpreter
HandoffRequestInterpretation handoff => HandoffRequestInterpretation handoff =>
CreateHandoffActivity(command, handoff.TargetAgent, activeAgent), CreateHandoffActivity(command, handoff.TargetAgent, activeAgent),
ToolRequestInterpretation tool when activeAgent.HasValue => ToolRequestInterpretation tool when activeAgent.HasValue =>
CreateToolCallingActivity(command, activeAgent.Value, tool, toolNamesByCallId, toolCallHasArgumentsById), CreateToolCallingActivity(command, activeAgent.Value, tool, toolCalls),
_ => null, _ => null,
}; };
} }
@@ -66,22 +64,13 @@ internal static class WorkflowRequestInfoInterpreter
RunTurnCommandDto command, RunTurnCommandDto command,
AgentIdentity activeAgent, AgentIdentity activeAgent,
ToolRequestInterpretation tool, ToolRequestInterpretation tool,
ConcurrentDictionary<string, string> toolNamesByCallId, ToolCallRegistry toolCalls)
ConcurrentDictionary<string, bool> toolCallHasArgumentsById)
{ {
bool hasToolArguments = tool.ToolArguments is { Count: > 0 }; if (!toolCalls.TryRecordToolRequest(tool.ToolCallId, tool.ToolName, tool.ToolArguments))
if (tool.ToolCallId is not null && toolNamesByCallId.ContainsKey(tool.ToolCallId))
{ {
bool trackedHasArguments = toolCallHasArgumentsById.TryGetValue(tool.ToolCallId, out bool hasTrackedArguments) return null;
&& hasTrackedArguments;
if (trackedHasArguments || !hasToolArguments)
{
return null;
}
} }
TrackToolCallId(toolNamesByCallId, toolCallHasArgumentsById, tool.ToolCallId, tool.ToolName, hasToolArguments);
return new AgentActivityEventDto return new AgentActivityEventDto
{ {
Type = "agent-activity", Type = "agent-activity",
@@ -98,20 +87,6 @@ internal static class WorkflowRequestInfoInterpreter
}; };
} }
private static void TrackToolCallId(
ConcurrentDictionary<string, string> toolNamesByCallId,
ConcurrentDictionary<string, bool> toolCallHasArgumentsById,
string? toolCallId,
string toolName,
bool hasToolArguments)
{
if (toolCallId is not null)
{
toolNamesByCallId[toolCallId] = toolName;
toolCallHasArgumentsById[toolCallId] = hasToolArguments;
}
}
private static RequestInterpretation InterpretRequest( private static RequestInterpretation InterpretRequest(
RunTurnCommandDto command, RunTurnCommandDto command,
RequestInfoEvent requestInfo) RequestInfoEvent requestInfo)
@@ -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);
}
}
@@ -110,10 +110,9 @@ public sealed class CopilotTurnExecutionStateTests
Assert.Equal("tool-call-1", toolActivity.ToolCallId); Assert.Equal("tool-call-1", toolActivity.ToolCallId);
Assert.NotNull(toolActivity.ToolArguments); Assert.NotNull(toolActivity.ToolArguments);
Assert.Equal("/src/main.ts", toolActivity.ToolArguments["path"]); Assert.Equal("/src/main.ts", toolActivity.ToolArguments["path"]);
Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-1", out string? toolName)); Assert.True(state.ToolCalls.TryGetToolName("tool-call-1", out string? toolName));
Assert.Equal("view", toolName); Assert.Equal("view", toolName);
Assert.True(state.ToolCallHasArgumentsById.TryGetValue("tool-call-1", out bool hasArguments)); Assert.True(state.ToolCalls.HasTrackedArguments("tool-call-1"));
Assert.True(hasArguments);
} }
[Fact] [Fact]
@@ -129,8 +128,115 @@ public sealed class CopilotTurnExecutionStateTests
AgentActivityEventDto toolActivity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>()); AgentActivityEventDto toolActivity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
Assert.Null(toolActivity.ToolArguments); Assert.Null(toolActivity.ToolArguments);
Assert.True(state.ToolCallHasArgumentsById.TryGetValue("tool-call-1", out bool hasArguments)); Assert.False(state.ToolCalls.HasTrackedArguments("tool-call-1"));
Assert.False(hasArguments); }
[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] [Fact]
@@ -145,10 +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"}""")); """{"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.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.Equal("handoff_to_specialist", toolName);
Assert.True(state.ToolCallHasArgumentsById.TryGetValue("tool-call-1", out bool hasArguments)); Assert.False(state.ToolCalls.HasTrackedArguments("tool-call-1"));
Assert.False(hasArguments);
} }
[Fact] [Fact]
@@ -267,14 +372,12 @@ public sealed class CopilotTurnExecutionStateTests
Assert.Contains(toolActivities, activity => activity.ToolCallId == "tool-call-2" && activity.ToolName == "view"); Assert.Contains(toolActivities, activity => activity.ToolCallId == "tool-call-2" && activity.ToolName == "view");
MessageReclassifiedEventDto reclassified = Assert.Single(pending.OfType<MessageReclassifiedEventDto>()); MessageReclassifiedEventDto reclassified = Assert.Single(pending.OfType<MessageReclassifiedEventDto>());
Assert.Equal("msg-3", reclassified.MessageId); 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.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.Equal("view", secondToolName);
Assert.True(state.ToolCallHasArgumentsById.TryGetValue("tool-call-1", out bool firstHasArguments)); Assert.False(state.ToolCalls.HasTrackedArguments("tool-call-1"));
Assert.False(firstHasArguments); Assert.False(state.ToolCalls.HasTrackedArguments("tool-call-2"));
Assert.True(state.ToolCallHasArgumentsById.TryGetValue("tool-call-2", out bool secondHasArguments));
Assert.False(secondHasArguments);
} }
[Fact] [Fact]
@@ -366,6 +469,9 @@ public sealed class CopilotTurnExecutionStateTests
Assert.Equal("session-1", intent.SessionId); Assert.Equal("session-1", intent.SessionId);
Assert.Equal("agent-1", intent.AgentId); Assert.Equal("agent-1", intent.AgentId);
Assert.Equal("Searching incident playbooks", intent.Intent); 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] [Fact]
@@ -399,6 +505,93 @@ public sealed class CopilotTurnExecutionStateTests
Assert.Equal("agent-1", reasoning.AgentId); Assert.Equal("agent-1", reasoning.AgentId);
Assert.Equal("reasoning-2", reasoning.ReasoningId); Assert.Equal("reasoning-2", reasoning.ReasoningId);
Assert.Equal("Searching logs.", reasoning.ContentDelta); 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] [Fact]
@@ -654,6 +847,53 @@ public sealed class CopilotTurnExecutionStateTests
Assert.Equal("agent-1", evt.AgentId); 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() private static SessionEvent CreateHookStartEvent()
{ {
return SessionEvent.FromJson( return SessionEvent.FromJson(
@@ -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] [Fact]
public void ProjectCompletedMessages_UsesFallbackAgentForGenericAssistantOutput() 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); 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] [Fact]
public void ProjectCompletedMessages_DropsBlankAssistantOutputMessages() public void ProjectCompletedMessages_DropsBlankAssistantOutputMessages()
{ {
@@ -602,7 +676,22 @@ public sealed class CopilotWorkflowRunnerTests
Assert.Equal("msg-2", second.MessageId); Assert.Equal("msg-2", second.MessageId);
Assert.Equal("Implementer", second.AuthorName); Assert.Equal("Implementer", second.AuthorName);
Assert.Equal("B", second.Content); 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] [Fact]
@@ -776,8 +865,165 @@ public sealed class CopilotWorkflowRunnerTests
Assert.Equal("tool-call-1", activity.ToolCallId); Assert.Equal("tool-call-1", activity.ToolCallId);
Assert.NotNull(activity.ToolArguments); Assert.NotNull(activity.ToolArguments);
Assert.Equal(@"C:\workspace\README.md", activity.ToolArguments["path"]); Assert.Equal(@"C:\workspace\README.md", activity.ToolArguments["path"]);
Assert.True(state.ToolCallHasArgumentsById.TryGetValue("tool-call-1", out bool hasArguments)); Assert.True(state.ToolCalls.HasTrackedArguments("tool-call-1"));
Assert.True(hasArguments); }
[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] [Fact]
@@ -1372,14 +1618,12 @@ public sealed class CopilotWorkflowRunnerTests
[Fact] [Fact]
public void TryGetApprovalToolName_UsesToolCallLookupForPermissionCategoriesWithoutDirectToolNames() public void TryGetApprovalToolName_UsesToolCallLookupForPermissionCategoriesWithoutDirectToolNames()
{ {
Dictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal) ToolCallRegistry toolCalls = CreateToolCallRegistry(
{ ("tool-call-url", "web_fetch"),
["tool-call-url"] = "web_fetch", ("tool-call-shell", "shell"),
["tool-call-shell"] = "shell", ("tool-call-read", "view"),
["tool-call-read"] = "view", ("tool-call-write", "write_file"),
["tool-call-write"] = "write_file", ("tool-call-memory", "store_memory"));
["tool-call-memory"] = "store_memory",
};
Assert.True( Assert.True(
CopilotApprovalCoordinator.TryGetApprovalToolName( CopilotApprovalCoordinator.TryGetApprovalToolName(
@@ -1390,7 +1634,7 @@ public sealed class CopilotWorkflowRunnerTests
Intention = "Fetch the requested page", Intention = "Fetch the requested page",
Url = "https://example.com/docs", Url = "https://example.com/docs",
}, },
toolNamesByCallId, toolCalls,
out string? urlToolName)); out string? urlToolName));
Assert.Equal("web_fetch", urlToolName); Assert.Equal("web_fetch", urlToolName);
@@ -1408,7 +1652,7 @@ public sealed class CopilotWorkflowRunnerTests
HasWriteFileRedirection = false, HasWriteFileRedirection = false,
CanOfferSessionApproval = false, CanOfferSessionApproval = false,
}, },
toolNamesByCallId, toolCalls,
out string? shellToolName)); out string? shellToolName));
Assert.Equal("shell", shellToolName); Assert.Equal("shell", shellToolName);
@@ -1421,7 +1665,7 @@ public sealed class CopilotWorkflowRunnerTests
Intention = "Inspect a file", Intention = "Inspect a file",
Path = "README.md", Path = "README.md",
}, },
toolNamesByCallId, toolCalls,
out string? readToolName)); out string? readToolName));
Assert.Equal("view", readToolName); Assert.Equal("view", readToolName);
@@ -1435,7 +1679,7 @@ public sealed class CopilotWorkflowRunnerTests
FileName = "README.md", FileName = "README.md",
Diff = "@@ -1 +1 @@", Diff = "@@ -1 +1 @@",
}, },
toolNamesByCallId, toolCalls,
out string? writeToolName)); out string? writeToolName));
Assert.Equal("write_file", writeToolName); Assert.Equal("write_file", writeToolName);
@@ -1449,7 +1693,7 @@ public sealed class CopilotWorkflowRunnerTests
Fact = "Use Bun for script execution.", Fact = "Use Bun for script execution.",
Citations = "package.json", Citations = "package.json",
}, },
toolNamesByCallId, toolCalls,
out string? memoryToolName)); out string? memoryToolName));
Assert.Equal("store_memory", memoryToolName); Assert.Equal("store_memory", memoryToolName);
} }
@@ -1960,7 +2204,7 @@ public sealed class CopilotWorkflowRunnerTests
{ {
SessionId = "copilot-session-1", SessionId = "copilot-session-1",
}, },
new Dictionary<string, string>(StringComparer.Ordinal), CreateToolCallRegistry(),
approval => approval =>
{ {
observedApproval = approval; observedApproval = approval;
@@ -2007,10 +2251,7 @@ public sealed class CopilotWorkflowRunnerTests
{ {
SessionId = "copilot-session-1", SessionId = "copilot-session-1",
}, },
new Dictionary<string, string>(StringComparer.Ordinal) CreateToolCallRegistry(("tool-call-write-1", "apply_patch")),
{
["tool-call-write-1"] = "apply_patch",
},
activity => activity =>
{ {
observedActivity = activity; observedActivity = activity;
@@ -2067,7 +2308,7 @@ public sealed class CopilotWorkflowRunnerTests
{ {
SessionId = "copilot-session-1", SessionId = "copilot-session-1",
}, },
new Dictionary<string, string>(StringComparer.Ordinal), CreateToolCallRegistry(),
approval => approval =>
{ {
sawApproval = true; sawApproval = true;
@@ -2104,7 +2345,7 @@ public sealed class CopilotWorkflowRunnerTests
{ {
SessionId = "copilot-session-1", SessionId = "copilot-session-1",
}, },
new Dictionary<string, string>(StringComparer.Ordinal), CreateToolCallRegistry(),
approval => approval =>
{ {
sawApproval = true; sawApproval = true;
@@ -2137,10 +2378,7 @@ public sealed class CopilotWorkflowRunnerTests
{ {
SessionId = "copilot-session-1", SessionId = "copilot-session-1",
}, },
new Dictionary<string, string>(StringComparer.Ordinal) CreateToolCallRegistry(("tool-call-read-1", "view")),
{
["tool-call-read-1"] = "view",
},
approval => approval =>
{ {
firstApproval = approval; firstApproval = approval;
@@ -2178,10 +2416,7 @@ public sealed class CopilotWorkflowRunnerTests
{ {
SessionId = "copilot-session-1", SessionId = "copilot-session-1",
}, },
new Dictionary<string, string>(StringComparer.Ordinal) CreateToolCallRegistry(("tool-call-read-2", "grep")),
{
["tool-call-read-2"] = "grep",
},
approval => approval =>
{ {
sawSecondApproval = true; sawSecondApproval = true;
@@ -2214,10 +2449,7 @@ public sealed class CopilotWorkflowRunnerTests
{ {
SessionId = "copilot-session-1", SessionId = "copilot-session-1",
}, },
new Dictionary<string, string>(StringComparer.Ordinal) CreateToolCallRegistry(("tool-call-read-1", "view")),
{
["tool-call-read-1"] = "view",
},
approval => approval =>
{ {
firstApproval = approval; firstApproval = approval;
@@ -2254,10 +2486,7 @@ public sealed class CopilotWorkflowRunnerTests
{ {
SessionId = "copilot-session-1", SessionId = "copilot-session-1",
}, },
new Dictionary<string, string>(StringComparer.Ordinal) CreateToolCallRegistry(("tool-call-read-2", "grep")),
{
["tool-call-read-2"] = "grep",
},
approval => approval =>
{ {
secondApproval = approval; secondApproval = approval;
@@ -2548,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() private static RunTurnCommandDto CreateRequestPortCommand()
{ {
return new RunTurnCommandDto return new RunTurnCommandDto
@@ -1,5 +1,4 @@
using System.Collections; using System.Collections;
using System.Collections.Concurrent;
using System.Runtime.CompilerServices; using System.Runtime.CompilerServices;
using System.Text.Json; using System.Text.Json;
using Aryx.AgentHost.Contracts; using Aryx.AgentHost.Contracts;
@@ -27,8 +26,7 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateSingleAgentCommand(), CreateSingleAgentCommand(),
requestInfo, requestInfo,
new AgentIdentity("agent-1", "Primary"), new AgentIdentity("agent-1", "Primary"),
tracking.ToolNamesByCallId, tracking);
tracking.ToolCallHasArgumentsById);
Assert.NotNull(activity); Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType); Assert.Equal("tool-calling", activity.ActivityType);
@@ -38,8 +36,9 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.NotNull(activity.ToolArguments); Assert.NotNull(activity.ToolArguments);
Assert.Equal(@"C:\workspace\file.txt", activity.ToolArguments["path"]); Assert.Equal(@"C:\workspace\file.txt", activity.ToolArguments["path"]);
Assert.Equal([10, 25], Assert.IsAssignableFrom<IReadOnlyList<object?>>(activity.ToolArguments["viewRange"])); Assert.Equal([10, 25], Assert.IsAssignableFrom<IReadOnlyList<object?>>(activity.ToolArguments["viewRange"]));
Assert.Equal("view", tracking.ToolNamesByCallId["call-1"]); Assert.True(tracking.TryGetToolName("call-1", out string? toolName));
Assert.True(tracking.ToolCallHasArgumentsById["call-1"]); Assert.Equal("view", toolName);
Assert.True(tracking.HasTrackedArguments("call-1"));
} }
[Fact] [Fact]
@@ -61,8 +60,7 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateSingleAgentCommand(), CreateSingleAgentCommand(),
requestInfo, requestInfo,
new AgentIdentity("agent-1", "Primary"), new AgentIdentity("agent-1", "Primary"),
tracking.ToolNamesByCallId, tracking);
tracking.ToolCallHasArgumentsById);
Assert.NotNull(activity); Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType); Assert.Equal("tool-calling", activity.ActivityType);
@@ -70,8 +68,9 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.NotNull(activity.ToolArguments); Assert.NotNull(activity.ToolArguments);
Assert.Equal(@"C:\workspace", activity.ToolArguments["path"]); Assert.Equal(@"C:\workspace", activity.ToolArguments["path"]);
Assert.Equal(true, activity.ToolArguments["includeIgnored"]); Assert.Equal(true, activity.ToolArguments["includeIgnored"]);
Assert.Equal("git.status", tracking.ToolNamesByCallId["call-1"]); Assert.True(tracking.TryGetToolName("call-1", out string? toolName));
Assert.True(tracking.ToolCallHasArgumentsById["call-1"]); Assert.Equal("git.status", toolName);
Assert.True(tracking.HasTrackedArguments("call-1"));
} }
[Fact] [Fact]
@@ -85,8 +84,7 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateSingleAgentCommand(), CreateSingleAgentCommand(),
requestInfo, requestInfo,
new AgentIdentity("agent-1", "Primary"), new AgentIdentity("agent-1", "Primary"),
tracking.ToolNamesByCallId, tracking);
tracking.ToolCallHasArgumentsById);
Assert.NotNull(activity); Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType); Assert.Equal("tool-calling", activity.ActivityType);
@@ -95,8 +93,9 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.Equal( Assert.Equal(
["print('hello')"], ["print('hello')"],
Assert.IsAssignableFrom<IReadOnlyList<object?>>(activity.ToolArguments["inputs"])); Assert.IsAssignableFrom<IReadOnlyList<object?>>(activity.ToolArguments["inputs"]));
Assert.Equal("code interpreter", tracking.ToolNamesByCallId["call-1"]); Assert.True(tracking.TryGetToolName("call-1", out string? toolName));
Assert.True(tracking.ToolCallHasArgumentsById["call-1"]); Assert.Equal("code interpreter", toolName);
Assert.True(tracking.HasTrackedArguments("call-1"));
} }
[Fact] [Fact]
@@ -109,15 +108,14 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateSingleAgentCommand(), CreateSingleAgentCommand(),
requestInfo, requestInfo,
new AgentIdentity("agent-1", "Primary"), new AgentIdentity("agent-1", "Primary"),
tracking.ToolNamesByCallId, tracking);
tracking.ToolCallHasArgumentsById);
Assert.NotNull(activity); Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType); Assert.Equal("tool-calling", activity.ActivityType);
Assert.Equal("image generation", activity.ToolName); Assert.Equal("image generation", activity.ToolName);
Assert.Null(activity.ToolArguments); Assert.Null(activity.ToolArguments);
Assert.Empty(tracking.ToolNamesByCallId); Assert.False(tracking.TryGetToolName("call-1", out _));
Assert.Empty(tracking.ToolCallHasArgumentsById); Assert.False(tracking.HasTrackedArguments("call-1"));
} }
[Fact] [Fact]
@@ -135,12 +133,11 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateSingleAgentCommand(), CreateSingleAgentCommand(),
requestInfo, requestInfo,
new AgentIdentity("agent-1", "Primary"), new AgentIdentity("agent-1", "Primary"),
tracking.ToolNamesByCallId, tracking);
tracking.ToolCallHasArgumentsById);
Assert.NotNull(activity); Assert.NotNull(activity);
Assert.Null(activity.ToolArguments); Assert.Null(activity.ToolArguments);
Assert.False(tracking.ToolCallHasArgumentsById["call-1"]); Assert.False(tracking.HasTrackedArguments("call-1"));
} }
[Fact] [Fact]
@@ -160,21 +157,25 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateSingleAgentCommand(), CreateSingleAgentCommand(),
requestInfo, requestInfo,
new AgentIdentity("agent-1", "Primary"), new AgentIdentity("agent-1", "Primary"),
tracking.ToolNamesByCallId, tracking);
tracking.ToolCallHasArgumentsById);
Assert.NotNull(activity); Assert.NotNull(activity);
Assert.NotNull(activity.ToolArguments); Assert.NotNull(activity.ToolArguments);
Assert.Equal("[truncated]", activity.ToolArguments["command"]); Assert.Equal("[truncated]", activity.ToolArguments["command"]);
Assert.True(tracking.ToolCallHasArgumentsById["call-1"]); Assert.True(tracking.HasTrackedArguments("call-1"));
} }
[Fact] [Fact]
public void TryCreateActivityFromRequest_SkipsDuplicateTrackedToolCallIdsThatAlreadyHaveArguments() public void TryCreateActivityFromRequest_SkipsDuplicateTrackedToolCallIdsThatAlreadyHaveArguments()
{ {
var tracking = CreateToolTracking(); var tracking = CreateToolTracking();
tracking.ToolNamesByCallId["call-1"] = "view"; tracking.RecordToolStart(
tracking.ToolCallHasArgumentsById["call-1"] = true; "call-1",
"view",
new Dictionary<string, object?>
{
["path"] = @"C:\workspace\seed.txt",
});
RequestInfoEvent requestInfo = CreateRequestInfoEvent( RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("call-1", "view", new Dictionary<string, object?> new FunctionCallContent("call-1", "view", new Dictionary<string, object?>
{ {
@@ -185,20 +186,19 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateSingleAgentCommand(), CreateSingleAgentCommand(),
requestInfo, requestInfo,
new AgentIdentity("agent-1", "Primary"), new AgentIdentity("agent-1", "Primary"),
tracking.ToolNamesByCallId, tracking);
tracking.ToolCallHasArgumentsById);
Assert.Null(activity); Assert.Null(activity);
Assert.Equal("view", tracking.ToolNamesByCallId["call-1"]); Assert.True(tracking.TryGetToolName("call-1", out string? toolName));
Assert.True(tracking.ToolCallHasArgumentsById["call-1"]); Assert.Equal("view", toolName);
Assert.True(tracking.HasTrackedArguments("call-1"));
} }
[Fact] [Fact]
public void TryCreateActivityFromRequest_EmitsEnrichmentWhenTrackedToolCallWasMissingArguments() public void TryCreateActivityFromRequest_EmitsEnrichmentWhenTrackedToolCallWasMissingArguments()
{ {
var tracking = CreateToolTracking(); var tracking = CreateToolTracking();
tracking.ToolNamesByCallId["call-1"] = "view"; tracking.RecordToolStart("call-1", "view", toolArguments: null);
tracking.ToolCallHasArgumentsById["call-1"] = false;
RequestInfoEvent requestInfo = CreateRequestInfoEvent( RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("call-1", "view", new Dictionary<string, object?> new FunctionCallContent("call-1", "view", new Dictionary<string, object?>
{ {
@@ -209,16 +209,16 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateSingleAgentCommand(), CreateSingleAgentCommand(),
requestInfo, requestInfo,
new AgentIdentity("agent-1", "Primary"), new AgentIdentity("agent-1", "Primary"),
tracking.ToolNamesByCallId, tracking);
tracking.ToolCallHasArgumentsById);
Assert.NotNull(activity); Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType); Assert.Equal("tool-calling", activity.ActivityType);
Assert.Equal("call-1", activity.ToolCallId); Assert.Equal("call-1", activity.ToolCallId);
Assert.NotNull(activity.ToolArguments); Assert.NotNull(activity.ToolArguments);
Assert.Equal(@"C:\workspace\file.txt", activity.ToolArguments["path"]); Assert.Equal(@"C:\workspace\file.txt", activity.ToolArguments["path"]);
Assert.Equal("view", tracking.ToolNamesByCallId["call-1"]); Assert.True(tracking.TryGetToolName("call-1", out string? toolName));
Assert.True(tracking.ToolCallHasArgumentsById["call-1"]); Assert.Equal("view", toolName);
Assert.True(tracking.HasTrackedArguments("call-1"));
} }
[Fact] [Fact]
@@ -232,8 +232,7 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateHandoffCommand(), CreateHandoffCommand(),
requestInfo, requestInfo,
new AgentIdentity("agent-handoff-triage", "Triage"), new AgentIdentity("agent-handoff-triage", "Triage"),
tracking.ToolNamesByCallId, tracking);
tracking.ToolCallHasArgumentsById);
Assert.NotNull(activity); Assert.NotNull(activity);
Assert.Equal("handoff", activity.ActivityType); Assert.Equal("handoff", activity.ActivityType);
@@ -242,8 +241,8 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.Equal("agent-handoff-triage", activity.SourceAgentId); Assert.Equal("agent-handoff-triage", activity.SourceAgentId);
Assert.Equal("Triage", activity.SourceAgentName); Assert.Equal("Triage", activity.SourceAgentName);
Assert.Null(activity.ToolName); Assert.Null(activity.ToolName);
Assert.Empty(tracking.ToolNamesByCallId); Assert.False(tracking.TryGetToolName("call-1", out _));
Assert.Empty(tracking.ToolCallHasArgumentsById); Assert.False(tracking.HasTrackedArguments("call-1"));
} }
[Fact] [Fact]
@@ -263,8 +262,7 @@ public sealed class WorkflowRequestInfoInterpreterTests
"agent-reviewer", "agent-reviewer",
"Reviewer", "Reviewer",
new SubworkflowContext("subworkflow-review", "Review Lane")), new SubworkflowContext("subworkflow-review", "Review Lane")),
tracking.ToolNamesByCallId, tracking);
tracking.ToolCallHasArgumentsById);
Assert.NotNull(activity); Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType); Assert.Equal("tool-calling", activity.ActivityType);
@@ -283,8 +281,7 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateHandoffCommandWithReferencedSubworkflow(), CreateHandoffCommandWithReferencedSubworkflow(),
requestInfo, requestInfo,
new AgentIdentity("agent-handoff-triage", "Triage"), new AgentIdentity("agent-handoff-triage", "Triage"),
tracking.ToolNamesByCallId, tracking);
tracking.ToolCallHasArgumentsById);
Assert.NotNull(activity); Assert.NotNull(activity);
Assert.Equal("handoff", activity.ActivityType); Assert.Equal("handoff", activity.ActivityType);
@@ -417,10 +414,7 @@ public sealed class WorkflowRequestInfoInterpreterTests
workflowLibrary: [nestedWorkflow]); workflowLibrary: [nestedWorkflow]);
} }
private static ( private static ToolCallRegistry CreateToolTracking() => new();
ConcurrentDictionary<string, string> ToolNamesByCallId,
ConcurrentDictionary<string, bool> ToolCallHasArgumentsById) CreateToolTracking()
=> (new(StringComparer.Ordinal), new(StringComparer.Ordinal));
private static RunTurnCommandDto CreateCommand( private static RunTurnCommandDto CreateCommand(
string orchestrationMode, string orchestrationMode,
+42 -154
View File
@@ -117,7 +117,6 @@ import {
} from '@shared/domain/session'; } from '@shared/domain/session';
import { prepareChatMessageContent } from '@shared/utils/chatMessage'; import { prepareChatMessageContent } from '@shared/utils/chatMessage';
import { import {
appendRunActivityEvent,
cancelSessionRunRecord, cancelSessionRunRecord,
completeSessionRunRecord, completeSessionRunRecord,
createSessionRunRecord, createSessionRunRecord,
@@ -132,6 +131,7 @@ import {
} from '@shared/domain/runTimeline'; } from '@shared/domain/runTimeline';
import { import {
createSessionToolingSelection, createSessionToolingSelection,
createDefaultQuickPromptSettings,
listApprovalToolNames, listApprovalToolNames,
normalizeTerminalHeight, normalizeTerminalHeight,
normalizeTheme, normalizeTheme,
@@ -140,6 +140,7 @@ import {
type AppearanceTheme, type AppearanceTheme,
type LspProfileDefinition, type LspProfileDefinition,
type McpServerDefinition, type McpServerDefinition,
type QuickPromptSettings,
type SessionToolingSelection, type SessionToolingSelection,
type WorkspaceToolingSettings, type WorkspaceToolingSettings,
normalizeLspProfileDefinition, normalizeLspProfileDefinition,
@@ -150,7 +151,6 @@ import {
} from '@shared/domain/tooling'; } from '@shared/domain/tooling';
import type { WorkspaceState } from '@shared/domain/workspace'; import type { WorkspaceState } from '@shared/domain/workspace';
import { createId, nowIso } from '@shared/utils/ids'; import { createId, nowIso } from '@shared/utils/ids';
import { mergeStreamingText } from '@shared/utils/streamingText';
import { WorkspaceRepository } from '@main/persistence/workspaceRepository'; import { WorkspaceRepository } from '@main/persistence/workspaceRepository';
import { getScratchpadSessionPath } from '@main/persistence/appPaths'; import { getScratchpadSessionPath } from '@main/persistence/appPaths';
@@ -830,6 +830,21 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
return this.persistAndBroadcast(workspace); return this.persistAndBroadcast(workspace);
} }
getQuickPromptSettings(): QuickPromptSettings {
return this.workspace?.settings.quickPrompt ?? createDefaultQuickPromptSettings();
}
getCurrentTheme(): AppearanceTheme {
return this.workspace?.settings.theme ?? 'dark';
}
async setQuickPromptSettings(patch: Partial<QuickPromptSettings>): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const current = workspace.settings.quickPrompt ?? createDefaultQuickPromptSettings();
workspace.settings.quickPrompt = { ...current, ...patch };
return this.persistAndBroadcast(workspace);
}
async describeTerminal(): Promise<TerminalSnapshot | undefined> { async describeTerminal(): Promise<TerminalSnapshot | undefined> {
return this.ptyManager.getSnapshot(); return this.ptyManager.getSnapshot();
} }
@@ -1907,158 +1922,6 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
session.cwd = scratchpadDirectory; session.cwd = scratchpadDirectory;
} }
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);
// When a new assistant message begins, auto-complete any previously pending
// assistant messages so only the latest one shows the "Thinking" indicator.
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.workspaceRepository.save(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: event.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.workspaceRepository.save(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.workspaceRepository.save(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( private emitCompletedActivity(
sessionId: string, sessionId: string,
workflow: WorkflowDefinition, workflow: WorkflowDefinition,
@@ -2384,6 +2247,31 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
usageQuotaSnapshots: event.quotaSnapshots, usageQuotaSnapshots: event.quotaSnapshots,
}); });
return; return;
case 'assistant-intent': {
const session = this.requireSession(workspace, sessionId);
session.currentIntent = event.intent;
session.updatedAt = occurredAt;
this.emitSessionEvent({
sessionId,
kind: 'assistant-intent',
occurredAt,
agentId: event.agentId,
agentName: event.agentName,
intent: event.intent,
});
return;
}
case 'reasoning-delta':
this.emitSessionEvent({
sessionId,
kind: 'reasoning-delta',
occurredAt,
agentId: event.agentId,
agentName: event.agentName,
reasoningId: event.reasoningId,
reasoningDelta: event.contentDelta,
});
return;
} }
} }
+50 -3
View File
@@ -1,19 +1,47 @@
import electron from 'electron'; import electron from 'electron';
import type { BrowserWindow as BrowserWindowType } 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 { AryxAppService } from '@main/AryxAppService';
import { AutoUpdateService } from '@main/services/autoUpdater'; import { AutoUpdateService } from '@main/services/autoUpdater';
import { GlobalHotkeyService } from '@main/services/globalHotkey';
import { createMainWindow } from '@main/windows/createMainWindow'; import { createMainWindow } from '@main/windows/createMainWindow';
import {
createQuickPromptWindow,
toggleQuickPromptWindow,
} from '@main/windows/createQuickPromptWindow';
import { applyTitleBarTheme } from '@main/windows/titleBarTheme'; import { applyTitleBarTheme } from '@main/windows/titleBarTheme';
import { SystemTray, setupCloseToTray, showAndFocusWindow } from '@main/services/systemTray'; import { SystemTray, setupCloseToTray, showAndFocusWindow } from '@main/services/systemTray';
import { createDefaultQuickPromptSettings } from '@shared/domain/tooling';
const { app, BrowserWindow } = electron; const { app, BrowserWindow } = electron;
let mainWindow: BrowserWindowType | undefined; let mainWindow: BrowserWindowType | undefined;
let quickPromptWindow: BrowserWindowType | undefined;
let appService: AryxAppService | undefined; let appService: AryxAppService | undefined;
let systemTray: SystemTray | undefined; let systemTray: SystemTray | undefined;
let autoUpdateService: AutoUpdateService | 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> { async function bootstrap(): Promise<void> {
appService = new AryxAppService(); appService = new AryxAppService();
@@ -21,13 +49,14 @@ async function bootstrap(): Promise<void> {
autoUpdateService = new AutoUpdateService({ isPackaged: app.isPackaged }); autoUpdateService = new AutoUpdateService({ isPackaged: app.isPackaged });
mainWindow = createMainWindow(); mainWindow = createMainWindow();
registerIpcHandlers(mainWindow, appService, autoUpdateService); registerIpcHandlers(mainWindow, appService, autoUpdateService);
// Start workspace loading in parallel — don't block window from showing. // Start workspace loading in parallel — don't block window from showing.
// The renderer fetches the workspace via its own IPC call after mount. // The renderer fetches the workspace via its own IPC call after mount.
const workspaceReady = appService.loadWorkspace(); const workspaceReady = appService.loadWorkspace();
// Apply theme and set up tray once workspace is available // Apply theme, set up tray, and register global hotkey once workspace is available
workspaceReady workspaceReady
.then((workspace) => { .then((workspace) => {
if (!mainWindow) return; if (!mainWindow) return;
@@ -47,6 +76,21 @@ async function bootstrap(): Promise<void> {
appService!.on('workspace-updated', (updatedWorkspace) => { appService!.on('workspace-updated', (updatedWorkspace) => {
systemTray?.updateRunningCount(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) => { .catch((error) => {
console.error('[aryx bootstrap] workspace load failed', error); console.error('[aryx bootstrap] workspace load failed', error);
@@ -72,7 +116,9 @@ app.on('window-all-closed', () => {
if (process.platform === 'darwin') return; if (process.platform === 'darwin') return;
const windows = BrowserWindow.getAllWindows(); 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; if (allHidden) return;
app.quit(); app.quit();
@@ -87,6 +133,7 @@ app.on('activate', async () => {
}); });
app.on('before-quit', async () => { app.on('before-quit', async () => {
globalHotkeyService?.dispose();
autoUpdateService?.dispose(); autoUpdateService?.dispose();
autoUpdateService = undefined; autoUpdateService = undefined;
systemTray?.dispose(); systemTray?.dispose();
+102 -1
View File
@@ -51,14 +51,18 @@ import type {
UpdateSessionModelConfigInput, UpdateSessionModelConfigInput,
UpdateSessionApprovalSettingsInput, UpdateSessionApprovalSettingsInput,
UpdateSessionToolingInput, UpdateSessionToolingInput,
QuickPromptSendInput,
} from '@shared/contracts/ipc'; } from '@shared/contracts/ipc';
import type { QuerySessionsInput } from '@shared/domain/sessionLibrary'; import type { QuerySessionsInput } from '@shared/domain/sessionLibrary';
import type { AppearanceTheme } from '@shared/domain/tooling'; import type { AppearanceTheme, QuickPromptSettings } from '@shared/domain/tooling';
import { AryxAppService } from '@main/AryxAppService'; import { AryxAppService } from '@main/AryxAppService';
import { AutoUpdateService } from '@main/services/autoUpdater'; import { AutoUpdateService } from '@main/services/autoUpdater';
import { createDesktopNotificationHandler } from '@main/services/desktopNotifications'; import { createDesktopNotificationHandler } from '@main/services/desktopNotifications';
import { applyTitleBarTheme } from '@main/windows/titleBarTheme'; 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'; import type { UpdateStatus } from '@shared/contracts/ipc';
const { ipcMain } = electron; const { ipcMain } = electron;
@@ -348,4 +352,101 @@ export function registerIpcHandlers(
service.on('terminal-exit', (info) => { service.on('terminal-exit', (info) => {
window.webContents.send(ipcChannels.terminalExit, 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);
}
});
} }
+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');
}
+3 -1
View File
@@ -633,7 +633,7 @@ export class SessionTurnExecutor {
messageId: event.messageId, messageId: event.messageId,
authorName: event.authorName, authorName: event.authorName,
contentDelta: event.contentDelta, contentDelta: event.contentDelta,
content: event.content, content,
}); });
if (nextRun) { if (nextRun) {
this.emitRunUpdated(sessionId, occurredAt, nextRun); this.emitRunUpdated(sessionId, occurredAt, nextRun);
@@ -822,6 +822,7 @@ export class SessionTurnExecutor {
const completedAt = nowIso(); const completedAt = nowIso();
session.status = 'idle'; session.status = 'idle';
session.lastError = undefined; session.lastError = undefined;
session.currentIntent = undefined;
session.pendingUserInput = undefined; session.pendingUserInput = undefined;
session.pendingPlanReview = undefined; session.pendingPlanReview = undefined;
session.pendingMcpAuth = undefined; session.pendingMcpAuth = undefined;
@@ -854,6 +855,7 @@ export class SessionTurnExecutor {
const cancelledAt = nowIso(); const cancelledAt = nowIso();
session.status = 'idle'; session.status = 'idle';
session.lastError = undefined; session.lastError = undefined;
session.currentIntent = undefined;
session.pendingUserInput = undefined; session.pendingUserInput = undefined;
session.pendingPlanReview = undefined; session.pendingPlanReview = undefined;
session.pendingMcpAuth = undefined; session.pendingMcpAuth = undefined;
+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);
}
+2
View File
@@ -36,6 +36,8 @@ const api: ElectronApi = {
setNotificationsEnabled: (enabled) => ipcRenderer.invoke(ipcChannels.setNotificationsEnabled, enabled), setNotificationsEnabled: (enabled) => ipcRenderer.invoke(ipcChannels.setNotificationsEnabled, enabled),
setMinimizeToTray: (enabled) => ipcRenderer.invoke(ipcChannels.setMinimizeToTray, enabled), setMinimizeToTray: (enabled) => ipcRenderer.invoke(ipcChannels.setMinimizeToTray, enabled),
setGitAutoRefreshEnabled: (enabled) => ipcRenderer.invoke(ipcChannels.setGitAutoRefreshEnabled, enabled), setGitAutoRefreshEnabled: (enabled) => ipcRenderer.invoke(ipcChannels.setGitAutoRefreshEnabled, enabled),
getQuickPromptSettings: () => ipcRenderer.invoke(ipcChannels.quickPromptGetSettings),
setQuickPromptSettings: (settings) => ipcRenderer.invoke(ipcChannels.quickPromptSetSettings, settings),
checkForUpdates: () => ipcRenderer.invoke(ipcChannels.checkForUpdates), checkForUpdates: () => ipcRenderer.invoke(ipcChannels.checkForUpdates),
installUpdate: () => ipcRenderer.invoke(ipcChannels.installUpdate), installUpdate: () => ipcRenderer.invoke(ipcChannels.installUpdate),
saveMcpServer: (input) => ipcRenderer.invoke(ipcChannels.saveMcpServer, input), saveMcpServer: (input) => ipcRenderer.invoke(ipcChannels.saveMcpServer, 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);
+16 -1
View File
@@ -34,6 +34,7 @@ import { isScratchpadProject, SCRATCHPAD_PROJECT_ID } from '@shared/domain/proje
import type { ProjectGitFileReference } from '@shared/domain/project'; import type { ProjectGitFileReference } from '@shared/domain/project';
import { applySessionModelConfig } from '@shared/domain/session'; import { applySessionModelConfig } from '@shared/domain/session';
import type { AppearanceTheme, LspProfileDefinition, McpServerDefinition } from '@shared/domain/tooling'; 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 { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
import type { WorkspaceState } from '@shared/domain/workspace'; import type { WorkspaceState } from '@shared/domain/workspace';
import type { UpdateStatus } from '@shared/contracts/ipc'; import type { UpdateStatus } from '@shared/contracts/ipc';
@@ -59,7 +60,7 @@ const WelcomePane = lazy(() => import('@renderer/components/WelcomePane').then((
const WorkflowPicker = lazy(() => import('@renderer/components/workflow/WorkflowPicker').then((m) => ({ default: m.WorkflowPicker }))); 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 // 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' | 'troubleshooting'; type SettingsSection = 'appearance' | 'connection' | 'workflows' | 'agents' | 'mcp-servers' | 'lsp-profiles' | 'quick-prompt' | 'troubleshooting';
type BottomPanelTab = 'terminal' | 'git'; type BottomPanelTab = 'terminal' | 'git';
// Constants duplicated from BottomPanel to avoid importing the full module at startup // Constants duplicated from BottomPanel to avoid importing the full module at startup
@@ -180,6 +181,9 @@ export default function App() {
// Commit composer state // Commit composer state
const [commitComposerCtx, setCommitComposerCtx] = useState<{ projectId: string; sessionId: string; runId?: string }>(); const [commitComposerCtx, setCommitComposerCtx] = useState<{ projectId: string; sessionId: string; runId?: string }>();
// Quick prompt settings
const [quickPromptSettings, setQuickPromptSettings] = useState<QuickPromptSettings>(createDefaultQuickPromptSettings);
// Bottom panel state (terminal + git) // Bottom panel state (terminal + git)
const [bottomPanelOpen, setBottomPanelOpen] = useState(false); const [bottomPanelOpen, setBottomPanelOpen] = useState(false);
const [bottomPanelTab, setBottomPanelTab] = useState<BottomPanelTab>('terminal'); const [bottomPanelTab, setBottomPanelTab] = useState<BottomPanelTab>('terminal');
@@ -198,6 +202,11 @@ export default function App() {
.then((ws) => !disposed && setWorkspace(ws)) .then((ws) => !disposed && setWorkspace(ws))
.catch((e) => !disposed && setError(e instanceof Error ? e.message : String(e))); .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) => { const offWorkspace = api.onWorkspaceUpdated((ws) => {
setWorkspace(ws); setWorkspace(ws);
setError(undefined); setError(undefined);
@@ -858,6 +867,12 @@ export default function App() {
void api.resolveWorkspaceDiscoveredTooling({ serverIds, resolution }); void api.resolveWorkspaceDiscoveredTooling({ serverIds, resolution });
}} }}
onGetQuota={() => api.getQuota()} onGetQuota={() => api.getQuota()}
quickPromptSettings={quickPromptSettings}
onSetQuickPromptSettings={(patch) => {
const updated = { ...quickPromptSettings, ...patch };
setQuickPromptSettings(updated);
void api.setQuickPromptSettings(patch);
}}
/> />
</Suspense> </Suspense>
) : null; ) : null;
+2 -1
View File
@@ -530,7 +530,7 @@ export function ChatPane({
</div> </div>
)} )}
{isSessionBusy && !pendingApproval && !pendingUserInput && (() => { {isSessionBusy && !pendingApproval && !pendingUserInput && (() => {
const label = summarizeSessionActivity(sessionActivity); const label = session.currentIntent ?? summarizeSessionActivity(sessionActivity);
return ( return (
<div className="flex items-center gap-1.5 text-[12px] text-[var(--color-accent-sky)]"> <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)]" /> <span className="size-2 animate-pulse rounded-full bg-[var(--color-accent-sky)]" />
@@ -593,6 +593,7 @@ export function ChatPane({
isActive={isTurnActive(item, itemIndex)} isActive={isTurnActive(item, itemIndex)}
turnStartedAt={item.turnStartedAt} turnStartedAt={item.turnStartedAt}
sessionId={session.id} sessionId={session.id}
currentIntent={session.currentIntent}
agentNames={item.agentNames} agentNames={item.agentNames}
isLastRunPanel={item.isLastRunPanel} isLastRunPanel={item.isLastRunPanel}
onDiscard={onDiscardRunChanges} onDiscard={onDiscardRunChanges}
+234 -2
View File
@@ -1,5 +1,5 @@
import { useEffect, useState, type ReactNode } from 'react'; 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 } from 'lucide-react';
import { CopilotStatusCard } from '@renderer/components/CopilotStatusCard'; import { CopilotStatusCard } from '@renderer/components/CopilotStatusCard';
import { WorkflowEditor } from '@renderer/components/WorkflowEditor'; import { WorkflowEditor } from '@renderer/components/WorkflowEditor';
@@ -8,6 +8,7 @@ import { LspProfileEditor } from '@renderer/components/settings/LspProfileEditor
import { McpServerEditor } from '@renderer/components/settings/McpServerEditor'; import { McpServerEditor } from '@renderer/components/settings/McpServerEditor';
import { WorkspaceAgentEditor } from '@renderer/components/settings/WorkspaceAgentEditor'; import { WorkspaceAgentEditor } from '@renderer/components/settings/WorkspaceAgentEditor';
import { getElectronApi } from '@renderer/lib/electronApi'; import { getElectronApi } from '@renderer/lib/electronApi';
import { isMac } from '@renderer/lib/platform';
import type { SidecarCapabilities, QuotaSnapshot } from '@shared/contracts/sidecar'; import type { SidecarCapabilities, QuotaSnapshot } from '@shared/contracts/sidecar';
import type { DiscoveredMcpServer, DiscoveredToolingState } from '@shared/domain/discoveredTooling'; import type { DiscoveredMcpServer, DiscoveredToolingState } from '@shared/domain/discoveredTooling';
import { listAcceptedDiscoveredMcpServers, listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling'; import { listAcceptedDiscoveredMcpServers, listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
@@ -21,6 +22,7 @@ import {
type AppearanceTheme, type AppearanceTheme,
type LspProfileDefinition, type LspProfileDefinition,
type McpServerDefinition, type McpServerDefinition,
type QuickPromptSettings,
type WorkspaceToolingSettings, type WorkspaceToolingSettings,
} from '@shared/domain/tooling'; } from '@shared/domain/tooling';
import { normalizeWorkspaceAgentDefinition, findWorkspaceAgentUsages, type WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent'; import { normalizeWorkspaceAgentDefinition, findWorkspaceAgentUsages, type WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
@@ -62,9 +64,11 @@ interface SettingsPanelProps {
onGetQuota?: () => Promise<Record<string, QuotaSnapshot>>; onGetQuota?: () => Promise<Record<string, QuotaSnapshot>>;
workflowTemplates?: WorkflowTemplateDefinition[]; workflowTemplates?: WorkflowTemplateDefinition[];
onCreateWorkflowFromTemplate?: (templateId: string, name?: string) => Promise<void>; onCreateWorkflowFromTemplate?: (templateId: string, name?: string) => Promise<void>;
quickPromptSettings?: QuickPromptSettings;
onSetQuickPromptSettings?: (patch: Partial<QuickPromptSettings>) => 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' | 'troubleshooting';
interface NavItem { interface NavItem {
id: SettingsSection; id: SettingsSection;
@@ -82,6 +86,7 @@ const navGroups: NavGroup[] = [
label: 'General', label: 'General',
items: [ items: [
{ id: 'appearance', label: 'Appearance', icon: <Palette className="size-3.5" /> }, { id: 'appearance', label: 'Appearance', icon: <Palette className="size-3.5" /> },
{ id: 'quick-prompt', label: 'Quick Prompt', icon: <Sparkles className="size-3.5" /> },
], ],
}, },
{ {
@@ -149,6 +154,8 @@ export function SettingsPanel({
onGetQuota, onGetQuota,
workflowTemplates, workflowTemplates,
onCreateWorkflowFromTemplate, onCreateWorkflowFromTemplate,
quickPromptSettings,
onSetQuickPromptSettings,
}: SettingsPanelProps) { }: SettingsPanelProps) {
const [activeSection, setActiveSection] = useState<SettingsSection>(initialSection ?? 'appearance'); const [activeSection, setActiveSection] = useState<SettingsSection>(initialSection ?? 'appearance');
const [editingWorkflow, setEditingWorkflow] = useState<WorkflowDefinition | null>(null); const [editingWorkflow, setEditingWorkflow] = useState<WorkflowDefinition | null>(null);
@@ -380,6 +387,13 @@ export function SettingsPanel({
profiles={toolingSettings.lspProfiles} profiles={toolingSettings.lspProfiles}
/> />
)} )}
{activeSection === 'quick-prompt' && (
<QuickPromptSettingsSection
settings={quickPromptSettings}
availableModels={availableModels}
onUpdate={onSetQuickPromptSettings}
/>
)}
{activeSection === 'troubleshooting' && ( {activeSection === 'troubleshooting' && (
<TroubleshootingSection <TroubleshootingSection
onOpenAppDataFolder={onOpenAppDataFolder} onOpenAppDataFolder={onOpenAppDataFolder}
@@ -1304,3 +1318,221 @@ function TroubleshootingAction({
</button> </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;
const hotkeyKeys = hotkey
.replace('Super', isMac ? '⌘' : 'Win')
.replace('Alt', isMac ? '⌥' : 'Alt')
.replace('Shift', '⇧')
.split('+')
.map((k) => k.trim());
// 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 + Keyboard Shortcut — compact row */}
<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)]">
Global hotkey
</span>
<div className="mt-1.5 flex items-center gap-1">
{hotkeyKeys.map((key) => (
<kbd
key={key}
className="rounded-[5px] border border-[var(--color-border)] bg-[var(--color-surface-2)] px-2 py-[3px] font-mono text-[11px] font-medium leading-none text-[var(--color-text-secondary)] shadow-sm shadow-black/20"
>
{key}
</kbd>
))}
</div>
</div>
</button>
<button onClick={() => onUpdate?.({ enabled: !enabled })} type="button">
<ToggleSwitch enabled={enabled} />
</button>
</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>
);
}
@@ -41,6 +41,7 @@ export interface TurnActivityPanelProps {
isActive: boolean; isActive: boolean;
turnStartedAt?: string; turnStartedAt?: string;
sessionId: string; sessionId: string;
currentIntent?: string;
agentNames?: ReadonlySet<string>; agentNames?: ReadonlySet<string>;
isLastRunPanel?: boolean; isLastRunPanel?: boolean;
onDiscard?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise<unknown>; onDiscard?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise<unknown>;
@@ -418,6 +419,7 @@ export function TurnActivityPanel({
isActive, isActive,
turnStartedAt, turnStartedAt,
sessionId, sessionId,
currentIntent,
agentNames, agentNames,
isLastRunPanel, isLastRunPanel,
onDiscard, onDiscard,
@@ -471,8 +473,12 @@ export function TurnActivityPanel({
return groupActivityStream(stream); return groupActivityStream(stream);
}, [thinkingMessages, scopedEvents]); }, [thinkingMessages, scopedEvents]);
// Extract intent text for the header // Prefer the session-level normalized intent when active; fall back to
const intentText = useMemo(() => extractLatestIntent(scopedEvents), [scopedEvents]); // scanning run timeline for report_intent tool calls (backward compat).
const intentText = useMemo(
() => (isActive ? currentIntent : undefined) ?? extractLatestIntent(scopedEvents),
[isActive, currentIntent, scopedEvents],
);
const fallbackSummary = useMemo( const fallbackSummary = useMemo(
() => !intentText ? generateActivitySummary(scopedEvents) : undefined, () => !intentText ? generateActivitySummary(scopedEvents) : undefined,
[intentText, scopedEvents], [intentText, scopedEvents],
@@ -0,0 +1,200 @@
import { useEffect, useRef, useMemo } 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);
useEffect(() => {
const handleClickOutside = (e: MouseEvent) => {
if (containerRef.current && !containerRef.current.contains(e.target as Node)) {
onClose();
}
};
const handleEscape = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.stopPropagation();
onClose();
}
};
document.addEventListener('mousedown', handleClickOutside);
document.addEventListener('keydown', handleEscape, true);
return () => {
document.removeEventListener('mousedown', handleClickOutside);
document.removeEventListener('keydown', handleEscape, true);
};
}, [onClose]);
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]);
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)] shadow-2xl shadow-black/50"
role="listbox"
aria-label="Select model"
>
{/* 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) => {
const isSelected = model.id === selectedModelId;
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}
onClick={() => onSelect(model)}
className={`flex w-full items-center gap-2 rounded-lg px-3 py-[7px] text-left transition-colors ${
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>
{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>
);
}
@@ -0,0 +1,317 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import type { QuickPromptElectronApi, QuickPromptCapabilities } from '@shared/contracts/ipc';
import type { SessionEventRecord } from '@shared/domain/event';
import type { ReasoningEffort } from '@shared/domain/workflow';
import type { ModelDefinition } from '@shared/domain/models';
import { QuickPromptInput } from '@renderer/components/quick-prompt/QuickPromptInput';
import { QuickPromptResponse } from '@renderer/components/quick-prompt/QuickPromptResponse';
import { QuickPromptActions } from '@renderer/components/quick-prompt/QuickPromptActions';
declare global {
interface Window {
quickPromptApi: QuickPromptElectronApi;
}
}
type PromptPhase = 'idle' | 'streaming' | 'complete' | 'error';
/**
* Per-message state tracked by messageId, mirroring the main app's
* SessionRecord.messages model. This is necessary because the sidecar's
* fire-and-forget event handlers can emit events out of order e.g.
* `message-complete` and `status: idle` can arrive before all
* `message-delta` events have been emitted.
*/
interface TrackedMessage {
content: string;
messageKind?: string;
authorName: string;
pending: boolean;
finalized: boolean;
}
/** Derive display values from tracked messages. */
function deriveDisplay(messages: Map<string, TrackedMessage>) {
let content = '';
let thinkingContent = '';
let authorName = '';
let hasVisibleContent = false;
let allComplete = messages.size > 0;
for (const msg of messages.values()) {
if (msg.messageKind === 'thinking') {
if (msg.content) thinkingContent += (thinkingContent ? '\n' : '') + msg.content;
} else {
// Last non-thinking message wins as the response
content = msg.content;
authorName = msg.authorName;
if (msg.content.length > 0) hasVisibleContent = true;
}
if (msg.pending) allComplete = false;
}
return { content, thinkingContent, authorName, isComplete: allComplete && hasVisibleContent };
}
export function QuickPromptApp() {
const [phase, setPhase] = useState<PromptPhase>('idle');
const [displayContent, setDisplayContent] = useState('');
const [displayThinking, setDisplayThinking] = useState('');
const [displayAuthor, setDisplayAuthor] = useState('');
const [errorMessage, setErrorMessage] = useState<string>();
const [capabilities, setCapabilities] = useState<QuickPromptCapabilities>();
const [selectedModel, setSelectedModel] = useState<string>();
const [selectedReasoning, setSelectedReasoning] = useState<ReasoningEffort>();
const [visible, setVisible] = useState(false);
const sessionIdRef = useRef<string | null>(null);
const messagesRef = useRef<Map<string, TrackedMessage>>(new Map());
const api = window.quickPromptApi;
/** Push tracked-message state into React display state. */
const syncDisplay = useCallback(() => {
const display = deriveDisplay(messagesRef.current);
setDisplayContent(display.content);
setDisplayThinking(display.thinkingContent);
setDisplayAuthor(display.authorName);
return display;
}, []);
// Load capabilities on mount
useEffect(() => {
api.getCapabilities().then((caps) => {
setCapabilities(caps);
setSelectedModel(caps.defaultModel ?? caps.models[0]?.id);
setSelectedReasoning(caps.defaultReasoningEffort);
});
}, [api]);
// Subscribe to show/hide events from main process
useEffect(() => {
const offShow = api.onShow((theme: string) => {
const effective = theme === 'system'
? (window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light')
: theme;
document.documentElement.dataset.theme = effective;
setVisible(true);
resetState();
});
const offHide = api.onHide(() => setVisible(false));
return () => {
offShow();
offHide();
};
}, [api]);
// Subscribe to session events (streaming) — processes events using
// message-by-ID tracking that tolerates out-of-order delivery.
useEffect(() => {
const off = api.onSessionEvent((event: SessionEventRecord) => {
const msgs = messagesRef.current;
if (event.kind === 'message-delta' && event.messageId && (event.contentDelta || event.content !== undefined)) {
const existing = msgs.get(event.messageId);
if (existing) {
// Skip deltas that arrive after message-complete replaced the
// content with the authoritative final version.
if (existing.finalized) return;
if (event.content !== undefined) {
existing.content = event.content;
} else if (event.contentDelta) {
existing.content += event.contentDelta;
}
existing.authorName = event.authorName ?? existing.authorName;
} else {
msgs.set(event.messageId, {
content: event.contentDelta ?? event.content ?? '',
messageKind: event.messageKind,
authorName: event.authorName ?? '',
pending: true,
finalized: false,
});
}
syncDisplay();
setPhase('streaming');
} else if (event.kind === 'message-complete' && event.messageId) {
const existing = msgs.get(event.messageId);
if (existing) {
if (event.content !== undefined) existing.content = event.content;
existing.pending = false;
existing.finalized = true;
} else {
msgs.set(event.messageId, {
content: event.content ?? '',
messageKind: undefined,
authorName: event.authorName ?? '',
pending: false,
finalized: true,
});
}
const display = syncDisplay();
if (display.isComplete) setPhase('complete');
} else if (event.kind === 'message-reclassified' && event.messageId && event.messageKind) {
const existing = msgs.get(event.messageId);
if (existing) {
existing.messageKind = event.messageKind;
syncDisplay();
}
} else if (event.kind === 'status' && event.status === 'idle') {
for (const msg of msgs.values()) {
msg.pending = false;
}
const display = syncDisplay();
if (display.isComplete) setPhase('complete');
} else if (event.kind === 'error') {
setErrorMessage(event.error ?? 'An unexpected error occurred.');
setPhase('error');
}
});
return off;
}, [api, syncDisplay]);
const resetState = useCallback(() => {
setPhase('idle');
setDisplayContent('');
setDisplayThinking('');
setDisplayAuthor('');
setErrorMessage(undefined);
sessionIdRef.current = null;
messagesRef.current = new Map();
api.getCapabilities().then((caps) => {
setCapabilities(caps);
setSelectedModel(caps.defaultModel ?? caps.models[0]?.id);
setSelectedReasoning(caps.defaultReasoningEffort);
});
}, [api]);
const handleSend = useCallback(async (content: string) => {
if (!content.trim() || phase === 'streaming') return;
setPhase('streaming');
setDisplayContent('');
setDisplayThinking('');
setDisplayAuthor('');
setErrorMessage(undefined);
messagesRef.current = new Map();
try {
const result = await api.send({
content,
model: selectedModel,
reasoningEffort: selectedReasoning,
});
sessionIdRef.current = result.sessionId;
} catch (err) {
setErrorMessage(err instanceof Error ? err.message : 'Failed to send message.');
setPhase('error');
}
}, [api, phase, selectedModel, selectedReasoning]);
const handleCancel = useCallback(() => {
api.cancelTurn();
setPhase('complete');
}, [api]);
const handleDiscard = useCallback(() => {
api.discard();
resetState();
}, [api, resetState]);
const handleClose = useCallback(() => {
api.close();
resetState();
}, [api, resetState]);
const handleContinueInAryx = useCallback(() => {
api.continueInAryx();
resetState();
}, [api, resetState]);
const handleModelChange = useCallback((model: ModelDefinition) => {
setSelectedModel(model.id);
}, []);
const handleReasoningChange = useCallback((effort: ReasoningEffort | undefined) => {
setSelectedReasoning(effort);
}, []);
// Global keyboard handler
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === 'Escape') {
e.preventDefault();
if (phase === 'streaming') {
handleCancel();
} else if (phase === 'complete' || phase === 'error') {
handleClose();
} else {
api.close();
}
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [phase, handleCancel, handleClose, api]);
if (!visible) return null;
const hasResponse = phase !== 'idle';
const resolvedModel = capabilities?.models.find((m) => m.id === selectedModel);
return (
<div className="qp-container flex h-screen w-screen items-start justify-center pt-0">
<div
className={`qp-panel qp-panel-enter flex w-full max-w-[680px] flex-col rounded-2xl ${
phase === 'streaming' ? 'qp-border-streaming' : hasResponse ? 'qp-border-complete' : 'qp-border-idle'
}`}
role="dialog"
aria-modal="true"
aria-label="Quick Prompt"
>
{/* Input area */}
<QuickPromptInput
onSend={handleSend}
onCancel={handleCancel}
phase={phase}
models={capabilities?.models}
selectedModel={resolvedModel}
selectedReasoning={selectedReasoning}
onModelChange={handleModelChange}
onReasoningChange={handleReasoningChange}
/>
{/* Response area — grows dynamically */}
{hasResponse && (
<QuickPromptResponse
content={displayContent}
thinkingContent={displayThinking}
authorName={displayAuthor}
phase={phase}
error={errorMessage}
/>
)}
{/* Action bar */}
{(phase === 'complete' || phase === 'error') && (
<QuickPromptActions
onDiscard={handleDiscard}
onClose={handleClose}
onContinueInAryx={handleContinueInAryx}
/>
)}
</div>
</div>
);
}
@@ -0,0 +1,154 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ChevronDown, Loader2, Square, Sparkles } from 'lucide-react';
import type { ModelDefinition } from '@shared/domain/models';
import type { ReasoningEffort } from '@shared/domain/workflow';
import { ModelSelector } from '@renderer/components/quick-prompt/ModelSelector';
type PromptPhase = 'idle' | 'streaming' | 'complete' | 'error';
interface QuickPromptInputProps {
onSend: (content: string) => void;
onCancel: () => void;
phase: PromptPhase;
models?: ReadonlyArray<ModelDefinition>;
selectedModel?: ModelDefinition;
selectedReasoning?: ReasoningEffort;
onModelChange: (model: ModelDefinition) => void;
onReasoningChange: (effort: ReasoningEffort | undefined) => void;
}
export function QuickPromptInput({
onSend,
onCancel,
phase,
models,
selectedModel,
selectedReasoning,
onModelChange,
onReasoningChange,
}: QuickPromptInputProps) {
const [value, setValue] = useState('');
const [modelSelectorOpen, setModelSelectorOpen] = useState(false);
const textareaRef = useRef<HTMLTextAreaElement>(null);
// Auto-focus when phase resets to idle
useEffect(() => {
if (phase === 'idle') {
setTimeout(() => textareaRef.current?.focus(), 50);
}
}, [phase]);
// Auto-resize textarea
useEffect(() => {
const el = textareaRef.current;
if (!el) return;
el.style.height = 'auto';
el.style.height = `${Math.min(el.scrollHeight, 120)}px`;
}, [value]);
const handleKeyDown = useCallback(
(e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
if (phase === 'idle' && value.trim()) {
onSend(value);
}
}
},
[onSend, value, phase],
);
const reasoningLabel = selectedReasoning
? selectedReasoning === 'xhigh' ? 'xHigh' : selectedReasoning.charAt(0).toUpperCase() + selectedReasoning.slice(1)
: undefined;
return (
<div className="relative flex flex-col">
{/* Primary input area */}
<div className="flex items-start gap-3 px-5 pt-4 pb-2">
<div className="mt-1 flex-none">
{phase === 'streaming' ? (
<Loader2 className="size-[18px] animate-spin text-[var(--color-accent)]" aria-label="Processing" />
) : (
<Sparkles className="size-[18px] text-[var(--color-text-muted)]" />
)}
</div>
<textarea
ref={textareaRef}
value={value}
onChange={(e) => setValue(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Ask anything…"
disabled={phase === 'streaming'}
rows={1}
className="min-h-[28px] max-h-[120px] flex-1 resize-none bg-transparent font-[var(--font-body)] text-[14px] leading-[1.6] text-[var(--color-text-primary)] outline-none placeholder:text-[var(--color-text-muted)]/60 disabled:opacity-40"
aria-label="Quick prompt input"
/>
{phase === 'streaming' && (
<button
onClick={onCancel}
className="mt-0.5 flex flex-none items-center gap-1.5 rounded-md border border-[var(--color-border)] bg-[var(--color-surface-2)] px-2.5 py-1 text-[11px] font-medium text-[var(--color-text-secondary)] transition hover:border-[var(--color-status-error)]/40 hover:bg-[var(--color-status-error)]/10 hover:text-[var(--color-status-error)]"
type="button"
aria-label="Stop generating"
>
<Square className="size-2.5 fill-current" />
Stop
</button>
)}
</div>
{/* Footer bar: model selector + shortcuts */}
<div className="flex items-center gap-2 border-t border-[var(--color-border-subtle)]/60 px-5 py-2">
<button
onClick={() => setModelSelectorOpen((prev) => !prev)}
className={`flex items-center gap-1.5 rounded-lg px-2.5 py-1.5 text-[11px] transition ${
modelSelectorOpen
? 'bg-[var(--color-accent-muted)] text-[var(--color-text-accent)]'
: 'text-[var(--color-text-muted)] hover:bg-[var(--color-surface-3)] hover:text-[var(--color-text-secondary)]'
}`}
type="button"
aria-haspopup="listbox"
aria-expanded={modelSelectorOpen}
>
<span className="max-w-[160px] truncate font-medium">{selectedModel?.name ?? 'Select model'}</span>
{reasoningLabel && (
<span className="rounded-[4px] bg-[var(--color-accent)]/15 px-1.5 py-px text-[9px] font-semibold tracking-wide text-[var(--color-text-accent)] uppercase">
{reasoningLabel}
</span>
)}
<ChevronDown className={`size-3 transition-transform ${modelSelectorOpen ? 'rotate-180' : ''}`} />
</button>
<span className="ml-auto flex items-center gap-3 text-[10px] text-[var(--color-text-muted)] select-none opacity-50">
<kbd className="rounded border border-[var(--color-border-subtle)] bg-[var(--color-surface-2)] px-1 py-px font-mono text-[9px]">
</kbd>
<span>Send</span>
<kbd className="rounded border border-[var(--color-border-subtle)] bg-[var(--color-surface-2)] px-1 py-px font-mono text-[9px]">
Esc
</kbd>
<span>Dismiss</span>
</span>
</div>
{/* Model selector dropdown */}
{modelSelectorOpen && models && (
<ModelSelector
models={models}
selectedModelId={selectedModel?.id}
selectedReasoning={selectedReasoning}
onSelect={(model) => {
onModelChange(model);
setModelSelectorOpen(false);
}}
onReasoningChange={onReasoningChange}
onClose={() => setModelSelectorOpen(false)}
/>
)}
</div>
);
}
@@ -0,0 +1,89 @@
import { useEffect, useRef } from 'react';
import ReactMarkdown from 'react-markdown';
import remarkGfm from 'remark-gfm';
import { AlertCircle, Brain } from 'lucide-react';
type PromptPhase = 'idle' | 'streaming' | 'complete' | 'error';
interface QuickPromptResponseProps {
content: string;
thinkingContent: string;
authorName: string;
phase: PromptPhase;
error?: string;
}
export function QuickPromptResponse({
content,
thinkingContent,
phase,
error,
}: QuickPromptResponseProps) {
const scrollRef = useRef<HTMLDivElement>(null);
// Auto-scroll to bottom during streaming
useEffect(() => {
if (phase === 'streaming' && scrollRef.current) {
scrollRef.current.scrollTop = scrollRef.current.scrollHeight;
}
}, [content, thinkingContent, phase]);
return (
<div
ref={scrollRef}
className="qp-response-enter max-h-[min(50vh,480px)] overflow-y-auto overscroll-contain border-t border-[var(--color-border-subtle)]/60"
>
{/* Error state */}
{phase === 'error' && error && (
<div className="flex items-start gap-3 px-5 py-4">
<AlertCircle className="mt-0.5 size-4 flex-none text-[var(--color-status-error)]" />
<div>
<p className="text-[12px] font-semibold text-[var(--color-status-error)]">Something went wrong</p>
<p className="mt-1 text-[12px] leading-relaxed text-[var(--color-status-error)]/80">{error}</p>
</div>
</div>
)}
{/* Thinking block — compact collapsed visualization */}
{thinkingContent && (
<div className="mx-5 mt-3 mb-1 rounded-lg border border-[var(--color-border-subtle)]/50 bg-[var(--color-surface-0)]/40 px-3.5 py-2.5">
<div className="flex items-center gap-2 text-[10px] font-semibold tracking-wide text-[var(--color-text-muted)] uppercase">
<Brain className="size-3" />
<span>Reasoning</span>
{phase === 'streaming' && !content && (
<span className="ml-0.5 flex gap-[3px]">
<span className="thinking-dot inline-block size-[3px] rounded-full bg-[var(--color-text-muted)]" />
<span className="thinking-dot inline-block size-[3px] rounded-full bg-[var(--color-text-muted)]" />
<span className="thinking-dot inline-block size-[3px] rounded-full bg-[var(--color-text-muted)]" />
</span>
)}
</div>
<p className="mt-1 text-[11px] leading-relaxed text-[var(--color-text-muted)]/70 italic line-clamp-2">
{thinkingContent.slice(-200)}
</p>
</div>
)}
{/* Main response content */}
{content && (
<div className="px-5 py-3.5">
<div className="markdown-content text-[13px] leading-[1.7] text-[var(--color-text-primary)]">
<ReactMarkdown remarkPlugins={[remarkGfm]}>{content}</ReactMarkdown>
</div>
</div>
)}
{/* Streaming indicator when no content yet */}
{phase === 'streaming' && !content && !thinkingContent && (
<div className="flex items-center gap-3 px-5 py-4">
<span className="flex gap-[3px]">
<span className="thinking-dot inline-block size-[5px] rounded-full bg-[var(--color-accent)]" />
<span className="thinking-dot inline-block size-[5px] rounded-full bg-[var(--color-accent)]" />
<span className="thinking-dot inline-block size-[5px] rounded-full bg-[var(--color-accent)]" />
</span>
<span className="text-[12px] text-[var(--color-text-muted)]">Generating</span>
</div>
)}
</div>
);
}
+24 -3
View File
@@ -2,7 +2,6 @@ import type { SessionEventRecord } from '@shared/domain/event';
import { upsertSessionRunRecord } from '@shared/domain/runTimeline'; import { upsertSessionRunRecord } from '@shared/domain/runTimeline';
import type { ChatMessageRecord, SessionRecord } from '@shared/domain/session'; import type { ChatMessageRecord, SessionRecord } from '@shared/domain/session';
import type { WorkspaceState } from '@shared/domain/workspace'; import type { WorkspaceState } from '@shared/domain/workspace';
import { mergeStreamingText } from '@shared/utils/streamingText';
export function applySessionEventWorkspace( export function applySessionEventWorkspace(
current: WorkspaceState | undefined, current: WorkspaceState | undefined,
@@ -45,6 +44,8 @@ function applySessionEvent(session: SessionRecord, event: SessionEventRecord): S
return applyMessageReclassifiedEvent(session, event); return applyMessageReclassifiedEvent(session, event);
case 'run-updated': case 'run-updated':
return applyRunUpdatedEvent(session, event); return applyRunUpdatedEvent(session, event);
case 'assistant-intent':
return applyAssistantIntentEvent(session, event);
default: default:
return session; return session;
} }
@@ -63,6 +64,7 @@ function applyStatusEvent(session: SessionRecord, event: SessionEventRecord): Se
...session, ...session,
status: event.status, status: event.status,
lastError: event.status === 'error' ? session.lastError : undefined, lastError: event.status === 'error' ? session.lastError : undefined,
currentIntent: event.status === 'idle' ? undefined : session.currentIntent,
updatedAt: event.occurredAt, updatedAt: event.occurredAt,
}; };
} }
@@ -86,14 +88,19 @@ function applyMessageDeltaEvent(session: SessionRecord, event: SessionEventRecor
return session; return session;
} }
const resolvedContent = event.content ?? event.contentDelta ?? '';
const messageIndex = session.messages.findIndex((message) => message.id === event.messageId); const messageIndex = session.messages.findIndex((message) => message.id === event.messageId);
if (messageIndex >= 0) { if (messageIndex >= 0) {
const existing = session.messages[messageIndex]; const existing = session.messages[messageIndex];
// The main process resolves content authoritatively; prefer event.content
// (full assembled text) and fall back to simple append for backward compat.
const nextContent =
event.content !== undefined
? event.content
: existing.content + (event.contentDelta ?? '');
const nextMessage: ChatMessageRecord = { const nextMessage: ChatMessageRecord = {
...existing, ...existing,
authorName: event.authorName ?? existing.authorName, authorName: event.authorName ?? existing.authorName,
content: event.content ?? mergeStreamingText(existing.content, resolvedContent), content: nextContent,
pending: true, pending: true,
}; };
@@ -114,6 +121,8 @@ function applyMessageDeltaEvent(session: SessionRecord, event: SessionEventRecor
}; };
} }
const resolvedContent = event.content ?? event.contentDelta ?? '';
// Auto-complete any previously pending assistant messages so only // Auto-complete any previously pending assistant messages so only
// the new message shows the "Thinking" indicator. // the new message shows the "Thinking" indicator.
const completedMessages = session.messages.map((message) => const completedMessages = session.messages.map((message) =>
@@ -217,3 +226,15 @@ function applyRunUpdatedEvent(session: SessionRecord, event: SessionEventRecord)
updatedAt: event.occurredAt, updatedAt: event.occurredAt,
}; };
} }
function applyAssistantIntentEvent(session: SessionRecord, event: SessionEventRecord): SessionRecord {
if (!event.intent || session.currentIntent === event.intent) {
return session;
}
return {
...session,
currentIntent: event.intent,
updatedAt: event.occurredAt,
};
}
+18
View File
@@ -0,0 +1,18 @@
<!doctype html>
<html lang="en" style="background: transparent;">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0"
/>
<title>Aryx Quick Prompt</title>
</head>
<body data-quickprompt style="margin: 0; background: transparent; overflow: hidden;">
<div id="root"></div>
<script
type="module"
src="./quickprompt.tsx"
></script>
</body>
</html>
+20
View File
@@ -0,0 +1,20 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import '@fontsource-variable/outfit';
import '@fontsource-variable/dm-sans';
import '@fontsource-variable/jetbrains-mono';
import { QuickPromptApp } from '@renderer/components/quick-prompt/QuickPromptApp';
import '@renderer/styles.css';
const container = document.getElementById('root');
if (!container) {
throw new Error('Could not find the root element.');
}
createRoot(container).render(
<StrictMode>
<QuickPromptApp />
</StrictMode>,
);
+155 -1
View File
@@ -689,9 +689,124 @@ body {
.update-banner-enter, .update-banner-enter,
.turn-activity-enter, .turn-activity-enter,
.turn-activity-row, .turn-activity-row,
.turn-activity-row[role="separator"] { .turn-activity-row[role="separator"],
.qp-panel-enter,
.qp-response-enter,
.qp-actions-enter,
.qp-dropdown-enter {
animation: none; animation: none;
} }
.qp-border-streaming {
animation: none;
border-color: var(--color-accent);
}
}
/* ── Quick Prompt — Glass Command Bar ────────────────────────── */
/* Override body/root backgrounds when inside the quickprompt window so the
transparent BrowserWindow doesn't show a colored rectangle behind the
rounded panel. The data attribute is set on the <body> in quickprompt.html. */
body[data-quickprompt],
body[data-quickprompt] #root {
background: transparent !important;
min-height: 0 !important;
}
html:has(body[data-quickprompt]) {
background: transparent !important;
}
.qp-container {
background: transparent;
}
.qp-panel {
background: var(--color-surface-1);
/* No box-shadow on Windows transparent windows, shadows paint visibly
on the transparent canvas and create a visible dark rectangle. */
transition: border-color 0.3s ease;
}
/* Border states */
.qp-border-idle {
border: 1px solid var(--color-glass-border);
}
.qp-border-complete {
border: 1px solid var(--color-border-glow);
}
/* Animated breathing border during streaming */
@keyframes qp-border-breathe {
0%, 100% {
border-color: rgba(36, 92, 249, 0.35);
}
50% {
border-color: rgba(138, 41, 230, 0.4);
}
}
.qp-border-streaming {
border: 1px solid rgba(36, 92, 249, 0.35);
animation: qp-border-breathe 2.5s ease-in-out infinite;
}
/* Panel entrance animation */
@keyframes qp-panel-in {
from {
opacity: 0;
transform: scale(0.96) translateY(-6px);
}
}
.qp-panel-enter {
animation: qp-panel-in 0.2s cubic-bezier(0.16, 1, 0.3, 1);
}
/* Response area entrance */
@keyframes qp-response-in {
from {
opacity: 0;
max-height: 0;
}
to {
opacity: 1;
max-height: min(55vh, 520px);
}
}
.qp-response-enter {
animation: qp-response-in 0.25s cubic-bezier(0.16, 1, 0.3, 1);
}
/* Action bar entrance */
@keyframes qp-actions-in {
from {
opacity: 0;
transform: translateY(4px);
}
}
.qp-actions-enter {
animation: qp-actions-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) 0.05s both;
}
/* Dropdown entrance — opens downward */
@keyframes qp-dropdown-in {
from {
opacity: 0;
transform: translateY(-6px) scale(0.97);
}
to {
opacity: 1;
transform: translateY(0) scale(1);
}
}
.qp-dropdown-enter {
animation: qp-dropdown-in 0.15s cubic-bezier(0.16, 1, 0.3, 1);
} }
/* ── Markdown prose styles ───────────────────────────────────── */ /* ── Markdown prose styles ───────────────────────────────────── */
@@ -800,3 +915,42 @@ body {
max-width: 100%; max-width: 100%;
border-radius: 0.5rem; border-radius: 0.5rem;
} }
.markdown-content code {
font-family: var(--font-mono);
font-size: 0.85em;
background: var(--color-surface-2);
border: 1px solid var(--color-border-subtle);
border-radius: 0.25rem;
padding: 0.15em 0.35em;
color: var(--color-text-primary);
}
.markdown-content pre {
background: var(--color-surface-0);
border: 1px solid var(--color-border);
border-radius: 0.5rem;
padding: 0.875em 1em;
overflow-x: auto;
margin-top: 0.75em;
margin-bottom: 0.75em;
}
.markdown-content pre code {
background: none;
border: none;
padding: 0;
font-size: 0.8125rem;
line-height: 1.6;
}
.markdown-content a {
color: var(--color-text-accent);
text-decoration: underline;
text-decoration-color: var(--color-text-accent);
text-underline-offset: 2px;
}
.markdown-content a:hover {
color: var(--color-accent-hover);
}
+14
View File
@@ -83,4 +83,18 @@ export const ipcChannels = {
updateStatus: 'app:update-status', updateStatus: 'app:update-status',
getQuota: 'sidecar:get-quota', getQuota: 'sidecar:get-quota',
trayCreateScratchpad: 'tray:create-scratchpad', trayCreateScratchpad: 'tray:create-scratchpad',
// Quick Prompt
quickPromptSend: 'quick-prompt:send',
quickPromptDiscard: 'quick-prompt:discard',
quickPromptClose: 'quick-prompt:close',
quickPromptContinueInAryx: 'quick-prompt:continue-in-aryx',
quickPromptCancelTurn: 'quick-prompt:cancel-turn',
quickPromptSetSettings: 'quick-prompt:set-settings',
quickPromptGetSettings: 'quick-prompt:get-settings',
quickPromptGetCapabilities: 'quick-prompt:get-capabilities',
quickPromptSessionCreated: 'quick-prompt:session-created',
quickPromptSessionEvent: 'quick-prompt:session-event',
quickPromptShow: 'quick-prompt:show',
quickPromptHide: 'quick-prompt:hide',
} as const; } as const;
+35
View File
@@ -19,11 +19,13 @@ import type {
McpServerDefinition, McpServerDefinition,
SessionToolingSelection, SessionToolingSelection,
AppearanceTheme, AppearanceTheme,
QuickPromptSettings,
} from '@shared/domain/tooling'; } from '@shared/domain/tooling';
import type { WorkspaceState } from '@shared/domain/workspace'; import type { WorkspaceState } from '@shared/domain/workspace';
import type { ChatMessageAttachment } from '@shared/domain/attachment'; import type { ChatMessageAttachment } from '@shared/domain/attachment';
import type { ProjectPromptInvocation } from '@shared/domain/projectCustomization'; import type { ProjectPromptInvocation } from '@shared/domain/projectCustomization';
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent'; import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
import type { ModelDefinition } from '@shared/domain/models';
export interface CreateSessionInput { export interface CreateSessionInput {
projectId: string; projectId: string;
@@ -381,9 +383,42 @@ export interface ElectronApi {
onSessionEvent(listener: (event: SessionEventRecord) => void): () => void; onSessionEvent(listener: (event: SessionEventRecord) => void): () => void;
onUpdateStatus(listener: (status: UpdateStatus) => void): () => void; onUpdateStatus(listener: (status: UpdateStatus) => void): () => void;
onTrayCreateScratchpad(listener: () => void): () => void; onTrayCreateScratchpad(listener: () => void): () => void;
getQuickPromptSettings(): Promise<QuickPromptSettings>;
setQuickPromptSettings(settings: Partial<QuickPromptSettings>): Promise<void>;
} }
export interface RendererSelectionState { export interface RendererSelectionState {
selectedProject?: ProjectRecord; selectedProject?: ProjectRecord;
selectedWorkflow?: WorkflowDefinition; selectedWorkflow?: WorkflowDefinition;
} }
// --- Quick Prompt contracts ---
export interface QuickPromptSendInput {
content: string;
model?: string;
reasoningEffort?: ReasoningEffort;
}
export interface QuickPromptSessionInfo {
sessionId: string;
}
export interface QuickPromptCapabilities {
models: ReadonlyArray<ModelDefinition>;
defaultModel?: string;
defaultReasoningEffort?: ReasoningEffort;
}
export interface QuickPromptElectronApi {
send(input: QuickPromptSendInput): Promise<QuickPromptSessionInfo>;
discard(): Promise<void>;
close(): Promise<void>;
continueInAryx(): Promise<void>;
cancelTurn(): Promise<void>;
getCapabilities(): Promise<QuickPromptCapabilities>;
setSettings(settings: Partial<QuickPromptSettings>): Promise<void>;
onSessionEvent(listener: (event: SessionEventRecord) => void): () => void;
onShow(listener: (theme: string) => void): () => void;
onHide(listener: () => void): () => void;
}
+9
View File
@@ -31,6 +31,8 @@ export type SessionEventKind =
| 'session-compaction' | 'session-compaction'
| 'pending-messages-modified' | 'pending-messages-modified'
| 'assistant-usage' | 'assistant-usage'
| 'assistant-intent'
| 'reasoning-delta'
| 'workflow-diagnostic'; | 'workflow-diagnostic';
export type SubagentEventKind = 'started' | 'completed' | 'failed' | 'selected' | 'deselected'; export type SubagentEventKind = 'started' | 'completed' | 'failed' | 'selected' | 'deselected';
@@ -102,6 +104,13 @@ export interface SessionEventRecord {
usageTotalNanoAiu?: number; usageTotalNanoAiu?: number;
usageQuotaSnapshots?: Record<string, QuotaSnapshot>; usageQuotaSnapshots?: Record<string, QuotaSnapshot>;
// Assistant intent fields
intent?: string;
// Reasoning delta fields
reasoningId?: string;
reasoningDelta?: string;
// Workflow diagnostic fields // Workflow diagnostic fields
diagnosticSeverity?: WorkflowDiagnosticSeverity; diagnosticSeverity?: WorkflowDiagnosticSeverity;
diagnosticKind?: WorkflowDiagnosticKind; diagnosticKind?: WorkflowDiagnosticKind;
+1
View File
@@ -85,6 +85,7 @@ export interface SessionRecord {
pendingPlanReview?: PendingPlanReviewRecord; pendingPlanReview?: PendingPlanReviewRecord;
pendingMcpAuth?: PendingMcpAuthRecord; pendingMcpAuth?: PendingMcpAuthRecord;
runs: SessionRunRecord[]; runs: SessionRunRecord[];
currentIntent?: string;
} }
function normalizeOptionalString(value?: string): string | undefined { function normalizeOptionalString(value?: string): string | undefined {
+16
View File
@@ -61,6 +61,20 @@ export interface WorkspaceToolingSettings {
export type AppearanceTheme = 'dark' | 'light' | 'system'; export type AppearanceTheme = 'dark' | 'light' | 'system';
export interface QuickPromptSettings {
enabled: boolean;
hotkey: string;
defaultModel?: string;
defaultReasoningEffort?: 'low' | 'medium' | 'high' | 'xhigh';
}
export function createDefaultQuickPromptSettings(): QuickPromptSettings {
return {
enabled: true,
hotkey: 'Alt+Shift+C',
};
}
export interface WorkspaceSettings { export interface WorkspaceSettings {
theme: AppearanceTheme; theme: AppearanceTheme;
tooling: WorkspaceToolingSettings; tooling: WorkspaceToolingSettings;
@@ -70,6 +84,7 @@ export interface WorkspaceSettings {
notificationsEnabled?: boolean; notificationsEnabled?: boolean;
minimizeToTray?: boolean; minimizeToTray?: boolean;
gitAutoRefreshEnabled?: boolean; gitAutoRefreshEnabled?: boolean;
quickPrompt?: QuickPromptSettings;
} }
export interface SessionToolingSelection { export interface SessionToolingSelection {
@@ -215,6 +230,7 @@ export function normalizeWorkspaceSettings(settings?: Partial<WorkspaceSettings>
...(settings?.notificationsEnabled !== undefined ? { notificationsEnabled: settings.notificationsEnabled } : {}), ...(settings?.notificationsEnabled !== undefined ? { notificationsEnabled: settings.notificationsEnabled } : {}),
...(settings?.minimizeToTray !== undefined ? { minimizeToTray: settings.minimizeToTray } : {}), ...(settings?.minimizeToTray !== undefined ? { minimizeToTray: settings.minimizeToTray } : {}),
...(settings?.gitAutoRefreshEnabled !== undefined ? { gitAutoRefreshEnabled: settings.gitAutoRefreshEnabled } : {}), ...(settings?.gitAutoRefreshEnabled !== undefined ? { gitAutoRefreshEnabled: settings.gitAutoRefreshEnabled } : {}),
...(settings?.quickPrompt !== undefined ? { quickPrompt: settings.quickPrompt } : {}),
}; };
} }
+351
View File
@@ -351,4 +351,355 @@ describe('session workspace helpers', () => {
expect(result).toBe(workspace); expect(result).toBe(workspace);
}); });
test('uses resolved content from main process and falls back to simple append', () => {
// When content is present (main process resolved it), use it directly
const first = applySessionEventWorkspace(createWorkspace(), {
sessionId: 'session-1',
kind: 'message-delta',
occurredAt: '2026-03-23T00:00:01.000Z',
messageId: 'assistant-1',
authorName: 'Agent',
contentDelta: 'Hello',
content: 'Hello',
} satisfies SessionEventRecord);
// Main process sends full resolved content on every delta
const second = applySessionEventWorkspace(first, {
sessionId: 'session-1',
kind: 'message-delta',
occurredAt: '2026-03-23T00:00:02.000Z',
messageId: 'assistant-1',
authorName: 'Agent',
contentDelta: ' world',
content: 'Hello world',
} satisfies SessionEventRecord);
expect(second?.sessions[0].messages[0].content).toBe('Hello world');
// Backward compat: if content is missing, simple append of contentDelta
const fallback = applySessionEventWorkspace(second, {
sessionId: 'session-1',
kind: 'message-delta',
occurredAt: '2026-03-23T00:00:03.000Z',
messageId: 'assistant-1',
authorName: 'Agent',
contentDelta: '!',
} satisfies SessionEventRecord);
expect(fallback?.sessions[0].messages[0].content).toBe('Hello world!');
});
test('sets currentIntent from assistant-intent events', () => {
const workspace = createWorkspace();
const updated = applySessionEventWorkspace(workspace, {
sessionId: 'session-1',
kind: 'assistant-intent',
occurredAt: '2026-03-23T00:00:01.000Z',
intent: 'Exploring codebase',
} satisfies SessionEventRecord);
expect(updated?.sessions[0].currentIntent).toBe('Exploring codebase');
});
test('clears currentIntent when session transitions to idle', () => {
let workspace = applySessionEventWorkspace(createWorkspace(), {
sessionId: 'session-1',
kind: 'status',
occurredAt: '2026-03-23T00:00:01.000Z',
status: 'running',
} satisfies SessionEventRecord);
workspace = applySessionEventWorkspace(workspace, {
sessionId: 'session-1',
kind: 'assistant-intent',
occurredAt: '2026-03-23T00:00:02.000Z',
intent: 'Writing tests',
} satisfies SessionEventRecord);
expect(workspace?.sessions[0].currentIntent).toBe('Writing tests');
workspace = applySessionEventWorkspace(workspace, {
sessionId: 'session-1',
kind: 'status',
occurredAt: '2026-03-23T00:00:03.000Z',
status: 'idle',
} satisfies SessionEventRecord);
expect(workspace?.sessions[0].currentIntent).toBeUndefined();
});
test('ignores duplicate assistant-intent events', () => {
const workspace = applySessionEventWorkspace(createWorkspace(), {
sessionId: 'session-1',
kind: 'assistant-intent',
occurredAt: '2026-03-23T00:00:01.000Z',
intent: 'Exploring codebase',
} satisfies SessionEventRecord);
const duplicate = applySessionEventWorkspace(workspace, {
sessionId: 'session-1',
kind: 'assistant-intent',
occurredAt: '2026-03-23T00:00:02.000Z',
intent: 'Exploring codebase',
} satisfies SessionEventRecord);
expect(duplicate).toBe(workspace);
});
test('preserves currentIntent when status changes to running', () => {
let workspace = applySessionEventWorkspace(createWorkspace(), {
sessionId: 'session-1',
kind: 'assistant-intent',
occurredAt: '2026-03-23T00:00:01.000Z',
intent: 'Analyzing code',
} satisfies SessionEventRecord);
workspace = applySessionEventWorkspace(workspace, {
sessionId: 'session-1',
kind: 'status',
occurredAt: '2026-03-23T00:00:02.000Z',
status: 'running',
} satisfies SessionEventRecord);
expect(workspace?.sessions[0].currentIntent).toBe('Analyzing code');
});
/* ── Streaming regression tests ──────────────────────────── */
test('thinking-to-response transition: reclassified message stays hidden, new message becomes final', () => {
// Step 1: first message streams as normal assistant content
let ws = applySessionEventWorkspace(createWorkspace(), {
sessionId: 'session-1',
kind: 'message-delta',
occurredAt: '2026-03-23T00:00:01.000Z',
messageId: 'assistant-1',
authorName: 'Agent',
contentDelta: 'Let me look into this...',
content: 'Let me look into this...',
} satisfies SessionEventRecord);
// Step 2: reclassified as thinking (the agent decided to search first)
ws = applySessionEventWorkspace(ws, {
sessionId: 'session-1',
kind: 'message-reclassified',
occurredAt: '2026-03-23T00:00:02.000Z',
messageId: 'assistant-1',
messageKind: 'thinking',
} satisfies SessionEventRecord);
expect(ws?.sessions[0].messages[0]).toMatchObject({
id: 'assistant-1',
messageKind: 'thinking',
pending: true,
});
// Step 3: a new actual response message starts streaming
ws = applySessionEventWorkspace(ws, {
sessionId: 'session-1',
kind: 'message-delta',
occurredAt: '2026-03-23T00:00:03.000Z',
messageId: 'assistant-2',
authorName: 'Agent',
contentDelta: 'Here is the answer.',
content: 'Here is the answer.',
} satisfies SessionEventRecord);
// The thinking message should be auto-completed, new one is pending
expect(ws?.sessions[0].messages).toHaveLength(2);
expect(ws?.sessions[0].messages[0]).toMatchObject({
id: 'assistant-1',
messageKind: 'thinking',
pending: false,
});
expect(ws?.sessions[0].messages[1]).toMatchObject({
id: 'assistant-2',
content: 'Here is the answer.',
pending: true,
});
});
test('concurrent multi-message streaming keeps messages independent', () => {
// Two different assistant messages stream interleaved (e.g. multi-agent)
let ws = applySessionEventWorkspace(createWorkspace(), {
sessionId: 'session-1',
kind: 'message-delta',
occurredAt: '2026-03-23T00:00:01.000Z',
messageId: 'msg-agent-a',
authorName: 'Architect',
contentDelta: 'Design: ',
content: 'Design: ',
} satisfies SessionEventRecord);
ws = applySessionEventWorkspace(ws, {
sessionId: 'session-1',
kind: 'message-delta',
occurredAt: '2026-03-23T00:00:02.000Z',
messageId: 'msg-agent-a',
authorName: 'Architect',
contentDelta: 'Use modules.',
content: 'Design: Use modules.',
} satisfies SessionEventRecord);
// Complete one message, verify others are unaffected
ws = applySessionEventWorkspace(ws, {
sessionId: 'session-1',
kind: 'message-complete',
occurredAt: '2026-03-23T00:00:04.000Z',
messageId: 'msg-agent-a',
authorName: 'Architect',
content: 'Design: Use modules.',
} satisfies SessionEventRecord);
expect(ws?.sessions[0].messages).toHaveLength(1);
expect(ws?.sessions[0].messages[0]).toMatchObject({
id: 'msg-agent-a',
content: 'Design: Use modules.',
pending: false,
});
});
test('run-updated replaces existing run by id and handles new runs', () => {
const runV1: SessionRunRecord = {
id: 'run-1',
requestId: 'turn-1',
projectId: 'project-1',
projectPath: 'C:\\workspace',
workspaceKind: 'project',
workflowId: 'workflow-1',
workflowName: 'Review',
workflowMode: 'sequential',
triggerMessageId: 'msg-user-1',
startedAt: '2026-03-23T00:00:01.000Z',
status: 'running',
agents: [],
events: [],
};
let ws = applySessionEventWorkspace(createWorkspace(), {
sessionId: 'session-1',
kind: 'run-updated',
occurredAt: '2026-03-23T00:00:01.000Z',
run: runV1,
} satisfies SessionEventRecord);
expect(ws?.sessions[0].runs).toHaveLength(1);
expect(ws?.sessions[0].runs[0].status).toBe('running');
// Run updated with new status and events
const runV2: SessionRunRecord = {
...runV1,
status: 'completed',
events: [
{
id: 'evt-1',
kind: 'run-started',
occurredAt: '2026-03-23T00:00:01.000Z',
status: 'completed',
},
],
};
ws = applySessionEventWorkspace(ws, {
sessionId: 'session-1',
kind: 'run-updated',
occurredAt: '2026-03-23T00:00:02.000Z',
run: runV2,
} satisfies SessionEventRecord);
// Should replace, not duplicate
expect(ws?.sessions[0].runs).toHaveLength(1);
expect(ws?.sessions[0].runs[0].status).toBe('completed');
expect(ws?.sessions[0].runs[0].events).toHaveLength(1);
});
test('full turn lifecycle: intent → deltas → complete → idle clears intent', () => {
let ws = applySessionEventWorkspace(createWorkspace(), {
sessionId: 'session-1',
kind: 'status',
occurredAt: '2026-03-23T00:00:00.000Z',
status: 'running',
} satisfies SessionEventRecord);
ws = applySessionEventWorkspace(ws, {
sessionId: 'session-1',
kind: 'assistant-intent',
occurredAt: '2026-03-23T00:00:01.000Z',
intent: 'Exploring codebase',
} satisfies SessionEventRecord);
ws = applySessionEventWorkspace(ws, {
sessionId: 'session-1',
kind: 'message-delta',
occurredAt: '2026-03-23T00:00:02.000Z',
messageId: 'assistant-1',
authorName: 'Agent',
contentDelta: 'Found the issue.',
content: 'Found the issue.',
} satisfies SessionEventRecord);
// Intent survives during message streaming
expect(ws?.sessions[0].currentIntent).toBe('Exploring codebase');
expect(ws?.sessions[0].messages[0].pending).toBe(true);
ws = applySessionEventWorkspace(ws, {
sessionId: 'session-1',
kind: 'assistant-intent',
occurredAt: '2026-03-23T00:00:03.000Z',
intent: 'Fixing bug',
} satisfies SessionEventRecord);
expect(ws?.sessions[0].currentIntent).toBe('Fixing bug');
ws = applySessionEventWorkspace(ws, {
sessionId: 'session-1',
kind: 'message-complete',
occurredAt: '2026-03-23T00:00:04.000Z',
messageId: 'assistant-1',
authorName: 'Agent',
content: 'Found the issue. Here is the fix.',
} satisfies SessionEventRecord);
// Intent still active until idle
expect(ws?.sessions[0].currentIntent).toBe('Fixing bug');
ws = applySessionEventWorkspace(ws, {
sessionId: 'session-1',
kind: 'status',
occurredAt: '2026-03-23T00:00:05.000Z',
status: 'idle',
} satisfies SessionEventRecord);
expect(ws?.sessions[0].currentIntent).toBeUndefined();
expect(ws?.sessions[0].status).toBe('idle');
expect(ws?.sessions[0].messages[0].content).toBe('Found the issue. Here is the fix.');
expect(ws?.sessions[0].messages[0].pending).toBe(false);
});
test('message-complete content overrides previously streamed content', () => {
let ws = applySessionEventWorkspace(createWorkspace(), {
sessionId: 'session-1',
kind: 'message-delta',
occurredAt: '2026-03-23T00:00:01.000Z',
messageId: 'assistant-1',
authorName: 'Agent',
contentDelta: 'Partial draft...',
content: 'Partial draft...',
} satisfies SessionEventRecord);
// Backend sends final authoritative content on completion
ws = applySessionEventWorkspace(ws, {
sessionId: 'session-1',
kind: 'message-complete',
occurredAt: '2026-03-23T00:00:02.000Z',
messageId: 'assistant-1',
authorName: 'Agent',
content: 'The complete, polished final answer with all details.',
} satisfies SessionEventRecord);
expect(ws?.sessions[0].messages[0]).toMatchObject({
content: 'The complete, polished final answer with all details.',
pending: false,
});
});
}); });
+10
View File
@@ -354,6 +354,16 @@ import Base from '../layouts/Base.astro';
<p class="mt-0.5 text-xs leading-relaxed text-warm-400">Minimize to tray, quick-launch scratchpads, see running session count.</p> <p class="mt-0.5 text-xs leading-relaxed text-warm-400">Minimize to tray, quick-launch scratchpads, see running session count.</p>
</div> </div>
</div> </div>
<!-- Quick Prompt -->
<div class="group flex gap-3.5 rounded-xl px-3 py-2.5 transition hover:bg-card/60" data-reveal data-reveal-d="6">
<div class="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-accent/[0.07] text-accent transition group-hover:bg-accent/[0.12]">
<svg class="size-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z"></path></svg>
</div>
<div>
<h4 class="text-sm font-medium text-warm-50">Quick Prompt</h4>
<p class="mt-0.5 text-xs leading-relaxed text-warm-400">Global hotkey summons a floating AI prompt from any app. Discard, close, or continue in Aryx.</p>
</div>
</div>
<!-- Desktop Notifications --> <!-- Desktop Notifications -->
<div class="group flex gap-3.5 rounded-xl px-3 py-2.5 transition hover:bg-card/60" data-reveal data-reveal-d="7"> <div class="group flex gap-3.5 rounded-xl px-3 py-2.5 transition hover:bg-card/60" data-reveal data-reveal-d="7">
<div class="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-brand/[0.07] text-brand transition group-hover:bg-brand/[0.12]"> <div class="mt-0.5 flex size-8 shrink-0 items-center justify-center rounded-lg bg-brand/[0.07] text-brand transition group-hover:bg-brand/[0.12]">