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 |
| Preload | Narrow bridge between browser context and Electron IPC | Business logic, persistence, orchestration | `ipcRenderer` / `ipcMain` |
| Main process | Workspace mutation, persistence, git inspection/write operations, run change attribution, commit workflow orchestration, session lifecycle, native window state, sidecar lifecycle, PTY-backed terminal lifecycle | UI rendering, LLM orchestration internals | IPC, filesystem, git CLI, stdio with sidecar, native child processes |
| Sidecar | Capability discovery, workflow validation, run execution, streaming deltas and activity | UI, workspace persistence, Electron APIs | Line-delimited JSON over stdio |
| Main process | Workspace mutation, persistence, git inspection/write operations, run change attribution, commit workflow orchestration, session lifecycle, native window state, global hotkey registration, sidecar lifecycle, PTY-backed terminal lifecycle | UI rendering, LLM orchestration internals | IPC, filesystem, git CLI, stdio with sidecar, native child processes |
| Sidecar | Capability discovery, workflow validation, run execution, provider event normalization, 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 |
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.
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.
@@ -216,7 +218,7 @@ This is a structured stdio protocol used for:
- streaming partial output
- streaming agent activity
This protocol boundary keeps the AI execution runtime replaceable and prevents the Electron main process from becoming overloaded with workflow-specific behavior. 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:
@@ -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
- **Skill invocation events**: emitted when an agent-side skill is triggered
- **Message reclassification events**: let the sidecar retroactively mark a streamed assistant message as `thinking` once the SDK confirms that message requested tool work, so the UI can separate intermediate planning chatter from the final response without sacrificing live streaming
- **Assistant intent and reasoning-delta events**: optional Copilot SDK metadata that exposes short "what I'm doing" labels plus incremental reasoning text for richer thinking-process surfaces
- **Assistant intent and reasoning-delta events**: optional provider metadata that exposes short "what I'm doing" labels plus incremental reasoning text for richer thinking-process surfaces; normalized through the provider adapter layer so the UI consumes them uniformly regardless of provider origin
- **Hook lifecycle events**: start and end of configured project hook commands discovered from `.github/hooks/*.json`; Aryx suppresses the SDK's built-in no-op hook chatter so the UI only sees meaningful hook activity
- **Assistant usage events**: per-LLM-call tokens, cost, AIU, and quota snapshots from the Copilot SDK's `assistant.usage` stream
- **Session compaction events**: start and complete, with token-reduction metrics when infinite sessions trigger context trimming
@@ -350,7 +352,8 @@ That gives the system:
The main process owns desktop concerns such as:
- native window creation
- native window creation (main window and quick prompt overlay)
- global hotkey registration
- title bar behavior
- background process management
- filesystem access
@@ -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.
### Multi-window setup and Quick Prompt
Aryx runs two `BrowserWindow` instances:
- the **main window** — the full workspace UI
- the **quick prompt window** — a frameless, transparent, always-on-top overlay for one-off AI questions
The quick prompt window loads a separate, lightweight renderer entry (`quickprompt.html` / `quickprompt.tsx`) with its own preload script (`preload/quickprompt.ts`). This keeps its bundle small and avoids loading the full workspace renderer. It communicates with the main process through dedicated IPC channels prefixed with `quick-prompt:`.
A **global hotkey service** (`src/main/services/globalHotkey.ts`) registers a system-wide keyboard shortcut (default `Super+Shift+A`, configurable in settings) using Electron's `globalShortcut` API. Pressing the hotkey toggles the quick prompt window. The service re-registers the shortcut when the configured hotkey changes and unregisters on app quit.
Quick prompt sessions are real `SessionRecord` instances created on the scratchpad project. The main process routes matching session events from the sidecar to the quick prompt window's `webContents`. After a response completes, the user can discard the session, close the window (preserving the session for later access in the main UI), or continue the conversation in the main window.
The `window-all-closed` handler excludes the quick prompt window so the app does not stay alive solely because the hidden popup exists.
## Build and release architecture
Aryx ships as an Electron application bundled together with a self-contained .NET sidecar.
The build pipeline is organized around three layers:
- building the Electron renderer and main process assets
- building the Electron renderer entries (main workspace and quick prompt) and main process assets
- publishing the sidecar for the target runtime
- packaging platform artifacts with electron-builder
+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 |
| Bookmarks | Browse all pinned messages across sessions in one panel (`Ctrl+Shift+B`) |
| System tray | Minimize to tray, quick-launch scratchpads, and see running session count |
| Quick Prompt | System-wide hotkey (`Win+Shift+A` / `Cmd+Shift+A`) summons a floating popup for instant AI questions from any app |
| Desktop notifications | Native OS alerts when runs complete, fail, or need approval |
| Onboarding | First-launch walkthrough, interactive tooltips, and a "try it" quickstart |
+12
View File
@@ -20,6 +20,12 @@ export default defineConfig({
preload: {
build: {
outDir: 'dist-electron/preload',
rollupOptions: {
input: {
index: resolve(__dirname, 'src/preload/index.ts'),
quickprompt: resolve(__dirname, 'src/preload/quickprompt.ts'),
},
},
},
plugins: [externalizeDepsPlugin()],
resolve: {
@@ -32,6 +38,12 @@ export default defineConfig({
root: 'src/renderer',
build: {
outDir: 'dist/renderer',
rollupOptions: {
input: {
index: resolve(__dirname, 'src/renderer/index.html'),
quickprompt: resolve(__dirname, 'src/renderer/quickprompt.html'),
},
},
},
plugins: [react(), tailwindcss()],
resolve: {
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aryx",
"version": "0.0.24",
"version": "0.0.25",
"description": "Orchestrator for Copilot-powered agent workflows across multiple projects.",
"private": true,
"main": "dist-electron/main/index.js",
@@ -2,10 +2,13 @@ namespace Aryx.AgentHost.Contracts;
internal abstract record ProviderSessionEvent;
internal sealed record ProviderAssistantMessageDeltaEvent(string MessageId) : ProviderSessionEvent;
internal sealed record ProviderAssistantMessageDeltaEvent(
string MessageId,
string? DeltaContent) : ProviderSessionEvent;
internal sealed record ProviderAssistantMessageEvent(
string MessageId,
string? Content,
bool HasToolRequests) : ProviderSessionEvent;
internal sealed record ProviderToolExecutionStartEvent(
@@ -13,12 +16,35 @@ internal sealed record ProviderToolExecutionStartEvent(
string ToolName,
IReadOnlyDictionary<string, object?>? ToolArguments) : ProviderSessionEvent;
internal sealed record ProviderToolExecutionProgressEvent(
string ToolCallId,
string? ProgressMessage) : ProviderSessionEvent;
internal sealed record ProviderToolExecutionPartialResultEvent(
string ToolCallId,
string? PartialOutput) : ProviderSessionEvent;
internal sealed record ProviderToolExecutionCompleteEvent(
string ToolCallId,
bool Success,
string? ResultContent,
string? DetailedResultContent,
string? Error) : ProviderSessionEvent;
internal sealed record ProviderAssistantIntentEvent(string? Intent) : ProviderSessionEvent;
internal sealed record ProviderAssistantReasoningDeltaEvent(
string? ReasoningId,
string? DeltaContent) : ProviderSessionEvent;
internal sealed record ProviderAssistantReasoningEvent(
string? ReasoningId,
string? Content) : ProviderSessionEvent;
internal sealed record ProviderAssistantTurnStartEvent(string? TurnId) : ProviderSessionEvent;
internal sealed record ProviderAssistantTurnEndEvent(string? TurnId) : ProviderSessionEvent;
internal sealed record ProviderSubagentStartedEvent(
string? ToolCallId,
string? AgentName,
@@ -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,
onEvent)
.ConfigureAwait(false);
await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false);
await EmitPendingEventsAsync(state, onDelta, onEvent).ConfigureAwait(false);
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
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);
return state.FinalizeCompletedMessages(transcriptProjector);
}
catch (OperationCanceledException) when (runCancellation.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
{
await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false);
await EmitPendingEventsAsync(state, onDelta, onEvent).ConfigureAwait(false);
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
ExitPlanModeRequestedEventDto? exitPlanModeEvent =
_providerTurnSupport.ConsumePendingExitPlanModeRequest(command.RequestId);
@@ -230,10 +230,17 @@ public class AgentWorkflowTurnRunner : ITurnWorkflowRunner
private static async Task EmitPendingEventsAsync(
TurnExecutionState state,
Func<TurnDeltaEventDto, Task> onDelta,
Func<SidecarEventDto, Task> onEvent)
{
foreach (SidecarEventDto pendingEvent in state.DrainPendingEvents())
{
if (pendingEvent is TurnDeltaEventDto delta)
{
await onDelta(delta).ConfigureAwait(false);
continue;
}
await onEvent(pendingEvent).ConfigureAwait(false);
}
}
@@ -326,8 +333,7 @@ public class AgentWorkflowTurnRunner : ITurnWorkflowRunner
command,
requestInfo,
state.ActiveAgent,
state.ToolNamesByCallId,
state.ToolCallHasArgumentsById);
state.ToolCalls);
if (activity is null)
{
@@ -524,10 +530,14 @@ public class AgentWorkflowTurnRunner : ITurnWorkflowRunner
}
string messageId = state.CreateMessageId(update.Update.MessageId);
(string _, string currentAuthorName, string currentContent) = state.AppendDelta(
if (!state.TryAppendDelta(
messageId,
authorName,
update.Update.Text);
update.Update.Text,
out TranscriptSegment currentSegment))
{
return;
}
await onDelta(new TurnDeltaEventDto
{
@@ -535,9 +545,9 @@ public class AgentWorkflowTurnRunner : ITurnWorkflowRunner
RequestId = command.RequestId,
SessionId = command.SessionId,
MessageId = messageId,
AuthorName = currentAuthorName,
AuthorName = currentSegment.AuthorName,
ContentDelta = update.Update.Text,
Content = currentContent,
Content = currentSegment.Content,
}).ConfigureAwait(false);
}
@@ -4,5 +4,7 @@ namespace Aryx.AgentHost.Services;
internal interface IProviderEventAdapter
{
ProviderTurnStreamCapabilities Capabilities { get; }
ProviderSessionEvent? TryAdapt(object rawEvent);
}
@@ -70,7 +70,7 @@ internal sealed class CopilotApprovalCoordinator
WorkflowNodeDto agent,
PermissionRequest request,
PermissionInvocation invocation,
IReadOnlyDictionary<string, string> toolNamesByCallId,
ToolCallRegistry toolCalls,
Func<ApprovalRequestedEventDto, Task> onApproval,
CancellationToken cancellationToken)
{
@@ -79,7 +79,7 @@ internal sealed class CopilotApprovalCoordinator
agent,
request,
invocation,
toolNamesByCallId,
toolCalls,
onActivity: null,
onApproval,
cancellationToken)
@@ -91,12 +91,12 @@ internal sealed class CopilotApprovalCoordinator
WorkflowNodeDto agent,
PermissionRequest request,
PermissionInvocation invocation,
IReadOnlyDictionary<string, string> toolNamesByCallId,
ToolCallRegistry toolCalls,
Func<AgentActivityEventDto, Task>? onActivity,
Func<ApprovalRequestedEventDto, Task> onApproval,
CancellationToken cancellationToken)
{
string? toolName = ResolveApprovalToolName(request, toolNamesByCallId);
string? toolName = ResolveApprovalToolName(request, toolCalls);
string? autoApprovedToolName = ResolveAutoApprovedToolName(request);
string? mcpServerApprovalKey = ResolveMcpServerApprovalKey(request, command.Tooling?.McpServers);
string? approvalCacheKey = ResolveApprovalCacheKey(toolName, autoApprovedToolName);
@@ -339,15 +339,15 @@ internal sealed class CopilotApprovalCoordinator
internal static bool TryGetApprovalToolName(
PermissionRequest request,
IReadOnlyDictionary<string, string>? toolNamesByCallId,
ToolCallRegistry? toolCalls,
out string? toolName)
{
toolName = ResolveApprovalToolName(request, toolNamesByCallId);
toolName = ResolveApprovalToolName(request, toolCalls);
return toolName is not null;
}
internal static bool TryGetApprovalToolName(PermissionRequest request, out string? toolName)
=> TryGetApprovalToolName(request, toolNamesByCallId: null, out toolName);
=> TryGetApprovalToolName(request, toolCalls: null, out toolName);
internal void ClearRequestApprovals(string requestId)
{
@@ -404,10 +404,10 @@ internal sealed class CopilotApprovalCoordinator
private static string? ResolveApprovalToolName(
PermissionRequest request,
IReadOnlyDictionary<string, string>? toolNamesByCallId)
ToolCallRegistry? toolCalls)
{
return GetDirectToolName(request)
?? ResolveToolNameFromLookup(request, toolNamesByCallId)
?? ResolveToolNameFromLookup(request, toolCalls)
?? GetFallbackToolName(request);
}
@@ -480,16 +480,16 @@ internal sealed class CopilotApprovalCoordinator
private static string? ResolveToolNameFromLookup(
PermissionRequest request,
IReadOnlyDictionary<string, string>? toolNamesByCallId)
ToolCallRegistry? toolCalls)
{
if (toolNamesByCallId is null)
if (toolCalls is null)
{
return null;
}
string? toolCallId = GetToolCallId(request);
if (toolCallId is null
|| !toolNamesByCallId.TryGetValue(toolCallId, out string? resolvedToolName))
|| !toolCalls.TryGetToolName(toolCallId, out string? resolvedToolName))
{
return null;
}
@@ -5,18 +5,34 @@ namespace Aryx.AgentHost.Services;
internal sealed class CopilotEventAdapter : IProviderEventAdapter
{
public ProviderTurnStreamCapabilities Capabilities { get; } = new()
{
SupportsIntent = true,
SupportsReasoningDelta = true,
SupportsReasoningBlock = true,
SupportsToolExecutionProgress = true,
SupportsToolExecutionPartialResult = true,
SupportsToolExecutionCompletion = true,
SupportsSubagentLifecycle = true,
SupportsHookLifecycle = true,
SupportsSessionCompaction = true,
SupportsPendingMessagesMutation = true,
SupportsSessionTurnBoundaries = true,
};
public ProviderSessionEvent? TryAdapt(object rawEvent)
{
return rawEvent switch
{
AssistantMessageDeltaEvent messageDelta
when NormalizeRequiredString(messageDelta.Data?.MessageId) is { } messageId =>
new ProviderAssistantMessageDeltaEvent(messageId),
new ProviderAssistantMessageDeltaEvent(messageId, messageDelta.Data?.DeltaContent),
AssistantMessageEvent assistantMessage
when NormalizeRequiredString(assistantMessage.Data?.MessageId) is { } messageId =>
new ProviderAssistantMessageEvent(
messageId,
assistantMessage.Data?.Content,
assistantMessage.Data?.ToolRequests is { Length: > 0 }),
ToolExecutionStartEvent toolExecutionStart
@@ -27,6 +43,27 @@ internal sealed class CopilotEventAdapter : IProviderEventAdapter
toolName,
WorkflowRequestInfoInterpreter.NormalizeRawToolArguments(toolExecutionStart.Data?.Arguments)),
ToolExecutionProgressEvent toolExecutionProgress
when NormalizeRequiredString(toolExecutionProgress.Data?.ToolCallId) is { } toolCallId =>
new ProviderToolExecutionProgressEvent(
toolCallId,
NormalizeOptionalString(toolExecutionProgress.Data?.ProgressMessage)),
ToolExecutionPartialResultEvent toolExecutionPartialResult
when NormalizeRequiredString(toolExecutionPartialResult.Data?.ToolCallId) is { } toolCallId =>
new ProviderToolExecutionPartialResultEvent(
toolCallId,
toolExecutionPartialResult.Data?.PartialOutput),
ToolExecutionCompleteEvent toolExecutionComplete
when NormalizeRequiredString(toolExecutionComplete.Data?.ToolCallId) is { } toolCallId =>
new ProviderToolExecutionCompleteEvent(
toolCallId,
toolExecutionComplete.Data?.Success ?? false,
NormalizeOptionalString(toolExecutionComplete.Data?.Result?.Content),
NormalizeOptionalString(toolExecutionComplete.Data?.Result?.DetailedContent),
NormalizeOptionalString(toolExecutionComplete.Data?.Error?.Message)),
AssistantIntentEvent intentEvent =>
new ProviderAssistantIntentEvent(NormalizeOptionalString(intentEvent.Data?.Intent)),
@@ -35,6 +72,17 @@ internal sealed class CopilotEventAdapter : IProviderEventAdapter
NormalizeOptionalString(reasoningDelta.Data?.ReasoningId),
reasoningDelta.Data?.DeltaContent),
AssistantReasoningEvent reasoning =>
new ProviderAssistantReasoningEvent(
NormalizeOptionalString(reasoning.Data?.ReasoningId),
reasoning.Data?.Content),
AssistantTurnStartEvent turnStart =>
new ProviderAssistantTurnStartEvent(NormalizeOptionalString(turnStart.Data?.TurnId)),
AssistantTurnEndEvent turnEnd =>
new ProviderAssistantTurnEndEvent(NormalizeOptionalString(turnEnd.Data?.TurnId)),
SubagentStartedEvent started =>
new ProviderSubagentStartedEvent(
started.Data?.ToolCallId,
@@ -20,6 +20,8 @@ internal sealed class CopilotTurnRunnerSupport : IProviderTurnSupport
CancellationTokenSource runCancellation,
CancellationToken cancellationToken)
{
state.SetStreamCapabilities(_providerEventAdapter.Capabilities);
return await CopilotAgentBundle.CreateAsync(
command,
(agent, request, invocation) => _approvalCoordinator.RequestApprovalAsync(
@@ -27,7 +29,7 @@ internal sealed class CopilotTurnRunnerSupport : IProviderTurnSupport
agent,
request,
invocation,
state.ToolNamesByCallId,
state.ToolCalls,
activity => AgentWorkflowTurnRunner.EmitActivityAsync(command, state, activity, onEvent),
onApproval,
runCancellation.Token),
@@ -1,16 +1,9 @@
using System.Text;
using Aryx.AgentHost.Contracts;
using GitHub.Copilot.SDK;
using Microsoft.Extensions.AI;
namespace Aryx.AgentHost.Services;
internal readonly record struct TranscriptSegment(string MessageId, string AuthorName, string Content)
{
public static TranscriptSegment FromTuple((string MessageId, string AuthorName, string Content) segment)
=> new(segment.MessageId, segment.AuthorName, segment.Content);
}
internal static class WorkflowTranscriptProjector
{
public static ChatMessage ToChatMessage(ChatMessageDto message)
@@ -146,10 +139,16 @@ internal static class WorkflowTranscriptProjector
ChatMessage message,
TranscriptSegment? matchedSegment)
{
if (matchedSegment is { IsFinalized: true } finalizedSegment
&& !string.IsNullOrWhiteSpace(finalizedSegment.Content))
{
return finalizedSegment.Content;
}
return FirstNonBlank(
message.Text,
matchedSegment?.Content,
TryGetAssistantMessageContent(message))
TryGetAssistantMessageContent(message),
matchedSegment?.Content)
?? string.Empty;
}
@@ -227,6 +226,16 @@ internal static class WorkflowTranscriptProjector
return null;
}
string? messageId = FirstNonBlank(message.MessageId);
if (messageId is not null
&& TryFindSegment(
remainingSegments,
segment => string.Equals(segment.MessageId, messageId, StringComparison.Ordinal),
out TranscriptSegment messageIdMatchedSegment))
{
return messageIdMatchedSegment;
}
string? messageText = string.IsNullOrWhiteSpace(message.Text) ? null : message.Text;
if (messageText is not null)
{
@@ -445,70 +454,3 @@ internal static class WorkflowTranscriptProjector
return null;
}
}
internal sealed class StreamingTranscriptBuffer
{
private readonly List<BufferedTranscriptSegment> _segments = [];
public int Count => _segments.Count;
public TranscriptSegment AppendDelta(
string messageId,
string authorName,
string delta)
{
BufferedTranscriptSegment segment = GetOrCreateSegment(messageId, authorName);
segment.SetContent(StreamingTextMerger.Merge(segment.Content.ToString(), delta));
segment.SetAuthorName(authorName);
return segment.ToSnapshot();
}
public IReadOnlyList<TranscriptSegment> Snapshot()
{
return _segments.Select(segment => segment.ToSnapshot()).ToList();
}
private BufferedTranscriptSegment GetOrCreateSegment(string messageId, string authorName)
{
BufferedTranscriptSegment? existing = _segments.LastOrDefault(segment => segment.MessageId == messageId);
if (existing is not null)
{
return existing;
}
BufferedTranscriptSegment created = new(messageId, authorName);
_segments.Add(created);
return created;
}
private sealed class BufferedTranscriptSegment
{
public BufferedTranscriptSegment(string messageId, string authorName)
{
MessageId = messageId;
AuthorName = authorName;
}
public string MessageId { get; }
public string AuthorName { get; private set; }
public StringBuilder Content { get; } = new();
public void SetContent(string value)
{
Content.Clear();
Content.Append(value);
}
public void SetAuthorName(string value)
{
AuthorName = value;
}
public TranscriptSegment ToSnapshot()
{
return new TranscriptSegment(MessageId, AuthorName, Content.ToString());
}
}
}
@@ -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.Diagnostics.CodeAnalysis;
using Aryx.AgentHost.Contracts;
using Microsoft.Extensions.AI;
@@ -14,6 +15,8 @@ internal class TurnExecutionState
private readonly ConcurrentQueue<SidecarEventDto> _pendingEvents = new();
private readonly ConcurrentQueue<McpOauthRequiredEventDto> _pendingMcpOauthRequests = new();
private readonly ConcurrentDictionary<string, AgentIdentity> _observedAgentsByMessageId = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, ProviderReasoningSnapshot> _reasoningById = new(StringComparer.Ordinal);
private readonly ConcurrentDictionary<string, string> _latestIntentByAgentId = new(StringComparer.Ordinal);
private readonly StreamingTranscriptBuffer _transcriptBuffer = new();
private int _fallbackMessageIndex;
private string? _lastObservedMessageId;
@@ -25,9 +28,7 @@ internal class TurnExecutionState
_agentSubworkflowIndex = AgentIdentityResolver.BuildAgentSubworkflowIndex(command.Workflow, _workflowLibrary);
}
public ConcurrentDictionary<string, string> ToolNamesByCallId { get; } = new(StringComparer.Ordinal);
public ConcurrentDictionary<string, bool> ToolCallHasArgumentsById { get; } = new(StringComparer.Ordinal);
public ToolCallRegistry ToolCalls { get; } = new();
public AgentIdentity? ActiveAgent { get; private set; }
@@ -35,8 +36,19 @@ internal class TurnExecutionState
public bool HasPendingExitPlanModeRequest { get; private set; }
public ProviderTurnStreamCapabilities StreamCapabilities { get; private set; } = ProviderTurnStreamCapabilities.None;
public string? CurrentProviderTurnId { get; private set; }
public string? LatestCompletedProviderTurnId { get; private set; }
public bool SuppressHookLifecycleEvents { get; set; }
public void SetStreamCapabilities(ProviderTurnStreamCapabilities capabilities)
{
StreamCapabilities = capabilities ?? throw new ArgumentNullException(nameof(capabilities));
}
public AgentIdentity ResolveAgentIdentity(string? agentId, string? agentName)
{
return AgentIdentityResolver.ResolveAgentIdentity(
@@ -143,6 +155,20 @@ internal class TurnExecutionState
case ProviderAssistantMessageEvent assistantMessage:
RecordObservedAgentForMessage(agent, assistantMessage.MessageId);
QueueThinkingIfNeeded(agent);
if (!string.IsNullOrWhiteSpace(assistantMessage.Content)
&& TryFinalizeTranscriptMessage(
assistantMessage.MessageId,
agent.AgentName,
assistantMessage.Content,
out TranscriptSegment finalizedSegment))
{
_pendingEvents.Enqueue(CreateTurnDeltaEvent(
finalizedSegment.MessageId,
finalizedSegment.AuthorName,
string.Empty,
finalizedSegment.Content));
}
if (assistantMessage.HasToolRequests)
{
QueueMessageReclassifiedIfNeeded(assistantMessage.MessageId);
@@ -151,20 +177,36 @@ internal class TurnExecutionState
case ProviderToolExecutionStartEvent toolExecutionStart:
string toolCallId = toolExecutionStart.ToolCallId;
string toolName = toolExecutionStart.ToolName;
TrackToolCall(toolCallId, toolName, toolExecutionStart.ToolArguments);
bool shouldQueueToolActivity = TrackToolCall(toolCallId, toolName, toolExecutionStart.ToolArguments);
ActiveAgent = agent;
AgentActivityEventDto? toolActivity = CreateToolCallingActivity(
agent, toolName, toolCallId, toolExecutionStart.ToolArguments);
if (toolActivity is not null)
if (shouldQueueToolActivity)
{
_pendingEvents.Enqueue(toolActivity);
AgentActivityEventDto? toolActivity = CreateToolCallingActivity(
agent, toolName, toolCallId, toolExecutionStart.ToolArguments);
if (toolActivity is not null)
{
_pendingEvents.Enqueue(toolActivity);
}
}
QueueMessageReclassifiedIfNeeded(_lastObservedMessageId);
break;
case ProviderToolExecutionProgressEvent toolExecutionProgress:
ActiveAgent = agent;
TrackToolExecutionProgress(toolExecutionProgress.ToolCallId, toolExecutionProgress.ProgressMessage);
break;
case ProviderToolExecutionPartialResultEvent toolExecutionPartialResult:
ActiveAgent = agent;
TrackToolExecutionPartialResult(toolExecutionPartialResult.ToolCallId, toolExecutionPartialResult.PartialOutput);
break;
case ProviderToolExecutionCompleteEvent toolExecutionComplete:
ActiveAgent = agent;
TrackToolExecutionComplete(toolExecutionComplete);
break;
case ProviderAssistantIntentEvent intentEvent:
ActiveAgent = agent;
QueueThinkingIfNeeded(agent);
TrackLatestIntent(agent.AgentId, intentEvent.Intent);
AssistantIntentEventDto? assistantIntent = CreateAssistantIntentEvent(agent, intentEvent.Intent);
if (assistantIntent is not null)
{
@@ -174,6 +216,7 @@ internal class TurnExecutionState
case ProviderAssistantReasoningDeltaEvent reasoningDelta:
ActiveAgent = agent;
QueueThinkingIfNeeded(agent);
TrackReasoningContent(reasoningDelta.ReasoningId, reasoningDelta.DeltaContent, isComplete: false);
ReasoningDeltaEventDto? reasoningDeltaEvent = CreateReasoningDeltaEvent(
agent,
reasoningDelta.ReasoningId,
@@ -183,6 +226,22 @@ internal class TurnExecutionState
_pendingEvents.Enqueue(reasoningDeltaEvent);
}
break;
case ProviderAssistantReasoningEvent reasoning:
ActiveAgent = agent;
TrackReasoningContent(reasoning.ReasoningId, reasoning.Content, isComplete: true);
break;
case ProviderAssistantTurnStartEvent turnStart:
ActiveAgent = agent;
CurrentProviderTurnId = turnStart.TurnId;
break;
case ProviderAssistantTurnEndEvent turnEnd:
ActiveAgent = agent;
LatestCompletedProviderTurnId = turnEnd.TurnId;
if (string.Equals(CurrentProviderTurnId, turnEnd.TurnId, StringComparison.Ordinal))
{
CurrentProviderTurnId = null;
}
break;
case ProviderSubagentStartedEvent started:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateSubagentStartedEvent(agent, started));
@@ -291,6 +350,25 @@ internal class TurnExecutionState
return pending;
}
public bool TryGetToolExecution(string? toolCallId, [NotNullWhen(true)] out ProviderToolExecutionSnapshot? snapshot)
{
return ToolCalls.TryGetExecution(toolCallId, out snapshot);
}
public bool TryGetReasoning(string? reasoningId, [NotNullWhen(true)] out ProviderReasoningSnapshot? snapshot)
{
snapshot = null;
return !string.IsNullOrWhiteSpace(reasoningId)
&& _reasoningById.TryGetValue(reasoningId, out snapshot);
}
public bool TryGetLatestIntent(string? agentId, [NotNullWhen(true)] out string? intent)
{
intent = null;
return !string.IsNullOrWhiteSpace(agentId)
&& _latestIntentByAgentId.TryGetValue(agentId, out intent);
}
public bool TryResolveObservedAgentForMessage(string? messageId, out AgentIdentity agent)
{
agent = default;
@@ -311,6 +389,24 @@ internal class TurnExecutionState
return _transcriptBuffer.AppendDelta(messageId, authorName, delta);
}
public bool TryAppendDelta(
string messageId,
string authorName,
string delta,
out TranscriptSegment segment)
{
return _transcriptBuffer.TryAppendDelta(messageId, authorName, delta, out segment);
}
public bool TryFinalizeTranscriptMessage(
string messageId,
string authorName,
string content,
out TranscriptSegment segment)
{
return _transcriptBuffer.TryApplySnapshot(messageId, authorName, content, out segment);
}
public void ClearActiveAgentIfMatching(AgentIdentity completedAgent)
{
if (ActiveAgent.HasValue
@@ -327,13 +423,61 @@ internal class TurnExecutionState
_lastObservedMessageId = messageId;
}
private void TrackToolCall(
private bool TrackToolCall(
string toolCallId,
string toolName,
IReadOnlyDictionary<string, object?>? toolArguments)
{
ToolNamesByCallId[toolCallId] = toolName;
ToolCallHasArgumentsById[toolCallId] = toolArguments is { Count: > 0 };
return ToolCalls.TryRecordToolRequest(toolCallId, toolName, toolArguments);
}
private void TrackToolExecutionProgress(string toolCallId, string? progressMessage)
{
ToolCalls.RecordProgress(toolCallId, progressMessage);
}
private void TrackToolExecutionPartialResult(string toolCallId, string? partialOutput)
{
ToolCalls.RecordPartialResult(toolCallId, partialOutput);
}
private void TrackToolExecutionComplete(ProviderToolExecutionCompleteEvent toolExecution)
{
ToolCalls.RecordCompletion(toolExecution);
}
private void TrackLatestIntent(string agentId, string? intent)
{
string? normalizedIntent = NormalizeOptionalString(intent);
if (normalizedIntent is null)
{
return;
}
_latestIntentByAgentId[agentId] = normalizedIntent;
}
private void TrackReasoningContent(string? reasoningId, string? content, bool isComplete)
{
string? normalizedReasoningId = NormalizeOptionalString(reasoningId);
if (normalizedReasoningId is null || content is null)
{
return;
}
_reasoningById.AddOrUpdate(
normalizedReasoningId,
id => new ProviderReasoningSnapshot
{
ReasoningId = id,
Content = content,
IsComplete = isComplete,
},
(_, existing) => existing with
{
Content = isComplete ? content : string.Concat(existing.Content, content),
IsComplete = isComplete || existing.IsComplete,
});
}
private void QueueMessageReclassifiedIfNeeded(string? messageId)
@@ -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(
AgentIdentity agent,
ProviderSkillInvokedEvent data)
@@ -759,4 +921,9 @@ internal class TurnExecutionState
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 Aryx.AgentHost.Contracts;
using Microsoft.Agents.AI.Workflows;
@@ -20,8 +19,7 @@ internal static class WorkflowRequestInfoInterpreter
RunTurnCommandDto command,
RequestInfoEvent requestInfo,
AgentIdentity? activeAgent,
ConcurrentDictionary<string, string> toolNamesByCallId,
ConcurrentDictionary<string, bool> toolCallHasArgumentsById)
ToolCallRegistry toolCalls)
{
RequestInterpretation interpretation = InterpretRequest(command, requestInfo);
return interpretation switch
@@ -29,7 +27,7 @@ internal static class WorkflowRequestInfoInterpreter
HandoffRequestInterpretation handoff =>
CreateHandoffActivity(command, handoff.TargetAgent, activeAgent),
ToolRequestInterpretation tool when activeAgent.HasValue =>
CreateToolCallingActivity(command, activeAgent.Value, tool, toolNamesByCallId, toolCallHasArgumentsById),
CreateToolCallingActivity(command, activeAgent.Value, tool, toolCalls),
_ => null,
};
}
@@ -66,22 +64,13 @@ internal static class WorkflowRequestInfoInterpreter
RunTurnCommandDto command,
AgentIdentity activeAgent,
ToolRequestInterpretation tool,
ConcurrentDictionary<string, string> toolNamesByCallId,
ConcurrentDictionary<string, bool> toolCallHasArgumentsById)
ToolCallRegistry toolCalls)
{
bool hasToolArguments = tool.ToolArguments is { Count: > 0 };
if (tool.ToolCallId is not null && toolNamesByCallId.ContainsKey(tool.ToolCallId))
if (!toolCalls.TryRecordToolRequest(tool.ToolCallId, tool.ToolName, tool.ToolArguments))
{
bool trackedHasArguments = toolCallHasArgumentsById.TryGetValue(tool.ToolCallId, out bool hasTrackedArguments)
&& hasTrackedArguments;
if (trackedHasArguments || !hasToolArguments)
{
return null;
}
return null;
}
TrackToolCallId(toolNamesByCallId, toolCallHasArgumentsById, tool.ToolCallId, tool.ToolName, hasToolArguments);
return new AgentActivityEventDto
{
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(
RunTurnCommandDto command,
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.NotNull(toolActivity.ToolArguments);
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.True(state.ToolCallHasArgumentsById.TryGetValue("tool-call-1", out bool hasArguments));
Assert.True(hasArguments);
Assert.True(state.ToolCalls.HasTrackedArguments("tool-call-1"));
}
[Fact]
@@ -129,8 +128,115 @@ public sealed class CopilotTurnExecutionStateTests
AgentActivityEventDto toolActivity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
Assert.Null(toolActivity.ToolArguments);
Assert.True(state.ToolCallHasArgumentsById.TryGetValue("tool-call-1", out bool hasArguments));
Assert.False(hasArguments);
Assert.False(state.ToolCalls.HasTrackedArguments("tool-call-1"));
}
[Fact]
public void ObserveSessionEvent_ToolExecutionProgress_TracksLatestProgressMessage()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""{"type":"tool.execution_start","data":{"toolCallId":"tool-call-1","toolName":"view"},"id":"33333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:00Z"}"""));
_ = state.DrainPendingEvents();
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""{"type":"tool.execution_progress","data":{"toolCallId":"tool-call-1","progressMessage":"Scanning repository"},"id":"43333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:01Z"}"""));
Assert.True(state.TryGetToolExecution("tool-call-1", out ProviderToolExecutionSnapshot? toolExecution));
Assert.NotNull(toolExecution);
Assert.Equal(ProviderToolExecutionStatus.Running, toolExecution.Status);
Assert.Equal("view", toolExecution.ToolName);
Assert.Equal("Scanning repository", toolExecution.LatestProgressMessage);
}
[Fact]
public void ObserveSessionEvent_ToolExecutionPartialResult_AppendsPartialOutput()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""{"type":"tool.execution_start","data":{"toolCallId":"tool-call-1","toolName":"bash"},"id":"53333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:00Z"}"""));
_ = state.DrainPendingEvents();
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""{"type":"tool.execution_partial_result","data":{"toolCallId":"tool-call-1","partialOutput":"first line\n"},"id":"63333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:01Z"}"""));
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""{"type":"tool.execution_partial_result","data":{"toolCallId":"tool-call-1","partialOutput":"second line"},"id":"73333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:02Z"}"""));
Assert.True(state.TryGetToolExecution("tool-call-1", out ProviderToolExecutionSnapshot? toolExecution));
Assert.NotNull(toolExecution);
Assert.Equal("first line\nsecond line", toolExecution.PartialOutput);
}
[Fact]
public void ObserveSessionEvent_ToolExecutionComplete_TracksFinalToolState()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""{"type":"tool.execution_start","data":{"toolCallId":"tool-call-1","toolName":"view","arguments":{"path":"README.md"}},"id":"83333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:00Z"}"""));
_ = state.DrainPendingEvents();
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
"type": "tool.execution_complete",
"data": {
"toolCallId": "tool-call-1",
"success": true,
"result": {
"content": "README excerpt",
"detailedContent": "README excerpt\nwith more detail"
}
},
"id": "93333333-3333-3333-3333-333333333333",
"timestamp": "2026-03-27T00:00:01Z"
}
"""));
Assert.True(state.TryGetToolExecution("tool-call-1", out ProviderToolExecutionSnapshot? toolExecution));
Assert.NotNull(toolExecution);
Assert.Equal(ProviderToolExecutionStatus.Completed, toolExecution.Status);
Assert.Equal("view", toolExecution.ToolName);
Assert.NotNull(toolExecution.ToolArguments);
Assert.Equal("README excerpt", toolExecution.ResultContent);
Assert.Equal("README excerpt\nwith more detail", toolExecution.DetailedResultContent);
Assert.Null(toolExecution.Error);
}
[Fact]
public void ObserveSessionEvent_ToolExecutionCompleteFailure_TracksErrorState()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""{"type":"tool.execution_complete","data":{"toolCallId":"tool-call-err","success":false,"error":{"message":"permission denied"}},"id":"a3333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:01Z"}"""));
Assert.True(state.TryGetToolExecution("tool-call-err", out ProviderToolExecutionSnapshot? toolExecution));
Assert.NotNull(toolExecution);
Assert.Equal(ProviderToolExecutionStatus.Failed, toolExecution.Status);
Assert.Equal("permission denied", toolExecution.Error);
}
[Fact]
@@ -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"}"""));
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.True(state.ToolCallHasArgumentsById.TryGetValue("tool-call-1", out bool hasArguments));
Assert.False(hasArguments);
Assert.False(state.ToolCalls.HasTrackedArguments("tool-call-1"));
}
[Fact]
@@ -267,14 +372,12 @@ public sealed class CopilotTurnExecutionStateTests
Assert.Contains(toolActivities, activity => activity.ToolCallId == "tool-call-2" && activity.ToolName == "view");
MessageReclassifiedEventDto reclassified = Assert.Single(pending.OfType<MessageReclassifiedEventDto>());
Assert.Equal("msg-3", reclassified.MessageId);
Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-1", out string? firstToolName));
Assert.True(state.ToolCalls.TryGetToolName("tool-call-1", out string? firstToolName));
Assert.Equal("rg", firstToolName);
Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-2", out string? secondToolName));
Assert.True(state.ToolCalls.TryGetToolName("tool-call-2", out string? secondToolName));
Assert.Equal("view", secondToolName);
Assert.True(state.ToolCallHasArgumentsById.TryGetValue("tool-call-1", out bool firstHasArguments));
Assert.False(firstHasArguments);
Assert.True(state.ToolCallHasArgumentsById.TryGetValue("tool-call-2", out bool secondHasArguments));
Assert.False(secondHasArguments);
Assert.False(state.ToolCalls.HasTrackedArguments("tool-call-1"));
Assert.False(state.ToolCalls.HasTrackedArguments("tool-call-2"));
}
[Fact]
@@ -366,6 +469,9 @@ public sealed class CopilotTurnExecutionStateTests
Assert.Equal("session-1", intent.SessionId);
Assert.Equal("agent-1", intent.AgentId);
Assert.Equal("Searching incident playbooks", intent.Intent);
Assert.True(state.TryGetLatestIntent("agent-1", out string? latestIntent));
Assert.NotNull(latestIntent);
Assert.Equal("Searching incident playbooks", latestIntent);
}
[Fact]
@@ -399,6 +505,93 @@ public sealed class CopilotTurnExecutionStateTests
Assert.Equal("agent-1", reasoning.AgentId);
Assert.Equal("reasoning-2", reasoning.ReasoningId);
Assert.Equal("Searching logs.", reasoning.ContentDelta);
Assert.True(state.TryGetReasoning("reasoning-2", out ProviderReasoningSnapshot? reasoningState));
Assert.NotNull(reasoningState);
Assert.Equal("Searching logs.", reasoningState.Content);
Assert.False(reasoningState.IsComplete);
}
[Fact]
public void ObserveSessionEvent_AssistantReasoning_TracksCompletedReasoningBlock()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
"type": "assistant.reasoning_delta",
"data": {
"reasoningId": "reasoning-3",
"deltaContent": "Planning."
},
"id": "cd269258-5e5d-46b6-bf3f-bd8cba793b1a",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
_ = state.DrainPendingEvents();
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
"type": "assistant.reasoning",
"data": {
"reasoningId": "reasoning-3",
"content": "Planning. Checking logs."
},
"id": "dd269258-5e5d-46b6-bf3f-bd8cba793b1a",
"timestamp": "2026-03-27T00:00:01Z"
}
"""));
Assert.True(state.TryGetReasoning("reasoning-3", out ProviderReasoningSnapshot? reasoning));
Assert.NotNull(reasoning);
Assert.Equal("Planning. Checking logs.", reasoning.Content);
Assert.True(reasoning.IsComplete);
}
[Fact]
public void ObserveSessionEvent_AssistantTurnBoundaries_TrackProviderTurnIds()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
"type": "assistant.turn_start",
"data": {
"turnId": "turn-sdk-1"
},
"id": "ed269258-5e5d-46b6-bf3f-bd8cba793b1a",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
Assert.Equal("turn-sdk-1", state.CurrentProviderTurnId);
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
"type": "assistant.turn_end",
"data": {
"turnId": "turn-sdk-1"
},
"id": "fd269258-5e5d-46b6-bf3f-bd8cba793b1a",
"timestamp": "2026-03-27T00:00:01Z"
}
"""));
Assert.Null(state.CurrentProviderTurnId);
Assert.Equal("turn-sdk-1", state.LatestCompletedProviderTurnId);
}
[Fact]
@@ -654,6 +847,53 @@ public sealed class CopilotTurnExecutionStateTests
Assert.Equal("agent-1", evt.AgentId);
}
[Fact]
public void ObserveSessionEvent_AssistantMessage_FinalizesTranscriptAndSuppressesLateDeltas()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
Assert.True(state.TryAppendDelta("msg-final", "Primary", "Draft response", out TranscriptSegment streamed));
Assert.False(streamed.IsFinalized);
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""
{
"type": "assistant.message",
"data": {
"messageId": "msg-final",
"content": "Provider final response"
},
"id": "12121212-1212-1212-1212-121212121212",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
TurnDeltaEventDto correction = Assert.Single(state.DrainPendingEvents().OfType<TurnDeltaEventDto>());
Assert.Equal("msg-final", correction.MessageId);
Assert.Equal("Primary", correction.AuthorName);
Assert.Equal(string.Empty, correction.ContentDelta);
Assert.Equal("Provider final response", correction.Content);
Assert.False(state.TryAppendDelta("msg-final", "Primary", " late delta", out TranscriptSegment unchanged));
Assert.True(unchanged.IsFinalized);
Assert.Equal("Provider final response", unchanged.Content);
ChatMessage output = new(ChatRole.Assistant, "Workflow wording")
{
MessageId = "msg-final",
AuthorName = "assistant",
};
state.UpdateCompletedMessages([output], []);
ChatMessageDto completed = Assert.Single(state.FinalizeCompletedMessages());
Assert.Equal("msg-final", completed.Id);
Assert.Equal("Primary", completed.AuthorName);
Assert.Equal("Provider final response", completed.Content);
}
private static SessionEvent CreateHookStartEvent()
{
return SessionEvent.FromJson(
@@ -451,6 +451,50 @@ public sealed class CopilotWorkflowRunnerTests
});
}
[Fact]
public void ProjectCompletedMessages_UsesExactMessageIdMatchBeforeFallbackHeuristics()
{
RunTurnCommandDto command = CreateCommand(
"group-chat",
CreateAgent(id: "agent-group-writer", name: "Writer"),
CreateAgent(id: "agent-group-reviewer", name: "Reviewer"));
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessagesFromSegments(
command,
[
new ChatMessage(ChatRole.Assistant, "Revised draft with cleaner wording.")
{
AuthorName = "assistant",
MessageId = "msg-writer-2",
},
new ChatMessage(ChatRole.Assistant, "Review feedback with cleaner wording.")
{
AuthorName = "assistant",
MessageId = "msg-reviewer-1",
},
],
[
new TranscriptSegment("msg-writer-1", "Writer", "Initial draft."),
new TranscriptSegment("msg-reviewer-1", "Reviewer", "Review feedback with draft wording."),
new TranscriptSegment("msg-writer-2", "Writer", "Revised draft with draft wording."),
]);
Assert.Collection(
messages,
writer =>
{
Assert.Equal("msg-writer-2", writer.Id);
Assert.Equal("Writer", writer.AuthorName);
Assert.Equal("Revised draft with cleaner wording.", writer.Content);
},
reviewer =>
{
Assert.Equal("msg-reviewer-1", reviewer.Id);
Assert.Equal("Reviewer", reviewer.AuthorName);
Assert.Equal("Review feedback with cleaner wording.", reviewer.Content);
});
}
[Fact]
public void ProjectCompletedMessages_UsesFallbackAgentForGenericAssistantOutput()
{
@@ -531,6 +575,36 @@ public sealed class CopilotWorkflowRunnerTests
Assert.Equal("Done — GoogleClient.cs now contains the requested class with cleaned formatting.", message.Content);
}
[Fact]
public void ProjectCompletedMessages_PrefersFinalizedSegmentContentOverWorkflowOutput()
{
RunTurnCommandDto command = CreateCommand(
"single",
CreateAgent(id: "agent-single-primary", name: "Primary Agent"));
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessagesFromSegments(
command,
[
new ChatMessage(ChatRole.Assistant, "Workflow wording that arrived later.")
{
AuthorName = "assistant",
MessageId = "msg-1",
},
],
[
new TranscriptSegment(
"msg-1",
"Primary Agent",
"Provider final wording that should win.",
IsFinalized: true),
]);
ChatMessageDto message = Assert.Single(messages);
Assert.Equal("msg-1", message.Id);
Assert.Equal("Primary Agent", message.AuthorName);
Assert.Equal("Provider final wording that should win.", message.Content);
}
[Fact]
public void ProjectCompletedMessages_DropsBlankAssistantOutputMessages()
{
@@ -602,7 +676,22 @@ public sealed class CopilotWorkflowRunnerTests
Assert.Equal("msg-2", second.MessageId);
Assert.Equal("Implementer", second.AuthorName);
Assert.Equal("B", second.Content);
});
});
}
[Fact]
public void StreamingTranscriptBuffer_IgnoresLateDeltasAfterFinalSnapshot()
{
StreamingTranscriptBuffer buffer = new();
buffer.AppendDelta("msg-1", "Architect", "Draft");
Assert.True(buffer.TryApplySnapshot("msg-1", "Architect", "Final", out TranscriptSegment finalized));
Assert.True(finalized.IsFinalized);
Assert.Equal("Final", finalized.Content);
Assert.False(buffer.TryAppendDelta("msg-1", "Architect", " late delta", out TranscriptSegment unchanged));
Assert.True(unchanged.IsFinalized);
Assert.Equal("Final", unchanged.Content);
}
[Fact]
@@ -776,8 +865,165 @@ public sealed class CopilotWorkflowRunnerTests
Assert.Equal("tool-call-1", activity.ToolCallId);
Assert.NotNull(activity.ToolArguments);
Assert.Equal(@"C:\workspace\README.md", activity.ToolArguments["path"]);
Assert.True(state.ToolCallHasArgumentsById.TryGetValue("tool-call-1", out bool hasArguments));
Assert.True(hasArguments);
Assert.True(state.ToolCalls.HasTrackedArguments("tool-call-1"));
}
[Fact]
public async Task ObserveSessionEvent_ToolExecutionStart_DoesNotDuplicateTrackedRequestInfoActivity()
{
RunTurnCommandDto command = CreateApprovalCommand();
CopilotTurnExecutionState state = new(command);
WorkflowNodeDto agent = CreateAgent("agent-1", "Primary");
state.ObserveSessionEvent(
agent,
SessionEvent.FromJson(
"""
{
"type": "assistant.message_delta",
"data": {
"messageId": "msg-1",
"deltaContent": "Inspecting"
},
"id": "b61652d1-120e-4a9f-8f0e-1dbf04fb18da",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
_ = state.DrainPendingEvents();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("tool-call-1", "view", new Dictionary<string, object?>
{
["path"] = @"C:\workspace\README.md",
}));
List<AgentActivityEventDto> requestActivities = [];
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
"HandleWorkflowEventAsync",
BindingFlags.NonPublic | BindingFlags.Static)!;
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
null,
[
command,
requestInfo,
Array.Empty<ChatMessage>(),
state,
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
(Func<SidecarEventDto, Task>)(sidecarEvent =>
{
requestActivities.Add(Assert.IsType<AgentActivityEventDto>(sidecarEvent));
return Task.CompletedTask;
}),
])!;
bool shouldEndTurn = await handleTask;
Assert.False(shouldEndTurn);
AgentActivityEventDto requestActivity = Assert.Single(requestActivities);
Assert.Equal("tool-calling", requestActivity.ActivityType);
Assert.True(state.ToolCalls.HasTrackedArguments("tool-call-1"));
state.ObserveSessionEvent(
agent,
SessionEvent.FromJson(
"""
{
"type": "tool.execution_start",
"data": {
"toolCallId": "tool-call-1",
"toolName": "view",
"arguments": {
"path": "C:\\workspace\\README.md"
}
},
"id": "c61652d1-120e-4a9f-8f0e-1dbf04fb18da",
"timestamp": "2026-03-27T00:00:01Z"
}
"""));
IReadOnlyList<SidecarEventDto> pending = state.DrainPendingEvents();
Assert.DoesNotContain(
pending.OfType<AgentActivityEventDto>(),
activity => activity.ActivityType == "tool-calling");
MessageReclassifiedEventDto reclassified = Assert.Single(pending.OfType<MessageReclassifiedEventDto>());
Assert.Equal("msg-1", reclassified.MessageId);
}
[Fact]
public async Task ObserveSessionEvent_ToolExecutionStart_EmitsEnrichmentWhenRequestInfoWasMissingArguments()
{
RunTurnCommandDto command = CreateApprovalCommand();
CopilotTurnExecutionState state = new(command);
WorkflowNodeDto agent = CreateAgent("agent-1", "Primary");
state.ObserveSessionEvent(
agent,
SessionEvent.FromJson(
"""
{
"type": "assistant.message_delta",
"data": {
"messageId": "msg-2",
"deltaContent": "Inspecting"
},
"id": "d61652d1-120e-4a9f-8f0e-1dbf04fb18da",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
_ = state.DrainPendingEvents();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("tool-call-1", "view", new Dictionary<string, object?>()));
List<AgentActivityEventDto> requestActivities = [];
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
"HandleWorkflowEventAsync",
BindingFlags.NonPublic | BindingFlags.Static)!;
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
null,
[
command,
requestInfo,
Array.Empty<ChatMessage>(),
state,
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
(Func<SidecarEventDto, Task>)(sidecarEvent =>
{
requestActivities.Add(Assert.IsType<AgentActivityEventDto>(sidecarEvent));
return Task.CompletedTask;
}),
])!;
bool shouldEndTurn = await handleTask;
Assert.False(shouldEndTurn);
AgentActivityEventDto requestActivity = Assert.Single(requestActivities);
Assert.Null(requestActivity.ToolArguments);
Assert.False(state.ToolCalls.HasTrackedArguments("tool-call-1"));
state.ObserveSessionEvent(
agent,
SessionEvent.FromJson(
"""
{
"type": "tool.execution_start",
"data": {
"toolCallId": "tool-call-1",
"toolName": "view",
"arguments": {
"path": "C:\\workspace\\README.md"
}
},
"id": "e61652d1-120e-4a9f-8f0e-1dbf04fb18da",
"timestamp": "2026-03-27T00:00:01Z"
}
"""));
AgentActivityEventDto enrichment = Assert.Single(
state.DrainPendingEvents().OfType<AgentActivityEventDto>(),
activity => activity.ActivityType == "tool-calling");
Assert.NotNull(enrichment.ToolArguments);
Assert.Equal(@"C:\workspace\README.md", enrichment.ToolArguments["path"]);
Assert.True(state.ToolCalls.HasTrackedArguments("tool-call-1"));
}
[Fact]
@@ -1372,14 +1618,12 @@ public sealed class CopilotWorkflowRunnerTests
[Fact]
public void TryGetApprovalToolName_UsesToolCallLookupForPermissionCategoriesWithoutDirectToolNames()
{
Dictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal)
{
["tool-call-url"] = "web_fetch",
["tool-call-shell"] = "shell",
["tool-call-read"] = "view",
["tool-call-write"] = "write_file",
["tool-call-memory"] = "store_memory",
};
ToolCallRegistry toolCalls = CreateToolCallRegistry(
("tool-call-url", "web_fetch"),
("tool-call-shell", "shell"),
("tool-call-read", "view"),
("tool-call-write", "write_file"),
("tool-call-memory", "store_memory"));
Assert.True(
CopilotApprovalCoordinator.TryGetApprovalToolName(
@@ -1390,7 +1634,7 @@ public sealed class CopilotWorkflowRunnerTests
Intention = "Fetch the requested page",
Url = "https://example.com/docs",
},
toolNamesByCallId,
toolCalls,
out string? urlToolName));
Assert.Equal("web_fetch", urlToolName);
@@ -1408,7 +1652,7 @@ public sealed class CopilotWorkflowRunnerTests
HasWriteFileRedirection = false,
CanOfferSessionApproval = false,
},
toolNamesByCallId,
toolCalls,
out string? shellToolName));
Assert.Equal("shell", shellToolName);
@@ -1421,7 +1665,7 @@ public sealed class CopilotWorkflowRunnerTests
Intention = "Inspect a file",
Path = "README.md",
},
toolNamesByCallId,
toolCalls,
out string? readToolName));
Assert.Equal("view", readToolName);
@@ -1435,7 +1679,7 @@ public sealed class CopilotWorkflowRunnerTests
FileName = "README.md",
Diff = "@@ -1 +1 @@",
},
toolNamesByCallId,
toolCalls,
out string? writeToolName));
Assert.Equal("write_file", writeToolName);
@@ -1449,7 +1693,7 @@ public sealed class CopilotWorkflowRunnerTests
Fact = "Use Bun for script execution.",
Citations = "package.json",
},
toolNamesByCallId,
toolCalls,
out string? memoryToolName));
Assert.Equal("store_memory", memoryToolName);
}
@@ -1960,7 +2204,7 @@ public sealed class CopilotWorkflowRunnerTests
{
SessionId = "copilot-session-1",
},
new Dictionary<string, string>(StringComparer.Ordinal),
CreateToolCallRegistry(),
approval =>
{
observedApproval = approval;
@@ -2007,10 +2251,7 @@ public sealed class CopilotWorkflowRunnerTests
{
SessionId = "copilot-session-1",
},
new Dictionary<string, string>(StringComparer.Ordinal)
{
["tool-call-write-1"] = "apply_patch",
},
CreateToolCallRegistry(("tool-call-write-1", "apply_patch")),
activity =>
{
observedActivity = activity;
@@ -2067,7 +2308,7 @@ public sealed class CopilotWorkflowRunnerTests
{
SessionId = "copilot-session-1",
},
new Dictionary<string, string>(StringComparer.Ordinal),
CreateToolCallRegistry(),
approval =>
{
sawApproval = true;
@@ -2104,7 +2345,7 @@ public sealed class CopilotWorkflowRunnerTests
{
SessionId = "copilot-session-1",
},
new Dictionary<string, string>(StringComparer.Ordinal),
CreateToolCallRegistry(),
approval =>
{
sawApproval = true;
@@ -2137,10 +2378,7 @@ public sealed class CopilotWorkflowRunnerTests
{
SessionId = "copilot-session-1",
},
new Dictionary<string, string>(StringComparer.Ordinal)
{
["tool-call-read-1"] = "view",
},
CreateToolCallRegistry(("tool-call-read-1", "view")),
approval =>
{
firstApproval = approval;
@@ -2178,10 +2416,7 @@ public sealed class CopilotWorkflowRunnerTests
{
SessionId = "copilot-session-1",
},
new Dictionary<string, string>(StringComparer.Ordinal)
{
["tool-call-read-2"] = "grep",
},
CreateToolCallRegistry(("tool-call-read-2", "grep")),
approval =>
{
sawSecondApproval = true;
@@ -2214,10 +2449,7 @@ public sealed class CopilotWorkflowRunnerTests
{
SessionId = "copilot-session-1",
},
new Dictionary<string, string>(StringComparer.Ordinal)
{
["tool-call-read-1"] = "view",
},
CreateToolCallRegistry(("tool-call-read-1", "view")),
approval =>
{
firstApproval = approval;
@@ -2254,10 +2486,7 @@ public sealed class CopilotWorkflowRunnerTests
{
SessionId = "copilot-session-1",
},
new Dictionary<string, string>(StringComparer.Ordinal)
{
["tool-call-read-2"] = "grep",
},
CreateToolCallRegistry(("tool-call-read-2", "grep")),
approval =>
{
secondApproval = approval;
@@ -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()
{
return new RunTurnCommandDto
@@ -1,5 +1,4 @@
using System.Collections;
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using System.Text.Json;
using Aryx.AgentHost.Contracts;
@@ -27,8 +26,7 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity("agent-1", "Primary"),
tracking.ToolNamesByCallId,
tracking.ToolCallHasArgumentsById);
tracking);
Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType);
@@ -38,8 +36,9 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.NotNull(activity.ToolArguments);
Assert.Equal(@"C:\workspace\file.txt", activity.ToolArguments["path"]);
Assert.Equal([10, 25], Assert.IsAssignableFrom<IReadOnlyList<object?>>(activity.ToolArguments["viewRange"]));
Assert.Equal("view", tracking.ToolNamesByCallId["call-1"]);
Assert.True(tracking.ToolCallHasArgumentsById["call-1"]);
Assert.True(tracking.TryGetToolName("call-1", out string? toolName));
Assert.Equal("view", toolName);
Assert.True(tracking.HasTrackedArguments("call-1"));
}
[Fact]
@@ -61,8 +60,7 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity("agent-1", "Primary"),
tracking.ToolNamesByCallId,
tracking.ToolCallHasArgumentsById);
tracking);
Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType);
@@ -70,8 +68,9 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.NotNull(activity.ToolArguments);
Assert.Equal(@"C:\workspace", activity.ToolArguments["path"]);
Assert.Equal(true, activity.ToolArguments["includeIgnored"]);
Assert.Equal("git.status", tracking.ToolNamesByCallId["call-1"]);
Assert.True(tracking.ToolCallHasArgumentsById["call-1"]);
Assert.True(tracking.TryGetToolName("call-1", out string? toolName));
Assert.Equal("git.status", toolName);
Assert.True(tracking.HasTrackedArguments("call-1"));
}
[Fact]
@@ -85,8 +84,7 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity("agent-1", "Primary"),
tracking.ToolNamesByCallId,
tracking.ToolCallHasArgumentsById);
tracking);
Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType);
@@ -95,8 +93,9 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.Equal(
["print('hello')"],
Assert.IsAssignableFrom<IReadOnlyList<object?>>(activity.ToolArguments["inputs"]));
Assert.Equal("code interpreter", tracking.ToolNamesByCallId["call-1"]);
Assert.True(tracking.ToolCallHasArgumentsById["call-1"]);
Assert.True(tracking.TryGetToolName("call-1", out string? toolName));
Assert.Equal("code interpreter", toolName);
Assert.True(tracking.HasTrackedArguments("call-1"));
}
[Fact]
@@ -109,15 +108,14 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity("agent-1", "Primary"),
tracking.ToolNamesByCallId,
tracking.ToolCallHasArgumentsById);
tracking);
Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType);
Assert.Equal("image generation", activity.ToolName);
Assert.Null(activity.ToolArguments);
Assert.Empty(tracking.ToolNamesByCallId);
Assert.Empty(tracking.ToolCallHasArgumentsById);
Assert.False(tracking.TryGetToolName("call-1", out _));
Assert.False(tracking.HasTrackedArguments("call-1"));
}
[Fact]
@@ -135,12 +133,11 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity("agent-1", "Primary"),
tracking.ToolNamesByCallId,
tracking.ToolCallHasArgumentsById);
tracking);
Assert.NotNull(activity);
Assert.Null(activity.ToolArguments);
Assert.False(tracking.ToolCallHasArgumentsById["call-1"]);
Assert.False(tracking.HasTrackedArguments("call-1"));
}
[Fact]
@@ -160,21 +157,25 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity("agent-1", "Primary"),
tracking.ToolNamesByCallId,
tracking.ToolCallHasArgumentsById);
tracking);
Assert.NotNull(activity);
Assert.NotNull(activity.ToolArguments);
Assert.Equal("[truncated]", activity.ToolArguments["command"]);
Assert.True(tracking.ToolCallHasArgumentsById["call-1"]);
Assert.True(tracking.HasTrackedArguments("call-1"));
}
[Fact]
public void TryCreateActivityFromRequest_SkipsDuplicateTrackedToolCallIdsThatAlreadyHaveArguments()
{
var tracking = CreateToolTracking();
tracking.ToolNamesByCallId["call-1"] = "view";
tracking.ToolCallHasArgumentsById["call-1"] = true;
tracking.RecordToolStart(
"call-1",
"view",
new Dictionary<string, object?>
{
["path"] = @"C:\workspace\seed.txt",
});
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>
{
@@ -185,20 +186,19 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity("agent-1", "Primary"),
tracking.ToolNamesByCallId,
tracking.ToolCallHasArgumentsById);
tracking);
Assert.Null(activity);
Assert.Equal("view", tracking.ToolNamesByCallId["call-1"]);
Assert.True(tracking.ToolCallHasArgumentsById["call-1"]);
Assert.True(tracking.TryGetToolName("call-1", out string? toolName));
Assert.Equal("view", toolName);
Assert.True(tracking.HasTrackedArguments("call-1"));
}
[Fact]
public void TryCreateActivityFromRequest_EmitsEnrichmentWhenTrackedToolCallWasMissingArguments()
{
var tracking = CreateToolTracking();
tracking.ToolNamesByCallId["call-1"] = "view";
tracking.ToolCallHasArgumentsById["call-1"] = false;
tracking.RecordToolStart("call-1", "view", toolArguments: null);
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>
{
@@ -209,16 +209,16 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity("agent-1", "Primary"),
tracking.ToolNamesByCallId,
tracking.ToolCallHasArgumentsById);
tracking);
Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType);
Assert.Equal("call-1", activity.ToolCallId);
Assert.NotNull(activity.ToolArguments);
Assert.Equal(@"C:\workspace\file.txt", activity.ToolArguments["path"]);
Assert.Equal("view", tracking.ToolNamesByCallId["call-1"]);
Assert.True(tracking.ToolCallHasArgumentsById["call-1"]);
Assert.True(tracking.TryGetToolName("call-1", out string? toolName));
Assert.Equal("view", toolName);
Assert.True(tracking.HasTrackedArguments("call-1"));
}
[Fact]
@@ -232,8 +232,7 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateHandoffCommand(),
requestInfo,
new AgentIdentity("agent-handoff-triage", "Triage"),
tracking.ToolNamesByCallId,
tracking.ToolCallHasArgumentsById);
tracking);
Assert.NotNull(activity);
Assert.Equal("handoff", activity.ActivityType);
@@ -242,8 +241,8 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.Equal("agent-handoff-triage", activity.SourceAgentId);
Assert.Equal("Triage", activity.SourceAgentName);
Assert.Null(activity.ToolName);
Assert.Empty(tracking.ToolNamesByCallId);
Assert.Empty(tracking.ToolCallHasArgumentsById);
Assert.False(tracking.TryGetToolName("call-1", out _));
Assert.False(tracking.HasTrackedArguments("call-1"));
}
[Fact]
@@ -263,8 +262,7 @@ public sealed class WorkflowRequestInfoInterpreterTests
"agent-reviewer",
"Reviewer",
new SubworkflowContext("subworkflow-review", "Review Lane")),
tracking.ToolNamesByCallId,
tracking.ToolCallHasArgumentsById);
tracking);
Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType);
@@ -283,8 +281,7 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateHandoffCommandWithReferencedSubworkflow(),
requestInfo,
new AgentIdentity("agent-handoff-triage", "Triage"),
tracking.ToolNamesByCallId,
tracking.ToolCallHasArgumentsById);
tracking);
Assert.NotNull(activity);
Assert.Equal("handoff", activity.ActivityType);
@@ -417,10 +414,7 @@ public sealed class WorkflowRequestInfoInterpreterTests
workflowLibrary: [nestedWorkflow]);
}
private static (
ConcurrentDictionary<string, string> ToolNamesByCallId,
ConcurrentDictionary<string, bool> ToolCallHasArgumentsById) CreateToolTracking()
=> (new(StringComparer.Ordinal), new(StringComparer.Ordinal));
private static ToolCallRegistry CreateToolTracking() => new();
private static RunTurnCommandDto CreateCommand(
string orchestrationMode,
+42 -154
View File
@@ -117,7 +117,6 @@ import {
} from '@shared/domain/session';
import { prepareChatMessageContent } from '@shared/utils/chatMessage';
import {
appendRunActivityEvent,
cancelSessionRunRecord,
completeSessionRunRecord,
createSessionRunRecord,
@@ -132,6 +131,7 @@ import {
} from '@shared/domain/runTimeline';
import {
createSessionToolingSelection,
createDefaultQuickPromptSettings,
listApprovalToolNames,
normalizeTerminalHeight,
normalizeTheme,
@@ -140,6 +140,7 @@ import {
type AppearanceTheme,
type LspProfileDefinition,
type McpServerDefinition,
type QuickPromptSettings,
type SessionToolingSelection,
type WorkspaceToolingSettings,
normalizeLspProfileDefinition,
@@ -150,7 +151,6 @@ import {
} from '@shared/domain/tooling';
import type { WorkspaceState } from '@shared/domain/workspace';
import { createId, nowIso } from '@shared/utils/ids';
import { mergeStreamingText } from '@shared/utils/streamingText';
import { WorkspaceRepository } from '@main/persistence/workspaceRepository';
import { getScratchpadSessionPath } from '@main/persistence/appPaths';
@@ -830,6 +830,21 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
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> {
return this.ptyManager.getSnapshot();
}
@@ -1907,158 +1922,6 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
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(
sessionId: string,
workflow: WorkflowDefinition,
@@ -2384,6 +2247,31 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
usageQuotaSnapshots: event.quotaSnapshots,
});
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 type { BrowserWindow as BrowserWindowType } from 'electron';
import { registerIpcHandlers } from '@main/ipc/registerIpcHandlers';
import { registerIpcHandlers, registerQuickPromptIpcHandlers } from '@main/ipc/registerIpcHandlers';
import { AryxAppService } from '@main/AryxAppService';
import { AutoUpdateService } from '@main/services/autoUpdater';
import { GlobalHotkeyService } from '@main/services/globalHotkey';
import { createMainWindow } from '@main/windows/createMainWindow';
import {
createQuickPromptWindow,
toggleQuickPromptWindow,
} from '@main/windows/createQuickPromptWindow';
import { applyTitleBarTheme } from '@main/windows/titleBarTheme';
import { SystemTray, setupCloseToTray, showAndFocusWindow } from '@main/services/systemTray';
import { createDefaultQuickPromptSettings } from '@shared/domain/tooling';
const { app, BrowserWindow } = electron;
let mainWindow: BrowserWindowType | undefined;
let quickPromptWindow: BrowserWindowType | undefined;
let appService: AryxAppService | undefined;
let systemTray: SystemTray | undefined;
let autoUpdateService: AutoUpdateService | undefined;
let globalHotkeyService: GlobalHotkeyService | undefined;
let quickPromptInitialized = false;
/** Lazily creates the quick prompt window on first use. */
function ensureQuickPromptWindow(): BrowserWindowType | undefined {
if (quickPromptWindow && !quickPromptWindow.isDestroyed()) return quickPromptWindow;
if (quickPromptInitialized) return undefined;
if (!mainWindow || !appService) return undefined;
try {
quickPromptWindow = createQuickPromptWindow();
registerQuickPromptIpcHandlers(mainWindow, appService, quickPromptWindow);
quickPromptInitialized = true;
} catch (err) {
console.error('[aryx] Failed to create quick prompt window:', err);
quickPromptInitialized = true;
}
return quickPromptWindow;
}
async function bootstrap(): Promise<void> {
appService = new AryxAppService();
@@ -21,13 +49,14 @@ async function bootstrap(): Promise<void> {
autoUpdateService = new AutoUpdateService({ isPackaged: app.isPackaged });
mainWindow = createMainWindow();
registerIpcHandlers(mainWindow, appService, autoUpdateService);
// Start workspace loading in parallel — don't block window from showing.
// The renderer fetches the workspace via its own IPC call after mount.
const workspaceReady = appService.loadWorkspace();
// Apply theme and set up tray once workspace is available
// Apply theme, set up tray, and register global hotkey once workspace is available
workspaceReady
.then((workspace) => {
if (!mainWindow) return;
@@ -47,6 +76,21 @@ async function bootstrap(): Promise<void> {
appService!.on('workspace-updated', (updatedWorkspace) => {
systemTray?.updateRunningCount(updatedWorkspace);
});
// Register global hotkey — the quick prompt window is created lazily on
// first press so it cannot interfere with main window startup.
globalHotkeyService = new GlobalHotkeyService();
const hotkeySettings = workspace.settings.quickPrompt ?? createDefaultQuickPromptSettings();
globalHotkeyService.register(hotkeySettings, () => {
const win = ensureQuickPromptWindow();
if (win) void toggleQuickPromptWindow(win, appService!.getCurrentTheme());
});
// Re-register hotkey when settings change
appService!.on('workspace-updated', (updatedWorkspace) => {
const updatedSettings = updatedWorkspace.settings.quickPrompt ?? createDefaultQuickPromptSettings();
globalHotkeyService?.update(updatedSettings);
});
})
.catch((error) => {
console.error('[aryx bootstrap] workspace load failed', error);
@@ -72,7 +116,9 @@ app.on('window-all-closed', () => {
if (process.platform === 'darwin') return;
const windows = BrowserWindow.getAllWindows();
const allHidden = windows.length > 0 && windows.every((w) => !w.isVisible());
// Ignore the quick prompt window (it's always hidden, never truly closed)
const visibleWindows = windows.filter((w) => w !== quickPromptWindow);
const allHidden = visibleWindows.length > 0 && visibleWindows.every((w) => !w.isVisible());
if (allHidden) return;
app.quit();
@@ -87,6 +133,7 @@ app.on('activate', async () => {
});
app.on('before-quit', async () => {
globalHotkeyService?.dispose();
autoUpdateService?.dispose();
autoUpdateService = undefined;
systemTray?.dispose();
+102 -1
View File
@@ -51,14 +51,18 @@ import type {
UpdateSessionModelConfigInput,
UpdateSessionApprovalSettingsInput,
UpdateSessionToolingInput,
QuickPromptSendInput,
} from '@shared/contracts/ipc';
import type { QuerySessionsInput } from '@shared/domain/sessionLibrary';
import type { AppearanceTheme } from '@shared/domain/tooling';
import type { AppearanceTheme, QuickPromptSettings } from '@shared/domain/tooling';
import { AryxAppService } from '@main/AryxAppService';
import { AutoUpdateService } from '@main/services/autoUpdater';
import { createDesktopNotificationHandler } from '@main/services/desktopNotifications';
import { applyTitleBarTheme } from '@main/windows/titleBarTheme';
import { hideQuickPromptWindow } from '@main/windows/createQuickPromptWindow';
import { buildAvailableModelCatalog } from '@shared/domain/models';
import { SCRATCHPAD_PROJECT_ID } from '@shared/domain/project';
import type { UpdateStatus } from '@shared/contracts/ipc';
const { ipcMain } = electron;
@@ -348,4 +352,101 @@ export function registerIpcHandlers(
service.on('terminal-exit', (info) => {
window.webContents.send(ipcChannels.terminalExit, info);
});
// --- Quick Prompt IPC (window-independent) ---
ipcMain.handle(ipcChannels.quickPromptGetCapabilities, async () => {
const capabilities = await service.describeSidecarCapabilities();
const settings = service.getQuickPromptSettings();
const models = buildAvailableModelCatalog(capabilities.models);
return {
models,
defaultModel: settings.defaultModel,
defaultReasoningEffort: settings.defaultReasoningEffort,
};
});
ipcMain.handle(
ipcChannels.quickPromptSetSettings,
(_event, settings: Partial<QuickPromptSettings>) => service.setQuickPromptSettings(settings),
);
ipcMain.handle(
ipcChannels.quickPromptGetSettings,
() => service.getQuickPromptSettings(),
);
}
/**
* Register IPC handlers that depend on the quick prompt BrowserWindow.
* Called separately after the quick prompt window is created (deferred from bootstrap).
*/
export function registerQuickPromptIpcHandlers(
mainWindow: BrowserWindow,
service: AryxAppService,
quickPromptWindow: BrowserWindow,
): void {
let quickPromptSessionId: string | undefined;
ipcMain.handle(ipcChannels.quickPromptSend, async (_event, input: QuickPromptSendInput) => {
const workspace = await service.loadWorkspace();
const workflowId = workspace.selectedWorkflowId ?? workspace.workflows[0]?.id;
if (!workflowId) throw new Error('No workflow available');
const created = await service.createSession(SCRATCHPAD_PROJECT_ID, workflowId);
const session = created.sessions[0];
if (!session) throw new Error('Failed to create quick prompt session');
quickPromptSessionId = session.id;
// Apply model override if provided
if (input.model) {
await service.updateSessionModelConfig(session.id, input.model, input.reasoningEffort);
}
// Send the message (fire-and-forget — results arrive via session events)
void service.sendSessionMessage(session.id, input.content);
return { sessionId: session.id };
});
ipcMain.handle(ipcChannels.quickPromptCancelTurn, async () => {
if (quickPromptSessionId) {
await service.cancelSessionTurn(quickPromptSessionId);
}
});
ipcMain.handle(ipcChannels.quickPromptDiscard, async () => {
if (quickPromptSessionId) {
await service.deleteSession(quickPromptSessionId);
quickPromptSessionId = undefined;
}
hideQuickPromptWindow(quickPromptWindow);
});
ipcMain.handle(ipcChannels.quickPromptClose, async () => {
quickPromptSessionId = undefined;
hideQuickPromptWindow(quickPromptWindow);
});
ipcMain.handle(ipcChannels.quickPromptContinueInAryx, async () => {
if (quickPromptSessionId) {
await service.selectSession(quickPromptSessionId);
quickPromptSessionId = undefined;
}
hideQuickPromptWindow(quickPromptWindow);
// Show and focus the main window
if (!mainWindow.isDestroyed()) {
if (mainWindow.isMinimized()) mainWindow.restore();
mainWindow.show();
mainWindow.focus();
}
});
// Route session events to the quick prompt window
service.on('session-event', (event) => {
if (event.sessionId === quickPromptSessionId && !quickPromptWindow.isDestroyed()) {
quickPromptWindow.webContents.send(ipcChannels.quickPromptSessionEvent, event);
}
});
}
+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,
authorName: event.authorName,
contentDelta: event.contentDelta,
content: event.content,
content,
});
if (nextRun) {
this.emitRunUpdated(sessionId, occurredAt, nextRun);
@@ -822,6 +822,7 @@ export class SessionTurnExecutor {
const completedAt = nowIso();
session.status = 'idle';
session.lastError = undefined;
session.currentIntent = undefined;
session.pendingUserInput = undefined;
session.pendingPlanReview = undefined;
session.pendingMcpAuth = undefined;
@@ -854,6 +855,7 @@ export class SessionTurnExecutor {
const cancelledAt = nowIso();
session.status = 'idle';
session.lastError = undefined;
session.currentIntent = undefined;
session.pendingUserInput = undefined;
session.pendingPlanReview = 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),
setMinimizeToTray: (enabled) => ipcRenderer.invoke(ipcChannels.setMinimizeToTray, 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),
installUpdate: () => ipcRenderer.invoke(ipcChannels.installUpdate),
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 { applySessionModelConfig } from '@shared/domain/session';
import type { AppearanceTheme, LspProfileDefinition, McpServerDefinition } from '@shared/domain/tooling';
import { createDefaultQuickPromptSettings, type QuickPromptSettings } from '@shared/domain/tooling';
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
import type { WorkspaceState } from '@shared/domain/workspace';
import type { UpdateStatus } from '@shared/contracts/ipc';
@@ -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 })));
// 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';
// Constants duplicated from BottomPanel to avoid importing the full module at startup
@@ -180,6 +181,9 @@ export default function App() {
// Commit composer state
const [commitComposerCtx, setCommitComposerCtx] = useState<{ projectId: string; sessionId: string; runId?: string }>();
// Quick prompt settings
const [quickPromptSettings, setQuickPromptSettings] = useState<QuickPromptSettings>(createDefaultQuickPromptSettings);
// Bottom panel state (terminal + git)
const [bottomPanelOpen, setBottomPanelOpen] = useState(false);
const [bottomPanelTab, setBottomPanelTab] = useState<BottomPanelTab>('terminal');
@@ -198,6 +202,11 @@ export default function App() {
.then((ws) => !disposed && setWorkspace(ws))
.catch((e) => !disposed && setError(e instanceof Error ? e.message : String(e)));
void api
.getQuickPromptSettings()
.then((s) => !disposed && setQuickPromptSettings(s))
.catch(() => { /* quick prompt settings unavailable, use defaults */ });
const offWorkspace = api.onWorkspaceUpdated((ws) => {
setWorkspace(ws);
setError(undefined);
@@ -858,6 +867,12 @@ export default function App() {
void api.resolveWorkspaceDiscoveredTooling({ serverIds, resolution });
}}
onGetQuota={() => api.getQuota()}
quickPromptSettings={quickPromptSettings}
onSetQuickPromptSettings={(patch) => {
const updated = { ...quickPromptSettings, ...patch };
setQuickPromptSettings(updated);
void api.setQuickPromptSettings(patch);
}}
/>
</Suspense>
) : null;
+2 -1
View File
@@ -530,7 +530,7 @@ export function ChatPane({
</div>
)}
{isSessionBusy && !pendingApproval && !pendingUserInput && (() => {
const label = summarizeSessionActivity(sessionActivity);
const label = session.currentIntent ?? summarizeSessionActivity(sessionActivity);
return (
<div className="flex items-center gap-1.5 text-[12px] text-[var(--color-accent-sky)]">
<span className="size-2 animate-pulse rounded-full bg-[var(--color-accent-sky)]" />
@@ -593,6 +593,7 @@ export function ChatPane({
isActive={isTurnActive(item, itemIndex)}
turnStartedAt={item.turnStartedAt}
sessionId={session.id}
currentIntent={session.currentIntent}
agentNames={item.agentNames}
isLastRunPanel={item.isLastRunPanel}
onDiscard={onDiscardRunChanges}
+234 -2
View File
@@ -1,5 +1,5 @@
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 { WorkflowEditor } from '@renderer/components/WorkflowEditor';
@@ -8,6 +8,7 @@ import { LspProfileEditor } from '@renderer/components/settings/LspProfileEditor
import { McpServerEditor } from '@renderer/components/settings/McpServerEditor';
import { WorkspaceAgentEditor } from '@renderer/components/settings/WorkspaceAgentEditor';
import { getElectronApi } from '@renderer/lib/electronApi';
import { isMac } from '@renderer/lib/platform';
import type { SidecarCapabilities, QuotaSnapshot } from '@shared/contracts/sidecar';
import type { DiscoveredMcpServer, DiscoveredToolingState } from '@shared/domain/discoveredTooling';
import { listAcceptedDiscoveredMcpServers, listPendingDiscoveredMcpServers } from '@shared/domain/discoveredTooling';
@@ -21,6 +22,7 @@ import {
type AppearanceTheme,
type LspProfileDefinition,
type McpServerDefinition,
type QuickPromptSettings,
type WorkspaceToolingSettings,
} from '@shared/domain/tooling';
import { normalizeWorkspaceAgentDefinition, findWorkspaceAgentUsages, type WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
@@ -62,9 +64,11 @@ interface SettingsPanelProps {
onGetQuota?: () => Promise<Record<string, QuotaSnapshot>>;
workflowTemplates?: WorkflowTemplateDefinition[];
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 {
id: SettingsSection;
@@ -82,6 +86,7 @@ const navGroups: NavGroup[] = [
label: 'General',
items: [
{ id: 'appearance', label: 'Appearance', icon: <Palette className="size-3.5" /> },
{ id: 'quick-prompt', label: 'Quick Prompt', icon: <Sparkles className="size-3.5" /> },
],
},
{
@@ -149,6 +154,8 @@ export function SettingsPanel({
onGetQuota,
workflowTemplates,
onCreateWorkflowFromTemplate,
quickPromptSettings,
onSetQuickPromptSettings,
}: SettingsPanelProps) {
const [activeSection, setActiveSection] = useState<SettingsSection>(initialSection ?? 'appearance');
const [editingWorkflow, setEditingWorkflow] = useState<WorkflowDefinition | null>(null);
@@ -380,6 +387,13 @@ export function SettingsPanel({
profiles={toolingSettings.lspProfiles}
/>
)}
{activeSection === 'quick-prompt' && (
<QuickPromptSettingsSection
settings={quickPromptSettings}
availableModels={availableModels}
onUpdate={onSetQuickPromptSettings}
/>
)}
{activeSection === 'troubleshooting' && (
<TroubleshootingSection
onOpenAppDataFolder={onOpenAppDataFolder}
@@ -1304,3 +1318,221 @@ function TroubleshootingAction({
</button>
);
}
function QuickPromptSettingsSection({
settings,
availableModels,
onUpdate,
}: {
settings?: QuickPromptSettings;
availableModels: ReadonlyArray<ModelDefinition>;
onUpdate?: (patch: Partial<QuickPromptSettings>) => void;
}) {
const [modelDropdownOpen, setModelDropdownOpen] = useState(false);
const enabled = settings?.enabled ?? true;
const hotkey = settings?.hotkey ?? 'Alt+Shift+C';
const defaultModel = settings?.defaultModel;
const defaultReasoning = settings?.defaultReasoningEffort;
const resolvedModel = defaultModel ? availableModels.find((m) => m.id === defaultModel) : undefined;
const modelSupportsReasoning = resolvedModel?.supportedReasoningEfforts?.length;
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;
turnStartedAt?: string;
sessionId: string;
currentIntent?: string;
agentNames?: ReadonlySet<string>;
isLastRunPanel?: boolean;
onDiscard?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise<unknown>;
@@ -418,6 +419,7 @@ export function TurnActivityPanel({
isActive,
turnStartedAt,
sessionId,
currentIntent,
agentNames,
isLastRunPanel,
onDiscard,
@@ -471,8 +473,12 @@ export function TurnActivityPanel({
return groupActivityStream(stream);
}, [thinkingMessages, scopedEvents]);
// Extract intent text for the header
const intentText = useMemo(() => extractLatestIntent(scopedEvents), [scopedEvents]);
// Prefer the session-level normalized intent when active; fall back to
// scanning run timeline for report_intent tool calls (backward compat).
const intentText = useMemo(
() => (isActive ? currentIntent : undefined) ?? extractLatestIntent(scopedEvents),
[isActive, currentIntent, scopedEvents],
);
const fallbackSummary = useMemo(
() => !intentText ? generateActivitySummary(scopedEvents) : undefined,
[intentText, scopedEvents],
@@ -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 type { ChatMessageRecord, SessionRecord } from '@shared/domain/session';
import type { WorkspaceState } from '@shared/domain/workspace';
import { mergeStreamingText } from '@shared/utils/streamingText';
export function applySessionEventWorkspace(
current: WorkspaceState | undefined,
@@ -45,6 +44,8 @@ function applySessionEvent(session: SessionRecord, event: SessionEventRecord): S
return applyMessageReclassifiedEvent(session, event);
case 'run-updated':
return applyRunUpdatedEvent(session, event);
case 'assistant-intent':
return applyAssistantIntentEvent(session, event);
default:
return session;
}
@@ -63,6 +64,7 @@ function applyStatusEvent(session: SessionRecord, event: SessionEventRecord): Se
...session,
status: event.status,
lastError: event.status === 'error' ? session.lastError : undefined,
currentIntent: event.status === 'idle' ? undefined : session.currentIntent,
updatedAt: event.occurredAt,
};
}
@@ -86,14 +88,19 @@ function applyMessageDeltaEvent(session: SessionRecord, event: SessionEventRecor
return session;
}
const resolvedContent = event.content ?? event.contentDelta ?? '';
const messageIndex = session.messages.findIndex((message) => message.id === event.messageId);
if (messageIndex >= 0) {
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 = {
...existing,
authorName: event.authorName ?? existing.authorName,
content: event.content ?? mergeStreamingText(existing.content, resolvedContent),
content: nextContent,
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
// the new message shows the "Thinking" indicator.
const completedMessages = session.messages.map((message) =>
@@ -217,3 +226,15 @@ function applyRunUpdatedEvent(session: SessionRecord, event: SessionEventRecord)
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,
.turn-activity-enter,
.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;
}
.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 ───────────────────────────────────── */
@@ -800,3 +915,42 @@ body {
max-width: 100%;
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',
getQuota: 'sidecar:get-quota',
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;
+35
View File
@@ -19,11 +19,13 @@ import type {
McpServerDefinition,
SessionToolingSelection,
AppearanceTheme,
QuickPromptSettings,
} from '@shared/domain/tooling';
import type { WorkspaceState } from '@shared/domain/workspace';
import type { ChatMessageAttachment } from '@shared/domain/attachment';
import type { ProjectPromptInvocation } from '@shared/domain/projectCustomization';
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
import type { ModelDefinition } from '@shared/domain/models';
export interface CreateSessionInput {
projectId: string;
@@ -381,9 +383,42 @@ export interface ElectronApi {
onSessionEvent(listener: (event: SessionEventRecord) => void): () => void;
onUpdateStatus(listener: (status: UpdateStatus) => void): () => void;
onTrayCreateScratchpad(listener: () => void): () => void;
getQuickPromptSettings(): Promise<QuickPromptSettings>;
setQuickPromptSettings(settings: Partial<QuickPromptSettings>): Promise<void>;
}
export interface RendererSelectionState {
selectedProject?: ProjectRecord;
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'
| 'pending-messages-modified'
| 'assistant-usage'
| 'assistant-intent'
| 'reasoning-delta'
| 'workflow-diagnostic';
export type SubagentEventKind = 'started' | 'completed' | 'failed' | 'selected' | 'deselected';
@@ -102,6 +104,13 @@ export interface SessionEventRecord {
usageTotalNanoAiu?: number;
usageQuotaSnapshots?: Record<string, QuotaSnapshot>;
// Assistant intent fields
intent?: string;
// Reasoning delta fields
reasoningId?: string;
reasoningDelta?: string;
// Workflow diagnostic fields
diagnosticSeverity?: WorkflowDiagnosticSeverity;
diagnosticKind?: WorkflowDiagnosticKind;
+1
View File
@@ -85,6 +85,7 @@ export interface SessionRecord {
pendingPlanReview?: PendingPlanReviewRecord;
pendingMcpAuth?: PendingMcpAuthRecord;
runs: SessionRunRecord[];
currentIntent?: string;
}
function normalizeOptionalString(value?: string): string | undefined {
+16
View File
@@ -61,6 +61,20 @@ export interface WorkspaceToolingSettings {
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 {
theme: AppearanceTheme;
tooling: WorkspaceToolingSettings;
@@ -70,6 +84,7 @@ export interface WorkspaceSettings {
notificationsEnabled?: boolean;
minimizeToTray?: boolean;
gitAutoRefreshEnabled?: boolean;
quickPrompt?: QuickPromptSettings;
}
export interface SessionToolingSelection {
@@ -215,6 +230,7 @@ export function normalizeWorkspaceSettings(settings?: Partial<WorkspaceSettings>
...(settings?.notificationsEnabled !== undefined ? { notificationsEnabled: settings.notificationsEnabled } : {}),
...(settings?.minimizeToTray !== undefined ? { minimizeToTray: settings.minimizeToTray } : {}),
...(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);
});
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>
</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 -->
<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]">