mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-23 21:18:40 +02:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6068ee42e1 | ||
|
|
41d19e007d | ||
|
|
cc626a3152 | ||
|
|
8d08a1df1a | ||
|
|
08886f9078 | ||
|
|
1fdb1c27d5 | ||
|
|
4e34d2abfc | ||
|
|
7b9c4d140c | ||
|
|
fde82bef4d | ||
|
|
6ef44c6689 | ||
|
|
872476b2e3 | ||
|
|
b02a90a15f | ||
|
|
d3fb0a64c5 | ||
|
|
efcee8a621 | ||
|
|
543cf677ab | ||
|
|
952e69da9f | ||
|
|
fb5970cfa7 | ||
|
|
6714b93bed | ||
|
|
b038019954 |
+25
-7
@@ -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
|
||||
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
+12
-12
@@ -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),
|
||||
|
||||
+18
-76
@@ -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
@@ -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
@@ -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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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),
|
||||
|
||||
@@ -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
@@ -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;
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
@@ -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
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -85,6 +85,7 @@ export interface SessionRecord {
|
||||
pendingPlanReview?: PendingPlanReviewRecord;
|
||||
pendingMcpAuth?: PendingMcpAuthRecord;
|
||||
runs: SessionRunRecord[];
|
||||
currentIntent?: string;
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value?: string): string | undefined {
|
||||
|
||||
@@ -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,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,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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]">
|
||||
|
||||
Reference in New Issue
Block a user