Compare commits

..
20 Commits
Author SHA1 Message Date
David KayaandCopilot ec92b61663 fix: handle async FSWatcher errors in project customization watcher
On Linux, fs.watch() on a non-existent directory emits an asynchronous
'error' event instead of throwing synchronously. Without an error handler,
this crashes the process. Tests that create AryxAppService with fake
project paths (e.g. C:\workspace\project-one) trigger this on Linux CI
because those paths don't exist.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 22:18:52 +02:00
David Kaya 36b8dd0c12 chore: bump version to 0.0.23 2026-04-07 19:15:40 +02:00
David KayaandCopilot 7a7b3fcdca fix: defer Copilot CLI resolution until agent nodes exist
CopilotAgentBundle.CreateAsync unconditionally resolved the Copilot CLI
path and started a CopilotClient, even for workflows with no agent
nodes (e.g. request-port-only workflows). This caused a hard failure
in CI where the copilot binary is not on PATH.

Move CLI path resolution, client creation, and agent setup inside a
guard that checks whether the workflow actually contains agent nodes.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 19:14:56 +02:00
David KayaandCopilot 794794afe4 fix(ci): update .NET SDK version to 10.0.x to match sidecar target framework
The sidecar projects were updated to target net10.0 but the CI
workflow still installed dotnet-version 9.0.x, causing all validate
jobs to fail at the sidecar test step.

Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
2026-04-07 18:54:03 +02:00
90 changed files with 9015 additions and 3141 deletions
+2 -2
View File
@@ -40,7 +40,7 @@ jobs:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
dotnet-version: 10.0.x
- name: Install Linux native dependencies
if: runner.os == 'Linux'
@@ -115,7 +115,7 @@ jobs:
- name: Set up .NET
uses: actions/setup-dotnet@v4
with:
dotnet-version: 9.0.x
dotnet-version: 10.0.x
- name: Install Linux native dependencies
if: runner.os == 'Linux'
+9 -5
View File
@@ -141,13 +141,15 @@ Workflows describe how agents collaborate. The architecture supports:
Their runtime semantics follow the Agent Framework orchestration model: sequential and group chat preserve a visible shared conversation, concurrent aggregates multiple independent responses into one turn, and handoff turns can end once the active agent has responded and is waiting for the next user input.
For Copilot-backed agents, Aryx uses a repo-local adapter around the Copilot SDK session layer so workflow agent routes still behave like Agent Framework handoffs. This is necessary because the upstream `GitHubCopilotAgent` does not currently project run-time handoff tool declarations into Copilot sessions or surface Copilot tool requests back as `FunctionCallContent` for the workflow runtime.
For Copilot-backed agents, Aryx uses a repo-local provider module around the Copilot SDK session layer so workflow agent routes still behave like Agent Framework handoffs. This is necessary because the upstream `GitHubCopilotAgent` does not currently project run-time handoff tool declarations into Copilot sessions or surface Copilot tool requests back as `FunctionCallContent` for the workflow runtime.
The sidecar now keeps that Copilot-specific behavior behind provider seams. Core execution uses shared `IAgentProvider`, `IProviderTurnSupport`, `ProviderSessionEvent`, `ProviderAgentBundle`, `TurnExecutionState`, and `AgentWorkflowTurnRunner` abstractions, while `Services/Providers/Copilot/` owns SDK-specific bundle creation, transcript projection, approvals, user input, MCP OAuth, exit-plan-mode handling, CLI/session management, and event adaptation.
Workflows are shared application data, not renderer-only configuration. The same workflow definition now drives validation, persistence, session execution, and sidecar orchestration.
Each workflow persists an explicit graph-backed topology. Agent nodes carry stable ids, ordering, and layout metadata, while start/end, fan-out/fan-in, sub-workflow, function, and request-port nodes make execution structure visible in the saved contract.
That graph remains the execution contract for the sidecar, but orchestration mode is now a first-class backend concept. Graph-based modes (`single`, `sequential`, `concurrent`) still execute directly from saved edges. Builder-based modes (`handoff`, `group-chat`) additionally persist mode-specific `settings.modeSettings` data for handoff filtering, triage selection, return behavior, and group-chat round limits, and the sidecar translates those settings into specialized Agent Framework workflow builders at run time.
That graph remains the execution contract for the sidecar, but orchestration mode is now a first-class backend concept. Graph-based modes (`single`, `sequential`, `concurrent`) still execute directly from saved edges. Builder-based modes (`handoff`, `group-chat`) additionally persist mode-specific `settings.modeSettings` data for handoff filtering, triage selection, return behavior, and group-chat round limits, and the sidecar translates those settings into specialized Agent Framework workflow builders at run time through shared orchestration helpers rather than Copilot-specific workflow code.
Workflow templates remain a first-class shared-domain contract. The shared layer owns workflow definitions, workflow template definitions, and workflow import/export helpers (YAML import/export plus Mermaid and DOT export). Built-in workflows seed workspace state directly, while built-in and custom templates let the main process create additional saved workflows without expanding the sidecar protocol.
@@ -214,10 +216,12 @@ This is a structured stdio protocol used for:
- streaming partial output
- streaming agent activity
This protocol boundary keeps the AI execution runtime replaceable and prevents the Electron main process from becoming overloaded with workflow-specific behavior.
This protocol boundary keeps the AI execution runtime replaceable and prevents the Electron main process from becoming overloaded with workflow-specific behavior. On the sidecar side, raw provider events are first normalized into sidecar-owned provider event records before they become streamed run activity, so future providers can plug into the same transport without reshaping the main-process contract.
The protocol also carries **turn-scoped lifecycle events** alongside output deltas. These events let the UI visualize execution internals without the main process having to interpret AI workflow semantics:
- **Workflow activity events**: agent activity records preserve agent, tool, and optional sub-workflow context (`subworkflowNodeId`, `subworkflowName`) so the UI can distinguish root-level activity from nested execution without rebuilding workflow ancestry in Electron
- **Sub-workflow lifecycle events**: `subworkflow-started` and `subworkflow-completed` are emitted when nested workflow executors begin and finish, so the Activity panel can surface sub-workflow groups as first-class runtime activity
- **Sub-agent events**: started, completed, failed, selected, deselected — surfaced when custom agents are defined
- **Skill invocation events**: emitted when an agent-side skill is triggered
- **Message reclassification events**: let the sidecar retroactively mark a streamed assistant message as `thinking` once the SDK confirms that message requested tool work, so the UI can separate intermediate planning chatter from the final response without sacrificing live streaming
@@ -316,8 +320,8 @@ For git-backed projects, the renderer surfaces three specialized components. `Ru
The architecture treats execution as observable by design:
- partial output is streamed
- agent activity is surfaced
- turn-scoped lifecycle events (sub-agent, hook, skill, compaction, usage) are streamed
- agent activity is surfaced with optional sub-workflow context
- turn-scoped lifecycle events (sub-agent, sub-workflow, hook, skill, compaction, usage) are streamed
- runs are surfaced inline as collapsible turn activity panels
- failures are represented explicitly
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "aryx",
"version": "0.0.22",
"version": "0.0.24",
"description": "Orchestrator for Copilot-powered agent workflows across multiple projects.",
"private": true,
"main": "dist-electron/main/index.js",
@@ -9,10 +9,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="GitHub.Copilot.SDK" Version="0.2.0" />
<PackageReference Include="Microsoft.Agents.AI" Version="1.0.0-rc4" />
<PackageReference Include="Microsoft.Agents.AI.GitHub.Copilot" Version="1.0.0-preview.260311.1" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.0.0-rc4" />
<PackageReference Include="GitHub.Copilot.SDK" Version="0.2.1" />
<PackageReference Include="Microsoft.Agents.AI" Version="1.0.0" />
<PackageReference Include="Microsoft.Agents.AI.GitHub.Copilot" Version="1.0.0-preview.260402.1" />
<PackageReference Include="Microsoft.Agents.AI.Workflows" Version="1.0.0" />
</ItemGroup>
</Project>
@@ -452,6 +452,8 @@ public sealed class AgentActivityEventDto : SidecarEventDto
public string ActivityType { get; init; } = string.Empty;
public string? AgentId { get; init; }
public string? AgentName { get; init; }
public string? SubworkflowNodeId { get; init; }
public string? SubworkflowName { get; init; }
public string? SourceAgentId { get; init; }
public string? SourceAgentName { get; init; }
public string? ToolName { get; init; }
@@ -0,0 +1,110 @@
namespace Aryx.AgentHost.Contracts;
internal abstract record ProviderSessionEvent;
internal sealed record ProviderAssistantMessageDeltaEvent(string MessageId) : ProviderSessionEvent;
internal sealed record ProviderAssistantMessageEvent(
string MessageId,
bool HasToolRequests) : ProviderSessionEvent;
internal sealed record ProviderToolExecutionStartEvent(
string ToolCallId,
string ToolName,
IReadOnlyDictionary<string, object?>? ToolArguments) : ProviderSessionEvent;
internal sealed record ProviderAssistantIntentEvent(string? Intent) : ProviderSessionEvent;
internal sealed record ProviderAssistantReasoningDeltaEvent(
string? ReasoningId,
string? DeltaContent) : ProviderSessionEvent;
internal sealed record ProviderSubagentStartedEvent(
string? ToolCallId,
string? AgentName,
string? AgentDisplayName,
string? AgentDescription) : ProviderSessionEvent;
internal sealed record ProviderSubagentCompletedEvent(
string? ToolCallId,
string? AgentName,
string? AgentDisplayName) : ProviderSessionEvent;
internal sealed record ProviderSubagentFailedEvent(
string? ToolCallId,
string? AgentName,
string? AgentDisplayName,
string? Error) : ProviderSessionEvent;
internal sealed record ProviderSubagentSelectedEvent(
string? AgentName,
string? AgentDisplayName,
IReadOnlyList<string>? Tools) : ProviderSessionEvent;
internal sealed record ProviderSubagentDeselectedEvent() : ProviderSessionEvent;
internal sealed record ProviderSkillInvokedEvent(
string SkillName,
string Path,
string Content,
IReadOnlyList<string>? AllowedTools,
string? PluginName,
string? PluginVersion) : ProviderSessionEvent;
internal sealed record ProviderHookStartEvent(
string HookInvocationId,
string HookType,
object? Input) : ProviderSessionEvent;
internal sealed record ProviderHookEndEvent(
string HookInvocationId,
string HookType,
bool? Success,
object? Output,
string? Error) : ProviderSessionEvent;
internal sealed record ProviderAssistantUsageEvent(
string Model,
double? InputTokens,
double? OutputTokens,
double? CacheReadTokens,
double? CacheWriteTokens,
double? Cost,
double? Duration,
double? TotalNanoAiu,
Dictionary<string, QuotaSnapshotDto>? QuotaSnapshots) : ProviderSessionEvent;
internal sealed record ProviderSessionUsageEvent(
double TokenLimit,
double CurrentTokens,
double MessagesLength,
double? SystemTokens,
double? ConversationTokens,
double? ToolDefinitionsTokens,
bool? IsInitial) : ProviderSessionEvent;
internal sealed record ProviderSessionCompactionStartEvent(
double? SystemTokens,
double? ConversationTokens,
double? ToolDefinitionsTokens) : ProviderSessionEvent;
internal sealed record ProviderSessionCompactionCompleteEvent(
bool? Success,
string? Error,
double? SystemTokens,
double? ConversationTokens,
double? ToolDefinitionsTokens,
double? PreCompactionTokens,
double? PostCompactionTokens,
double? PreCompactionMessagesLength,
double? MessagesRemoved,
double? TokensRemoved,
string? SummaryContent,
double? CheckpointNumber,
string? CheckpointPath) : ProviderSessionEvent;
internal sealed record ProviderPendingMessagesModifiedEvent() : ProviderSessionEvent;
internal sealed record ProviderMcpOauthRequiredEvent() : ProviderSessionEvent;
internal sealed record ProviderExitPlanModeRequestedEvent() : ProviderSessionEvent;
@@ -0,0 +1,19 @@
using Microsoft.Agents.AI.Workflows;
namespace Aryx.AgentHost.Services;
internal static class AgentHostOptionsFactory
{
public static AIAgentHostOptions CreateDefault()
{
return new AIAgentHostOptions
{
EmitAgentUpdateEvents = null,
EmitAgentResponseEvents = false,
InterceptUserInputRequests = false,
InterceptUnterminatedFunctionCalls = false,
ReassignOtherAgentsAsUsers = true,
ForwardIncomingMessages = true,
};
}
}
@@ -3,7 +3,12 @@ using Aryx.AgentHost.Contracts;
namespace Aryx.AgentHost.Services;
internal readonly record struct AgentIdentity(string AgentId, string AgentName);
internal readonly record struct SubworkflowContext(string SubworkflowNodeId, string SubworkflowName);
internal readonly record struct AgentIdentity(
string AgentId,
string AgentName,
SubworkflowContext? Subworkflow = null);
internal static class AgentIdentityResolver
{
@@ -13,17 +18,69 @@ internal static class AgentIdentityResolver
WorkflowDefinitionDto workflow,
string? agentIdentifier,
out AgentIdentity agent)
{
return TryResolveKnownAgentIdentity(
workflow,
WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(null),
agentIdentifier,
agentSubworkflowIndex: null,
out agent);
}
public static bool TryResolveKnownAgentIdentity(
WorkflowDefinitionDto workflow,
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary,
string? agentIdentifier,
out AgentIdentity agent)
{
return TryResolveKnownAgentIdentity(
workflow,
WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(workflowLibrary),
agentIdentifier,
agentSubworkflowIndex: null,
out agent);
}
internal static bool TryResolveKnownAgentIdentity(
WorkflowDefinitionDto workflow,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
string? agentIdentifier,
IReadOnlyDictionary<string, SubworkflowContext>? agentSubworkflowIndex,
out AgentIdentity agent)
{
agent = default;
WorkflowNodeDto? match = FindKnownAgent(workflow, agentIdentifier)
?? ResolveSingleAgentAssistantAlias(workflow, agentIdentifier);
if (match is null)
WorkflowNodeDto? shallowMatch = FindKnownAgent(workflow.GetAgentNodes(), agentIdentifier);
if (shallowMatch is not null)
{
agent = ToAgentIdentity(shallowMatch);
return true;
}
WorkflowNodeDto? deepMatch = FindKnownAgent(workflow.GetAllAgentNodes(workflowLibrary), agentIdentifier);
if (deepMatch is not null)
{
IReadOnlyDictionary<string, SubworkflowContext> subworkflowIndex = agentSubworkflowIndex
?? BuildAgentSubworkflowIndex(workflow, workflowLibrary);
agent = ToAgentIdentity(deepMatch, subworkflowIndex);
return true;
}
WorkflowNodeDto? aliasMatch = ResolveSingleAgentAssistantAlias(workflow, workflowLibrary, agentIdentifier);
if (aliasMatch is null)
{
return false;
}
agent = ToAgentIdentity(match);
if (workflow.GetAgentNodes().Contains(aliasMatch))
{
agent = ToAgentIdentity(aliasMatch);
return true;
}
IReadOnlyDictionary<string, SubworkflowContext> aliasSubworkflowIndex = agentSubworkflowIndex
?? BuildAgentSubworkflowIndex(workflow, workflowLibrary);
agent = ToAgentIdentity(aliasMatch, aliasSubworkflowIndex);
return true;
}
@@ -33,7 +90,24 @@ internal static class AgentIdentityResolver
AgentIdentity? fallbackAgent,
out AgentIdentity agent)
{
if (TryResolveKnownAgentIdentity(workflow, agentIdentifier, out agent))
return TryResolveObservedAgentIdentity(
workflow,
WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(null),
agentIdentifier,
fallbackAgent,
agentSubworkflowIndex: null,
out agent);
}
internal static bool TryResolveObservedAgentIdentity(
WorkflowDefinitionDto workflow,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
string? agentIdentifier,
AgentIdentity? fallbackAgent,
IReadOnlyDictionary<string, SubworkflowContext>? agentSubworkflowIndex,
out AgentIdentity agent)
{
if (TryResolveKnownAgentIdentity(workflow, workflowLibrary, agentIdentifier, agentSubworkflowIndex, out agent))
{
return true;
}
@@ -53,13 +127,46 @@ internal static class AgentIdentityResolver
string? agentId,
string? agentName)
{
WorkflowNodeDto? match = FindKnownAgent(workflow, agentId)
?? FindKnownAgent(workflow, agentName)
?? ResolveSingleAgentAssistantAlias(workflow, agentId, agentName);
return ResolveAgentIdentity(
workflow,
WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(null),
agentId,
agentName,
agentSubworkflowIndex: null);
}
return match is not null
? ToAgentIdentity(match)
: CreateFallbackIdentity(agentId, agentName);
public static AgentIdentity ResolveAgentIdentity(
WorkflowDefinitionDto workflow,
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary,
string? agentId,
string? agentName)
{
return ResolveAgentIdentity(
workflow,
WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(workflowLibrary),
agentId,
agentName,
agentSubworkflowIndex: null);
}
internal static AgentIdentity ResolveAgentIdentity(
WorkflowDefinitionDto workflow,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
string? agentId,
string? agentName,
IReadOnlyDictionary<string, SubworkflowContext>? agentSubworkflowIndex)
{
if (TryResolveKnownAgentIdentity(workflow, workflowLibrary, agentId, agentSubworkflowIndex, out AgentIdentity resolvedById))
{
return resolvedById;
}
if (TryResolveKnownAgentIdentity(workflow, workflowLibrary, agentName, agentSubworkflowIndex, out AgentIdentity resolvedByName))
{
return resolvedByName;
}
return CreateFallbackIdentity(agentId, agentName, agentSubworkflowIndex);
}
public static string ResolveDisplayAuthorName(
@@ -90,6 +197,53 @@ internal static class AgentIdentityResolver
return GenericAssistantIdentifier;
}
public static IReadOnlyDictionary<string, SubworkflowContext> BuildAgentSubworkflowIndex(
WorkflowDefinitionDto workflow,
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null)
{
return BuildAgentSubworkflowIndex(
workflow,
WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(workflowLibrary));
}
internal static IReadOnlyDictionary<string, SubworkflowContext> BuildAgentSubworkflowIndex(
WorkflowDefinitionDto workflow,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary)
{
ArgumentNullException.ThrowIfNull(workflow);
ArgumentNullException.ThrowIfNull(workflowLibrary);
Dictionary<string, SubworkflowContext> index = new(StringComparer.Ordinal);
CollectAgentSubworkflowContexts(
workflow,
workflowLibrary,
currentSubworkflow: null,
index,
new HashSet<string>(StringComparer.Ordinal),
new HashSet<WorkflowDefinitionDto>(ReferenceEqualityComparer.Instance));
return index;
}
internal static bool TryResolveSubworkflowContext(
WorkflowDefinitionDto workflow,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
string? subworkflowNodeId,
out SubworkflowContext context)
{
ArgumentNullException.ThrowIfNull(workflow);
ArgumentNullException.ThrowIfNull(workflowLibrary);
context = default;
WorkflowNodeDto? node = workflow.FindSubWorkflowNode(subworkflowNodeId, workflowLibrary);
if (node is null)
{
return false;
}
context = CreateSubworkflowContext(node, workflowLibrary);
return true;
}
internal static bool IsGenericAssistantIdentifier(string? candidate)
{
return string.Equals(
@@ -100,34 +254,125 @@ internal static class AgentIdentityResolver
private static WorkflowNodeDto? ResolveSingleAgentAssistantAlias(
WorkflowDefinitionDto workflow,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
params string?[] agentIdentifiers)
{
IReadOnlyList<WorkflowNodeDto> agentNodes = workflow.GetAgentNodes();
return agentNodes.Count == 1 && agentIdentifiers.Any(IsGenericAssistantIdentifier)
? agentNodes[0]
: null;
if (!agentIdentifiers.Any(IsGenericAssistantIdentifier))
{
return null;
}
IReadOnlyList<WorkflowNodeDto> topLevelAgents = workflow.GetAgentNodes();
if (topLevelAgents.Count == 1)
{
return topLevelAgents[0];
}
IReadOnlyList<WorkflowNodeDto> allAgents = workflow.GetAllAgentNodes(workflowLibrary);
return allAgents.Count == 1 ? allAgents[0] : null;
}
private static WorkflowNodeDto? FindKnownAgent(WorkflowDefinitionDto workflow, string? candidate)
private static WorkflowNodeDto? FindKnownAgent(
IEnumerable<WorkflowNodeDto> agents,
string? candidate)
{
return workflow.GetAgentNodes().FirstOrDefault(agent => MatchesAgent(agent, candidate));
return agents.FirstOrDefault(agent => MatchesAgent(agent, candidate));
}
private static AgentIdentity ToAgentIdentity(WorkflowNodeDto agent)
=> new(agent.GetAgentId(), agent.GetAgentName());
private static AgentIdentity CreateFallbackIdentity(string? agentId, string? agentName)
private static AgentIdentity ToAgentIdentity(
WorkflowNodeDto agent,
IReadOnlyDictionary<string, SubworkflowContext> agentSubworkflowIndex)
{
string resolvedAgentId = !string.IsNullOrWhiteSpace(agentId)
? agentId
: agentName ?? "agent";
string resolvedAgentName = !string.IsNullOrWhiteSpace(agentName)
? agentName
: resolvedAgentId;
string agentId = agent.GetAgentId();
return agentSubworkflowIndex.TryGetValue(agentId, out SubworkflowContext subworkflow)
? new AgentIdentity(agentId, agent.GetAgentName(), subworkflow)
: new AgentIdentity(agentId, agent.GetAgentName());
}
private static AgentIdentity CreateFallbackIdentity(
string? agentId,
string? agentName,
IReadOnlyDictionary<string, SubworkflowContext>? agentSubworkflowIndex)
{
string resolvedAgentId = NormalizeOptionalString(agentId)
?? NormalizeOptionalString(agentName)
?? "agent";
string resolvedAgentName = NormalizeOptionalString(agentName)
?? resolvedAgentId;
if (agentSubworkflowIndex is not null
&& agentSubworkflowIndex.TryGetValue(resolvedAgentId, out SubworkflowContext subworkflow))
{
return new AgentIdentity(resolvedAgentId, resolvedAgentName, subworkflow);
}
return new AgentIdentity(resolvedAgentId, resolvedAgentName);
}
private static void CollectAgentSubworkflowContexts(
WorkflowDefinitionDto workflowDefinition,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
SubworkflowContext? currentSubworkflow,
Dictionary<string, SubworkflowContext> index,
ISet<string> visitedWorkflowIds,
ISet<WorkflowDefinitionDto> visitedAnonymousWorkflows)
{
string? workflowId = NormalizeOptionalString(workflowDefinition.Id);
if (workflowId is not null)
{
if (!visitedWorkflowIds.Add(workflowId))
{
return;
}
}
else if (!visitedAnonymousWorkflows.Add(workflowDefinition))
{
return;
}
foreach (WorkflowNodeDto node in workflowDefinition.Graph.Nodes)
{
if (node.IsAgentNode())
{
if (currentSubworkflow.HasValue)
{
index[node.GetAgentId()] = currentSubworkflow.Value;
}
continue;
}
if (!node.IsSubWorkflowNode())
{
continue;
}
WorkflowDefinitionDto? subWorkflow = node.TryResolveSubWorkflowDefinition(workflowLibrary);
if (subWorkflow is null)
{
continue;
}
CollectAgentSubworkflowContexts(
subWorkflow,
workflowLibrary,
CreateSubworkflowContext(node, workflowLibrary),
index,
visitedWorkflowIds,
visitedAnonymousWorkflows);
}
}
private static SubworkflowContext CreateSubworkflowContext(
WorkflowNodeDto node,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary)
{
return new SubworkflowContext(node.Id, node.GetSubworkflowDisplayName(workflowLibrary));
}
private static bool MatchesAgent(WorkflowNodeDto agent, string? candidate)
{
if (string.IsNullOrWhiteSpace(candidate))
@@ -164,6 +409,9 @@ internal static class AgentIdentityResolver
&& normalizedCandidate.Contains(normalizedName, StringComparison.Ordinal));
}
private static string? NormalizeOptionalString(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string NormalizeComparisonKey(string? value)
{
if (string.IsNullOrWhiteSpace(value))
@@ -12,18 +12,18 @@ using Microsoft.Extensions.AI;
namespace Aryx.AgentHost.Services;
public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
public class AgentWorkflowTurnRunner : ITurnWorkflowRunner
{
private const string HandoffFunctionPrefix = "handoff_to_";
private readonly WorkflowValidator _workflowValidator;
private readonly WorkflowRunner _workflowRunner = new();
private readonly CopilotApprovalCoordinator _approvalCoordinator = new();
private readonly CopilotUserInputCoordinator _userInputCoordinator = new();
private readonly CopilotMcpOAuthCoordinator _mcpOAuthCoordinator = new();
private readonly CopilotExitPlanModeCoordinator _exitPlanModeCoordinator = new();
private readonly IProviderTurnSupport _providerTurnSupport;
public CopilotWorkflowRunner(WorkflowValidator? workflowValidator = null)
internal AgentWorkflowTurnRunner(
IProviderTurnSupport providerTurnSupport,
WorkflowValidator? workflowValidator = null)
{
_providerTurnSupport = providerTurnSupport ?? throw new ArgumentNullException(nameof(providerTurnSupport));
_workflowValidator = workflowValidator ?? new WorkflowValidator();
}
@@ -44,50 +44,27 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
throw new InvalidOperationException(validationError);
}
CopilotTurnExecutionState state = new(command);
TurnExecutionState state = new(command);
using CancellationTokenSource runCancellation =
CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
IProviderTranscriptProjector? transcriptProjector = null;
try
{
await using CopilotAgentBundle bundle = await CopilotAgentBundle.CreateAsync(
command,
(agent, request, invocation) => _approvalCoordinator.RequestApprovalAsync(
await using ProviderAgentBundle bundle = await _providerTurnSupport.CreateAgentBundleAsync(
command,
agent,
request,
invocation,
state.ToolNamesByCallId,
activity => EmitActivityAsync(command, state, activity, onEvent),
state,
onEvent,
onApproval,
runCancellation.Token),
(agent, request, invocation) => _userInputCoordinator.RequestUserInputAsync(
command,
agent,
request,
invocation,
onUserInput,
runCancellation.Token),
(agent, sessionEvent) =>
{
state.ObserveSessionEvent(agent, sessionEvent);
if (sessionEvent is McpOauthRequiredEvent mcpOauthRequired)
{
state.EnqueuePendingMcpOauthRequest(
_mcpOAuthCoordinator.BuildMcpOauthRequiredEvent(command, agent, mcpOauthRequired));
}
if (sessionEvent is ExitPlanModeRequestedEvent exitPlanModeRequested)
{
_exitPlanModeCoordinator.RecordExitPlanModeRequest(command, agent, exitPlanModeRequested);
runCancellation.Cancel();
}
},
runCancellation.Token);
runCancellation,
runCancellation.Token)
.ConfigureAwait(false);
transcriptProjector = bundle.TranscriptProjector;
ConfigureHookLifecycleEventSuppression(state, bundle);
Workflow workflow = BuildWorkflowForCommand(command, bundle.Agents, _workflowRunner);
List<ChatMessage> inputMessages = command.Messages.Select(WorkflowTranscriptProjector.ToChatMessage).ToList();
WorkflowTranscriptProjector.AttachMessageMode(inputMessages, command.MessageMode);
List<ChatMessage> inputMessages = command.Messages.Select(transcriptProjector.ToChatMessage).ToList();
transcriptProjector.AttachMessageMode(inputMessages, command.MessageMode);
using FileSystemJsonCheckpointStore? checkpointStore = CreateCheckpointStore(command);
CheckpointManager? checkpointManager = checkpointStore is not null
@@ -114,7 +91,14 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
continue;
}
bool shouldEndTurn = await HandleWorkflowEventAsync(command, evt, inputMessages, state, onDelta, onEvent)
bool shouldEndTurn = await HandleWorkflowEventAsync(
command,
evt,
inputMessages,
state,
transcriptProjector,
onDelta,
onEvent)
.ConfigureAwait(false);
await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false);
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
@@ -126,25 +110,26 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false);
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
return state.FinalizeCompletedMessages();
return state.FinalizeCompletedMessages(transcriptProjector);
}
catch (OperationCanceledException) when (runCancellation.IsCancellationRequested && !cancellationToken.IsCancellationRequested)
{
await EmitPendingEventsAsync(state, onEvent).ConfigureAwait(false);
await EmitPendingMcpOauthRequestsAsync(state, onMcpOAuthRequired).ConfigureAwait(false);
ExitPlanModeRequestedEventDto? exitPlanModeEvent =
_exitPlanModeCoordinator.ConsumePendingRequest(command.RequestId);
_providerTurnSupport.ConsumePendingExitPlanModeRequest(command.RequestId);
if (exitPlanModeEvent is null || !state.HasPendingExitPlanModeRequest)
{
throw;
}
await onExitPlanMode(exitPlanModeEvent).ConfigureAwait(false);
return state.FinalizeCompletedMessages();
return state.FinalizeCompletedMessages(
transcriptProjector ?? throw new InvalidOperationException("Provider transcript projector was not initialized."));
}
finally
{
_approvalCoordinator.ClearRequestApprovals(command.RequestId);
_providerTurnSupport.ClearRequestState(command.RequestId);
}
}
@@ -158,8 +143,8 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
return NormalizeOrchestrationMode(command.Workflow.Settings.OrchestrationMode) switch
{
"handoff" => CopilotAgentBundle.CreateHandoffWorkflow(command.Workflow, agents),
"group-chat" => CopilotAgentBundle.CreateGroupChatWorkflow(command.Workflow, agents),
"handoff" => WorkflowOrchestrationFactory.CreateHandoffWorkflow(command.Workflow, agents),
"group-chat" => WorkflowOrchestrationFactory.CreateGroupChatWorkflow(command.Workflow, agents),
_ => (workflowRunner ?? new WorkflowRunner()).BuildWorkflow(command.Workflow, agents, command.WorkflowLibrary),
};
}
@@ -234,8 +219,8 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
}
internal static void ConfigureHookLifecycleEventSuppression(
CopilotTurnExecutionState state,
CopilotAgentBundle bundle)
TurnExecutionState state,
ProviderAgentBundle bundle)
{
ArgumentNullException.ThrowIfNull(state);
ArgumentNullException.ThrowIfNull(bundle);
@@ -244,7 +229,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
}
private static async Task EmitPendingEventsAsync(
CopilotTurnExecutionState state,
TurnExecutionState state,
Func<SidecarEventDto, Task> onEvent)
{
foreach (SidecarEventDto pendingEvent in state.DrainPendingEvents())
@@ -254,7 +239,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
}
private static async Task EmitPendingMcpOauthRequestsAsync(
CopilotTurnExecutionState state,
TurnExecutionState state,
Func<McpOauthRequiredEventDto, Task> onMcpOAuthRequired)
{
foreach (McpOauthRequiredEventDto request in state.DrainPendingMcpOauthRequests())
@@ -267,14 +252,14 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
ResolveApprovalCommandDto command,
CancellationToken cancellationToken)
{
return _approvalCoordinator.ResolveApprovalAsync(command, cancellationToken);
return _providerTurnSupport.ResolveApprovalAsync(command, cancellationToken);
}
public Task ResolveUserInputAsync(
ResolveUserInputCommandDto command,
CancellationToken cancellationToken)
{
return _userInputCoordinator.ResolveUserInputAsync(command, cancellationToken);
return _providerTurnSupport.ResolveUserInputAsync(command, cancellationToken);
}
private async Task<bool> TryHandleRequestPortRequestAsync(
@@ -290,7 +275,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
}
UserInputRequest userInputRequest = CreateRequestPortUserInputRequest(metadata!, requestInfo);
UserInputResponse response = await _userInputCoordinator.RequestUserInputAsync(
UserInputResponse response = await _providerTurnSupport.RequestRequestPortUserInputAsync(
command,
userInputRequest,
onUserInput,
@@ -301,24 +286,32 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
return true;
}
private static async Task<bool> HandleWorkflowEventAsync(
internal static async Task<bool> HandleWorkflowEventAsync(
RunTurnCommandDto command,
WorkflowEvent evt,
IReadOnlyList<ChatMessage> inputMessages,
CopilotTurnExecutionState state,
TurnExecutionState state,
IProviderTranscriptProjector transcriptProjector,
Func<TurnDeltaEventDto, Task> onDelta,
Func<SidecarEventDto, Task> onEvent)
{
if (evt is ExecutorInvokedEvent invoked)
{
if (AgentIdentityResolver.TryResolveKnownAgentIdentity(
command.Workflow,
invoked.ExecutorId,
out AgentIdentity invokedAgent))
if (state.TryResolveKnownAgentIdentity(invoked.ExecutorId, out AgentIdentity invokedAgent))
{
TraceHandoff(command, $"Executor invoked: {invoked.ExecutorId} -> {invokedAgent.AgentName} ({invokedAgent.AgentId}).");
await state.EmitThinkingIfNeeded(invokedAgent, onEvent).ConfigureAwait(false);
}
else if (state.TryCreateSubworkflowLifecycleActivity(
"subworkflow-started",
invoked.ExecutorId,
out AgentActivityEventDto subworkflowStarted))
{
TraceHandoff(
command,
$"Sub-workflow executor invoked: {invoked.ExecutorId} -> {subworkflowStarted.SubworkflowName ?? subworkflowStarted.SubworkflowNodeId ?? "<unknown>"}.");
await EmitActivityAsync(command, state, subworkflowStarted, onEvent).ConfigureAwait(false);
}
else
{
TraceHandoff(command, $"Executor invoked without a known agent match: {invoked.ExecutorId}.");
@@ -333,7 +326,8 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
command,
requestInfo,
state.ActiveAgent,
state.ToolNamesByCallId);
state.ToolNamesByCallId,
state.ToolCallHasArgumentsById);
if (activity is null)
{
@@ -368,16 +362,22 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
if (evt is ExecutorCompletedEvent completed)
{
if (AgentIdentityResolver.TryResolveObservedAgentIdentity(
command.Workflow,
completed.ExecutorId,
state.ActiveAgent,
out AgentIdentity completedAgent))
if (state.TryResolveObservedAgentIdentity(completed.ExecutorId, state.ActiveAgent, out AgentIdentity completedAgent))
{
TraceHandoff(command, $"Executor completed: {completed.ExecutorId} -> {completedAgent.AgentName} ({completedAgent.AgentId}).");
state.QueueCompletedActivity(completedAgent);
state.ClearActiveAgentIfMatching(completedAgent);
}
else if (state.TryCreateSubworkflowLifecycleActivity(
"subworkflow-completed",
completed.ExecutorId,
out AgentActivityEventDto subworkflowCompleted))
{
TraceHandoff(
command,
$"Sub-workflow executor completed: {completed.ExecutorId} -> {subworkflowCompleted.SubworkflowName ?? subworkflowCompleted.SubworkflowNodeId ?? "<unknown>"}.");
await EmitActivityAsync(command, state, subworkflowCompleted, onEvent).ConfigureAwait(false);
}
else
{
TraceHandoff(command, $"Executor completed without a known agent match: {completed.ExecutorId}.");
@@ -389,7 +389,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
if (evt is WorkflowOutputEvent outputEvent)
{
List<ChatMessage> allMessages = outputEvent.As<List<ChatMessage>>() ?? [];
state.UpdateCompletedMessages(allMessages, inputMessages);
state.UpdateCompletedMessages(allMessages, inputMessages, transcriptProjector);
}
return false;
@@ -466,7 +466,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
private static async Task HandleAgentResponseUpdateAsync(
RunTurnCommandDto command,
AgentResponseUpdateEvent update,
CopilotTurnExecutionState state,
TurnExecutionState state,
Func<TurnDeltaEventDto, Task> onDelta,
Func<SidecarEventDto, Task> onEvent)
{
@@ -483,11 +483,10 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
updateAgent = observedMessageAgent;
authorName = observedMessageAgent.AgentName;
}
else if (AgentIdentityResolver.TryResolveObservedAgentIdentity(
command.Workflow,
update.ExecutorId,
state.ActiveAgent,
out AgentIdentity resolvedUpdateAgent))
else if (state.TryResolveObservedAgentIdentity(
update.ExecutorId,
state.ActiveAgent,
out AgentIdentity resolvedUpdateAgent))
{
updateAgent = resolvedUpdateAgent;
authorName = resolvedUpdateAgent.AgentName;
@@ -542,9 +541,9 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
}).ConfigureAwait(false);
}
private static async Task EmitActivityAsync(
internal static async Task EmitActivityAsync(
RunTurnCommandDto command,
CopilotTurnExecutionState state,
TurnExecutionState state,
AgentActivityEventDto activity,
Func<SidecarEventDto, Task> onEvent)
{
@@ -558,11 +557,12 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
&& !string.IsNullOrWhiteSpace(activity.AgentId)
&& !string.IsNullOrWhiteSpace(activity.AgentName))
{
AgentIdentity promotedAgent = state.ResolveAgentIdentity(activity.AgentId, activity.AgentName);
TraceHandoff(
command,
$"Promoting handoff target to thinking: {activity.AgentName} ({activity.AgentId}).");
$"Promoting handoff target to thinking: {promotedAgent.AgentName} ({promotedAgent.AgentId}).");
await state.EmitThinkingIfNeeded(
new AgentIdentity(activity.AgentId, activity.AgentName),
promotedAgent,
onEvent).ConfigureAwait(false);
}
}
@@ -597,7 +597,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
private static bool TryCreateWorkflowDiagnosticEvent(
RunTurnCommandDto command,
WorkflowEvent evt,
CopilotTurnExecutionState state,
TurnExecutionState state,
out WorkflowDiagnosticEventDto diagnostic)
{
diagnostic = default!;
@@ -606,8 +606,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
{
case ExecutorFailedEvent executorFailed:
{
AgentIdentity? agent = AgentIdentityResolver.TryResolveObservedAgentIdentity(
command.Workflow,
AgentIdentity? agent = state.TryResolveObservedAgentIdentity(
executorFailed.ExecutorId,
state.ActiveAgent,
out AgentIdentity resolvedAgent)
@@ -1,668 +1,24 @@
using System.Collections.Concurrent;
using Aryx.AgentHost.Contracts;
using GitHub.Copilot.SDK;
using Microsoft.Extensions.AI;
namespace Aryx.AgentHost.Services;
internal sealed class CopilotTurnExecutionState
internal sealed class CopilotTurnExecutionState : TurnExecutionState
{
private readonly RunTurnCommandDto _command;
private readonly HashSet<string> _startedAgents = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _reclassifiedMessageIds = new(StringComparer.Ordinal);
private readonly ConcurrentQueue<SidecarEventDto> _pendingEvents = new();
private readonly ConcurrentQueue<McpOauthRequiredEventDto> _pendingMcpOauthRequests = new();
private readonly ConcurrentDictionary<string, AgentIdentity> _observedAgentsByMessageId = new(StringComparer.Ordinal);
private readonly StreamingTranscriptBuffer _transcriptBuffer = new();
private int _fallbackMessageIndex;
private string? _lastObservedMessageId;
public CopilotTurnExecutionState(RunTurnCommandDto command)
: base(command)
{
_command = command;
}
public ConcurrentDictionary<string, string> ToolNamesByCallId { get; } = new(StringComparer.Ordinal);
public AgentIdentity? ActiveAgent { get; private set; }
public List<ChatMessageDto> CompletedMessages { get; private set; } = [];
public bool HasPendingExitPlanModeRequest { get; private set; }
public bool SuppressHookLifecycleEvents { get; set; }
public async Task EmitThinkingIfNeeded(
AgentIdentity agent,
Func<SidecarEventDto, Task> onEvent)
{
AgentActivityEventDto? thinkingActivity = CreateThinkingActivityIfNeeded(agent);
if (thinkingActivity is null)
{
return;
}
await onEvent(thinkingActivity).ConfigureAwait(false);
}
public void QueueThinkingIfNeeded(AgentIdentity agent)
{
AgentActivityEventDto? thinkingActivity = CreateThinkingActivityIfNeeded(agent);
if (thinkingActivity is not null)
{
_pendingEvents.Enqueue(thinkingActivity);
}
}
public void QueueCompletedActivity(AgentIdentity agent)
{
_pendingEvents.Enqueue(CreateCompletedActivity(agent));
}
public void ApplyEvent(SidecarEventDto evt)
{
if (evt is AgentActivityEventDto activity
&& string.Equals(activity.ActivityType, "handoff", StringComparison.Ordinal)
&& !string.IsNullOrWhiteSpace(activity.AgentId)
&& !string.IsNullOrWhiteSpace(activity.AgentName))
{
ActiveAgent = new AgentIdentity(activity.AgentId, activity.AgentName);
}
}
public void ObserveSessionEvent(WorkflowNodeDto agentDefinition, SessionEvent sessionEvent)
{
AgentIdentity agent = AgentIdentityResolver.ResolveAgentIdentity(
_command.Workflow,
agentDefinition.GetAgentId(),
agentDefinition.GetAgentName());
switch (sessionEvent)
{
case AssistantMessageDeltaEvent messageDelta when !string.IsNullOrWhiteSpace(messageDelta.Data?.MessageId):
RecordObservedAgentForMessage(agent, messageDelta.Data!.MessageId);
QueueThinkingIfNeeded(agent);
break;
case AssistantMessageEvent assistantMessage when !string.IsNullOrWhiteSpace(assistantMessage.Data?.MessageId):
RecordObservedAgentForMessage(agent, assistantMessage.Data!.MessageId);
QueueThinkingIfNeeded(agent);
if (assistantMessage.Data?.ToolRequests is { Length: > 0 })
{
QueueMessageReclassifiedIfNeeded(assistantMessage.Data.MessageId);
}
break;
case ToolExecutionStartEvent toolExecutionStart
when !string.IsNullOrWhiteSpace(toolExecutionStart.Data?.ToolCallId)
&& !string.IsNullOrWhiteSpace(toolExecutionStart.Data?.ToolName):
string toolCallId = toolExecutionStart.Data.ToolCallId.Trim();
string toolName = toolExecutionStart.Data.ToolName.Trim();
ToolNamesByCallId[toolCallId] = toolName;
ActiveAgent = agent;
AgentActivityEventDto? toolActivity = CreateToolCallingActivity(agent, toolName, toolCallId);
if (toolActivity is not null)
{
_pendingEvents.Enqueue(toolActivity);
}
QueueMessageReclassifiedIfNeeded(_lastObservedMessageId);
break;
case AssistantIntentEvent intentEvent:
ActiveAgent = agent;
QueueThinkingIfNeeded(agent);
AssistantIntentEventDto? assistantIntent = CreateAssistantIntentEvent(agent, intentEvent.Data);
if (assistantIntent is not null)
{
_pendingEvents.Enqueue(assistantIntent);
}
break;
case AssistantReasoningDeltaEvent reasoningDelta:
ActiveAgent = agent;
QueueThinkingIfNeeded(agent);
ReasoningDeltaEventDto? reasoningDeltaEvent = CreateReasoningDeltaEvent(agent, reasoningDelta.Data);
if (reasoningDeltaEvent is not null)
{
_pendingEvents.Enqueue(reasoningDeltaEvent);
}
break;
case SubagentStartedEvent started:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateSubagentEvent(agent, "started", started.Data));
break;
case SubagentCompletedEvent completed:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateSubagentCompletedEvent(agent, completed.Data));
break;
case SubagentFailedEvent failed:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateSubagentFailedEvent(agent, failed.Data));
break;
case SubagentSelectedEvent selected:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateSubagentSelectedEvent(agent, selected.Data));
break;
case SubagentDeselectedEvent:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateSubagentDeselectedEvent(agent));
break;
case SkillInvokedEvent skillInvoked:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateSkillInvokedEvent(agent, skillInvoked.Data));
break;
case HookStartEvent hookStart:
ActiveAgent = agent;
if (!SuppressHookLifecycleEvents)
{
_pendingEvents.Enqueue(CreateHookLifecycleEvent(agent, "start", hookStart.Data));
}
break;
case HookEndEvent hookEnd:
ActiveAgent = agent;
if (!SuppressHookLifecycleEvents)
{
_pendingEvents.Enqueue(CreateHookLifecycleEvent(agent, "end", hookEnd.Data));
}
break;
case AssistantUsageEvent assistantUsage:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateAssistantUsageEvent(agent, assistantUsage.Data));
break;
case SessionUsageInfoEvent usageInfo:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateUsageEvent(agent, usageInfo.Data));
break;
case SessionCompactionStartEvent compactionStart:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateCompactionStartEvent(agent, compactionStart.Data));
break;
case SessionCompactionCompleteEvent compactionComplete:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateCompactionCompleteEvent(agent, compactionComplete.Data));
break;
case PendingMessagesModifiedEvent:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreatePendingMessagesModifiedEvent(agent));
break;
case McpOauthRequiredEvent:
ActiveAgent = agent;
break;
case ExitPlanModeRequestedEvent:
HasPendingExitPlanModeRequest = true;
ActiveAgent = agent;
break;
}
}
public IReadOnlyList<SidecarEventDto> DrainPendingEvents()
{
List<SidecarEventDto> pending = [];
while (_pendingEvents.TryDequeue(out SidecarEventDto? pendingEvent))
{
pending.Add(pendingEvent);
}
return pending;
}
public void EnqueuePendingMcpOauthRequest(McpOauthRequiredEventDto request)
{
ArgumentNullException.ThrowIfNull(request);
_pendingMcpOauthRequests.Enqueue(request);
}
public IReadOnlyList<McpOauthRequiredEventDto> DrainPendingMcpOauthRequests()
{
List<McpOauthRequiredEventDto> pending = [];
while (_pendingMcpOauthRequests.TryDequeue(out McpOauthRequiredEventDto? request))
{
pending.Add(request);
}
return pending;
}
public bool TryResolveObservedAgentForMessage(string? messageId, out AgentIdentity agent)
{
agent = default;
return !string.IsNullOrWhiteSpace(messageId)
&& _observedAgentsByMessageId.TryGetValue(messageId, out agent);
}
public string CreateMessageId(string? messageId)
{
return messageId ?? $"{_command.RequestId}-delta-{_fallbackMessageIndex++}";
}
public TranscriptSegment AppendDelta(
string messageId,
string authorName,
string delta)
{
return _transcriptBuffer.AppendDelta(messageId, authorName, delta);
}
public void ClearActiveAgentIfMatching(AgentIdentity completedAgent)
{
if (ActiveAgent.HasValue
&& string.Equals(ActiveAgent.Value.AgentId, completedAgent.AgentId, StringComparison.Ordinal))
{
ActiveAgent = null;
}
}
private void RecordObservedAgentForMessage(AgentIdentity agent, string messageId)
{
ActiveAgent = agent;
_observedAgentsByMessageId[messageId] = agent;
_lastObservedMessageId = messageId;
}
private void QueueMessageReclassifiedIfNeeded(string? messageId)
{
if (string.IsNullOrWhiteSpace(messageId))
{
return;
}
string normalizedMessageId = messageId.Trim();
if (!_reclassifiedMessageIds.Add(normalizedMessageId))
{
return;
}
_pendingEvents.Enqueue(CreateMessageReclassifiedEvent(normalizedMessageId));
}
private AgentActivityEventDto? CreateThinkingActivityIfNeeded(AgentIdentity agent)
{
ActiveAgent = agent;
if (!_startedAgents.Add(agent.AgentId))
{
return null;
}
return new AgentActivityEventDto
{
Type = "agent-activity",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
ActivityType = "thinking",
AgentId = agent.AgentId,
AgentName = agent.AgentName,
};
}
private AgentActivityEventDto? CreateToolCallingActivity(
AgentIdentity agent,
string toolName,
string toolCallId)
{
if (toolName.StartsWith("handoff_to_", StringComparison.Ordinal))
{
return null;
}
return new AgentActivityEventDto
{
Type = "agent-activity",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
ActivityType = "tool-calling",
AgentId = agent.AgentId,
AgentName = agent.AgentName,
ToolName = toolName,
ToolCallId = toolCallId,
};
}
private AgentActivityEventDto CreateCompletedActivity(AgentIdentity agent)
{
return new AgentActivityEventDto
{
Type = "agent-activity",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
ActivityType = "completed",
AgentId = agent.AgentId,
AgentName = agent.AgentName,
};
}
private MessageReclassifiedEventDto CreateMessageReclassifiedEvent(string messageId)
{
return new MessageReclassifiedEventDto
{
Type = "message-reclassified",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
MessageId = messageId,
NewKind = "thinking",
};
}
public void UpdateCompletedMessages(
IReadOnlyList<ChatMessage> allMessages,
IReadOnlyList<ChatMessage> inputMessages)
{
List<ChatMessage> newMessages = WorkflowTranscriptProjector.SelectNewOutputMessages(allMessages, inputMessages);
CompletedMessages = WorkflowTranscriptProjector.ProjectCompletedMessagesFromSegments(
_command,
newMessages,
_transcriptBuffer.Snapshot(),
ActiveAgent);
base.UpdateCompletedMessages(allMessages, inputMessages, CopilotTranscriptProjector.Instance);
}
public IReadOnlyList<ChatMessageDto> FinalizeCompletedMessages()
{
if (CompletedMessages.Count == 0 && _transcriptBuffer.Count > 0)
{
CompletedMessages = WorkflowTranscriptProjector.ProjectCompletedMessagesFromSegments(
_command,
[],
_transcriptBuffer.Snapshot(),
ActiveAgent);
}
foreach (ChatMessageDto message in CompletedMessages)
{
if (_reclassifiedMessageIds.Contains(message.Id))
{
message.MessageKind = "thinking";
}
}
return CompletedMessages;
}
private SubagentEventDto CreateSubagentEvent(
AgentIdentity agent,
string eventKind,
SubagentStartedData? data)
{
return new SubagentEventDto
{
Type = "subagent-event",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
EventKind = eventKind,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
ToolCallId = data?.ToolCallId,
CustomAgentName = data?.AgentName,
CustomAgentDisplayName = data?.AgentDisplayName,
CustomAgentDescription = data?.AgentDescription,
};
}
private SubagentEventDto CreateSubagentCompletedEvent(
AgentIdentity agent,
SubagentCompletedData? data)
{
return new SubagentEventDto
{
Type = "subagent-event",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
EventKind = "completed",
AgentId = agent.AgentId,
AgentName = agent.AgentName,
ToolCallId = data?.ToolCallId,
CustomAgentName = data?.AgentName,
CustomAgentDisplayName = data?.AgentDisplayName,
};
}
private SubagentEventDto CreateSubagentFailedEvent(
AgentIdentity agent,
SubagentFailedData? data)
{
return new SubagentEventDto
{
Type = "subagent-event",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
EventKind = "failed",
AgentId = agent.AgentId,
AgentName = agent.AgentName,
ToolCallId = data?.ToolCallId,
CustomAgentName = data?.AgentName,
CustomAgentDisplayName = data?.AgentDisplayName,
Error = data?.Error,
};
}
private SubagentEventDto CreateSubagentSelectedEvent(
AgentIdentity agent,
SubagentSelectedData? data)
{
return new SubagentEventDto
{
Type = "subagent-event",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
EventKind = "selected",
AgentId = agent.AgentId,
AgentName = agent.AgentName,
CustomAgentName = data?.AgentName,
CustomAgentDisplayName = data?.AgentDisplayName,
Tools = data?.Tools,
};
}
private SubagentEventDto CreateSubagentDeselectedEvent(AgentIdentity agent)
{
return new SubagentEventDto
{
Type = "subagent-event",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
EventKind = "deselected",
AgentId = agent.AgentId,
AgentName = agent.AgentName,
};
}
private AssistantIntentEventDto? CreateAssistantIntentEvent(
AgentIdentity agent,
AssistantIntentData? data)
{
string? intent = data?.Intent?.Trim();
if (string.IsNullOrWhiteSpace(intent))
{
return null;
}
return new AssistantIntentEventDto
{
Type = "assistant-intent",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
Intent = intent,
};
}
private ReasoningDeltaEventDto? CreateReasoningDeltaEvent(
AgentIdentity agent,
AssistantReasoningDeltaData? data)
{
if (data is null
|| string.IsNullOrWhiteSpace(data.ReasoningId)
|| string.IsNullOrEmpty(data.DeltaContent))
{
return null;
}
return new ReasoningDeltaEventDto
{
Type = "reasoning-delta",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
ReasoningId = data.ReasoningId,
ContentDelta = data.DeltaContent,
};
}
private SkillInvokedEventDto CreateSkillInvokedEvent(
AgentIdentity agent,
SkillInvokedData? data)
{
return new SkillInvokedEventDto
{
Type = "skill-invoked",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
SkillName = data?.Name ?? string.Empty,
Path = data?.Path ?? string.Empty,
Content = data?.Content ?? string.Empty,
AllowedTools = data?.AllowedTools,
PluginName = data?.PluginName,
PluginVersion = data?.PluginVersion,
};
}
private HookLifecycleEventDto CreateHookLifecycleEvent(
AgentIdentity agent,
string phase,
HookStartData? data)
{
return new HookLifecycleEventDto
{
Type = "hook-lifecycle",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
HookInvocationId = data?.HookInvocationId ?? string.Empty,
HookType = data?.HookType ?? string.Empty,
Phase = phase,
Input = data?.Input,
};
}
private HookLifecycleEventDto CreateHookLifecycleEvent(
AgentIdentity agent,
string phase,
HookEndData? data)
{
return new HookLifecycleEventDto
{
Type = "hook-lifecycle",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
HookInvocationId = data?.HookInvocationId ?? string.Empty,
HookType = data?.HookType ?? string.Empty,
Phase = phase,
Success = data?.Success,
Output = data?.Output,
Error = data?.Error?.Message,
};
}
private AssistantUsageEventDto CreateAssistantUsageEvent(
AgentIdentity agent,
AssistantUsageData? data)
{
return new AssistantUsageEventDto
{
Type = "assistant-usage",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
Model = data?.Model ?? string.Empty,
InputTokens = data?.InputTokens,
OutputTokens = data?.OutputTokens,
CacheReadTokens = data?.CacheReadTokens,
CacheWriteTokens = data?.CacheWriteTokens,
Cost = data?.Cost,
Duration = data?.Duration,
TotalNanoAiu = data?.CopilotUsage?.TotalNanoAiu,
QuotaSnapshots = QuotaSnapshotMapper.MapOrNull(data?.QuotaSnapshots),
};
}
private SessionUsageEventDto CreateUsageEvent(AgentIdentity agent, SessionUsageInfoData? data)
{
return new SessionUsageEventDto
{
Type = "session-usage",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
TokenLimit = data?.TokenLimit ?? 0,
CurrentTokens = data?.CurrentTokens ?? 0,
MessagesLength = data?.MessagesLength ?? 0,
SystemTokens = data?.SystemTokens,
ConversationTokens = data?.ConversationTokens,
ToolDefinitionsTokens = data?.ToolDefinitionsTokens,
IsInitial = data?.IsInitial,
};
}
private SessionCompactionEventDto CreateCompactionStartEvent(
AgentIdentity agent,
SessionCompactionStartData? data)
{
return new SessionCompactionEventDto
{
Type = "session-compaction",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
Phase = "start",
SystemTokens = data?.SystemTokens,
ConversationTokens = data?.ConversationTokens,
ToolDefinitionsTokens = data?.ToolDefinitionsTokens,
};
}
private SessionCompactionEventDto CreateCompactionCompleteEvent(
AgentIdentity agent,
SessionCompactionCompleteData? data)
{
return new SessionCompactionEventDto
{
Type = "session-compaction",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
Phase = "complete",
Success = data?.Success,
Error = data?.Error,
SystemTokens = data?.SystemTokens,
ConversationTokens = data?.ConversationTokens,
ToolDefinitionsTokens = data?.ToolDefinitionsTokens,
PreCompactionTokens = data?.PreCompactionTokens,
PostCompactionTokens = data?.PostCompactionTokens,
PreCompactionMessagesLength = data?.PreCompactionMessagesLength,
MessagesRemoved = data?.MessagesRemoved,
TokensRemoved = data?.TokensRemoved,
SummaryContent = data?.SummaryContent,
CheckpointNumber = data?.CheckpointNumber,
CheckpointPath = data?.CheckpointPath,
};
}
private PendingMessagesModifiedEventDto CreatePendingMessagesModifiedEvent(AgentIdentity agent)
{
return new PendingMessagesModifiedEventDto
{
Type = "pending-messages-modified",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
};
return base.FinalizeCompletedMessages(CopilotTranscriptProjector.Instance);
}
}
@@ -0,0 +1,12 @@
using Aryx.AgentHost.Contracts;
namespace Aryx.AgentHost.Services;
internal interface IAgentProvider
{
ITurnWorkflowRunner CreateWorkflowRunner(WorkflowValidator workflowValidator);
Task<SidecarCapabilitiesDto> GetCapabilitiesAsync(CancellationToken cancellationToken);
IProviderSessionManager CreateSessionManager();
}
@@ -0,0 +1,8 @@
using Aryx.AgentHost.Contracts;
namespace Aryx.AgentHost.Services;
internal interface IProviderEventAdapter
{
ProviderSessionEvent? TryAdapt(object rawEvent);
}
@@ -0,0 +1,21 @@
using Aryx.AgentHost.Contracts;
using Microsoft.Extensions.AI;
namespace Aryx.AgentHost.Services;
internal interface IProviderTranscriptProjector
{
ChatMessage ToChatMessage(ChatMessageDto message);
void AttachMessageMode(IList<ChatMessage> messages, string? messageMode);
List<ChatMessage> SelectNewOutputMessages(
IReadOnlyList<ChatMessage> outputMessages,
IReadOnlyList<ChatMessage> inputMessages);
List<ChatMessageDto> ProjectCompletedMessagesFromSegments(
RunTurnCommandDto command,
IReadOnlyList<ChatMessage> newMessages,
IReadOnlyList<TranscriptSegment> segments,
AgentIdentity? fallbackAgent = null);
}
@@ -0,0 +1,34 @@
using Aryx.AgentHost.Contracts;
using GitHub.Copilot.SDK;
namespace Aryx.AgentHost.Services;
internal interface IProviderTurnSupport
{
Task<ProviderAgentBundle> CreateAgentBundleAsync(
RunTurnCommandDto command,
TurnExecutionState state,
Func<SidecarEventDto, Task> onEvent,
Func<ApprovalRequestedEventDto, Task> onApproval,
Func<UserInputRequestedEventDto, Task> onUserInput,
CancellationTokenSource runCancellation,
CancellationToken cancellationToken);
Task ResolveApprovalAsync(
ResolveApprovalCommandDto command,
CancellationToken cancellationToken);
Task ResolveUserInputAsync(
ResolveUserInputCommandDto command,
CancellationToken cancellationToken);
Task<UserInputResponse> RequestRequestPortUserInputAsync(
RunTurnCommandDto command,
UserInputRequest request,
Func<UserInputRequestedEventDto, Task> onUserInput,
CancellationToken cancellationToken);
ExitPlanModeRequestedEventDto? ConsumePendingExitPlanModeRequest(string requestId);
void ClearRequestState(string requestId);
}
@@ -0,0 +1,14 @@
using Microsoft.Agents.AI;
namespace Aryx.AgentHost.Services;
internal abstract class ProviderAgentBundle : IAsyncDisposable
{
public abstract IReadOnlyList<AIAgent> Agents { get; }
public abstract bool HasConfiguredHooks { get; }
public abstract IProviderTranscriptProjector TranscriptProjector { get; }
public abstract ValueTask DisposeAsync();
}
@@ -8,7 +8,7 @@ using Microsoft.Extensions.AI;
namespace Aryx.AgentHost.Services;
internal sealed class CopilotAgentBundle : IAsyncDisposable
internal sealed class CopilotAgentBundle : ProviderAgentBundle
{
private static readonly string[] RequiredPromptTools =
[
@@ -25,9 +25,11 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
HasConfiguredHooks = hasConfiguredHooks;
}
public IReadOnlyList<AIAgent> Agents { get; }
public override IReadOnlyList<AIAgent> Agents { get; }
public bool HasConfiguredHooks { get; }
public override bool HasConfiguredHooks { get; }
public override IProviderTranscriptProjector TranscriptProjector { get; } = CopilotTranscriptProjector.Instance;
public static async Task<CopilotAgentBundle> CreateAsync(
RunTurnCommandDto command,
@@ -38,7 +40,6 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
{
List<IAsyncDisposable> disposables = [];
List<AIAgent> agents = [];
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions();
ResolvedHookSet configuredHooks = await HookConfigLoader.LoadAsync(command.ProjectPath, cancellationToken)
.ConfigureAwait(false);
IHookCommandRunner hookCommandRunner = HookCommandRunner.Instance;
@@ -52,42 +53,48 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
disposables.Add(toolingBundle);
}
// Share a single CopilotClient across all agents to avoid spawning
// multiple CLI processes that race on token refresh during auto-login.
CopilotClient sharedClient = new(clientOptions);
await sharedClient.StartAsync(cancellationToken).ConfigureAwait(false);
IReadOnlyList<WorkflowNodeDto> agentNodes = command.Workflow.GetAllAgentNodes(command.WorkflowLibrary);
foreach ((WorkflowNodeDto definition, int agentIndex) in agentNodes.Select((definition, index) => (definition, index)))
if (agentNodes.Count > 0)
{
SessionConfig sessionConfig = CreateSessionConfig(
command,
definition,
agentIndex,
(request, invocation) => onPermissionRequest(definition, request, invocation),
(request, invocation) => onUserInputRequest(definition, request, invocation),
evt => onSessionEvent?.Invoke(definition, evt),
configuredHooks,
hookCommandRunner);
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions();
ApplySessionTooling(sessionConfig, toolingBundle?.McpServers, toolingBundle?.Tools);
ApplyPromptInvocation(sessionConfig, command.PromptInvocation);
// Share a single CopilotClient across all agents to avoid spawning
// multiple CLI processes that race on token refresh during auto-login.
CopilotClient sharedClient = new(clientOptions);
await sharedClient.StartAsync(cancellationToken).ConfigureAwait(false);
AryxCopilotAgent agent = new(
sharedClient,
sessionConfig,
ownsClient: false,
id: definition.GetAgentId(),
name: definition.GetAgentName(),
description: NormalizeOptionalString(definition.Config.Description));
foreach ((WorkflowNodeDto definition, int agentIndex) in agentNodes.Select((definition, index) => (definition, index)))
{
SessionConfig sessionConfig = CreateSessionConfig(
command,
definition,
agentIndex,
(request, invocation) => onPermissionRequest(definition, request, invocation),
(request, invocation) => onUserInputRequest(definition, request, invocation),
evt => onSessionEvent?.Invoke(definition, evt),
configuredHooks,
hookCommandRunner);
agents.Add(agent);
disposables.Add(agent);
ApplySessionTooling(sessionConfig, toolingBundle?.McpServers, toolingBundle?.Tools);
ApplyPromptInvocation(sessionConfig, command.PromptInvocation);
AryxCopilotAgent agent = new(
sharedClient,
sessionConfig,
ownsClient: false,
id: definition.GetAgentId(),
name: definition.GetAgentName(),
description: NormalizeOptionalString(definition.Config.Description));
agents.Add(agent);
disposables.Add(agent);
}
// The bundle owns the shared client — disposed after all agents.
disposables.Add(sharedClient);
}
// The bundle owns the shared client — disposed after all agents.
disposables.Add(sharedClient);
CopilotAgentBundle bundle = new(agents, hasConfiguredHooks: !configuredHooks.IsEmpty);
bundle._disposables.AddRange(disposables);
return bundle;
@@ -210,116 +217,38 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
internal static AIAgentHostOptions CreateAgentHostOptions()
{
return new AIAgentHostOptions
{
EmitAgentUpdateEvents = null,
EmitAgentResponseEvents = false,
InterceptUserInputRequests = false,
InterceptUnterminatedFunctionCalls = false,
ReassignOtherAgentsAsUsers = true,
ForwardIncomingMessages = true,
};
return AgentHostOptionsFactory.CreateDefault();
}
internal static HandoffsWorkflowBuilder CreateHandoffWorkflowBuilder(
internal static HandoffWorkflowBuilder CreateHandoffWorkflowBuilder(
AIAgent entryAgent,
HandoffModeSettingsDto? settings = null)
{
HandoffModeSettingsDto effectiveSettings = settings ?? new HandoffModeSettingsDto();
HandoffsWorkflowBuilder builder = AgentWorkflowBuilder.CreateHandoffBuilderWith(entryAgent)
.WithToolCallFilteringBehavior(MapHandoffToolCallFiltering(effectiveSettings.ToolCallFiltering))
.WithHandoffInstructions(NormalizeOptionalString(effectiveSettings.HandoffInstructions)
?? HandoffWorkflowGuidance.CreateWorkflowInstructions());
if (effectiveSettings.ReturnToPrevious)
{
TryEnableReturnToPrevious(builder);
}
return builder;
return WorkflowOrchestrationFactory.CreateHandoffWorkflowBuilder(entryAgent, settings);
}
internal static Workflow CreateHandoffWorkflow(
WorkflowDefinitionDto workflowDefinition,
IReadOnlyList<AIAgent> agents)
{
ArgumentNullException.ThrowIfNull(workflowDefinition);
ArgumentNullException.ThrowIfNull(agents);
IReadOnlyList<WorkflowNodeDto> agentNodes = workflowDefinition.GetAgentNodes();
Dictionary<string, AIAgent> agentsById = CreateAgentMap(agents);
WorkflowNodeDto triageNode = ResolveTriageAgentNode(workflowDefinition, agentNodes);
AIAgent triageAgent = ResolveAgentForNode(triageNode, agentsById);
HandoffModeSettingsDto? settings = workflowDefinition.Settings.ModeSettings?.Handoff;
HandoffsWorkflowBuilder builder = CreateHandoffWorkflowBuilder(triageAgent, settings);
List<WorkflowNodeDto> specialistNodes = agentNodes
.Where(node => !string.Equals(node.Id, triageNode.Id, StringComparison.Ordinal))
.ToList();
if (specialistNodes.Count == 0)
{
throw new InvalidOperationException("Handoff workflows require at least one specialist agent in addition to the triage agent.");
}
foreach (WorkflowNodeDto specialistNode in specialistNodes)
{
AIAgent specialistAgent = ResolveAgentForNode(specialistNode, agentsById);
builder.WithHandoff(
triageAgent,
specialistAgent,
HandoffWorkflowGuidance.CreateForwardReason(specialistNode));
if (settings?.ReturnToPrevious != true)
{
builder.WithHandoff(
specialistAgent,
triageAgent,
HandoffWorkflowGuidance.CreateReturnReason(triageNode));
}
}
return builder.Build();
return WorkflowOrchestrationFactory.CreateHandoffWorkflow(workflowDefinition, agents);
}
internal static GroupChatWorkflowBuilder CreateGroupChatWorkflowBuilder(
WorkflowDefinitionDto workflowDefinition,
IReadOnlyList<AIAgent> agents)
{
ArgumentNullException.ThrowIfNull(workflowDefinition);
ArgumentNullException.ThrowIfNull(agents);
int maxRounds = ResolveGroupChatMaxRounds(workflowDefinition);
GroupChatWorkflowBuilder builder = AgentWorkflowBuilder.CreateGroupChatBuilderWith(
participants => new RoundRobinGroupChatManager(participants)
{
MaximumIterationCount = maxRounds,
})
.AddParticipants(agents);
string? name = NormalizeOptionalString(workflowDefinition.Name);
if (name is not null)
{
builder.WithName(name);
}
string? description = NormalizeOptionalString(workflowDefinition.Description);
if (description is not null)
{
builder.WithDescription(description);
}
return builder;
return WorkflowOrchestrationFactory.CreateGroupChatWorkflowBuilder(workflowDefinition, agents);
}
internal static Workflow CreateGroupChatWorkflow(
WorkflowDefinitionDto workflowDefinition,
IReadOnlyList<AIAgent> agents)
{
return CreateGroupChatWorkflowBuilder(workflowDefinition, agents).Build();
return WorkflowOrchestrationFactory.CreateGroupChatWorkflow(workflowDefinition, agents);
}
public async ValueTask DisposeAsync()
public override async ValueTask DisposeAsync()
{
foreach (IAsyncDisposable disposable in _disposables)
{
@@ -375,97 +304,6 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
.ToList();
}
private static Dictionary<string, AIAgent> CreateAgentMap(IReadOnlyList<AIAgent> agents)
{
Dictionary<string, AIAgent> agentMap = new(StringComparer.OrdinalIgnoreCase);
foreach (AIAgent agent in agents)
{
if (!string.IsNullOrWhiteSpace(agent.Id))
{
agentMap[agent.Id] = agent;
}
if (!string.IsNullOrWhiteSpace(agent.Name))
{
agentMap[agent.Name] = agent;
}
}
return agentMap;
}
private static AIAgent ResolveAgentForNode(
WorkflowNodeDto node,
IReadOnlyDictionary<string, AIAgent> agentsById)
{
string agentId = node.GetAgentId();
if (agentsById.TryGetValue(agentId, out AIAgent? agent))
{
return agent;
}
string agentName = node.GetAgentName();
if (agentsById.TryGetValue(agentName, out agent))
{
return agent;
}
throw new InvalidOperationException($"Workflow agent \"{agentId}\" could not be resolved from the constructed Copilot agents.");
}
private static WorkflowNodeDto ResolveTriageAgentNode(
WorkflowDefinitionDto workflowDefinition,
IReadOnlyList<WorkflowNodeDto> agentNodes)
{
if (agentNodes.Count == 0)
{
throw new InvalidOperationException("Handoff workflows require at least one agent node.");
}
string? triageAgentNodeId = NormalizeOptionalString(workflowDefinition.Settings.ModeSettings?.Handoff?.TriageAgentNodeId);
if (triageAgentNodeId is null)
{
return agentNodes[0];
}
WorkflowNodeDto? triageNode = agentNodes.FirstOrDefault(node => string.Equals(node.Id, triageAgentNodeId, StringComparison.Ordinal));
return triageNode ?? throw new InvalidOperationException(
$"Handoff workflow triage agent node \"{triageAgentNodeId}\" was not found in the workflow graph.");
}
private static HandoffToolCallFilteringBehavior MapHandoffToolCallFiltering(string? value)
{
return value?.Trim().ToLowerInvariant() switch
{
"none" => HandoffToolCallFilteringBehavior.None,
"all" => HandoffToolCallFilteringBehavior.All,
_ => HandoffToolCallFilteringBehavior.HandoffOnly,
};
}
private static void TryEnableReturnToPrevious(HandoffsWorkflowBuilder builder)
{
builder.GetType()
.GetMethod("EnableReturnToPrevious", Type.EmptyTypes)?
.Invoke(builder, null);
}
private static int ResolveGroupChatMaxRounds(WorkflowDefinitionDto workflowDefinition)
{
int? configuredMaxRounds = workflowDefinition.Settings.ModeSettings?.GroupChat?.MaxRounds;
if (configuredMaxRounds is > 0)
{
return configuredMaxRounds.Value;
}
if (workflowDefinition.Settings.MaxIterations is > 0)
{
return workflowDefinition.Settings.MaxIterations.Value;
}
return 5;
}
private static string? ResolveEffectiveAgent(
string? defaultAgent,
RunTurnPromptInvocationDto? promptInvocation)
@@ -0,0 +1,288 @@
using GitHub.Copilot.SDK;
using GitHub.Copilot.SDK.Rpc;
using Aryx.AgentHost.Contracts;
namespace Aryx.AgentHost.Services;
internal sealed class CopilotAgentProvider : IAgentProvider
{
private const string AskUserToolName = "ask_user";
private static readonly HashSet<string> ExcludedRuntimeToolNames = new(StringComparer.OrdinalIgnoreCase)
{
AskUserToolName,
"report_intent",
"task_complete",
};
private static readonly string[] AuthenticationErrorIndicators =
[
"login",
"log in",
"sign in",
"authenticate",
"authentication",
"not signed in",
"not logged in",
"reauth",
"credential",
];
public ITurnWorkflowRunner CreateWorkflowRunner(WorkflowValidator workflowValidator)
{
ArgumentNullException.ThrowIfNull(workflowValidator);
return new AgentWorkflowTurnRunner(new CopilotTurnRunnerSupport(), workflowValidator);
}
public Task<SidecarCapabilitiesDto> GetCapabilitiesAsync(CancellationToken cancellationToken)
{
return BuildCapabilitiesAsync(cancellationToken);
}
public IProviderSessionManager CreateSessionManager()
{
return new CopilotSessionManager();
}
private static async Task<SidecarCapabilitiesDto> BuildCapabilitiesAsync(CancellationToken cancellationToken)
{
try
{
CopilotCliContext cliContext = CopilotCliPathResolver.ResolveCliContext();
CapabilityProbeResult probe = await ProbeCapabilitiesAsync(cliContext, cancellationToken).ConfigureAwait(false);
return CreateCapabilities(probe.Models, probe.RuntimeTools, probe.Connection);
}
catch (Exception exception)
{
SidecarConnectionDiagnosticsDto connection = CreateMissingCliDiagnostics(exception);
Console.Error.WriteLine($"[aryx sidecar] {connection.Summary} {exception.Message}");
return CreateCapabilities([], [], connection);
}
}
private static async Task<CapabilityProbeResult> ProbeCapabilitiesAsync(
CopilotCliContext cliContext,
CancellationToken cancellationToken)
{
IReadOnlyList<SidecarModelCapabilityDto> models = [];
IReadOnlyList<SidecarRuntimeToolDto> runtimeTools = [];
SidecarCopilotAccountDiagnosticsDto? account = null;
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null;
Task<SidecarCopilotCliVersionDiagnosticsDto> cliVersionTask =
CopilotConnectionMetadataResolver.GetCliVersionDiagnosticsAsync(cliContext, cancellationToken);
try
{
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions(cliContext);
await using CopilotClient client = new(clientOptions);
await client.StartAsync(cancellationToken).ConfigureAwait(false);
GetAuthStatusResponse? authStatus =
await CopilotConnectionMetadataResolver.TryGetAuthStatusAsync(client, cancellationToken).ConfigureAwait(false);
account = await CopilotConnectionMetadataResolver.CreateAccountDiagnosticsAsync(
authStatus,
cliContext.Environment,
cancellationToken)
.ConfigureAwait(false);
models = await ListAvailableModelsAsync(client, cancellationToken).ConfigureAwait(false);
runtimeTools = await TryListAvailableRuntimeToolsAsync(client, cancellationToken).ConfigureAwait(false);
cliVersion = await cliVersionTask.ConfigureAwait(false);
return new CapabilityProbeResult(
models,
runtimeTools,
CreateReadyConnectionDiagnostics(cliContext.CliPath, models.Count, cliVersion, account));
}
catch (Exception exception)
{
cliVersion = await cliVersionTask.ConfigureAwait(false);
Console.Error.WriteLine($"[aryx sidecar] Failed to list available Copilot models: {exception.Message}");
return new CapabilityProbeResult(
models,
runtimeTools,
CreateFailureConnectionDiagnostics(cliContext.CliPath, exception, cliVersion, account));
}
}
private static SidecarCapabilitiesDto CreateCapabilities(
IReadOnlyList<SidecarModelCapabilityDto> models,
IReadOnlyList<SidecarRuntimeToolDto> runtimeTools,
SidecarConnectionDiagnosticsDto connection)
{
return new SidecarCapabilitiesDto
{
Modes = BuildModeCapabilities(),
Models = models,
RuntimeTools = runtimeTools,
Connection = connection,
};
}
private static Dictionary<string, SidecarModeCapabilityDto> BuildModeCapabilities()
{
return new Dictionary<string, SidecarModeCapabilityDto>(StringComparer.OrdinalIgnoreCase)
{
["single"] = new() { Available = true },
["sequential"] = new() { Available = true },
["concurrent"] = new() { Available = true },
["handoff"] = new() { Available = true },
["group-chat"] = new() { Available = true },
["magentic"] = new()
{
Available = false,
Reason = "Microsoft Agent Framework currently documents Magentic orchestration as unsupported in C#.",
},
};
}
private static async Task<IReadOnlyList<SidecarModelCapabilityDto>> ListAvailableModelsAsync(
CopilotClient client,
CancellationToken cancellationToken)
{
List<ModelInfo> models = await client.ListModelsAsync(cancellationToken).ConfigureAwait(false);
return models
.Select(model => new SidecarModelCapabilityDto
{
Id = model.Id,
Name = model.Name,
SupportedReasoningEfforts = (model.SupportedReasoningEfforts ?? [])
.Where(IsReasoningEffort)
.Distinct(StringComparer.Ordinal)
.ToList(),
DefaultReasoningEffort = IsReasoningEffort(model.DefaultReasoningEffort)
? model.DefaultReasoningEffort
: null,
})
.OrderBy(model => model.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static async Task<IReadOnlyList<SidecarRuntimeToolDto>> TryListAvailableRuntimeToolsAsync(
CopilotClient client,
CancellationToken cancellationToken)
{
try
{
return await ListAvailableRuntimeToolsAsync(client, cancellationToken).ConfigureAwait(false);
}
catch (Exception exception)
{
Console.Error.WriteLine($"[aryx sidecar] Failed to list available Copilot runtime tools: {exception.Message}");
return [];
}
}
private static async Task<IReadOnlyList<SidecarRuntimeToolDto>> ListAvailableRuntimeToolsAsync(
CopilotClient client,
CancellationToken cancellationToken)
{
ToolsListResult result = await client.Rpc.Tools.ListAsync(null!, cancellationToken).ConfigureAwait(false);
return MapRuntimeTools(result.Tools);
}
internal static IReadOnlyList<SidecarRuntimeToolDto> MapRuntimeTools(IEnumerable<Tool> tools)
{
return tools
.Where(ShouldIncludeRuntimeTool)
.Where(tool => !string.IsNullOrWhiteSpace(tool.Name))
.Select(tool => new SidecarRuntimeToolDto
{
Id = tool.Name.Trim(),
Label = tool.Name.Trim(),
Description = string.IsNullOrWhiteSpace(tool.Description) ? null : tool.Description.Trim(),
})
.DistinctBy(tool => tool.Id, StringComparer.OrdinalIgnoreCase)
.OrderBy(tool => tool.Label, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static bool ShouldIncludeRuntimeTool(Tool tool)
{
string? toolName = string.IsNullOrWhiteSpace(tool.Name) ? null : tool.Name.Trim();
return toolName is not null
&& !ExcludedRuntimeToolNames.Contains(toolName);
}
private static bool IsReasoningEffort(string? value)
{
return value is "low" or "medium" or "high" or "xhigh";
}
internal static SidecarConnectionDiagnosticsDto CreateMissingCliDiagnostics(Exception exception)
{
return new SidecarConnectionDiagnosticsDto
{
Status = "copilot-cli-missing",
Summary = "GitHub Copilot CLI is not installed or is not available on PATH.",
Detail = exception.Message,
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
};
}
internal static SidecarConnectionDiagnosticsDto CreateReadyConnectionDiagnostics(
string cliPath,
int modelCount,
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null,
SidecarCopilotAccountDiagnosticsDto? account = null)
{
string summary = modelCount switch
{
0 => "Connected to GitHub Copilot, but no models were reported.",
1 => "Connected to GitHub Copilot. 1 model is available.",
_ => $"Connected to GitHub Copilot. {modelCount} models are available.",
};
return new SidecarConnectionDiagnosticsDto
{
Status = "ready",
Summary = summary,
Detail = $"Using Copilot CLI at {cliPath}.",
CopilotCliPath = cliPath,
CopilotCliVersion = cliVersion,
Account = account,
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
};
}
internal static SidecarConnectionDiagnosticsDto CreateFailureConnectionDiagnostics(
string? cliPath,
Exception exception,
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null,
SidecarCopilotAccountDiagnosticsDto? account = null)
{
string status = ClassifyConnectionStatus(exception);
string summary = status == "copilot-auth-required"
? "GitHub Copilot requires authentication before Aryx can load models."
: "GitHub Copilot was found, but Aryx could not load its model list.";
return new SidecarConnectionDiagnosticsDto
{
Status = status,
Summary = summary,
Detail = exception.Message,
CopilotCliPath = cliPath,
CopilotCliVersion = cliVersion,
Account = account,
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
};
}
internal static string ClassifyConnectionStatus(Exception exception)
{
string message = exception.Message;
if (AuthenticationErrorIndicators.Any(indicator =>
message.Contains(indicator, StringComparison.OrdinalIgnoreCase)))
{
return "copilot-auth-required";
}
return "copilot-error";
}
private sealed record CapabilityProbeResult(
IReadOnlyList<SidecarModelCapabilityDto> Models,
IReadOnlyList<SidecarRuntimeToolDto> RuntimeTools,
SidecarConnectionDiagnosticsDto Connection);
}
@@ -0,0 +1,157 @@
using Aryx.AgentHost.Contracts;
using GitHub.Copilot.SDK;
namespace Aryx.AgentHost.Services;
internal sealed class CopilotEventAdapter : IProviderEventAdapter
{
public ProviderSessionEvent? TryAdapt(object rawEvent)
{
return rawEvent switch
{
AssistantMessageDeltaEvent messageDelta
when NormalizeRequiredString(messageDelta.Data?.MessageId) is { } messageId =>
new ProviderAssistantMessageDeltaEvent(messageId),
AssistantMessageEvent assistantMessage
when NormalizeRequiredString(assistantMessage.Data?.MessageId) is { } messageId =>
new ProviderAssistantMessageEvent(
messageId,
assistantMessage.Data?.ToolRequests is { Length: > 0 }),
ToolExecutionStartEvent toolExecutionStart
when NormalizeRequiredString(toolExecutionStart.Data?.ToolCallId) is { } toolCallId
&& NormalizeRequiredString(toolExecutionStart.Data?.ToolName) is { } toolName =>
new ProviderToolExecutionStartEvent(
toolCallId,
toolName,
WorkflowRequestInfoInterpreter.NormalizeRawToolArguments(toolExecutionStart.Data?.Arguments)),
AssistantIntentEvent intentEvent =>
new ProviderAssistantIntentEvent(NormalizeOptionalString(intentEvent.Data?.Intent)),
AssistantReasoningDeltaEvent reasoningDelta =>
new ProviderAssistantReasoningDeltaEvent(
NormalizeOptionalString(reasoningDelta.Data?.ReasoningId),
reasoningDelta.Data?.DeltaContent),
SubagentStartedEvent started =>
new ProviderSubagentStartedEvent(
started.Data?.ToolCallId,
started.Data?.AgentName,
started.Data?.AgentDisplayName,
started.Data?.AgentDescription),
SubagentCompletedEvent completed =>
new ProviderSubagentCompletedEvent(
completed.Data?.ToolCallId,
completed.Data?.AgentName,
completed.Data?.AgentDisplayName),
SubagentFailedEvent failed =>
new ProviderSubagentFailedEvent(
failed.Data?.ToolCallId,
failed.Data?.AgentName,
failed.Data?.AgentDisplayName,
failed.Data?.Error),
SubagentSelectedEvent selected =>
new ProviderSubagentSelectedEvent(
selected.Data?.AgentName,
selected.Data?.AgentDisplayName,
selected.Data?.Tools),
SubagentDeselectedEvent =>
new ProviderSubagentDeselectedEvent(),
SkillInvokedEvent skillInvoked =>
new ProviderSkillInvokedEvent(
skillInvoked.Data?.Name ?? string.Empty,
skillInvoked.Data?.Path ?? string.Empty,
skillInvoked.Data?.Content ?? string.Empty,
skillInvoked.Data?.AllowedTools,
skillInvoked.Data?.PluginName,
skillInvoked.Data?.PluginVersion),
HookStartEvent hookStart =>
new ProviderHookStartEvent(
hookStart.Data?.HookInvocationId ?? string.Empty,
hookStart.Data?.HookType ?? string.Empty,
hookStart.Data?.Input),
HookEndEvent hookEnd =>
new ProviderHookEndEvent(
hookEnd.Data?.HookInvocationId ?? string.Empty,
hookEnd.Data?.HookType ?? string.Empty,
hookEnd.Data?.Success,
hookEnd.Data?.Output,
hookEnd.Data?.Error?.Message),
AssistantUsageEvent assistantUsage =>
new ProviderAssistantUsageEvent(
assistantUsage.Data?.Model ?? string.Empty,
assistantUsage.Data?.InputTokens,
assistantUsage.Data?.OutputTokens,
assistantUsage.Data?.CacheReadTokens,
assistantUsage.Data?.CacheWriteTokens,
assistantUsage.Data?.Cost,
assistantUsage.Data?.Duration,
assistantUsage.Data?.CopilotUsage?.TotalNanoAiu,
QuotaSnapshotMapper.MapOrNull(assistantUsage.Data?.QuotaSnapshots)),
SessionUsageInfoEvent usageInfo =>
new ProviderSessionUsageEvent(
usageInfo.Data?.TokenLimit ?? 0,
usageInfo.Data?.CurrentTokens ?? 0,
usageInfo.Data?.MessagesLength ?? 0,
usageInfo.Data?.SystemTokens,
usageInfo.Data?.ConversationTokens,
usageInfo.Data?.ToolDefinitionsTokens,
usageInfo.Data?.IsInitial),
SessionCompactionStartEvent compactionStart =>
new ProviderSessionCompactionStartEvent(
compactionStart.Data?.SystemTokens,
compactionStart.Data?.ConversationTokens,
compactionStart.Data?.ToolDefinitionsTokens),
SessionCompactionCompleteEvent compactionComplete =>
new ProviderSessionCompactionCompleteEvent(
compactionComplete.Data?.Success,
compactionComplete.Data?.Error,
compactionComplete.Data?.SystemTokens,
compactionComplete.Data?.ConversationTokens,
compactionComplete.Data?.ToolDefinitionsTokens,
compactionComplete.Data?.PreCompactionTokens,
compactionComplete.Data?.PostCompactionTokens,
compactionComplete.Data?.PreCompactionMessagesLength,
compactionComplete.Data?.MessagesRemoved,
compactionComplete.Data?.TokensRemoved,
compactionComplete.Data?.SummaryContent,
compactionComplete.Data?.CheckpointNumber,
compactionComplete.Data?.CheckpointPath),
PendingMessagesModifiedEvent =>
new ProviderPendingMessagesModifiedEvent(),
McpOauthRequiredEvent =>
new ProviderMcpOauthRequiredEvent(),
ExitPlanModeRequestedEvent =>
new ProviderExitPlanModeRequestedEvent(),
_ => null,
};
}
private static string? NormalizeRequiredString(string? value)
{
string? normalized = NormalizeOptionalString(value);
return string.IsNullOrWhiteSpace(normalized) ? null : normalized;
}
private static string? NormalizeOptionalString(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
}
@@ -0,0 +1,35 @@
using Aryx.AgentHost.Contracts;
using Microsoft.Extensions.AI;
namespace Aryx.AgentHost.Services;
internal sealed class CopilotTranscriptProjector : IProviderTranscriptProjector
{
public static CopilotTranscriptProjector Instance { get; } = new();
private CopilotTranscriptProjector()
{
}
public ChatMessage ToChatMessage(ChatMessageDto message)
=> WorkflowTranscriptProjector.ToChatMessage(message);
public void AttachMessageMode(IList<ChatMessage> messages, string? messageMode)
=> WorkflowTranscriptProjector.AttachMessageMode(messages, messageMode);
public List<ChatMessage> SelectNewOutputMessages(
IReadOnlyList<ChatMessage> outputMessages,
IReadOnlyList<ChatMessage> inputMessages)
=> WorkflowTranscriptProjector.SelectNewOutputMessages(outputMessages, inputMessages);
public List<ChatMessageDto> ProjectCompletedMessagesFromSegments(
RunTurnCommandDto command,
IReadOnlyList<ChatMessage> newMessages,
IReadOnlyList<TranscriptSegment> segments,
AgentIdentity? fallbackAgent = null)
=> WorkflowTranscriptProjector.ProjectCompletedMessagesFromSegments(
command,
newMessages,
segments,
fallbackAgent);
}
@@ -0,0 +1,107 @@
using Aryx.AgentHost.Contracts;
using GitHub.Copilot.SDK;
namespace Aryx.AgentHost.Services;
internal sealed class CopilotTurnRunnerSupport : IProviderTurnSupport
{
private readonly CopilotApprovalCoordinator _approvalCoordinator = new();
private readonly CopilotUserInputCoordinator _userInputCoordinator = new();
private readonly CopilotMcpOAuthCoordinator _mcpOAuthCoordinator = new();
private readonly CopilotExitPlanModeCoordinator _exitPlanModeCoordinator = new();
private readonly IProviderEventAdapter _providerEventAdapter = new CopilotEventAdapter();
public async Task<ProviderAgentBundle> CreateAgentBundleAsync(
RunTurnCommandDto command,
TurnExecutionState state,
Func<SidecarEventDto, Task> onEvent,
Func<ApprovalRequestedEventDto, Task> onApproval,
Func<UserInputRequestedEventDto, Task> onUserInput,
CancellationTokenSource runCancellation,
CancellationToken cancellationToken)
{
return await CopilotAgentBundle.CreateAsync(
command,
(agent, request, invocation) => _approvalCoordinator.RequestApprovalAsync(
command,
agent,
request,
invocation,
state.ToolNamesByCallId,
activity => AgentWorkflowTurnRunner.EmitActivityAsync(command, state, activity, onEvent),
onApproval,
runCancellation.Token),
(agent, request, invocation) => _userInputCoordinator.RequestUserInputAsync(
command,
agent,
request,
invocation,
onUserInput,
runCancellation.Token),
(agent, sessionEvent) => ObserveSessionEvent(command, state, runCancellation, agent, sessionEvent),
cancellationToken)
.ConfigureAwait(false);
}
public Task ResolveApprovalAsync(
ResolveApprovalCommandDto command,
CancellationToken cancellationToken)
{
return _approvalCoordinator.ResolveApprovalAsync(command, cancellationToken);
}
public Task ResolveUserInputAsync(
ResolveUserInputCommandDto command,
CancellationToken cancellationToken)
{
return _userInputCoordinator.ResolveUserInputAsync(command, cancellationToken);
}
public Task<UserInputResponse> RequestRequestPortUserInputAsync(
RunTurnCommandDto command,
UserInputRequest request,
Func<UserInputRequestedEventDto, Task> onUserInput,
CancellationToken cancellationToken)
{
return _userInputCoordinator.RequestUserInputAsync(
command,
request,
onUserInput,
cancellationToken);
}
public ExitPlanModeRequestedEventDto? ConsumePendingExitPlanModeRequest(string requestId)
{
return _exitPlanModeCoordinator.ConsumePendingRequest(requestId);
}
public void ClearRequestState(string requestId)
{
_approvalCoordinator.ClearRequestApprovals(requestId);
}
private void ObserveSessionEvent(
RunTurnCommandDto command,
TurnExecutionState state,
CancellationTokenSource runCancellation,
WorkflowNodeDto agent,
SessionEvent sessionEvent)
{
if (_providerEventAdapter.TryAdapt(sessionEvent) is { } providerEvent)
{
state.ObserveSessionEvent(agent, providerEvent);
}
if (sessionEvent is McpOauthRequiredEvent mcpOauthRequired)
{
state.EnqueuePendingMcpOauthRequest(
_mcpOAuthCoordinator.BuildMcpOauthRequiredEvent(command, agent, mcpOauthRequired));
}
if (sessionEvent is ExitPlanModeRequestedEvent exitPlanModeRequested)
{
_exitPlanModeCoordinator.RecordExitPlanModeRequest(command, agent, exitPlanModeRequested);
runCancellation.Cancel();
}
}
}
@@ -0,0 +1,86 @@
using System.IO;
using System.Text.Json;
using Aryx.AgentHost.Contracts;
using GitHub.Copilot.SDK;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
using Microsoft.Agents.AI.Workflows.Checkpointing;
using Microsoft.Agents.AI.Workflows.InProc;
using Microsoft.Extensions.AI;
namespace Aryx.AgentHost.Services;
public sealed class CopilotWorkflowRunner : AgentWorkflowTurnRunner
{
public CopilotWorkflowRunner(WorkflowValidator? workflowValidator = null)
: base(new CopilotTurnRunnerSupport(), workflowValidator)
{
}
internal new static Workflow BuildWorkflowForCommand(
RunTurnCommandDto command,
IReadOnlyList<AIAgent> agents,
WorkflowRunner? workflowRunner = null)
{
return AgentWorkflowTurnRunner.BuildWorkflowForCommand(command, agents, workflowRunner);
}
internal new static FileSystemJsonCheckpointStore? CreateCheckpointStore(RunTurnCommandDto command)
{
return AgentWorkflowTurnRunner.CreateCheckpointStore(command);
}
internal new static bool ShouldEnableWorkflowCheckpointing(RunTurnCommandDto command)
{
return AgentWorkflowTurnRunner.ShouldEnableWorkflowCheckpointing(command);
}
internal new static string GetCheckpointStorePath(RunTurnCommandDto command)
{
return AgentWorkflowTurnRunner.GetCheckpointStorePath(command);
}
internal new static InProcessExecutionEnvironment CreateExecutionEnvironment(
RunTurnCommandDto command,
CheckpointManager? checkpointManager)
{
return AgentWorkflowTurnRunner.CreateExecutionEnvironment(command, checkpointManager);
}
internal static void ConfigureHookLifecycleEventSuppression(
CopilotTurnExecutionState state,
CopilotAgentBundle bundle)
{
AgentWorkflowTurnRunner.ConfigureHookLifecycleEventSuppression(state, bundle);
}
internal new static UserInputRequest CreateRequestPortUserInputRequest(
AgentWorkflowTurnRunner.WorkflowRequestPortMetadata metadata,
RequestInfoEvent requestInfo)
{
return AgentWorkflowTurnRunner.CreateRequestPortUserInputRequest(metadata, requestInfo);
}
internal new static object CoerceRequestPortResponse(string responseType, string? answer)
{
return AgentWorkflowTurnRunner.CoerceRequestPortResponse(responseType, answer);
}
private static Task<bool> HandleWorkflowEventAsync(
RunTurnCommandDto command,
WorkflowEvent evt,
IReadOnlyList<ChatMessage> inputMessages,
CopilotTurnExecutionState state,
Func<TurnDeltaEventDto, Task> onDelta,
Func<SidecarEventDto, Task> onEvent)
{
return AgentWorkflowTurnRunner.HandleWorkflowEventAsync(
command,
evt,
inputMessages,
state,
CopilotTranscriptProjector.Instance,
onDelta,
onEvent);
}
}
@@ -2,7 +2,7 @@ using Aryx.AgentHost.Contracts;
namespace Aryx.AgentHost.Services;
public interface ICopilotSessionManager
public interface IProviderSessionManager
{
Task<IReadOnlyList<CopilotSessionInfoDto>> ListSessionsAsync(
CopilotSessionListFilterDto? filter,
@@ -17,3 +17,5 @@ public interface ICopilotSessionManager
CancellationToken cancellationToken);
}
public interface ICopilotSessionManager : IProviderSessionManager;
@@ -1,8 +1,6 @@
using System.Collections.Concurrent;
using System.Text.Json;
using System.Text.Json.Serialization;
using GitHub.Copilot.SDK;
using GitHub.Copilot.SDK.Rpc;
using Aryx.AgentHost.Contracts;
namespace Aryx.AgentHost.Services;
@@ -19,31 +17,11 @@ public sealed class SidecarProtocolHost
private const string DeleteSessionCommandType = "delete-session";
private const string DisconnectSessionCommandType = "disconnect-session";
private const string GetQuotaCommandType = "get-quota";
private const string AskUserToolName = "ask_user";
private static readonly HashSet<string> ExcludedRuntimeToolNames = new(StringComparer.OrdinalIgnoreCase)
{
AskUserToolName,
"report_intent",
"task_complete",
};
private static readonly string[] AuthenticationErrorIndicators =
[
"login",
"log in",
"sign in",
"authenticate",
"authentication",
"not signed in",
"not logged in",
"reauth",
"credential",
];
private readonly Func<CancellationToken, Task<SidecarCapabilitiesDto>> _capabilitiesProvider;
private readonly WorkflowValidator _workflowValidator;
private readonly ITurnWorkflowRunner _workflowRunner;
private readonly ICopilotSessionManager _sessionManager;
private readonly IProviderSessionManager _sessionManager;
private readonly JsonSerializerOptions _jsonOptions;
private readonly IReadOnlyDictionary<string, Func<CommandContext, Task>> _commandHandlers;
private readonly SemaphoreSlim _writeLock = new(1, 1);
@@ -60,7 +38,7 @@ public sealed class SidecarProtocolHost
public SidecarProtocolHost(
ITurnWorkflowRunner? workflowRunner = null,
Func<CancellationToken, Task<SidecarCapabilitiesDto>>? capabilitiesProvider = null,
ICopilotSessionManager? sessionManager = null)
IProviderSessionManager? sessionManager = null)
: this(new WorkflowValidator(), workflowRunner, capabilitiesProvider, sessionManager)
{
}
@@ -69,12 +47,25 @@ public sealed class SidecarProtocolHost
WorkflowValidator workflowValidator,
ITurnWorkflowRunner? workflowRunner = null,
Func<CancellationToken, Task<SidecarCapabilitiesDto>>? capabilitiesProvider = null,
ICopilotSessionManager? sessionManager = null)
IProviderSessionManager? sessionManager = null)
: this(workflowValidator, new CopilotAgentProvider(), workflowRunner, capabilitiesProvider, sessionManager)
{
}
internal SidecarProtocolHost(
WorkflowValidator workflowValidator,
IAgentProvider agentProvider,
ITurnWorkflowRunner? workflowRunner = null,
Func<CancellationToken, Task<SidecarCapabilitiesDto>>? capabilitiesProvider = null,
IProviderSessionManager? sessionManager = null)
{
ArgumentNullException.ThrowIfNull(workflowValidator);
ArgumentNullException.ThrowIfNull(agentProvider);
_workflowValidator = workflowValidator;
_workflowRunner = workflowRunner ?? new CopilotWorkflowRunner(_workflowValidator);
_capabilitiesProvider = capabilitiesProvider ?? BuildCapabilitiesAsync;
_sessionManager = sessionManager ?? new CopilotSessionManager();
_workflowRunner = workflowRunner ?? agentProvider.CreateWorkflowRunner(_workflowValidator);
_capabilitiesProvider = capabilitiesProvider ?? agentProvider.GetCapabilitiesAsync;
_sessionManager = sessionManager ?? agentProvider.CreateSessionManager();
_jsonOptions = JsonSerialization.CreateWebOptions();
_jsonOptions.DefaultIgnoreCondition = JsonIgnoreCondition.WhenWritingNull;
_jsonOptions.PropertyNameCaseInsensitive = true;
@@ -459,252 +450,9 @@ public sealed class SidecarProtocolHost
return cancelledRequestIds;
}
private static async Task<SidecarCapabilitiesDto> BuildCapabilitiesAsync(CancellationToken cancellationToken)
{
try
{
CopilotCliContext cliContext = CopilotCliPathResolver.ResolveCliContext();
CapabilityProbeResult probe = await ProbeCapabilitiesAsync(cliContext, cancellationToken).ConfigureAwait(false);
return CreateCapabilities(probe.Models, probe.RuntimeTools, probe.Connection);
}
catch (Exception exception)
{
SidecarConnectionDiagnosticsDto connection = CreateMissingCliDiagnostics(exception);
Console.Error.WriteLine($"[aryx sidecar] {connection.Summary} {exception.Message}");
return CreateCapabilities([], [], connection);
}
}
private static async Task<CapabilityProbeResult> ProbeCapabilitiesAsync(
CopilotCliContext cliContext,
CancellationToken cancellationToken)
{
IReadOnlyList<SidecarModelCapabilityDto> models = [];
IReadOnlyList<SidecarRuntimeToolDto> runtimeTools = [];
SidecarCopilotAccountDiagnosticsDto? account = null;
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null;
Task<SidecarCopilotCliVersionDiagnosticsDto> cliVersionTask =
CopilotConnectionMetadataResolver.GetCliVersionDiagnosticsAsync(cliContext, cancellationToken);
try
{
CopilotClientOptions clientOptions = CopilotCliPathResolver.CreateClientOptions(cliContext);
await using CopilotClient client = new(clientOptions);
await client.StartAsync(cancellationToken).ConfigureAwait(false);
GetAuthStatusResponse? authStatus =
await CopilotConnectionMetadataResolver.TryGetAuthStatusAsync(client, cancellationToken).ConfigureAwait(false);
account = await CopilotConnectionMetadataResolver.CreateAccountDiagnosticsAsync(
authStatus,
cliContext.Environment,
cancellationToken)
.ConfigureAwait(false);
models = await ListAvailableModelsAsync(client, cancellationToken).ConfigureAwait(false);
runtimeTools = await TryListAvailableRuntimeToolsAsync(client, cancellationToken).ConfigureAwait(false);
cliVersion = await cliVersionTask.ConfigureAwait(false);
return new CapabilityProbeResult(
models,
runtimeTools,
CreateReadyConnectionDiagnostics(cliContext.CliPath, models.Count, cliVersion, account));
}
catch (Exception exception)
{
cliVersion = await cliVersionTask.ConfigureAwait(false);
Console.Error.WriteLine($"[aryx sidecar] Failed to list available Copilot models: {exception.Message}");
return new CapabilityProbeResult(
models,
runtimeTools,
CreateFailureConnectionDiagnostics(cliContext.CliPath, exception, cliVersion, account));
}
}
private static SidecarCapabilitiesDto CreateCapabilities(
IReadOnlyList<SidecarModelCapabilityDto> models,
IReadOnlyList<SidecarRuntimeToolDto> runtimeTools,
SidecarConnectionDiagnosticsDto connection)
{
return new SidecarCapabilitiesDto
{
Modes = BuildModeCapabilities(),
Models = models,
RuntimeTools = runtimeTools,
Connection = connection,
};
}
private static Dictionary<string, SidecarModeCapabilityDto> BuildModeCapabilities()
{
return new Dictionary<string, SidecarModeCapabilityDto>(StringComparer.OrdinalIgnoreCase)
{
["single"] = new() { Available = true },
["sequential"] = new() { Available = true },
["concurrent"] = new() { Available = true },
["handoff"] = new() { Available = true },
["group-chat"] = new() { Available = true },
["magentic"] = new()
{
Available = false,
Reason = "Microsoft Agent Framework currently documents Magentic orchestration as unsupported in C#.",
},
};
}
private static async Task<IReadOnlyList<SidecarModelCapabilityDto>> ListAvailableModelsAsync(
CopilotClient client,
CancellationToken cancellationToken)
{
List<ModelInfo> models = await client.ListModelsAsync(cancellationToken).ConfigureAwait(false);
return models
.Select(model => new SidecarModelCapabilityDto
{
Id = model.Id,
Name = model.Name,
SupportedReasoningEfforts = (model.SupportedReasoningEfforts ?? [])
.Where(IsReasoningEffort)
.Distinct(StringComparer.Ordinal)
.ToList(),
DefaultReasoningEffort = IsReasoningEffort(model.DefaultReasoningEffort)
? model.DefaultReasoningEffort
: null,
})
.OrderBy(model => model.Name, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static async Task<IReadOnlyList<SidecarRuntimeToolDto>> TryListAvailableRuntimeToolsAsync(
CopilotClient client,
CancellationToken cancellationToken)
{
try
{
return await ListAvailableRuntimeToolsAsync(client, cancellationToken).ConfigureAwait(false);
}
catch (Exception exception)
{
Console.Error.WriteLine($"[aryx sidecar] Failed to list available Copilot runtime tools: {exception.Message}");
return [];
}
}
private static async Task<IReadOnlyList<SidecarRuntimeToolDto>> ListAvailableRuntimeToolsAsync(
CopilotClient client,
CancellationToken cancellationToken)
{
ToolsListResult result = await client.Rpc.Tools.ListAsync(null!, cancellationToken).ConfigureAwait(false);
return MapRuntimeTools(result.Tools);
}
internal static IReadOnlyList<SidecarRuntimeToolDto> MapRuntimeTools(IEnumerable<Tool> tools)
{
return tools
.Where(ShouldIncludeRuntimeTool)
.Where(tool => !string.IsNullOrWhiteSpace(tool.Name))
.Select(tool => new SidecarRuntimeToolDto
{
Id = tool.Name.Trim(),
Label = tool.Name.Trim(),
Description = string.IsNullOrWhiteSpace(tool.Description) ? null : tool.Description.Trim(),
})
.DistinctBy(tool => tool.Id, StringComparer.OrdinalIgnoreCase)
.OrderBy(tool => tool.Label, StringComparer.OrdinalIgnoreCase)
.ToList();
}
private static bool ShouldIncludeRuntimeTool(Tool tool)
{
string? toolName = string.IsNullOrWhiteSpace(tool.Name) ? null : tool.Name.Trim();
return toolName is not null
&& !ExcludedRuntimeToolNames.Contains(toolName);
}
private static bool IsReasoningEffort(string? value)
{
return value is "low" or "medium" or "high" or "xhigh";
}
internal static SidecarConnectionDiagnosticsDto CreateMissingCliDiagnostics(Exception exception)
{
return new SidecarConnectionDiagnosticsDto
{
Status = "copilot-cli-missing",
Summary = "GitHub Copilot CLI is not installed or is not available on PATH.",
Detail = exception.Message,
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
};
}
internal static SidecarConnectionDiagnosticsDto CreateReadyConnectionDiagnostics(
string cliPath,
int modelCount,
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null,
SidecarCopilotAccountDiagnosticsDto? account = null)
{
string summary = modelCount switch
{
0 => "Connected to GitHub Copilot, but no models were reported.",
1 => "Connected to GitHub Copilot. 1 model is available.",
_ => $"Connected to GitHub Copilot. {modelCount} models are available.",
};
return new SidecarConnectionDiagnosticsDto
{
Status = "ready",
Summary = summary,
Detail = $"Using Copilot CLI at {cliPath}.",
CopilotCliPath = cliPath,
CopilotCliVersion = cliVersion,
Account = account,
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
};
}
internal static SidecarConnectionDiagnosticsDto CreateFailureConnectionDiagnostics(
string? cliPath,
Exception exception,
SidecarCopilotCliVersionDiagnosticsDto? cliVersion = null,
SidecarCopilotAccountDiagnosticsDto? account = null)
{
string status = ClassifyConnectionStatus(exception);
string summary = status == "copilot-auth-required"
? "GitHub Copilot requires authentication before Aryx can load models."
: "GitHub Copilot was found, but Aryx could not load its model list.";
return new SidecarConnectionDiagnosticsDto
{
Status = status,
Summary = summary,
Detail = exception.Message,
CopilotCliPath = cliPath,
CopilotCliVersion = cliVersion,
Account = account,
CheckedAt = DateTimeOffset.UtcNow.ToString("O"),
};
}
internal static string ClassifyConnectionStatus(Exception exception)
{
string message = exception.Message;
if (AuthenticationErrorIndicators.Any(indicator =>
message.Contains(indicator, StringComparison.OrdinalIgnoreCase)))
{
return "copilot-auth-required";
}
return "copilot-error";
}
private sealed record CommandContext(
string RawCommand,
SidecarCommandEnvelope Envelope,
TextWriter Output,
CancellationToken CancellationToken);
private sealed record CapabilityProbeResult(
IReadOnlyList<SidecarModelCapabilityDto> Models,
IReadOnlyList<SidecarRuntimeToolDto> RuntimeTools,
SidecarConnectionDiagnosticsDto Connection);
}
@@ -0,0 +1,762 @@
using System.Collections.Concurrent;
using Aryx.AgentHost.Contracts;
using Microsoft.Extensions.AI;
namespace Aryx.AgentHost.Services;
internal class TurnExecutionState
{
private readonly RunTurnCommandDto _command;
private readonly IReadOnlyDictionary<string, WorkflowDefinitionDto> _workflowLibrary;
private readonly IReadOnlyDictionary<string, SubworkflowContext> _agentSubworkflowIndex;
private readonly HashSet<string> _startedAgents = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _reclassifiedMessageIds = new(StringComparer.Ordinal);
private readonly ConcurrentQueue<SidecarEventDto> _pendingEvents = new();
private readonly ConcurrentQueue<McpOauthRequiredEventDto> _pendingMcpOauthRequests = new();
private readonly ConcurrentDictionary<string, AgentIdentity> _observedAgentsByMessageId = new(StringComparer.Ordinal);
private readonly StreamingTranscriptBuffer _transcriptBuffer = new();
private int _fallbackMessageIndex;
private string? _lastObservedMessageId;
public TurnExecutionState(RunTurnCommandDto command)
{
_command = command;
_workflowLibrary = WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(command.WorkflowLibrary);
_agentSubworkflowIndex = AgentIdentityResolver.BuildAgentSubworkflowIndex(command.Workflow, _workflowLibrary);
}
public ConcurrentDictionary<string, string> ToolNamesByCallId { get; } = new(StringComparer.Ordinal);
public ConcurrentDictionary<string, bool> ToolCallHasArgumentsById { get; } = new(StringComparer.Ordinal);
public AgentIdentity? ActiveAgent { get; private set; }
public List<ChatMessageDto> CompletedMessages { get; private set; } = [];
public bool HasPendingExitPlanModeRequest { get; private set; }
public bool SuppressHookLifecycleEvents { get; set; }
public AgentIdentity ResolveAgentIdentity(string? agentId, string? agentName)
{
return AgentIdentityResolver.ResolveAgentIdentity(
_command.Workflow,
_workflowLibrary,
agentId,
agentName,
_agentSubworkflowIndex);
}
public bool TryResolveKnownAgentIdentity(string? agentIdentifier, out AgentIdentity agent)
{
return AgentIdentityResolver.TryResolveKnownAgentIdentity(
_command.Workflow,
_workflowLibrary,
agentIdentifier,
_agentSubworkflowIndex,
out agent);
}
public bool TryResolveObservedAgentIdentity(
string? agentIdentifier,
AgentIdentity? fallbackAgent,
out AgentIdentity agent)
{
return AgentIdentityResolver.TryResolveObservedAgentIdentity(
_command.Workflow,
_workflowLibrary,
agentIdentifier,
fallbackAgent,
_agentSubworkflowIndex,
out agent);
}
public bool TryCreateSubworkflowLifecycleActivity(
string activityType,
string? executorId,
out AgentActivityEventDto activity)
{
activity = default!;
if (!AgentIdentityResolver.TryResolveSubworkflowContext(
_command.Workflow,
_workflowLibrary,
executorId,
out SubworkflowContext subworkflow))
{
return false;
}
activity = CreateSubworkflowActivity(activityType, subworkflow);
return true;
}
public async Task EmitThinkingIfNeeded(
AgentIdentity agent,
Func<SidecarEventDto, Task> onEvent)
{
AgentActivityEventDto? thinkingActivity = CreateThinkingActivityIfNeeded(agent);
if (thinkingActivity is null)
{
return;
}
await onEvent(thinkingActivity).ConfigureAwait(false);
}
public void QueueThinkingIfNeeded(AgentIdentity agent)
{
AgentActivityEventDto? thinkingActivity = CreateThinkingActivityIfNeeded(agent);
if (thinkingActivity is not null)
{
_pendingEvents.Enqueue(thinkingActivity);
}
}
public void QueueCompletedActivity(AgentIdentity agent)
{
_pendingEvents.Enqueue(CreateCompletedActivity(agent));
}
public void ApplyEvent(SidecarEventDto evt)
{
if (evt is AgentActivityEventDto activity
&& string.Equals(activity.ActivityType, "handoff", StringComparison.Ordinal)
&& !string.IsNullOrWhiteSpace(activity.AgentId)
&& !string.IsNullOrWhiteSpace(activity.AgentName))
{
ActiveAgent = ResolveAgentIdentity(activity.AgentId, activity.AgentName);
}
}
public void ObserveSessionEvent(WorkflowNodeDto agentDefinition, ProviderSessionEvent sessionEvent)
{
AgentIdentity agent = ResolveAgentIdentity(
agentDefinition.GetAgentId(),
agentDefinition.GetAgentName());
switch (sessionEvent)
{
case ProviderAssistantMessageDeltaEvent messageDelta:
RecordObservedAgentForMessage(agent, messageDelta.MessageId);
QueueThinkingIfNeeded(agent);
break;
case ProviderAssistantMessageEvent assistantMessage:
RecordObservedAgentForMessage(agent, assistantMessage.MessageId);
QueueThinkingIfNeeded(agent);
if (assistantMessage.HasToolRequests)
{
QueueMessageReclassifiedIfNeeded(assistantMessage.MessageId);
}
break;
case ProviderToolExecutionStartEvent toolExecutionStart:
string toolCallId = toolExecutionStart.ToolCallId;
string toolName = toolExecutionStart.ToolName;
TrackToolCall(toolCallId, toolName, toolExecutionStart.ToolArguments);
ActiveAgent = agent;
AgentActivityEventDto? toolActivity = CreateToolCallingActivity(
agent, toolName, toolCallId, toolExecutionStart.ToolArguments);
if (toolActivity is not null)
{
_pendingEvents.Enqueue(toolActivity);
}
QueueMessageReclassifiedIfNeeded(_lastObservedMessageId);
break;
case ProviderAssistantIntentEvent intentEvent:
ActiveAgent = agent;
QueueThinkingIfNeeded(agent);
AssistantIntentEventDto? assistantIntent = CreateAssistantIntentEvent(agent, intentEvent.Intent);
if (assistantIntent is not null)
{
_pendingEvents.Enqueue(assistantIntent);
}
break;
case ProviderAssistantReasoningDeltaEvent reasoningDelta:
ActiveAgent = agent;
QueueThinkingIfNeeded(agent);
ReasoningDeltaEventDto? reasoningDeltaEvent = CreateReasoningDeltaEvent(
agent,
reasoningDelta.ReasoningId,
reasoningDelta.DeltaContent);
if (reasoningDeltaEvent is not null)
{
_pendingEvents.Enqueue(reasoningDeltaEvent);
}
break;
case ProviderSubagentStartedEvent started:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateSubagentStartedEvent(agent, started));
break;
case ProviderSubagentCompletedEvent completed:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateSubagentCompletedEvent(agent, completed));
break;
case ProviderSubagentFailedEvent failed:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateSubagentFailedEvent(agent, failed));
break;
case ProviderSubagentSelectedEvent selected:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateSubagentSelectedEvent(agent, selected));
break;
case ProviderSubagentDeselectedEvent:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateSubagentDeselectedEvent(agent));
break;
case ProviderSkillInvokedEvent skillInvoked:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateSkillInvokedEvent(agent, skillInvoked));
break;
case ProviderHookStartEvent hookStart:
ActiveAgent = agent;
if (!SuppressHookLifecycleEvents)
{
_pendingEvents.Enqueue(CreateHookLifecycleEvent(
agent,
"start",
hookStart.HookInvocationId,
hookStart.HookType,
input: hookStart.Input));
}
break;
case ProviderHookEndEvent hookEnd:
ActiveAgent = agent;
if (!SuppressHookLifecycleEvents)
{
_pendingEvents.Enqueue(CreateHookLifecycleEvent(
agent,
"end",
hookEnd.HookInvocationId,
hookEnd.HookType,
success: hookEnd.Success,
output: hookEnd.Output,
error: hookEnd.Error));
}
break;
case ProviderAssistantUsageEvent assistantUsage:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateAssistantUsageEvent(agent, assistantUsage));
break;
case ProviderSessionUsageEvent usageInfo:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateUsageEvent(agent, usageInfo));
break;
case ProviderSessionCompactionStartEvent compactionStart:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateCompactionStartEvent(agent, compactionStart));
break;
case ProviderSessionCompactionCompleteEvent compactionComplete:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreateCompactionCompleteEvent(agent, compactionComplete));
break;
case ProviderPendingMessagesModifiedEvent:
ActiveAgent = agent;
_pendingEvents.Enqueue(CreatePendingMessagesModifiedEvent(agent));
break;
case ProviderMcpOauthRequiredEvent:
ActiveAgent = agent;
break;
case ProviderExitPlanModeRequestedEvent:
HasPendingExitPlanModeRequest = true;
ActiveAgent = agent;
break;
}
}
public IReadOnlyList<SidecarEventDto> DrainPendingEvents()
{
List<SidecarEventDto> pending = [];
while (_pendingEvents.TryDequeue(out SidecarEventDto? pendingEvent))
{
pending.Add(pendingEvent);
}
return pending;
}
public void EnqueuePendingMcpOauthRequest(McpOauthRequiredEventDto request)
{
ArgumentNullException.ThrowIfNull(request);
_pendingMcpOauthRequests.Enqueue(request);
}
public IReadOnlyList<McpOauthRequiredEventDto> DrainPendingMcpOauthRequests()
{
List<McpOauthRequiredEventDto> pending = [];
while (_pendingMcpOauthRequests.TryDequeue(out McpOauthRequiredEventDto? request))
{
pending.Add(request);
}
return pending;
}
public bool TryResolveObservedAgentForMessage(string? messageId, out AgentIdentity agent)
{
agent = default;
return !string.IsNullOrWhiteSpace(messageId)
&& _observedAgentsByMessageId.TryGetValue(messageId, out agent);
}
public string CreateMessageId(string? messageId)
{
return messageId ?? $"{_command.RequestId}-delta-{_fallbackMessageIndex++}";
}
public TranscriptSegment AppendDelta(
string messageId,
string authorName,
string delta)
{
return _transcriptBuffer.AppendDelta(messageId, authorName, delta);
}
public void ClearActiveAgentIfMatching(AgentIdentity completedAgent)
{
if (ActiveAgent.HasValue
&& string.Equals(ActiveAgent.Value.AgentId, completedAgent.AgentId, StringComparison.Ordinal))
{
ActiveAgent = null;
}
}
private void RecordObservedAgentForMessage(AgentIdentity agent, string messageId)
{
ActiveAgent = agent;
_observedAgentsByMessageId[messageId] = agent;
_lastObservedMessageId = messageId;
}
private void TrackToolCall(
string toolCallId,
string toolName,
IReadOnlyDictionary<string, object?>? toolArguments)
{
ToolNamesByCallId[toolCallId] = toolName;
ToolCallHasArgumentsById[toolCallId] = toolArguments is { Count: > 0 };
}
private void QueueMessageReclassifiedIfNeeded(string? messageId)
{
if (string.IsNullOrWhiteSpace(messageId))
{
return;
}
string normalizedMessageId = messageId.Trim();
if (!_reclassifiedMessageIds.Add(normalizedMessageId))
{
return;
}
_pendingEvents.Enqueue(CreateMessageReclassifiedEvent(normalizedMessageId));
}
private AgentActivityEventDto? CreateThinkingActivityIfNeeded(AgentIdentity agent)
{
ActiveAgent = agent;
if (!_startedAgents.Add(agent.AgentId))
{
return null;
}
return new AgentActivityEventDto
{
Type = "agent-activity",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
ActivityType = "thinking",
AgentId = agent.AgentId,
AgentName = agent.AgentName,
SubworkflowNodeId = agent.Subworkflow?.SubworkflowNodeId,
SubworkflowName = agent.Subworkflow?.SubworkflowName,
};
}
private AgentActivityEventDto? CreateToolCallingActivity(
AgentIdentity agent,
string toolName,
string toolCallId,
IReadOnlyDictionary<string, object?>? toolArguments = null)
{
if (toolName.StartsWith("handoff_to_", StringComparison.Ordinal))
{
return null;
}
return new AgentActivityEventDto
{
Type = "agent-activity",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
ActivityType = "tool-calling",
AgentId = agent.AgentId,
AgentName = agent.AgentName,
SubworkflowNodeId = agent.Subworkflow?.SubworkflowNodeId,
SubworkflowName = agent.Subworkflow?.SubworkflowName,
ToolName = toolName,
ToolCallId = toolCallId,
ToolArguments = toolArguments,
};
}
private AgentActivityEventDto CreateCompletedActivity(AgentIdentity agent)
{
return new AgentActivityEventDto
{
Type = "agent-activity",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
ActivityType = "completed",
AgentId = agent.AgentId,
AgentName = agent.AgentName,
SubworkflowNodeId = agent.Subworkflow?.SubworkflowNodeId,
SubworkflowName = agent.Subworkflow?.SubworkflowName,
};
}
private AgentActivityEventDto CreateSubworkflowActivity(
string activityType,
SubworkflowContext subworkflow)
{
return new AgentActivityEventDto
{
Type = "agent-activity",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
ActivityType = activityType,
SubworkflowNodeId = subworkflow.SubworkflowNodeId,
SubworkflowName = subworkflow.SubworkflowName,
};
}
private MessageReclassifiedEventDto CreateMessageReclassifiedEvent(string messageId)
{
return new MessageReclassifiedEventDto
{
Type = "message-reclassified",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
MessageId = messageId,
NewKind = "thinking",
};
}
public void UpdateCompletedMessages(
IReadOnlyList<ChatMessage> allMessages,
IReadOnlyList<ChatMessage> inputMessages,
IProviderTranscriptProjector transcriptProjector)
{
ArgumentNullException.ThrowIfNull(transcriptProjector);
List<ChatMessage> newMessages = transcriptProjector.SelectNewOutputMessages(allMessages, inputMessages);
CompletedMessages = transcriptProjector.ProjectCompletedMessagesFromSegments(
_command,
newMessages,
_transcriptBuffer.Snapshot(),
ActiveAgent);
}
public IReadOnlyList<ChatMessageDto> FinalizeCompletedMessages(IProviderTranscriptProjector transcriptProjector)
{
ArgumentNullException.ThrowIfNull(transcriptProjector);
if (CompletedMessages.Count == 0 && _transcriptBuffer.Count > 0)
{
CompletedMessages = transcriptProjector.ProjectCompletedMessagesFromSegments(
_command,
[],
_transcriptBuffer.Snapshot(),
ActiveAgent);
}
foreach (ChatMessageDto message in CompletedMessages)
{
if (_reclassifiedMessageIds.Contains(message.Id))
{
message.MessageKind = "thinking";
}
}
return CompletedMessages;
}
private SubagentEventDto CreateSubagentStartedEvent(
AgentIdentity agent,
ProviderSubagentStartedEvent data)
{
return new SubagentEventDto
{
Type = "subagent-event",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
EventKind = "started",
AgentId = agent.AgentId,
AgentName = agent.AgentName,
ToolCallId = data.ToolCallId,
CustomAgentName = data.AgentName,
CustomAgentDisplayName = data.AgentDisplayName,
CustomAgentDescription = data.AgentDescription,
};
}
private SubagentEventDto CreateSubagentCompletedEvent(
AgentIdentity agent,
ProviderSubagentCompletedEvent data)
{
return new SubagentEventDto
{
Type = "subagent-event",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
EventKind = "completed",
AgentId = agent.AgentId,
AgentName = agent.AgentName,
ToolCallId = data.ToolCallId,
CustomAgentName = data.AgentName,
CustomAgentDisplayName = data.AgentDisplayName,
};
}
private SubagentEventDto CreateSubagentFailedEvent(
AgentIdentity agent,
ProviderSubagentFailedEvent data)
{
return new SubagentEventDto
{
Type = "subagent-event",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
EventKind = "failed",
AgentId = agent.AgentId,
AgentName = agent.AgentName,
ToolCallId = data.ToolCallId,
CustomAgentName = data.AgentName,
CustomAgentDisplayName = data.AgentDisplayName,
Error = data.Error,
};
}
private SubagentEventDto CreateSubagentSelectedEvent(
AgentIdentity agent,
ProviderSubagentSelectedEvent data)
{
return new SubagentEventDto
{
Type = "subagent-event",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
EventKind = "selected",
AgentId = agent.AgentId,
AgentName = agent.AgentName,
CustomAgentName = data.AgentName,
CustomAgentDisplayName = data.AgentDisplayName,
Tools = data.Tools,
};
}
private SubagentEventDto CreateSubagentDeselectedEvent(AgentIdentity agent)
{
return new SubagentEventDto
{
Type = "subagent-event",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
EventKind = "deselected",
AgentId = agent.AgentId,
AgentName = agent.AgentName,
};
}
private AssistantIntentEventDto? CreateAssistantIntentEvent(
AgentIdentity agent,
string? intent)
{
string? normalizedIntent = intent?.Trim();
if (string.IsNullOrWhiteSpace(normalizedIntent))
{
return null;
}
return new AssistantIntentEventDto
{
Type = "assistant-intent",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
Intent = normalizedIntent,
};
}
private ReasoningDeltaEventDto? CreateReasoningDeltaEvent(
AgentIdentity agent,
string? reasoningId,
string? deltaContent)
{
if (string.IsNullOrWhiteSpace(reasoningId)
|| string.IsNullOrEmpty(deltaContent))
{
return null;
}
return new ReasoningDeltaEventDto
{
Type = "reasoning-delta",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
ReasoningId = reasoningId,
ContentDelta = deltaContent,
};
}
private SkillInvokedEventDto CreateSkillInvokedEvent(
AgentIdentity agent,
ProviderSkillInvokedEvent data)
{
return new SkillInvokedEventDto
{
Type = "skill-invoked",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
SkillName = data.SkillName,
Path = data.Path,
Content = data.Content,
AllowedTools = data.AllowedTools,
PluginName = data.PluginName,
PluginVersion = data.PluginVersion,
};
}
private HookLifecycleEventDto CreateHookLifecycleEvent(
AgentIdentity agent,
string phase,
string hookInvocationId,
string hookType,
object? input = null,
bool? success = null,
object? output = null,
string? error = null)
{
return new HookLifecycleEventDto
{
Type = "hook-lifecycle",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
HookInvocationId = hookInvocationId,
HookType = hookType,
Phase = phase,
Input = input,
Success = success,
Output = output,
Error = error,
};
}
private AssistantUsageEventDto CreateAssistantUsageEvent(
AgentIdentity agent,
ProviderAssistantUsageEvent data)
{
return new AssistantUsageEventDto
{
Type = "assistant-usage",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
Model = data.Model,
InputTokens = data.InputTokens,
OutputTokens = data.OutputTokens,
CacheReadTokens = data.CacheReadTokens,
CacheWriteTokens = data.CacheWriteTokens,
Cost = data.Cost,
Duration = data.Duration,
TotalNanoAiu = data.TotalNanoAiu,
QuotaSnapshots = data.QuotaSnapshots,
};
}
private SessionUsageEventDto CreateUsageEvent(AgentIdentity agent, ProviderSessionUsageEvent data)
{
return new SessionUsageEventDto
{
Type = "session-usage",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
TokenLimit = data.TokenLimit,
CurrentTokens = data.CurrentTokens,
MessagesLength = data.MessagesLength,
SystemTokens = data.SystemTokens,
ConversationTokens = data.ConversationTokens,
ToolDefinitionsTokens = data.ToolDefinitionsTokens,
IsInitial = data.IsInitial,
};
}
private SessionCompactionEventDto CreateCompactionStartEvent(
AgentIdentity agent,
ProviderSessionCompactionStartEvent data)
{
return new SessionCompactionEventDto
{
Type = "session-compaction",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
Phase = "start",
SystemTokens = data.SystemTokens,
ConversationTokens = data.ConversationTokens,
ToolDefinitionsTokens = data.ToolDefinitionsTokens,
};
}
private SessionCompactionEventDto CreateCompactionCompleteEvent(
AgentIdentity agent,
ProviderSessionCompactionCompleteEvent data)
{
return new SessionCompactionEventDto
{
Type = "session-compaction",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
Phase = "complete",
Success = data.Success,
Error = data.Error,
SystemTokens = data.SystemTokens,
ConversationTokens = data.ConversationTokens,
ToolDefinitionsTokens = data.ToolDefinitionsTokens,
PreCompactionTokens = data.PreCompactionTokens,
PostCompactionTokens = data.PostCompactionTokens,
PreCompactionMessagesLength = data.PreCompactionMessagesLength,
MessagesRemoved = data.MessagesRemoved,
TokensRemoved = data.TokensRemoved,
SummaryContent = data.SummaryContent,
CheckpointNumber = data.CheckpointNumber,
CheckpointPath = data.CheckpointPath,
};
}
private PendingMessagesModifiedEventDto CreatePendingMessagesModifiedEvent(AgentIdentity agent)
{
return new PendingMessagesModifiedEventDto
{
Type = "pending-messages-modified",
RequestId = _command.RequestId,
SessionId = _command.SessionId,
AgentId = agent.AgentId,
AgentName = agent.AgentName,
};
}
}
@@ -45,6 +45,12 @@ internal static class WorkflowDefinitionExtensions
return string.Equals(node.Kind, "agent", StringComparison.OrdinalIgnoreCase);
}
public static bool IsSubWorkflowNode(this WorkflowNodeDto node)
{
ArgumentNullException.ThrowIfNull(node);
return string.Equals(node.Kind, "sub-workflow", StringComparison.OrdinalIgnoreCase);
}
public static string GetAgentId(this WorkflowNodeDto node)
{
ArgumentNullException.ThrowIfNull(node);
@@ -60,6 +66,15 @@ internal static class WorkflowDefinitionExtensions
public static WorkflowDefinitionDto ResolveSubWorkflowDefinition(
this WorkflowNodeDto node,
IReadOnlyDictionary<string, WorkflowDefinitionDto>? workflowLibrary)
{
return node.TryResolveSubWorkflowDefinition(workflowLibrary)
?? throw new InvalidOperationException(
$"Sub-workflow node \"{node.Id}\" references unknown workflow \"{node.Config.WorkflowId}\".");
}
public static WorkflowDefinitionDto? TryResolveSubWorkflowDefinition(
this WorkflowNodeDto node,
IReadOnlyDictionary<string, WorkflowDefinitionDto>? workflowLibrary)
{
ArgumentNullException.ThrowIfNull(node);
@@ -75,8 +90,7 @@ internal static class WorkflowDefinitionExtensions
return workflow;
}
throw new InvalidOperationException(
$"Sub-workflow node \"{node.Id}\" references unknown workflow \"{node.Config.WorkflowId}\".");
return null;
}
public static bool IsOrchestrationMode(this WorkflowDefinitionDto workflow, string mode)
@@ -87,6 +101,67 @@ internal static class WorkflowDefinitionExtensions
return string.Equals(workflow.Settings.OrchestrationMode, mode, StringComparison.OrdinalIgnoreCase);
}
public static WorkflowNodeDto? FindSubWorkflowNode(
this WorkflowDefinitionDto workflow,
string? nodeId,
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null)
{
ArgumentNullException.ThrowIfNull(workflow);
string? normalizedNodeId = NormalizeOptionalString(nodeId);
if (normalizedNodeId is null)
{
return null;
}
return FindSubWorkflowNode(
workflow,
normalizedNodeId,
CreateWorkflowLibraryMap(workflowLibrary),
new HashSet<string>(StringComparer.Ordinal),
new HashSet<WorkflowDefinitionDto>(ReferenceEqualityComparer.Instance));
}
internal static WorkflowNodeDto? FindSubWorkflowNode(
this WorkflowDefinitionDto workflow,
string? nodeId,
IReadOnlyDictionary<string, WorkflowDefinitionDto>? workflowLibrary)
{
ArgumentNullException.ThrowIfNull(workflow);
string? normalizedNodeId = NormalizeOptionalString(nodeId);
if (normalizedNodeId is null)
{
return null;
}
return FindSubWorkflowNode(
workflow,
normalizedNodeId,
workflowLibrary ?? EmptyWorkflowLibrary,
new HashSet<string>(StringComparer.Ordinal),
new HashSet<WorkflowDefinitionDto>(ReferenceEqualityComparer.Instance));
}
internal static string GetSubworkflowDisplayName(
this WorkflowNodeDto node,
IReadOnlyDictionary<string, WorkflowDefinitionDto>? workflowLibrary)
{
ArgumentNullException.ThrowIfNull(node);
WorkflowDefinitionDto? resolvedWorkflow = null;
if (node.Config.InlineWorkflow is not null)
{
resolvedWorkflow = node.Config.InlineWorkflow;
}
else if (!string.IsNullOrWhiteSpace(node.Config.WorkflowId)
&& workflowLibrary is not null
&& workflowLibrary.TryGetValue(node.Config.WorkflowId, out WorkflowDefinitionDto? workflow))
{
resolvedWorkflow = workflow;
}
return FirstNonBlank(node.Label, resolvedWorkflow?.Name, node.Config.WorkflowId, node.Id) ?? "sub-workflow";
}
private static readonly IReadOnlyDictionary<string, WorkflowDefinitionDto> EmptyWorkflowLibrary =
new Dictionary<string, WorkflowDefinitionDto>(StringComparer.Ordinal);
@@ -123,12 +198,68 @@ internal static class WorkflowDefinitionExtensions
continue;
}
WorkflowDefinitionDto subWorkflow = node.ResolveSubWorkflowDefinition(workflowLibrary);
CollectAgentNodes(subWorkflow, workflowLibrary, agentNodes, visitedWorkflowIds, visitedAnonymousWorkflows);
WorkflowDefinitionDto? subWorkflow = node.TryResolveSubWorkflowDefinition(workflowLibrary);
if (subWorkflow is not null)
{
CollectAgentNodes(subWorkflow, workflowLibrary, agentNodes, visitedWorkflowIds, visitedAnonymousWorkflows);
}
}
}
private static Dictionary<string, WorkflowDefinitionDto> CreateWorkflowLibraryMap(
private static WorkflowNodeDto? FindSubWorkflowNode(
WorkflowDefinitionDto workflowDefinition,
string nodeId,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
ISet<string> visitedWorkflowIds,
ISet<WorkflowDefinitionDto> visitedAnonymousWorkflows)
{
string? workflowId = NormalizeOptionalString(workflowDefinition.Id);
if (workflowId is not null)
{
if (!visitedWorkflowIds.Add(workflowId))
{
return null;
}
}
else if (!visitedAnonymousWorkflows.Add(workflowDefinition))
{
return null;
}
foreach (WorkflowNodeDto node in workflowDefinition.Graph.Nodes)
{
if (!node.IsSubWorkflowNode())
{
continue;
}
if (string.Equals(node.Id, nodeId, StringComparison.OrdinalIgnoreCase))
{
return node;
}
WorkflowDefinitionDto? subWorkflow = node.TryResolveSubWorkflowDefinition(workflowLibrary);
if (subWorkflow is null)
{
continue;
}
WorkflowNodeDto? match = FindSubWorkflowNode(
subWorkflow,
nodeId,
workflowLibrary,
visitedWorkflowIds,
visitedAnonymousWorkflows);
if (match is not null)
{
return match;
}
}
return null;
}
internal static Dictionary<string, WorkflowDefinitionDto> CreateWorkflowLibraryMap(
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary)
{
return workflowLibrary?
@@ -0,0 +1,196 @@
using System.Linq;
using Aryx.AgentHost.Contracts;
using Microsoft.Agents.AI;
using Microsoft.Agents.AI.Workflows;
namespace Aryx.AgentHost.Services;
internal static class WorkflowOrchestrationFactory
{
public static HandoffWorkflowBuilder CreateHandoffWorkflowBuilder(
AIAgent entryAgent,
HandoffModeSettingsDto? settings = null)
{
HandoffModeSettingsDto effectiveSettings = settings ?? new HandoffModeSettingsDto();
HandoffWorkflowBuilder builder = AgentWorkflowBuilder.CreateHandoffBuilderWith(entryAgent)
.WithToolCallFilteringBehavior(MapHandoffToolCallFiltering(effectiveSettings.ToolCallFiltering))
.WithHandoffInstructions(NormalizeOptionalString(effectiveSettings.HandoffInstructions)
?? HandoffWorkflowGuidance.CreateWorkflowInstructions());
if (effectiveSettings.ReturnToPrevious)
{
builder = builder.EnableReturnToPrevious();
}
return builder;
}
public static Workflow CreateHandoffWorkflow(
WorkflowDefinitionDto workflowDefinition,
IReadOnlyList<AIAgent> agents)
{
ArgumentNullException.ThrowIfNull(workflowDefinition);
ArgumentNullException.ThrowIfNull(agents);
IReadOnlyList<WorkflowNodeDto> agentNodes = workflowDefinition.GetAgentNodes();
Dictionary<string, AIAgent> agentsById = CreateAgentMap(agents);
WorkflowNodeDto triageNode = ResolveTriageAgentNode(workflowDefinition, agentNodes);
AIAgent triageAgent = ResolveAgentForNode(triageNode, agentsById);
HandoffModeSettingsDto? settings = workflowDefinition.Settings.ModeSettings?.Handoff;
HandoffWorkflowBuilder builder = CreateHandoffWorkflowBuilder(triageAgent, settings);
List<WorkflowNodeDto> specialistNodes = agentNodes
.Where(node => !string.Equals(node.Id, triageNode.Id, StringComparison.Ordinal))
.ToList();
if (specialistNodes.Count == 0)
{
throw new InvalidOperationException("Handoff workflows require at least one specialist agent in addition to the triage agent.");
}
foreach (WorkflowNodeDto specialistNode in specialistNodes)
{
AIAgent specialistAgent = ResolveAgentForNode(specialistNode, agentsById);
builder.WithHandoff(
triageAgent,
specialistAgent,
HandoffWorkflowGuidance.CreateForwardReason(specialistNode));
if (settings?.ReturnToPrevious != true)
{
builder.WithHandoff(
specialistAgent,
triageAgent,
HandoffWorkflowGuidance.CreateReturnReason(triageNode));
}
}
return builder.Build();
}
public static GroupChatWorkflowBuilder CreateGroupChatWorkflowBuilder(
WorkflowDefinitionDto workflowDefinition,
IReadOnlyList<AIAgent> agents)
{
ArgumentNullException.ThrowIfNull(workflowDefinition);
ArgumentNullException.ThrowIfNull(agents);
int maxRounds = ResolveGroupChatMaxRounds(workflowDefinition);
GroupChatWorkflowBuilder builder = AgentWorkflowBuilder.CreateGroupChatBuilderWith(
participants => new RoundRobinGroupChatManager(participants)
{
MaximumIterationCount = maxRounds,
})
.AddParticipants(agents);
string? name = NormalizeOptionalString(workflowDefinition.Name);
if (name is not null)
{
builder.WithName(name);
}
string? description = NormalizeOptionalString(workflowDefinition.Description);
if (description is not null)
{
builder.WithDescription(description);
}
return builder;
}
public static Workflow CreateGroupChatWorkflow(
WorkflowDefinitionDto workflowDefinition,
IReadOnlyList<AIAgent> agents)
{
return CreateGroupChatWorkflowBuilder(workflowDefinition, agents).Build();
}
private static Dictionary<string, AIAgent> CreateAgentMap(IReadOnlyList<AIAgent> agents)
{
Dictionary<string, AIAgent> agentMap = new(StringComparer.OrdinalIgnoreCase);
foreach (AIAgent agent in agents)
{
if (!string.IsNullOrWhiteSpace(agent.Id))
{
agentMap[agent.Id] = agent;
}
if (!string.IsNullOrWhiteSpace(agent.Name))
{
agentMap[agent.Name] = agent;
}
}
return agentMap;
}
private static AIAgent ResolveAgentForNode(
WorkflowNodeDto node,
IReadOnlyDictionary<string, AIAgent> agentsById)
{
string agentId = node.GetAgentId();
if (agentsById.TryGetValue(agentId, out AIAgent? agent))
{
return agent;
}
string agentName = node.GetAgentName();
if (agentsById.TryGetValue(agentName, out agent))
{
return agent;
}
throw new InvalidOperationException($"Workflow agent \"{agentId}\" could not be resolved from the constructed agents.");
}
private static WorkflowNodeDto ResolveTriageAgentNode(
WorkflowDefinitionDto workflowDefinition,
IReadOnlyList<WorkflowNodeDto> agentNodes)
{
if (agentNodes.Count == 0)
{
throw new InvalidOperationException("Handoff workflows require at least one agent node.");
}
string? triageAgentNodeId = NormalizeOptionalString(workflowDefinition.Settings.ModeSettings?.Handoff?.TriageAgentNodeId);
if (triageAgentNodeId is null)
{
return agentNodes[0];
}
WorkflowNodeDto? triageNode = agentNodes.FirstOrDefault(node => string.Equals(node.Id, triageAgentNodeId, StringComparison.Ordinal));
return triageNode ?? throw new InvalidOperationException(
$"Handoff workflow triage agent node \"{triageAgentNodeId}\" was not found in the workflow graph.");
}
private static HandoffToolCallFilteringBehavior MapHandoffToolCallFiltering(string? value)
{
return value?.Trim().ToLowerInvariant() switch
{
"none" => HandoffToolCallFilteringBehavior.None,
"all" => HandoffToolCallFilteringBehavior.All,
_ => HandoffToolCallFilteringBehavior.HandoffOnly,
};
}
private static int ResolveGroupChatMaxRounds(WorkflowDefinitionDto workflowDefinition)
{
int? configuredMaxRounds = workflowDefinition.Settings.ModeSettings?.GroupChat?.MaxRounds;
if (configuredMaxRounds is > 0)
{
return configuredMaxRounds.Value;
}
if (workflowDefinition.Settings.MaxIterations is > 0)
{
return workflowDefinition.Settings.MaxIterations.Value;
}
return 5;
}
private static string? NormalizeOptionalString(string? value)
{
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
}
}
@@ -20,15 +20,16 @@ internal static class WorkflowRequestInfoInterpreter
RunTurnCommandDto command,
RequestInfoEvent requestInfo,
AgentIdentity? activeAgent,
ConcurrentDictionary<string, string> toolNamesByCallId)
ConcurrentDictionary<string, string> toolNamesByCallId,
ConcurrentDictionary<string, bool> toolCallHasArgumentsById)
{
RequestInterpretation interpretation = InterpretRequest(command.Workflow, requestInfo);
RequestInterpretation interpretation = InterpretRequest(command, requestInfo);
return interpretation switch
{
HandoffRequestInterpretation handoff =>
CreateHandoffActivity(command, handoff.TargetAgent, activeAgent),
ToolRequestInterpretation tool when activeAgent.HasValue =>
CreateToolCallingActivity(command, activeAgent.Value, tool, toolNamesByCallId),
CreateToolCallingActivity(command, activeAgent.Value, tool, toolNamesByCallId, toolCallHasArgumentsById),
_ => null,
};
}
@@ -38,7 +39,7 @@ internal static class WorkflowRequestInfoInterpreter
RequestInfoEvent requestInfo)
{
return command.Workflow.IsOrchestrationMode("handoff")
&& InterpretRequest(command.Workflow, requestInfo) is UnknownRequestInterpretation;
&& InterpretRequest(command, requestInfo) is UnknownRequestInterpretation;
}
private static AgentActivityEventDto CreateHandoffActivity(
@@ -54,6 +55,8 @@ internal static class WorkflowRequestInfoInterpreter
ActivityType = HandoffActivityType,
AgentId = handoffAgent.AgentId,
AgentName = handoffAgent.AgentName,
SubworkflowNodeId = handoffAgent.Subworkflow?.SubworkflowNodeId,
SubworkflowName = handoffAgent.Subworkflow?.SubworkflowName,
SourceAgentId = activeAgent?.AgentId,
SourceAgentName = activeAgent?.AgentName,
};
@@ -63,14 +66,21 @@ internal static class WorkflowRequestInfoInterpreter
RunTurnCommandDto command,
AgentIdentity activeAgent,
ToolRequestInterpretation tool,
ConcurrentDictionary<string, string> toolNamesByCallId)
ConcurrentDictionary<string, string> toolNamesByCallId,
ConcurrentDictionary<string, bool> toolCallHasArgumentsById)
{
bool hasToolArguments = tool.ToolArguments is { Count: > 0 };
if (tool.ToolCallId is not null && toolNamesByCallId.ContainsKey(tool.ToolCallId))
{
return null;
bool trackedHasArguments = toolCallHasArgumentsById.TryGetValue(tool.ToolCallId, out bool hasTrackedArguments)
&& hasTrackedArguments;
if (trackedHasArguments || !hasToolArguments)
{
return null;
}
}
TrackToolCallId(toolNamesByCallId, tool.ToolCallId, tool.ToolName);
TrackToolCallId(toolNamesByCallId, toolCallHasArgumentsById, tool.ToolCallId, tool.ToolName, hasToolArguments);
return new AgentActivityEventDto
{
@@ -80,6 +90,8 @@ internal static class WorkflowRequestInfoInterpreter
ActivityType = ToolCallingActivityType,
AgentId = activeAgent.AgentId,
AgentName = activeAgent.AgentName,
SubworkflowNodeId = activeAgent.Subworkflow?.SubworkflowNodeId,
SubworkflowName = activeAgent.Subworkflow?.SubworkflowName,
ToolName = tool.ToolName,
ToolCallId = tool.ToolCallId,
ToolArguments = tool.ToolArguments,
@@ -88,20 +100,23 @@ internal static class WorkflowRequestInfoInterpreter
private static void TrackToolCallId(
ConcurrentDictionary<string, string> toolNamesByCallId,
ConcurrentDictionary<string, bool> toolCallHasArgumentsById,
string? toolCallId,
string toolName)
string toolName,
bool hasToolArguments)
{
if (toolCallId is not null)
{
toolNamesByCallId[toolCallId] = toolName;
toolCallHasArgumentsById[toolCallId] = hasToolArguments;
}
}
private static RequestInterpretation InterpretRequest(
WorkflowDefinitionDto workflow,
RunTurnCommandDto command,
RequestInfoEvent requestInfo)
{
if (TryGetHandoffTarget(workflow, requestInfo, out AgentIdentity handoffAgent))
if (TryGetHandoffTarget(command, requestInfo, out AgentIdentity handoffAgent))
{
return new HandoffRequestInterpretation(handoffAgent);
}
@@ -112,7 +127,7 @@ internal static class WorkflowRequestInfoInterpreter
}
private static bool TryGetHandoffTarget(
WorkflowDefinitionDto workflow,
RunTurnCommandDto command,
RequestInfoEvent requestInfo,
out AgentIdentity agent)
{
@@ -131,7 +146,8 @@ internal static class WorkflowRequestInfoInterpreter
}
agent = AgentIdentityResolver.ResolveAgentIdentity(
workflow,
command.Workflow,
command.WorkflowLibrary,
target.Id,
target.Name);
return !string.IsNullOrWhiteSpace(agent.AgentName);
@@ -175,7 +191,7 @@ internal static class WorkflowRequestInfoInterpreter
{
if (requestData.Is<McpServerToolCallContent>(out McpServerToolCallContent? mcpToolCall))
{
toolName = NormalizeOptionalString(mcpToolCall.ToolName)
toolName = NormalizeOptionalString(mcpToolCall.Name)
?? NormalizeOptionalString(mcpToolCall.ServerName)
?? string.Empty;
toolCallId = NormalizeOptionalString(mcpToolCall.CallId);
@@ -205,6 +221,26 @@ internal static class WorkflowRequestInfoInterpreter
return false;
}
public static IReadOnlyDictionary<string, object?>? NormalizeRawToolArguments(object? rawArguments)
{
return rawArguments switch
{
null => null,
JsonElement { ValueKind: JsonValueKind.Object } element => NormalizeToolArgumentObject(element),
IEnumerable<KeyValuePair<string, object?>> dictionary => NormalizeToolArguments(dictionary),
_ => NormalizeRawToolArgumentsViaJson(rawArguments),
};
}
private static IReadOnlyDictionary<string, object?>? NormalizeRawToolArgumentsViaJson(object value)
{
string json = JsonSerializer.Serialize(value, value.GetType(), JsonOptions);
using JsonDocument document = JsonDocument.Parse(json);
return document.RootElement.ValueKind == JsonValueKind.Object
? NormalizeToolArgumentObject(document.RootElement)
: null;
}
private static IReadOnlyDictionary<string, object?>? NormalizeToolArguments(
IEnumerable<KeyValuePair<string, object?>>? arguments)
{
@@ -141,7 +141,7 @@ internal sealed class WorkflowRunner
throw new InvalidOperationException($"Workflow node \"{node.Id}\" references unknown agent \"{agentId}\".");
}
return new WorkflowNodeRoute(agent.BindAsExecutor(CopilotAgentBundle.CreateAgentHostOptions()));
return new WorkflowNodeRoute(agent.BindAsExecutor(AgentHostOptionsFactory.CreateDefault()));
}
if (string.Equals(node.Kind, "code-executor", StringComparison.OrdinalIgnoreCase))
@@ -115,19 +115,103 @@ public sealed class AgentIdentityResolverTests
Assert.Equal("UX Specialist", agent.AgentName);
}
[Fact]
public void TryResolveKnownAgentIdentity_ResolvesReferencedSubworkflowAgentWithContext()
{
WorkflowDefinitionDto nestedWorkflow = CreateWorkflow(
"nested-review-workflow",
[
CreateAgent("agent-reviewer", "Reviewer"),
],
orchestrationMode: "single");
WorkflowDefinitionDto workflow = CreateWorkflow(
"parent-workflow",
[
CreateSubworkflow("subworkflow-review", "Review Lane", workflowId: nestedWorkflow.Id),
],
orchestrationMode: "single");
bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity(
workflow,
[nestedWorkflow],
"Reviewer_agent_reviewer",
out AgentIdentity agent);
Assert.True(resolved);
Assert.Equal("agent-reviewer", agent.AgentId);
Assert.Equal("Reviewer", agent.AgentName);
Assert.Equal("subworkflow-review", agent.Subworkflow?.SubworkflowNodeId);
Assert.Equal("Review Lane", agent.Subworkflow?.SubworkflowName);
}
[Fact]
public void BuildAgentSubworkflowIndex_UsesImmediateNestedSubworkflowContext()
{
WorkflowDefinitionDto innerWorkflow = CreateWorkflow(
"inner-workflow",
[
CreateAgent("agent-inner-reviewer", "Inner Reviewer"),
],
orchestrationMode: "single");
WorkflowDefinitionDto outerWorkflow = CreateWorkflow(
"outer-workflow",
[
CreateSubworkflow("subworkflow-inner", "Inner Review", inlineWorkflow: innerWorkflow),
],
orchestrationMode: "single");
WorkflowDefinitionDto workflow = CreateWorkflow(
"parent-workflow",
[
CreateSubworkflow("subworkflow-outer", "Outer Review", inlineWorkflow: outerWorkflow),
],
orchestrationMode: "single");
IReadOnlyDictionary<string, SubworkflowContext> index =
AgentIdentityResolver.BuildAgentSubworkflowIndex(workflow);
Assert.True(index.TryGetValue("agent-inner-reviewer", out SubworkflowContext subworkflow));
Assert.Equal("subworkflow-inner", subworkflow.SubworkflowNodeId);
Assert.Equal("Inner Review", subworkflow.SubworkflowName);
}
[Fact]
public void BuildAgentSubworkflowIndex_SkipsUnresolvableSubWorkflowReferences()
{
WorkflowDefinitionDto workflow = CreateWorkflow(
"parent-workflow",
[
CreateAgent("agent-top-level", "Top Level"),
CreateSubworkflow("subworkflow-missing", "Missing Pipeline", workflowId: "nonexistent-workflow"),
],
orchestrationMode: "concurrent");
IReadOnlyDictionary<string, SubworkflowContext> index =
AgentIdentityResolver.BuildAgentSubworkflowIndex(workflow);
Assert.Empty(index);
}
private static WorkflowDefinitionDto CreateWorkflow(
IReadOnlyList<WorkflowNodeDto> agents,
IReadOnlyList<WorkflowNodeDto> nodes,
string orchestrationMode = "concurrent")
{
return CreateWorkflow($"{orchestrationMode}-workflow", nodes, orchestrationMode);
}
private static WorkflowDefinitionDto CreateWorkflow(
string id,
IReadOnlyList<WorkflowNodeDto> nodes,
string orchestrationMode = "concurrent")
{
return new WorkflowDefinitionDto
{
Id = $"{orchestrationMode}-workflow",
Id = id,
Name = "Workflow",
Graph = new WorkflowGraphDto
{
Nodes =
[
.. agents,
.. nodes,
],
},
Settings = new WorkflowSettingsDto
@@ -154,4 +238,24 @@ public sealed class AgentIdentityResolverTests
},
};
}
private static WorkflowNodeDto CreateSubworkflow(
string id,
string label,
string? workflowId = null,
WorkflowDefinitionDto? inlineWorkflow = null)
{
return new WorkflowNodeDto
{
Id = id,
Kind = "sub-workflow",
Label = label,
Config = new WorkflowNodeConfigDto
{
Kind = "sub-workflow",
WorkflowId = workflowId,
InlineWorkflow = inlineWorkflow,
},
};
}
}
@@ -8,10 +8,16 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="coverlet.collector" Version="6.0.2" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.12.0" />
<PackageReference Include="xunit" Version="2.9.2" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.8.2" />
<PackageReference Include="coverlet.collector" Version="8.0.1">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="18.4.0" />
<PackageReference Include="xunit" Version="2.9.3" />
<PackageReference Include="xunit.runner.visualstudio" Version="3.1.5">
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
<PrivateAssets>all</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
@@ -163,12 +163,12 @@ public sealed class CopilotAgentBundleTests
{
ChatClientAgent entryAgent = CreateChatClientAgent("agent-1", "Primary");
HandoffsWorkflowBuilder builder = CopilotAgentBundle.CreateHandoffWorkflowBuilder(entryAgent);
HandoffWorkflowBuilder builder = CopilotAgentBundle.CreateHandoffWorkflowBuilder(entryAgent);
FieldInfo field = typeof(HandoffsWorkflowBuilder).GetField(
FieldInfo field = GetInstanceField(
typeof(HandoffWorkflowBuilder),
"_toolCallFilteringBehavior",
BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException("Expected HandoffsWorkflowBuilder to expose a filtering field.");
"Expected HandoffWorkflowBuilder to expose a filtering field.");
HandoffToolCallFilteringBehavior behavior = Assert.IsType<HandoffToolCallFilteringBehavior>(field.GetValue(builder));
@@ -181,7 +181,7 @@ public sealed class CopilotAgentBundleTests
{
ChatClientAgent entryAgent = CreateChatClientAgent("agent-1", "Primary");
HandoffsWorkflowBuilder builder = CopilotAgentBundle.CreateHandoffWorkflowBuilder(
HandoffWorkflowBuilder builder = CopilotAgentBundle.CreateHandoffWorkflowBuilder(
entryAgent,
new HandoffModeSettingsDto
{
@@ -190,12 +190,17 @@ public sealed class CopilotAgentBundleTests
HandoffInstructions = "Use custom delegation guidance.",
});
FieldInfo filteringField = typeof(HandoffsWorkflowBuilder).GetField(
FieldInfo filteringField = GetInstanceField(
typeof(HandoffWorkflowBuilder),
"_toolCallFilteringBehavior",
BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException("Expected HandoffsWorkflowBuilder to expose a filtering field.");
"Expected HandoffWorkflowBuilder to expose a filtering field.");
FieldInfo returnToPreviousField = GetInstanceField(
typeof(HandoffWorkflowBuilder),
"_returnToPrevious",
"Expected HandoffWorkflowBuilder to expose a return-to-previous field.");
Assert.Equal(HandoffToolCallFilteringBehavior.All, filteringField.GetValue(builder));
Assert.Equal(true, returnToPreviousField.GetValue(builder));
Assert.Equal("Use custom delegation guidance.", builder.HandoffInstructions);
}
@@ -631,6 +636,20 @@ public sealed class CopilotAgentBundleTests
});
}
private static FieldInfo GetInstanceField(Type type, string name, string errorMessage)
{
for (Type? current = type; current is not null; current = current.BaseType)
{
FieldInfo? field = current.GetField(name, BindingFlags.Instance | BindingFlags.NonPublic | BindingFlags.Public);
if (field is not null)
{
return field;
}
}
throw new InvalidOperationException(errorMessage);
}
private static AIFunctionDeclaration CreateHandoffDeclaration()
{
return AIFunctionFactory.CreateDeclaration(
@@ -59,23 +59,78 @@ public sealed class CopilotTurnExecutionStateTests
Assert.Equal("agent-1", observedAgent.AgentId);
}
[Fact]
public void ObserveSessionEvent_AssistantMessageDelta_ForNestedAgent_IncludesSubworkflowContext()
{
RunTurnCommandDto command = CreateCommandWithReferencedSubworkflow();
CopilotTurnExecutionState state = new(command);
WorkflowDefinitionDto nestedWorkflow = Assert.Single(command.WorkflowLibrary!);
WorkflowNodeDto nestedAgent = Assert.Single(nestedWorkflow.GetAgentNodes());
state.ObserveSessionEvent(
nestedAgent,
SessionEvent.FromJson(
"""
{
"type": "assistant.message_delta",
"data": {
"messageId": "msg-nested-1",
"deltaContent": "Reviewing"
},
"id": "7ef95d90-7ee7-45e2-ac38-cf749caf4f69",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
AgentActivityEventDto activity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
Assert.Equal("thinking", activity.ActivityType);
Assert.Equal("agent-reviewer", activity.AgentId);
Assert.Equal("Reviewer", activity.AgentName);
Assert.Equal("subworkflow-review", activity.SubworkflowNodeId);
Assert.Equal("Review Lane", activity.SubworkflowName);
Assert.True(state.ActiveAgent.HasValue);
Assert.Equal("subworkflow-review", state.ActiveAgent.Value.Subworkflow?.SubworkflowNodeId);
Assert.Equal("Review Lane", state.ActiveAgent.Value.Subworkflow?.SubworkflowName);
}
[Fact]
public void ObserveSessionEvent_ToolExecutionStart_TracksToolNameByCallIdAndQueuesToolActivity()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""{"type":"tool.execution_start","data":{"toolCallId":"tool-call-1","toolName":"view","arguments":{"path":"/src/main.ts","view_range":[10,20]}},"id":"33333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:00Z"}"""));
AgentActivityEventDto toolActivity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
Assert.Equal("tool-calling", toolActivity.ActivityType);
Assert.Equal("view", toolActivity.ToolName);
Assert.Equal("tool-call-1", toolActivity.ToolCallId);
Assert.NotNull(toolActivity.ToolArguments);
Assert.Equal("/src/main.ts", toolActivity.ToolArguments["path"]);
Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-1", out string? toolName));
Assert.Equal("view", toolName);
Assert.True(state.ToolCallHasArgumentsById.TryGetValue("tool-call-1", out bool hasArguments));
Assert.True(hasArguments);
}
[Fact]
public void ObserveSessionEvent_ToolExecutionStart_WithoutArguments_SetsToolArgumentsToNull()
{
RunTurnCommandDto command = CreateCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
command.Workflow.GetAgentNodes()[0],
SessionEvent.FromJson(
"""{"type":"tool.execution_start","data":{"toolCallId":"tool-call-1","toolName":"view"},"id":"33333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:00Z"}"""));
AgentActivityEventDto toolActivity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
Assert.Equal("tool-calling", toolActivity.ActivityType);
Assert.Equal("view", toolActivity.ToolName);
Assert.Equal("tool-call-1", toolActivity.ToolCallId);
Assert.True(state.ToolNamesByCallId.TryGetValue("tool-call-1", out string? toolName));
Assert.Equal("view", toolName);
Assert.Null(toolActivity.ToolArguments);
Assert.True(state.ToolCallHasArgumentsById.TryGetValue("tool-call-1", out bool hasArguments));
Assert.False(hasArguments);
}
[Fact]
@@ -92,6 +147,8 @@ public sealed class CopilotTurnExecutionStateTests
Assert.Empty(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
Assert.True(state.ToolNamesByCallId.TryGetValue("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);
}
[Fact]
@@ -214,6 +271,10 @@ public sealed class CopilotTurnExecutionStateTests
Assert.Equal("rg", firstToolName);
Assert.True(state.ToolNamesByCallId.TryGetValue("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);
}
[Fact]
@@ -719,5 +780,79 @@ public sealed class CopilotTurnExecutionStateTests
},
};
}
private static RunTurnCommandDto CreateCommandWithReferencedSubworkflow()
{
WorkflowDefinitionDto nestedWorkflow = CreateWorkflow(
"nested-review-workflow",
[
CreateAgent("agent-reviewer", "Reviewer"),
]);
return new RunTurnCommandDto
{
RequestId = "turn-1",
SessionId = "session-1",
WorkflowLibrary = [nestedWorkflow],
Workflow = CreateWorkflow(
"workflow-parent",
[
CreateSubworkflow("subworkflow-review", "Review Lane", workflowId: nestedWorkflow.Id),
]),
};
}
private static WorkflowDefinitionDto CreateWorkflow(string id, IReadOnlyList<WorkflowNodeDto> nodes)
{
return new WorkflowDefinitionDto
{
Id = id,
Name = "Execution State Workflow",
Graph = new WorkflowGraphDto
{
Nodes = [.. nodes],
},
Settings = new WorkflowSettingsDto
{
OrchestrationMode = "single",
},
};
}
private static WorkflowNodeDto CreateAgent(string id, string name)
{
return new WorkflowNodeDto
{
Id = id,
Kind = "agent",
Label = name,
Config = new WorkflowNodeConfigDto
{
Kind = "agent",
Id = id,
Name = name,
Model = "gpt-5.4",
Instructions = "Help with the request.",
},
};
}
private static WorkflowNodeDto CreateSubworkflow(
string id,
string label,
string? workflowId = null)
{
return new WorkflowNodeDto
{
Id = id,
Kind = "sub-workflow",
Label = label,
Config = new WorkflowNodeConfigDto
{
Kind = "sub-workflow",
WorkflowId = workflowId,
},
};
}
}
@@ -721,6 +721,65 @@ public sealed class CopilotWorkflowRunnerTests
});
}
[Fact]
public async Task HandleWorkflowEventAsync_EmitsToolActivityEnrichmentWhenRequestInfoAddsMissingArguments()
{
RunTurnCommandDto command = CreateApprovalCommand();
CopilotTurnExecutionState state = new(command);
state.ObserveSessionEvent(
CreateAgent("agent-1", "Primary"),
SessionEvent.FromJson(
"""
{
"type": "tool.execution_start",
"data": {
"toolCallId": "tool-call-1",
"toolName": "view"
},
"id": "f61652d1-120e-4a9f-8f0e-1dbf04fb18da",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
_ = state.DrainPendingEvents();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("tool-call-1", "view", new Dictionary<string, object?>
{
["path"] = @"C:\workspace\README.md",
}));
List<AgentActivityEventDto> activities = [];
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
"HandleWorkflowEventAsync",
BindingFlags.NonPublic | BindingFlags.Static)!;
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
null,
[
command,
requestInfo,
Array.Empty<ChatMessage>(),
state,
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
(Func<SidecarEventDto, Task>)(sidecarEvent =>
{
activities.Add(Assert.IsType<AgentActivityEventDto>(sidecarEvent));
return Task.CompletedTask;
}),
])!;
bool shouldEndTurn = await handleTask;
Assert.False(shouldEndTurn);
AgentActivityEventDto activity = Assert.Single(activities);
Assert.Equal("tool-calling", activity.ActivityType);
Assert.Equal("view", activity.ToolName);
Assert.Equal("tool-call-1", activity.ToolCallId);
Assert.NotNull(activity.ToolArguments);
Assert.Equal(@"C:\workspace\README.md", activity.ToolArguments["path"]);
Assert.True(state.ToolCallHasArgumentsById.TryGetValue("tool-call-1", out bool hasArguments));
Assert.True(hasArguments);
}
[Fact]
public void CreateExecutionEnvironment_UsesLockstepWhenRequested()
{
@@ -993,6 +1052,78 @@ public sealed class CopilotWorkflowRunnerTests
Assert.Equal("Primary", completed.AgentName);
}
[Fact]
public async Task HandleWorkflowEventAsync_EmitsSubworkflowStartedActivityForSubworkflowExecutor()
{
RunTurnCommandDto command = CreateReferencedSubworkflowCommand();
CopilotTurnExecutionState state = new(command);
List<AgentActivityEventDto> activities = [];
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
"HandleWorkflowEventAsync",
BindingFlags.NonPublic | BindingFlags.Static)!;
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
null,
[
command,
new ExecutorInvokedEvent("subworkflow-review", null!),
Array.Empty<ChatMessage>(),
state,
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
(Func<SidecarEventDto, Task>)(sidecarEvent =>
{
activities.Add(Assert.IsType<AgentActivityEventDto>(sidecarEvent));
return Task.CompletedTask;
}),
])!;
bool shouldEndTurn = await handleTask;
Assert.False(shouldEndTurn);
AgentActivityEventDto activity = Assert.Single(activities);
Assert.Equal("subworkflow-started", activity.ActivityType);
Assert.Null(activity.AgentId);
Assert.Null(activity.AgentName);
Assert.Equal("subworkflow-review", activity.SubworkflowNodeId);
Assert.Equal("Review Lane", activity.SubworkflowName);
}
[Fact]
public async Task HandleWorkflowEventAsync_EmitsSubworkflowCompletedActivityForSubworkflowExecutor()
{
RunTurnCommandDto command = CreateReferencedSubworkflowCommand();
CopilotTurnExecutionState state = new(command);
List<AgentActivityEventDto> activities = [];
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
"HandleWorkflowEventAsync",
BindingFlags.NonPublic | BindingFlags.Static)!;
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
null,
[
command,
new ExecutorCompletedEvent("subworkflow-review", null),
Array.Empty<ChatMessage>(),
state,
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
(Func<SidecarEventDto, Task>)(sidecarEvent =>
{
activities.Add(Assert.IsType<AgentActivityEventDto>(sidecarEvent));
return Task.CompletedTask;
}),
])!;
bool shouldEndTurn = await handleTask;
Assert.False(shouldEndTurn);
AgentActivityEventDto activity = Assert.Single(activities);
Assert.Equal("subworkflow-completed", activity.ActivityType);
Assert.Null(activity.AgentId);
Assert.Null(activity.AgentName);
Assert.Equal("subworkflow-review", activity.SubworkflowNodeId);
Assert.Equal("Review Lane", activity.SubworkflowName);
}
[Fact]
public async Task HandleWorkflowEventAsync_EmitsWorkflowWarningDiagnostic()
{
@@ -2184,11 +2315,37 @@ public sealed class CopilotWorkflowRunnerTests
};
}
private static WorkflowNodeDto CreateSubworkflow(
string id,
string label,
string? workflowId = null,
WorkflowDefinitionDto? inlineWorkflow = null)
{
return new WorkflowNodeDto
{
Id = id,
Kind = "sub-workflow",
Label = label,
Config = new WorkflowNodeConfigDto
{
Kind = "sub-workflow",
WorkflowId = workflowId,
InlineWorkflow = inlineWorkflow,
},
};
}
private static RunTurnCommandDto CreateCommand(
string orchestrationMode,
params WorkflowNodeDto[] agents)
{
return CreateCommand(orchestrationMode, modeSettings: null, workflowName: null, workflowDescription: null, agents);
return CreateCommand(
orchestrationMode,
modeSettings: null,
workflowName: null,
workflowDescription: null,
workflowLibrary: null,
agents: agents);
}
private static RunTurnCommandDto CreateCommand(
@@ -2196,12 +2353,14 @@ public sealed class CopilotWorkflowRunnerTests
OrchestrationModeSettingsDto? modeSettings = null,
string? workflowName = null,
string? workflowDescription = null,
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null,
params WorkflowNodeDto[] agents)
{
return new RunTurnCommandDto
{
RequestId = "turn-1",
SessionId = "session-1",
WorkflowLibrary = workflowLibrary ?? [],
Workflow = new WorkflowDefinitionDto
{
Id = $"workflow-{orchestrationMode}",
@@ -2277,6 +2436,35 @@ public sealed class CopilotWorkflowRunnerTests
};
}
private static RunTurnCommandDto CreateReferencedSubworkflowCommand()
{
WorkflowDefinitionDto nestedWorkflow = new()
{
Id = "nested-review-workflow",
Name = "Nested Review Workflow",
Graph = new WorkflowGraphDto
{
Nodes =
[
CreateAgent("agent-reviewer", "Reviewer"),
],
},
Settings = new WorkflowSettingsDto
{
OrchestrationMode = "single",
},
};
return CreateCommand(
"single",
workflowName: "Parent Workflow",
workflowLibrary: [nestedWorkflow],
agents:
[
CreateSubworkflow("subworkflow-review", "Review Lane", workflowId: nestedWorkflow.Id),
]);
}
private static RunTurnCommandDto CreateHandoffCommand()
{
return CreateCommand(
@@ -0,0 +1,25 @@
using Aryx.AgentHost.Contracts;
using Aryx.AgentHost.Services;
using GitHub.Copilot.SDK;
namespace Aryx.AgentHost.Tests;
internal static class SessionEventTestExtensions
{
private static readonly IProviderEventAdapter ProviderEventAdapter = new CopilotEventAdapter();
public static void ObserveSessionEvent(
this CopilotTurnExecutionState state,
WorkflowNodeDto agentDefinition,
SessionEvent sessionEvent)
{
ArgumentNullException.ThrowIfNull(state);
ArgumentNullException.ThrowIfNull(agentDefinition);
ArgumentNullException.ThrowIfNull(sessionEvent);
ProviderSessionEvent providerEvent = Assert.IsAssignableFrom<ProviderSessionEvent>(
ProviderEventAdapter.TryAdapt(sessionEvent));
state.ObserveSessionEvent(agentDefinition, providerEvent);
}
}
@@ -62,6 +62,103 @@ public sealed class SidecarProtocolHostTests
});
}
[Fact]
public async Task InternalConstructor_UsesAgentProviderDefaults()
{
FakeWorkflowRunner workflowRunner = new(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
{
await onActivity(new AgentActivityEventDto
{
Type = "agent-activity",
RequestId = command.RequestId,
SessionId = command.SessionId,
ActivityType = "thinking",
AgentId = "agent-provider",
AgentName = "Provider Agent",
});
return
[
new ChatMessageDto
{
Id = "assistant-provider",
Role = "assistant",
AuthorName = "Provider Agent",
Content = "Hello from the provider.",
CreatedAt = "2026-01-01T00:00:00.0000000Z",
},
];
});
FakeSessionManager sessionManager = new()
{
Sessions =
[
new CopilotSessionInfoDto
{
CopilotSessionId = "aryx::provider-session::agent-provider",
ManagedByAryx = true,
SessionId = "provider-session",
AgentId = "agent-provider",
},
],
};
SidecarCapabilitiesDto capabilities = new()
{
Modes = new Dictionary<string, SidecarModeCapabilityDto>(StringComparer.OrdinalIgnoreCase)
{
["single"] = new() { Available = true },
},
Models =
[
new SidecarModelCapabilityDto
{
Id = "provider-model",
Name = "Provider Model",
},
],
RuntimeTools = [],
Connection = new SidecarConnectionDiagnosticsDto
{
Status = "ready",
Summary = "Provider is ready.",
CheckedAt = "2026-01-01T00:00:00.0000000Z",
},
};
SidecarProtocolHost host = new(
new WorkflowValidator(),
new FakeAgentProvider(workflowRunner, sessionManager, capabilities));
IReadOnlyList<JsonElement> capabilityEvents = await RunHostAsync(
new DescribeCapabilitiesCommandDto
{
Type = "describe-capabilities",
RequestId = "provider-capabilities",
},
host);
IReadOnlyList<JsonElement> sessionEvents = await RunHostAsync(
new ListSessionsCommandDto
{
Type = "list-sessions",
RequestId = "provider-sessions",
},
host);
IReadOnlyList<JsonElement> turnEvents = await RunHostAsync(
CreateRunTurnCommand(requestId: "provider-turn"),
host);
JsonElement capabilityEvent = AssertSingleEvent(capabilityEvents, "capabilities", "provider-capabilities");
JsonElement model = Assert.Single(capabilityEvent.GetProperty("capabilities").GetProperty("models").EnumerateArray());
Assert.Equal("provider-model", model.GetProperty("id").GetString());
JsonElement listedEvent = AssertSingleEvent(sessionEvents, "sessions-listed", "provider-sessions");
JsonElement session = Assert.Single(listedEvent.GetProperty("sessions").EnumerateArray());
Assert.Equal("provider-session", session.GetProperty("sessionId").GetString());
JsonElement turnComplete = AssertSingleEvent(turnEvents, "turn-complete", "provider-turn");
JsonElement message = Assert.Single(turnComplete.GetProperty("messages").EnumerateArray());
Assert.Equal("Hello from the provider.", message.GetProperty("content").GetString());
}
[Fact]
public async Task ValidateWorkflowCommand_ReturnsIssuesAndCompletion()
{
@@ -668,7 +765,7 @@ public sealed class SidecarProtocolHostTests
[Fact]
public void MapRuntimeTools_ExcludesOnlyInternalMetaToolsAndDeduplicatesByName()
{
IReadOnlyList<SidecarRuntimeToolDto> runtimeTools = SidecarProtocolHost.MapRuntimeTools(
IReadOnlyList<SidecarRuntimeToolDto> runtimeTools = CopilotAgentProvider.MapRuntimeTools(
[
new Tool
{
@@ -721,7 +818,7 @@ public sealed class SidecarProtocolHostTests
[Fact]
public void ClassifyConnectionStatus_ReturnsAuthRequiredForLoginFailures()
{
string status = SidecarProtocolHost.ClassifyConnectionStatus(
string status = CopilotAgentProvider.ClassifyConnectionStatus(
new InvalidOperationException("Please run copilot auth login to continue."));
Assert.Equal("copilot-auth-required", status);
@@ -731,7 +828,7 @@ public sealed class SidecarProtocolHostTests
public void CreateReadyConnectionDiagnostics_ReportsCliPathAndModelCount()
{
SidecarConnectionDiagnosticsDto diagnostics =
SidecarProtocolHost.CreateReadyConnectionDiagnostics(
CopilotAgentProvider.CreateReadyConnectionDiagnostics(
@"C:\tools\copilot\copilot.exe",
2,
new SidecarCopilotCliVersionDiagnosticsDto
@@ -1145,6 +1242,38 @@ public sealed class SidecarProtocolHostTests
}
}
private sealed class FakeAgentProvider : IAgentProvider
{
private readonly ITurnWorkflowRunner _workflowRunner;
private readonly IProviderSessionManager _sessionManager;
private readonly SidecarCapabilitiesDto _capabilities;
public FakeAgentProvider(
ITurnWorkflowRunner workflowRunner,
IProviderSessionManager sessionManager,
SidecarCapabilitiesDto capabilities)
{
_workflowRunner = workflowRunner;
_sessionManager = sessionManager;
_capabilities = capabilities;
}
public ITurnWorkflowRunner CreateWorkflowRunner(WorkflowValidator workflowValidator)
{
return _workflowRunner;
}
public Task<SidecarCapabilitiesDto> GetCapabilitiesAsync(CancellationToken cancellationToken)
{
return Task.FromResult(_capabilities);
}
public IProviderSessionManager CreateSessionManager()
{
return _sessionManager;
}
}
private sealed class FakeSessionManager : ICopilotSessionManager
{
public IReadOnlyList<CopilotSessionInfoDto> Sessions { get; init; } = [];
@@ -1,6 +1,7 @@
using System.Collections;
using System.Collections.Concurrent;
using System.Runtime.CompilerServices;
using System.Text.Json;
using Aryx.AgentHost.Contracts;
using Aryx.AgentHost.Services;
using Microsoft.Agents.AI;
@@ -14,7 +15,7 @@ public sealed class WorkflowRequestInfoInterpreterTests
[Fact]
public void TryCreateActivityFromRequest_ReturnsToolCallingActivityForFunctionCalls()
{
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
var tracking = CreateToolTracking();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>
{
@@ -26,7 +27,8 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity("agent-1", "Primary"),
toolNamesByCallId);
tracking.ToolNamesByCallId,
tracking.ToolCallHasArgumentsById);
Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType);
@@ -36,13 +38,14 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.NotNull(activity.ToolArguments);
Assert.Equal(@"C:\workspace\file.txt", activity.ToolArguments["path"]);
Assert.Equal([10, 25], Assert.IsAssignableFrom<IReadOnlyList<object?>>(activity.ToolArguments["viewRange"]));
Assert.Equal("view", toolNamesByCallId["call-1"]);
Assert.Equal("view", tracking.ToolNamesByCallId["call-1"]);
Assert.True(tracking.ToolCallHasArgumentsById["call-1"]);
}
[Fact]
public void TryCreateActivityFromRequest_MapsMcpToolCalls()
{
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
var tracking = CreateToolTracking();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
CreateMcpToolCall(
"call-1",
@@ -58,7 +61,8 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity("agent-1", "Primary"),
toolNamesByCallId);
tracking.ToolNamesByCallId,
tracking.ToolCallHasArgumentsById);
Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType);
@@ -66,13 +70,14 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.NotNull(activity.ToolArguments);
Assert.Equal(@"C:\workspace", activity.ToolArguments["path"]);
Assert.Equal(true, activity.ToolArguments["includeIgnored"]);
Assert.Equal("git.status", toolNamesByCallId["call-1"]);
Assert.Equal("git.status", tracking.ToolNamesByCallId["call-1"]);
Assert.True(tracking.ToolCallHasArgumentsById["call-1"]);
}
[Fact]
public void TryCreateActivityFromRequest_MapsCodeInterpreterCallsToSyntheticToolName()
{
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
var tracking = CreateToolTracking();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
CreateCodeInterpreterToolCall("call-1", "print('hello')"));
@@ -80,7 +85,8 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity("agent-1", "Primary"),
toolNamesByCallId);
tracking.ToolNamesByCallId,
tracking.ToolCallHasArgumentsById);
Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType);
@@ -89,32 +95,35 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.Equal(
["print('hello')"],
Assert.IsAssignableFrom<IReadOnlyList<object?>>(activity.ToolArguments["inputs"]));
Assert.Equal("code interpreter", toolNamesByCallId["call-1"]);
Assert.Equal("code interpreter", tracking.ToolNamesByCallId["call-1"]);
Assert.True(tracking.ToolCallHasArgumentsById["call-1"]);
}
[Fact]
public void TryCreateActivityFromRequest_MapsImageGenerationCallsWithoutTrackingCallId()
{
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
var tracking = CreateToolTracking();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(CreateImageGenerationToolCall());
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity("agent-1", "Primary"),
toolNamesByCallId);
tracking.ToolNamesByCallId,
tracking.ToolCallHasArgumentsById);
Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType);
Assert.Equal("image generation", activity.ToolName);
Assert.Null(activity.ToolArguments);
Assert.Empty(toolNamesByCallId);
Assert.Empty(tracking.ToolNamesByCallId);
Assert.Empty(tracking.ToolCallHasArgumentsById);
}
[Fact]
public void TryCreateActivityFromRequest_LeavesToolArgumentsNullWhenFunctionCallHasNoUsableArguments()
{
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
var tracking = CreateToolTracking();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>
{
@@ -126,16 +135,18 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity("agent-1", "Primary"),
toolNamesByCallId);
tracking.ToolNamesByCallId,
tracking.ToolCallHasArgumentsById);
Assert.NotNull(activity);
Assert.Null(activity.ToolArguments);
Assert.False(tracking.ToolCallHasArgumentsById["call-1"]);
}
[Fact]
public void TryCreateActivityFromRequest_TruncatesOversizedToolArgumentValues()
{
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
var tracking = CreateToolTracking();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent(
"call-1",
@@ -149,37 +160,71 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity("agent-1", "Primary"),
toolNamesByCallId);
tracking.ToolNamesByCallId,
tracking.ToolCallHasArgumentsById);
Assert.NotNull(activity);
Assert.NotNull(activity.ToolArguments);
Assert.Equal("[truncated]", activity.ToolArguments["command"]);
Assert.True(tracking.ToolCallHasArgumentsById["call-1"]);
}
[Fact]
public void TryCreateActivityFromRequest_SkipsDuplicateTrackedToolCallIds()
public void TryCreateActivityFromRequest_SkipsDuplicateTrackedToolCallIdsThatAlreadyHaveArguments()
{
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal)
{
["call-1"] = "view",
};
var tracking = CreateToolTracking();
tracking.ToolNamesByCallId["call-1"] = "view";
tracking.ToolCallHasArgumentsById["call-1"] = true;
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>()));
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>
{
["path"] = @"C:\workspace\file.txt",
}));
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity("agent-1", "Primary"),
toolNamesByCallId);
tracking.ToolNamesByCallId,
tracking.ToolCallHasArgumentsById);
Assert.Null(activity);
Assert.Equal("view", toolNamesByCallId["call-1"]);
Assert.Equal("view", tracking.ToolNamesByCallId["call-1"]);
Assert.True(tracking.ToolCallHasArgumentsById["call-1"]);
}
[Fact]
public void TryCreateActivityFromRequest_EmitsEnrichmentWhenTrackedToolCallWasMissingArguments()
{
var tracking = CreateToolTracking();
tracking.ToolNamesByCallId["call-1"] = "view";
tracking.ToolCallHasArgumentsById["call-1"] = false;
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>
{
["path"] = @"C:\workspace\file.txt",
}));
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity("agent-1", "Primary"),
tracking.ToolNamesByCallId,
tracking.ToolCallHasArgumentsById);
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"]);
}
[Fact]
public void TryCreateActivityFromRequest_ReturnsHandoffActivityForKnownTargets()
{
ConcurrentDictionary<string, string> toolNamesByCallId = new(StringComparer.Ordinal);
var tracking = CreateToolTracking();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
CreateHandoffTarget("agent-handoff-ux", "UX Specialist"));
@@ -187,7 +232,8 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateHandoffCommand(),
requestInfo,
new AgentIdentity("agent-handoff-triage", "Triage"),
toolNamesByCallId);
tracking.ToolNamesByCallId,
tracking.ToolCallHasArgumentsById);
Assert.NotNull(activity);
Assert.Equal("handoff", activity.ActivityType);
@@ -196,7 +242,56 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.Equal("agent-handoff-triage", activity.SourceAgentId);
Assert.Equal("Triage", activity.SourceAgentName);
Assert.Null(activity.ToolName);
Assert.Empty(toolNamesByCallId);
Assert.Empty(tracking.ToolNamesByCallId);
Assert.Empty(tracking.ToolCallHasArgumentsById);
}
[Fact]
public void TryCreateActivityFromRequest_IncludesSubworkflowContextForToolCallingAgent()
{
var tracking = CreateToolTracking();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>
{
["path"] = @"C:\workspace\file.txt",
}));
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity(
"agent-reviewer",
"Reviewer",
new SubworkflowContext("subworkflow-review", "Review Lane")),
tracking.ToolNamesByCallId,
tracking.ToolCallHasArgumentsById);
Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType);
Assert.Equal("subworkflow-review", activity.SubworkflowNodeId);
Assert.Equal("Review Lane", activity.SubworkflowName);
}
[Fact]
public void TryCreateActivityFromRequest_ResolvesReferencedSubworkflowContextForHandoffTargets()
{
var tracking = CreateToolTracking();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
CreateHandoffTarget("agent-handoff-ux", "UX Specialist"));
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
CreateHandoffCommandWithReferencedSubworkflow(),
requestInfo,
new AgentIdentity("agent-handoff-triage", "Triage"),
tracking.ToolNamesByCallId,
tracking.ToolCallHasArgumentsById);
Assert.NotNull(activity);
Assert.Equal("handoff", activity.ActivityType);
Assert.Equal("agent-handoff-ux", activity.AgentId);
Assert.Equal("UX Specialist", activity.AgentName);
Assert.Equal("subworkflow-review", activity.SubworkflowNodeId);
Assert.Equal("Review Lane", activity.SubworkflowName);
}
[Fact]
@@ -255,6 +350,35 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.False(requiresBoundary);
}
[Fact]
public void NormalizeRawToolArguments_JsonElement_ExtractsArguments()
{
using JsonDocument doc = JsonDocument.Parse("""{"path":"/src/main.ts","view_range":[10,20]}""");
JsonElement element = doc.RootElement.Clone();
IReadOnlyDictionary<string, object?>? result = WorkflowRequestInfoInterpreter.NormalizeRawToolArguments(element);
Assert.NotNull(result);
Assert.Equal("/src/main.ts", result["path"]);
IReadOnlyList<object?> viewRange = Assert.IsAssignableFrom<IReadOnlyList<object?>>(result["view_range"]);
Assert.Equal(2, viewRange.Count);
}
[Fact]
public void NormalizeRawToolArguments_Null_ReturnsNull()
{
Assert.Null(WorkflowRequestInfoInterpreter.NormalizeRawToolArguments(null));
}
[Fact]
public void NormalizeRawToolArguments_EmptyObject_ReturnsNull()
{
using JsonDocument doc = JsonDocument.Parse("{}");
JsonElement element = doc.RootElement.Clone();
Assert.Null(WorkflowRequestInfoInterpreter.NormalizeRawToolArguments(element));
}
private static RunTurnCommandDto CreateSingleAgentCommand()
=> CreateCommand("single", [CreateAgent("agent-1", "Primary")]);
@@ -265,19 +389,56 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateAgent("agent-handoff-ux", "UX Specialist"),
]);
private static RunTurnCommandDto CreateCommand(string orchestrationMode, IReadOnlyList<WorkflowNodeDto> agents)
private static RunTurnCommandDto CreateHandoffCommandWithReferencedSubworkflow()
{
WorkflowDefinitionDto nestedWorkflow = new()
{
Id = "nested-review-workflow",
Name = "Nested Review Workflow",
Graph = new WorkflowGraphDto
{
Nodes =
[
CreateAgent("agent-handoff-ux", "UX Specialist"),
],
},
Settings = new WorkflowSettingsDto
{
OrchestrationMode = "single",
},
};
return CreateCommand(
"handoff",
[
CreateAgent("agent-handoff-triage", "Triage"),
CreateSubworkflow("subworkflow-review", "Review Lane", workflowId: nestedWorkflow.Id),
],
workflowLibrary: [nestedWorkflow]);
}
private static (
ConcurrentDictionary<string, string> ToolNamesByCallId,
ConcurrentDictionary<string, bool> ToolCallHasArgumentsById) CreateToolTracking()
=> (new(StringComparer.Ordinal), new(StringComparer.Ordinal));
private static RunTurnCommandDto CreateCommand(
string orchestrationMode,
IReadOnlyList<WorkflowNodeDto> nodes,
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null)
{
return new RunTurnCommandDto
{
RequestId = "turn-1",
SessionId = "session-1",
WorkflowLibrary = workflowLibrary ?? [],
Workflow = new WorkflowDefinitionDto
{
Id = $"{orchestrationMode}-workflow",
Name = "Workflow",
Graph = new WorkflowGraphDto
{
Nodes = [.. agents],
Nodes = [.. nodes],
},
Settings = new WorkflowSettingsDto
{
@@ -305,6 +466,26 @@ public sealed class WorkflowRequestInfoInterpreterTests
};
}
private static WorkflowNodeDto CreateSubworkflow(
string id,
string label,
string? workflowId = null,
WorkflowDefinitionDto? inlineWorkflow = null)
{
return new WorkflowNodeDto
{
Id = id,
Kind = "sub-workflow",
Label = label,
Config = new WorkflowNodeConfigDto
{
Kind = "sub-workflow",
WorkflowId = workflowId,
InlineWorkflow = inlineWorkflow,
},
};
}
private static RequestInfoEvent CreateRequestInfoEvent(object payload)
{
RequestPort port = RequestPort.Create<object, object>("test-port");
@@ -317,8 +498,7 @@ public sealed class WorkflowRequestInfoInterpreterTests
Type type = Type.GetType(
"Microsoft.Extensions.AI.CodeInterpreterToolCallContent, Microsoft.Extensions.AI.Abstractions",
throwOnError: true)!;
object instance = Activator.CreateInstance(type)!;
type.GetProperty("CallId")!.SetValue(instance, callId);
object instance = Activator.CreateInstance(type, callId)!;
if (inputs.Length > 0)
{
Type aiContentType = Type.GetType(
@@ -363,7 +543,7 @@ public sealed class WorkflowRequestInfoInterpreterTests
Type type = Type.GetType(
"Microsoft.Extensions.AI.ImageGenerationToolCallContent, Microsoft.Extensions.AI.Abstractions",
throwOnError: true)!;
return Activator.CreateInstance(type)!;
return Activator.CreateInstance(type, "image-call-1")!;
}
private static object CreateHandoffTarget(string id, string name)
+332 -1359
View File
File diff suppressed because it is too large Load Diff
+27 -19
View File
@@ -23,21 +23,34 @@ async function bootstrap(): Promise<void> {
mainWindow = createMainWindow();
registerIpcHandlers(mainWindow, appService, autoUpdateService);
// Apply persisted theme to the title bar overlay
const workspace = await appService.loadWorkspace();
applyTitleBarTheme(mainWindow, workspace.settings.theme);
// Start workspace loading in parallel — don't block window from showing.
// The renderer fetches the workspace via its own IPC call after mount.
const workspaceReady = appService.loadWorkspace();
// Set up system tray
systemTray = new SystemTray({
onShowWindow: showAndFocusWindow,
onCreateScratchpad: () => {
showAndFocusWindow();
mainWindow?.webContents.send('tray:create-scratchpad');
},
onQuit: () => app.quit(),
});
systemTray.create();
systemTray.updateRunningCount(workspace);
// Apply theme and set up tray once workspace is available
workspaceReady
.then((workspace) => {
if (!mainWindow) return;
applyTitleBarTheme(mainWindow, workspace.settings.theme);
systemTray = new SystemTray({
onShowWindow: showAndFocusWindow,
onCreateScratchpad: () => {
showAndFocusWindow();
mainWindow?.webContents.send('tray:create-scratchpad');
},
onQuit: () => app.quit(),
});
systemTray.create();
systemTray.updateRunningCount(workspace);
appService!.on('workspace-updated', (updatedWorkspace) => {
systemTray?.updateRunningCount(updatedWorkspace);
});
})
.catch((error) => {
console.error('[aryx bootstrap] workspace load failed', error);
});
// Intercept close to hide to tray when the setting is enabled
setupCloseToTray(mainWindow, () => {
@@ -45,11 +58,6 @@ async function bootstrap(): Promise<void> {
return currentWorkspace?.settings.minimizeToTray === true;
});
// Keep tray status in sync when workspace changes
appService.on('workspace-updated', (updatedWorkspace) => {
systemTray?.updateRunningCount(updatedWorkspace);
});
if (!app.isPackaged) {
mainWindow.webContents.openDevTools({ mode: 'detach' });
}
+414
View File
@@ -0,0 +1,414 @@
import type {
ApprovalRequestedEvent,
ExitPlanModeRequestedEvent,
McpOauthRequiredEvent,
UserInputRequestedEvent,
} from '@shared/contracts/sidecar';
import {
dequeuePendingApprovalState,
enqueuePendingApprovalState,
listPendingApprovals,
resolvePendingApproval,
resolveApprovalToolKey,
type ApprovalDecision,
type PendingApprovalRecord,
} from '@shared/domain/approval';
import type { SessionRecord } from '@shared/domain/session';
import type { SessionEventRecord } from '@shared/domain/event';
import type { SessionRunRecord } from '@shared/domain/runTimeline';
import type { WorkspaceState } from '@shared/domain/workspace';
import { nowIso } from '@shared/utils/ids';
type PendingApprovalHandle = {
sessionId: string;
requestId: string;
resolve: (decision: ApprovalDecision, alwaysApprove?: boolean) => void | Promise<void>;
};
type PendingUserInputHandle = {
sessionId: string;
requestId: string;
resolve: (answer: string, wasFreeform: boolean) => void | Promise<void>;
};
type ApprovalCoordinatorDeps = {
requireSession: (workspace: WorkspaceState, sessionId: string) => SessionRecord;
persistWorkspace: (workspace: WorkspaceState) => Promise<WorkspaceState>;
updateSessionRun: (
session: SessionRecord,
requestId: string,
updater: (run: SessionRunRecord) => SessionRunRecord,
) => SessionRunRecord | undefined;
emitRunUpdated: (sessionId: string, occurredAt: string, run: SessionRunRecord) => void;
emitSessionEvent: (event: SessionEventRecord) => void;
failSessionRunRecord: (run: SessionRunRecord, failedAt: string, error: string) => SessionRunRecord;
upsertRunApprovalEvent: (
run: SessionRunRecord,
approval: PendingApprovalRecord,
) => SessionRunRecord;
};
export class ApprovalCoordinator {
readonly pendingApprovalHandles = new Map<string, PendingApprovalHandle>();
readonly pendingUserInputHandles = new Map<string, PendingUserInputHandle>();
private readonly requireSession: ApprovalCoordinatorDeps['requireSession'];
private readonly persistWorkspace: ApprovalCoordinatorDeps['persistWorkspace'];
private readonly updateSessionRun: ApprovalCoordinatorDeps['updateSessionRun'];
private readonly emitRunUpdated: ApprovalCoordinatorDeps['emitRunUpdated'];
private readonly emitSessionEvent: ApprovalCoordinatorDeps['emitSessionEvent'];
private readonly failSessionRunRecord: ApprovalCoordinatorDeps['failSessionRunRecord'];
private readonly upsertRunApprovalEvent: ApprovalCoordinatorDeps['upsertRunApprovalEvent'];
constructor(deps: ApprovalCoordinatorDeps) {
this.requireSession = deps.requireSession;
this.persistWorkspace = deps.persistWorkspace;
this.updateSessionRun = deps.updateSessionRun;
this.emitRunUpdated = deps.emitRunUpdated;
this.emitSessionEvent = deps.emitSessionEvent;
this.failSessionRunRecord = deps.failSessionRunRecord;
this.upsertRunApprovalEvent = deps.upsertRunApprovalEvent;
}
async resolveSessionApproval(
workspace: WorkspaceState,
sessionId: string,
approvalId: string,
decision: ApprovalDecision,
alwaysApprove?: boolean,
): Promise<WorkspaceState> {
const session = this.requireSession(workspace, sessionId);
const approval = session.pendingApproval;
if (!approval || approval.id !== approvalId) {
const queuedApproval = session.pendingApprovalQueue?.some((candidate) => candidate.id === approvalId);
if (queuedApproval) {
throw new Error(
approval
? `Approval "${approvalId}" is queued behind "${approval.id}" for session "${sessionId}". Resolve the active approval first.`
: `Approval "${approvalId}" is queued but not active for session "${sessionId}".`,
);
}
throw new Error(`Approval "${approvalId}" is not pending for session "${sessionId}".`);
}
const handle = this.pendingApprovalHandles.get(approvalId);
if (!handle || handle.sessionId !== sessionId) {
throw new Error(`Approval "${approvalId}" is no longer active. Restart the run and try again.`);
}
const resolvedAt = nowIso();
const resolvedApproval = resolvePendingApproval(approval, decision, resolvedAt);
this.setSessionPendingApprovalState(session, dequeuePendingApprovalState(session, approvalId));
session.updatedAt = resolvedAt;
const approvalKey = resolveApprovalToolKey(approval.toolName, approval.permissionKind);
if (decision === 'approved' && alwaysApprove && approvalKey) {
const existing = session.approvalSettings?.autoApprovedToolNames ?? [];
if (!existing.includes(approvalKey)) {
session.approvalSettings = { autoApprovedToolNames: [...existing, approvalKey] };
}
}
const updatedRun = this.updateSessionRun(session, handle.requestId, (run) =>
this.upsertRunApprovalEvent(run, resolvedApproval));
const cascadeHandles: PendingApprovalHandle[] = [];
if (decision === 'approved' && approvalKey && approval.kind === 'tool-call') {
for (const queued of listPendingApprovals(session)) {
if (queued.id === approvalId) {
continue;
}
const queuedKey = resolveApprovalToolKey(queued.toolName, queued.permissionKind);
if (queuedKey !== approvalKey) {
continue;
}
const queuedHandle = this.pendingApprovalHandles.get(queued.id);
if (!queuedHandle || queuedHandle.sessionId !== sessionId) {
continue;
}
const cascadeResolved = resolvePendingApproval(queued, 'approved', resolvedAt);
this.setSessionPendingApprovalState(session, dequeuePendingApprovalState(session, queued.id));
this.updateSessionRun(session, queuedHandle.requestId, (run) =>
this.upsertRunApprovalEvent(run, cascadeResolved));
this.pendingApprovalHandles.delete(queued.id);
cascadeHandles.push(queuedHandle);
}
}
const result = await this.persistWorkspace(workspace);
if (updatedRun) {
this.emitRunUpdated(sessionId, resolvedAt, updatedRun);
}
this.pendingApprovalHandles.delete(approvalId);
try {
await Promise.resolve(handle.resolve(decision, alwaysApprove));
for (const cascaded of cascadeHandles) {
await Promise.resolve(cascaded.resolve('approved', alwaysApprove));
}
} catch (error) {
const failedAt = nowIso();
this.rejectPendingApprovals(
session,
failedAt,
'Queued approval was cancelled because the run failed before it could resume.',
);
session.status = 'error';
session.lastError = error instanceof Error ? error.message : String(error);
session.updatedAt = failedAt;
const failedRun = this.updateSessionRun(session, handle.requestId, (run) =>
this.failSessionRunRecord(run, failedAt, session.lastError ?? 'Unknown error.'));
this.emitSessionEvent({
sessionId,
kind: 'error',
occurredAt: failedAt,
error: session.lastError,
});
if (failedRun) {
this.emitRunUpdated(sessionId, failedAt, failedRun);
}
await this.persistWorkspace(workspace);
throw error;
}
return result;
}
async resolveSessionUserInput(
workspace: WorkspaceState,
sessionId: string,
userInputId: string,
answer: string,
wasFreeform: boolean,
): Promise<WorkspaceState> {
const session = this.requireSession(workspace, sessionId);
const pending = session.pendingUserInput;
if (!pending || pending.id !== userInputId) {
throw new Error(`User input "${userInputId}" is not pending for session "${sessionId}".`);
}
const handle = this.pendingUserInputHandles.get(userInputId);
if (!handle || handle.sessionId !== sessionId) {
throw new Error(`User input "${userInputId}" is no longer active. Restart the run and try again.`);
}
const answeredAt = nowIso();
session.pendingUserInput = {
...pending,
status: 'answered',
answer,
answeredAt,
};
session.updatedAt = answeredAt;
const result = await this.persistWorkspace(workspace);
this.pendingUserInputHandles.delete(userInputId);
try {
await Promise.resolve(handle.resolve(answer, wasFreeform));
session.pendingUserInput = undefined;
await this.persistWorkspace(workspace);
} catch (error) {
session.status = 'error';
session.lastError = error instanceof Error ? error.message : String(error);
session.updatedAt = nowIso();
this.emitSessionEvent({
sessionId,
kind: 'error',
occurredAt: session.updatedAt,
error: session.lastError,
});
await this.persistWorkspace(workspace);
throw error;
}
return result;
}
async handleApprovalRequested(
workspace: WorkspaceState,
sessionId: string,
requestId: string,
approval: ApprovalRequestedEvent | PendingApprovalRecord,
resolve: (decision: ApprovalDecision, alwaysApprove?: boolean) => void | Promise<void>,
): Promise<void> {
const session = this.requireSession(workspace, sessionId);
const pendingApproval =
'type' in approval ? this.createPendingApprovalFromSidecarEvent(approval) : approval;
this.setSessionPendingApprovalState(session, enqueuePendingApprovalState(session, pendingApproval));
session.updatedAt = pendingApproval.requestedAt;
const updatedRun = this.updateSessionRun(session, requestId, (run) =>
this.upsertRunApprovalEvent(run, pendingApproval));
this.pendingApprovalHandles.set(pendingApproval.id, {
sessionId,
requestId,
resolve,
});
await this.persistWorkspace(workspace);
if (updatedRun) {
this.emitRunUpdated(sessionId, pendingApproval.requestedAt, updatedRun);
}
}
async handleUserInputRequested(
workspace: WorkspaceState,
sessionId: string,
requestId: string,
event: UserInputRequestedEvent,
resolve: (answer: string, wasFreeform: boolean) => void | Promise<void>,
): Promise<void> {
const session = this.requireSession(workspace, sessionId);
const requestedAt = nowIso();
session.pendingUserInput = {
id: event.userInputId,
status: 'pending',
agentId: event.agentId,
agentName: event.agentName,
question: event.question,
choices: event.choices,
allowFreeform: event.allowFreeform ?? true,
requestedAt,
};
session.updatedAt = requestedAt;
this.pendingUserInputHandles.set(event.userInputId, {
sessionId,
requestId,
resolve,
});
await this.persistWorkspace(workspace);
}
async handleExitPlanModeRequested(
workspace: WorkspaceState,
sessionId: string,
event: ExitPlanModeRequestedEvent,
): Promise<void> {
const session = this.requireSession(workspace, sessionId);
const requestedAt = nowIso();
session.pendingPlanReview = {
id: event.exitPlanId,
status: 'pending',
agentId: event.agentId,
agentName: event.agentName,
summary: event.summary,
planContent: event.planContent,
actions: event.actions,
recommendedAction: event.recommendedAction,
requestedAt,
};
session.updatedAt = requestedAt;
await this.persistWorkspace(workspace);
}
async handleMcpOAuthRequired(
workspace: WorkspaceState,
sessionId: string,
event: McpOauthRequiredEvent,
): Promise<void> {
const session = this.requireSession(workspace, sessionId);
const requestedAt = nowIso();
session.pendingMcpAuth = {
id: event.oauthRequestId,
status: 'pending',
agentId: event.agentId,
agentName: event.agentName,
serverName: event.serverName,
serverUrl: event.serverUrl,
staticClientConfig: event.staticClientConfig
? { clientId: event.staticClientConfig.clientId, publicClient: event.staticClientConfig.publicClient }
: undefined,
requestedAt,
};
session.updatedAt = requestedAt;
await this.persistWorkspace(workspace);
}
createPendingApprovalFromSidecarEvent(event: ApprovalRequestedEvent): PendingApprovalRecord {
return {
id: event.approvalId,
kind: event.approvalKind,
status: 'pending',
requestedAt: nowIso(),
agentId: event.agentId,
agentName: event.agentName,
toolName: event.toolName,
permissionKind: event.permissionKind,
title: event.title,
detail: event.detail,
permissionDetail: event.permissionDetail,
};
}
setSessionPendingApprovalState(
session: SessionRecord,
state: {
pendingApproval?: PendingApprovalRecord;
pendingApprovalQueue?: PendingApprovalRecord[];
},
): void {
session.pendingApproval = state.pendingApproval;
session.pendingApprovalQueue = state.pendingApprovalQueue;
}
rejectPendingApprovals(
session: SessionRecord,
failedAt: string,
error: string,
): string[] {
const requestIds = new Set<string>();
for (const pendingApproval of listPendingApprovals(session)) {
const requestId = this.findApprovalRequestId(session, pendingApproval.id);
const rejectedApproval = resolvePendingApproval(pendingApproval, 'rejected', failedAt, error);
if (requestId) {
requestIds.add(requestId);
this.updateSessionRun(session, requestId, (run) =>
this.upsertRunApprovalEvent(run, rejectedApproval));
}
this.pendingApprovalHandles.delete(pendingApproval.id);
}
this.setSessionPendingApprovalState(session, {});
return [...requestIds];
}
findApprovalRequestId(session: SessionRecord, approvalId: string): string | undefined {
const matchingRun = session.runs.find((run) =>
run.events.some((event) => event.kind === 'approval' && event.approvalId === approvalId));
if (matchingRun) {
return matchingRun.requestId;
}
return session.runs.find((run) => run.status === 'running')?.requestId;
}
}
@@ -0,0 +1,171 @@
import { rm } from 'node:fs/promises';
import type {
AgentActivityEvent,
ApprovalRequestedEvent,
ExitPlanModeRequestedEvent,
McpOauthRequiredEvent,
MessageReclassifiedEvent,
RunTurnCommand,
UserInputRequestedEvent,
TurnDeltaEvent,
WorkflowCheckpointResume,
WorkflowCheckpointSavedEvent,
} from '@shared/contracts/sidecar';
import type { ChatMessageRecord, SessionRecord } from '@shared/domain/session';
import type { SessionRunRecord } from '@shared/domain/runTimeline';
import { nowIso } from '@shared/utils/ids';
import type { TurnScopedEvent } from '@main/sidecar/runTurnPending';
export type PendingApprovalHandleLike = {
sessionId: string;
requestId: string;
};
export type PendingUserInputHandleLike = {
sessionId: string;
requestId: string;
};
export type WorkflowCheckpointRecoveryState = {
workflowSessionId: string;
checkpointId: string;
storePath: string;
stepNumber: number;
sessionMessages: ChatMessageRecord[];
runEvents: import('@shared/domain/runTimeline').RunTimelineEventRecord[];
};
type CheckpointRecoveryManagerDeps = {
persistWorkspace: (workspace: import('@shared/domain/workspace').WorkspaceState) => Promise<void>;
emitRunUpdated: (sessionId: string, occurredAt: string, run: SessionRunRecord) => void;
updateSessionRun: (
session: SessionRecord,
requestId: string,
updater: (run: SessionRunRecord) => SessionRunRecord,
) => SessionRunRecord | undefined;
setSessionPendingApprovalState: (
session: SessionRecord,
state: {
pendingApproval?: import('@shared/domain/approval').PendingApprovalRecord;
pendingApprovalQueue?: import('@shared/domain/approval').PendingApprovalRecord[];
},
) => void;
pendingApprovalHandles: Map<string, PendingApprovalHandleLike>;
pendingUserInputHandles: Map<string, PendingUserInputHandleLike>;
};
export class CheckpointRecoveryManager {
readonly recoveries = new Map<string, WorkflowCheckpointRecoveryState>();
private readonly persistWorkspace: (workspace: import('@shared/domain/workspace').WorkspaceState) => Promise<void>;
private readonly emitRunUpdated: (sessionId: string, occurredAt: string, run: SessionRunRecord) => void;
private readonly updateSessionRun: CheckpointRecoveryManagerDeps['updateSessionRun'];
private readonly setSessionPendingApprovalState: CheckpointRecoveryManagerDeps['setSessionPendingApprovalState'];
private readonly pendingApprovalHandles: Map<string, PendingApprovalHandleLike>;
private readonly pendingUserInputHandles: Map<string, PendingUserInputHandleLike>;
constructor(deps: CheckpointRecoveryManagerDeps) {
this.persistWorkspace = deps.persistWorkspace;
this.emitRunUpdated = deps.emitRunUpdated;
this.updateSessionRun = deps.updateSessionRun;
this.setSessionPendingApprovalState = deps.setSessionPendingApprovalState;
this.pendingApprovalHandles = deps.pendingApprovalHandles;
this.pendingUserInputHandles = deps.pendingUserInputHandles;
}
async runSidecarTurnWithCheckpointRecovery(
workspace: import('@shared/domain/workspace').WorkspaceState,
session: SessionRecord,
requestId: string,
invokeTurn: (resumeFromCheckpoint?: WorkflowCheckpointResume) => Promise<ChatMessageRecord[]>,
isUnexpectedSidecarTerminationError: (error: unknown) => boolean,
): Promise<ChatMessageRecord[]> {
try {
return await invokeTurn();
} catch (error) {
const recovery = this.recoveries.get(requestId);
if (!isUnexpectedSidecarTerminationError(error) || !recovery) {
throw error;
}
const restoredRun = this.restoreWorkflowCheckpointRecovery(session, requestId, recovery);
await this.persistWorkspace(workspace);
if (restoredRun) {
this.emitRunUpdated(session.id, session.updatedAt, restoredRun);
}
return invokeTurn({
workflowSessionId: recovery.workflowSessionId,
checkpointId: recovery.checkpointId,
storePath: recovery.storePath,
});
}
}
recordWorkflowCheckpointRecovery(
session: SessionRecord,
run: SessionRunRecord,
event: WorkflowCheckpointSavedEvent,
): void {
this.recoveries.set(event.requestId, {
workflowSessionId: event.workflowSessionId,
checkpointId: event.checkpointId,
storePath: event.storePath,
stepNumber: event.stepNumber,
sessionMessages: structuredClone(session.messages),
runEvents: structuredClone(run.events),
});
}
restoreWorkflowCheckpointRecovery(
session: SessionRecord,
requestId: string,
recovery: WorkflowCheckpointRecoveryState,
): SessionRunRecord | undefined {
session.messages = structuredClone(recovery.sessionMessages);
session.status = 'running';
session.lastError = undefined;
session.updatedAt = nowIso();
this.clearPendingRunState(session, requestId);
return this.updateSessionRun(session, requestId, (run) => ({
...run,
events: structuredClone(recovery.runEvents),
}));
}
clearPendingRunState(session: SessionRecord, requestId: string): void {
this.setSessionPendingApprovalState(session, {});
session.pendingUserInput = undefined;
session.pendingPlanReview = undefined;
session.pendingMcpAuth = undefined;
for (const [approvalId, handle] of this.pendingApprovalHandles.entries()) {
if (handle.sessionId === session.id && handle.requestId === requestId) {
this.pendingApprovalHandles.delete(approvalId);
}
}
for (const [userInputId, handle] of this.pendingUserInputHandles.entries()) {
if (handle.sessionId === session.id && handle.requestId === requestId) {
this.pendingUserInputHandles.delete(userInputId);
}
}
}
async cleanupWorkflowCheckpointRecovery(requestId: string): Promise<void> {
const recovery = this.recoveries.get(requestId);
this.recoveries.delete(requestId);
if (!recovery) {
return;
}
try {
await rm(recovery.storePath, { recursive: true, force: true });
} catch (error) {
console.warn('[aryx workflow-checkpoint] Failed to clean checkpoint store:', error);
}
}
}
@@ -0,0 +1,195 @@
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
import {
applyDiscoveredMcpServerStatus,
normalizeDiscoveredToolingState,
type DiscoveredMcpServer,
type DiscoveredToolingState,
type DiscoveredToolingStatus,
} from '@shared/domain/discoveredTooling';
import {
normalizeProjectCustomizationState,
type ProjectCustomizationState,
} from '@shared/domain/projectCustomization';
import type { WorkspaceState } from '@shared/domain/workspace';
import { ConfigScannerRegistry } from '@main/services/configScanner';
import { ProjectCustomizationScanner } from '@main/services/customizationScanner';
import { ProjectCustomizationWatcher } from '@main/services/projectCustomizationWatcher';
export type DiscoveredToolingResolution = 'accept' | 'dismiss';
type DiscoveredToolingSyncServiceDeps = {
configScanner: ConfigScannerRegistry;
customizationScanner: ProjectCustomizationScanner;
projectCustomizationWatcher: ProjectCustomizationWatcher;
loadWorkspace: () => Promise<WorkspaceState>;
persistWorkspace: (workspace: WorkspaceState) => Promise<void>;
};
export class DiscoveredToolingSyncService {
private customizationWatcherUpdateQueue = Promise.resolve();
private readonly configScanner: ConfigScannerRegistry;
private readonly customizationScanner: ProjectCustomizationScanner;
private readonly projectCustomizationWatcher: ProjectCustomizationWatcher;
private readonly loadWorkspace: () => Promise<WorkspaceState>;
private readonly persistWorkspace: (workspace: WorkspaceState) => Promise<void>;
constructor(deps: DiscoveredToolingSyncServiceDeps) {
this.configScanner = deps.configScanner;
this.customizationScanner = deps.customizationScanner;
this.projectCustomizationWatcher = deps.projectCustomizationWatcher;
this.loadWorkspace = deps.loadWorkspace;
this.persistWorkspace = deps.persistWorkspace;
}
async syncUserDiscoveredTooling(workspace: WorkspaceState): Promise<boolean> {
const nextState = await this.configScanner.scanUser(workspace.settings.discoveredUserTooling);
if (this.equalDiscoveredToolingState(workspace.settings.discoveredUserTooling, nextState)) {
return false;
}
workspace.settings.discoveredUserTooling = nextState;
return true;
}
async syncProjectCustomizationWatchers(workspace: WorkspaceState): Promise<void> {
await this.projectCustomizationWatcher.syncProjects(
workspace.projects
.filter((project) => !isScratchpadProject(project))
.map((project) => ({
id: project.id,
path: project.path,
})),
);
}
async handleProjectCustomizationWatcherChange(projectId: string): Promise<void> {
await this.enqueueCustomizationWatcherUpdate(async () => {
const workspace = await this.loadWorkspace();
const project = workspace.projects.find((candidate) => candidate.id === projectId);
await this.syncProjectCustomizationWatchers(workspace);
if (!project || isScratchpadProject(project)) {
return;
}
const didSyncProjectCustomization = await this.syncProjectCustomization(project);
await this.syncProjectCustomizationWatchers(workspace);
if (didSyncProjectCustomization) {
await this.persistWorkspace(workspace);
}
});
}
async syncProjectCustomization(project: ProjectRecord): Promise<boolean> {
if (isScratchpadProject(project)) {
if (!project.customization || this.equalProjectCustomizationState(project.customization, undefined)) {
return false;
}
project.customization = undefined;
return true;
}
const nextState = await this.customizationScanner.scanProject(project.path, project.customization);
if (this.equalProjectCustomizationState(project.customization, nextState)) {
return false;
}
project.customization = nextState;
return true;
}
async syncProjectDiscoveredTooling(
workspace: WorkspaceState,
project: ProjectRecord,
): Promise<boolean> {
if (isScratchpadProject(project)) {
if (!project.discoveredTooling || this.equalDiscoveredToolingState(project.discoveredTooling, undefined)) {
return false;
}
project.discoveredTooling = undefined;
return true;
}
const nextState = await this.configScanner.scanProject(
project.id,
project.path,
project.discoveredTooling,
);
if (this.equalDiscoveredToolingState(project.discoveredTooling, nextState)) {
return false;
}
project.discoveredTooling = nextState;
return true;
}
resolveDiscoveredToolingStatus(
resolution: DiscoveredToolingResolution,
): Exclude<DiscoveredToolingStatus, 'pending'> {
return resolution === 'accept' ? 'accepted' : 'dismissed';
}
resolveWorkspaceDiscoveredTooling(
workspace: WorkspaceState,
serverIds: string[],
resolution: DiscoveredToolingResolution,
): void {
workspace.settings.discoveredUserTooling = applyDiscoveredMcpServerStatus(
workspace.settings.discoveredUserTooling,
serverIds,
this.resolveDiscoveredToolingStatus(resolution),
);
}
resolveProjectDiscoveredTooling(
project: ProjectRecord,
serverIds: string[],
resolution: DiscoveredToolingResolution,
): void {
project.discoveredTooling = applyDiscoveredMcpServerStatus(
project.discoveredTooling,
serverIds,
this.resolveDiscoveredToolingStatus(resolution),
);
}
equalDiscoveredToolingState(
left?: DiscoveredToolingState,
right?: DiscoveredToolingState,
): boolean {
const stripRuntime = (servers: DiscoveredMcpServer[]) =>
servers.map(({ probedTools: _, ...rest }) => rest);
return JSON.stringify(stripRuntime(normalizeDiscoveredToolingState(left).mcpServers))
=== JSON.stringify(stripRuntime(normalizeDiscoveredToolingState(right).mcpServers));
}
equalProjectCustomizationState(
left?: ProjectCustomizationState,
right?: ProjectCustomizationState,
): boolean {
const normalizedLeft = normalizeProjectCustomizationState(left);
const normalizedRight = normalizeProjectCustomizationState(right);
return JSON.stringify({
instructions: normalizedLeft.instructions,
agentProfiles: normalizedLeft.agentProfiles,
promptFiles: normalizedLeft.promptFiles,
}) === JSON.stringify({
instructions: normalizedRight.instructions,
agentProfiles: normalizedRight.agentProfiles,
promptFiles: normalizedRight.promptFiles,
});
}
private enqueueCustomizationWatcherUpdate(task: () => Promise<void>): Promise<void> {
const scheduledTask = this.customizationWatcherUpdateQueue.then(task, task);
this.customizationWatcherUpdateQueue = scheduledTask.then(
() => undefined,
() => undefined,
);
return scheduledTask;
}
}
+306
View File
@@ -0,0 +1,306 @@
import {
isScratchpadProject,
type ProjectGitDetails,
type ProjectGitDiffPreview,
type ProjectGitFileReference,
type ProjectRecord,
} from '@shared/domain/project';
import type { SessionRecord } from '@shared/domain/session';
import { setSessionRunGitSummary, type SessionRunRecord } from '@shared/domain/runTimeline';
import type { WorkspaceState } from '@shared/domain/workspace';
import { nowIso } from '@shared/utils/ids';
import { GitService } from '@main/git/gitService';
const GIT_REFRESH_DEBOUNCE_MS = 750;
const GIT_REFRESH_INTERVAL_MS = 60_000;
type GitContextManagerDeps = {
gitService: GitService;
loadWorkspace: () => Promise<WorkspaceState>;
persistWorkspace: (workspace: WorkspaceState) => Promise<WorkspaceState>;
requireProject: (workspace: WorkspaceState, projectId: string) => ProjectRecord;
requireSession: (workspace: WorkspaceState, sessionId: string) => SessionRecord;
requireSessionRun: (session: SessionRecord, runId: string) => SessionRunRecord;
syncProjectDiscoveredTooling: (workspace: WorkspaceState, project: ProjectRecord) => Promise<boolean>;
syncProjectCustomization: (project: ProjectRecord) => Promise<boolean>;
pruneUnavailableSessionToolingSelections: (workspace: WorkspaceState) => boolean;
pruneUnavailableApprovalTools: (workspace: WorkspaceState) => Promise<boolean>;
updateSessionRun: (
session: SessionRecord,
requestId: string,
updater: (run: SessionRunRecord) => SessionRunRecord,
) => SessionRunRecord | undefined;
emitRunUpdated: (sessionId: string, occurredAt: string, run: SessionRunRecord) => void;
};
export class GitContextManager {
private readonly gitService: GitService;
private readonly loadWorkspace: () => Promise<WorkspaceState>;
private readonly persistWorkspace: (workspace: WorkspaceState) => Promise<WorkspaceState>;
private readonly requireProject: (workspace: WorkspaceState, projectId: string) => ProjectRecord;
private readonly requireSession: (workspace: WorkspaceState, sessionId: string) => SessionRecord;
private readonly requireSessionRun: (session: SessionRecord, runId: string) => SessionRunRecord;
private readonly syncProjectDiscoveredTooling: (workspace: WorkspaceState, project: ProjectRecord) => Promise<boolean>;
private readonly syncProjectCustomization: (project: ProjectRecord) => Promise<boolean>;
private readonly pruneUnavailableSessionToolingSelections: (workspace: WorkspaceState) => boolean;
private readonly pruneUnavailableApprovalTools: (workspace: WorkspaceState) => Promise<boolean>;
private readonly updateSessionRun: GitContextManagerDeps['updateSessionRun'];
private readonly emitRunUpdated: GitContextManagerDeps['emitRunUpdated'];
private didStartPeriodicProjectGitRefresh = false;
private pendingProjectGitRefreshIds = new Set<string>();
private pendingRefreshAllProjects = false;
private projectGitRefreshTimer?: ReturnType<typeof setTimeout>;
private periodicProjectGitRefreshTimer?: ReturnType<typeof setInterval>;
private runningProjectGitRefresh?: Promise<void>;
constructor(deps: GitContextManagerDeps) {
this.gitService = deps.gitService;
this.loadWorkspace = deps.loadWorkspace;
this.persistWorkspace = deps.persistWorkspace;
this.requireProject = deps.requireProject;
this.requireSession = deps.requireSession;
this.requireSessionRun = deps.requireSessionRun;
this.syncProjectDiscoveredTooling = deps.syncProjectDiscoveredTooling;
this.syncProjectCustomization = deps.syncProjectCustomization;
this.pruneUnavailableSessionToolingSelections = deps.pruneUnavailableSessionToolingSelections;
this.pruneUnavailableApprovalTools = deps.pruneUnavailableApprovalTools;
this.updateSessionRun = deps.updateSessionRun;
this.emitRunUpdated = deps.emitRunUpdated;
}
dispose(): void {
if (this.projectGitRefreshTimer) {
clearTimeout(this.projectGitRefreshTimer);
this.projectGitRefreshTimer = undefined;
}
if (this.periodicProjectGitRefreshTimer) {
clearInterval(this.periodicProjectGitRefreshTimer);
this.periodicProjectGitRefreshTimer = undefined;
}
this.didStartPeriodicProjectGitRefresh = false;
}
scheduleProjectGitRefresh(projectId?: string): void {
if (projectId) {
this.pendingProjectGitRefreshIds.add(projectId);
} else {
this.pendingRefreshAllProjects = true;
this.pendingProjectGitRefreshIds.clear();
}
if (this.projectGitRefreshTimer) {
clearTimeout(this.projectGitRefreshTimer);
}
this.projectGitRefreshTimer = setTimeout(() => {
this.projectGitRefreshTimer = undefined;
void this.flushScheduledProjectGitRefresh();
}, GIT_REFRESH_DEBOUNCE_MS);
this.projectGitRefreshTimer.unref?.();
}
async refreshProjectGitContext(projectId?: string): Promise<WorkspaceState> {
return this.refreshProjectGitContexts(projectId ? [projectId] : undefined);
}
async getProjectGitDetails(projectId: string, commitLimit = 20): Promise<ProjectGitDetails> {
const workspace = await this.loadWorkspace();
const project = this.requireProject(workspace, projectId);
return this.gitService.describeProjectGitDetails(project.path, nowIso(), commitLimit);
}
async getProjectGitFilePreview(
projectId: string,
file: ProjectGitFileReference,
): Promise<ProjectGitDiffPreview | undefined> {
const workspace = await this.loadWorkspace();
const project = this.requireProject(workspace, projectId);
return this.gitService.getWorkingTreeFilePreview(project.path, file);
}
async discardSessionRunGitChanges(
sessionId: string,
runId: string,
files?: ProjectGitFileReference[],
): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const session = this.requireSession(workspace, sessionId);
const project = this.requireProject(workspace, session.projectId);
const run = this.requireSessionRun(session, runId);
if (run.workspaceKind !== 'project') {
throw new Error('Run change review is only available for project-backed sessions.');
}
if (!run.postRunGitSummary) {
throw new Error('This run does not have any tracked git changes to discard.');
}
await this.gitService.discardRunChanges(
this.resolveRunWorkingDirectory(session, project, run),
{
summary: run.postRunGitSummary,
preRunBaselineFiles: run.preRunGitBaselineFiles,
files,
},
);
await this.refreshProjectGitContexts([project.id]);
const refreshedWorkspace = await this.loadWorkspace();
const refreshedSession = this.requireSession(refreshedWorkspace, sessionId);
const refreshedProject = this.requireProject(refreshedWorkspace, refreshedSession.projectId);
const nextRun = await this.refreshSessionRunGitSummary(
refreshedSession,
refreshedProject,
run.requestId,
nowIso(),
);
if (nextRun) {
this.emitRunUpdated(refreshedSession.id, nowIso(), nextRun);
}
return this.persistWorkspace(refreshedWorkspace);
}
async runProjectGitMutation(
projectId: string,
mutation: (project: ProjectRecord) => Promise<void>,
): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const project = this.requireProject(workspace, projectId);
if (isScratchpadProject(project)) {
throw new Error('Git operations are not available for the Scratchpad project.');
}
await mutation(project);
return this.refreshProjectGitContexts([project.id]);
}
resolveRunWorkingDirectory(
session: SessionRecord,
project: ProjectRecord,
run: SessionRunRecord,
): string {
return run.workingDirectory ?? session.cwd ?? run.projectPath ?? project.path;
}
async refreshSessionRunGitSummary(
session: SessionRecord,
project: ProjectRecord,
requestId: string,
occurredAt: string,
): Promise<SessionRunRecord | undefined> {
const run = session.runs.find((candidate) => candidate.requestId === requestId);
if (!run || run.workspaceKind !== 'project' || !run.preRunGitSnapshot) {
return undefined;
}
const summary = await this.gitService.computeRunChangeSummary(
this.resolveRunWorkingDirectory(session, project, run),
{
generatedAt: occurredAt,
preRunSnapshot: run.preRunGitSnapshot,
preRunBaselineFiles: run.preRunGitBaselineFiles,
},
);
return this.updateSessionRun(session, requestId, (currentRun) =>
setSessionRunGitSummary(currentRun, summary));
}
async refreshProjectGitContexts(projectIds?: readonly string[]): Promise<WorkspaceState> {
const workspace = await this.loadWorkspace();
const projects = projectIds?.length
? projectIds.map((currentProjectId) => this.requireProject(workspace, currentProjectId))
: workspace.projects;
let didRefreshGit = false;
let didSyncProjectTooling = false;
let didSyncProjectCustomization = false;
for (const project of projects) {
didRefreshGit = await this.refreshGitContextForProject(project) || didRefreshGit;
didSyncProjectTooling = await this.syncProjectDiscoveredTooling(workspace, project) || didSyncProjectTooling;
didSyncProjectCustomization = await this.syncProjectCustomization(project) || didSyncProjectCustomization;
}
const didPruneSelections = didSyncProjectTooling
? this.pruneUnavailableSessionToolingSelections(workspace)
: false;
const didPruneApprovalTools = didSyncProjectTooling
? await this.pruneUnavailableApprovalTools(workspace)
: false;
return (
didRefreshGit
|| didSyncProjectTooling
|| didSyncProjectCustomization
|| didPruneSelections
|| didPruneApprovalTools
)
? this.persistWorkspace(workspace)
: workspace;
}
startPeriodicProjectGitRefresh(): void {
if (this.didStartPeriodicProjectGitRefresh) {
return;
}
this.didStartPeriodicProjectGitRefresh = true;
this.periodicProjectGitRefreshTimer = setInterval(() => {
this.scheduleProjectGitRefresh();
}, GIT_REFRESH_INTERVAL_MS);
this.periodicProjectGitRefreshTimer.unref?.();
}
stopPeriodicProjectGitRefresh(): void {
if (this.periodicProjectGitRefreshTimer) {
clearInterval(this.periodicProjectGitRefreshTimer);
this.periodicProjectGitRefreshTimer = undefined;
}
this.didStartPeriodicProjectGitRefresh = false;
}
async flushScheduledProjectGitRefresh(): Promise<void> {
if (this.runningProjectGitRefresh) {
return;
}
const projectIds = this.pendingRefreshAllProjects
? undefined
: [...this.pendingProjectGitRefreshIds];
this.pendingRefreshAllProjects = false;
this.pendingProjectGitRefreshIds.clear();
this.runningProjectGitRefresh = this.refreshProjectGitContexts(projectIds).then(
() => undefined,
(error) => {
console.error('[aryx git]', error);
},
);
try {
await this.runningProjectGitRefresh;
} finally {
this.runningProjectGitRefresh = undefined;
if (this.pendingRefreshAllProjects || this.pendingProjectGitRefreshIds.size > 0) {
this.scheduleProjectGitRefresh();
}
}
}
private async refreshGitContextForProject(project: ProjectRecord): Promise<boolean> {
if (isScratchpadProject(project)) {
if (!project.git) {
return false;
}
project.git = undefined;
return true;
}
project.git = await this.gitService.describeProject(project.path);
return true;
}
}
+309
View File
@@ -0,0 +1,309 @@
import type { SessionToolingSelection, WorkspaceToolingSettings, McpServerDefinition } from '@shared/domain/tooling';
import {
listAcceptedDiscoveredMcpServers,
type DiscoveredMcpServer,
type DiscoveredToolingState,
} from '@shared/domain/discoveredTooling';
import type { WorkspaceState } from '@shared/domain/workspace';
import { nowIso } from '@shared/utils/ids';
import { probeServers, type McpProbeResult } from '@main/services/mcpToolProber';
import { getStoredToken } from '@main/services/mcpTokenStore';
import { performMcpOAuthFlow, requiresOAuth } from '@main/services/mcpOAuthService';
type McpProbeManagerDeps = {
loadWorkspace: () => Promise<WorkspaceState>;
persistWorkspace: (workspace: WorkspaceState) => Promise<void>;
probeMcpServers?: typeof probeServers;
tokenLookup?: (serverUrl: string) => string | undefined;
performMcpOAuthFlow?: typeof performMcpOAuthFlow;
requiresOAuth?: typeof requiresOAuth;
};
export class McpProbeManager {
private mcpProbeUpdateQueue = Promise.resolve();
private readonly loadWorkspace: () => Promise<WorkspaceState>;
private readonly persistWorkspace: (workspace: WorkspaceState) => Promise<void>;
private readonly probeMcpServers: typeof probeServers;
private readonly tokenLookup: (serverUrl: string) => string | undefined;
private readonly performMcpOAuthFlow: typeof performMcpOAuthFlow;
private readonly requiresOAuth: typeof requiresOAuth;
constructor(deps: McpProbeManagerDeps) {
this.loadWorkspace = deps.loadWorkspace;
this.persistWorkspace = deps.persistWorkspace;
this.probeMcpServers = deps.probeMcpServers ?? probeServers;
this.tokenLookup = deps.tokenLookup ?? ((serverUrl) => getStoredToken(serverUrl)?.accessToken);
this.performMcpOAuthFlow = deps.performMcpOAuthFlow ?? performMcpOAuthFlow;
this.requiresOAuth = deps.requiresOAuth ?? requiresOAuth;
}
async probeAndAuthenticateHttpMcpServers(
tooling: WorkspaceToolingSettings,
selection: SessionToolingSelection,
): Promise<void> {
const httpServers = selection.enabledMcpServerIds
.map((id) => tooling.mcpServers.find((server) => server.id === id))
.filter((server): server is McpServerDefinition => !!server && server.transport !== 'local')
.filter((server) => server.transport === 'http' || server.transport === 'sse');
if (httpServers.length === 0) {
return;
}
console.log(`[aryx oauth] Probing ${httpServers.length} HTTP MCP server(s) for OAuth requirements…`);
for (const server of httpServers) {
if (server.transport === 'local') {
continue;
}
const existingToken = this.tokenLookup(server.url);
if (existingToken) {
console.log(`[aryx oauth] Skipping ${server.name} — token already stored`);
continue;
}
try {
const needsAuth = await this.requiresOAuth(server.url);
if (!needsAuth) {
console.log(`[aryx oauth] ${server.name} does not require OAuth`);
continue;
}
console.log(`[aryx oauth] ${server.name} requires OAuth — starting flow…`);
const result = await this.performMcpOAuthFlow({ serverUrl: server.url });
if (result.success) {
console.log(`[aryx oauth] ${server.name} authenticated successfully`);
void this.reprobeServerByUrl(server.url).catch((error) => {
console.error('[aryx mcp-probe] re-probe after auth failed:', error);
});
} else {
console.warn(`[aryx oauth] Proactive auth failed for ${server.name}: ${result.error}`);
}
} catch (error) {
console.warn(`[aryx oauth] Proactive auth probe failed for ${server.name}:`, error);
}
}
}
async probeAllAcceptedMcpServers(workspace: WorkspaceState): Promise<void> {
const targets = [
...this.listAcceptedDiscoveredServerDefinitions(
workspace,
(server) => !server.probedTools || server.probedTools.length === 0,
),
...workspace.settings.tooling.mcpServers.filter(
(server) => server.tools.length === 0 && (!server.probedTools || server.probedTools.length === 0),
),
];
await this.probeWorkspaceMcpServers(workspace, targets);
}
async probeDiscoveredMcpServersFromState(
workspace: WorkspaceState,
state?: DiscoveredToolingState,
): Promise<void> {
const targets = listAcceptedDiscoveredMcpServers(state)
.filter((server) => !server.probedTools || server.probedTools.length === 0)
.map((server) => this.discoveredServerToDefinition(server));
await this.probeWorkspaceMcpServers(workspace, targets);
}
async probeDiscoveredMcpServers(
workspace: WorkspaceState,
state: DiscoveredToolingState | undefined,
serverIds: ReadonlyArray<string>,
): Promise<void> {
const targets = listAcceptedDiscoveredMcpServers(state)
.filter((server) => serverIds.includes(server.id))
.map((server) => this.discoveredServerToDefinition(server));
await this.probeWorkspaceMcpServers(workspace, targets);
}
async probeWorkspaceMcpServers(
workspace: WorkspaceState,
targets: ReadonlyArray<McpServerDefinition>,
): Promise<void> {
const uniqueTargets = [...new Map(targets.map((server) => [server.id, server])).values()];
if (uniqueTargets.length === 0) {
return;
}
const targetIds = uniqueTargets.map((server) => server.id);
await this.enqueueMcpProbeUpdate(async () => {
if (this.addMcpProbingServerIds(workspace, targetIds)) {
await this.persistWorkspace(workspace);
}
});
try {
await this.probeMcpServers(uniqueTargets, this.tokenLookup, (result) =>
this.enqueueMcpProbeUpdate(async () => {
const didUpdateProbing = this.removeMcpProbingServerIds(workspace, [result.serverId]);
const didApplyResult = this.applyMcpProbeResult(workspace, result);
if (didUpdateProbing || didApplyResult) {
await this.persistWorkspace(workspace);
}
}));
} finally {
await this.enqueueMcpProbeUpdate(async () => {
if (this.removeMcpProbingServerIds(workspace, targetIds)) {
await this.persistWorkspace(workspace);
}
});
}
}
async reprobeServerByUrl(serverUrl: string): Promise<void> {
const workspace = await this.loadWorkspace();
const targets: McpServerDefinition[] = [];
for (const server of workspace.settings.tooling.mcpServers) {
if (server.transport !== 'local' && server.url === serverUrl) {
targets.push(server);
}
}
const allDiscovered = [
...(workspace.settings.discoveredUserTooling?.mcpServers ?? []),
...workspace.projects.flatMap((project) => project.discoveredTooling?.mcpServers ?? []),
];
for (const server of allDiscovered) {
if (server.status === 'accepted' && server.transport !== 'local' && server.url === serverUrl) {
targets.push(this.discoveredServerToDefinition(server));
}
}
await this.probeWorkspaceMcpServers(workspace, targets);
}
listAcceptedDiscoveredServerDefinitions(
workspace: WorkspaceState,
predicate?: (server: DiscoveredMcpServer) => boolean,
): McpServerDefinition[] {
const definitions: McpServerDefinition[] = [];
for (const state of this.listDiscoveredToolingStates(workspace)) {
for (const server of listAcceptedDiscoveredMcpServers(state)) {
if (predicate && !predicate(server)) {
continue;
}
definitions.push(this.discoveredServerToDefinition(server));
}
}
return definitions;
}
listDiscoveredToolingStates(workspace: WorkspaceState): Array<DiscoveredToolingState | undefined> {
return [
workspace.settings.discoveredUserTooling,
...workspace.projects.map((project) => project.discoveredTooling),
];
}
addMcpProbingServerIds(workspace: WorkspaceState, serverIds: ReadonlyArray<string>): boolean {
return this.updateMcpProbingServerIds(workspace, serverIds, 'add');
}
removeMcpProbingServerIds(workspace: WorkspaceState, serverIds: ReadonlyArray<string>): boolean {
return this.updateMcpProbingServerIds(workspace, serverIds, 'remove');
}
updateMcpProbingServerIds(
workspace: WorkspaceState,
serverIds: ReadonlyArray<string>,
operation: 'add' | 'remove',
): boolean {
const next = new Set(workspace.mcpProbingServerIds ?? []);
const before = next.size;
for (const serverId of serverIds) {
if (operation === 'add') {
next.add(serverId);
} else {
next.delete(serverId);
}
}
if (next.size === before) {
return false;
}
if (next.size === 0) {
delete workspace.mcpProbingServerIds;
} else {
workspace.mcpProbingServerIds = [...next];
}
return true;
}
applyMcpProbeResult(workspace: WorkspaceState, result: McpProbeResult): boolean {
if (result.status !== 'success' || result.tools.length === 0) {
return false;
}
let changed = false;
for (const server of workspace.settings.tooling.mcpServers) {
if (server.id !== result.serverId) {
continue;
}
server.probedTools = result.tools;
changed = true;
}
for (const state of this.listDiscoveredToolingStates(workspace)) {
for (const server of state?.mcpServers ?? []) {
if (server.id !== result.serverId) {
continue;
}
server.probedTools = result.tools;
changed = true;
}
}
return changed;
}
discoveredServerToDefinition(server: DiscoveredMcpServer): McpServerDefinition {
if (server.transport === 'local') {
return {
id: server.id,
name: server.name,
transport: 'local',
command: server.command,
args: [...server.args],
cwd: server.cwd,
env: server.env ? { ...server.env } : undefined,
tools: [...server.tools],
timeoutMs: server.timeoutMs,
createdAt: nowIso(),
updatedAt: nowIso(),
};
}
return {
id: server.id,
name: server.name,
transport: server.transport,
url: server.url,
headers: server.headers ? { ...server.headers } : undefined,
tools: [...server.tools],
timeoutMs: server.timeoutMs,
createdAt: nowIso(),
updatedAt: nowIso(),
};
}
private async enqueueMcpProbeUpdate(update: () => Promise<void>): Promise<void> {
const next = this.mcpProbeUpdateQueue.then(update, update);
this.mcpProbeUpdateQueue = next.catch(() => undefined);
await next;
}
}
@@ -169,7 +169,14 @@ async function collectExistingDirectories(rootPath: string): Promise<string[]> {
}
function createProjectWatchHandle(directoryPath: string, onChange: () => void): ProjectWatchHandle {
return watch(directoryPath, { persistent: false }, () => {
const watcher = watch(directoryPath, { persistent: false }, () => {
onChange();
});
watcher.on('error', (error) => {
console.warn(`[aryx customization] Watcher error for ${directoryPath}:`, error);
watcher.close();
});
return watcher;
}
+954
View File
@@ -0,0 +1,954 @@
import type {
AgentActivityEvent,
ApprovalRequestedEvent,
ExitPlanModeRequestedEvent,
InteractionMode,
McpOauthRequiredEvent,
MessageMode,
MessageReclassifiedEvent,
RunTurnCommand,
RunTurnCustomAgentConfig,
RunTurnToolingConfig,
TurnDeltaEvent,
UserInputRequestedEvent,
WorkflowCheckpointResume,
} from '@shared/contracts/sidecar';
import {
buildAvailableModelCatalog,
findModelByReference,
normalizeWorkflowModels,
resolveReasoningEffort,
} from '@shared/domain/models';
import {
approvalPolicyRequiresCheckpoint,
type ApprovalDecision,
type PendingApprovalMessageRecord,
type PendingApprovalRecord,
} from '@shared/domain/approval';
import {
listEnabledProjectAgentProfiles,
normalizeProjectPromptInvocation,
type ProjectAgentProfile,
type ProjectPromptInvocation,
type ProjectCustomizationState,
} from '@shared/domain/projectCustomization';
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
import {
applySessionApprovalSettings,
applySessionModelConfig,
resolveSessionTitle,
type ChatMessageRecord,
type SessionRecord,
} from '@shared/domain/session';
import {
appendRunActivityEvent,
cancelSessionRunRecord,
completeSessionRunRecord,
createSessionRunRecord,
failSessionRunRecord,
upsertRunMessageEvent,
type SessionRunRecord,
} from '@shared/domain/runTimeline';
import type { ChatMessageAttachment } from '@shared/domain/attachment';
import type { SessionEventRecord } from '@shared/domain/event';
import type { WorkspaceState } from '@shared/domain/workspace';
import {
resolveWorkflowAgentNodes,
type ReasoningEffort,
type WorkflowDefinition,
} from '@shared/domain/workflow';
import {
resolveWorkflowAgents as resolveWorkspaceWorkflowAgents,
type WorkspaceAgentDefinition,
} from '@shared/domain/workspaceAgent';
import { nowIso } from '@shared/utils/ids';
import { mergeStreamingText } from '@shared/utils/streamingText';
import type { TurnScopedEvent } from '@main/sidecar/runTurnPending';
import { TurnCancelledError } from '@main/sidecar/turnCancelledError';
function isPlanPromptInvocation(promptInvocation?: ProjectPromptInvocation): boolean {
return promptInvocation?.agent?.trim().toLowerCase() === 'plan';
}
type SessionTurnExecutorDeps = {
saveWorkspace: (workspace: WorkspaceState) => Promise<void>;
persistWorkspace: (workspace: WorkspaceState) => Promise<WorkspaceState>;
requireSession: (workspace: WorkspaceState, sessionId: string) => SessionRecord;
resolveSessionWorkflow: (workspace: WorkspaceState, session: SessionRecord) => WorkflowDefinition;
updateSessionRun: (
session: SessionRecord,
requestId: string,
updater: (run: SessionRunRecord) => SessionRunRecord,
) => SessionRunRecord | undefined;
emitRunUpdated: (sessionId: string, occurredAt: string, run: SessionRunRecord) => void;
emitSessionEvent: (event: SessionEventRecord) => void;
rejectPendingApprovals: (session: SessionRecord, failedAt: string, error: string) => string[];
buildRunTurnToolingConfig: (
workspace: WorkspaceState,
session: SessionRecord,
) => RunTurnToolingConfig | undefined;
runSidecarTurnWithCheckpointRecovery: (
workspace: WorkspaceState,
session: SessionRecord,
requestId: string,
createCommand: (resumeFromCheckpoint?: WorkflowCheckpointResume) => RunTurnCommand,
onDelta: (event: TurnDeltaEvent) => void | Promise<void>,
onActivity: (event: AgentActivityEvent) => void | Promise<void>,
onApproval: (event: ApprovalRequestedEvent) => void | Promise<void>,
onUserInput: (event: UserInputRequestedEvent) => void | Promise<void>,
onMcpOAuthRequired: (event: McpOauthRequiredEvent) => void | Promise<void>,
onExitPlanMode: (event: ExitPlanModeRequestedEvent) => void | Promise<void>,
onMessageReclassified: (event: MessageReclassifiedEvent) => void | Promise<void>,
onTurnScopedEvent: (event: TurnScopedEvent) => void | Promise<void>,
) => Promise<ChatMessageRecord[]>;
handleApprovalRequested: (
workspace: WorkspaceState,
sessionId: string,
requestId: string,
approval: ApprovalRequestedEvent | PendingApprovalRecord,
resolve: (decision: ApprovalDecision, alwaysApprove?: boolean) => void | Promise<void>,
) => Promise<void>;
handleUserInputRequested: (
workspace: WorkspaceState,
sessionId: string,
requestId: string,
event: UserInputRequestedEvent,
resolve: (answer: string, wasFreeform: boolean) => void | Promise<void>,
) => Promise<void>;
handleMcpOAuthRequired: (
workspace: WorkspaceState,
sessionId: string,
event: McpOauthRequiredEvent,
) => Promise<void>;
handleExitPlanModeRequested: (
workspace: WorkspaceState,
sessionId: string,
event: ExitPlanModeRequestedEvent,
) => Promise<void>;
handleTurnScopedEvent: (
workspace: WorkspaceState,
sessionId: string,
event: TurnScopedEvent,
) => void | Promise<void>;
sidecarResolveApproval: (
approvalId: string,
decision: ApprovalDecision,
alwaysApprove?: boolean,
) => Promise<void>;
sidecarResolveUserInput: (
userInputId: string,
answer: string,
wasFreeform: boolean,
) => Promise<void>;
captureWorkingTreeSnapshot: (
projectPath: string,
scannedAt: string,
) => Promise<import('@shared/domain/project').ProjectGitWorkingTreeSnapshot | undefined>;
captureWorkingTreeBaseline: (
projectPath: string,
snapshot: import('@shared/domain/project').ProjectGitWorkingTreeSnapshot,
) => Promise<import('@shared/domain/project').ProjectGitBaselineFile[]>;
refreshSessionRunGitSummary: (
session: SessionRecord,
project: ProjectRecord,
requestId: string,
occurredAt: string,
) => Promise<SessionRunRecord | undefined>;
cleanupWorkflowCheckpointRecovery: (requestId: string) => Promise<void>;
scheduleProjectGitRefresh: (projectId: string) => void;
loadAvailableModelCatalog: () => Promise<ReturnType<typeof buildAvailableModelCatalog>>;
};
export class SessionTurnExecutor {
private readonly saveWorkspace: SessionTurnExecutorDeps['saveWorkspace'];
private readonly persistWorkspace: SessionTurnExecutorDeps['persistWorkspace'];
private readonly requireSession: SessionTurnExecutorDeps['requireSession'];
private readonly resolveSessionWorkflow: SessionTurnExecutorDeps['resolveSessionWorkflow'];
private readonly updateSessionRun: SessionTurnExecutorDeps['updateSessionRun'];
private readonly emitRunUpdated: SessionTurnExecutorDeps['emitRunUpdated'];
private readonly emitSessionEvent: SessionTurnExecutorDeps['emitSessionEvent'];
private readonly rejectPendingApprovals: SessionTurnExecutorDeps['rejectPendingApprovals'];
private readonly buildRunTurnToolingConfig: SessionTurnExecutorDeps['buildRunTurnToolingConfig'];
private readonly runSidecarTurnWithCheckpointRecovery: SessionTurnExecutorDeps['runSidecarTurnWithCheckpointRecovery'];
private readonly handleApprovalRequested: SessionTurnExecutorDeps['handleApprovalRequested'];
private readonly handleUserInputRequested: SessionTurnExecutorDeps['handleUserInputRequested'];
private readonly handleMcpOAuthRequired: SessionTurnExecutorDeps['handleMcpOAuthRequired'];
private readonly handleExitPlanModeRequested: SessionTurnExecutorDeps['handleExitPlanModeRequested'];
private readonly handleTurnScopedEvent: SessionTurnExecutorDeps['handleTurnScopedEvent'];
private readonly sidecarResolveApproval: SessionTurnExecutorDeps['sidecarResolveApproval'];
private readonly sidecarResolveUserInput: SessionTurnExecutorDeps['sidecarResolveUserInput'];
private readonly captureWorkingTreeSnapshot: SessionTurnExecutorDeps['captureWorkingTreeSnapshot'];
private readonly captureWorkingTreeBaseline: SessionTurnExecutorDeps['captureWorkingTreeBaseline'];
private readonly refreshSessionRunGitSummary: SessionTurnExecutorDeps['refreshSessionRunGitSummary'];
private readonly cleanupWorkflowCheckpointRecovery: SessionTurnExecutorDeps['cleanupWorkflowCheckpointRecovery'];
private readonly scheduleProjectGitRefresh: SessionTurnExecutorDeps['scheduleProjectGitRefresh'];
private readonly loadAvailableModelCatalog: SessionTurnExecutorDeps['loadAvailableModelCatalog'];
constructor(deps: SessionTurnExecutorDeps) {
this.saveWorkspace = deps.saveWorkspace;
this.persistWorkspace = deps.persistWorkspace;
this.requireSession = deps.requireSession;
this.resolveSessionWorkflow = deps.resolveSessionWorkflow;
this.updateSessionRun = deps.updateSessionRun;
this.emitRunUpdated = deps.emitRunUpdated;
this.emitSessionEvent = deps.emitSessionEvent;
this.rejectPendingApprovals = deps.rejectPendingApprovals;
this.buildRunTurnToolingConfig = deps.buildRunTurnToolingConfig;
this.runSidecarTurnWithCheckpointRecovery = deps.runSidecarTurnWithCheckpointRecovery;
this.handleApprovalRequested = deps.handleApprovalRequested;
this.handleUserInputRequested = deps.handleUserInputRequested;
this.handleMcpOAuthRequired = deps.handleMcpOAuthRequired;
this.handleExitPlanModeRequested = deps.handleExitPlanModeRequested;
this.handleTurnScopedEvent = deps.handleTurnScopedEvent;
this.sidecarResolveApproval = deps.sidecarResolveApproval;
this.sidecarResolveUserInput = deps.sidecarResolveUserInput;
this.captureWorkingTreeSnapshot = deps.captureWorkingTreeSnapshot;
this.captureWorkingTreeBaseline = deps.captureWorkingTreeBaseline;
this.refreshSessionRunGitSummary = deps.refreshSessionRunGitSummary;
this.cleanupWorkflowCheckpointRecovery = deps.cleanupWorkflowCheckpointRecovery;
this.scheduleProjectGitRefresh = deps.scheduleProjectGitRefresh;
this.loadAvailableModelCatalog = deps.loadAvailableModelCatalog;
}
async runPreparedSessionTurn(
workspace: WorkspaceState,
session: SessionRecord,
project: ProjectRecord,
effectiveWorkflow: WorkflowDefinition,
projectInstructions: string | undefined,
options: {
occurredAt: string;
requestId: string;
triggerMessageId: string;
messageMode?: MessageMode;
attachments?: ChatMessageAttachment[];
},
): Promise<void> {
const workspaceKind = isScratchpadProject(project) ? 'scratchpad' : 'project';
const { occurredAt, requestId, triggerMessageId, messageMode, attachments } = options;
const promptInvocation = this.resolveRunTurnPromptInvocation(session, triggerMessageId);
const workflowForTurn = await this.applyPromptInvocationToWorkflow(effectiveWorkflow, promptInvocation);
const interactionMode: InteractionMode = isPlanPromptInvocation(promptInvocation)
? 'plan'
: session.interactionMode ?? 'interactive';
const runWorkingDirectory = session.cwd ?? project.path;
const preRunGitSnapshot = workspaceKind === 'project'
? await this.captureWorkingTreeSnapshot(runWorkingDirectory, occurredAt)
: undefined;
const preRunGitBaselineFiles = workspaceKind === 'project' && preRunGitSnapshot
? await this.captureWorkingTreeBaseline(runWorkingDirectory, preRunGitSnapshot)
: undefined;
if (workspaceKind === 'project' && project.git?.status === 'ready' && !preRunGitSnapshot) {
console.warn(`[aryx git] Failed to capture pre-run git snapshot for project "${project.id}".`);
}
session.title = resolveSessionTitle(session, workflowForTurn, session.messages);
session.status = 'running';
session.lastError = undefined;
session.pendingPlanReview = undefined;
session.pendingMcpAuth = undefined;
session.updatedAt = occurredAt;
session.runs = [
createSessionRunRecord({
requestId,
project,
workingDirectory: runWorkingDirectory,
workspaceKind,
workflow: workflowForTurn,
triggerMessageId,
startedAt: occurredAt,
preRunGitSnapshot,
preRunGitBaselineFiles,
}),
...session.runs,
];
await this.persistWorkspace(workspace);
this.emitSessionEvent({
sessionId: session.id,
kind: 'status',
status: 'running',
occurredAt,
});
try {
const createRunTurnCommand = (
resumeFromCheckpoint?: WorkflowCheckpointResume,
): RunTurnCommand => ({
type: 'run-turn',
requestId,
sessionId: session.id,
projectPath: runWorkingDirectory,
workspaceKind,
mode: interactionMode,
messageMode,
projectInstructions,
workflow: workflowForTurn,
workflowLibrary: workspace.workflows,
messages: session.messages,
attachments: attachments?.length ? attachments : undefined,
promptInvocation,
tooling: this.buildRunTurnToolingConfig(workspace, session),
resumeFromCheckpoint,
});
const responseMessages = await this.runSidecarTurnWithCheckpointRecovery(
workspace,
session,
requestId,
createRunTurnCommand,
async (event) => {
await this.applyTurnDelta(workspace, session.id, requestId, event);
},
async (event) => {
await this.applyAgentActivity(workspace, session.id, requestId, event);
},
async (event) => {
await this.handleApprovalRequested(workspace, session.id, requestId, event, (decision, alwaysApprove) =>
this.sidecarResolveApproval(event.approvalId, decision, alwaysApprove));
},
async (event) => {
await this.handleUserInputRequested(workspace, session.id, requestId, event, (answer, wasFreeform) =>
this.sidecarResolveUserInput(event.userInputId, answer, wasFreeform));
},
async (event) => {
await this.handleMcpOAuthRequired(workspace, session.id, event);
},
async (event) => {
await this.handleExitPlanModeRequested(workspace, session.id, event);
},
async (event) => {
await this.applyMessageReclassified(workspace, session.id, event);
},
async (event) => {
await this.handleTurnScopedEvent(workspace, session.id, event);
},
);
await this.awaitFinalResponseApproval(workspace, session.id, requestId, workflowForTurn, responseMessages);
this.finalizeTurn(workspace, session.id, requestId, responseMessages);
if (workspaceKind === 'project') {
const completedRun = await this.refreshSessionRunGitSummary(session, project, requestId, nowIso());
if (completedRun) {
this.emitRunUpdated(session.id, nowIso(), completedRun);
}
}
await this.persistWorkspace(workspace);
await this.cleanupWorkflowCheckpointRecovery(requestId);
if (workspaceKind === 'project') {
this.scheduleProjectGitRefresh(project.id);
}
} catch (error) {
if (error instanceof TurnCancelledError) {
this.finalizeCancelledTurn(session, requestId);
if (workspaceKind === 'project') {
const cancelledRun = await this.refreshSessionRunGitSummary(session, project, requestId, nowIso());
if (cancelledRun) {
this.emitRunUpdated(session.id, nowIso(), cancelledRun);
}
}
await this.persistWorkspace(workspace);
await this.cleanupWorkflowCheckpointRecovery(requestId);
if (workspaceKind === 'project') {
this.scheduleProjectGitRefresh(project.id);
}
return;
}
const failedAt = nowIso();
session.status = 'error';
session.lastError = error instanceof Error ? error.message : String(error);
session.updatedAt = failedAt;
const failedRun = this.updateSessionRun(session, requestId, (run) =>
failSessionRunRecord(run, failedAt, session.lastError ?? 'Unknown error.'));
this.emitSessionEvent({
sessionId: session.id,
kind: 'error',
occurredAt: failedAt,
error: session.lastError,
});
if (failedRun) {
this.emitRunUpdated(session.id, failedAt, failedRun);
}
if (workspaceKind === 'project') {
const summarizedRun = await this.refreshSessionRunGitSummary(session, project, requestId, failedAt);
if (summarizedRun) {
this.emitRunUpdated(session.id, failedAt, summarizedRun);
}
}
await this.persistWorkspace(workspace);
await this.cleanupWorkflowCheckpointRecovery(requestId);
if (workspaceKind === 'project') {
this.scheduleProjectGitRefresh(project.id);
}
}
}
async buildEffectiveWorkflow(
workflow: WorkflowDefinition,
session: SessionRecord,
workspaceAgents: ReadonlyArray<WorkspaceAgentDefinition>,
): Promise<WorkflowDefinition> {
const resolvedWorkflow = resolveWorkspaceWorkflowAgents(workflow, workspaceAgents);
const workflowWithSessionConfig = session.sessionModelConfig
? applySessionModelConfig(resolvedWorkflow, session)
: resolvedWorkflow;
const workflowWithApprovalSettings = applySessionApprovalSettings(workflowWithSessionConfig, session);
const modelCatalog = await this.loadAvailableModelCatalog();
return normalizeWorkflowModels(workflowWithApprovalSettings, modelCatalog);
}
applyProjectCustomizationToWorkflow(
workflow: WorkflowDefinition,
project: ProjectRecord,
): WorkflowDefinition {
if (isScratchpadProject(project)) {
return workflow;
}
const projectCustomAgents = this.buildProjectCustomAgents(project.customization);
if (projectCustomAgents.length === 0) {
return workflow;
}
const primaryAgentNode = resolveWorkflowAgentNodes(workflow)[0];
if (!primaryAgentNode || primaryAgentNode.config.kind !== 'agent') {
return workflow;
}
const existingCustomAgents = primaryAgentNode.config.copilot?.customAgents ?? [];
const existingAgentNames = new Set(existingCustomAgents.map((agent) => agent.name.toLowerCase()));
const mergedCustomAgents = [
...existingCustomAgents,
...projectCustomAgents.filter((agent) => !existingAgentNames.has(agent.name.toLowerCase())),
];
return {
...workflow,
graph: {
...workflow.graph,
nodes: workflow.graph.nodes.map((node) => {
if (node.id !== primaryAgentNode.id || node.kind !== 'agent' || node.config.kind !== 'agent') {
return node;
}
return {
...node,
config: {
...node.config,
copilot: {
...node.config.copilot,
customAgents: mergedCustomAgents,
},
},
};
}),
},
};
}
resolveRunTurnPromptInvocation(
session: SessionRecord,
triggerMessageId: string,
): ProjectPromptInvocation | undefined {
const triggerMessage = session.messages.find((message) => message.id === triggerMessageId);
return normalizeProjectPromptInvocation(triggerMessage?.promptInvocation);
}
async applyPromptInvocationToWorkflow(
workflow: WorkflowDefinition,
promptInvocation?: ProjectPromptInvocation,
): Promise<WorkflowDefinition> {
const requestedModel = promptInvocation?.model?.trim();
if (!requestedModel) {
return workflow;
}
const modelCatalog = await this.loadAvailableModelCatalog();
const resolvedModel = findModelByReference(requestedModel, modelCatalog);
const effectiveModelId = resolvedModel?.id ?? requestedModel;
let didChange = false;
const nodes = workflow.graph.nodes.map((node) => {
if (node.kind !== 'agent' || node.config.kind !== 'agent') {
return node;
}
const agent = node.config;
const reasoningEffort: ReasoningEffort | undefined = resolvedModel?.supportedReasoningEfforts
? resolveReasoningEffort(resolvedModel, agent.reasoningEffort)
: undefined;
if (agent.model === effectiveModelId && agent.reasoningEffort === reasoningEffort) {
return node;
}
didChange = true;
return {
...node,
config: {
...agent,
model: effectiveModelId,
reasoningEffort,
},
};
});
return didChange
? {
...workflow,
graph: {
...workflow.graph,
nodes,
},
}
: workflow;
}
buildProjectCustomAgents(
customization?: ProjectCustomizationState,
): RunTurnCustomAgentConfig[] {
return listEnabledProjectAgentProfiles(customization).map((profile) => this.mapProjectAgentProfile(profile));
}
mapProjectAgentProfile(profile: ProjectAgentProfile): RunTurnCustomAgentConfig {
const customAgent: RunTurnCustomAgentConfig = {
name: profile.name,
prompt: profile.prompt,
};
if (profile.displayName) {
customAgent.displayName = profile.displayName;
}
if (profile.description) {
customAgent.description = profile.description;
}
if (profile.tools) {
customAgent.tools = profile.tools;
}
if (profile.infer !== undefined) {
customAgent.infer = profile.infer;
}
return customAgent;
}
private async applyTurnDelta(
workspace: WorkspaceState,
sessionId: string,
requestId: string,
event: TurnDeltaEvent,
): Promise<void> {
if (event.content === undefined && event.contentDelta === undefined) {
return;
}
const occurredAt = nowIso();
const session = this.requireSession(workspace, sessionId);
const existing = session.messages.find((message) => message.id === event.messageId);
const content =
existing && event.content === undefined
? mergeStreamingText(existing.content, event.contentDelta)
: (event.content ?? event.contentDelta);
const completedMessages: ChatMessageRecord[] = [];
if (existing) {
existing.content = content;
existing.pending = true;
existing.authorName = event.authorName;
} else {
for (const message of session.messages) {
if (message.pending && message.role === 'assistant') {
message.pending = false;
completedMessages.push(message);
}
}
session.messages.push({
id: event.messageId,
role: 'assistant',
authorName: event.authorName,
content,
createdAt: occurredAt,
pending: true,
});
}
const nextRun = this.updateSessionRun(session, requestId, (run) =>
upsertRunMessageEvent(run, {
messageId: event.messageId,
occurredAt,
authorName: event.authorName,
content,
status: 'running',
}));
session.updatedAt = occurredAt;
await this.saveWorkspace(workspace);
for (const completed of completedMessages) {
this.emitSessionEvent({
sessionId,
kind: 'message-complete',
occurredAt,
messageId: completed.id,
authorName: completed.authorName,
content: completed.content,
});
}
this.emitSessionEvent({
sessionId,
kind: 'message-delta',
occurredAt,
messageId: event.messageId,
authorName: event.authorName,
contentDelta: event.contentDelta,
content: 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.saveWorkspace(workspace);
this.emitSessionEvent({
sessionId,
kind: 'message-reclassified',
occurredAt,
messageId: event.messageId,
messageKind: 'thinking',
});
}
private async applyAgentActivity(
workspace: WorkspaceState,
sessionId: string,
requestId: string,
event: AgentActivityEvent,
): Promise<void> {
const occurredAt = nowIso();
const session = this.requireSession(workspace, sessionId);
const activityType = event.activityType;
let nextRun: SessionRunRecord | undefined;
if (activityType === 'thinking' || activityType === 'tool-calling' || activityType === 'handoff') {
nextRun = this.updateSessionRun(session, requestId, (run) =>
appendRunActivityEvent(run, {
activityType,
occurredAt,
agentId: event.agentId,
agentName: event.agentName,
sourceAgentId: event.sourceAgentId,
sourceAgentName: event.sourceAgentName,
toolName: event.toolName,
toolCallId: event.toolCallId,
toolArguments: event.toolArguments,
fileChanges: event.fileChanges,
}));
}
if (nextRun) {
session.updatedAt = occurredAt;
await this.saveWorkspace(workspace);
this.emitRunUpdated(sessionId, occurredAt, nextRun);
}
this.emitSessionEvent({
sessionId,
kind: 'agent-activity',
occurredAt,
activityType: event.activityType,
agentId: event.agentId,
agentName: event.agentName,
subworkflowNodeId: event.subworkflowNodeId,
subworkflowName: event.subworkflowName,
sourceAgentId: event.sourceAgentId,
sourceAgentName: event.sourceAgentName,
toolName: event.toolName,
toolCallId: event.toolCallId,
toolArguments: event.toolArguments,
fileChanges: event.fileChanges,
});
}
private emitCompletedActivity(
sessionId: string,
workflow: WorkflowDefinition,
message: ChatMessageRecord,
): void {
if (message.role !== 'assistant') {
return;
}
const agentNode = resolveWorkflowAgentNodes(workflow)
.find((candidate) =>
candidate.config.kind === 'agent'
&& (candidate.config.id === message.authorName || candidate.config.name === message.authorName))
;
const agent = agentNode?.config.kind === 'agent' ? agentNode.config : undefined;
if (!agent) {
return;
}
this.emitSessionEvent({
sessionId,
kind: 'agent-activity',
occurredAt: nowIso(),
activityType: 'completed',
agentId: agent.id,
agentName: agent.name,
});
}
private finalizeTurn(
workspace: WorkspaceState,
sessionId: string,
requestId: string,
messages: ChatMessageRecord[],
): void {
const session = this.requireSession(workspace, sessionId);
const workflow = this.resolveSessionWorkflow(workspace, session);
const incomingIds = new Set(messages.map((message) => message.id));
const existingIds = new Set(session.messages.map((message) => message.id));
const hasVisibleResponse = session.messages.some(
(message) => message.role === 'assistant' && message.messageKind !== 'thinking',
);
for (const message of messages) {
const occurredAt = nowIso();
const existing = session.messages.find((current) => current.id === message.id);
if (existing) {
existing.authorName = message.authorName;
existing.content = message.content;
existing.pending = false;
} else {
const isUnstreamedIntermediate =
message.role === 'assistant'
&& hasVisibleResponse
&& !message.messageKind;
session.messages.push({
...message,
pending: false,
messageKind: message.messageKind ?? (isUnstreamedIntermediate ? 'thinking' : undefined),
});
}
const reclassifiedAsThinking =
!existingIds.has(message.id)
&& (message.messageKind === 'thinking'
|| (message.role === 'assistant' && hasVisibleResponse && !message.messageKind));
const nextRun = this.updateSessionRun(session, requestId, (run) =>
upsertRunMessageEvent(run, {
messageId: message.id,
occurredAt,
authorName: message.authorName,
content: message.content,
status: 'completed',
}));
this.emitSessionEvent({
sessionId,
kind: 'message-complete',
occurredAt,
messageId: message.id,
authorName: message.authorName,
content: message.content,
});
if (reclassifiedAsThinking) {
this.emitSessionEvent({
sessionId,
kind: 'message-reclassified',
occurredAt,
messageId: message.id,
messageKind: 'thinking',
});
}
if (nextRun) {
this.emitRunUpdated(sessionId, occurredAt, nextRun);
}
this.emitCompletedActivity(sessionId, workflow, message);
}
for (const message of session.messages) {
if (message.pending && incomingIds.has(message.id)) {
message.pending = false;
}
}
const completedAt = nowIso();
session.status = 'idle';
session.lastError = undefined;
session.pendingUserInput = undefined;
session.pendingPlanReview = undefined;
session.pendingMcpAuth = undefined;
session.updatedAt = completedAt;
const completedRun = this.updateSessionRun(session, requestId, (run) =>
completeSessionRunRecord(run, completedAt));
this.emitSessionEvent({
sessionId,
kind: 'status',
occurredAt: completedAt,
status: 'idle',
});
if (completedRun) {
this.emitRunUpdated(sessionId, completedAt, completedRun);
}
}
private finalizeCancelledTurn(
session: SessionRecord,
requestId: string,
): void {
for (const message of session.messages) {
if (message.pending) {
message.pending = false;
}
}
this.rejectPendingApprovals(session, nowIso(), 'The turn was cancelled.');
const cancelledAt = nowIso();
session.status = 'idle';
session.lastError = undefined;
session.pendingUserInput = undefined;
session.pendingPlanReview = undefined;
session.pendingMcpAuth = undefined;
session.updatedAt = cancelledAt;
const cancelledRun = this.updateSessionRun(session, requestId, (run) =>
cancelSessionRunRecord(run, cancelledAt));
this.emitSessionEvent({
sessionId: session.id,
kind: 'status',
occurredAt: cancelledAt,
status: 'idle',
});
if (cancelledRun) {
this.emitRunUpdated(session.id, cancelledAt, cancelledRun);
}
}
private async awaitFinalResponseApproval(
workspace: WorkspaceState,
sessionId: string,
requestId: string,
workflow: WorkflowDefinition,
messages: ChatMessageRecord[],
): Promise<void> {
const pendingApproval = this.buildFinalResponseApproval(workflow, messages);
if (!pendingApproval) {
return;
}
let resolveDecision: ((decision: ApprovalDecision) => void) | undefined;
const decisionPromise = new Promise<ApprovalDecision>((resolve) => {
resolveDecision = resolve;
});
await this.handleApprovalRequested(
workspace,
sessionId,
requestId,
pendingApproval,
(decision) => {
resolveDecision?.(decision);
},
);
const decision = await decisionPromise;
if (decision === 'rejected') {
throw new Error('Final response approval was rejected.');
}
}
private buildFinalResponseApproval(
workflow: WorkflowDefinition,
messages: ChatMessageRecord[],
): PendingApprovalRecord | undefined {
const assistantMessages = messages.filter((message) => message.role === 'assistant');
if (assistantMessages.length === 0) {
return undefined;
}
const previewMessages: PendingApprovalMessageRecord[] = assistantMessages.map((message) => ({
id: message.id,
authorName: message.authorName,
content: message.content,
}));
for (let index = assistantMessages.length - 1; index >= 0; index -= 1) {
const message = assistantMessages[index];
if (!message) {
continue;
}
const agentNode = resolveWorkflowAgentNodes(workflow)
.find((candidate) =>
candidate.config.kind === 'agent'
&& (candidate.config.id === message.authorName || candidate.config.name === message.authorName))
;
const agent = agentNode?.config.kind === 'agent' ? agentNode.config : undefined;
if (!approvalPolicyRequiresCheckpoint(workflow.settings.approvalPolicy, 'final-response', agent?.id)) {
continue;
}
const agentName = agent?.name ?? message.authorName;
return {
id: `approval-${crypto.randomUUID()}`,
kind: 'final-response',
status: 'pending',
requestedAt: nowIso(),
agentId: agent?.id,
agentName,
title: agentName ? `Approve final response from ${agentName}` : 'Approve final response',
detail: 'Review the pending assistant response before it is added to the session transcript.',
messages: previewMessages,
};
}
return undefined;
}
}
+299
View File
@@ -0,0 +1,299 @@
import {
buildWorkflowExecutionDefinition,
normalizeWorkflowDefinition,
resolveWorkflowAgentNodes,
validateWorkflowDefinition,
type WorkflowDefinition,
type WorkflowReference,
} from '@shared/domain/workflow';
import {
exportWorkflowDefinition,
importWorkflowDefinition,
type WorkflowExportFormat,
type WorkflowExportResult,
} from '@shared/domain/workflowSerialization';
import {
applyWorkflowTemplate,
createWorkflowTemplateFromWorkflow,
normalizeWorkflowTemplateDefinition,
type WorkflowTemplateCategory,
type WorkflowTemplateDefinition,
} from '@shared/domain/workflowTemplate';
import { applyDefaultToolApprovalPolicy } from '@shared/domain/approval';
import type { SessionRecord } from '@shared/domain/session';
import type { WorkspaceState } from '@shared/domain/workspace';
import { createId, nowIso } from '@shared/utils/ids';
export class WorkflowManager {
saveWorkflow(workspace: WorkspaceState, workflow: WorkflowDefinition): WorkspaceState {
const normalizedWorkflow = normalizeWorkflowDefinition(workflow);
const issues = validateWorkflowDefinition(normalizedWorkflow).filter((issue) => issue.level === 'error');
if (issues.length > 0) {
throw new Error(issues[0].message);
}
const existingIndex = workspace.workflows.findIndex((current) => current.id === workflow.id);
const candidate: WorkflowDefinition = {
...normalizedWorkflow,
isFavorite: workflow.isFavorite ?? workspace.workflows[existingIndex]?.isFavorite,
createdAt: existingIndex >= 0 ? workspace.workflows[existingIndex].createdAt : nowIso(),
updatedAt: nowIso(),
};
this.validateWorkflowReferences(workspace, candidate);
if (existingIndex >= 0) {
workspace.workflows[existingIndex] = candidate;
} else {
workspace.workflows.push(candidate);
}
workspace.selectedWorkflowId = candidate.id;
return workspace;
}
saveWorkflowTemplate(
workspace: WorkspaceState,
workflowId: string,
options?: {
templateId?: string;
name?: string;
description?: string;
category?: WorkflowTemplateCategory;
},
): WorkspaceState {
const workflow = this.requireWorkflow(workspace, workflowId);
const candidate = createWorkflowTemplateFromWorkflow(workflow, options);
const existingIndex = workspace.workflowTemplates.findIndex((template) => template.id === candidate.id);
const existingTemplate = existingIndex >= 0 ? workspace.workflowTemplates[existingIndex] : undefined;
if (existingTemplate?.source === 'builtin') {
throw new Error(`Workflow template "${candidate.id}" is reserved by a built-in template.`);
}
const normalizedCandidate: WorkflowTemplateDefinition = normalizeWorkflowTemplateDefinition({
...candidate,
createdAt: existingTemplate?.createdAt ?? candidate.createdAt,
updatedAt: nowIso(),
});
if (existingIndex >= 0) {
workspace.workflowTemplates[existingIndex] = normalizedCandidate;
} else {
workspace.workflowTemplates.push(normalizedCandidate);
}
return workspace;
}
createWorkflowFromTemplate(
workspace: WorkspaceState,
templateId: string,
options?: {
workflowId?: string;
name?: string;
description?: string;
},
): WorkspaceState {
const template = this.requireWorkflowTemplate(workspace, templateId);
const workflowId = options?.workflowId?.trim()
|| this.createUniqueWorkflowId(workspace, template.workflow.id);
const workflow = applyWorkflowTemplate(template, {
...options,
workflowId,
});
return this.saveWorkflow(workspace, workflow);
}
deleteWorkflow(workspace: WorkspaceState, workflowId: string): WorkspaceState {
const workflow = this.requireWorkflow(workspace, workflowId);
const references = this.listWorkflowReferencesInWorkspace(workspace, workflowId)
.filter((reference) => reference.referencingWorkflowId !== workflowId);
if (references.length > 0) {
const blockingReference = references[0];
throw new Error(
`Workflow "${workflow.name}" cannot be deleted because workflow "${blockingReference.referencingWorkflowName}" references it from node "${blockingReference.nodeLabel}".`,
);
}
workspace.workflows = workspace.workflows.filter((candidate) => candidate.id !== workflowId);
if (workspace.selectedWorkflowId === workflowId) {
workspace.selectedWorkflowId = workspace.workflows[0]?.id;
}
return workspace;
}
listWorkflowReferences(workspace: WorkspaceState, workflowId: string): WorkflowReference[] {
this.requireWorkflow(workspace, workflowId);
return this.listWorkflowReferencesInWorkspace(workspace, workflowId);
}
exportWorkflow(workspace: WorkspaceState, workflowId: string, format: WorkflowExportFormat): WorkflowExportResult {
const workflow = this.requireWorkflow(workspace, workflowId);
return exportWorkflowDefinition(workflow, format);
}
importWorkflow(content: string, format: 'yaml' | 'json'): WorkflowDefinition {
return importWorkflowDefinition(content, format);
}
requireWorkflowTemplate(workspace: WorkspaceState, templateId: string): WorkflowTemplateDefinition {
const template = workspace.workflowTemplates.find((current) => current.id === templateId);
if (!template) {
throw new Error(`Workflow template "${templateId}" was not found.`);
}
return template;
}
requireWorkflow(workspace: WorkspaceState, workflowId: string): WorkflowDefinition {
const workflow = workspace.workflows.find((current) => current.id === workflowId);
if (!workflow) {
throw new Error(`Workflow "${workflowId}" was not found.`);
}
return workflow;
}
createUniqueWorkflowId(workspace: WorkspaceState, sourceId: string): string {
const normalizedSourceId = this.normalizeIdentifier(sourceId, 'workflow');
const existingIds = new Set(workspace.workflows.map((workflow) => workflow.id));
if (!existingIds.has(normalizedSourceId)) {
return normalizedSourceId;
}
let suffix = 2;
while (existingIds.has(`${normalizedSourceId}-${suffix}`)) {
suffix += 1;
}
return `${normalizedSourceId}-${suffix}`;
}
normalizeIdentifier(value: string, fallbackPrefix: string): string {
const normalized = value
.trim()
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
return normalized || createId(fallbackPrefix);
}
resolveSessionWorkflow(workspace: WorkspaceState, session: SessionRecord): WorkflowDefinition {
return this.requireWorkflow(workspace, session.workflowId);
}
buildResolvedExecutionWorkflow(workspace: WorkspaceState, workflow: WorkflowDefinition): WorkflowDefinition {
return normalizeWorkflowDefinition({
...workflow,
settings: {
...workflow.settings,
approvalPolicy: applyDefaultToolApprovalPolicy(workflow.settings.approvalPolicy),
},
});
}
createWorkflowResolutionOptions(workspace: WorkspaceState) {
return {
resolveWorkflow: (workflowId: string) => workspace.workflows.find((candidate) => candidate.id === workflowId),
};
}
validateWorkflowReferences(workspace: WorkspaceState, workflow: WorkflowDefinition): void {
const workflowLibrary = new Map<string, WorkflowDefinition>();
for (const candidate of workspace.workflows) {
if (candidate.id !== workflow.id) {
workflowLibrary.set(candidate.id, candidate);
}
}
workflowLibrary.set(workflow.id, workflow);
const visitWorkflow = (
currentWorkflow: WorkflowDefinition,
path: string[],
visitedInlineWorkflows: Set<WorkflowDefinition>,
): void => {
for (const node of currentWorkflow.graph.nodes) {
if (node.kind !== 'sub-workflow' || node.config.kind !== 'sub-workflow') {
continue;
}
const { inlineWorkflow, workflowId } = node.config;
if (workflowId) {
const referencedWorkflow = workflowLibrary.get(workflowId);
if (!referencedWorkflow) {
throw new Error(
`Sub-workflow node "${node.label || node.id}" references unknown workflow "${workflowId}".`,
);
}
if (path.includes(workflowId)) {
throw new Error(
`Saving workflow "${workflow.name}" would create a circular sub-workflow reference: ${[...path, workflowId].join(' -> ')}.`,
);
}
visitWorkflow(referencedWorkflow, [...path, workflowId], visitedInlineWorkflows);
}
if (inlineWorkflow && !visitedInlineWorkflows.has(inlineWorkflow)) {
visitedInlineWorkflows.add(inlineWorkflow);
visitWorkflow(inlineWorkflow, path, visitedInlineWorkflows);
}
}
};
visitWorkflow(workflow, [workflow.id], new Set<WorkflowDefinition>());
}
listWorkflowReferencesInWorkspace(workspace: WorkspaceState, workflowId: string): WorkflowReference[] {
const references: WorkflowReference[] = [];
const visitWorkflow = (
referencingWorkflow: WorkflowDefinition,
currentWorkflow: WorkflowDefinition,
visitedInlineWorkflows: Set<WorkflowDefinition>,
): void => {
for (const node of currentWorkflow.graph.nodes) {
if (node.kind !== 'sub-workflow' || node.config.kind !== 'sub-workflow') {
continue;
}
const { inlineWorkflow, workflowId: referencedWorkflowId } = node.config;
if (referencedWorkflowId === workflowId) {
references.push({
referencingWorkflowId: referencingWorkflow.id,
referencingWorkflowName: referencingWorkflow.name,
nodeId: node.id,
nodeLabel: node.label || node.id,
});
}
if (inlineWorkflow && !visitedInlineWorkflows.has(inlineWorkflow)) {
visitedInlineWorkflows.add(inlineWorkflow);
visitWorkflow(referencingWorkflow, inlineWorkflow, visitedInlineWorkflows);
}
}
};
for (const referencingWorkflow of workspace.workflows) {
visitWorkflow(referencingWorkflow, referencingWorkflow, new Set<WorkflowDefinition>());
}
return references;
}
buildWorkflowExecutionDefinition(workspace: WorkspaceState, workflow: WorkflowDefinition) {
return buildWorkflowExecutionDefinition(
this.buildResolvedExecutionWorkflow(workspace, workflow),
this.createWorkflowResolutionOptions(workspace),
);
}
resolveWorkflowAgentNodes(workspace: WorkspaceState, workflow: WorkflowDefinition) {
void workspace;
return resolveWorkflowAgentNodes(this.buildResolvedExecutionWorkflow(workspace, workflow));
}
}
+5
View File
@@ -14,6 +14,7 @@ export function createMainWindow(): BrowserWindowType {
height: 960,
minWidth: 1120,
minHeight: 720,
show: false,
title: 'aryx',
icon: resolveWindowIconPath({
appPath: app.getAppPath(),
@@ -36,6 +37,10 @@ export function createMainWindow(): BrowserWindowType {
},
});
window.once('ready-to-show', () => {
window.show();
});
const rendererUrl = process.env.ELECTRON_RENDERER_URL;
if (rendererUrl) {
+188 -146
View File
@@ -1,20 +1,7 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { CommitComposer } from '@renderer/components/chat/CommitComposer';
import { AppShell } from '@renderer/components/AppShell';
import { ActivityPanel } from '@renderer/components/ActivityPanel';
import { ChatPane } from '@renderer/components/ChatPane';
import { CommandPalette } from '@renderer/components/CommandPalette';
import { DiscoveredToolingModal } from '@renderer/components/DiscoveredToolingModal';
import { KeyboardShortcutsPanel } from '@renderer/components/KeyboardShortcutsPanel';
import { ProjectSettingsPanel } from '@renderer/components/ProjectSettingsPanel';
import { BookmarksPanel } from '@renderer/components/BookmarksPanel';
import { SessionSearchPanel } from '@renderer/components/SessionSearchPanel';
import { SettingsPanel, type SettingsSection } from '@renderer/components/SettingsPanel';
import { Sidebar } from '@renderer/components/Sidebar';
import { BottomPanel, DEFAULT_HEIGHT as DEFAULT_BOTTOM_HEIGHT, MIN_HEIGHT as MIN_BOTTOM_HEIGHT, type BottomPanelTab } from '@renderer/components/BottomPanel';
import { GitPanel } from '@renderer/components/GitPanel';
import { TerminalPanel } from '@renderer/components/TerminalPanel';
import { resolveChatToolingSettings } from '@renderer/lib/chatTooling';
import {
applySessionEventActivity,
@@ -33,7 +20,6 @@ import {
} from '@renderer/lib/sessionActivity';
import { applySubagentEvent, pruneSubagentMap, type ActiveSubagentMap } from '@renderer/lib/subagentTracker';
import { applySessionEventWorkspace } from '@renderer/lib/sessionWorkspace';
import { WelcomePane } from '@renderer/components/WelcomePane';
import { getElectronApi } from '@renderer/lib/electronApi';
import { useTheme, useSidecarCapabilities } from '@renderer/hooks/useAppHooks';
import {
@@ -52,7 +38,33 @@ import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
import type { WorkspaceState } from '@shared/domain/workspace';
import type { UpdateStatus } from '@shared/contracts/ipc';
import { createId, nowIso } from '@shared/utils/ids';
import { WorkflowPicker } from '@renderer/components/workflow/WorkflowPicker';
// Lazy-loaded components — kept off the critical startup bundle.
// These pull in heavy dependencies (Lexical, @xyflow/react, @xterm/xterm, motion, etc.)
// that are not needed until the user interacts with the corresponding feature.
const ActivityPanel = lazy(() => import('@renderer/components/ActivityPanel').then((m) => ({ default: m.ActivityPanel })));
const BookmarksPanel = lazy(() => import('@renderer/components/BookmarksPanel').then((m) => ({ default: m.BookmarksPanel })));
const BottomPanel = lazy(() => import('@renderer/components/BottomPanel').then((m) => ({ default: m.BottomPanel })));
const ChatPane = lazy(() => import('@renderer/components/ChatPane').then((m) => ({ default: m.ChatPane })));
const CommandPalette = lazy(() => import('@renderer/components/CommandPalette').then((m) => ({ default: m.CommandPalette })));
const CommitComposer = lazy(() => import('@renderer/components/chat/CommitComposer').then((m) => ({ default: m.CommitComposer })));
const DiscoveredToolingModal = lazy(() => import('@renderer/components/DiscoveredToolingModal').then((m) => ({ default: m.DiscoveredToolingModal })));
const GitPanel = lazy(() => import('@renderer/components/GitPanel').then((m) => ({ default: m.GitPanel })));
const KeyboardShortcutsPanel = lazy(() => import('@renderer/components/KeyboardShortcutsPanel').then((m) => ({ default: m.KeyboardShortcutsPanel })));
const ProjectSettingsPanel = lazy(() => import('@renderer/components/ProjectSettingsPanel').then((m) => ({ default: m.ProjectSettingsPanel })));
const SessionSearchPanel = lazy(() => import('@renderer/components/SessionSearchPanel').then((m) => ({ default: m.SessionSearchPanel })));
const SettingsPanel = lazy(() => import('@renderer/components/SettingsPanel').then((m) => ({ default: m.SettingsPanel })));
const TerminalPanel = lazy(() => import('@renderer/components/TerminalPanel').then((m) => ({ default: m.TerminalPanel })));
const WelcomePane = lazy(() => import('@renderer/components/WelcomePane').then((m) => ({ default: m.WelcomePane })));
const WorkflowPicker = lazy(() => import('@renderer/components/workflow/WorkflowPicker').then((m) => ({ default: m.WorkflowPicker })));
// Re-export type-only imports from lazy modules so they're available without pulling in the bundle
type SettingsSection = 'appearance' | 'connection' | 'workflows' | 'agents' | 'mcp-servers' | 'lsp-profiles' | 'troubleshooting';
type BottomPanelTab = 'terminal' | 'git';
// Constants duplicated from BottomPanel to avoid importing the full module at startup
const DEFAULT_BOTTOM_HEIGHT = 280;
const MIN_BOTTOM_HEIGHT = 120;
function createDraftMcpServer(): McpServerDefinition {
const timestamp = nowIso();
@@ -643,6 +655,9 @@ export default function App() {
);
}
// Suspense fallback for lazy-loaded panels — intentionally blank to avoid layout flash
const lazyFallback = null;
// Determine main content
let content: React.ReactNode;
let detailPanel: React.ReactNode | undefined;
@@ -657,6 +672,7 @@ export default function App() {
);
} else if (selectedSession && workflowForSession && projectForSession) {
content = (
<Suspense fallback={lazyFallback}>
<ChatPane
onSend={(c, attachments, messageMode, promptInvocation) => api.sendSessionMessage({
sessionId: selectedSession.id,
@@ -740,30 +756,37 @@ export default function App() {
onDiscardRunChanges={handleDiscardRunChanges}
onOpenCommitComposer={handleOpenCommitComposer}
/>
</Suspense>
);
detailPanel = (
<ActivityPanel
activity={activityForSession}
workflow={workflowForSession}
session={selectedSession}
sessionRequestUsage={requestUsageForSession}
turnEvents={turnEventsForSession}
/>
<Suspense fallback={lazyFallback}>
<ActivityPanel
activity={activityForSession}
workflow={workflowForSession}
workflows={workspace?.workflows}
session={selectedSession}
sessionRequestUsage={requestUsageForSession}
turnEvents={turnEventsForSession}
/>
</Suspense>
);
} else {
content = (
<WelcomePane
hasProjects={hasUserProjects}
connectionStatus={sidecarCapabilities?.connection.status}
onAddProject={() => void api.addProject()}
onNewScratchpad={() => handleCreateScratchpad()}
onOpenSettings={() => setShowSettings(true)}
/>
<Suspense fallback={lazyFallback}>
<WelcomePane
hasProjects={hasUserProjects}
connectionStatus={sidecarCapabilities?.connection.status}
onAddProject={() => void api.addProject()}
onNewScratchpad={() => handleCreateScratchpad()}
onOpenSettings={() => setShowSettings(true)}
/>
</Suspense>
);
}
// Settings overlay
const overlay = showSettings ? (
<Suspense fallback={lazyFallback}>
<SettingsPanel
availableModels={availableModels}
initialSection={settingsSection}
@@ -836,6 +859,7 @@ export default function App() {
}}
onGetQuota={() => api.getQuota()}
/>
</Suspense>
) : null;
return (
@@ -846,29 +870,31 @@ export default function App() {
overlay={overlay}
bottomPanel={
bottomPanelOpen ? (
<BottomPanel
activeTab={bottomPanelTab}
gitContent={
selectedSession && !isScratchpadProject(selectedSession.projectId) ? (
<GitPanel
onDirtyChange={setGitDirty}
projectId={selectedSession.projectId}
/>
) : (
<div className="flex items-center justify-center py-8 text-[11px] text-[var(--color-text-muted)]">
Git is not available for scratchpad sessions
</div>
)
}
gitDirty={gitDirty}
height={bottomPanelHeight}
onClose={handleBottomPanelClose}
onHeightChange={handleBottomPanelHeightChange}
onTabChange={setBottomPanelTab}
showGitTab={!!selectedSession && !isScratchpadProject(selectedSession.projectId)}
terminalContent={<TerminalPanel onRunningChange={setTerminalRunning} />}
terminalRunning={terminalRunning}
/>
<Suspense fallback={lazyFallback}>
<BottomPanel
activeTab={bottomPanelTab}
gitContent={
selectedSession && !isScratchpadProject(selectedSession.projectId) ? (
<GitPanel
onDirtyChange={setGitDirty}
projectId={selectedSession.projectId}
/>
) : (
<div className="flex items-center justify-center py-8 text-[11px] text-[var(--color-text-muted)]">
Git is not available for scratchpad sessions
</div>
)
}
gitDirty={gitDirty}
height={bottomPanelHeight}
onClose={handleBottomPanelClose}
onHeightChange={handleBottomPanelHeightChange}
onTabChange={setBottomPanelTab}
showGitTab={!!selectedSession && !isScratchpadProject(selectedSession.projectId)}
terminalContent={<TerminalPanel onRunningChange={setTerminalRunning} />}
terminalRunning={terminalRunning}
/>
</Suspense>
) : undefined
}
sidebar={
@@ -911,120 +937,136 @@ export default function App() {
/>
{showDiscoveryModal && (
<DiscoveredToolingModal
onClose={() => setShowDiscoveryModal(false)}
onResolveProjectServers={(serverIds, resolution) => {
if (selectedProject) {
void api.resolveProjectDiscoveredTooling({ projectId: selectedProject.id, serverIds, resolution });
}
}}
onResolveUserServers={(serverIds, resolution) => {
void api.resolveWorkspaceDiscoveredTooling({ serverIds, resolution });
}}
projectDiscoveredTooling={selectedProject?.discoveredTooling}
projectName={selectedProject?.name}
userDiscoveredTooling={workspace.settings.discoveredUserTooling}
/>
<Suspense fallback={lazyFallback}>
<DiscoveredToolingModal
onClose={() => setShowDiscoveryModal(false)}
onResolveProjectServers={(serverIds, resolution) => {
if (selectedProject) {
void api.resolveProjectDiscoveredTooling({ projectId: selectedProject.id, serverIds, resolution });
}
}}
onResolveUserServers={(serverIds, resolution) => {
void api.resolveWorkspaceDiscoveredTooling({ serverIds, resolution });
}}
projectDiscoveredTooling={selectedProject?.discoveredTooling}
projectName={selectedProject?.name}
userDiscoveredTooling={workspace.settings.discoveredUserTooling}
/>
</Suspense>
)}
{projectForSettings && (
<ProjectSettingsPanel
project={projectForSettings}
onClose={() => setProjectSettingsId(undefined)}
onRescanConfigs={() => {
void api.rescanProjectConfigs({ projectId: projectForSettings.id });
}}
onRescanCustomization={() => {
void api.rescanProjectCustomization({ projectId: projectForSettings.id });
}}
onResolveDiscoveredTooling={(serverIds, resolution) => {
void api.resolveProjectDiscoveredTooling({ projectId: projectForSettings.id, serverIds, resolution });
}}
onSetAgentProfileEnabled={(agentProfileId, enabled) => {
void api.setProjectAgentProfileEnabled({ projectId: projectForSettings.id, agentProfileId, enabled });
}}
onRemoveProject={() => {
void api.removeProject(projectForSettings.id);
setProjectSettingsId(undefined);
}}
/>
<Suspense fallback={lazyFallback}>
<ProjectSettingsPanel
project={projectForSettings}
onClose={() => setProjectSettingsId(undefined)}
onRescanConfigs={() => {
void api.rescanProjectConfigs({ projectId: projectForSettings.id });
}}
onRescanCustomization={() => {
void api.rescanProjectCustomization({ projectId: projectForSettings.id });
}}
onResolveDiscoveredTooling={(serverIds, resolution) => {
void api.resolveProjectDiscoveredTooling({ projectId: projectForSettings.id, serverIds, resolution });
}}
onSetAgentProfileEnabled={(agentProfileId, enabled) => {
void api.setProjectAgentProfileEnabled({ projectId: projectForSettings.id, agentProfileId, enabled });
}}
onRemoveProject={() => {
void api.removeProject(projectForSettings.id);
setProjectSettingsId(undefined);
}}
/>
</Suspense>
)}
{commandPaletteOpen && workspace && (
<CommandPalette
workspace={workspace}
onClose={() => setCommandPaletteOpen(false)}
onSelectSession={(sessionId) => {
void api.selectSession(sessionId);
}}
onSelectProject={(projectId) => {
void api.selectProject(projectId);
}}
onNewSession={(projectId) => handleNewSession(projectId)}
onCreateScratchpad={handleCreateScratchpad}
onOpenSettings={() => setShowSettings(true)}
onOpenProjectSettings={(projectId) => setProjectSettingsId(projectId)}
onToggleTerminal={handleTerminalToggle}
onSetTheme={(theme) => void api.setTheme(theme)}
onDuplicateSession={(sessionId) => {
void api.duplicateSession({ sessionId });
}}
onPinSession={(sessionId, isPinned) => {
void api.setSessionPinned({ sessionId, isPinned });
}}
onArchiveSession={(sessionId, isArchived) => {
void api.setSessionArchived({ sessionId, isArchived });
}}
onAddProject={() => void api.addProject()}
onOpenAppDataFolder={() => void api.openAppDataFolder()}
onShowShortcuts={() => setShowShortcuts(true)}
onShowSearch={() => setShowSearch(true)}
onShowBookmarks={() => setShowBookmarks(true)}
/>
<Suspense fallback={lazyFallback}>
<CommandPalette
workspace={workspace}
onClose={() => setCommandPaletteOpen(false)}
onSelectSession={(sessionId) => {
void api.selectSession(sessionId);
}}
onSelectProject={(projectId) => {
void api.selectProject(projectId);
}}
onNewSession={(projectId) => handleNewSession(projectId)}
onCreateScratchpad={handleCreateScratchpad}
onOpenSettings={() => setShowSettings(true)}
onOpenProjectSettings={(projectId) => setProjectSettingsId(projectId)}
onToggleTerminal={handleTerminalToggle}
onSetTheme={(theme) => void api.setTheme(theme)}
onDuplicateSession={(sessionId) => {
void api.duplicateSession({ sessionId });
}}
onPinSession={(sessionId, isPinned) => {
void api.setSessionPinned({ sessionId, isPinned });
}}
onArchiveSession={(sessionId, isArchived) => {
void api.setSessionArchived({ sessionId, isArchived });
}}
onAddProject={() => void api.addProject()}
onOpenAppDataFolder={() => void api.openAppDataFolder()}
onShowShortcuts={() => setShowShortcuts(true)}
onShowSearch={() => setShowSearch(true)}
onShowBookmarks={() => setShowBookmarks(true)}
/>
</Suspense>
)}
{showShortcuts && (
<KeyboardShortcutsPanel onClose={() => setShowShortcuts(false)} />
<Suspense fallback={lazyFallback}>
<KeyboardShortcutsPanel onClose={() => setShowShortcuts(false)} />
</Suspense>
)}
{showSearch && workspace && (
<SessionSearchPanel
workspace={workspace}
onClose={() => setShowSearch(false)}
onSelectSession={(sessionId) => {
void api.selectSession(sessionId);
}}
/>
<Suspense fallback={lazyFallback}>
<SessionSearchPanel
workspace={workspace}
onClose={() => setShowSearch(false)}
onSelectSession={(sessionId) => {
void api.selectSession(sessionId);
}}
/>
</Suspense>
)}
{showBookmarks && workspace && (
<BookmarksPanel
workspace={workspace}
onClose={() => setShowBookmarks(false)}
onSelectSession={(sessionId) => {
void api.selectSession(sessionId);
}}
onUnpinMessage={(sessionId, messageId) => {
void api.setSessionMessagePinned({ sessionId, messageId, isPinned: false });
}}
/>
<Suspense fallback={lazyFallback}>
<BookmarksPanel
workspace={workspace}
onClose={() => setShowBookmarks(false)}
onSelectSession={(sessionId) => {
void api.selectSession(sessionId);
}}
onUnpinMessage={(sessionId, messageId) => {
void api.setSessionMessagePinned({ sessionId, messageId, isPinned: false });
}}
/>
</Suspense>
)}
{commitComposerCtx && (
<CommitComposer
onClose={() => setCommitComposerCtx(undefined)}
projectId={commitComposerCtx.projectId}
runId={commitComposerCtx.runId}
sessionId={commitComposerCtx.sessionId}
/>
<Suspense fallback={lazyFallback}>
<CommitComposer
onClose={() => setCommitComposerCtx(undefined)}
projectId={commitComposerCtx.projectId}
runId={commitComposerCtx.runId}
sessionId={commitComposerCtx.sessionId}
/>
</Suspense>
)}
{workflowPickerProjectId && workspace && (
<WorkflowPicker
workflows={workspace.workflows}
onSelect={handleWorkflowPicked}
onClose={() => setWorkflowPickerProjectId(null)}
/>
<Suspense fallback={lazyFallback}>
<WorkflowPicker
workflows={workspace.workflows}
onSelect={handleWorkflowPicked}
onClose={() => setWorkflowPickerProjectId(null)}
/>
</Suspense>
)}
</>
);
+57 -171
View File
@@ -1,59 +1,21 @@
import { useMemo, type ReactNode } from 'react';
import { Activity, AlertTriangle, ArrowRight, BarChart3, CheckCircle2, Cog, ShieldAlert, Sparkles, Users, Zap } from 'lucide-react';
import { Activity, AlertTriangle, ArrowRight, BarChart3, CheckCircle2, Cog, GitBranch, ShieldAlert, Sparkles, Users, Zap } from 'lucide-react';
import {
buildAgentActivityRows,
formatAgentActivityLabel,
buildGroupedActivityRows,
formatDuration,
formatNanoAiu,
formatTokenCount,
isAgentActivityActive,
isAgentActivityCompleted,
type AgentActivityRow,
type AgentUsageAccumulator,
type SessionActivityState,
type SessionRequestUsageState,
type TurnEventLog,
} from '@renderer/lib/sessionActivity';
import { inferProvider } from '@shared/domain/models';
import { resolveWorkflowAgentNodes, type AgentNodeConfig, type WorkflowDefinition, type WorkflowOrchestrationMode } from '@shared/domain/workflow';
import { resolveWorkflowAgentHierarchy, type AgentNodeConfig, type WorkflowDefinition } from '@shared/domain/workflow';
import type { SessionRecord } from '@shared/domain/session';
import { ProviderIcon } from './ProviderIcons';
/* ── Mode accent colours ───────────────────────────────────── */
const modeAccent: Record<WorkflowOrchestrationMode, { dot: string; bar: string; label: string }> = {
single: { dot: 'bg-[#245CF9]', bar: 'bg-[#245CF9] opacity-60', label: 'text-[#245CF9]' },
sequential: { dot: 'bg-[var(--color-status-warning)]', bar: 'bg-[var(--color-status-warning)] opacity-60', label: 'text-[var(--color-status-warning)]' },
concurrent: { dot: 'bg-[var(--color-status-success)]', bar: 'bg-[var(--color-status-success)] opacity-60', label: 'text-[var(--color-status-success)]' },
handoff: { dot: 'bg-[var(--color-accent-sky)]', bar: 'bg-[var(--color-accent-sky)] opacity-60', label: 'text-[var(--color-accent-sky)]' },
'group-chat': { dot: 'bg-[var(--color-accent-purple)]', bar: 'bg-[var(--color-accent-purple)] opacity-60', label: 'text-[var(--color-accent-purple)]' },
};
/* ── Helpers ───────────────────────────────────────────────── */
function formatModel(model: string): string {
return model.replace(/-/g, '\u2011');
}
function formatEffort(effort: string | undefined): string | undefined {
if (!effort) return undefined;
const labels: Record<string, string> = {
low: 'Low',
medium: 'Medium',
high: 'High',
xhigh: 'Max',
};
return labels[effort] ?? effort;
}
const modeLabels: Record<WorkflowOrchestrationMode, string> = {
single: 'Single agent',
sequential: 'Sequential',
concurrent: 'Concurrent',
handoff: 'Handoff',
'group-chat': 'Group chat',
};
import { AgentRow } from './activity/AgentRow';
import { SubWorkflowGroup } from './activity/SubWorkflowGroup';
import { modeAccent, modeLabels } from './activity/constants';
/* ── Section header ────────────────────────────────────────── */
@@ -65,112 +27,6 @@ function SectionHeader({ children }: { children: ReactNode }) {
);
}
/* ── Agent row ─────────────────────────────────────────────── */
function AgentRow({
row,
agent,
accent,
isLast,
agentUsage,
}: {
row: AgentActivityRow;
agent?: AgentNodeConfig;
accent: (typeof modeAccent)[WorkflowOrchestrationMode];
isLast: boolean;
agentUsage?: AgentUsageAccumulator;
}) {
const isActive = isAgentActivityActive(row.activity);
const isCompleted = isAgentActivityCompleted(row.activity);
return (
<div className={`relative flex gap-2.5 py-2.5 ${isLast ? '' : 'border-b border-[var(--color-border-subtle)]'}`}>
{/* Left accent bar — visible only when this agent is actively working */}
{isActive && (
<div className={`absolute -left-3 bottom-2 top-2 w-[3px] rounded-full ${accent.bar}`} />
)}
{/* Status dot */}
<div className="flex shrink-0 pt-0.5">
<span
className={`size-2 rounded-full transition-all duration-200 ${
isActive
? `animate-pulse ${accent.dot} ring-2 ring-[var(--color-border-glow)]`
: isCompleted
? 'bg-[var(--color-status-success)]'
: 'bg-[var(--color-surface-3)]'
}`}
/>
</div>
{/* Content */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-[12px] font-medium text-[var(--color-text-primary)]">{row.agentName}</span>
</div>
{/* Model + effort inline */}
{agent && (
<div className="mt-1 flex flex-wrap items-center gap-1">
<span className="inline-flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
{(() => {
const prov = inferProvider(agent.model);
return prov ? <ProviderIcon provider={prov} className="size-2.5" /> : null;
})()}
{formatModel(agent.model)}
</span>
{agent.reasoningEffort && (
<>
<span className="text-[10px] text-[var(--color-text-muted)]">·</span>
<span className="inline-flex items-center gap-0.5 text-[10px] text-[var(--color-text-muted)]">
<Sparkles className="size-2" />
{formatEffort(agent.reasoningEffort)}
</span>
</>
)}
</div>
)}
{/* Activity label */}
<div className="mt-1 flex items-center gap-1">
<span
className={`text-[10px] ${
isActive
? accent.label
: isCompleted
? 'text-[var(--color-status-success)]'
: 'text-[var(--color-text-muted)]'
}`}
>
{formatAgentActivityLabel(row.activity)}
</span>
</div>
{/* Per-agent usage summary */}
{agentUsage && agentUsage.requestCount > 0 && (
<div className="mt-0.5 flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
<span className="font-mono tabular-nums">{formatTokenCount(agentUsage.inputTokens)} in</span>
<span className="text-[var(--color-text-muted)]">·</span>
<span className="font-mono tabular-nums">{formatTokenCount(agentUsage.outputTokens)} out</span>
{agentUsage.cost > 0 && (
<>
<span className="text-[var(--color-text-muted)]">·</span>
<span className="font-mono tabular-nums">{agentUsage.cost.toFixed(2)} cost</span>
</>
)}
{agentUsage.durationMs > 0 && (
<>
<span className="text-[var(--color-text-muted)]">·</span>
<span className="font-mono tabular-nums">{formatDuration(agentUsage.durationMs)}</span>
</>
)}
</div>
)}
</div>
</div>
);
}
/* ── Turn event helpers ─────────────────────────────────────── */
import type { SessionEventKind } from '@shared/domain/event';
@@ -178,6 +34,8 @@ import type { SessionEventKind } from '@shared/domain/event';
function TurnEventIcon({ kind, phase, success }: { kind: SessionEventKind; phase?: string; success?: boolean }) {
const base = 'size-3';
switch (kind) {
case 'agent-activity':
return <GitBranch className={`${base} ${phase === 'start' ? 'text-[var(--color-accent-sky)]' : 'text-[var(--color-status-success)]'}`} />;
case 'subagent':
return <ArrowRight className={`${base} ${success === false ? 'text-[var(--color-status-error)]' : 'text-[var(--color-accent-sky)]'}`} />;
case 'hook-lifecycle':
@@ -207,6 +65,7 @@ function formatTurnEventTimestamp(iso: string): string {
interface ActivityPanelProps {
activity?: SessionActivityState;
workflow: WorkflowDefinition;
workflows?: ReadonlyArray<WorkflowDefinition>;
session: SessionRecord;
sessionRequestUsage?: SessionRequestUsageState;
turnEvents?: TurnEventLog;
@@ -215,32 +74,40 @@ interface ActivityPanelProps {
export function ActivityPanel({
activity,
workflow,
workflows,
session,
sessionRequestUsage,
turnEvents,
}: ActivityPanelProps) {
const workflowAgents = useMemo(
() => resolveWorkflowAgentNodes(workflow)
.map((n) => n.config)
.filter((c): c is AgentNodeConfig => c.kind === 'agent'),
[workflow],
);
const workflowMode = workflow.settings.orchestrationMode ?? 'single';
const resolveOptions = useMemo(() => ({
resolveWorkflow: (id: string) => workflows?.find((w) => w.id === id),
}), [workflows]);
const activityRows = useMemo(
() => buildAgentActivityRows(activity, workflowAgents),
[activity, workflowAgents],
const hierarchy = useMemo(
() => resolveWorkflowAgentHierarchy(workflow, resolveOptions),
[workflow, resolveOptions],
);
const groupedRows = useMemo(
() => buildGroupedActivityRows(activity, hierarchy),
[activity, hierarchy],
);
const workflowMode = workflow.settings.orchestrationMode ?? 'single';
const totalAgentCount = hierarchy.topLevelAgents.length
+ hierarchy.subWorkflows.reduce((sum, sw) => sum + sw.agents.length, 0);
const isBusy = session.status === 'running';
const hasPendingApproval = session.pendingApproval?.status === 'pending';
const queuedCount = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending').length;
const totalApprovalCount = (hasPendingApproval ? 1 : 0) + queuedCount;
const accent = modeAccent[workflowMode] ?? modeAccent.single;
const hasSubWorkflows = groupedRows.subWorkflows.length > 0;
return (
<div className="flex h-full flex-col">
{/* Header — top padding clears the title bar overlay zone */}
{/* Header */}
<div className="drag-region border-b border-[var(--color-border)] px-4 pb-3 pt-3">
<div className="flex min-h-8 items-center gap-2">
<Activity className="size-4 text-[var(--color-text-muted)]" />
@@ -267,32 +134,53 @@ export function ActivityPanel({
<Users className="size-3" />
<span>Agents</span>
<span className="font-mono rounded-full bg-[var(--color-surface-2)] px-1.5 py-0.5 text-[9px] tabular-nums text-[var(--color-text-muted)]">
{activityRows.length}
{totalAgentCount}
</span>
<span className={`ml-auto text-[9px] font-medium normal-case tracking-normal ${accent.label}`}>
{modeLabels[workflowMode]}
</span>
</SectionHeader>
{activityRows.length > 0 ? (
{/* Top-level agents */}
{groupedRows.topLevelAgents.length > 0 && (
<div className="glass-surface rounded-lg px-3">
{activityRows.map((row, index) => {
{groupedRows.topLevelAgents.map((row, index) => {
const agent = hierarchy.topLevelAgents.find((a) => a.id === row.key || a.name === row.agentName);
const agentKey = row.activity?.agentId ?? row.key;
const agentUsage = sessionRequestUsage?.perAgent[agentKey]
?? sessionRequestUsage?.perAgent[row.agentName];
return (
<AgentRow
accent={accent}
agent={workflowAgents[index]}
agentUsage={agentUsage}
isLast={index === activityRows.length - 1}
key={row.key}
row={row}
agent={agent}
accent={accent}
isLast={!hasSubWorkflows && index === groupedRows.topLevelAgents.length - 1}
agentUsage={agentUsage}
/>
);
})}
</div>
) : (
)}
{/* Sub-workflow groups */}
{hasSubWorkflows && (
<div className={`space-y-2 ${groupedRows.topLevelAgents.length > 0 ? 'mt-2' : ''}`}>
{groupedRows.subWorkflows.map((group) => {
const subDef = hierarchy.subWorkflows.find((sw) => sw.nodeId === group.nodeId);
return (
<SubWorkflowGroup
key={group.nodeId}
group={group}
agentConfigs={subDef?.agents ?? []}
agentUsage={sessionRequestUsage?.perAgent}
/>
);
})}
</div>
)}
{totalAgentCount === 0 && !hasSubWorkflows && (
<p className="py-4 text-center text-[11px] text-[var(--color-text-muted)]">No agents configured</p>
)}
</div>
@@ -376,5 +264,3 @@ export function ActivityPanel({
</div>
);
}
@@ -3,6 +3,7 @@ import { RotateCcw } from 'lucide-react';
import { Terminal } from '@xterm/xterm';
import { FitAddon } from '@xterm/addon-fit';
import '@xterm/xterm/css/xterm.css';
import '@fontsource-variable/jetbrains-mono';
import { getElectronApi } from '@renderer/lib/electronApi';
import type { TerminalSnapshot } from '@shared/domain/terminal';
@@ -0,0 +1,111 @@
import { Sparkles } from 'lucide-react';
import type { AgentNodeConfig } from '@shared/domain/workflow';
import { inferProvider } from '@shared/domain/models';
import {
formatAgentActivityLabel,
formatDuration,
formatTokenCount,
isAgentActivityActive,
isAgentActivityCompleted,
type AgentActivityRow,
type AgentUsageAccumulator,
} from '@renderer/lib/sessionActivity';
import { ProviderIcon } from '@renderer/components/ProviderIcons';
import { type ModeAccent, formatEffort, formatModel } from './constants';
interface AgentRowProps {
row: AgentActivityRow;
agent?: AgentNodeConfig;
accent: ModeAccent;
isLast: boolean;
agentUsage?: AgentUsageAccumulator;
}
export function AgentRow({ row, agent, accent, isLast, agentUsage }: AgentRowProps) {
const isActive = isAgentActivityActive(row.activity);
const isCompleted = isAgentActivityCompleted(row.activity);
return (
<div className={`relative flex gap-2.5 py-2.5 ${isLast ? '' : 'border-b border-[var(--color-border-subtle)]'}`}>
{isActive && (
<div className={`absolute -left-3 bottom-2 top-2 w-[3px] rounded-full ${accent.bar}`} />
)}
{/* Status dot */}
<div className="flex shrink-0 pt-0.5">
<span
className={`size-2 rounded-full transition-all duration-200 ${
isActive
? `animate-pulse ${accent.dot} ring-2 ring-[var(--color-border-glow)]`
: isCompleted
? 'bg-[var(--color-status-success)]'
: 'bg-[var(--color-surface-3)]'
}`}
/>
</div>
{/* Content */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-[12px] font-medium text-[var(--color-text-primary)]">{row.agentName}</span>
</div>
{agent && (
<div className="mt-1 flex flex-wrap items-center gap-1">
<span className="inline-flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
{(() => {
const prov = inferProvider(agent.model);
return prov ? <ProviderIcon provider={prov} className="size-2.5" /> : null;
})()}
{formatModel(agent.model)}
</span>
{agent.reasoningEffort && (
<>
<span className="text-[10px] text-[var(--color-text-muted)]">·</span>
<span className="inline-flex items-center gap-0.5 text-[10px] text-[var(--color-text-muted)]">
<Sparkles className="size-2" />
{formatEffort(agent.reasoningEffort)}
</span>
</>
)}
</div>
)}
<div className="mt-1 flex items-center gap-1">
<span
className={`text-[10px] ${
isActive
? accent.label
: isCompleted
? 'text-[var(--color-status-success)]'
: 'text-[var(--color-text-muted)]'
}`}
>
{formatAgentActivityLabel(row.activity)}
</span>
</div>
{agentUsage && agentUsage.requestCount > 0 && (
<div className="mt-0.5 flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
<span className="font-mono tabular-nums">{formatTokenCount(agentUsage.inputTokens)} in</span>
<span className="text-[var(--color-text-muted)]">·</span>
<span className="font-mono tabular-nums">{formatTokenCount(agentUsage.outputTokens)} out</span>
{agentUsage.cost > 0 && (
<>
<span className="text-[var(--color-text-muted)]">·</span>
<span className="font-mono tabular-nums">{agentUsage.cost.toFixed(2)} cost</span>
</>
)}
{agentUsage.durationMs > 0 && (
<>
<span className="text-[var(--color-text-muted)]">·</span>
<span className="font-mono tabular-nums">{formatDuration(agentUsage.durationMs)}</span>
</>
)}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,145 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ChevronDown, GitBranch } from 'lucide-react';
import type { AgentNodeConfig } from '@shared/domain/workflow';
import {
isAgentActivityActive,
type AgentUsageAccumulator,
type SubWorkflowActivityGroup,
} from '@renderer/lib/sessionActivity';
import { AgentRow } from './AgentRow';
import { modeAccent, modeLabels } from './constants';
interface SubWorkflowGroupProps {
group: SubWorkflowActivityGroup;
agentConfigs: ReadonlyArray<AgentNodeConfig>;
agentUsage?: Record<string, AgentUsageAccumulator>;
}
const statusPresentation = {
idle: {
dot: 'bg-[var(--color-surface-3)]',
text: 'text-[var(--color-text-muted)]',
label: 'Idle',
},
running: {
dot: 'animate-pulse',
text: '',
label: 'Running',
},
completed: {
dot: 'bg-[var(--color-status-success)]',
text: 'text-[var(--color-status-success)]',
label: 'Done',
},
} as const;
export function SubWorkflowGroup({ group, agentConfigs, agentUsage }: SubWorkflowGroupProps) {
const [isExpanded, setIsExpanded] = useState(false);
const prevStatusRef = useRef(group.status);
useEffect(() => {
if (prevStatusRef.current !== 'running' && group.status === 'running') {
setIsExpanded(true);
}
prevStatusRef.current = group.status;
}, [group.status]);
const toggle = useCallback(() => setIsExpanded((prev) => !prev), []);
const accent = modeAccent[group.orchestrationMode] ?? modeAccent.single;
const status = statusPresentation[group.status];
const hasActiveAgent = group.agents.some((a) => isAgentActivityActive(a.activity));
return (
<div
className="overflow-hidden rounded-lg border border-[var(--color-border-subtle)] border-l-[3px] bg-[var(--color-surface-1)]"
style={{ borderLeftColor: accent.color }}
role="group"
aria-label={`Sub-workflow: ${group.name}`}
>
{/* Collapsible header */}
<button
className="flex w-full items-center gap-2 px-3 py-2.5 text-left transition-colors hover:bg-[var(--color-surface-2)]"
onClick={toggle}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggle();
}
}}
aria-expanded={isExpanded}
type="button"
>
<GitBranch className="size-3.5 shrink-0 text-[var(--color-text-muted)]" />
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-[var(--color-text-primary)]">
{group.name}
</span>
{/* Status badge */}
<span className="flex items-center gap-1" aria-live="polite">
<span
className={`size-1.5 rounded-full ${
group.status === 'running'
? `${accent.dot} ${status.dot} ring-1 ring-[var(--color-border-glow)]`
: status.dot
}`}
/>
<span className={`text-[9px] font-medium ${group.status === 'running' ? accent.label : status.text}`}>
{status.label}
</span>
</span>
{/* Agent count pill */}
<span className="font-mono rounded-full bg-[var(--color-surface-2)] px-1.5 py-0.5 text-[9px] tabular-nums text-[var(--color-text-muted)]">
{group.agents.length}
</span>
<ChevronDown
className={`size-3 shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${
isExpanded ? 'rotate-180' : ''
}`}
/>
</button>
{/* Expandable agent list */}
<div
className="grid transition-[grid-template-rows] duration-150 ease-out"
style={{ gridTemplateRows: isExpanded ? '1fr' : '0fr' }}
>
<div className="overflow-hidden">
<div className="relative border-t border-[var(--color-border-subtle)] py-1 pl-5 pr-3">
{/* Connecting vertical accent line */}
{hasActiveAgent && (
<div className={`absolute bottom-3 left-[11px] top-3 w-px ${accent.bar}`} />
)}
{group.agents.map((row, index) => {
const agent = agentConfigs.find((c) => c.id === row.key || c.name === row.agentName);
const usage = agentUsage?.[row.activity?.agentId ?? row.key] ?? agentUsage?.[row.agentName];
return (
<AgentRow
key={row.key}
row={row}
agent={agent}
accent={accent}
isLast={index === group.agents.length - 1}
agentUsage={usage}
/>
);
})}
{/* Mode label */}
<div className="flex items-center gap-1 pb-1 pt-0.5">
<span className={`text-[9px] font-medium ${accent.label}`}>
{modeLabels[group.orchestrationMode]}
</span>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,34 @@
import type { WorkflowOrchestrationMode } from '@shared/domain/workflow';
export interface ModeAccent {
dot: string;
bar: string;
label: string;
color: string;
}
export const modeAccent: Record<WorkflowOrchestrationMode, ModeAccent> = {
single: { dot: 'bg-[#245CF9]', bar: 'bg-[#245CF9] opacity-60', label: 'text-[#245CF9]', color: '#245CF9' },
sequential: { dot: 'bg-[var(--color-status-warning)]', bar: 'bg-[var(--color-status-warning)] opacity-60', label: 'text-[var(--color-status-warning)]', color: 'var(--color-status-warning)' },
concurrent: { dot: 'bg-[var(--color-status-success)]', bar: 'bg-[var(--color-status-success)] opacity-60', label: 'text-[var(--color-status-success)]', color: 'var(--color-status-success)' },
handoff: { dot: 'bg-[var(--color-accent-sky)]', bar: 'bg-[var(--color-accent-sky)] opacity-60', label: 'text-[var(--color-accent-sky)]', color: 'var(--color-accent-sky)' },
'group-chat': { dot: 'bg-[var(--color-accent-purple)]', bar: 'bg-[var(--color-accent-purple)] opacity-60', label: 'text-[var(--color-accent-purple)]', color: 'var(--color-accent-purple)' },
};
export const modeLabels: Record<WorkflowOrchestrationMode, string> = {
single: 'Single agent',
sequential: 'Sequential',
concurrent: 'Concurrent',
handoff: 'Handoff',
'group-chat': 'Group chat',
};
export function formatModel(model: string): string {
return model.replace(/-/g, '\u2011');
}
export function formatEffort(effort: string | undefined): string | undefined {
if (!effort) return undefined;
const labels: Record<string, string> = { low: 'Low', medium: 'Medium', high: 'High', xhigh: 'Max' };
return labels[effort] ?? effort;
}
@@ -6,8 +6,17 @@ import {
CheckCircle2,
ChevronDown,
ChevronRight,
Database,
Eye,
ExternalLink,
FileSearch,
Github,
MessageSquare,
Pencil,
Search,
ShieldAlert,
Terminal,
Users,
Wrench,
XCircle,
Zap,
@@ -18,21 +27,12 @@ import { FileChangePreview } from '@renderer/components/chat/FileChangePreview';
import { ToolCallDetailPanel } from '@renderer/components/chat/ToolCallDetailPanel';
import { RunChangeSummaryCard } from '@renderer/components/chat/RunChangeSummaryCard';
import { formatEventLabel, truncateContent, filterEventsByAgent, summarizeActivity, type ActivitySummary } from '@renderer/lib/runTimelineFormatting';
import { formatToolGroupLabel, extractToolCallSnippet, formatToolCallPrimaryLabel } from '@renderer/lib/toolCallSummary';
import { buildActivityStream, groupActivityStream, extractLatestIntent, generateActivitySummary, type GroupedActivityItem } from '@renderer/lib/activityGrouping';
import type { ChatMessageRecord } from '@shared/domain/session';
import type { ProjectGitFileReference } from '@shared/domain/project';
import type { RunTimelineEventRecord, SessionRunRecord } from '@shared/domain/runTimeline';
/* ── Types ─────────────────────────────────────────────────── */
/** A unified activity stream item, merging chat thinking messages
* and run timeline events into a single chronological list. */
type ActivityStreamItem =
| { kind: 'thinking-step'; message: ChatMessageRecord }
| { kind: 'timeline-event'; event: RunTimelineEventRecord };
/** Events to skip in the inline panel (redundant or implicit). */
const SKIP_EVENT_KINDS = new Set(['run-started', 'thinking']);
/* ── Props ─────────────────────────────────────────────────── */
export interface TurnActivityPanelProps {
@@ -41,9 +41,7 @@ export interface TurnActivityPanelProps {
isActive: boolean;
turnStartedAt?: string;
sessionId: string;
/** Agent names in this turn group — used to scope run events in multi-agent runs. */
agentNames?: ReadonlySet<string>;
/** True when this panel is the last one sharing a given run (controls git summary / discard). */
isLastRunPanel?: boolean;
onDiscard?: (sessionId: string, runId: string, files?: ProjectGitFileReference[]) => Promise<unknown>;
onOpenCommitComposer?: () => void;
@@ -58,35 +56,10 @@ function truncatePreview(text: string, maxLength: number): string {
return `${cleaned.slice(0, maxLength)}`;
}
function buildActivityStream(
thinkingMessages: ChatMessageRecord[],
events: readonly RunTimelineEventRecord[],
): ActivityStreamItem[] {
const items: ActivityStreamItem[] = [];
for (const msg of thinkingMessages) {
items.push({ kind: 'thinking-step', message: msg });
}
for (const event of events) {
if (SKIP_EVENT_KINDS.has(event.kind)) continue;
items.push({ kind: 'timeline-event', event });
}
// Sort chronologically by timestamp
items.sort((a, b) => {
const tsA = a.kind === 'thinking-step' ? a.message.createdAt : a.event.occurredAt;
const tsB = b.kind === 'thinking-step' ? b.message.createdAt : b.event.occurredAt;
return new Date(tsA).getTime() - new Date(tsB).getTime();
});
return items;
}
function formatSummaryParts(summary: ActivitySummary): string[] {
const parts: string[] = [];
if (summary.toolCalls > 0) {
parts.push(`${summary.toolCalls} tool ${summary.toolCalls === 1 ? 'call' : 'calls'}`);
parts.push(`${summary.toolCalls} ${summary.toolCalls === 1 ? 'action' : 'actions'}`);
}
if (summary.handoffs > 0) {
parts.push(`${summary.handoffs} ${summary.handoffs === 1 ? 'handoff' : 'handoffs'}`);
@@ -94,20 +67,57 @@ function formatSummaryParts(summary: ActivitySummary): string[] {
if (summary.approvals > 0) {
parts.push(`${summary.approvals} ${summary.approvals === 1 ? 'approval' : 'approvals'}`);
}
if (summary.thinkingSteps > 0) {
parts.push(`${summary.thinkingSteps} thinking ${summary.thinkingSteps === 1 ? 'step' : 'steps'}`);
}
return parts;
}
/* ── Event icon ────────────────────────────────────────────── */
/* ── Tool-category icon ────────────────────────────────────── */
function ActivityEventIcon({ kind, status }: { kind: RunTimelineEventRecord['kind']; status: RunTimelineEventRecord['status'] }) {
function ToolCategoryIcon({ toolName, className }: { toolName?: string; className?: string }) {
const base = className ?? 'size-3 shrink-0';
if (!toolName) return <Wrench className={`${base} text-[var(--color-text-muted)]`} />;
if (toolName.startsWith('github-')) {
return <Github className={`${base} text-[var(--color-text-secondary)]`} />;
}
switch (toolName) {
case 'view':
return <Eye className={`${base} text-[var(--color-accent-sky)]`} />;
case 'grep':
case 'glob':
return <Search className={`${base} text-[var(--color-accent-purple)]`} />;
case 'lsp':
return <FileSearch className={`${base} text-[var(--color-accent-purple)]`} />;
case 'edit':
case 'create':
return <Pencil className={`${base} text-[var(--color-status-warning)]`} />;
case 'powershell':
return <Terminal className={`${base} text-[var(--color-text-secondary)]`} />;
case 'web_fetch':
case 'web_search':
return <ExternalLink className={`${base} text-[var(--color-accent-sky)]`} />;
case 'sql':
return <Database className={`${base} text-[var(--color-accent-purple)]`} />;
case 'task':
return <Users className={`${base} text-[var(--color-accent-sky)]`} />;
default:
return <Wrench className={`${base} text-[var(--color-text-muted)]`} />;
}
}
/* ── Event icon (for non-tool events) ──────────────────────── */
function ActivityEventIcon({ kind, status, toolName }: {
kind: RunTimelineEventRecord['kind'];
status: RunTimelineEventRecord['status'];
toolName?: string;
}) {
const base = 'size-3 shrink-0';
switch (kind) {
case 'tool-call':
return <Wrench className={`${base} text-[var(--color-accent-purple)]`} />;
return <ToolCategoryIcon toolName={toolName} className={base} />;
case 'approval':
return (
<ShieldAlert
@@ -135,7 +145,7 @@ function ActivityEventIcon({ kind, status }: { kind: RunTimelineEventRecord['kin
}
}
/* ── Activity event row ────────────────────────────────────── */
/* ── Single event row ──────────────────────────────────────── */
function ActivityTimelineEventRow({ event }: { event: RunTimelineEventRecord }) {
const label = formatEventLabel(event);
@@ -144,7 +154,7 @@ function ActivityTimelineEventRow({ event }: { event: RunTimelineEventRecord })
return (
<div className="turn-activity-row flex gap-2 py-1">
<div className="mt-0.5 flex shrink-0 items-start">
<ActivityEventIcon kind={event.kind} status={event.status} />
<ActivityEventIcon kind={event.kind} status={event.status} toolName={event.toolName} />
</div>
<div className="min-w-0 flex-1">
<span className={`text-[12px] font-medium ${isTerminal ? 'text-[var(--color-text-muted)]' : 'text-[var(--color-text-secondary)]'}`}>
@@ -203,6 +213,106 @@ function ActivityTimelineEventRow({ event }: { event: RunTimelineEventRecord })
);
}
/* ── Grouped tool-call row ─────────────────────────────────── */
function GroupedToolCallRow({ toolName, events }: { toolName: string; events: RunTimelineEventRecord[] }) {
const [expanded, setExpanded] = useState(false);
const label = formatToolGroupLabel(toolName, events.length);
const snippets = useMemo(
() => events.map((e) => extractToolCallSnippet(toolName, e.toolArguments)).filter(Boolean) as string[],
[toolName, events],
);
const hasFileChanges = events.some((e) => e.fileChanges && e.fileChanges.length > 0);
return (
<div className="turn-activity-row py-0.5">
<button
type="button"
className="flex w-full items-start gap-2 py-1 text-left transition-colors hover:bg-[var(--color-surface-2)]/30 rounded px-1 -mx-1"
onClick={() => setExpanded((prev) => !prev)}
aria-expanded={expanded}
>
<div className="mt-0.5 flex shrink-0 items-start">
<ToolCategoryIcon toolName={toolName} />
</div>
<span className="min-w-0 flex-1 text-[12px] font-medium text-[var(--color-text-secondary)]">
{label}
</span>
<ChevronRight
className={`mt-0.5 size-3 shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${
expanded ? 'rotate-90' : ''
}`}
/>
</button>
{/* Collapsed preview: show snippets inline */}
{!expanded && snippets.length > 0 && (
<div className="ml-5 flex flex-wrap gap-x-2 gap-y-0.5 pb-0.5">
{snippets.slice(0, 6).map((s, i) => (
<span key={i} className="truncate font-mono text-[10px] text-[var(--color-text-muted)]">
{s}
</span>
))}
{snippets.length > 6 && (
<span className="text-[10px] text-[var(--color-text-muted)]">
+{snippets.length - 6} more
</span>
)}
</div>
)}
{/* Expanded: full per-event rows */}
{expanded && (
<div className="ml-5 border-l border-[var(--color-border)]/30 pl-2">
{events.map((event) => (
<div key={event.id} className="py-0.5">
<span className="text-[11px] text-[var(--color-text-secondary)]">
{formatToolCallPrimaryLabel(event.toolName, event.toolArguments)}
</span>
<ToolCallDetailPanel toolName={event.toolName} toolArguments={event.toolArguments} />
{event.fileChanges && event.fileChanges.length > 0 && (
<div className="mt-0.5">
<FileChangePreview fileChanges={event.fileChanges} />
</div>
)}
</div>
))}
</div>
)}
{/* Aggregate file changes when collapsed */}
{!expanded && hasFileChanges && (
<div className="ml-5 mt-0.5">
{events
.filter((e) => e.fileChanges && e.fileChanges.length > 0)
.flatMap((e) => e.fileChanges!)
.length > 0 && (
<FileChangePreview
fileChanges={events.flatMap((e) => e.fileChanges ?? [])}
/>
)}
</div>
)}
</div>
);
}
/* ── Intent divider ────────────────────────────────────────── */
function IntentDividerRow({ text }: { text: string }) {
return (
<div className="turn-activity-row flex items-center gap-2 py-1.5" role="separator">
<div className="h-px flex-1 bg-[var(--color-border)]/40" />
<span className="shrink-0 text-[10px] font-medium tracking-wide text-[var(--color-text-muted)]">
{text}
</span>
<div className="h-px flex-1 bg-[var(--color-border)]/40" />
</div>
);
}
/* ── Thinking step row ─────────────────────────────────────── */
function ThinkingStepRow({ message }: { message: ChatMessageRecord }) {
@@ -216,17 +326,61 @@ function ThinkingStepRow({ message }: { message: ChatMessageRecord }) {
<Brain className="size-3 text-[var(--color-accent-purple)]" />
</div>
<div className="min-w-0 flex-1">
{message.authorName && (
<span className="mr-1.5 text-[12px] font-medium text-[var(--color-text-secondary)]">
{message.authorName}
</span>
)}
<span className="text-[12px] text-[var(--color-text-muted)]">{preview}</span>
<p className="border-l-2 border-[var(--color-accent-purple)]/20 pl-2 text-[11px] italic leading-snug text-[var(--color-text-muted)]">
"{preview}"
</p>
</div>
</div>
);
}
/* ── Thinking group (multiple consecutive) ─────────────────── */
function ThinkingGroupRow({ messages }: { messages: ChatMessageRecord[] }) {
const [expanded, setExpanded] = useState(false);
const visibleMessages = messages.filter((m) => !m.pending || m.content);
if (visibleMessages.length === 0) return null;
const latest = visibleMessages[visibleMessages.length - 1];
const preview = truncatePreview(latest.content, 180);
const hiddenCount = visibleMessages.length - 1;
return (
<div className="turn-activity-row py-0.5">
<div className="flex gap-2 py-1">
<div className="mt-0.5 flex shrink-0 items-start">
<Brain className="size-3 text-[var(--color-accent-purple)]" />
</div>
<div className="min-w-0 flex-1">
<p className="border-l-2 border-[var(--color-accent-purple)]/20 pl-2 text-[11px] italic leading-snug text-[var(--color-text-muted)]">
"{preview}"
</p>
{hiddenCount > 0 && (
<button
type="button"
className="mt-0.5 pl-2 text-[10px] text-[var(--color-accent)] hover:underline"
onClick={() => setExpanded((prev) => !prev)}
>
{expanded ? 'Hide' : `${hiddenCount} earlier ${hiddenCount === 1 ? 'thought' : 'thoughts'}`}
</button>
)}
</div>
</div>
{expanded && (
<div className="ml-5 space-y-0.5 border-l border-[var(--color-border)]/30 pl-2">
{visibleMessages.slice(0, -1).map((msg) => (
<p key={msg.id} className="text-[10px] italic leading-snug text-[var(--color-text-muted)]">
"{truncatePreview(msg.content, 140)}"
</p>
))}
</div>
)}
</div>
);
}
/* ── Active pulse dots ─────────────────────────────────────── */
function ActivityPulse() {
@@ -239,6 +393,23 @@ function ActivityPulse() {
);
}
/* ── Grouped item renderer ─────────────────────────────────── */
function GroupedItemRow({ item }: { item: GroupedActivityItem }) {
switch (item.kind) {
case 'intent-divider':
return <IntentDividerRow text={item.intentText} />;
case 'single-event':
return <ActivityTimelineEventRow event={item.event} />;
case 'tool-group':
return <GroupedToolCallRow toolName={item.toolName} events={item.events} />;
case 'single-thinking':
return <ThinkingStepRow message={item.message} />;
case 'thinking-group':
return <ThinkingGroupRow messages={item.messages} />;
}
}
/* ── Main component ────────────────────────────────────────── */
export function TurnActivityPanel({
@@ -255,8 +426,6 @@ export function TurnActivityPanel({
const [expanded, setExpanded] = useState(false);
const wasActiveRef = useRef(isActive);
// Auto-expand when the turn is active (run exists or thinking arrives).
// Auto-collapse once the turn finishes.
useEffect(() => {
if (isActive && (thinkingMessages.length > 0 || run)) {
setExpanded(true);
@@ -268,25 +437,20 @@ export function TurnActivityPanel({
const toggle = useCallback(() => setExpanded((prev) => !prev), []);
// When the run is shared across multiple panels (multi-agent sequential),
// scope events to only those belonging to this panel's agents.
const scopedEvents = useMemo(
() => filterEventsByAgent(run?.events ?? [], agentNames),
[run?.events, agentNames],
);
// Derive per-agent timing from the scoped events when agent names are set
// (multi-agent run). For single-agent runs, use the run-level start time.
const effectiveTurnStartedAt = useMemo(() => {
if (!agentNames || agentNames.size === 0 || scopedEvents.length === 0) {
return turnStartedAt;
}
// Use the earliest scoped event as the start time for this agent's panel.
let earliest = turnStartedAt;
for (const e of scopedEvents) {
if (!earliest || e.occurredAt < earliest) {
earliest = e.occurredAt;
break; // events are already in insertion order (chronological)
break;
}
}
return earliest;
@@ -302,12 +466,18 @@ export function TurnActivityPanel({
[thinkingMessages, scopedEvents],
);
const activityStream = useMemo(
() => buildActivityStream(thinkingMessages, scopedEvents),
[thinkingMessages, scopedEvents],
const groupedItems = useMemo(() => {
const stream = buildActivityStream(thinkingMessages, scopedEvents);
return groupActivityStream(stream);
}, [thinkingMessages, scopedEvents]);
// Extract intent text for the header
const intentText = useMemo(() => extractLatestIntent(scopedEvents), [scopedEvents]);
const fallbackSummary = useMemo(
() => !intentText ? generateActivitySummary(scopedEvents) : undefined,
[intentText, scopedEvents],
);
// Nothing to show — no thinking messages, no run, and not active
if (thinkingMessages.length === 0 && !run) {
return null;
}
@@ -318,10 +488,8 @@ export function TurnActivityPanel({
const isFailed = runStatus === 'error';
const isCancelled = runStatus === 'cancelled';
const isTerminated = isCompleted || isFailed || isCancelled;
// Only show git summary and discard on the last panel for a given run
const showGitSummary = run && isTerminated && run.postRunGitSummary && onDiscard && (isLastRunPanel !== false);
// Build the summary label
let summaryLabel: string;
if (isActive) {
summaryLabel = 'Working';
@@ -335,6 +503,8 @@ export function TurnActivityPanel({
summaryLabel = 'Completed';
}
const headerDetail = intentText ?? fallbackSummary;
const statusColorClass = isFailed
? 'text-[var(--color-status-error)]'
: isCancelled
@@ -374,9 +544,17 @@ export function TurnActivityPanel({
<span className={statusColorClass}>{summaryLabel}</span>
)}
{/* Intent / generated summary */}
{headerDetail && (
<span className="min-w-0 truncate text-[11px] text-[var(--color-text-muted)]">
{'· '}
{intentText ? `"${headerDetail}"` : headerDetail}
</span>
)}
{/* Inline counters */}
{summaryParts.length > 0 && (
<span className="font-mono text-[10px] text-[var(--color-text-muted)]">
<span className="shrink-0 font-mono text-[10px] text-[var(--color-text-muted)]">
{'· '}
{summaryParts.join(' · ')}
</span>
@@ -389,16 +567,13 @@ export function TurnActivityPanel({
</span>
</button>
{/* Expanded activity stream */}
{/* Expanded activity stream — grouped */}
{expanded && (
<div className="border-t border-[var(--color-border)]/30 px-3 py-2">
<div className="space-y-0.5">
{activityStream.map((item) => {
if (item.kind === 'thinking-step') {
return <ThinkingStepRow key={item.message.id} message={item.message} />;
}
return <ActivityTimelineEventRow key={item.event.id} event={item.event} />;
})}
{groupedItems.map((item, index) => (
<GroupedItemRow key={index} item={item} />
))}
</div>
{/* Post-run git changes */}
+171
View File
@@ -0,0 +1,171 @@
import type { ChatMessageRecord } from '@shared/domain/session';
import type { RunTimelineEventRecord } from '@shared/domain/runTimeline';
/* ── Input / output types ──────────────────────────────────── */
/** A flat activity stream item (the existing model). */
export type ActivityStreamItem =
| { kind: 'thinking-step'; message: ChatMessageRecord }
| { kind: 'timeline-event'; event: RunTimelineEventRecord };
/** A grouped timeline item ready for rendering. */
export type GroupedActivityItem =
| { kind: 'intent-divider'; intentText: string; event: RunTimelineEventRecord }
| { kind: 'single-event'; event: RunTimelineEventRecord }
| { kind: 'tool-group'; toolName: string; events: RunTimelineEventRecord[] }
| { kind: 'thinking-group'; messages: ChatMessageRecord[] }
| { kind: 'single-thinking'; message: ChatMessageRecord };
/* ── Events to skip in the inline panel ────────────────────── */
const SKIP_EVENT_KINDS = new Set(['run-started', 'thinking']);
/* ── Build the flat stream (moved from TurnActivityPanel) ──── */
export function buildActivityStream(
thinkingMessages: ChatMessageRecord[],
events: readonly RunTimelineEventRecord[],
): ActivityStreamItem[] {
const items: ActivityStreamItem[] = [];
for (const msg of thinkingMessages) {
items.push({ kind: 'thinking-step', message: msg });
}
for (const event of events) {
if (SKIP_EVENT_KINDS.has(event.kind)) continue;
items.push({ kind: 'timeline-event', event });
}
items.sort((a, b) => {
const tsA = a.kind === 'thinking-step' ? a.message.createdAt : a.event.occurredAt;
const tsB = b.kind === 'thinking-step' ? b.message.createdAt : b.event.occurredAt;
return new Date(tsA).getTime() - new Date(tsB).getTime();
});
return items;
}
/* ── Group the flat stream into displayable chunks ─────────── */
export function groupActivityStream(items: ActivityStreamItem[]): GroupedActivityItem[] {
const result: GroupedActivityItem[] = [];
let i = 0;
while (i < items.length) {
const item = items[i];
// ── Thinking steps: group consecutive runs ──────────────
if (item.kind === 'thinking-step') {
const group: ChatMessageRecord[] = [item.message];
let j = i + 1;
while (j < items.length && items[j].kind === 'thinking-step') {
group.push((items[j] as { kind: 'thinking-step'; message: ChatMessageRecord }).message);
j++;
}
if (group.length === 1) {
result.push({ kind: 'single-thinking', message: group[0] });
} else {
result.push({ kind: 'thinking-group', messages: group });
}
i = j;
continue;
}
// ── Timeline events ─────────────────────────────────────
const event = item.event;
// report_intent → phase divider
if (event.kind === 'tool-call' && event.toolName === 'report_intent') {
const intentText =
typeof event.toolArguments?.intent === 'string'
? event.toolArguments.intent
: '';
if (intentText) {
result.push({ kind: 'intent-divider', intentText, event });
}
// Skip silently when intent text is empty
i++;
continue;
}
// Tool calls: group consecutive calls of the same tool
if (event.kind === 'tool-call' && event.toolName) {
const toolName = event.toolName;
const group: RunTimelineEventRecord[] = [event];
let j = i + 1;
while (j < items.length) {
const next = items[j];
if (next.kind !== 'timeline-event') break;
if (next.event.kind !== 'tool-call') break;
if (next.event.toolName !== toolName) break;
group.push(next.event);
j++;
}
if (group.length === 1) {
result.push({ kind: 'single-event', event: group[0] });
} else {
result.push({ kind: 'tool-group', toolName, events: group });
}
i = j;
continue;
}
// Everything else: single event
result.push({ kind: 'single-event', event });
i++;
}
return result;
}
/* ── Extract the latest intent from events ─────────────────── */
export function extractLatestIntent(events: readonly RunTimelineEventRecord[]): string | undefined {
for (let i = events.length - 1; i >= 0; i--) {
const e = events[i];
if (
e.kind === 'tool-call'
&& e.toolName === 'report_intent'
&& typeof e.toolArguments?.intent === 'string'
&& e.toolArguments.intent.trim()
) {
return e.toolArguments.intent.trim();
}
}
return undefined;
}
/* ── Generate a fallback summary from the tool mix ─────────── */
export function generateActivitySummary(events: readonly RunTimelineEventRecord[]): string | undefined {
const toolCounts = new Map<string, number>();
let editCount = 0;
let searchCount = 0;
let viewCount = 0;
for (const e of events) {
if (e.kind !== 'tool-call' || !e.toolName) continue;
if (e.toolName === 'report_intent') continue;
toolCounts.set(e.toolName, (toolCounts.get(e.toolName) ?? 0) + 1);
if (e.toolName === 'edit' || e.toolName === 'create') editCount++;
else if (e.toolName === 'grep' || e.toolName === 'glob' || e.toolName === 'lsp') searchCount++;
else if (e.toolName === 'view') viewCount++;
}
const parts: string[] = [];
if (searchCount > 0) parts.push(`searched ${searchCount} ${searchCount === 1 ? 'pattern' : 'patterns'}`);
if (viewCount > 0) parts.push(`viewed ${viewCount} ${viewCount === 1 ? 'file' : 'files'}`);
if (editCount > 0) parts.push(`edited ${editCount} ${editCount === 1 ? 'file' : 'files'}`);
if (parts.length === 0) {
const total = Array.from(toolCounts.values()).reduce((a, b) => a + b, 0);
if (total > 0) return `${total} ${total === 1 ? 'action' : 'actions'}`;
return undefined;
}
// Capitalize first part
parts[0] = parts[0].charAt(0).toUpperCase() + parts[0].slice(1);
return parts.join(', ');
}
+2 -3
View File
@@ -5,6 +5,7 @@ import type {
SessionRunStatus,
} from '@shared/domain/runTimeline';
import { buildMarkdownExcerpt } from '@shared/utils/markdownText';
import { formatToolCallPrimaryLabel } from '@renderer/lib/toolCallSummary';
export function formatRunTimestamp(isoDate: string): string {
try {
@@ -78,9 +79,7 @@ export function formatEventLabel(event: RunTimelineEventRecord): string {
}
return event.targetAgentName ? `Handoff to ${event.targetAgentName}` : 'Handoff';
case 'tool-call':
return event.toolName
? `${event.agentName ?? 'Agent'} used ${event.toolName}`
: `${event.agentName ?? 'Agent'} tool call`;
return formatToolCallPrimaryLabel(event.toolName, event.toolArguments);
case 'approval':
if (event.status === 'completed') {
return event.approvalTitle ? `${event.approvalTitle} approved` : 'Approval granted';
+117 -4
View File
@@ -1,4 +1,4 @@
import type { AgentNodeConfig } from '@shared/domain/workflow';
import type { AgentNodeConfig, WorkflowAgentHierarchy, WorkflowOrchestrationMode } from '@shared/domain/workflow';
import type { SessionEventRecord } from '@shared/domain/event';
import type { QuotaSnapshot, WorkflowDiagnosticKind, WorkflowDiagnosticSeverity } from '@shared/contracts/sidecar';
@@ -7,6 +7,9 @@ export interface AgentActivityState {
agentName: string;
activityType?: SessionEventRecord['activityType'];
toolName?: string;
toolArguments?: Record<string, unknown>;
subworkflowNodeId?: string;
subworkflowName?: string;
}
export interface SessionUsageState {
@@ -30,9 +33,14 @@ export function applySessionEventActivity(
event: SessionEventRecord,
): SessionActivityMap {
if (event.kind === 'agent-activity') {
const agentKey = resolveAgentKey(event);
const isSubworkflowLifecycle =
event.activityType === 'subworkflow-started' || event.activityType === 'subworkflow-completed';
const agentKey = isSubworkflowLifecycle
? event.subworkflowNodeId?.trim()
: resolveAgentKey(event);
if (!agentKey) {
console.warn('[aryx activity] Dropping agent-activity event without agentId/agentName.', event);
console.warn('[aryx activity] Dropping agent-activity event without key.', event);
return current;
}
@@ -42,9 +50,12 @@ export function applySessionEventActivity(
...(current[event.sessionId] ?? {}),
[agentKey]: {
agentId: event.agentId ?? agentKey,
agentName: event.agentName?.trim() || event.agentId?.trim() || agentKey,
agentName: event.agentName?.trim() || event.subworkflowName?.trim() || event.agentId?.trim() || agentKey,
activityType: event.activityType,
toolName: event.toolName,
toolArguments: event.toolArguments,
subworkflowNodeId: event.subworkflowNodeId,
subworkflowName: event.subworkflowName,
},
},
};
@@ -144,6 +155,88 @@ export function isAgentActivityCompleted(activity: AgentActivityState | undefine
return activity?.activityType === 'completed';
}
export type SubWorkflowGroupStatus = 'idle' | 'running' | 'completed';
export interface SubWorkflowActivityGroup {
nodeId: string;
name: string;
workflowId?: string;
orchestrationMode: WorkflowOrchestrationMode;
status: SubWorkflowGroupStatus;
agents: AgentActivityRow[];
}
export interface GroupedActivityRows {
topLevelAgents: AgentActivityRow[];
subWorkflows: SubWorkflowActivityGroup[];
}
function resolveSubWorkflowGroupStatus(
agents: AgentActivityRow[],
lifecycleEntry: AgentActivityState | undefined,
): SubWorkflowGroupStatus {
if (lifecycleEntry?.activityType === 'subworkflow-completed') return 'completed';
if (lifecycleEntry?.activityType === 'subworkflow-started') return 'running';
if (agents.some((a) => isAgentActivityActive(a.activity))) return 'running';
if (agents.some((a) => isAgentActivityCompleted(a.activity))) return 'completed';
return 'idle';
}
export function buildGroupedActivityRows(
current: SessionActivityState | undefined,
hierarchy: WorkflowAgentHierarchy,
): GroupedActivityRows {
const topLevelAgents = buildAgentActivityRows(current, hierarchy.topLevelAgents);
const subWorkflows: SubWorkflowActivityGroup[] = hierarchy.subWorkflows.map((sub) => {
const agents = buildAgentActivityRows(current, sub.agents);
const lifecycleEntry = current?.[sub.nodeId];
return {
nodeId: sub.nodeId,
name: sub.workflowName || sub.nodeLabel,
workflowId: sub.workflowId,
orchestrationMode: sub.orchestrationMode,
status: resolveSubWorkflowGroupStatus(agents, lifecycleEntry),
agents,
};
});
// Pick up agents that arrived via activity events with a subworkflowNodeId
// but whose sub-workflow isn't in the statically-resolved hierarchy (e.g.
// a referenced workflow that couldn't be resolved at design time).
if (current) {
const knownKeys = new Set([
...topLevelAgents.map((a) => a.key),
...subWorkflows.flatMap((sw) => [sw.nodeId, ...sw.agents.map((a) => a.key)]),
]);
const dynamicGroups = new Map<string, { name: string; agents: AgentActivityRow[] }>();
for (const [key, state] of Object.entries(current)) {
if (knownKeys.has(key)) continue;
if (!state.subworkflowNodeId) continue;
if (state.activityType === 'subworkflow-started' || state.activityType === 'subworkflow-completed') continue;
const group = dynamicGroups.get(state.subworkflowNodeId)
?? { name: state.subworkflowName ?? state.subworkflowNodeId, agents: [] };
group.agents.push({ key, agentName: state.agentName, activity: state });
dynamicGroups.set(state.subworkflowNodeId, group);
}
for (const [nodeId, group] of dynamicGroups) {
const lifecycleEntry = current[nodeId];
subWorkflows.push({
nodeId,
name: group.name,
orchestrationMode: 'sequential',
status: resolveSubWorkflowGroupStatus(group.agents, lifecycleEntry),
agents: group.agents,
});
}
}
return { topLevelAgents, subWorkflows };
}
function removeSessionActivity(
current: SessionActivityMap,
sessionId: string,
@@ -278,6 +371,26 @@ function formatDiagnosticLabel(
function formatTurnEventEntry(event: SessionEventRecord): TurnEventEntry | undefined {
switch (event.kind) {
case 'agent-activity': {
if (event.activityType === 'subworkflow-started') {
return {
kind: event.kind,
occurredAt: event.occurredAt,
label: `Sub-workflow started: ${event.subworkflowName ?? event.subworkflowNodeId ?? 'unknown'}`,
phase: 'start',
};
}
if (event.activityType === 'subworkflow-completed') {
return {
kind: event.kind,
occurredAt: event.occurredAt,
label: `Sub-workflow completed: ${event.subworkflowName ?? event.subworkflowNodeId ?? 'unknown'}`,
phase: 'end',
success: true,
};
}
return undefined;
}
case 'subagent':
return {
kind: event.kind,
+196
View File
@@ -114,3 +114,199 @@ export function getDisplayableArguments(
&& value !== '',
);
}
/* ── Verb-based primary labels ─────────────────────────────── */
function shortenPath(rawPath: string): string {
const normalized = rawPath.replace(/\\/g, '/');
const lastSlash = normalized.lastIndexOf('/');
const fileName = lastSlash >= 0 ? normalized.slice(lastSlash + 1) : normalized;
return truncateSummary(fileName);
}
function pathWithRange(args: Record<string, unknown>): string | undefined {
const path = stringArg(args, 'path');
if (!path) return undefined;
const name = shortenPath(path);
const range = args['view_range'] ?? args['viewRange'];
if (Array.isArray(range) && range.length === 2) {
return `${name}:${range[0]}-${range[1]}`;
}
return name;
}
type VerbLabelExtractor = (args: Record<string, unknown>) => string;
const verbLabels: Record<string, VerbLabelExtractor> = {
view: (args) => {
const target = pathWithRange(args);
if (target) return `Viewed \`${target}\``;
const fb = fallbackSummary(args);
return fb ? `Viewed \`${fb}\`` : 'view';
},
edit: (args) => {
const name = stringArg(args, 'path') ? shortenPath(stringArg(args, 'path')!) : undefined;
if (name) return `Edited \`${name}\``;
const fb = fallbackSummary(args);
return fb ? `Edited \`${fb}\`` : 'edit';
},
create: (args) => {
const name = stringArg(args, 'path') ? shortenPath(stringArg(args, 'path')!) : undefined;
if (name) return `Created \`${name}\``;
const fb = fallbackSummary(args);
return fb ? `Created \`${fb}\`` : 'create';
},
grep: (args) => {
const pattern = stringArg(args, 'pattern');
if (pattern) return `Searched for \`${truncateSummary(pattern)}\``;
const fb = fallbackSummary(args);
return fb ? `Searched for \`${fb}\`` : 'grep';
},
glob: (args) => {
const pattern = stringArg(args, 'pattern');
if (pattern) return `Glob \`${truncateSummary(pattern)}\``;
const fb = fallbackSummary(args);
return fb ? `Glob \`${fb}\`` : 'glob';
},
lsp: (args) => {
const op = stringArg(args, 'operation');
const file = stringArg(args, 'file') ? shortenPath(stringArg(args, 'file')!) : undefined;
if (op && file) return `LSP ${op} \`${file}\``;
return op ? `LSP ${op}` : 'lsp';
},
powershell: (args) => {
const cmd = stringArg(args, 'command');
if (cmd) return `Ran \`${truncateSummary(cmd)}\``;
const fb = fallbackSummary(args);
return fb ? `Ran \`${fb}\`` : 'powershell';
},
web_fetch: (args) => {
const url = stringArg(args, 'url');
if (!url) return 'web_fetch';
try {
const hostname = new URL(url).hostname;
return `Fetched \`${hostname}\``;
} catch {
return `Fetched \`${truncateSummary(url)}\``;
}
},
web_search: (args) => {
const query = stringArg(args, 'query');
return query ? `Searched web: "${truncateSummary(query)}"` : 'web_search';
},
sql: (args) => {
const desc = stringArg(args, 'description');
return desc ? `SQL: ${truncateSummary(desc)}` : 'sql';
},
task: (args) => {
const desc = stringArg(args, 'description');
return desc ? `Launched agent: ${truncateSummary(desc)}` : 'task';
},
ask_user: (args) => {
const q = stringArg(args, 'question');
return q ? `Asked: "${truncateSummary(q)}"` : 'ask_user';
},
skill: (args) => {
const name = stringArg(args, 'skill');
return name ? `Used skill \`${name}\`` : 'skill';
},
store_memory: () => 'Stored a memory',
report_intent: (args) => {
const intent = stringArg(args, 'intent');
return intent ? intent : 'Updated intent';
},
};
/**
* Produces a verb-based, context-rich label for a tool call.
* E.g. "Viewed `ChatPane.tsx:148-250`", "Searched for `toolCall`".
* Falls back to "Used <toolName>" when no specific formatter exists.
*/
export function formatToolCallPrimaryLabel(
toolName: string | undefined,
toolArguments: Record<string, unknown> | undefined,
): string {
if (!toolName) return 'Tool call';
const args = toolArguments ?? {};
// GitHub MCP tools
if (toolName.startsWith('github-')) {
const detail = summarizeGitHub(toolName, args);
const shortName = toolName.replace(/^github-mcp-server-/, '').replace(/_/g, ' ');
return detail ? `GitHub: ${shortName}${detail}` : `GitHub: ${shortName}`;
}
const extractor = verbLabels[toolName];
if (extractor) {
return extractor(args);
}
// Unknown tool — try to extract something useful
const detail = fallbackSummary(args);
return detail ? `Used ${toolName}: ${detail}` : `Used ${toolName}`;
}
/**
* Produces a compact group label for N consecutive calls of the same tool.
* E.g. "Viewed 4 files", "Ran 3 commands", "Searched 5 patterns".
*/
export function formatToolGroupLabel(toolName: string, count: number): string {
const n = count;
switch (toolName) {
case 'view': return `Viewed ${n} ${n === 1 ? 'file' : 'files'}`;
case 'edit': return `Edited ${n} ${n === 1 ? 'file' : 'files'}`;
case 'create': return `Created ${n} ${n === 1 ? 'file' : 'files'}`;
case 'grep': return `Searched ${n} ${n === 1 ? 'pattern' : 'patterns'}`;
case 'glob': return `Glob ${n} ${n === 1 ? 'pattern' : 'patterns'}`;
case 'lsp': return `${n} LSP ${n === 1 ? 'operation' : 'operations'}`;
case 'powershell': return `Ran ${n} ${n === 1 ? 'command' : 'commands'}`;
case 'web_fetch': return `Fetched ${n} ${n === 1 ? 'URL' : 'URLs'}`;
case 'sql': return `${n} SQL ${n === 1 ? 'query' : 'queries'}`;
case 'task': return `Launched ${n} ${n === 1 ? 'agent' : 'agents'}`;
default: return `${n} ${toolName} ${n === 1 ? 'call' : 'calls'}`;
}
}
/**
* Extracts a short contextual snippet for a tool call to show inside
* a grouped row's detail list (e.g. the file path for view, the pattern for grep).
*/
export function extractToolCallSnippet(
toolName: string,
toolArguments: Record<string, unknown> | undefined,
): string | undefined {
if (!toolArguments) return undefined;
let result: string | undefined;
switch (toolName) {
case 'view':
case 'edit':
case 'create':
result = pathWithRange(toolArguments) ?? stringArg(toolArguments, 'path');
break;
case 'grep':
case 'glob':
result = stringArg(toolArguments, 'pattern');
break;
case 'powershell':
result = stringArg(toolArguments, 'command') ? truncateSummary(stringArg(toolArguments, 'command')!) : undefined;
break;
case 'lsp': {
const op = stringArg(toolArguments, 'operation');
const file = stringArg(toolArguments, 'file') ? shortenPath(stringArg(toolArguments, 'file')!) : undefined;
result = op && file ? `${op} ${file}` : op ?? undefined;
break;
}
case 'web_fetch':
result = stringArg(toolArguments, 'url');
break;
case 'sql':
result = stringArg(toolArguments, 'description');
break;
case 'task':
result = stringArg(toolArguments, 'description');
break;
}
// Fall back to the first usable string value from any argument key
return result ?? fallbackSummary(toolArguments);
}
-1
View File
@@ -3,7 +3,6 @@ import { createRoot } from 'react-dom/client';
import '@fontsource-variable/outfit';
import '@fontsource-variable/dm-sans';
import '@fontsource-variable/jetbrains-mono';
import App from '@renderer/App';
import '@renderer/styles.css';
+13 -1
View File
@@ -663,6 +663,17 @@ body {
animation: turn-activity-row-in 0.15s cubic-bezier(0.16, 1, 0.3, 1) both;
}
@keyframes intent-divider-in {
from {
opacity: 0;
transform: scaleX(0.7);
}
}
.turn-activity-row[role="separator"] {
animation: intent-divider-in 0.2s cubic-bezier(0.16, 1, 0.3, 1) both;
}
/* ── Respect reduced motion ──────────────────────────────────── */
@media (prefers-reduced-motion: reduce) {
@@ -677,7 +688,8 @@ body {
.banner-slide-enter,
.update-banner-enter,
.turn-activity-enter,
.turn-activity-row {
.turn-activity-row,
.turn-activity-row[role="separator"] {
animation: none;
}
}
+9 -1
View File
@@ -271,7 +271,13 @@ export interface MessageReclassifiedEvent {
newKind: 'thinking';
}
export type AgentActivityType = 'thinking' | 'tool-calling' | 'handoff' | 'completed';
export type AgentActivityType =
| 'thinking'
| 'tool-calling'
| 'handoff'
| 'completed'
| 'subworkflow-started'
| 'subworkflow-completed';
export interface ToolCallFileChangePreview {
path: string;
@@ -286,6 +292,8 @@ export interface AgentActivityEvent {
activityType: AgentActivityType;
agentId?: string;
agentName?: string;
subworkflowNodeId?: string;
subworkflowName?: string;
sourceAgentId?: string;
sourceAgentName?: string;
toolName?: string;
+9 -1
View File
@@ -8,7 +8,13 @@ import type {
WorkflowDiagnosticSeverity,
} from '@shared/contracts/sidecar';
export type SessionActivityType = 'thinking' | 'tool-calling' | 'handoff' | 'completed';
export type SessionActivityType =
| 'thinking'
| 'tool-calling'
| 'handoff'
| 'completed'
| 'subworkflow-started'
| 'subworkflow-completed';
export type SessionEventKind =
| 'status'
@@ -42,6 +48,8 @@ export interface SessionEventRecord {
activityType?: SessionActivityType;
agentId?: string;
agentName?: string;
subworkflowNodeId?: string;
subworkflowName?: string;
sourceAgentId?: string;
sourceAgentName?: string;
toolName?: string;
+1
View File
@@ -494,6 +494,7 @@ function normalizeRunTimelineEvent(
targetAgentName: normalizeOptionalString(event.targetAgentName),
toolName: normalizeOptionalString(event.toolName),
toolCallId: normalizeOptionalString(event.toolCallId),
toolArguments: event.toolArguments,
fileChanges: normalizeToolCallFileChanges(event.fileChanges),
approvalId: normalizeOptionalString(event.approvalId),
approvalKind: event.approvalKind,
+62
View File
@@ -536,6 +536,68 @@ export function resolveWorkflowAgents(workflow: WorkflowDefinition): AgentNodeCo
});
}
export interface SubWorkflowGroupDescriptor {
nodeId: string;
nodeLabel: string;
workflowId?: string;
workflowName: string;
orchestrationMode: WorkflowOrchestrationMode;
agents: AgentNodeConfig[];
}
export interface WorkflowAgentHierarchy {
topLevelAgents: AgentNodeConfig[];
subWorkflows: SubWorkflowGroupDescriptor[];
}
export function resolveWorkflowAgentHierarchy(
workflow: WorkflowDefinition,
options?: WorkflowResolutionOptions,
): WorkflowAgentHierarchy {
const topLevelAgents = resolveWorkflowAgents(workflow);
const subWorkflows: SubWorkflowGroupDescriptor[] = [];
const subWorkflowNodes = workflow.graph.nodes
.filter((node): node is WorkflowNode & { config: SubWorkflowConfig } => node.kind === 'sub-workflow')
.slice()
.sort((a, b) => {
const orderA = a.order ?? Number.MAX_SAFE_INTEGER;
const orderB = b.order ?? Number.MAX_SAFE_INTEGER;
if (orderA !== orderB) return orderA - orderB;
return a.label.localeCompare(b.label);
});
for (const node of subWorkflowNodes) {
const subWorkflowDef = resolveSubWorkflowDefinition(node, options);
if (!subWorkflowDef) continue;
const agents = resolveWorkflowAgents(subWorkflowDef);
const mode = inferWorkflowOrchestrationMode(subWorkflowDef, options);
subWorkflows.push({
nodeId: node.id,
nodeLabel: node.label,
workflowId: node.config.workflowId,
workflowName: subWorkflowDef.name || node.label,
orchestrationMode: mode,
agents,
});
}
return { topLevelAgents, subWorkflows };
}
function resolveSubWorkflowDefinition(
node: WorkflowNode & { config: SubWorkflowConfig },
options?: WorkflowResolutionOptions,
): WorkflowDefinition | undefined {
if (node.config.inlineWorkflow) return node.config.inlineWorkflow;
if (node.config.workflowId && options?.resolveWorkflow) {
return options.resolveWorkflow(node.config.workflowId);
}
return undefined;
}
function hasWorkflowExecutionFanEdges(
workflow: WorkflowDefinition,
options?: WorkflowResolutionOptions,
+71
View File
@@ -0,0 +1,71 @@
import { describe, expect, mock, test } from 'bun:test';
import type { SidecarCapabilities } from '@shared/contracts/sidecar';
mock.module('electron', () => {
const electronMock = {
app: {
isPackaged: false,
getAppPath: () => 'C:\\workspace\\personal\\repositories\\aryx',
getPath: () => 'C:\\workspace\\personal\\repositories\\aryx\\tests\\fixtures',
},
dialog: {
showOpenDialog: async () => ({ canceled: true, filePaths: [] }),
},
shell: {
openPath: async () => '',
},
};
return {
...electronMock,
default: electronMock,
};
});
mock.module('keytar', () => ({
default: {
getPassword: async () => null,
setPassword: async () => undefined,
deletePassword: async () => false,
},
}));
const { AryxAppService } = await import('@main/AryxAppService');
describe('AryxAppService dependency injection', () => {
test('uses injected sidecar dependencies for capability lookups', async () => {
const capabilities: SidecarCapabilities = {
runtime: 'dotnet-maf',
modes: {
single: { available: true },
sequential: { available: true },
concurrent: { available: true },
handoff: { available: true },
'group-chat': { available: true },
magentic: { available: true },
},
models: [
{
id: 'gpt-5.4',
name: 'GPT-5.4',
},
],
runtimeTools: [],
connection: {
status: 'ready',
summary: 'Ready',
checkedAt: '2026-04-07T00:00:00.000Z',
},
};
const service = new AryxAppService({
sidecar: {
describeCapabilities: async () => capabilities,
dispose: async () => undefined,
} as never,
});
await expect(service.describeSidecarCapabilities()).resolves.toEqual(capabilities);
});
});
+14 -24
View File
@@ -183,7 +183,19 @@ function createService(
runTurn?: (command: RunTurnCommand) => Promise<[]>;
},
): InstanceType<typeof AryxAppService> {
const service = new AryxAppService();
const service = new AryxAppService({
gitService: {
captureWorkingTreeSnapshot: async (projectPath: string, scannedAt: string) => {
options?.onCaptureSnapshot?.(projectPath, scannedAt);
return options?.snapshot;
},
captureWorkingTreeBaseline: async () => [],
computeRunChangeSummary: async (projectPath: string) => {
options?.onComputeRunSummary?.(projectPath);
return options?.runSummary;
},
} as never,
});
const internals = service as unknown as Record<string, unknown>;
internals.loadWorkspace = async () => {
internals.workspace = workspace;
@@ -216,33 +228,11 @@ function createService(
computeRunChangeSummary: (projectPath: string) => Promise<ProjectGitRunChangeSummary | undefined>;
};
}
).sidecar = {
).sidecar = {
runTurn: async (command) => options?.runTurn ? options.runTurn(command) : [],
resolveApproval: async () => undefined,
resolveUserInput: async () => undefined,
};
(
service as unknown as {
gitService: {
captureWorkingTreeSnapshot: (
projectPath: string,
scannedAt: string,
) => Promise<ProjectGitWorkingTreeSnapshot | undefined>;
captureWorkingTreeBaseline: () => Promise<[]>;
computeRunChangeSummary: (projectPath: string) => Promise<ProjectGitRunChangeSummary | undefined>;
};
}
).gitService = {
captureWorkingTreeSnapshot: async (projectPath, scannedAt) => {
options?.onCaptureSnapshot?.(projectPath, scannedAt);
return options?.snapshot;
},
captureWorkingTreeBaseline: async () => [],
computeRunChangeSummary: async (projectPath) => {
options?.onComputeRunSummary?.(projectPath);
return options?.runSummary;
},
};
return service;
}
+14 -13
View File
@@ -70,26 +70,27 @@ function createService(workspace: WorkspaceState): {
service: InstanceType<typeof AryxAppService>;
snapshots: WorkspaceState[];
} {
const service = new AryxAppService();
const internals = service as unknown as Record<string, unknown>;
const snapshots: WorkspaceState[] = [];
const service = new AryxAppService({
probeMcpServers: (async (
servers: Array<{ id: string }>,
_tokenLookup?: (serverUrl: string) => string | undefined,
onResult?: (result: MockProbeResult) => void | Promise<void>,
) => {
probeCalls.push(servers.map((server) => server.id));
for (const result of probeResults) {
await onResult?.(result);
}
return probeResults;
}) as never,
});
const internals = service as unknown as Record<string, unknown>;
internals.loadWorkspace = async () => workspace;
internals.persistAndBroadcast = async (nextWorkspace: WorkspaceState) => {
snapshots.push(cloneWorkspaceState(nextWorkspace));
return nextWorkspace;
};
internals.probeMcpServers = async (
servers: Array<{ id: string }>,
_tokenLookup?: (serverUrl: string) => string | undefined,
onResult?: (result: MockProbeResult) => void | Promise<void>,
) => {
probeCalls.push(servers.map((server) => server.id));
for (const result of probeResults) {
await onResult?.(result);
}
return probeResults;
};
return { service, snapshots };
}
@@ -0,0 +1,255 @@
import { describe, expect, test } from 'bun:test';
import type { AgentActivityEvent } from '@shared/contracts/sidecar';
import type { SessionEventRecord } from '@shared/domain/event';
import { SCRATCHPAD_PROJECT_ID } from '@shared/domain/project';
import {
createSessionRunRecord,
type SessionRunRecord,
} from '@shared/domain/runTimeline';
import type { SessionRecord } from '@shared/domain/session';
import type { WorkflowDefinition } from '@shared/domain/workflow';
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
import { SessionTurnExecutor } from '@main/services/sessionTurnExecutor';
describe('SessionTurnExecutor agent activity', () => {
test('preserves subworkflow context on nested agent activity session events', async () => {
const { workspace, session, run } = createRunningContext();
const harness = createExecutor();
const { executor, emittedEvents, runUpdates } = harness;
const initialEventCount = run.events.length;
const activityEvent: AgentActivityEvent = {
type: 'agent-activity',
requestId: run.requestId,
sessionId: session.id,
activityType: 'thinking',
agentId: 'agent-1',
agentName: 'Primary',
subworkflowNodeId: 'nested-flow',
subworkflowName: 'Nested flow',
};
await (
executor as unknown as {
applyAgentActivity: (
workspace: WorkspaceState,
sessionId: string,
requestId: string,
event: AgentActivityEvent,
) => Promise<void>;
}
).applyAgentActivity(workspace, session.id, run.requestId, activityEvent);
expect(harness.saveCalls).toBe(1);
expect(runUpdates).toHaveLength(1);
expect(session.runs[0]?.events).toHaveLength(initialEventCount + 1);
expect(session.runs[0]?.events.at(-1)).toMatchObject({
kind: 'thinking',
agentId: 'agent-1',
agentName: 'Primary',
status: 'completed',
});
expect(emittedEvents).toHaveLength(1);
expect(emittedEvents[0]).toMatchObject({
sessionId: session.id,
kind: 'agent-activity',
activityType: 'thinking',
agentId: 'agent-1',
agentName: 'Primary',
subworkflowNodeId: 'nested-flow',
subworkflowName: 'Nested flow',
});
});
test('emits subworkflow lifecycle events without appending run timeline activity', async () => {
const { workspace, session, run } = createRunningContext();
const harness = createExecutor();
const { executor, emittedEvents, runUpdates } = harness;
const initialEventCount = run.events.length;
const activityEvent: AgentActivityEvent = {
type: 'agent-activity',
requestId: run.requestId,
sessionId: session.id,
activityType: 'subworkflow-started',
subworkflowNodeId: 'nested-flow',
subworkflowName: 'Nested flow',
};
await (
executor as unknown as {
applyAgentActivity: (
workspace: WorkspaceState,
sessionId: string,
requestId: string,
event: AgentActivityEvent,
) => Promise<void>;
}
).applyAgentActivity(workspace, session.id, run.requestId, activityEvent);
expect(harness.saveCalls).toBe(0);
expect(runUpdates).toHaveLength(0);
expect(session.runs[0]?.events).toHaveLength(initialEventCount);
expect(emittedEvents).toHaveLength(1);
expect(emittedEvents[0]).toMatchObject({
sessionId: session.id,
kind: 'agent-activity',
activityType: 'subworkflow-started',
subworkflowNodeId: 'nested-flow',
subworkflowName: 'Nested flow',
});
});
});
function createExecutor(): {
executor: SessionTurnExecutor;
emittedEvents: SessionEventRecord[];
runUpdates: SessionRunRecord[];
saveCalls: number;
} {
const emittedEvents: SessionEventRecord[] = [];
const runUpdates: SessionRunRecord[] = [];
let saveCalls = 0;
const executor = new SessionTurnExecutor({
saveWorkspace: async () => {
saveCalls += 1;
},
persistWorkspace: async (workspace) => workspace,
requireSession: (workspace, sessionId) => {
const session = workspace.sessions.find((candidate) => candidate.id === sessionId);
if (!session) {
throw new Error(`Missing session ${sessionId}`);
}
return session;
},
resolveSessionWorkflow: () => createWorkflow(),
updateSessionRun: (session, requestId, updater) => {
const runIndex = session.runs.findIndex((candidate) => candidate.requestId === requestId);
if (runIndex < 0) {
return undefined;
}
const nextRun = updater(session.runs[runIndex]!);
session.runs[runIndex] = nextRun;
return nextRun;
},
emitRunUpdated: (_sessionId, _occurredAt, run) => {
runUpdates.push(run);
},
emitSessionEvent: (event) => {
emittedEvents.push(event);
},
rejectPendingApprovals: () => [],
buildRunTurnToolingConfig: () => undefined,
runSidecarTurnWithCheckpointRecovery: async () => [],
handleApprovalRequested: async () => undefined,
handleUserInputRequested: async () => undefined,
handleMcpOAuthRequired: async () => undefined,
handleExitPlanModeRequested: async () => undefined,
handleTurnScopedEvent: async () => undefined,
sidecarResolveApproval: async () => undefined,
sidecarResolveUserInput: async () => undefined,
captureWorkingTreeSnapshot: async () => undefined,
captureWorkingTreeBaseline: async () => [],
refreshSessionRunGitSummary: async () => undefined,
cleanupWorkflowCheckpointRecovery: async () => undefined,
scheduleProjectGitRefresh: () => undefined,
loadAvailableModelCatalog: async () => [],
});
return {
executor,
emittedEvents,
runUpdates,
get saveCalls() {
return saveCalls;
},
};
}
function createRunningContext(): {
workspace: WorkspaceState;
session: SessionRecord;
run: SessionRunRecord;
} {
const workflow = createWorkflow();
const run = createSessionRunRecord({
requestId: 'turn-1',
project: {
id: SCRATCHPAD_PROJECT_ID,
path: 'C:\\scratchpad',
},
workingDirectory: 'C:\\scratchpad',
workspaceKind: 'scratchpad',
workflow,
triggerMessageId: 'msg-user-1',
startedAt: '2026-04-01T12:00:00.000Z',
});
const session: SessionRecord = {
id: 'session-1',
projectId: SCRATCHPAD_PROJECT_ID,
workflowId: workflow.id,
title: 'Activity session',
createdAt: '2026-04-01T12:00:00.000Z',
updatedAt: '2026-04-01T12:00:00.000Z',
status: 'running',
messages: [
{
id: 'msg-user-1',
role: 'user',
authorName: 'You',
content: 'Continue the workflow.',
createdAt: '2026-04-01T12:00:00.000Z',
},
],
runs: [run],
};
const workspace = createWorkspaceSeed();
workspace.sessions = [session];
workspace.workflows = [workflow];
return { workspace, session, run };
}
function createWorkflow(): WorkflowDefinition {
return {
id: 'workflow-handoff',
name: 'Activity flow',
description: '',
graph: {
nodes: [
{ id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
{
id: 'agent-1',
kind: 'agent',
label: 'Primary',
position: { x: 200, y: 0 },
order: 0,
config: {
kind: 'agent',
id: 'agent-1',
name: 'Primary',
description: '',
instructions: 'Help with the request.',
model: 'gpt-5.4',
},
},
{ id: 'end', kind: 'end', label: 'End', position: { x: 400, y: 0 }, config: { kind: 'end' } },
],
edges: [
{ id: 'edge-start-agent', source: 'start', target: 'agent-1', kind: 'direct' },
{ id: 'edge-agent-end', source: 'agent-1', target: 'end', kind: 'direct' },
],
},
settings: {
checkpointing: { enabled: true },
executionMode: 'off-thread',
orchestrationMode: 'handoff',
maxIterations: 4,
},
createdAt: '2026-04-01T00:00:00.000Z',
updatedAt: '2026-04-01T00:00:00.000Z',
};
}
+181
View File
@@ -0,0 +1,181 @@
import { describe, expect, test } from 'bun:test';
import type { WorkflowDefinition } from '@shared/domain/workflow';
import { exportWorkflowDefinition } from '@shared/domain/workflowSerialization';
import { createWorkspaceSeed } from '@shared/domain/workspace';
import { WorkflowManager } from '@main/services/workflowManager';
function createWorkflow(): WorkflowDefinition {
return {
id: 'workflow-test',
name: 'Workflow Test',
description: 'Simple workflow',
createdAt: '2026-04-05T00:00:00.000Z',
updatedAt: '2026-04-05T00:00:00.000Z',
graph: {
nodes: [
{ id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
{
id: 'agent-primary',
kind: 'agent',
label: 'Primary',
position: { x: 200, y: 0 },
order: 0,
config: {
kind: 'agent',
id: 'agent-primary',
name: 'Primary',
description: 'Main agent',
instructions: 'Help the user',
model: 'gpt-5.4',
},
},
{ id: 'end', kind: 'end', label: 'End', position: { x: 400, y: 0 }, config: { kind: 'end' } },
],
edges: [
{ id: 'e1', source: 'start', target: 'agent-primary', kind: 'direct' },
{ id: 'e2', source: 'agent-primary', target: 'end', kind: 'direct' },
],
},
settings: {
checkpointing: { enabled: false },
executionMode: 'off-thread',
},
};
}
function createSubWorkflow(
id: string,
name: string,
config: { workflowId?: string; inlineWorkflow?: WorkflowDefinition },
): WorkflowDefinition {
return {
id,
name,
description: `${name} description`,
createdAt: '2026-04-05T00:00:00.000Z',
updatedAt: '2026-04-05T00:00:00.000Z',
graph: {
nodes: [
{ id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
{
id: 'sub-workflow',
kind: 'sub-workflow',
label: 'Nested Workflow',
position: { x: 200, y: 0 },
config: {
kind: 'sub-workflow',
workflowId: config.workflowId,
inlineWorkflow: config.inlineWorkflow,
},
},
{ id: 'end', kind: 'end', label: 'End', position: { x: 400, y: 0 }, config: { kind: 'end' } },
],
edges: [
{ id: 'edge-start-sub', source: 'start', target: 'sub-workflow', kind: 'direct' },
{ id: 'edge-sub-end', source: 'sub-workflow', target: 'end', kind: 'direct' },
],
},
settings: {
checkpointing: { enabled: false },
executionMode: 'off-thread',
},
};
}
describe('WorkflowManager', () => {
test('saves workflows into workspace state and selects them', () => {
const workspace = createWorkspaceSeed();
const manager = new WorkflowManager();
const result = manager.saveWorkflow(workspace, createWorkflow());
expect(result.workflows.some((workflow) => workflow.id === 'workflow-test')).toBe(true);
expect(result.selectedWorkflowId).toBe('workflow-test');
});
test('creates workflow templates and workflows from templates', () => {
const workspace = createWorkspaceSeed();
const manager = new WorkflowManager();
manager.saveWorkflow(workspace, createWorkflow());
manager.saveWorkflowTemplate(workspace, 'workflow-test', {
name: 'Saved Template',
description: 'From workflow',
category: 'human-in-loop',
});
const template = workspace.workflowTemplates.find((candidate) => candidate.name === 'Saved Template');
expect(template).toBeDefined();
manager.createWorkflowFromTemplate(workspace, template!.id, { name: 'Template Copy' });
const createdWorkflow = workspace.workflows.find((workflow) => workflow.name === 'Template Copy');
expect(createdWorkflow).toBeDefined();
expect(workspace.selectedWorkflowId).toBe(createdWorkflow?.id);
});
test('imports exported yaml workflows', () => {
const manager = new WorkflowManager();
const yaml = exportWorkflowDefinition(createWorkflow(), 'yaml').content;
const workflow = manager.importWorkflow(yaml, 'yaml');
expect(workflow.id).toBe('workflow-test');
expect(workflow.name).toBe('Workflow Test');
});
test('rejects missing and circular sub-workflow references', () => {
const manager = new WorkflowManager();
const missingWorkspace = createWorkspaceSeed();
expect(() => manager.saveWorkflow(
missingWorkspace,
createSubWorkflow('parent', 'Parent', { workflowId: 'missing-child' }),
)).toThrow('references unknown workflow "missing-child"');
const circularWorkspace = createWorkspaceSeed();
circularWorkspace.workflows.push(
createSubWorkflow('workflow-b', 'Workflow B', { workflowId: 'workflow-a' }),
);
expect(() => manager.saveWorkflow(
circularWorkspace,
createSubWorkflow('workflow-a', 'Workflow A', { workflowId: 'workflow-b' }),
)).toThrow('circular sub-workflow reference');
});
test('prevents deleting referenced workflows and lists references through inline workflows', () => {
const workspace = createWorkspaceSeed();
const manager = new WorkflowManager();
manager.saveWorkflow(workspace, createWorkflow());
manager.saveWorkflow(
workspace,
createSubWorkflow('parent', 'Parent Workflow', { workflowId: 'workflow-test' }),
);
manager.saveWorkflow(
workspace,
createSubWorkflow('inline-parent', 'Inline Parent', {
inlineWorkflow: createSubWorkflow('inline-child', 'Inline Child', { workflowId: 'workflow-test' }),
}),
);
expect(() => manager.deleteWorkflow(workspace, 'workflow-test')).toThrow('cannot be deleted');
expect(manager.listWorkflowReferences(workspace, 'workflow-test')).toEqual([
{
referencingWorkflowId: 'parent',
referencingWorkflowName: 'Parent Workflow',
nodeId: 'sub-workflow',
nodeLabel: 'Nested Workflow',
},
{
referencingWorkflowId: 'inline-parent',
referencingWorkflowName: 'Inline Parent',
nodeId: 'sub-workflow',
nodeLabel: 'Nested Workflow',
},
]);
});
});
+244
View File
@@ -0,0 +1,244 @@
import { describe, expect, test } from 'bun:test';
import {
buildActivityStream,
groupActivityStream,
extractLatestIntent,
generateActivitySummary,
type ActivityStreamItem,
} from '@renderer/lib/activityGrouping';
import type { RunTimelineEventRecord } from '@shared/domain/runTimeline';
import type { ChatMessageRecord } from '@shared/domain/session';
function createEvent(overrides?: Partial<RunTimelineEventRecord>): RunTimelineEventRecord {
return {
id: `evt-${Math.random().toString(36).slice(2, 8)}`,
kind: 'tool-call',
occurredAt: '2026-04-01T12:00:00.000Z',
status: 'completed',
...overrides,
};
}
function createThinkingMessage(content: string, overrides?: Partial<ChatMessageRecord>): ChatMessageRecord {
return {
id: `msg-${Math.random().toString(36).slice(2, 8)}`,
role: 'assistant',
content,
createdAt: '2026-04-01T12:00:00.000Z',
messageKind: 'thinking',
...overrides,
} as ChatMessageRecord;
}
/* ── buildActivityStream ───────────────────────────────────── */
describe('buildActivityStream', () => {
test('merges thinking messages and events into chronological order', () => {
const msgs = [createThinkingMessage('think1', { createdAt: '2026-04-01T12:00:02.000Z' })];
const events = [
createEvent({ id: 'e1', occurredAt: '2026-04-01T12:00:01.000Z' }),
createEvent({ id: 'e2', occurredAt: '2026-04-01T12:00:03.000Z' }),
];
const stream = buildActivityStream(msgs, events);
expect(stream).toHaveLength(3);
expect(stream[0].kind).toBe('timeline-event');
expect(stream[1].kind).toBe('thinking-step');
expect(stream[2].kind).toBe('timeline-event');
});
test('skips run-started and thinking event kinds', () => {
const events = [
createEvent({ id: 'e1', kind: 'run-started' }),
createEvent({ id: 'e2', kind: 'thinking' }),
createEvent({ id: 'e3', kind: 'tool-call', toolName: 'view' }),
];
const stream = buildActivityStream([], events);
expect(stream).toHaveLength(1);
expect((stream[0] as { kind: 'timeline-event'; event: RunTimelineEventRecord }).event.id).toBe('e3');
});
});
/* ── groupActivityStream ───────────────────────────────────── */
describe('groupActivityStream', () => {
test('groups consecutive same-tool calls', () => {
const items: ActivityStreamItem[] = [
{ kind: 'timeline-event', event: createEvent({ id: 'e1', toolName: 'view' }) },
{ kind: 'timeline-event', event: createEvent({ id: 'e2', toolName: 'view' }) },
{ kind: 'timeline-event', event: createEvent({ id: 'e3', toolName: 'view' }) },
];
const grouped = groupActivityStream(items);
expect(grouped).toHaveLength(1);
expect(grouped[0].kind).toBe('tool-group');
if (grouped[0].kind === 'tool-group') {
expect(grouped[0].toolName).toBe('view');
expect(grouped[0].events).toHaveLength(3);
}
});
test('does not group non-consecutive same-tool calls', () => {
const items: ActivityStreamItem[] = [
{ kind: 'timeline-event', event: createEvent({ id: 'e1', toolName: 'view' }) },
{ kind: 'timeline-event', event: createEvent({ id: 'e2', toolName: 'grep' }) },
{ kind: 'timeline-event', event: createEvent({ id: 'e3', toolName: 'view' }) },
];
const grouped = groupActivityStream(items);
expect(grouped).toHaveLength(3);
expect(grouped[0].kind).toBe('single-event');
expect(grouped[1].kind).toBe('single-event');
expect(grouped[2].kind).toBe('single-event');
});
test('converts report_intent to intent-divider', () => {
const items: ActivityStreamItem[] = [
{
kind: 'timeline-event',
event: createEvent({
id: 'e1',
toolName: 'report_intent',
toolArguments: { intent: 'Exploring codebase' },
}),
},
];
const grouped = groupActivityStream(items);
expect(grouped).toHaveLength(1);
expect(grouped[0].kind).toBe('intent-divider');
if (grouped[0].kind === 'intent-divider') {
expect(grouped[0].intentText).toBe('Exploring codebase');
}
});
test('skips report_intent with empty intent text', () => {
const items: ActivityStreamItem[] = [
{
kind: 'timeline-event',
event: createEvent({
id: 'e1',
toolName: 'report_intent',
toolArguments: { intent: '' },
}),
},
];
const grouped = groupActivityStream(items);
expect(grouped).toHaveLength(0);
});
test('groups consecutive thinking steps', () => {
const items: ActivityStreamItem[] = [
{ kind: 'thinking-step', message: createThinkingMessage('thought 1') },
{ kind: 'thinking-step', message: createThinkingMessage('thought 2') },
{ kind: 'thinking-step', message: createThinkingMessage('thought 3') },
];
const grouped = groupActivityStream(items);
expect(grouped).toHaveLength(1);
expect(grouped[0].kind).toBe('thinking-group');
if (grouped[0].kind === 'thinking-group') {
expect(grouped[0].messages).toHaveLength(3);
}
});
test('keeps single thinking step as single-thinking', () => {
const items: ActivityStreamItem[] = [
{ kind: 'thinking-step', message: createThinkingMessage('solo thought') },
];
const grouped = groupActivityStream(items);
expect(grouped).toHaveLength(1);
expect(grouped[0].kind).toBe('single-thinking');
});
test('produces a mixed timeline with all item kinds', () => {
const items: ActivityStreamItem[] = [
{
kind: 'timeline-event',
event: createEvent({ id: 'intent', toolName: 'report_intent', toolArguments: { intent: 'Phase 1' } }),
},
{ kind: 'timeline-event', event: createEvent({ id: 'v1', toolName: 'view' }) },
{ kind: 'timeline-event', event: createEvent({ id: 'v2', toolName: 'view' }) },
{ kind: 'thinking-step', message: createThinkingMessage('thinking...') },
{ kind: 'timeline-event', event: createEvent({ id: 'g1', toolName: 'grep' }) },
{
kind: 'timeline-event',
event: createEvent({ id: 'intent2', toolName: 'report_intent', toolArguments: { intent: 'Phase 2' } }),
},
{ kind: 'timeline-event', event: createEvent({ id: 'e1', toolName: 'edit' }) },
];
const grouped = groupActivityStream(items);
expect(grouped.map((g) => g.kind)).toEqual([
'intent-divider',
'tool-group',
'single-thinking',
'single-event',
'intent-divider',
'single-event',
]);
});
});
/* ── extractLatestIntent ───────────────────────────────────── */
describe('extractLatestIntent', () => {
test('returns the intent from the last report_intent event', () => {
const events = [
createEvent({ id: 'e1', toolName: 'report_intent', toolArguments: { intent: 'Phase 1' } }),
createEvent({ id: 'e2', toolName: 'view' }),
createEvent({ id: 'e3', toolName: 'report_intent', toolArguments: { intent: 'Phase 2' } }),
];
expect(extractLatestIntent(events)).toBe('Phase 2');
});
test('returns undefined when no report_intent events exist', () => {
const events = [
createEvent({ id: 'e1', toolName: 'view' }),
createEvent({ id: 'e2', toolName: 'grep' }),
];
expect(extractLatestIntent(events)).toBeUndefined();
});
test('skips empty intent values', () => {
const events = [
createEvent({ id: 'e1', toolName: 'report_intent', toolArguments: { intent: 'Valid' } }),
createEvent({ id: 'e2', toolName: 'report_intent', toolArguments: { intent: ' ' } }),
];
expect(extractLatestIntent(events)).toBe('Valid');
});
});
/* ── generateActivitySummary ───────────────────────────────── */
describe('generateActivitySummary', () => {
test('generates summary from tool mix', () => {
const events = [
createEvent({ toolName: 'grep' }),
createEvent({ toolName: 'grep' }),
createEvent({ toolName: 'view' }),
createEvent({ toolName: 'view' }),
createEvent({ toolName: 'view' }),
createEvent({ toolName: 'edit' }),
];
const result = generateActivitySummary(events);
expect(result).toBe('Searched 2 patterns, viewed 3 files, edited 1 file');
});
test('excludes report_intent from counts', () => {
const events = [
createEvent({ toolName: 'report_intent' }),
createEvent({ toolName: 'view' }),
];
const result = generateActivitySummary(events);
expect(result).toBe('Viewed 1 file');
});
test('returns undefined for empty events', () => {
expect(generateActivitySummary([])).toBeUndefined();
});
test('falls back to total count for unknown tools', () => {
const events = [
createEvent({ toolName: 'custom_tool' }),
createEvent({ toolName: 'custom_tool' }),
];
const result = generateActivitySummary(events);
expect(result).toBe('2 actions');
});
});
+1 -1
View File
@@ -67,7 +67,7 @@ describe('run timeline formatting', () => {
kind: 'tool-call',
agentName: 'Writer',
toolName: 'file_search',
}))).toBe('Writer used file_search');
}))).toBe('Used file_search');
expect(formatEventLabel(createEvent({
kind: 'approval',
status: 'running',
+344
View File
@@ -99,6 +99,32 @@ describe('session activity helpers', () => {
});
});
test('includes toolArguments in activity state', () => {
const event: SessionEventRecord = {
sessionId: 'session-1',
kind: 'agent-activity',
occurredAt: '2026-03-23T00:00:00.000Z',
activityType: 'tool-calling',
agentId: 'architect',
agentName: 'Architect',
toolName: 'view',
toolArguments: { path: 'src/main.ts', view_range: [1, 50] },
};
const result = applySessionEventActivity({}, event);
expect(result).toEqual({
'session-1': {
architect: {
agentId: 'architect',
agentName: 'Architect',
activityType: 'tool-calling',
toolName: 'view',
toolArguments: { path: 'src/main.ts', view_range: [1, 50] },
},
},
});
});
test('warns when an agent-activity event is missing identifiers', () => {
const originalWarn = console.warn;
const warnings: unknown[][] = [];
@@ -710,4 +736,322 @@ describe('workflow diagnostic turn events', () => {
const entries = result['session-1']!;
expect(entries[0].label).toBe('Workflow warning');
});
test('formats subworkflow-started lifecycle event', () => {
const result = applyTurnEventLog({}, {
sessionId: 'session-1',
kind: 'agent-activity',
occurredAt: '2026-03-23T00:00:00.000Z',
activityType: 'subworkflow-started',
subworkflowNodeId: 'data-pipeline',
subworkflowName: 'Data Pipeline',
});
const entries = result['session-1']!;
expect(entries).toHaveLength(1);
expect(entries[0].label).toBe('Sub-workflow started: Data Pipeline');
expect(entries[0].phase).toBe('start');
});
test('formats subworkflow-completed lifecycle event', () => {
const result = applyTurnEventLog({}, {
sessionId: 'session-1',
kind: 'agent-activity',
occurredAt: '2026-03-23T00:00:00.000Z',
activityType: 'subworkflow-completed',
subworkflowNodeId: 'data-pipeline',
subworkflowName: 'Data Pipeline',
});
const entries = result['session-1']!;
expect(entries).toHaveLength(1);
expect(entries[0].label).toBe('Sub-workflow completed: Data Pipeline');
expect(entries[0].phase).toBe('end');
expect(entries[0].success).toBe(true);
});
});
/* ── Sub-workflow grouping tests ───────────────────────────── */
import {
buildGroupedActivityRows,
type SubWorkflowActivityGroup,
} from '@renderer/lib/sessionActivity';
import {
resolveWorkflowAgentHierarchy,
type SubWorkflowGroupDescriptor,
type WorkflowAgentHierarchy,
} from '@shared/domain/workflow';
describe('sub-workflow activity grouping', () => {
function makeWorkflow(overrides?: Partial<WorkflowDefinition>): WorkflowDefinition {
return {
id: 'wf-1',
name: 'Test Workflow',
description: 'A test workflow.',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
settings: { orchestrationMode: 'sequential', checkpointing: { enabled: false }, executionMode: 'off-thread' },
graph: {
nodes: [
{ id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
{ id: 'agent-a', kind: 'agent', label: 'Agent A', position: { x: 100, y: 0 }, order: 1, config: { kind: 'agent', id: 'agent-a', name: 'Agent A', description: '', instructions: '', model: 'gpt-5.4' } },
{ id: 'end', kind: 'end', label: 'End', position: { x: 200, y: 0 }, config: { kind: 'end' } },
],
edges: [
{ id: 'e1', source: 'start', target: 'agent-a', kind: 'direct' },
{ id: 'e2', source: 'agent-a', target: 'end', kind: 'direct' },
],
},
...overrides,
};
}
function makeSubWorkflow(): WorkflowDefinition {
return makeWorkflow({
id: 'sub-wf-1',
name: 'Sub Pipeline',
graph: {
nodes: [
{ id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
{ id: 'inner-agent', kind: 'agent', label: 'Inner Agent', position: { x: 100, y: 0 }, order: 1, config: { kind: 'agent', id: 'inner-agent', name: 'Inner Agent', description: '', instructions: '', model: 'gpt-5.4' } },
{ id: 'inner-agent-2', kind: 'agent', label: 'Inner Agent 2', position: { x: 200, y: 0 }, order: 2, config: { kind: 'agent', id: 'inner-agent-2', name: 'Inner Agent 2', description: '', instructions: '', model: 'gpt-5.4' } },
{ id: 'end', kind: 'end', label: 'End', position: { x: 300, y: 0 }, config: { kind: 'end' } },
],
edges: [
{ id: 'e1', source: 'start', target: 'inner-agent', kind: 'direct' },
{ id: 'e2', source: 'inner-agent', target: 'inner-agent-2', kind: 'direct' },
{ id: 'e3', source: 'inner-agent-2', target: 'end', kind: 'direct' },
],
},
});
}
test('flat workflow produces no sub-workflow groups', () => {
const workflow = makeWorkflow();
const hierarchy = resolveWorkflowAgentHierarchy(workflow);
const result = buildGroupedActivityRows(undefined, hierarchy);
expect(result.topLevelAgents).toHaveLength(1);
expect(result.topLevelAgents[0].agentName).toBe('Agent A');
expect(result.subWorkflows).toHaveLength(0);
});
test('workflow with inline sub-workflow produces grouped agents', () => {
const subWf = makeSubWorkflow();
const workflow = makeWorkflow({
graph: {
nodes: [
{ id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
{ id: 'agent-a', kind: 'agent', label: 'Agent A', position: { x: 100, y: 0 }, order: 1, config: { kind: 'agent', id: 'agent-a', name: 'Agent A', description: '', instructions: '', model: 'gpt-5.4' } },
{ id: 'sub-node', kind: 'sub-workflow', label: 'Sub Pipeline', position: { x: 200, y: 0 }, order: 2, config: { kind: 'sub-workflow', inlineWorkflow: subWf } },
{ id: 'end', kind: 'end', label: 'End', position: { x: 300, y: 0 }, config: { kind: 'end' } },
],
edges: [
{ id: 'e1', source: 'start', target: 'agent-a', kind: 'direct' },
{ id: 'e2', source: 'agent-a', target: 'sub-node', kind: 'direct' },
{ id: 'e3', source: 'sub-node', target: 'end', kind: 'direct' },
],
},
});
const hierarchy = resolveWorkflowAgentHierarchy(workflow);
expect(hierarchy.topLevelAgents).toHaveLength(1);
expect(hierarchy.subWorkflows).toHaveLength(1);
expect(hierarchy.subWorkflows[0].workflowName).toBe('Sub Pipeline');
expect(hierarchy.subWorkflows[0].agents).toHaveLength(2);
const result = buildGroupedActivityRows(undefined, hierarchy);
expect(result.topLevelAgents).toHaveLength(1);
expect(result.subWorkflows).toHaveLength(1);
expect(result.subWorkflows[0].agents).toHaveLength(2);
expect(result.subWorkflows[0].status).toBe('idle');
});
test('sub-workflow group status reflects agent activity', () => {
const subWf = makeSubWorkflow();
const workflow = makeWorkflow({
graph: {
nodes: [
{ id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
{ id: 'sub-node', kind: 'sub-workflow', label: 'Pipeline', position: { x: 100, y: 0 }, config: { kind: 'sub-workflow', inlineWorkflow: subWf } },
{ id: 'end', kind: 'end', label: 'End', position: { x: 200, y: 0 }, config: { kind: 'end' } },
],
edges: [
{ id: 'e1', source: 'start', target: 'sub-node', kind: 'direct' },
{ id: 'e2', source: 'sub-node', target: 'end', kind: 'direct' },
],
},
});
const hierarchy = resolveWorkflowAgentHierarchy(workflow);
// Running: when lifecycle event says 'subworkflow-started'
const runningActivity = {
'sub-node': { agentId: 'sub-node', agentName: 'Pipeline', activityType: 'subworkflow-started' as const },
'inner-agent': { agentId: 'inner-agent', agentName: 'Inner Agent', activityType: 'thinking' as const },
};
const running = buildGroupedActivityRows(runningActivity, hierarchy);
expect(running.subWorkflows[0].status).toBe('running');
// Completed: when lifecycle event says 'subworkflow-completed'
const completedActivity = {
'sub-node': { agentId: 'sub-node', agentName: 'Pipeline', activityType: 'subworkflow-completed' as const },
'inner-agent': { agentId: 'inner-agent', agentName: 'Inner Agent', activityType: 'completed' as const },
};
const completed = buildGroupedActivityRows(completedActivity, hierarchy);
expect(completed.subWorkflows[0].status).toBe('completed');
});
test('sub-workflow group derives running status from active agents', () => {
const subWf = makeSubWorkflow();
const workflow = makeWorkflow({
graph: {
nodes: [
{ id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
{ id: 'sub-node', kind: 'sub-workflow', label: 'Pipeline', position: { x: 100, y: 0 }, config: { kind: 'sub-workflow', inlineWorkflow: subWf } },
{ id: 'end', kind: 'end', label: 'End', position: { x: 200, y: 0 }, config: { kind: 'end' } },
],
edges: [
{ id: 'e1', source: 'start', target: 'sub-node', kind: 'direct' },
{ id: 'e2', source: 'sub-node', target: 'end', kind: 'direct' },
],
},
});
const hierarchy = resolveWorkflowAgentHierarchy(workflow);
// No lifecycle event, but agent is active — derive running status
const activity = {
'inner-agent': { agentId: 'inner-agent', agentName: 'Inner Agent', activityType: 'thinking' as const },
};
const result = buildGroupedActivityRows(activity, hierarchy);
expect(result.subWorkflows[0].status).toBe('running');
});
test('referenced sub-workflow resolves via options', () => {
const subWf = makeSubWorkflow();
const workflow = makeWorkflow({
graph: {
nodes: [
{ id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
{ id: 'sub-node', kind: 'sub-workflow', label: 'Ref Pipeline', position: { x: 100, y: 0 }, config: { kind: 'sub-workflow', workflowId: 'sub-wf-1' } },
{ id: 'end', kind: 'end', label: 'End', position: { x: 200, y: 0 }, config: { kind: 'end' } },
],
edges: [
{ id: 'e1', source: 'start', target: 'sub-node', kind: 'direct' },
{ id: 'e2', source: 'sub-node', target: 'end', kind: 'direct' },
],
},
});
const hierarchy = resolveWorkflowAgentHierarchy(workflow, {
resolveWorkflow: (id) => (id === 'sub-wf-1' ? subWf : undefined),
});
expect(hierarchy.subWorkflows).toHaveLength(1);
expect(hierarchy.subWorkflows[0].agents).toHaveLength(2);
expect(hierarchy.subWorkflows[0].workflowId).toBe('sub-wf-1');
});
test('dynamic grouping picks up unresolved sub-workflow agents from activity', () => {
const workflow = makeWorkflow(); // flat workflow, no sub-workflow nodes
const hierarchy = resolveWorkflowAgentHierarchy(workflow);
// Activity events arrive with subworkflowNodeId for agents not in the hierarchy
const activity = {
'agent-a': { agentId: 'agent-a', agentName: 'Agent A', activityType: 'thinking' as const },
'dynamic-agent': {
agentId: 'dynamic-agent',
agentName: 'Dynamic Agent',
activityType: 'tool-calling' as const,
subworkflowNodeId: 'dynamic-sub',
subworkflowName: 'Dynamic Sub',
toolName: 'search',
},
};
const result = buildGroupedActivityRows(activity, hierarchy);
expect(result.topLevelAgents).toHaveLength(1);
expect(result.subWorkflows).toHaveLength(1);
expect(result.subWorkflows[0].nodeId).toBe('dynamic-sub');
expect(result.subWorkflows[0].name).toBe('Dynamic Sub');
expect(result.subWorkflows[0].agents).toHaveLength(1);
expect(result.subWorkflows[0].agents[0].agentName).toBe('Dynamic Agent');
expect(result.subWorkflows[0].status).toBe('running');
});
});
describe('sub-workflow activity event handling', () => {
test('stores subworkflow-started lifecycle event keyed by subworkflowNodeId', () => {
const event: SessionEventRecord = {
sessionId: 'session-1',
kind: 'agent-activity',
occurredAt: '2026-03-23T00:00:00.000Z',
activityType: 'subworkflow-started',
subworkflowNodeId: 'pipeline-node',
subworkflowName: 'Data Pipeline',
};
const result = applySessionEventActivity({}, event);
expect(result['session-1']).toBeDefined();
expect(result['session-1']!['pipeline-node']).toEqual({
agentId: 'pipeline-node',
agentName: 'Data Pipeline',
activityType: 'subworkflow-started',
subworkflowNodeId: 'pipeline-node',
subworkflowName: 'Data Pipeline',
});
});
test('stores subworkflow-completed lifecycle event replacing started', () => {
const startEvent: SessionEventRecord = {
sessionId: 'session-1',
kind: 'agent-activity',
occurredAt: '2026-03-23T00:00:00.000Z',
activityType: 'subworkflow-started',
subworkflowNodeId: 'pipeline-node',
subworkflowName: 'Data Pipeline',
};
const completeEvent: SessionEventRecord = {
sessionId: 'session-1',
kind: 'agent-activity',
occurredAt: '2026-03-23T00:00:01.000Z',
activityType: 'subworkflow-completed',
subworkflowNodeId: 'pipeline-node',
subworkflowName: 'Data Pipeline',
};
let state = applySessionEventActivity({}, startEvent);
state = applySessionEventActivity(state, completeEvent);
expect(state['session-1']!['pipeline-node']?.activityType).toBe('subworkflow-completed');
});
test('propagates subworkflow context on regular agent activity events', () => {
const event: SessionEventRecord = {
sessionId: 'session-1',
kind: 'agent-activity',
occurredAt: '2026-03-23T00:00:00.000Z',
activityType: 'thinking',
agentId: 'inner-agent',
agentName: 'Inner Agent',
subworkflowNodeId: 'pipeline-node',
subworkflowName: 'Data Pipeline',
};
const result = applySessionEventActivity({}, event);
const agentState = result['session-1']!['inner-agent'];
expect(agentState).toBeDefined();
expect(agentState.subworkflowNodeId).toBe('pipeline-node');
expect(agentState.subworkflowName).toBe('Data Pipeline');
});
test('drops lifecycle event without subworkflowNodeId', () => {
const event: SessionEventRecord = {
sessionId: 'session-1',
kind: 'agent-activity',
occurredAt: '2026-03-23T00:00:00.000Z',
activityType: 'subworkflow-started',
// No subworkflowNodeId
};
const result = applySessionEventActivity({}, event);
expect(result['session-1']).toBeUndefined();
});
});
+119
View File
@@ -4,6 +4,9 @@ import {
formatToolCallSummary,
formatToolArgumentValue,
getDisplayableArguments,
formatToolCallPrimaryLabel,
formatToolGroupLabel,
extractToolCallSnippet,
} from '@renderer/lib/toolCallSummary';
describe('formatToolCallSummary', () => {
@@ -171,3 +174,119 @@ describe('getDisplayableArguments', () => {
expect(result).toEqual([['query', 'INSERT ...']]);
});
});
/* ── formatToolCallPrimaryLabel ────────────────────────────── */
describe('formatToolCallPrimaryLabel', () => {
test('produces verb-based label for view with path', () => {
expect(formatToolCallPrimaryLabel('view', { path: '/src/components/ChatPane.tsx' }))
.toBe('Viewed `ChatPane.tsx`');
});
test('includes view range', () => {
expect(formatToolCallPrimaryLabel('view', { path: '/src/index.ts', view_range: [10, 25] }))
.toBe('Viewed `index.ts:10-25`');
});
test('falls back to tool name when view has no args', () => {
expect(formatToolCallPrimaryLabel('view', {})).toBe('view');
});
test('produces verb-based label for edit', () => {
expect(formatToolCallPrimaryLabel('edit', { path: '/src/utils.ts', old_str: 'foo' }))
.toBe('Edited `utils.ts`');
});
test('produces verb-based label for create', () => {
expect(formatToolCallPrimaryLabel('create', { path: '/new-file.ts' }))
.toBe('Created `new-file.ts`');
});
test('produces verb-based label for grep', () => {
expect(formatToolCallPrimaryLabel('grep', { pattern: 'TODO', path: '/src' }))
.toBe('Searched for `TODO`');
});
test('produces verb-based label for powershell', () => {
expect(formatToolCallPrimaryLabel('powershell', { command: 'npm run build' }))
.toBe('Ran `npm run build`');
});
test('produces verb-based label for task', () => {
expect(formatToolCallPrimaryLabel('task', { description: 'Explore codebase' }))
.toBe('Launched agent: Explore codebase');
});
test('produces verb-based label for web_fetch with hostname', () => {
expect(formatToolCallPrimaryLabel('web_fetch', { url: 'https://example.com/page' }))
.toBe('Fetched `example.com`');
});
test('produces verb-based label for sql', () => {
expect(formatToolCallPrimaryLabel('sql', { description: 'Insert todos', query: 'INSERT ...' }))
.toBe('SQL: Insert todos');
});
test('uses fallback string arg when specific key is missing', () => {
expect(formatToolCallPrimaryLabel('view', { file_path: '/src/foo.ts' }))
.toBe('Viewed `/src/foo.ts`');
});
test('handles GitHub tools', () => {
const result = formatToolCallPrimaryLabel('github-mcp-server-search_code', { query: 'auth' });
expect(result).toContain('search code');
expect(result).toContain('auth');
});
test('falls back to "Used <tool>" for unknown tools', () => {
expect(formatToolCallPrimaryLabel('custom_tool', { data: 'test' }))
.toBe('Used custom_tool: test');
});
test('returns "Tool call" for undefined toolName', () => {
expect(formatToolCallPrimaryLabel(undefined, {})).toBe('Tool call');
});
});
/* ── formatToolGroupLabel ─────────────────────────────────── */
describe('formatToolGroupLabel', () => {
test('pluralizes correctly for view', () => {
expect(formatToolGroupLabel('view', 1)).toBe('Viewed 1 file');
expect(formatToolGroupLabel('view', 4)).toBe('Viewed 4 files');
});
test('pluralizes correctly for grep', () => {
expect(formatToolGroupLabel('grep', 1)).toBe('Searched 1 pattern');
expect(formatToolGroupLabel('grep', 3)).toBe('Searched 3 patterns');
});
test('uses generic format for unknown tools', () => {
expect(formatToolGroupLabel('custom', 2)).toBe('2 custom calls');
});
});
/* ── extractToolCallSnippet ───────────────────────────────── */
describe('extractToolCallSnippet', () => {
test('extracts file name for view', () => {
expect(extractToolCallSnippet('view', { path: '/src/components/ChatPane.tsx' }))
.toBe('ChatPane.tsx');
});
test('extracts pattern for grep', () => {
expect(extractToolCallSnippet('grep', { pattern: 'TODO' })).toBe('TODO');
});
test('extracts command for powershell', () => {
expect(extractToolCallSnippet('powershell', { command: 'npm test' })).toBe('npm test');
});
test('returns undefined for unknown tool', () => {
expect(extractToolCallSnippet('custom', {})).toBeUndefined();
});
test('falls back to first string arg when specific key is missing', () => {
expect(extractToolCallSnippet('view', { file_path: '/src/foo.ts' })).toBe('/src/foo.ts');
});
});
+66
View File
@@ -9,6 +9,7 @@ import {
upsertRunApprovalEvent,
upsertRunMessageEvent,
} from '@shared/domain/runTimeline';
import type { SessionRunRecord } from '@shared/domain/runTimeline';
import type { ProjectRecord } from '@shared/domain/project';
import type { PendingApprovalRecord } from '@shared/domain/approval';
@@ -265,6 +266,71 @@ describe('run timeline helpers', () => {
expect(normalizeSessionRunRecords(undefined)).toEqual([]);
});
test('preserves toolArguments through normalization round-trip', () => {
const baseRun = createSessionRunRecord({
requestId: 'turn-1',
project: createProject(),
workspaceKind: 'project',
workflow: createWorkflow(),
triggerMessageId: 'msg-user-1',
startedAt: '2026-03-23T00:00:01.000Z',
});
const run = appendRunActivityEvent(baseRun, {
activityType: 'tool-calling',
occurredAt: '2026-03-23T00:00:02.000Z',
agentId: 'agent-writer',
toolName: 'view',
toolCallId: 'tool-call-view-1',
toolArguments: { path: 'src/main.ts', view_range: [1, 50] },
});
// Simulate persisting and reloading via normalizeSessionRunRecords
const roundTripped = normalizeSessionRunRecords(
JSON.parse(JSON.stringify([run])) as SessionRunRecord[],
);
expect(roundTripped).toHaveLength(1);
const toolCallEvent = roundTripped[0].events.find(
(event) => event.kind === 'tool-call',
);
expect(toolCallEvent).toBeDefined();
expect(toolCallEvent!.toolArguments).toEqual({
path: 'src/main.ts',
view_range: [1, 50],
});
});
test('handles missing toolArguments gracefully during normalization', () => {
const baseRun = createSessionRunRecord({
requestId: 'turn-1',
project: createProject(),
workspaceKind: 'project',
workflow: createWorkflow(),
triggerMessageId: 'msg-user-1',
startedAt: '2026-03-23T00:00:01.000Z',
});
const run = appendRunActivityEvent(baseRun, {
activityType: 'tool-calling',
occurredAt: '2026-03-23T00:00:02.000Z',
agentId: 'agent-writer',
toolName: 'rg',
toolCallId: 'tool-call-rg-1',
// No toolArguments provided
});
const roundTripped = normalizeSessionRunRecords(
JSON.parse(JSON.stringify([run])) as SessionRunRecord[],
);
const toolCallEvent = roundTripped[0].events.find(
(event) => event.kind === 'tool-call',
);
expect(toolCallEvent).toBeDefined();
expect(toolCallEvent!.toolArguments).toBeUndefined();
});
test('tracks approval checkpoints as a single timeline event that can be resolved later', () => {
const baseRun = createSessionRunRecord({
requestId: 'turn-1',