mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-28 13:47:12 +02:00
refactor: remove legacy patterns system, unify on workflows
Remove the entire patterns domain model, IPC channels, sidecar services, renderer components, and tests. Sessions now bind exclusively to workflows via workflowId. Builtin workflows replace builtin patterns. Backend: - Make AgentNodeConfig standalone (no longer extends PatternAgentDefinition) - Add WorkflowOrchestrationMode and WorkflowExecutionDefinition - Create builtin workflows (single-agent, sequential, concurrent, handoff, group-chat) - Rewrite session model config helpers for workflow-only - Remove pattern IPC channels, handlers, and preload bindings - Merge createSession/createWorkflowSession into single method - Remove sidecar PatternGraphResolver, PatternValidator, pattern DTOs - Add workspace migration for legacy sessions (patternId -> workflowId) Frontend: - Delete PatternEditor, pattern-graph components, patternGraph lib - Delete NewSessionModal (session creation uses workflows directly) - Remove PatternsSection from SettingsPanel - Update App.tsx, ChatPane, ActivityPanel, Sidebar, RunTimeline, AgentConfigFields, InlinePills, sessionActivity to use workflow types - Delete pattern.ts domain module 78 files changed across backend and frontend. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
+19
-22
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
## What this system is
|
## What this system is
|
||||||
|
|
||||||
Aryx is a desktop workspace for Copilot-powered development work. It combines a persistent session model, project-aware context, reusable multi-agent orchestration patterns, optional external tooling, and live run visibility inside a single Electron application.
|
Aryx is a desktop workspace for Copilot-powered development work. It combines a persistent session model, project-aware context, reusable workflow orchestration, optional external tooling, and live run visibility inside a single Electron application.
|
||||||
|
|
||||||
At a high level, the architecture is built around one core idea:
|
At a high level, the architecture is built around one core idea:
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ The current architecture optimizes for:
|
|||||||
- **persistent workspaces** rather than disposable chat threads
|
- **persistent workspaces** rather than disposable chat threads
|
||||||
- **project-aware execution** with repository context and optional tooling
|
- **project-aware execution** with repository context and optional tooling
|
||||||
- **observable AI runs** with streamed output, activity, and history
|
- **observable AI runs** with streamed output, activity, and history
|
||||||
- **extensible orchestration** so patterns, models, and tool integrations can evolve without collapsing boundaries
|
- **extensible orchestration** so workflows, models, and tool integrations can evolve without collapsing boundaries
|
||||||
|
|
||||||
## System context
|
## System context
|
||||||
|
|
||||||
@@ -56,7 +56,7 @@ flowchart LR
|
|||||||
| Renderer | Screens, interaction, local view composition, theme application | Filesystem, process spawning, raw Electron access, Copilot runtime | Typed preload API and pushed events |
|
| Renderer | Screens, interaction, local view composition, theme application | Filesystem, process spawning, raw Electron access, Copilot runtime | Typed preload API and pushed events |
|
||||||
| Preload | Narrow bridge between browser context and Electron IPC | Business logic, persistence, orchestration | `ipcRenderer` / `ipcMain` |
|
| Preload | Narrow bridge between browser context and Electron IPC | Business logic, persistence, orchestration | `ipcRenderer` / `ipcMain` |
|
||||||
| Main process | Workspace mutation, persistence, git inspection/write operations, run change attribution, commit workflow orchestration, session lifecycle, native window state, sidecar lifecycle, PTY-backed terminal lifecycle | UI rendering, LLM orchestration internals | IPC, filesystem, git CLI, stdio with sidecar, native child processes |
|
| Main process | Workspace mutation, persistence, git inspection/write operations, run change attribution, commit workflow orchestration, session lifecycle, native window state, sidecar lifecycle, PTY-backed terminal lifecycle | UI rendering, LLM orchestration internals | IPC, filesystem, git CLI, stdio with sidecar, native child processes |
|
||||||
| Sidecar | Capability discovery, pattern validation, run execution, streaming deltas and activity | UI, workspace persistence, Electron APIs | Line-delimited JSON over stdio |
|
| Sidecar | Capability discovery, workflow validation, run execution, streaming deltas and activity | UI, workspace persistence, Electron APIs | Line-delimited JSON over stdio |
|
||||||
| External systems | Git data, Copilot account/model access, OS window chrome | Application state and UI behavior | Controlled adapters owned by main or sidecar |
|
| External systems | Git data, Copilot account/model access, OS window chrome | Application state and UI behavior | Controlled adapters owned by main or sidecar |
|
||||||
|
|
||||||
This split is the most important architectural feature in the app. It is what keeps the system understandable as more capabilities are added.
|
This split is the most important architectural feature in the app. It is what keeps the system understandable as more capabilities are added.
|
||||||
@@ -108,9 +108,8 @@ This flow is important because it shows that Aryx is not architected as a simple
|
|||||||
The durable state of the app is a **workspace**. The workspace contains:
|
The durable state of the app is a **workspace**. The workspace contains:
|
||||||
|
|
||||||
- connected projects
|
- connected projects
|
||||||
- orchestration patterns
|
|
||||||
- workflow templates
|
|
||||||
- workflows
|
- workflows
|
||||||
|
- workflow templates
|
||||||
- sessions
|
- sessions
|
||||||
- settings
|
- settings
|
||||||
- run history
|
- run history
|
||||||
@@ -130,36 +129,34 @@ Project-backed entries also persist scanned Copilot customization metadata disco
|
|||||||
|
|
||||||
For git-backed projects, the main process also owns background git refreshes, captures a structured pre-run working-tree snapshot on each run record, and persists a post-run git change summary after project-backed turns complete. It also owns all git write operations exposed by Aryx — selective discard, staging, commit, push/pull/fetch, and branch lifecycle actions — so the renderer never shells out directly or manipulates repository state on its own.
|
For git-backed projects, the main process also owns background git refreshes, captures a structured pre-run working-tree snapshot on each run record, and persists a post-run git change summary after project-backed turns complete. It also owns all git write operations exposed by Aryx — selective discard, staging, commit, push/pull/fetch, and branch lifecycle actions — so the renderer never shells out directly or manipulates repository state on its own.
|
||||||
|
|
||||||
### Patterns
|
### Workflows
|
||||||
|
|
||||||
Patterns describe how agents collaborate. The architecture supports:
|
Workflows describe how agents collaborate. The architecture supports:
|
||||||
|
|
||||||
- one-agent conversations
|
- one-agent conversations
|
||||||
- sequential workflows
|
- sequential execution
|
||||||
- concurrent responses
|
- concurrent fan-out / fan-in flows
|
||||||
- handoff flows
|
- handoff-style routing
|
||||||
- group chat style collaboration
|
- group-chat style collaboration
|
||||||
|
|
||||||
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.
|
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 handoff 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 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.
|
||||||
|
|
||||||
Patterns are shared application data, not renderer-only configuration. That means the same pattern definition can drive validation, persistence, UI rendering, and sidecar execution.
|
Workflows are shared application data, not renderer-only configuration. The same workflow definition now drives validation, persistence, session execution, and sidecar orchestration.
|
||||||
|
|
||||||
Patterns now persist an explicit graph-backed topology alongside the flat agent list. Agent nodes carry stable agent ids, ordering, and layout metadata, while system nodes such as user input/output, distributor, collector, and orchestrator make mode-specific flow visible in the saved contract.
|
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 is now the execution contract for the sidecar: sequential order comes from the saved path, handoff routes come from directed graph edges, and concurrent/group-chat participant ordering can be derived from graph node metadata instead of hard-coded runtime assumptions.
|
That graph is the execution contract for the sidecar: sequencing comes from saved edges, sub-workflow execution comes from referenced workflow graphs, and orchestration hints such as handoff or group-chat are carried as workflow settings rather than through a separate pattern domain.
|
||||||
|
|
||||||
The pattern editor renders an interactive graph canvas powered by React Flow (`@xyflow/react`). The canvas projects the authoritative `PatternGraph` into React Flow nodes and edges via a view-model layer (`src/renderer/lib/patternGraph.ts`). Users can drag nodes to reposition them, and in handoff mode can draw new agent-to-agent edges directly on the canvas. A right-side inspector panel shows the details of the selected node — system node metadata for system nodes, or the full agent configuration form (model, reasoning, instructions) for agent nodes. The mode selector, pattern metadata, approval checkpoints, and tool auto-approval settings remain below the graph as scrollable settings sections. The `syncPatternGraph()` adapter is still called when agents are added/removed or the mode changes, rebuilding the graph from the current state; direct graph edits (drag positions, handoff edges) are persisted without the adapter.
|
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.
|
||||||
|
|
||||||
Workflows now sit alongside patterns as 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 workflow templates are derived from built-in patterns, while custom templates are persisted in workspace state and used by the main process to create new saved workflows without expanding the sidecar protocol.
|
|
||||||
|
|
||||||
### Sessions
|
### Sessions
|
||||||
|
|
||||||
A session is the working unit of the product. It binds together:
|
A session is the working unit of the product. It binds together:
|
||||||
|
|
||||||
- a project
|
- a project
|
||||||
- a pattern
|
- a workflow
|
||||||
- a message history
|
- a message history
|
||||||
- status and errors
|
- status and errors
|
||||||
- optional per-session tool selection
|
- optional per-session tool selection
|
||||||
@@ -212,7 +209,7 @@ This is a structured stdio protocol used for:
|
|||||||
|
|
||||||
- capability discovery
|
- capability discovery
|
||||||
- on-demand account quota lookup
|
- on-demand account quota lookup
|
||||||
- pattern validation
|
- workflow validation
|
||||||
- run execution
|
- run execution
|
||||||
- streaming partial output
|
- streaming partial output
|
||||||
- streaming agent activity
|
- streaming agent activity
|
||||||
@@ -241,7 +238,7 @@ The same boundary also supports server-scoped sidecar commands that do not requi
|
|||||||
|
|
||||||
For project-backed sessions, the sidecar also discovers GitHub Copilot CLI hook definitions from `.github/hooks/*.json` under the repository root. Those files are parsed and merged once per run bundle, then projected onto the SDK session hook delegates. Hook commands run synchronously in the sidecar through the platform shell, with stdin JSON payloads shaped to match Copilot CLI hook expectations as closely as the SDK allows. Hook failures are logged to stderr and treated as non-fatal diagnostics, while `preToolUse` hook outputs can still deny a tool call before Aryx falls back to its built-in approval policy.
|
For project-backed sessions, the sidecar also discovers GitHub Copilot CLI hook definitions from `.github/hooks/*.json` under the repository root. Those files are parsed and merged once per run bundle, then projected onto the SDK session hook delegates. Hook commands run synchronously in the sidecar through the platform shell, with stdin JSON payloads shaped to match Copilot CLI hook expectations as closely as the SDK allows. Hook failures are logged to stderr and treated as non-fatal diagnostics, while `preToolUse` hook outputs can still deny a tool call before Aryx falls back to its built-in approval policy.
|
||||||
|
|
||||||
The `run-turn` command now also carries a project-instruction payload derived from scanned repo customization files. The main process composes that payload from always-on repo instructions plus formatted file-scoped and task-scoped `.instructions.md` / `.claude/rules` entries, while omitting manual-only instruction files from automatic injection. It also merges enabled discovered custom agent profiles into the primary pattern agent's Copilot configuration before sending the command across the stdio boundary. Prompt-file submissions can additionally attach a structured `promptInvocation` payload with prompt identity, resolved prompt body, optional `agent`, optional `model`, and optional `tools` metadata. The main process stores that prompt invocation metadata on the triggering user message so replay and regenerate flows can rebuild it, hydrates missing metadata from the scanned prompt definition, promotes `agent: plan` to a per-turn plan-mode override, applies per-turn prompt model overrides to the effective pattern before execution, and falls back to a lightweight transcript message instead of pasting the full prompt body into chat history. The sidecar then folds both the project instructions and prompt invocation into the final SDK system message, uses prompt agent metadata to override `SessionConfig.Agent`, and narrows available tools for that turn when prompt `tools` metadata is present.
|
The `run-turn` command now also carries a project-instruction payload derived from scanned repo customization files. The main process composes that payload from always-on repo instructions plus formatted file-scoped and task-scoped `.instructions.md` / `.claude/rules` entries, while omitting manual-only instruction files from automatic injection. It also merges enabled discovered custom agent profiles into the primary workflow agent's Copilot configuration before sending the command across the stdio boundary. Prompt-file submissions can additionally attach a structured `promptInvocation` payload with prompt identity, resolved prompt body, optional `agent`, optional `model`, and optional `tools` metadata. The main process stores that prompt invocation metadata on the triggering user message so replay and regenerate flows can rebuild it, hydrates missing metadata from the scanned prompt definition, promotes `agent: plan` to a per-turn plan-mode override, applies per-turn prompt model overrides to the effective workflow before execution, and falls back to a lightweight transcript message instead of pasting the full prompt body into chat history. The sidecar then folds both the project instructions and prompt invocation into the final SDK system message, uses prompt agent metadata to override `SessionConfig.Agent`, and narrows available tools for that turn when prompt `tools` metadata is present.
|
||||||
|
|
||||||
For handoff workflows, the sidecar now also enables Agent Framework JSON checkpointing backed by a per-turn filesystem store under local app data. Each saved checkpoint is surfaced to the main process, which pairs the durable Agent Framework checkpoint with an in-memory rollback snapshot of `session.messages` and the active run timeline events. If the sidecar child process exits unexpectedly during the same app lifetime, Aryx restores the latest snapshot, clears pending approval/user-input/MCP-auth state for that run, and retries the `run-turn` request once with `resumeFromCheckpoint`. Checkpoint directories are deleted after the turn completes, cancels, or fails. This recovery path is intentionally scoped to same-app sidecar restarts; full app-restart workflow rehydration would require durable rollback snapshots in addition to the Agent Framework checkpoint payloads.
|
For handoff workflows, the sidecar now also enables Agent Framework JSON checkpointing backed by a per-turn filesystem store under local app data. Each saved checkpoint is surfaced to the main process, which pairs the durable Agent Framework checkpoint with an in-memory rollback snapshot of `session.messages` and the active run timeline events. If the sidecar child process exits unexpectedly during the same app lifetime, Aryx restores the latest snapshot, clears pending approval/user-input/MCP-auth state for that run, and retries the `run-turn` request once with `resumeFromCheckpoint`. Checkpoint directories are deleted after the turn completes, cancels, or fails. This recovery path is intentionally scoped to same-app sidecar restarts; full app-restart workflow rehydration would require durable rollback snapshots in addition to the Agent Framework checkpoint payloads.
|
||||||
|
|
||||||
@@ -303,7 +300,7 @@ Tooling is deliberately split into two levels:
|
|||||||
- **global definitions** for MCP servers and LSP profiles
|
- **global definitions** for MCP servers and LSP profiles
|
||||||
- **MCP tool discovery** — when MCP server configs declare wildcard tools (empty `tools` array), the main process probes each server directly via the MCP protocol `tools/list` method to discover available tools, using the same auth credentials Aryx manages for OAuth-protected servers
|
- **MCP tool discovery** — when MCP server configs declare wildcard tools (empty `tools` array), the main process probes each server directly via the MCP protocol `tools/list` method to discover available tools, using the same auth credentials Aryx manages for OAuth-protected servers
|
||||||
- **incremental probe progress** — MCP probing runs concurrently and publishes per-server progress through the pushed workspace snapshot, using the runtime-only `mcpProbingServerIds` field so the renderer can reflect in-flight discovery without persisting transient UI state
|
- **incremental probe progress** — MCP probing runs concurrently and publishes per-server progress through the pushed workspace snapshot, using the runtime-only `mcpProbingServerIds` field so the renderer can reflect in-flight discovery without persisting transient UI state
|
||||||
- **pattern defaults** where tool-call approval is enabled by default, plus which known runtime tools can bypass manual approval
|
- **workflow defaults** where tool-call approval is enabled by default, plus which known runtime tools can bypass manual approval
|
||||||
- **per-session overrides** for both tool enablement and tool auto-approval
|
- **per-session overrides** for both tool enablement and tool auto-approval
|
||||||
|
|
||||||
This lets the application treat tooling as reusable workspace capability while still preserving session-level control and safety.
|
This lets the application treat tooling as reusable workspace capability while still preserving session-level control and safety.
|
||||||
|
|||||||
@@ -3,18 +3,7 @@ using System.Text.Json.Serialization;
|
|||||||
|
|
||||||
namespace Aryx.AgentHost.Contracts;
|
namespace Aryx.AgentHost.Contracts;
|
||||||
|
|
||||||
public sealed class PatternAgentDefinitionDto
|
public sealed class WorkflowAgentCopilotConfigDto
|
||||||
{
|
|
||||||
public string Id { get; init; } = string.Empty;
|
|
||||||
public string Name { get; init; } = string.Empty;
|
|
||||||
public string Description { get; init; } = string.Empty;
|
|
||||||
public string Instructions { get; init; } = string.Empty;
|
|
||||||
public string Model { get; init; } = string.Empty;
|
|
||||||
public string? ReasoningEffort { get; init; }
|
|
||||||
public PatternAgentCopilotConfigDto? Copilot { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class PatternAgentCopilotConfigDto
|
|
||||||
{
|
{
|
||||||
public IReadOnlyList<RunTurnCustomAgentConfigDto> CustomAgents { get; init; } = [];
|
public IReadOnlyList<RunTurnCustomAgentConfigDto> CustomAgents { get; init; } = [];
|
||||||
public string? Agent { get; init; }
|
public string? Agent { get; init; }
|
||||||
@@ -23,50 +12,6 @@ public sealed class PatternAgentCopilotConfigDto
|
|||||||
public RunTurnInfiniteSessionsConfigDto? InfiniteSessions { get; init; }
|
public RunTurnInfiniteSessionsConfigDto? InfiniteSessions { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class PatternGraphPositionDto
|
|
||||||
{
|
|
||||||
public double X { get; init; }
|
|
||||||
public double Y { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class PatternGraphNodeDto
|
|
||||||
{
|
|
||||||
public string Id { get; init; } = string.Empty;
|
|
||||||
public string Kind { get; init; } = string.Empty;
|
|
||||||
public PatternGraphPositionDto Position { get; init; } = new();
|
|
||||||
public string? AgentId { get; init; }
|
|
||||||
public int? Order { get; init; }
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class PatternGraphEdgeDto
|
|
||||||
{
|
|
||||||
public string Id { get; init; } = string.Empty;
|
|
||||||
public string Source { get; init; } = string.Empty;
|
|
||||||
public string Target { get; init; } = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class PatternGraphDto
|
|
||||||
{
|
|
||||||
public IReadOnlyList<PatternGraphNodeDto> Nodes { get; init; } = [];
|
|
||||||
public IReadOnlyList<PatternGraphEdgeDto> Edges { get; init; } = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class PatternDefinitionDto
|
|
||||||
{
|
|
||||||
public string Id { get; init; } = string.Empty;
|
|
||||||
public string Name { get; init; } = string.Empty;
|
|
||||||
public string Description { get; init; } = string.Empty;
|
|
||||||
public string Mode { get; init; } = string.Empty;
|
|
||||||
public string Availability { get; init; } = "available";
|
|
||||||
public string? UnavailabilityReason { get; init; }
|
|
||||||
public int MaxIterations { get; init; }
|
|
||||||
public ApprovalPolicyDto? ApprovalPolicy { get; init; }
|
|
||||||
public IReadOnlyList<PatternAgentDefinitionDto> Agents { get; init; } = [];
|
|
||||||
public PatternGraphDto? Graph { get; init; }
|
|
||||||
public string CreatedAt { get; init; } = string.Empty;
|
|
||||||
public string UpdatedAt { get; init; } = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class WorkflowPositionDto
|
public sealed class WorkflowPositionDto
|
||||||
{
|
{
|
||||||
public double X { get; init; }
|
public double X { get; init; }
|
||||||
@@ -84,7 +29,7 @@ public sealed class WorkflowNodeConfigDto
|
|||||||
public string Instructions { get; init; } = string.Empty;
|
public string Instructions { get; init; } = string.Empty;
|
||||||
public string Model { get; init; } = string.Empty;
|
public string Model { get; init; } = string.Empty;
|
||||||
public string? ReasoningEffort { get; init; }
|
public string? ReasoningEffort { get; init; }
|
||||||
public PatternAgentCopilotConfigDto? Copilot { get; init; }
|
public WorkflowAgentCopilotConfigDto? Copilot { get; init; }
|
||||||
public string? WorkspaceAgentId { get; init; }
|
public string? WorkspaceAgentId { get; init; }
|
||||||
public string? Implementation { get; init; }
|
public string? Implementation { get; init; }
|
||||||
public string? FunctionRef { get; init; }
|
public string? FunctionRef { get; init; }
|
||||||
@@ -170,6 +115,7 @@ public sealed class WorkflowSettingsDto
|
|||||||
{
|
{
|
||||||
public WorkflowCheckpointSettingsDto Checkpointing { get; init; } = new();
|
public WorkflowCheckpointSettingsDto Checkpointing { get; init; } = new();
|
||||||
public string ExecutionMode { get; init; } = "off-thread";
|
public string ExecutionMode { get; init; } = "off-thread";
|
||||||
|
public string? OrchestrationMode { get; init; }
|
||||||
public int? MaxIterations { get; init; }
|
public int? MaxIterations { get; init; }
|
||||||
public ApprovalPolicyDto? ApprovalPolicy { get; init; }
|
public ApprovalPolicyDto? ApprovalPolicy { get; init; }
|
||||||
public IReadOnlyList<WorkflowStateScopeDto> StateScopes { get; init; } = [];
|
public IReadOnlyList<WorkflowStateScopeDto> StateScopes { get; init; } = [];
|
||||||
@@ -220,13 +166,6 @@ public sealed class ChatMessageAttachmentDto
|
|||||||
public string? DisplayName { get; init; }
|
public string? DisplayName { get; init; }
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class PatternValidationIssueDto
|
|
||||||
{
|
|
||||||
public string Level { get; init; } = "error";
|
|
||||||
public string? Field { get; init; }
|
|
||||||
public string Message { get; init; } = string.Empty;
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class WorkflowValidationIssueDto
|
public sealed class WorkflowValidationIssueDto
|
||||||
{
|
{
|
||||||
public string Level { get; init; } = "error";
|
public string Level { get; init; } = "error";
|
||||||
@@ -303,11 +242,6 @@ public class SidecarCommandEnvelope
|
|||||||
|
|
||||||
public sealed class DescribeCapabilitiesCommandDto : SidecarCommandEnvelope;
|
public sealed class DescribeCapabilitiesCommandDto : SidecarCommandEnvelope;
|
||||||
|
|
||||||
public sealed class ValidatePatternCommandDto : SidecarCommandEnvelope
|
|
||||||
{
|
|
||||||
public PatternDefinitionDto Pattern { get; init; } = new();
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class ValidateWorkflowCommandDto : SidecarCommandEnvelope
|
public sealed class ValidateWorkflowCommandDto : SidecarCommandEnvelope
|
||||||
{
|
{
|
||||||
public WorkflowDefinitionDto Workflow { get; init; } = new();
|
public WorkflowDefinitionDto Workflow { get; init; } = new();
|
||||||
@@ -322,8 +256,7 @@ public sealed class RunTurnCommandDto : SidecarCommandEnvelope
|
|||||||
public string Mode { get; init; } = "interactive";
|
public string Mode { get; init; } = "interactive";
|
||||||
public string MessageMode { get; init; } = "enqueue";
|
public string MessageMode { get; init; } = "enqueue";
|
||||||
public string? ProjectInstructions { get; init; }
|
public string? ProjectInstructions { get; init; }
|
||||||
public PatternDefinitionDto Pattern { get; init; } = new();
|
public WorkflowDefinitionDto Workflow { get; init; } = new();
|
||||||
public WorkflowDefinitionDto? Workflow { get; init; }
|
|
||||||
public IReadOnlyList<WorkflowDefinitionDto> WorkflowLibrary { get; init; } = [];
|
public IReadOnlyList<WorkflowDefinitionDto> WorkflowLibrary { get; init; } = [];
|
||||||
public IReadOnlyList<ChatMessageDto> Messages { get; init; } = [];
|
public IReadOnlyList<ChatMessageDto> Messages { get; init; } = [];
|
||||||
public RunTurnPromptInvocationDto? PromptInvocation { get; init; }
|
public RunTurnPromptInvocationDto? PromptInvocation { get; init; }
|
||||||
@@ -464,11 +397,6 @@ public sealed class CapabilitiesEventDto : SidecarEventDto
|
|||||||
public SidecarCapabilitiesDto Capabilities { get; init; } = new();
|
public SidecarCapabilitiesDto Capabilities { get; init; } = new();
|
||||||
}
|
}
|
||||||
|
|
||||||
public sealed class PatternValidationEventDto : SidecarEventDto
|
|
||||||
{
|
|
||||||
public IReadOnlyList<PatternValidationIssueDto> Issues { get; init; } = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class WorkflowValidationEventDto : SidecarEventDto
|
public sealed class WorkflowValidationEventDto : SidecarEventDto
|
||||||
{
|
{
|
||||||
public IReadOnlyList<WorkflowValidationIssueDto> Issues { get; init; } = [];
|
public IReadOnlyList<WorkflowValidationIssueDto> Issues { get; init; } = [];
|
||||||
|
|||||||
@@ -10,14 +10,14 @@ internal static class AgentIdentityResolver
|
|||||||
private const string GenericAssistantIdentifier = "assistant";
|
private const string GenericAssistantIdentifier = "assistant";
|
||||||
|
|
||||||
public static bool TryResolveKnownAgentIdentity(
|
public static bool TryResolveKnownAgentIdentity(
|
||||||
PatternDefinitionDto pattern,
|
WorkflowDefinitionDto workflow,
|
||||||
string? agentIdentifier,
|
string? agentIdentifier,
|
||||||
out AgentIdentity agent)
|
out AgentIdentity agent)
|
||||||
{
|
{
|
||||||
agent = default;
|
agent = default;
|
||||||
|
|
||||||
PatternAgentDefinitionDto? match = FindKnownAgent(pattern, agentIdentifier)
|
WorkflowNodeDto? match = FindKnownAgent(workflow, agentIdentifier)
|
||||||
?? ResolveSingleAgentAssistantAlias(pattern, agentIdentifier);
|
?? ResolveSingleAgentAssistantAlias(workflow, agentIdentifier);
|
||||||
if (match is null)
|
if (match is null)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
@@ -28,12 +28,12 @@ internal static class AgentIdentityResolver
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static bool TryResolveObservedAgentIdentity(
|
public static bool TryResolveObservedAgentIdentity(
|
||||||
PatternDefinitionDto pattern,
|
WorkflowDefinitionDto workflow,
|
||||||
string? agentIdentifier,
|
string? agentIdentifier,
|
||||||
AgentIdentity? fallbackAgent,
|
AgentIdentity? fallbackAgent,
|
||||||
out AgentIdentity agent)
|
out AgentIdentity agent)
|
||||||
{
|
{
|
||||||
if (TryResolveKnownAgentIdentity(pattern, agentIdentifier, out agent))
|
if (TryResolveKnownAgentIdentity(workflow, agentIdentifier, out agent))
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
@@ -49,13 +49,13 @@ internal static class AgentIdentityResolver
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static AgentIdentity ResolveAgentIdentity(
|
public static AgentIdentity ResolveAgentIdentity(
|
||||||
PatternDefinitionDto pattern,
|
WorkflowDefinitionDto workflow,
|
||||||
string? agentId,
|
string? agentId,
|
||||||
string? agentName)
|
string? agentName)
|
||||||
{
|
{
|
||||||
PatternAgentDefinitionDto? match = FindKnownAgent(pattern, agentId)
|
WorkflowNodeDto? match = FindKnownAgent(workflow, agentId)
|
||||||
?? FindKnownAgent(pattern, agentName)
|
?? FindKnownAgent(workflow, agentName)
|
||||||
?? ResolveSingleAgentAssistantAlias(pattern, agentId, agentName);
|
?? ResolveSingleAgentAssistantAlias(workflow, agentId, agentName);
|
||||||
|
|
||||||
return match is not null
|
return match is not null
|
||||||
? ToAgentIdentity(match)
|
? ToAgentIdentity(match)
|
||||||
@@ -63,16 +63,16 @@ internal static class AgentIdentityResolver
|
|||||||
}
|
}
|
||||||
|
|
||||||
public static string ResolveDisplayAuthorName(
|
public static string ResolveDisplayAuthorName(
|
||||||
PatternDefinitionDto pattern,
|
WorkflowDefinitionDto workflow,
|
||||||
string? primaryIdentifier,
|
string? primaryIdentifier,
|
||||||
string? fallbackIdentifier = null)
|
string? fallbackIdentifier = null)
|
||||||
{
|
{
|
||||||
if (TryResolveKnownAgentIdentity(pattern, primaryIdentifier, out AgentIdentity primaryAgent))
|
if (TryResolveKnownAgentIdentity(workflow, primaryIdentifier, out AgentIdentity primaryAgent))
|
||||||
{
|
{
|
||||||
return primaryAgent.AgentName;
|
return primaryAgent.AgentName;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (TryResolveKnownAgentIdentity(pattern, fallbackIdentifier, out AgentIdentity fallbackAgent))
|
if (TryResolveKnownAgentIdentity(workflow, fallbackIdentifier, out AgentIdentity fallbackAgent))
|
||||||
{
|
{
|
||||||
return fallbackAgent.AgentName;
|
return fallbackAgent.AgentName;
|
||||||
}
|
}
|
||||||
@@ -98,26 +98,23 @@ internal static class AgentIdentityResolver
|
|||||||
StringComparison.Ordinal);
|
StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PatternAgentDefinitionDto? ResolveSingleAgentAssistantAlias(
|
private static WorkflowNodeDto? ResolveSingleAgentAssistantAlias(
|
||||||
PatternDefinitionDto pattern,
|
WorkflowDefinitionDto workflow,
|
||||||
params string?[] agentIdentifiers)
|
params string?[] agentIdentifiers)
|
||||||
{
|
{
|
||||||
return pattern.Agents.Count == 1 && agentIdentifiers.Any(IsGenericAssistantIdentifier)
|
IReadOnlyList<WorkflowNodeDto> agentNodes = workflow.GetAgentNodes();
|
||||||
? pattern.Agents[0]
|
return agentNodes.Count == 1 && agentIdentifiers.Any(IsGenericAssistantIdentifier)
|
||||||
|
? agentNodes[0]
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PatternAgentDefinitionDto? FindKnownAgent(PatternDefinitionDto pattern, string? candidate)
|
private static WorkflowNodeDto? FindKnownAgent(WorkflowDefinitionDto workflow, string? candidate)
|
||||||
{
|
{
|
||||||
return pattern.Agents.FirstOrDefault(agent => MatchesAgent(agent, candidate));
|
return workflow.GetAgentNodes().FirstOrDefault(agent => MatchesAgent(agent, candidate));
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AgentIdentity ToAgentIdentity(PatternAgentDefinitionDto agent)
|
private static AgentIdentity ToAgentIdentity(WorkflowNodeDto agent)
|
||||||
{
|
=> new(agent.GetAgentId(), agent.GetAgentName());
|
||||||
return new AgentIdentity(
|
|
||||||
agent.Id,
|
|
||||||
string.IsNullOrWhiteSpace(agent.Name) ? agent.Id : agent.Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static AgentIdentity CreateFallbackIdentity(string? agentId, string? agentName)
|
private static AgentIdentity CreateFallbackIdentity(string? agentId, string? agentName)
|
||||||
{
|
{
|
||||||
@@ -131,22 +128,24 @@ internal static class AgentIdentityResolver
|
|||||||
return new AgentIdentity(resolvedAgentId, resolvedAgentName);
|
return new AgentIdentity(resolvedAgentId, resolvedAgentName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static bool MatchesAgent(PatternAgentDefinitionDto agent, string? candidate)
|
private static bool MatchesAgent(WorkflowNodeDto agent, string? candidate)
|
||||||
{
|
{
|
||||||
if (string.IsNullOrWhiteSpace(candidate))
|
if (string.IsNullOrWhiteSpace(candidate))
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.Equals(agent.Id, candidate, StringComparison.OrdinalIgnoreCase)
|
string agentId = agent.GetAgentId();
|
||||||
|| string.Equals(agent.Name, candidate, StringComparison.OrdinalIgnoreCase))
|
string agentName = agent.GetAgentName();
|
||||||
|
if (string.Equals(agentId, candidate, StringComparison.OrdinalIgnoreCase)
|
||||||
|
|| string.Equals(agentName, candidate, StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
string normalizedCandidate = NormalizeComparisonKey(candidate);
|
string normalizedCandidate = NormalizeComparisonKey(candidate);
|
||||||
string normalizedId = NormalizeComparisonKey(agent.Id);
|
string normalizedId = NormalizeComparisonKey(agentId);
|
||||||
string normalizedName = NormalizeComparisonKey(agent.Name);
|
string normalizedName = NormalizeComparisonKey(agentName);
|
||||||
if (normalizedCandidate.Length == 0)
|
if (normalizedCandidate.Length == 0)
|
||||||
{
|
{
|
||||||
return false;
|
return false;
|
||||||
|
|||||||
@@ -5,15 +5,15 @@ namespace Aryx.AgentHost.Services;
|
|||||||
internal static class AgentInstructionComposer
|
internal static class AgentInstructionComposer
|
||||||
{
|
{
|
||||||
public static string Compose(
|
public static string Compose(
|
||||||
PatternDefinitionDto pattern,
|
WorkflowDefinitionDto workflow,
|
||||||
PatternAgentDefinitionDto agent,
|
WorkflowNodeDto agentNode,
|
||||||
int agentIndex,
|
int agentIndex,
|
||||||
string workspaceKind = "project",
|
string workspaceKind = "project",
|
||||||
string interactionMode = "interactive",
|
string interactionMode = "interactive",
|
||||||
string? projectInstructions = null,
|
string? projectInstructions = null,
|
||||||
RunTurnPromptInvocationDto? promptInvocation = null)
|
RunTurnPromptInvocationDto? promptInvocation = null)
|
||||||
{
|
{
|
||||||
string baseInstructions = agent.Instructions.Trim();
|
string baseInstructions = agentNode.Config.Instructions.Trim();
|
||||||
string repositoryInstructions = projectInstructions?.Trim() ?? string.Empty;
|
string repositoryInstructions = projectInstructions?.Trim() ?? string.Empty;
|
||||||
string promptInvocationInstructions = FormatPromptInvocation(promptInvocation);
|
string promptInvocationInstructions = FormatPromptInvocation(promptInvocation);
|
||||||
string workspaceGuidance = string.Equals(workspaceKind, "scratchpad", StringComparison.OrdinalIgnoreCase)
|
string workspaceGuidance = string.Equals(workspaceKind, "scratchpad", StringComparison.OrdinalIgnoreCase)
|
||||||
@@ -34,7 +34,7 @@ internal static class AgentInstructionComposer
|
|||||||
"""
|
"""
|
||||||
: string.Empty;
|
: string.Empty;
|
||||||
|
|
||||||
if (string.Equals(pattern.Mode, "group-chat", StringComparison.OrdinalIgnoreCase))
|
if (string.Equals(workflow.Settings.OrchestrationMode, "group-chat", StringComparison.OrdinalIgnoreCase))
|
||||||
{
|
{
|
||||||
string groupChatGuidance = agentIndex == 0
|
string groupChatGuidance = agentIndex == 0
|
||||||
? """
|
? """
|
||||||
|
|||||||
@@ -1,10 +1,9 @@
|
|||||||
using System.Threading;
|
using System.Linq;
|
||||||
using GitHub.Copilot.SDK;
|
|
||||||
using Aryx.AgentHost.Contracts;
|
using Aryx.AgentHost.Contracts;
|
||||||
|
using GitHub.Copilot.SDK;
|
||||||
using Microsoft.Agents.AI;
|
using Microsoft.Agents.AI;
|
||||||
using Microsoft.Agents.AI.GitHub.Copilot;
|
using Microsoft.Agents.AI.GitHub.Copilot;
|
||||||
using Microsoft.Agents.AI.Workflows;
|
using Microsoft.Agents.AI.Workflows;
|
||||||
using Microsoft.Agents.AI.Workflows.Specialized;
|
|
||||||
using Microsoft.Extensions.AI;
|
using Microsoft.Extensions.AI;
|
||||||
|
|
||||||
namespace Aryx.AgentHost.Services;
|
namespace Aryx.AgentHost.Services;
|
||||||
@@ -32,9 +31,9 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
|||||||
|
|
||||||
public static async Task<CopilotAgentBundle> CreateAsync(
|
public static async Task<CopilotAgentBundle> CreateAsync(
|
||||||
RunTurnCommandDto command,
|
RunTurnCommandDto command,
|
||||||
Func<PatternAgentDefinitionDto, PermissionRequest, PermissionInvocation, Task<PermissionRequestResult>> onPermissionRequest,
|
Func<WorkflowNodeDto, PermissionRequest, PermissionInvocation, Task<PermissionRequestResult>> onPermissionRequest,
|
||||||
Func<PatternAgentDefinitionDto, UserInputRequest, UserInputInvocation, Task<UserInputResponse>> onUserInputRequest,
|
Func<WorkflowNodeDto, UserInputRequest, UserInputInvocation, Task<UserInputResponse>> onUserInputRequest,
|
||||||
Action<PatternAgentDefinitionDto, SessionEvent>? onSessionEvent,
|
Action<WorkflowNodeDto, SessionEvent>? onSessionEvent,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
List<IAsyncDisposable> disposables = [];
|
List<IAsyncDisposable> disposables = [];
|
||||||
@@ -53,7 +52,8 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
|||||||
disposables.Add(toolingBundle);
|
disposables.Add(toolingBundle);
|
||||||
}
|
}
|
||||||
|
|
||||||
foreach ((PatternAgentDefinitionDto definition, int agentIndex) in command.Pattern.Agents.Select((definition, index) => (definition, index)))
|
IReadOnlyList<WorkflowNodeDto> agentNodes = command.Workflow.GetAgentNodes();
|
||||||
|
foreach ((WorkflowNodeDto definition, int agentIndex) in agentNodes.Select((definition, index) => (definition, index)))
|
||||||
{
|
{
|
||||||
CopilotClient client = new(clientOptions);
|
CopilotClient client = new(clientOptions);
|
||||||
await client.StartAsync(cancellationToken).ConfigureAwait(false);
|
await client.StartAsync(cancellationToken).ConfigureAwait(false);
|
||||||
@@ -75,9 +75,9 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
|||||||
client,
|
client,
|
||||||
sessionConfig,
|
sessionConfig,
|
||||||
ownsClient: true,
|
ownsClient: true,
|
||||||
id: definition.Id,
|
id: definition.GetAgentId(),
|
||||||
name: definition.Name,
|
name: definition.GetAgentName(),
|
||||||
description: definition.Description);
|
description: NormalizeOptionalString(definition.Config.Description));
|
||||||
|
|
||||||
agents.Add(agent);
|
agents.Add(agent);
|
||||||
disposables.Add(agent);
|
disposables.Add(agent);
|
||||||
@@ -90,7 +90,7 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
|||||||
|
|
||||||
internal static SessionConfig CreateSessionConfig(
|
internal static SessionConfig CreateSessionConfig(
|
||||||
RunTurnCommandDto command,
|
RunTurnCommandDto command,
|
||||||
PatternAgentDefinitionDto definition,
|
WorkflowNodeDto definition,
|
||||||
int agentIndex,
|
int agentIndex,
|
||||||
PermissionRequestHandler? onPermissionRequest = null,
|
PermissionRequestHandler? onPermissionRequest = null,
|
||||||
UserInputHandler? onUserInputRequest = null,
|
UserInputHandler? onUserInputRequest = null,
|
||||||
@@ -98,16 +98,14 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
|||||||
ResolvedHookSet? configuredHooks = null,
|
ResolvedHookSet? configuredHooks = null,
|
||||||
IHookCommandRunner? hookCommandRunner = null)
|
IHookCommandRunner? hookCommandRunner = null)
|
||||||
{
|
{
|
||||||
// Let the Copilot SDK allocate session IDs. Explicit custom SessionId values currently
|
|
||||||
// cause turns to complete without assistant output, even for simple single-agent prompts.
|
|
||||||
return new SessionConfig
|
return new SessionConfig
|
||||||
{
|
{
|
||||||
Model = definition.Model,
|
Model = definition.Config.Model,
|
||||||
ReasoningEffort = definition.ReasoningEffort,
|
ReasoningEffort = definition.Config.ReasoningEffort,
|
||||||
SystemMessage = new SystemMessageConfig
|
SystemMessage = new SystemMessageConfig
|
||||||
{
|
{
|
||||||
Content = AgentInstructionComposer.Compose(
|
Content = AgentInstructionComposer.Compose(
|
||||||
command.Pattern,
|
command.Workflow,
|
||||||
definition,
|
definition,
|
||||||
agentIndex,
|
agentIndex,
|
||||||
command.WorkspaceKind,
|
command.WorkspaceKind,
|
||||||
@@ -121,11 +119,11 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
|||||||
Hooks = CopilotSessionHooks.Create(command, definition, configuredHooks, hookCommandRunner),
|
Hooks = CopilotSessionHooks.Create(command, definition, configuredHooks, hookCommandRunner),
|
||||||
OnEvent = onSessionEvent,
|
OnEvent = onSessionEvent,
|
||||||
Streaming = true,
|
Streaming = true,
|
||||||
CustomAgents = CreateCustomAgents(definition.Copilot?.CustomAgents),
|
CustomAgents = CreateCustomAgents(definition.Config.Copilot?.CustomAgents),
|
||||||
Agent = ResolveEffectiveAgent(definition.Copilot?.Agent, command.PromptInvocation),
|
Agent = ResolveEffectiveAgent(definition.Config.Copilot?.Agent, command.PromptInvocation),
|
||||||
SkillDirectories = CreateStringList(definition.Copilot?.SkillDirectories),
|
SkillDirectories = CreateStringList(definition.Config.Copilot?.SkillDirectories),
|
||||||
DisabledSkills = CreateStringList(definition.Copilot?.DisabledSkills),
|
DisabledSkills = CreateStringList(definition.Config.Copilot?.DisabledSkills),
|
||||||
InfiniteSessions = CreateInfiniteSessions(definition.Copilot?.InfiniteSessions),
|
InfiniteSessions = CreateInfiniteSessions(definition.Config.Copilot?.InfiniteSessions),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -205,6 +203,34 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
internal static AIAgentHostOptions CreateAgentHostOptions()
|
||||||
|
{
|
||||||
|
return new AIAgentHostOptions
|
||||||
|
{
|
||||||
|
EmitAgentUpdateEvents = null,
|
||||||
|
EmitAgentResponseEvents = false,
|
||||||
|
InterceptUserInputRequests = false,
|
||||||
|
InterceptUnterminatedFunctionCalls = false,
|
||||||
|
ReassignOtherAgentsAsUsers = true,
|
||||||
|
ForwardIncomingMessages = true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
internal static HandoffsWorkflowBuilder CreateHandoffWorkflowBuilder(AIAgent entryAgent)
|
||||||
|
{
|
||||||
|
return AgentWorkflowBuilder.CreateHandoffBuilderWith(entryAgent)
|
||||||
|
.WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior.HandoffOnly)
|
||||||
|
.WithHandoffInstructions(HandoffWorkflowGuidance.CreateWorkflowInstructions());
|
||||||
|
}
|
||||||
|
|
||||||
|
public async ValueTask DisposeAsync()
|
||||||
|
{
|
||||||
|
foreach (IAsyncDisposable disposable in _disposables)
|
||||||
|
{
|
||||||
|
await disposable.DisposeAsync().ConfigureAwait(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static List<string>? CreateStringList(IReadOnlyList<string>? values)
|
private static List<string>? CreateStringList(IReadOnlyList<string>? values)
|
||||||
{
|
{
|
||||||
return values is { Count: > 0 }
|
return values is { Count: > 0 }
|
||||||
@@ -277,221 +303,4 @@ internal sealed class CopilotAgentBundle : IAsyncDisposable
|
|||||||
{
|
{
|
||||||
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
return string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
public Workflow BuildWorkflow(PatternDefinitionDto pattern)
|
|
||||||
{
|
|
||||||
return pattern.Mode switch
|
|
||||||
{
|
|
||||||
"single" => BuildSequentialWorkflow(pattern),
|
|
||||||
"sequential" => BuildSequentialWorkflow(pattern),
|
|
||||||
"concurrent" => BuildConcurrentWorkflow(pattern),
|
|
||||||
"handoff" => BuildHandoffWorkflow(pattern),
|
|
||||||
"group-chat" => BuildGroupChatWorkflow(pattern),
|
|
||||||
"magentic" => throw new NotSupportedException(
|
|
||||||
pattern.UnavailabilityReason
|
|
||||||
?? "Magentic orchestration is not yet supported in the .NET Agent Framework."),
|
|
||||||
_ => throw new NotSupportedException($"Unsupported orchestration mode '{pattern.Mode}'."),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public async ValueTask DisposeAsync()
|
|
||||||
{
|
|
||||||
foreach (IAsyncDisposable disposable in _disposables)
|
|
||||||
{
|
|
||||||
await disposable.DisposeAsync().ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private Workflow BuildHandoffWorkflow(PatternDefinitionDto pattern)
|
|
||||||
{
|
|
||||||
Dictionary<string, AIAgent> agentMap = BuildAgentMap(pattern);
|
|
||||||
Dictionary<string, PatternAgentDefinitionDto> definitionMap = pattern.Agents.ToDictionary(
|
|
||||||
definition => definition.Id,
|
|
||||||
definition => definition,
|
|
||||||
StringComparer.Ordinal);
|
|
||||||
PatternHandoffTopology topology = PatternGraphResolver.ResolveHandoff(pattern);
|
|
||||||
string entryAgentId = agentMap.ContainsKey(topology.EntryAgentId)
|
|
||||||
? topology.EntryAgentId
|
|
||||||
: pattern.Agents.FirstOrDefault()?.Id ?? topology.EntryAgentId;
|
|
||||||
AIAgent entryAgent = agentMap.GetValueOrDefault(entryAgentId) ?? Agents[0];
|
|
||||||
|
|
||||||
HandoffsWorkflowBuilder builder = CreateHandoffWorkflowBuilder(entryAgent);
|
|
||||||
|
|
||||||
foreach (PatternHandoffRoute route in topology.Routes)
|
|
||||||
{
|
|
||||||
if (!agentMap.TryGetValue(route.SourceAgentId, out AIAgent? sourceAgent)
|
|
||||||
|| !agentMap.TryGetValue(route.TargetAgentId, out AIAgent? targetAgent)
|
|
||||||
|| !definitionMap.TryGetValue(route.TargetAgentId, out PatternAgentDefinitionDto? targetDefinition))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
string handoffReason = string.Equals(
|
|
||||||
route.TargetAgentId,
|
|
||||||
topology.EntryAgentId,
|
|
||||||
StringComparison.Ordinal)
|
|
||||||
? HandoffWorkflowGuidance.CreateReturnReason(targetDefinition)
|
|
||||||
: HandoffWorkflowGuidance.CreateForwardReason(targetDefinition);
|
|
||||||
|
|
||||||
builder = builder.WithHandoff(
|
|
||||||
sourceAgent,
|
|
||||||
targetAgent,
|
|
||||||
handoffReason);
|
|
||||||
}
|
|
||||||
|
|
||||||
return builder.Build();
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static AIAgentHostOptions CreateAgentHostOptions()
|
|
||||||
{
|
|
||||||
return new AIAgentHostOptions
|
|
||||||
{
|
|
||||||
// Aryx controls per-turn streaming with TurnToken(emitEvents: true), so keep this
|
|
||||||
// null to preserve that behavior while making the host defaults explicit in code.
|
|
||||||
EmitAgentUpdateEvents = null,
|
|
||||||
// Aryx already projects streamed transcript state itself; enabling this would add
|
|
||||||
// extra response events that need separate reconciliation first.
|
|
||||||
EmitAgentResponseEvents = false,
|
|
||||||
InterceptUserInputRequests = false,
|
|
||||||
InterceptUnterminatedFunctionCalls = false,
|
|
||||||
ReassignOtherAgentsAsUsers = true,
|
|
||||||
ForwardIncomingMessages = true,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
internal static HandoffsWorkflowBuilder CreateHandoffWorkflowBuilder(AIAgent entryAgent)
|
|
||||||
{
|
|
||||||
return AgentWorkflowBuilder.CreateHandoffBuilderWith(entryAgent)
|
|
||||||
// Preserve normal tool-call history across handoffs while still hiding the
|
|
||||||
// workflow's handoff plumbing. Make this explicit so AF default changes
|
|
||||||
// cannot silently alter Aryx handoff behavior.
|
|
||||||
.WithToolCallFilteringBehavior(HandoffToolCallFilteringBehavior.HandoffOnly)
|
|
||||||
.WithHandoffInstructions(HandoffWorkflowGuidance.CreateWorkflowInstructions());
|
|
||||||
}
|
|
||||||
|
|
||||||
private Workflow BuildSequentialWorkflow(PatternDefinitionDto pattern)
|
|
||||||
{
|
|
||||||
IReadOnlyList<AIAgent> agents = ResolveOrderedAgents(pattern);
|
|
||||||
List<ExecutorBinding> agentExecutors = agents
|
|
||||||
.Select(CreateAgentExecutorBinding)
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
ExecutorBinding previous = agentExecutors[0];
|
|
||||||
WorkflowBuilder builder = new(previous);
|
|
||||||
|
|
||||||
foreach (ExecutorBinding next in agentExecutors.Skip(1))
|
|
||||||
{
|
|
||||||
builder.AddEdge(previous, next);
|
|
||||||
previous = next;
|
|
||||||
}
|
|
||||||
|
|
||||||
WorkflowOutputMessagesExecutor end = new();
|
|
||||||
builder = builder.AddEdge(previous, end).WithOutputFrom(end);
|
|
||||||
|
|
||||||
if (pattern.Name is not null)
|
|
||||||
{
|
|
||||||
builder = builder.WithName(pattern.Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
return builder.Build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Workflow BuildConcurrentWorkflow(PatternDefinitionDto pattern)
|
|
||||||
{
|
|
||||||
IReadOnlyList<AIAgent> agents = ResolveOrderedAgents(pattern);
|
|
||||||
ChatForwardingExecutor start = new("Start");
|
|
||||||
WorkflowBuilder builder = new(start);
|
|
||||||
|
|
||||||
ExecutorBinding[] agentExecutors = agents
|
|
||||||
.Select(CreateAgentExecutorBinding)
|
|
||||||
.ToArray();
|
|
||||||
ExecutorBinding[] accumulators = agentExecutors
|
|
||||||
.Select(executor => CreateAggregateMessagesExecutorBinding($"Batcher/{executor.Id}"))
|
|
||||||
.ToArray();
|
|
||||||
|
|
||||||
builder.AddFanOutEdge(start, agentExecutors);
|
|
||||||
|
|
||||||
for (int index = 0; index < agentExecutors.Length; index++)
|
|
||||||
{
|
|
||||||
builder.AddEdge(agentExecutors[index], accumulators[index]);
|
|
||||||
}
|
|
||||||
|
|
||||||
Func<string, string, ValueTask<WorkflowConcurrentEndExecutor>> endFactory =
|
|
||||||
(_, __) => new(new WorkflowConcurrentEndExecutor(agentExecutors.Length, AggregateConcurrentResults));
|
|
||||||
ExecutorBinding end = endFactory.BindExecutor(WorkflowConcurrentEndExecutor.ExecutorId);
|
|
||||||
|
|
||||||
builder.AddFanInBarrierEdge(accumulators, end);
|
|
||||||
builder = builder.WithOutputFrom(end);
|
|
||||||
|
|
||||||
if (pattern.Name is not null)
|
|
||||||
{
|
|
||||||
builder = builder.WithName(pattern.Name);
|
|
||||||
}
|
|
||||||
|
|
||||||
return builder.Build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private Workflow BuildGroupChatWorkflow(PatternDefinitionDto pattern)
|
|
||||||
{
|
|
||||||
int maximumIterations = pattern.MaxIterations <= 0 ? 5 : pattern.MaxIterations;
|
|
||||||
AIAgent[] agents = ResolveOrderedAgents(pattern).ToArray();
|
|
||||||
Dictionary<AIAgent, ExecutorBinding> agentMap = agents.ToDictionary(
|
|
||||||
agent => agent,
|
|
||||||
CreateAgentExecutorBinding);
|
|
||||||
|
|
||||||
Func<string, string, ValueTask<WorkflowRoundRobinGroupChatHost>> groupChatHostFactory =
|
|
||||||
(id, _) => new(new WorkflowRoundRobinGroupChatHost(
|
|
||||||
id,
|
|
||||||
agents,
|
|
||||||
agentMap,
|
|
||||||
maximumIterations));
|
|
||||||
|
|
||||||
ExecutorBinding host = groupChatHostFactory.BindExecutor("GroupChatHost");
|
|
||||||
WorkflowBuilder builder = new(host);
|
|
||||||
|
|
||||||
foreach (ExecutorBinding participant in agentMap.Values)
|
|
||||||
{
|
|
||||||
builder
|
|
||||||
.AddEdge(host, participant)
|
|
||||||
.AddEdge(participant, host);
|
|
||||||
}
|
|
||||||
|
|
||||||
return builder.WithOutputFrom(host).Build();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static ExecutorBinding CreateAgentExecutorBinding(AIAgent agent)
|
|
||||||
=> agent.BindAsExecutor(CreateAgentHostOptions());
|
|
||||||
|
|
||||||
private static ExecutorBinding CreateAggregateMessagesExecutorBinding(string id)
|
|
||||||
{
|
|
||||||
Func<string, string, ValueTask<WorkflowAggregateTurnMessagesExecutor>> factory =
|
|
||||||
(_, __) => new(new WorkflowAggregateTurnMessagesExecutor(id));
|
|
||||||
return factory.BindExecutor(id);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static List<ChatMessage> AggregateConcurrentResults(IList<List<ChatMessage>> lists)
|
|
||||||
=> [.. from list in lists where list.Count > 0 select list.Last()];
|
|
||||||
|
|
||||||
private IReadOnlyList<AIAgent> ResolveOrderedAgents(PatternDefinitionDto pattern)
|
|
||||||
{
|
|
||||||
Dictionary<string, AIAgent> agentMap = BuildAgentMap(pattern);
|
|
||||||
List<AIAgent> orderedAgents = PatternGraphResolver.ResolveOrderedAgentIds(pattern)
|
|
||||||
.Select(agentId => agentMap.TryGetValue(agentId, out AIAgent? agent) ? agent : null)
|
|
||||||
.Where(agent => agent is not null)
|
|
||||||
.Cast<AIAgent>()
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return orderedAgents.Count == Agents.Count ? orderedAgents : Agents;
|
|
||||||
}
|
|
||||||
|
|
||||||
private Dictionary<string, AIAgent> BuildAgentMap(PatternDefinitionDto pattern)
|
|
||||||
{
|
|
||||||
Dictionary<string, AIAgent> agentMap = new(StringComparer.Ordinal);
|
|
||||||
foreach ((PatternAgentDefinitionDto definition, AIAgent agent) in pattern.Agents.Zip(Agents))
|
|
||||||
{
|
|
||||||
agentMap[definition.Id] = agent;
|
|
||||||
}
|
|
||||||
|
|
||||||
return agentMap;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -67,7 +67,7 @@ internal sealed class CopilotApprovalCoordinator
|
|||||||
|
|
||||||
public async Task<PermissionRequestResult> RequestApprovalAsync(
|
public async Task<PermissionRequestResult> RequestApprovalAsync(
|
||||||
RunTurnCommandDto command,
|
RunTurnCommandDto command,
|
||||||
PatternAgentDefinitionDto agent,
|
WorkflowNodeDto agent,
|
||||||
PermissionRequest request,
|
PermissionRequest request,
|
||||||
PermissionInvocation invocation,
|
PermissionInvocation invocation,
|
||||||
IReadOnlyDictionary<string, string> toolNamesByCallId,
|
IReadOnlyDictionary<string, string> toolNamesByCallId,
|
||||||
@@ -88,7 +88,7 @@ internal sealed class CopilotApprovalCoordinator
|
|||||||
|
|
||||||
public async Task<PermissionRequestResult> RequestApprovalAsync(
|
public async Task<PermissionRequestResult> RequestApprovalAsync(
|
||||||
RunTurnCommandDto command,
|
RunTurnCommandDto command,
|
||||||
PatternAgentDefinitionDto agent,
|
WorkflowNodeDto agent,
|
||||||
PermissionRequest request,
|
PermissionRequest request,
|
||||||
PermissionInvocation invocation,
|
PermissionInvocation invocation,
|
||||||
IReadOnlyDictionary<string, string> toolNamesByCallId,
|
IReadOnlyDictionary<string, string> toolNamesByCallId,
|
||||||
@@ -108,7 +108,7 @@ internal sealed class CopilotApprovalCoordinator
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (IsToolApprovedForRequest(command.RequestId, approvalCacheKey)
|
if (IsToolApprovedForRequest(command.RequestId, approvalCacheKey)
|
||||||
|| !RequiresToolCallApproval(command.Pattern.ApprovalPolicy, agent.Id, toolName, autoApprovedToolName, mcpServerApprovalKey))
|
|| !RequiresToolCallApproval(command.Workflow.Settings.ApprovalPolicy, agent.GetAgentId(), toolName, autoApprovedToolName, mcpServerApprovalKey))
|
||||||
{
|
{
|
||||||
return CreateApprovalResult(PermissionRequestResultKind.Approved);
|
return CreateApprovalResult(PermissionRequestResultKind.Approved);
|
||||||
}
|
}
|
||||||
@@ -149,7 +149,7 @@ internal sealed class CopilotApprovalCoordinator
|
|||||||
|
|
||||||
internal static ApprovalRequestedEventDto BuildPermissionApprovalEvent(
|
internal static ApprovalRequestedEventDto BuildPermissionApprovalEvent(
|
||||||
RunTurnCommandDto command,
|
RunTurnCommandDto command,
|
||||||
PatternAgentDefinitionDto agent,
|
WorkflowNodeDto agent,
|
||||||
PermissionRequest request,
|
PermissionRequest request,
|
||||||
PermissionInvocation invocation,
|
PermissionInvocation invocation,
|
||||||
string approvalId,
|
string approvalId,
|
||||||
@@ -157,7 +157,8 @@ internal sealed class CopilotApprovalCoordinator
|
|||||||
{
|
{
|
||||||
string permissionKind = ResolvePermissionKind(request, command.Tooling?.McpServers);
|
string permissionKind = ResolvePermissionKind(request, command.Tooling?.McpServers);
|
||||||
|
|
||||||
string agentName = string.IsNullOrWhiteSpace(agent.Name) ? agent.Id : agent.Name;
|
string agentId = agent.GetAgentId();
|
||||||
|
string agentName = agent.GetAgentName();
|
||||||
string? sessionId = NormalizeOptionalString(invocation.SessionId);
|
string? sessionId = NormalizeOptionalString(invocation.SessionId);
|
||||||
string? normalizedToolName = NormalizeOptionalString(toolName);
|
string? normalizedToolName = NormalizeOptionalString(toolName);
|
||||||
string? requestedUrl = request is PermissionRequestUrl urlRequest
|
string? requestedUrl = request is PermissionRequestUrl urlRequest
|
||||||
@@ -191,7 +192,7 @@ internal sealed class CopilotApprovalCoordinator
|
|||||||
SessionId = command.SessionId,
|
SessionId = command.SessionId,
|
||||||
ApprovalId = approvalId,
|
ApprovalId = approvalId,
|
||||||
ApprovalKind = ToolCallApprovalKind,
|
ApprovalKind = ToolCallApprovalKind,
|
||||||
AgentId = NormalizeOptionalString(agent.Id),
|
AgentId = NormalizeOptionalString(agentId),
|
||||||
AgentName = NormalizeOptionalString(agentName),
|
AgentName = NormalizeOptionalString(agentName),
|
||||||
ToolName = normalizedToolName,
|
ToolName = normalizedToolName,
|
||||||
PermissionKind = permissionKind,
|
PermissionKind = permissionKind,
|
||||||
@@ -203,7 +204,7 @@ internal sealed class CopilotApprovalCoordinator
|
|||||||
|
|
||||||
internal static AgentActivityEventDto? BuildToolCallFileChangeActivity(
|
internal static AgentActivityEventDto? BuildToolCallFileChangeActivity(
|
||||||
RunTurnCommandDto command,
|
RunTurnCommandDto command,
|
||||||
PatternAgentDefinitionDto agent,
|
WorkflowNodeDto agent,
|
||||||
PermissionRequest request,
|
PermissionRequest request,
|
||||||
string? toolName)
|
string? toolName)
|
||||||
{
|
{
|
||||||
@@ -218,14 +219,15 @@ internal sealed class CopilotApprovalCoordinator
|
|||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
string agentName = string.IsNullOrWhiteSpace(agent.Name) ? agent.Id : agent.Name;
|
string agentId = agent.GetAgentId();
|
||||||
|
string agentName = agent.GetAgentName();
|
||||||
return new AgentActivityEventDto
|
return new AgentActivityEventDto
|
||||||
{
|
{
|
||||||
Type = "agent-activity",
|
Type = "agent-activity",
|
||||||
RequestId = command.RequestId,
|
RequestId = command.RequestId,
|
||||||
SessionId = command.SessionId,
|
SessionId = command.SessionId,
|
||||||
ActivityType = ToolCallingActivityType,
|
ActivityType = ToolCallingActivityType,
|
||||||
AgentId = NormalizeOptionalString(agent.Id),
|
AgentId = NormalizeOptionalString(agentId),
|
||||||
AgentName = NormalizeOptionalString(agentName),
|
AgentName = NormalizeOptionalString(agentName),
|
||||||
ToolName = NormalizeOptionalString(toolName),
|
ToolName = NormalizeOptionalString(toolName),
|
||||||
ToolCallId = NormalizeOptionalString(write.ToolCallId),
|
ToolCallId = NormalizeOptionalString(write.ToolCallId),
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ internal sealed class CopilotExitPlanModeCoordinator
|
|||||||
|
|
||||||
public ExitPlanModeRequestedEventDto RecordExitPlanModeRequest(
|
public ExitPlanModeRequestedEventDto RecordExitPlanModeRequest(
|
||||||
RunTurnCommandDto command,
|
RunTurnCommandDto command,
|
||||||
PatternAgentDefinitionDto agent,
|
WorkflowNodeDto agent,
|
||||||
ExitPlanModeRequestedEvent request)
|
ExitPlanModeRequestedEvent request)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(command);
|
ArgumentNullException.ThrowIfNull(command);
|
||||||
@@ -33,7 +33,7 @@ internal sealed class CopilotExitPlanModeCoordinator
|
|||||||
|
|
||||||
internal static ExitPlanModeRequestedEventDto BuildExitPlanModeRequestedEvent(
|
internal static ExitPlanModeRequestedEventDto BuildExitPlanModeRequestedEvent(
|
||||||
RunTurnCommandDto command,
|
RunTurnCommandDto command,
|
||||||
PatternAgentDefinitionDto agent,
|
WorkflowNodeDto agent,
|
||||||
ExitPlanModeRequestedEvent request)
|
ExitPlanModeRequestedEvent request)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(command);
|
ArgumentNullException.ThrowIfNull(command);
|
||||||
@@ -45,8 +45,8 @@ internal sealed class CopilotExitPlanModeCoordinator
|
|||||||
|
|
||||||
string exitPlanId = NormalizeOptionalString(requestData.RequestId)
|
string exitPlanId = NormalizeOptionalString(requestData.RequestId)
|
||||||
?? throw new InvalidOperationException("Exit plan mode request ID is required.");
|
?? throw new InvalidOperationException("Exit plan mode request ID is required.");
|
||||||
string? normalizedAgentId = NormalizeOptionalString(agent.Id);
|
string? normalizedAgentId = NormalizeOptionalString(agent.GetAgentId());
|
||||||
string? normalizedAgentName = NormalizeOptionalString(agent.Name) ?? normalizedAgentId;
|
string? normalizedAgentName = NormalizeOptionalString(agent.GetAgentName()) ?? normalizedAgentId;
|
||||||
|
|
||||||
return new ExitPlanModeRequestedEventDto
|
return new ExitPlanModeRequestedEventDto
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ internal sealed class CopilotMcpOAuthCoordinator
|
|||||||
{
|
{
|
||||||
public McpOauthRequiredEventDto BuildMcpOauthRequiredEvent(
|
public McpOauthRequiredEventDto BuildMcpOauthRequiredEvent(
|
||||||
RunTurnCommandDto command,
|
RunTurnCommandDto command,
|
||||||
PatternAgentDefinitionDto agent,
|
WorkflowNodeDto agent,
|
||||||
McpOauthRequiredEvent request)
|
McpOauthRequiredEvent request)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(command);
|
ArgumentNullException.ThrowIfNull(command);
|
||||||
@@ -19,8 +19,8 @@ internal sealed class CopilotMcpOAuthCoordinator
|
|||||||
|
|
||||||
string oauthRequestId = NormalizeOptionalString(requestData.RequestId)
|
string oauthRequestId = NormalizeOptionalString(requestData.RequestId)
|
||||||
?? throw new InvalidOperationException("MCP OAuth request ID is required.");
|
?? throw new InvalidOperationException("MCP OAuth request ID is required.");
|
||||||
string? normalizedAgentId = NormalizeOptionalString(agent.Id);
|
string? normalizedAgentId = NormalizeOptionalString(agent.GetAgentId());
|
||||||
string? normalizedAgentName = NormalizeOptionalString(agent.Name) ?? normalizedAgentId;
|
string? normalizedAgentName = NormalizeOptionalString(agent.GetAgentName()) ?? normalizedAgentId;
|
||||||
|
|
||||||
return new McpOauthRequiredEventDto
|
return new McpOauthRequiredEventDto
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ internal static class CopilotSessionHooks
|
|||||||
|
|
||||||
public static SessionHooks Create(
|
public static SessionHooks Create(
|
||||||
RunTurnCommandDto command,
|
RunTurnCommandDto command,
|
||||||
PatternAgentDefinitionDto agentDefinition,
|
WorkflowNodeDto agentDefinition,
|
||||||
ResolvedHookSet? configuredHooks = null,
|
ResolvedHookSet? configuredHooks = null,
|
||||||
IHookCommandRunner? hookCommandRunner = null)
|
IHookCommandRunner? hookCommandRunner = null)
|
||||||
{
|
{
|
||||||
@@ -62,7 +62,7 @@ internal static class CopilotSessionHooks
|
|||||||
|
|
||||||
private static async Task<PreToolUseHookOutput?> CreatePreToolUseOutputAsync(
|
private static async Task<PreToolUseHookOutput?> CreatePreToolUseOutputAsync(
|
||||||
RunTurnCommandDto command,
|
RunTurnCommandDto command,
|
||||||
PatternAgentDefinitionDto agentDefinition,
|
WorkflowNodeDto agentDefinition,
|
||||||
ResolvedHookSet configuredHooks,
|
ResolvedHookSet configuredHooks,
|
||||||
IHookCommandRunner hookCommandRunner,
|
IHookCommandRunner hookCommandRunner,
|
||||||
PreToolUseHookInput input)
|
PreToolUseHookInput input)
|
||||||
@@ -236,7 +236,7 @@ internal static class CopilotSessionHooks
|
|||||||
|
|
||||||
private static PreToolUseHookOutput CreateApprovalPolicyOutput(
|
private static PreToolUseHookOutput CreateApprovalPolicyOutput(
|
||||||
RunTurnCommandDto command,
|
RunTurnCommandDto command,
|
||||||
PatternAgentDefinitionDto agentDefinition,
|
WorkflowNodeDto agentDefinition,
|
||||||
PreToolUseHookInput input)
|
PreToolUseHookInput input)
|
||||||
{
|
{
|
||||||
string? toolName = Normalize(input.ToolName);
|
string? toolName = Normalize(input.ToolName);
|
||||||
@@ -254,8 +254,8 @@ internal static class CopilotSessionHooks
|
|||||||
command.Tooling?.McpServers);
|
command.Tooling?.McpServers);
|
||||||
|
|
||||||
bool requiresApproval = CopilotApprovalCoordinator.RequiresToolCallApproval(
|
bool requiresApproval = CopilotApprovalCoordinator.RequiresToolCallApproval(
|
||||||
command.Pattern.ApprovalPolicy,
|
command.Workflow.Settings.ApprovalPolicy,
|
||||||
agentDefinition.Id,
|
agentDefinition.GetAgentId(),
|
||||||
toolName,
|
toolName,
|
||||||
autoApprovedToolName,
|
autoApprovedToolName,
|
||||||
mcpServerApprovalKey);
|
mcpServerApprovalKey);
|
||||||
|
|||||||
@@ -65,12 +65,12 @@ internal sealed class CopilotTurnExecutionState
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public void ObserveSessionEvent(PatternAgentDefinitionDto agentDefinition, SessionEvent sessionEvent)
|
public void ObserveSessionEvent(WorkflowNodeDto agentDefinition, SessionEvent sessionEvent)
|
||||||
{
|
{
|
||||||
AgentIdentity agent = AgentIdentityResolver.ResolveAgentIdentity(
|
AgentIdentity agent = AgentIdentityResolver.ResolveAgentIdentity(
|
||||||
_command.Pattern,
|
_command.Workflow,
|
||||||
agentDefinition.Id,
|
agentDefinition.GetAgentId(),
|
||||||
agentDefinition.Name);
|
agentDefinition.GetAgentName());
|
||||||
|
|
||||||
switch (sessionEvent)
|
switch (sessionEvent)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -32,7 +32,7 @@ internal sealed class CopilotUserInputCoordinator
|
|||||||
|
|
||||||
public async Task<UserInputResponse> RequestUserInputAsync(
|
public async Task<UserInputResponse> RequestUserInputAsync(
|
||||||
RunTurnCommandDto command,
|
RunTurnCommandDto command,
|
||||||
PatternAgentDefinitionDto agent,
|
WorkflowNodeDto agent,
|
||||||
UserInputRequest request,
|
UserInputRequest request,
|
||||||
UserInputInvocation invocation,
|
UserInputInvocation invocation,
|
||||||
Func<UserInputRequestedEventDto, Task> onUserInput,
|
Func<UserInputRequestedEventDto, Task> onUserInput,
|
||||||
@@ -46,8 +46,8 @@ internal sealed class CopilotUserInputCoordinator
|
|||||||
|
|
||||||
return await RequestUserInputCoreAsync(
|
return await RequestUserInputCoreAsync(
|
||||||
command,
|
command,
|
||||||
agent.Id,
|
agent.GetAgentId(),
|
||||||
agent.Name,
|
agent.GetAgentName(),
|
||||||
request,
|
request,
|
||||||
onUserInput,
|
onUserInput,
|
||||||
cancellationToken).ConfigureAwait(false);
|
cancellationToken).ConfigureAwait(false);
|
||||||
@@ -74,7 +74,7 @@ internal sealed class CopilotUserInputCoordinator
|
|||||||
|
|
||||||
internal static UserInputRequestedEventDto BuildUserInputRequestedEvent(
|
internal static UserInputRequestedEventDto BuildUserInputRequestedEvent(
|
||||||
RunTurnCommandDto command,
|
RunTurnCommandDto command,
|
||||||
PatternAgentDefinitionDto agent,
|
WorkflowNodeDto agent,
|
||||||
UserInputRequest request,
|
UserInputRequest request,
|
||||||
string userInputId)
|
string userInputId)
|
||||||
{
|
{
|
||||||
@@ -82,8 +82,8 @@ internal sealed class CopilotUserInputCoordinator
|
|||||||
ArgumentNullException.ThrowIfNull(agent);
|
ArgumentNullException.ThrowIfNull(agent);
|
||||||
ArgumentNullException.ThrowIfNull(request);
|
ArgumentNullException.ThrowIfNull(request);
|
||||||
|
|
||||||
string? normalizedAgentId = NormalizeOptionalString(agent.Id);
|
string? normalizedAgentId = NormalizeOptionalString(agent.GetAgentId());
|
||||||
string? normalizedAgentName = NormalizeOptionalString(agent.Name) ?? normalizedAgentId;
|
string? normalizedAgentName = NormalizeOptionalString(agent.GetAgentName()) ?? normalizedAgentId;
|
||||||
|
|
||||||
return new UserInputRequestedEventDto
|
return new UserInputRequestedEventDto
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -14,7 +14,6 @@ namespace Aryx.AgentHost.Services;
|
|||||||
public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
||||||
{
|
{
|
||||||
private const string HandoffFunctionPrefix = "handoff_to_";
|
private const string HandoffFunctionPrefix = "handoff_to_";
|
||||||
private readonly PatternValidator _patternValidator;
|
|
||||||
private readonly WorkflowValidator _workflowValidator;
|
private readonly WorkflowValidator _workflowValidator;
|
||||||
private readonly WorkflowRunner _workflowRunner = new();
|
private readonly WorkflowRunner _workflowRunner = new();
|
||||||
private readonly CopilotApprovalCoordinator _approvalCoordinator = new();
|
private readonly CopilotApprovalCoordinator _approvalCoordinator = new();
|
||||||
@@ -22,9 +21,8 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
private readonly CopilotMcpOAuthCoordinator _mcpOAuthCoordinator = new();
|
private readonly CopilotMcpOAuthCoordinator _mcpOAuthCoordinator = new();
|
||||||
private readonly CopilotExitPlanModeCoordinator _exitPlanModeCoordinator = new();
|
private readonly CopilotExitPlanModeCoordinator _exitPlanModeCoordinator = new();
|
||||||
|
|
||||||
public CopilotWorkflowRunner(PatternValidator patternValidator, WorkflowValidator? workflowValidator = null)
|
public CopilotWorkflowRunner(WorkflowValidator? workflowValidator = null)
|
||||||
{
|
{
|
||||||
_patternValidator = patternValidator;
|
|
||||||
_workflowValidator = workflowValidator ?? new WorkflowValidator();
|
_workflowValidator = workflowValidator ?? new WorkflowValidator();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,9 +36,8 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
Func<ExitPlanModeRequestedEventDto, Task> onExitPlanMode,
|
Func<ExitPlanModeRequestedEventDto, Task> onExitPlanMode,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
string? validationError = command.Workflow is null
|
string? validationError = _workflowValidator.Validate(command.Workflow, command.WorkflowLibrary)
|
||||||
? _patternValidator.Validate(command.Pattern).FirstOrDefault()?.Message
|
.FirstOrDefault()?.Message;
|
||||||
: _workflowValidator.Validate(command.Workflow, command.WorkflowLibrary).FirstOrDefault()?.Message;
|
|
||||||
if (validationError is not null)
|
if (validationError is not null)
|
||||||
{
|
{
|
||||||
throw new InvalidOperationException(validationError);
|
throw new InvalidOperationException(validationError);
|
||||||
@@ -87,9 +84,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
},
|
},
|
||||||
runCancellation.Token);
|
runCancellation.Token);
|
||||||
ConfigureHookLifecycleEventSuppression(state, bundle);
|
ConfigureHookLifecycleEventSuppression(state, bundle);
|
||||||
Workflow workflow = command.Workflow is null
|
Workflow workflow = _workflowRunner.BuildWorkflow(command.Workflow, bundle.Agents, command.WorkflowLibrary);
|
||||||
? bundle.BuildWorkflow(command.Pattern)
|
|
||||||
: _workflowRunner.BuildWorkflow(command.Workflow, command.Pattern, bundle.Agents, command.WorkflowLibrary);
|
|
||||||
List<ChatMessage> inputMessages = command.Messages.Select(WorkflowTranscriptProjector.ToChatMessage).ToList();
|
List<ChatMessage> inputMessages = command.Messages.Select(WorkflowTranscriptProjector.ToChatMessage).ToList();
|
||||||
WorkflowTranscriptProjector.AttachMessageMode(inputMessages, command.MessageMode);
|
WorkflowTranscriptProjector.AttachMessageMode(inputMessages, command.MessageMode);
|
||||||
|
|
||||||
@@ -166,12 +161,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
internal static bool ShouldEnableWorkflowCheckpointing(RunTurnCommandDto command)
|
internal static bool ShouldEnableWorkflowCheckpointing(RunTurnCommandDto command)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(command);
|
ArgumentNullException.ThrowIfNull(command);
|
||||||
if (command.Workflow is not null)
|
return command.Workflow.Settings.Checkpointing.Enabled;
|
||||||
{
|
|
||||||
return command.Workflow.Settings.Checkpointing.Enabled;
|
|
||||||
}
|
|
||||||
|
|
||||||
return string.Equals(command.Pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal static string GetCheckpointStorePath(RunTurnCommandDto command)
|
internal static string GetCheckpointStorePath(RunTurnCommandDto command)
|
||||||
@@ -210,7 +200,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(command);
|
ArgumentNullException.ThrowIfNull(command);
|
||||||
|
|
||||||
string executionMode = command.Workflow?.Settings.ExecutionMode?.Trim() ?? "off-thread";
|
string executionMode = command.Workflow.Settings.ExecutionMode?.Trim() ?? "off-thread";
|
||||||
InProcessExecutionEnvironment environment = string.Equals(
|
InProcessExecutionEnvironment environment = string.Equals(
|
||||||
executionMode,
|
executionMode,
|
||||||
"lockstep",
|
"lockstep",
|
||||||
@@ -300,7 +290,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
if (evt is ExecutorInvokedEvent invoked)
|
if (evt is ExecutorInvokedEvent invoked)
|
||||||
{
|
{
|
||||||
if (AgentIdentityResolver.TryResolveKnownAgentIdentity(
|
if (AgentIdentityResolver.TryResolveKnownAgentIdentity(
|
||||||
command.Pattern,
|
command.Workflow,
|
||||||
invoked.ExecutorId,
|
invoked.ExecutorId,
|
||||||
out AgentIdentity invokedAgent))
|
out AgentIdentity invokedAgent))
|
||||||
{
|
{
|
||||||
@@ -357,7 +347,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
if (evt is ExecutorCompletedEvent completed)
|
if (evt is ExecutorCompletedEvent completed)
|
||||||
{
|
{
|
||||||
if (AgentIdentityResolver.TryResolveObservedAgentIdentity(
|
if (AgentIdentityResolver.TryResolveObservedAgentIdentity(
|
||||||
command.Pattern,
|
command.Workflow,
|
||||||
completed.ExecutorId,
|
completed.ExecutorId,
|
||||||
state.ActiveAgent,
|
state.ActiveAgent,
|
||||||
out AgentIdentity completedAgent))
|
out AgentIdentity completedAgent))
|
||||||
@@ -471,7 +461,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
authorName = observedMessageAgent.AgentName;
|
authorName = observedMessageAgent.AgentName;
|
||||||
}
|
}
|
||||||
else if (AgentIdentityResolver.TryResolveObservedAgentIdentity(
|
else if (AgentIdentityResolver.TryResolveObservedAgentIdentity(
|
||||||
command.Pattern,
|
command.Workflow,
|
||||||
update.ExecutorId,
|
update.ExecutorId,
|
||||||
state.ActiveAgent,
|
state.ActiveAgent,
|
||||||
out AgentIdentity resolvedUpdateAgent))
|
out AgentIdentity resolvedUpdateAgent))
|
||||||
@@ -594,7 +584,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
case ExecutorFailedEvent executorFailed:
|
case ExecutorFailedEvent executorFailed:
|
||||||
{
|
{
|
||||||
AgentIdentity? agent = AgentIdentityResolver.TryResolveObservedAgentIdentity(
|
AgentIdentity? agent = AgentIdentityResolver.TryResolveObservedAgentIdentity(
|
||||||
command.Pattern,
|
command.Workflow,
|
||||||
executorFailed.ExecutorId,
|
executorFailed.ExecutorId,
|
||||||
state.ActiveAgent,
|
state.ActiveAgent,
|
||||||
out AgentIdentity resolvedAgent)
|
out AgentIdentity resolvedAgent)
|
||||||
@@ -754,7 +744,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner
|
|||||||
|
|
||||||
private static void TraceHandoff(RunTurnCommandDto command, string message)
|
private static void TraceHandoff(RunTurnCommandDto command, string message)
|
||||||
{
|
{
|
||||||
if (!string.Equals(command.Pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase))
|
if (!command.Workflow.IsOrchestrationMode("handoff"))
|
||||||
{
|
{
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -21,17 +21,17 @@ internal static class HandoffWorkflowGuidance
|
|||||||
""";
|
""";
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string CreateForwardReason(PatternAgentDefinitionDto target)
|
public static string CreateForwardReason(WorkflowNodeDto target)
|
||||||
{
|
{
|
||||||
string specialty = string.IsNullOrWhiteSpace(target.Description)
|
string specialty = string.IsNullOrWhiteSpace(target.Config.Description)
|
||||||
? target.Name
|
? target.GetAgentName()
|
||||||
: target.Description.TrimEnd('.');
|
: target.Config.Description.TrimEnd('.');
|
||||||
|
|
||||||
return $"Hand off when the request primarily concerns {specialty}. Once handed off, let {target.Name} own the substantive response.";
|
return $"Hand off when the request primarily concerns {specialty}. Once handed off, let {target.GetAgentName()} own the substantive response.";
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string CreateReturnReason(PatternAgentDefinitionDto triageAgent)
|
public static string CreateReturnReason(WorkflowNodeDto triageAgent)
|
||||||
{
|
{
|
||||||
return $"Hand off back to {triageAgent.Name} only when the task needs re-routing, cross-specialist coordination, or is outside your specialty.";
|
return $"Hand off back to {triageAgent.GetAgentName()} only when the task needs re-routing, cross-specialist coordination, or is outside your specialty.";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,345 +0,0 @@
|
|||||||
using Aryx.AgentHost.Contracts;
|
|
||||||
|
|
||||||
namespace Aryx.AgentHost.Services;
|
|
||||||
|
|
||||||
internal sealed record PatternHandoffRoute(string SourceAgentId, string TargetAgentId);
|
|
||||||
|
|
||||||
internal sealed record PatternHandoffTopology(string EntryAgentId, IReadOnlyList<PatternHandoffRoute> Routes);
|
|
||||||
|
|
||||||
internal static class PatternGraphResolver
|
|
||||||
{
|
|
||||||
private const string UserInputKind = "user-input";
|
|
||||||
private const string UserOutputKind = "user-output";
|
|
||||||
private const string AgentKind = "agent";
|
|
||||||
private const string DistributorKind = "distributor";
|
|
||||||
private const string CollectorKind = "collector";
|
|
||||||
private const string OrchestratorKind = "orchestrator";
|
|
||||||
|
|
||||||
private static readonly StringComparer Comparer = StringComparer.OrdinalIgnoreCase;
|
|
||||||
|
|
||||||
public static PatternGraphDto Resolve(PatternDefinitionDto pattern)
|
|
||||||
=> pattern.Graph ?? CreateDefault(pattern);
|
|
||||||
|
|
||||||
public static IReadOnlyList<string> ResolveOrderedAgentIds(PatternDefinitionDto pattern)
|
|
||||||
{
|
|
||||||
PatternGraphDto graph = Resolve(pattern);
|
|
||||||
|
|
||||||
return pattern.Mode switch
|
|
||||||
{
|
|
||||||
"single" or "sequential" or "magentic" => ResolveLinearAgentIds(pattern, graph),
|
|
||||||
"concurrent" or "group-chat" or "handoff" => ResolveAgentOrder(pattern, graph),
|
|
||||||
_ => pattern.Agents.Select(agent => agent.Id).ToList()
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
public static PatternHandoffTopology ResolveHandoff(PatternDefinitionDto pattern)
|
|
||||||
{
|
|
||||||
return TryResolveHandoff(pattern, Resolve(pattern))
|
|
||||||
?? TryResolveHandoff(pattern, CreateDefault(pattern))
|
|
||||||
?? new PatternHandoffTopology(
|
|
||||||
pattern.Agents.FirstOrDefault()?.Id ?? string.Empty,
|
|
||||||
[]);
|
|
||||||
}
|
|
||||||
|
|
||||||
public static PatternGraphDto CreateDefault(PatternDefinitionDto pattern)
|
|
||||||
{
|
|
||||||
return pattern.Mode switch
|
|
||||||
{
|
|
||||||
"single" or "sequential" or "magentic" => CreateLinearGraph(pattern.Agents),
|
|
||||||
"concurrent" => CreateConcurrentGraph(pattern.Agents),
|
|
||||||
"handoff" => CreateHandoffGraph(pattern.Agents),
|
|
||||||
"group-chat" => CreateGroupChatGraph(pattern.Agents),
|
|
||||||
_ => CreateLinearGraph(pattern.Agents)
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IReadOnlyList<string> ResolveLinearAgentIds(PatternDefinitionDto pattern, PatternGraphDto graph)
|
|
||||||
{
|
|
||||||
PatternGraphNodeDto? inputNode = GetNodeByKind(graph, UserInputKind);
|
|
||||||
PatternGraphNodeDto? outputNode = GetNodeByKind(graph, UserOutputKind);
|
|
||||||
if (inputNode is null || outputNode is null)
|
|
||||||
{
|
|
||||||
return pattern.Agents.Select(agent => agent.Id).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
Dictionary<string, PatternGraphNodeDto> nodesById = graph.Nodes.ToDictionary(node => node.Id, node => node);
|
|
||||||
Dictionary<string, List<PatternGraphEdgeDto>> outgoing = BuildOutgoingLookup(graph);
|
|
||||||
List<string> orderedAgentIds = [];
|
|
||||||
HashSet<string> visitedNodeIds = [];
|
|
||||||
string currentNodeId = inputNode.Id;
|
|
||||||
|
|
||||||
while (visitedNodeIds.Add(currentNodeId))
|
|
||||||
{
|
|
||||||
if (!outgoing.TryGetValue(currentNodeId, out List<PatternGraphEdgeDto>? edges) || edges.Count != 1)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
string nextNodeId = edges[0].Target;
|
|
||||||
if (!nodesById.TryGetValue(nextNodeId, out PatternGraphNodeDto? nextNode))
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Comparer.Equals(nextNode.Id, outputNode.Id))
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Comparer.Equals(nextNode.Kind, AgentKind) && !string.IsNullOrWhiteSpace(nextNode.AgentId))
|
|
||||||
{
|
|
||||||
orderedAgentIds.Add(nextNode.AgentId);
|
|
||||||
}
|
|
||||||
|
|
||||||
currentNodeId = nextNodeId;
|
|
||||||
}
|
|
||||||
|
|
||||||
return orderedAgentIds.Count == pattern.Agents.Count
|
|
||||||
? orderedAgentIds
|
|
||||||
: pattern.Agents.Select(agent => agent.Id).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static IReadOnlyList<string> ResolveAgentOrder(PatternDefinitionDto pattern, PatternGraphDto graph)
|
|
||||||
{
|
|
||||||
Dictionary<string, int> fallbackOrder = pattern.Agents
|
|
||||||
.Select((agent, index) => new { agent.Id, Index = index })
|
|
||||||
.ToDictionary(item => item.Id, item => item.Index);
|
|
||||||
|
|
||||||
List<string> orderedAgentIds = graph.Nodes
|
|
||||||
.Where(node => Comparer.Equals(node.Kind, AgentKind) && !string.IsNullOrWhiteSpace(node.AgentId))
|
|
||||||
.OrderBy(node => node.Order ?? int.MaxValue)
|
|
||||||
.ThenBy(node => fallbackOrder.GetValueOrDefault(node.AgentId!, int.MaxValue))
|
|
||||||
.Select(node => node.AgentId!)
|
|
||||||
.Distinct()
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return orderedAgentIds.Count == pattern.Agents.Count
|
|
||||||
? orderedAgentIds
|
|
||||||
: pattern.Agents.Select(agent => agent.Id).ToList();
|
|
||||||
}
|
|
||||||
|
|
||||||
private static PatternHandoffTopology? TryResolveHandoff(PatternDefinitionDto pattern, PatternGraphDto graph)
|
|
||||||
{
|
|
||||||
Dictionary<string, PatternGraphNodeDto> nodesById = graph.Nodes.ToDictionary(node => node.Id, node => node);
|
|
||||||
PatternGraphNodeDto? inputNode = GetNodeByKind(graph, UserInputKind);
|
|
||||||
string? entryAgentId = null;
|
|
||||||
|
|
||||||
if (inputNode is not null)
|
|
||||||
{
|
|
||||||
entryAgentId = graph.Edges
|
|
||||||
.Where(edge => Comparer.Equals(edge.Source, inputNode.Id))
|
|
||||||
.Select(edge => nodesById.TryGetValue(edge.Target, out PatternGraphNodeDto? targetNode)
|
|
||||||
? targetNode.AgentId
|
|
||||||
: null)
|
|
||||||
.FirstOrDefault(agentId => !string.IsNullOrWhiteSpace(agentId));
|
|
||||||
}
|
|
||||||
|
|
||||||
List<PatternHandoffRoute> routes = graph.Edges
|
|
||||||
.Select(edge => (SourceNode: nodesById.GetValueOrDefault(edge.Source), TargetNode: nodesById.GetValueOrDefault(edge.Target)))
|
|
||||||
.Where(item =>
|
|
||||||
item.SourceNode is not null
|
|
||||||
&& item.TargetNode is not null
|
|
||||||
&& Comparer.Equals(item.SourceNode.Kind, AgentKind)
|
|
||||||
&& Comparer.Equals(item.TargetNode.Kind, AgentKind)
|
|
||||||
&& !string.IsNullOrWhiteSpace(item.SourceNode.AgentId)
|
|
||||||
&& !string.IsNullOrWhiteSpace(item.TargetNode.AgentId))
|
|
||||||
.Select(item => new PatternHandoffRoute(item.SourceNode!.AgentId!, item.TargetNode!.AgentId!))
|
|
||||||
.Distinct()
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(entryAgentId) || routes.Count == 0)
|
|
||||||
{
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new PatternHandoffTopology(entryAgentId!, routes);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Dictionary<string, List<PatternGraphEdgeDto>> BuildOutgoingLookup(PatternGraphDto graph)
|
|
||||||
{
|
|
||||||
Dictionary<string, List<PatternGraphEdgeDto>> lookup = new(StringComparer.Ordinal);
|
|
||||||
foreach (PatternGraphNodeDto node in graph.Nodes)
|
|
||||||
{
|
|
||||||
lookup[node.Id] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (PatternGraphEdgeDto edge in graph.Edges)
|
|
||||||
{
|
|
||||||
if (!lookup.TryGetValue(edge.Source, out List<PatternGraphEdgeDto>? edges))
|
|
||||||
{
|
|
||||||
edges = [];
|
|
||||||
lookup[edge.Source] = edges;
|
|
||||||
}
|
|
||||||
|
|
||||||
edges.Add(edge);
|
|
||||||
}
|
|
||||||
|
|
||||||
return lookup;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static PatternGraphNodeDto? GetNodeByKind(PatternGraphDto graph, string kind)
|
|
||||||
=> graph.Nodes.FirstOrDefault(node => Comparer.Equals(node.Kind, kind));
|
|
||||||
|
|
||||||
private static PatternGraphDto CreateLinearGraph(IReadOnlyList<PatternAgentDefinitionDto> agents)
|
|
||||||
{
|
|
||||||
PatternGraphNodeDto inputNode = CreateSystemNode("system-user-input", UserInputKind, 0, 0);
|
|
||||||
PatternGraphNodeDto outputNode = CreateSystemNode("system-user-output", UserOutputKind, 220 * Math.Max(agents.Count + 1, 2), 0);
|
|
||||||
List<PatternGraphNodeDto> agentNodes = agents
|
|
||||||
.Select((agent, index) => CreateAgentNode(agent, index, 220 * (index + 1), 0))
|
|
||||||
.ToList();
|
|
||||||
List<PatternGraphEdgeDto> edges = [];
|
|
||||||
List<string> path = [inputNode.Id, .. agentNodes.Select(node => node.Id), outputNode.Id];
|
|
||||||
for (int index = 0; index < path.Count - 1; index += 1)
|
|
||||||
{
|
|
||||||
edges.Add(CreateEdge(path[index], path[index + 1]));
|
|
||||||
}
|
|
||||||
|
|
||||||
return new PatternGraphDto
|
|
||||||
{
|
|
||||||
Nodes = [inputNode, .. agentNodes, outputNode],
|
|
||||||
Edges = edges
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static PatternGraphDto CreateConcurrentGraph(IReadOnlyList<PatternAgentDefinitionDto> agents)
|
|
||||||
{
|
|
||||||
PatternGraphNodeDto inputNode = CreateSystemNode("system-user-input", UserInputKind, 0, 0);
|
|
||||||
PatternGraphNodeDto distributorNode = CreateSystemNode("system-distributor", DistributorKind, 190, 0);
|
|
||||||
PatternGraphNodeDto collectorNode = CreateSystemNode("system-collector", CollectorKind, 650, 0);
|
|
||||||
PatternGraphNodeDto outputNode = CreateSystemNode("system-user-output", UserOutputKind, 860, 0);
|
|
||||||
List<PatternGraphNodeDto> agentNodes = agents
|
|
||||||
.Select((agent, index) => CreateAgentNode(agent, index, 430, SpreadY(index, Math.Max(agents.Count, 1), 170)))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return new PatternGraphDto
|
|
||||||
{
|
|
||||||
Nodes = [inputNode, distributorNode, .. agentNodes, collectorNode, outputNode],
|
|
||||||
Edges =
|
|
||||||
[
|
|
||||||
CreateEdge(inputNode.Id, distributorNode.Id),
|
|
||||||
.. agentNodes.Select(node => CreateEdge(distributorNode.Id, node.Id)),
|
|
||||||
.. agentNodes.Select(node => CreateEdge(node.Id, collectorNode.Id)),
|
|
||||||
CreateEdge(collectorNode.Id, outputNode.Id)
|
|
||||||
]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static PatternGraphDto CreateHandoffGraph(IReadOnlyList<PatternAgentDefinitionDto> agents)
|
|
||||||
{
|
|
||||||
PatternGraphNodeDto inputNode = CreateSystemNode("system-user-input", UserInputKind, 0, 0);
|
|
||||||
PatternGraphNodeDto outputNode = CreateSystemNode("system-user-output", UserOutputKind, 860, 0);
|
|
||||||
PatternAgentDefinitionDto? entryAgent = agents.FirstOrDefault();
|
|
||||||
PatternGraphNodeDto? entryNode = entryAgent is null
|
|
||||||
? null
|
|
||||||
: CreateAgentNode(entryAgent, 0, 220, 0);
|
|
||||||
List<PatternGraphNodeDto> specialistNodes = agents
|
|
||||||
.Skip(1)
|
|
||||||
.Select((agent, index) => CreateAgentNode(agent, index + 1, 540, SpreadY(index, Math.Max(agents.Count - 1, 1), 220)))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
List<PatternGraphEdgeDto> edges = [];
|
|
||||||
if (entryNode is not null)
|
|
||||||
{
|
|
||||||
edges.Add(CreateEdge(inputNode.Id, entryNode.Id));
|
|
||||||
edges.Add(CreateEdge(entryNode.Id, outputNode.Id));
|
|
||||||
|
|
||||||
foreach (PatternGraphNodeDto specialistNode in specialistNodes)
|
|
||||||
{
|
|
||||||
edges.Add(CreateEdge(entryNode.Id, specialistNode.Id));
|
|
||||||
edges.Add(CreateEdge(specialistNode.Id, entryNode.Id));
|
|
||||||
edges.Add(CreateEdge(specialistNode.Id, outputNode.Id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
List<PatternGraphNodeDto> nodes = [inputNode];
|
|
||||||
if (entryNode is not null)
|
|
||||||
{
|
|
||||||
nodes.Add(entryNode);
|
|
||||||
}
|
|
||||||
nodes.AddRange(specialistNodes);
|
|
||||||
nodes.Add(outputNode);
|
|
||||||
|
|
||||||
return new PatternGraphDto
|
|
||||||
{
|
|
||||||
Nodes = nodes,
|
|
||||||
Edges = edges
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static PatternGraphDto CreateGroupChatGraph(IReadOnlyList<PatternAgentDefinitionDto> agents)
|
|
||||||
{
|
|
||||||
PatternGraphNodeDto inputNode = CreateSystemNode("system-user-input", UserInputKind, 0, 0);
|
|
||||||
PatternGraphNodeDto orchestratorNode = CreateSystemNode("system-orchestrator", OrchestratorKind, 250, 0);
|
|
||||||
PatternGraphNodeDto outputNode = CreateSystemNode("system-user-output", UserOutputKind, 900, 0);
|
|
||||||
const double centerX = 560;
|
|
||||||
const double centerY = 0;
|
|
||||||
const double radiusX = 190;
|
|
||||||
const double radiusY = 170;
|
|
||||||
|
|
||||||
List<PatternGraphNodeDto> agentNodes = agents
|
|
||||||
.Select((agent, index) =>
|
|
||||||
{
|
|
||||||
double angle = agents.Count <= 1
|
|
||||||
? 0
|
|
||||||
: (Math.PI * 2 * index) / agents.Count - (Math.PI / 2);
|
|
||||||
return CreateAgentNode(
|
|
||||||
agent,
|
|
||||||
index,
|
|
||||||
Math.Round(centerX + Math.Cos(angle) * radiusX),
|
|
||||||
Math.Round(centerY + Math.Sin(angle) * radiusY));
|
|
||||||
})
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return new PatternGraphDto
|
|
||||||
{
|
|
||||||
Nodes = [inputNode, orchestratorNode, .. agentNodes, outputNode],
|
|
||||||
Edges =
|
|
||||||
[
|
|
||||||
CreateEdge(inputNode.Id, orchestratorNode.Id),
|
|
||||||
.. agentNodes.SelectMany(node => new[]
|
|
||||||
{
|
|
||||||
CreateEdge(orchestratorNode.Id, node.Id),
|
|
||||||
CreateEdge(node.Id, orchestratorNode.Id)
|
|
||||||
}),
|
|
||||||
CreateEdge(orchestratorNode.Id, outputNode.Id)
|
|
||||||
]
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static PatternGraphNodeDto CreateSystemNode(string id, string kind, double x, double y)
|
|
||||||
=> new()
|
|
||||||
{
|
|
||||||
Id = id,
|
|
||||||
Kind = kind,
|
|
||||||
Position = new PatternGraphPositionDto
|
|
||||||
{
|
|
||||||
X = x,
|
|
||||||
Y = y
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private static PatternGraphNodeDto CreateAgentNode(PatternAgentDefinitionDto agent, int order, double x, double y)
|
|
||||||
=> new()
|
|
||||||
{
|
|
||||||
Id = $"agent-node-{agent.Id}",
|
|
||||||
Kind = AgentKind,
|
|
||||||
AgentId = agent.Id,
|
|
||||||
Order = order,
|
|
||||||
Position = new PatternGraphPositionDto
|
|
||||||
{
|
|
||||||
X = x,
|
|
||||||
Y = y
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
private static PatternGraphEdgeDto CreateEdge(string source, string target)
|
|
||||||
=> new()
|
|
||||||
{
|
|
||||||
Id = $"edge-{source}-to-{target}",
|
|
||||||
Source = source,
|
|
||||||
Target = target
|
|
||||||
};
|
|
||||||
|
|
||||||
private static double SpreadY(int index, int count, double gap)
|
|
||||||
=> (index - ((count - 1) / 2d)) * gap;
|
|
||||||
}
|
|
||||||
@@ -1,573 +0,0 @@
|
|||||||
using Aryx.AgentHost.Contracts;
|
|
||||||
|
|
||||||
namespace Aryx.AgentHost.Services;
|
|
||||||
|
|
||||||
public sealed class PatternValidator
|
|
||||||
{
|
|
||||||
private static readonly StringComparer Comparer = StringComparer.OrdinalIgnoreCase;
|
|
||||||
|
|
||||||
public IReadOnlyList<PatternValidationIssueDto> Validate(PatternDefinitionDto pattern)
|
|
||||||
{
|
|
||||||
List<PatternValidationIssueDto> issues = [];
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(pattern.Name))
|
|
||||||
{
|
|
||||||
issues.Add(new PatternValidationIssueDto
|
|
||||||
{
|
|
||||||
Field = "name",
|
|
||||||
Message = "Pattern name is required.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.Equals(pattern.Availability, "unavailable", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
issues.Add(new PatternValidationIssueDto
|
|
||||||
{
|
|
||||||
Field = "availability",
|
|
||||||
Message = pattern.UnavailabilityReason ?? "This orchestration mode is currently unavailable.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pattern.Agents.Count == 0)
|
|
||||||
{
|
|
||||||
issues.Add(new PatternValidationIssueDto
|
|
||||||
{
|
|
||||||
Field = "agents",
|
|
||||||
Message = "At least one agent is required.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.Equals(pattern.Mode, "single", StringComparison.OrdinalIgnoreCase) && pattern.Agents.Count != 1)
|
|
||||||
{
|
|
||||||
issues.Add(new PatternValidationIssueDto
|
|
||||||
{
|
|
||||||
Field = "agents",
|
|
||||||
Message = "Single-agent chat requires exactly one agent.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.Equals(pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase) && pattern.Agents.Count < 2)
|
|
||||||
{
|
|
||||||
issues.Add(new PatternValidationIssueDto
|
|
||||||
{
|
|
||||||
Field = "agents",
|
|
||||||
Message = "Handoff orchestration requires at least two agents.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.Equals(pattern.Mode, "group-chat", StringComparison.OrdinalIgnoreCase) && pattern.Agents.Count < 2)
|
|
||||||
{
|
|
||||||
issues.Add(new PatternValidationIssueDto
|
|
||||||
{
|
|
||||||
Field = "agents",
|
|
||||||
Message = "Group chat requires at least two agents.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.Equals(pattern.Mode, "magentic", StringComparison.OrdinalIgnoreCase))
|
|
||||||
{
|
|
||||||
issues.Add(new PatternValidationIssueDto
|
|
||||||
{
|
|
||||||
Field = "mode",
|
|
||||||
Message = pattern.UnavailabilityReason
|
|
||||||
?? "Magentic orchestration is currently documented as unsupported in the .NET Agent Framework.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (PatternAgentDefinitionDto agent in pattern.Agents)
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(agent.Name))
|
|
||||||
{
|
|
||||||
issues.Add(new PatternValidationIssueDto
|
|
||||||
{
|
|
||||||
Field = "agents.name",
|
|
||||||
Message = "Every agent needs a name.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (string.IsNullOrWhiteSpace(agent.Model))
|
|
||||||
{
|
|
||||||
issues.Add(new PatternValidationIssueDto
|
|
||||||
{
|
|
||||||
Field = "agents.model",
|
|
||||||
Message = $"Agent \"{agent.Name}\" requires a model identifier.",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
ValidateGraph(pattern, PatternGraphResolver.Resolve(pattern), issues);
|
|
||||||
return issues;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ValidateGraph(
|
|
||||||
PatternDefinitionDto pattern,
|
|
||||||
PatternGraphDto graph,
|
|
||||||
List<PatternValidationIssueDto> issues)
|
|
||||||
{
|
|
||||||
if (graph.Nodes.Count == 0)
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, "Pattern graph must include nodes.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
HashSet<string> nodeIds = new(StringComparer.Ordinal);
|
|
||||||
HashSet<string> edgeIds = new(StringComparer.Ordinal);
|
|
||||||
HashSet<string> agentIds = pattern.Agents.Select(agent => agent.Id).ToHashSet(StringComparer.Ordinal);
|
|
||||||
HashSet<string> seenAgentIds = new(StringComparer.Ordinal);
|
|
||||||
HashSet<int> seenAgentOrders = [];
|
|
||||||
Dictionary<string, PatternGraphNodeDto> nodesById = new(StringComparer.Ordinal);
|
|
||||||
|
|
||||||
foreach (PatternGraphNodeDto node in graph.Nodes)
|
|
||||||
{
|
|
||||||
if (!nodeIds.Add(node.Id))
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, $"Pattern graph contains duplicate node \"{node.Id}\".");
|
|
||||||
}
|
|
||||||
|
|
||||||
nodesById[node.Id] = node;
|
|
||||||
|
|
||||||
if (Comparer.Equals(node.Kind, "agent"))
|
|
||||||
{
|
|
||||||
if (string.IsNullOrWhiteSpace(node.AgentId) || !agentIds.Contains(node.AgentId))
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, $"Agent node \"{node.Id}\" must reference a known agent.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(node.AgentId) && !seenAgentIds.Add(node.AgentId))
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, $"Pattern graph contains multiple nodes for agent \"{node.AgentId}\".");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!node.Order.HasValue)
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, $"Agent node \"{node.Id}\" must define an order.");
|
|
||||||
}
|
|
||||||
else if (!seenAgentOrders.Add(node.Order.Value))
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, $"Pattern graph contains duplicate agent order \"{node.Order.Value}\".");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
else if (!string.IsNullOrWhiteSpace(node.AgentId))
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, $"System node \"{node.Id}\" cannot reference an agent.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (PatternAgentDefinitionDto agent in pattern.Agents)
|
|
||||||
{
|
|
||||||
if (!seenAgentIds.Contains(agent.Id))
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, $"Pattern graph is missing node metadata for agent \"{agent.Id}\".");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (PatternGraphEdgeDto edge in graph.Edges)
|
|
||||||
{
|
|
||||||
if (!edgeIds.Add(edge.Id))
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, $"Pattern graph contains duplicate edge \"{edge.Id}\".");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!nodesById.ContainsKey(edge.Source) || !nodesById.ContainsKey(edge.Target))
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, $"Pattern graph edge \"{edge.Id}\" must connect known nodes.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (pattern.Mode)
|
|
||||||
{
|
|
||||||
case "single":
|
|
||||||
case "sequential":
|
|
||||||
case "magentic":
|
|
||||||
ValidateLinearGraph(pattern, graph, issues);
|
|
||||||
break;
|
|
||||||
case "concurrent":
|
|
||||||
ValidateConcurrentGraph(pattern, graph, issues);
|
|
||||||
break;
|
|
||||||
case "handoff":
|
|
||||||
ValidateHandoffGraph(graph, issues);
|
|
||||||
break;
|
|
||||||
case "group-chat":
|
|
||||||
ValidateGroupChatGraph(pattern, graph, issues);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ValidateLinearGraph(
|
|
||||||
PatternDefinitionDto pattern,
|
|
||||||
PatternGraphDto graph,
|
|
||||||
List<PatternValidationIssueDto> issues)
|
|
||||||
{
|
|
||||||
ValidateSystemNodeCounts(graph, ["user-input", "user-output"], issues);
|
|
||||||
PatternGraphNodeDto? inputNode = GetNodeByKind(graph, "user-input");
|
|
||||||
PatternGraphNodeDto? outputNode = GetNodeByKind(graph, "user-output");
|
|
||||||
if (inputNode is null || outputNode is null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Dictionary<string, List<PatternGraphEdgeDto>> incoming = BuildIncomingLookup(graph);
|
|
||||||
Dictionary<string, List<PatternGraphEdgeDto>> outgoing = BuildOutgoingLookup(graph);
|
|
||||||
List<PatternGraphNodeDto> agentNodes = GetAgentNodes(graph);
|
|
||||||
|
|
||||||
if (graph.Edges.Count != pattern.Agents.Count + 1)
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, "Linear orchestration graphs must be a single path from user input through every agent to user output.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (incoming.GetValueOrDefault(inputNode.Id, []).Count != 0 || outgoing.GetValueOrDefault(inputNode.Id, []).Count != 1)
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, "User input must start exactly one path.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (incoming.GetValueOrDefault(outputNode.Id, []).Count != 1 || outgoing.GetValueOrDefault(outputNode.Id, []).Count != 0)
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, "User output must terminate exactly one path.");
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (PatternGraphNodeDto node in agentNodes)
|
|
||||||
{
|
|
||||||
if (incoming.GetValueOrDefault(node.Id, []).Count != 1 || outgoing.GetValueOrDefault(node.Id, []).Count != 1)
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, "Each agent in a linear orchestration must have exactly one incoming and one outgoing edge.");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HashSet<string> visited = new(StringComparer.Ordinal);
|
|
||||||
string currentNodeId = inputNode.Id;
|
|
||||||
while (visited.Add(currentNodeId))
|
|
||||||
{
|
|
||||||
List<PatternGraphEdgeDto> nextEdges = outgoing.GetValueOrDefault(currentNodeId, []);
|
|
||||||
if (nextEdges.Count == 0)
|
|
||||||
{
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (nextEdges.Count != 1)
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, "Linear orchestration nodes may only branch to one next step.");
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
currentNodeId = nextEdges[0].Target;
|
|
||||||
if (Comparer.Equals(currentNodeId, outputNode.Id))
|
|
||||||
{
|
|
||||||
visited.Add(currentNodeId);
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
HashSet<string> expectedVisited = new(StringComparer.Ordinal)
|
|
||||||
{
|
|
||||||
inputNode.Id,
|
|
||||||
outputNode.Id
|
|
||||||
};
|
|
||||||
foreach (PatternGraphNodeDto node in agentNodes)
|
|
||||||
{
|
|
||||||
expectedVisited.Add(node.Id);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!expectedVisited.SetEquals(visited))
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, "Linear orchestration graphs must visit every agent exactly once.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ValidateConcurrentGraph(
|
|
||||||
PatternDefinitionDto pattern,
|
|
||||||
PatternGraphDto graph,
|
|
||||||
List<PatternValidationIssueDto> issues)
|
|
||||||
{
|
|
||||||
ValidateSystemNodeCounts(graph, ["user-input", "distributor", "collector", "user-output"], issues);
|
|
||||||
PatternGraphNodeDto? inputNode = GetNodeByKind(graph, "user-input");
|
|
||||||
PatternGraphNodeDto? distributorNode = GetNodeByKind(graph, "distributor");
|
|
||||||
PatternGraphNodeDto? collectorNode = GetNodeByKind(graph, "collector");
|
|
||||||
PatternGraphNodeDto? outputNode = GetNodeByKind(graph, "user-output");
|
|
||||||
if (inputNode is null || distributorNode is null || collectorNode is null || outputNode is null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Dictionary<string, List<PatternGraphEdgeDto>> incoming = BuildIncomingLookup(graph);
|
|
||||||
Dictionary<string, List<PatternGraphEdgeDto>> outgoing = BuildOutgoingLookup(graph);
|
|
||||||
List<PatternGraphNodeDto> agentNodes = GetAgentNodes(graph);
|
|
||||||
HashSet<string> distributorTargets = outgoing.GetValueOrDefault(distributorNode.Id, []).Select(edge => edge.Target).ToHashSet(StringComparer.Ordinal);
|
|
||||||
HashSet<string> collectorSources = incoming.GetValueOrDefault(collectorNode.Id, []).Select(edge => edge.Source).ToHashSet(StringComparer.Ordinal);
|
|
||||||
|
|
||||||
if (graph.Edges.Count != pattern.Agents.Count * 2 + 2)
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, "Concurrent orchestration graphs must fan out from the distributor and fan back into the collector.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (incoming.GetValueOrDefault(inputNode.Id, []).Count != 0 || outgoing.GetValueOrDefault(inputNode.Id, []).Count != 1)
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, "User input must connect only to the distributor.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (incoming.GetValueOrDefault(distributorNode.Id, []).Count != 1)
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, "Distributor must receive exactly one edge from user input.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (outgoing.GetValueOrDefault(collectorNode.Id, []).Count != 1 || incoming.GetValueOrDefault(outputNode.Id, []).Count != 1)
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, "Collector must forward exactly one edge to user output.");
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (PatternGraphNodeDto agentNode in agentNodes)
|
|
||||||
{
|
|
||||||
if (!distributorTargets.Contains(agentNode.Id))
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, $"Distributor must connect to agent \"{agentNode.AgentId}\".");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!collectorSources.Contains(agentNode.Id))
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, $"Agent \"{agentNode.AgentId}\" must connect to the collector.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ValidateHandoffGraph(
|
|
||||||
PatternGraphDto graph,
|
|
||||||
List<PatternValidationIssueDto> issues)
|
|
||||||
{
|
|
||||||
ValidateSystemNodeCounts(graph, ["user-input", "user-output"], issues);
|
|
||||||
PatternGraphNodeDto? inputNode = GetNodeByKind(graph, "user-input");
|
|
||||||
PatternGraphNodeDto? outputNode = GetNodeByKind(graph, "user-output");
|
|
||||||
if (inputNode is null || outputNode is null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Dictionary<string, List<PatternGraphEdgeDto>> incoming = BuildIncomingLookup(graph);
|
|
||||||
Dictionary<string, List<PatternGraphEdgeDto>> outgoing = BuildOutgoingLookup(graph);
|
|
||||||
List<PatternGraphNodeDto> agentNodes = GetAgentNodes(graph);
|
|
||||||
HashSet<string> agentNodeIds = agentNodes.Select(node => node.Id).ToHashSet(StringComparer.Ordinal);
|
|
||||||
List<PatternGraphEdgeDto> entryEdges = outgoing.GetValueOrDefault(inputNode.Id, []);
|
|
||||||
List<PatternGraphEdgeDto> completionEdges = incoming.GetValueOrDefault(outputNode.Id, []);
|
|
||||||
|
|
||||||
if (entryEdges.Count != 1)
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, "Handoff graphs must connect user input to exactly one entry agent.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!agentNodeIds.Contains(entryEdges[0].Target))
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, "Handoff entry edges must target an agent node.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (completionEdges.Count == 0)
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, "Handoff graphs must allow at least one agent to complete back to user output.");
|
|
||||||
}
|
|
||||||
|
|
||||||
bool hasAgentToAgentRoute = false;
|
|
||||||
foreach (PatternGraphEdgeDto edge in graph.Edges)
|
|
||||||
{
|
|
||||||
if (Comparer.Equals(edge.Source, inputNode.Id))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Comparer.Equals(edge.Target, outputNode.Id))
|
|
||||||
{
|
|
||||||
if (!agentNodeIds.Contains(edge.Source))
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, "Only agent nodes may complete to user output.");
|
|
||||||
}
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!agentNodeIds.Contains(edge.Source) || !agentNodeIds.Contains(edge.Target))
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, "Handoff routes may only connect agents to agents or agents to user output.");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Comparer.Equals(edge.Source, edge.Target))
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, "Handoff routes cannot target the same agent node.");
|
|
||||||
}
|
|
||||||
|
|
||||||
hasAgentToAgentRoute = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!hasAgentToAgentRoute && agentNodes.Count > 1)
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, "Handoff graphs must include at least one agent-to-agent handoff route.");
|
|
||||||
}
|
|
||||||
|
|
||||||
HashSet<string> reachable = new(StringComparer.Ordinal);
|
|
||||||
Stack<string> stack = new Stack<string>([entryEdges[0].Target]);
|
|
||||||
while (stack.Count > 0)
|
|
||||||
{
|
|
||||||
string nodeId = stack.Pop();
|
|
||||||
if (!reachable.Add(nodeId))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (PatternGraphEdgeDto edge in outgoing.GetValueOrDefault(nodeId, []))
|
|
||||||
{
|
|
||||||
if (agentNodeIds.Contains(edge.Target) && !reachable.Contains(edge.Target))
|
|
||||||
{
|
|
||||||
stack.Push(edge.Target);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (PatternGraphNodeDto agentNode in agentNodes)
|
|
||||||
{
|
|
||||||
if (!reachable.Contains(agentNode.Id))
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, $"Handoff entry agent must be able to reach \"{agentNode.AgentId}\".");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (incoming.GetValueOrDefault(inputNode.Id, []).Count != 0 || outgoing.GetValueOrDefault(outputNode.Id, []).Count != 0)
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, "User input cannot have incoming edges and user output cannot have outgoing edges.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ValidateGroupChatGraph(
|
|
||||||
PatternDefinitionDto pattern,
|
|
||||||
PatternGraphDto graph,
|
|
||||||
List<PatternValidationIssueDto> issues)
|
|
||||||
{
|
|
||||||
ValidateSystemNodeCounts(graph, ["user-input", "orchestrator", "user-output"], issues);
|
|
||||||
PatternGraphNodeDto? inputNode = GetNodeByKind(graph, "user-input");
|
|
||||||
PatternGraphNodeDto? orchestratorNode = GetNodeByKind(graph, "orchestrator");
|
|
||||||
PatternGraphNodeDto? outputNode = GetNodeByKind(graph, "user-output");
|
|
||||||
if (inputNode is null || orchestratorNode is null || outputNode is null)
|
|
||||||
{
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
Dictionary<string, List<PatternGraphEdgeDto>> incoming = BuildIncomingLookup(graph);
|
|
||||||
Dictionary<string, List<PatternGraphEdgeDto>> outgoing = BuildOutgoingLookup(graph);
|
|
||||||
List<PatternGraphNodeDto> agentNodes = GetAgentNodes(graph);
|
|
||||||
HashSet<string> orchestratorTargets = outgoing.GetValueOrDefault(orchestratorNode.Id, []).Select(edge => edge.Target).ToHashSet(StringComparer.Ordinal);
|
|
||||||
HashSet<string> orchestratorSources = incoming.GetValueOrDefault(orchestratorNode.Id, []).Select(edge => edge.Source).ToHashSet(StringComparer.Ordinal);
|
|
||||||
|
|
||||||
if (graph.Edges.Count != pattern.Agents.Count * 2 + 2)
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, "Group chat graphs must connect the orchestrator to every participant and then back to user output.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (outgoing.GetValueOrDefault(inputNode.Id, []).Any(edge => !Comparer.Equals(edge.Target, orchestratorNode.Id)))
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, "User input must only connect to the orchestrator.");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!outgoing.GetValueOrDefault(orchestratorNode.Id, []).Any(edge => Comparer.Equals(edge.Target, outputNode.Id)))
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, "Group chat orchestrator must connect to user output.");
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (PatternGraphNodeDto agentNode in agentNodes)
|
|
||||||
{
|
|
||||||
if (!orchestratorTargets.Contains(agentNode.Id))
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, $"Orchestrator must connect to agent \"{agentNode.AgentId}\".");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!orchestratorSources.Contains(agentNode.Id))
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, $"Agent \"{agentNode.AgentId}\" must connect back to the orchestrator.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void ValidateSystemNodeCounts(
|
|
||||||
PatternGraphDto graph,
|
|
||||||
IReadOnlyList<string> expectedKinds,
|
|
||||||
List<PatternValidationIssueDto> issues)
|
|
||||||
{
|
|
||||||
Dictionary<string, int> counts = graph.Nodes
|
|
||||||
.GroupBy(node => node.Kind, Comparer)
|
|
||||||
.ToDictionary(group => group.Key, group => group.Count(), Comparer);
|
|
||||||
HashSet<string> expected = expectedKinds.ToHashSet(Comparer);
|
|
||||||
|
|
||||||
foreach (string kind in expectedKinds)
|
|
||||||
{
|
|
||||||
if (counts.GetValueOrDefault(kind, 0) != 1)
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, $"Pattern graph must include exactly one \"{kind}\" node.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach ((string kind, int count) in counts)
|
|
||||||
{
|
|
||||||
if (Comparer.Equals(kind, "agent"))
|
|
||||||
{
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!expected.Contains(kind) && count > 0)
|
|
||||||
{
|
|
||||||
AddGraphIssue(issues, $"Pattern graph does not allow \"{kind}\" nodes in this mode.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static PatternGraphNodeDto? GetNodeByKind(PatternGraphDto graph, string kind)
|
|
||||||
=> graph.Nodes.FirstOrDefault(node => Comparer.Equals(node.Kind, kind));
|
|
||||||
|
|
||||||
private static List<PatternGraphNodeDto> GetAgentNodes(PatternGraphDto graph)
|
|
||||||
=> graph.Nodes.Where(node => Comparer.Equals(node.Kind, "agent")).ToList();
|
|
||||||
|
|
||||||
private static Dictionary<string, List<PatternGraphEdgeDto>> BuildIncomingLookup(PatternGraphDto graph)
|
|
||||||
{
|
|
||||||
Dictionary<string, List<PatternGraphEdgeDto>> incoming = new(StringComparer.Ordinal);
|
|
||||||
foreach (PatternGraphNodeDto node in graph.Nodes)
|
|
||||||
{
|
|
||||||
incoming[node.Id] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (PatternGraphEdgeDto edge in graph.Edges)
|
|
||||||
{
|
|
||||||
if (!incoming.TryGetValue(edge.Target, out List<PatternGraphEdgeDto>? edges))
|
|
||||||
{
|
|
||||||
edges = [];
|
|
||||||
incoming[edge.Target] = edges;
|
|
||||||
}
|
|
||||||
|
|
||||||
edges.Add(edge);
|
|
||||||
}
|
|
||||||
|
|
||||||
return incoming;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static Dictionary<string, List<PatternGraphEdgeDto>> BuildOutgoingLookup(PatternGraphDto graph)
|
|
||||||
{
|
|
||||||
Dictionary<string, List<PatternGraphEdgeDto>> outgoing = new(StringComparer.Ordinal);
|
|
||||||
foreach (PatternGraphNodeDto node in graph.Nodes)
|
|
||||||
{
|
|
||||||
outgoing[node.Id] = [];
|
|
||||||
}
|
|
||||||
|
|
||||||
foreach (PatternGraphEdgeDto edge in graph.Edges)
|
|
||||||
{
|
|
||||||
if (!outgoing.TryGetValue(edge.Source, out List<PatternGraphEdgeDto>? edges))
|
|
||||||
{
|
|
||||||
edges = [];
|
|
||||||
outgoing[edge.Source] = edges;
|
|
||||||
}
|
|
||||||
|
|
||||||
edges.Add(edge);
|
|
||||||
}
|
|
||||||
|
|
||||||
return outgoing;
|
|
||||||
}
|
|
||||||
|
|
||||||
private static void AddGraphIssue(List<PatternValidationIssueDto> issues, string message)
|
|
||||||
=> issues.Add(new PatternValidationIssueDto
|
|
||||||
{
|
|
||||||
Field = "graph",
|
|
||||||
Message = message,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -10,7 +10,6 @@ namespace Aryx.AgentHost.Services;
|
|||||||
public sealed class SidecarProtocolHost
|
public sealed class SidecarProtocolHost
|
||||||
{
|
{
|
||||||
private const string DescribeCapabilitiesCommandType = "describe-capabilities";
|
private const string DescribeCapabilitiesCommandType = "describe-capabilities";
|
||||||
private const string ValidatePatternCommandType = "validate-pattern";
|
|
||||||
private const string ValidateWorkflowCommandType = "validate-workflow";
|
private const string ValidateWorkflowCommandType = "validate-workflow";
|
||||||
private const string RunTurnCommandType = "run-turn";
|
private const string RunTurnCommandType = "run-turn";
|
||||||
private const string CancelTurnCommandType = "cancel-turn";
|
private const string CancelTurnCommandType = "cancel-turn";
|
||||||
@@ -42,7 +41,6 @@ public sealed class SidecarProtocolHost
|
|||||||
];
|
];
|
||||||
|
|
||||||
private readonly Func<CancellationToken, Task<SidecarCapabilitiesDto>> _capabilitiesProvider;
|
private readonly Func<CancellationToken, Task<SidecarCapabilitiesDto>> _capabilitiesProvider;
|
||||||
private readonly PatternValidator _patternValidator;
|
|
||||||
private readonly WorkflowValidator _workflowValidator;
|
private readonly WorkflowValidator _workflowValidator;
|
||||||
private readonly ITurnWorkflowRunner _workflowRunner;
|
private readonly ITurnWorkflowRunner _workflowRunner;
|
||||||
private readonly ICopilotSessionManager _sessionManager;
|
private readonly ICopilotSessionManager _sessionManager;
|
||||||
@@ -55,29 +53,26 @@ public sealed class SidecarProtocolHost
|
|||||||
new(StringComparer.Ordinal);
|
new(StringComparer.Ordinal);
|
||||||
|
|
||||||
public SidecarProtocolHost()
|
public SidecarProtocolHost()
|
||||||
: this(new PatternValidator(), new WorkflowValidator())
|
: this(new WorkflowValidator())
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
public SidecarProtocolHost(
|
public SidecarProtocolHost(
|
||||||
PatternValidator patternValidator,
|
|
||||||
ITurnWorkflowRunner? workflowRunner = null,
|
ITurnWorkflowRunner? workflowRunner = null,
|
||||||
Func<CancellationToken, Task<SidecarCapabilitiesDto>>? capabilitiesProvider = null,
|
Func<CancellationToken, Task<SidecarCapabilitiesDto>>? capabilitiesProvider = null,
|
||||||
ICopilotSessionManager? sessionManager = null)
|
ICopilotSessionManager? sessionManager = null)
|
||||||
: this(patternValidator, new WorkflowValidator(), workflowRunner, capabilitiesProvider, sessionManager)
|
: this(new WorkflowValidator(), workflowRunner, capabilitiesProvider, sessionManager)
|
||||||
{
|
{
|
||||||
}
|
}
|
||||||
|
|
||||||
public SidecarProtocolHost(
|
public SidecarProtocolHost(
|
||||||
PatternValidator patternValidator,
|
|
||||||
WorkflowValidator workflowValidator,
|
WorkflowValidator workflowValidator,
|
||||||
ITurnWorkflowRunner? workflowRunner = null,
|
ITurnWorkflowRunner? workflowRunner = null,
|
||||||
Func<CancellationToken, Task<SidecarCapabilitiesDto>>? capabilitiesProvider = null,
|
Func<CancellationToken, Task<SidecarCapabilitiesDto>>? capabilitiesProvider = null,
|
||||||
ICopilotSessionManager? sessionManager = null)
|
ICopilotSessionManager? sessionManager = null)
|
||||||
{
|
{
|
||||||
_patternValidator = patternValidator;
|
|
||||||
_workflowValidator = workflowValidator;
|
_workflowValidator = workflowValidator;
|
||||||
_workflowRunner = workflowRunner ?? new CopilotWorkflowRunner(_patternValidator, _workflowValidator);
|
_workflowRunner = workflowRunner ?? new CopilotWorkflowRunner(_workflowValidator);
|
||||||
_capabilitiesProvider = capabilitiesProvider ?? BuildCapabilitiesAsync;
|
_capabilitiesProvider = capabilitiesProvider ?? BuildCapabilitiesAsync;
|
||||||
_sessionManager = sessionManager ?? new CopilotSessionManager();
|
_sessionManager = sessionManager ?? new CopilotSessionManager();
|
||||||
_jsonOptions = JsonSerialization.CreateWebOptions();
|
_jsonOptions = JsonSerialization.CreateWebOptions();
|
||||||
@@ -86,7 +81,6 @@ public sealed class SidecarProtocolHost
|
|||||||
_commandHandlers = new Dictionary<string, Func<CommandContext, Task>>(StringComparer.Ordinal)
|
_commandHandlers = new Dictionary<string, Func<CommandContext, Task>>(StringComparer.Ordinal)
|
||||||
{
|
{
|
||||||
[DescribeCapabilitiesCommandType] = HandleDescribeCapabilitiesAsync,
|
[DescribeCapabilitiesCommandType] = HandleDescribeCapabilitiesAsync,
|
||||||
[ValidatePatternCommandType] = HandleValidatePatternAsync,
|
|
||||||
[ValidateWorkflowCommandType] = HandleValidateWorkflowAsync,
|
[ValidateWorkflowCommandType] = HandleValidateWorkflowAsync,
|
||||||
[RunTurnCommandType] = HandleRunTurnAsync,
|
[RunTurnCommandType] = HandleRunTurnAsync,
|
||||||
[CancelTurnCommandType] = HandleCancelTurnAsync,
|
[CancelTurnCommandType] = HandleCancelTurnAsync,
|
||||||
@@ -182,18 +176,6 @@ public sealed class SidecarProtocolHost
|
|||||||
}, context.CancellationToken).ConfigureAwait(false);
|
}, context.CancellationToken).ConfigureAwait(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task HandleValidatePatternAsync(CommandContext context)
|
|
||||||
{
|
|
||||||
ValidatePatternCommandDto command = DeserializeCommand<ValidatePatternCommandDto>(context);
|
|
||||||
|
|
||||||
await WriteAsync(context.Output, new PatternValidationEventDto
|
|
||||||
{
|
|
||||||
Type = "pattern-validation",
|
|
||||||
RequestId = context.Envelope.RequestId,
|
|
||||||
Issues = _patternValidator.Validate(command.Pattern),
|
|
||||||
}, context.CancellationToken).ConfigureAwait(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task HandleValidateWorkflowAsync(CommandContext context)
|
private async Task HandleValidateWorkflowAsync(CommandContext context)
|
||||||
{
|
{
|
||||||
ValidateWorkflowCommandDto command = DeserializeCommand<ValidateWorkflowCommandDto>(context);
|
ValidateWorkflowCommandDto command = DeserializeCommand<ValidateWorkflowCommandDto>(context);
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
using Aryx.AgentHost.Contracts;
|
||||||
|
|
||||||
|
namespace Aryx.AgentHost.Services;
|
||||||
|
|
||||||
|
internal static class WorkflowDefinitionExtensions
|
||||||
|
{
|
||||||
|
public static IReadOnlyList<WorkflowNodeDto> GetAgentNodes(this WorkflowDefinitionDto workflow)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(workflow);
|
||||||
|
|
||||||
|
return workflow.Graph.Nodes
|
||||||
|
.Where(IsAgentNode)
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsAgentNode(this WorkflowNodeDto node)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(node);
|
||||||
|
return string.Equals(node.Kind, "agent", StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetAgentId(this WorkflowNodeDto node)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(node);
|
||||||
|
return !string.IsNullOrWhiteSpace(node.Config.Id) ? node.Config.Id : node.Id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static string GetAgentName(this WorkflowNodeDto node)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(node);
|
||||||
|
return FirstNonBlank(node.Config.Name, node.Label, node.Id) ?? "agent";
|
||||||
|
}
|
||||||
|
|
||||||
|
public static bool IsOrchestrationMode(this WorkflowDefinitionDto workflow, string mode)
|
||||||
|
{
|
||||||
|
ArgumentNullException.ThrowIfNull(workflow);
|
||||||
|
ArgumentException.ThrowIfNullOrWhiteSpace(mode);
|
||||||
|
|
||||||
|
return string.Equals(workflow.Settings.OrchestrationMode, mode, StringComparison.OrdinalIgnoreCase);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string? FirstNonBlank(params string?[] values)
|
||||||
|
{
|
||||||
|
foreach (string? value in values)
|
||||||
|
{
|
||||||
|
if (!string.IsNullOrWhiteSpace(value))
|
||||||
|
{
|
||||||
|
return value.Trim();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@ internal static class WorkflowRequestInfoInterpreter
|
|||||||
AgentIdentity? activeAgent,
|
AgentIdentity? activeAgent,
|
||||||
ConcurrentDictionary<string, string> toolNamesByCallId)
|
ConcurrentDictionary<string, string> toolNamesByCallId)
|
||||||
{
|
{
|
||||||
RequestInterpretation interpretation = InterpretRequest(command.Pattern, requestInfo);
|
RequestInterpretation interpretation = InterpretRequest(command.Workflow, requestInfo);
|
||||||
return interpretation switch
|
return interpretation switch
|
||||||
{
|
{
|
||||||
HandoffRequestInterpretation handoff =>
|
HandoffRequestInterpretation handoff =>
|
||||||
@@ -35,8 +35,8 @@ internal static class WorkflowRequestInfoInterpreter
|
|||||||
RunTurnCommandDto command,
|
RunTurnCommandDto command,
|
||||||
RequestInfoEvent requestInfo)
|
RequestInfoEvent requestInfo)
|
||||||
{
|
{
|
||||||
return string.Equals(command.Pattern.Mode, "handoff", StringComparison.OrdinalIgnoreCase)
|
return command.Workflow.IsOrchestrationMode("handoff")
|
||||||
&& InterpretRequest(command.Pattern, requestInfo) is UnknownRequestInterpretation;
|
&& InterpretRequest(command.Workflow, requestInfo) is UnknownRequestInterpretation;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static AgentActivityEventDto CreateHandoffActivity(
|
private static AgentActivityEventDto CreateHandoffActivity(
|
||||||
@@ -95,10 +95,10 @@ internal static class WorkflowRequestInfoInterpreter
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static RequestInterpretation InterpretRequest(
|
private static RequestInterpretation InterpretRequest(
|
||||||
PatternDefinitionDto pattern,
|
WorkflowDefinitionDto workflow,
|
||||||
RequestInfoEvent requestInfo)
|
RequestInfoEvent requestInfo)
|
||||||
{
|
{
|
||||||
if (TryGetHandoffTarget(pattern, requestInfo, out AgentIdentity handoffAgent))
|
if (TryGetHandoffTarget(workflow, requestInfo, out AgentIdentity handoffAgent))
|
||||||
{
|
{
|
||||||
return new HandoffRequestInterpretation(handoffAgent);
|
return new HandoffRequestInterpretation(handoffAgent);
|
||||||
}
|
}
|
||||||
@@ -109,7 +109,7 @@ internal static class WorkflowRequestInfoInterpreter
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static bool TryGetHandoffTarget(
|
private static bool TryGetHandoffTarget(
|
||||||
PatternDefinitionDto pattern,
|
WorkflowDefinitionDto workflow,
|
||||||
RequestInfoEvent requestInfo,
|
RequestInfoEvent requestInfo,
|
||||||
out AgentIdentity agent)
|
out AgentIdentity agent)
|
||||||
{
|
{
|
||||||
@@ -128,7 +128,7 @@ internal static class WorkflowRequestInfoInterpreter
|
|||||||
}
|
}
|
||||||
|
|
||||||
agent = AgentIdentityResolver.ResolveAgentIdentity(
|
agent = AgentIdentityResolver.ResolveAgentIdentity(
|
||||||
pattern,
|
workflow,
|
||||||
target.Id,
|
target.Id,
|
||||||
target.Name);
|
target.Name);
|
||||||
return !string.IsNullOrWhiteSpace(agent.AgentName);
|
return !string.IsNullOrWhiteSpace(agent.AgentName);
|
||||||
|
|||||||
@@ -8,12 +8,10 @@ internal sealed class WorkflowRunner
|
|||||||
{
|
{
|
||||||
public Workflow BuildWorkflow(
|
public Workflow BuildWorkflow(
|
||||||
WorkflowDefinitionDto workflowDefinition,
|
WorkflowDefinitionDto workflowDefinition,
|
||||||
PatternDefinitionDto patternDefinition,
|
|
||||||
IReadOnlyList<AIAgent> agents,
|
IReadOnlyList<AIAgent> agents,
|
||||||
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null)
|
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null)
|
||||||
{
|
{
|
||||||
ArgumentNullException.ThrowIfNull(workflowDefinition);
|
ArgumentNullException.ThrowIfNull(workflowDefinition);
|
||||||
ArgumentNullException.ThrowIfNull(patternDefinition);
|
|
||||||
ArgumentNullException.ThrowIfNull(agents);
|
ArgumentNullException.ThrowIfNull(agents);
|
||||||
|
|
||||||
Dictionary<string, WorkflowDefinitionDto> workflowLibraryMap = workflowLibrary?
|
Dictionary<string, WorkflowDefinitionDto> workflowLibraryMap = workflowLibrary?
|
||||||
@@ -22,9 +20,10 @@ internal sealed class WorkflowRunner
|
|||||||
.ToDictionary(group => group.Key, group => group.Last(), StringComparer.Ordinal)
|
.ToDictionary(group => group.Key, group => group.Last(), StringComparer.Ordinal)
|
||||||
?? new Dictionary<string, WorkflowDefinitionDto>(StringComparer.Ordinal);
|
?? new Dictionary<string, WorkflowDefinitionDto>(StringComparer.Ordinal);
|
||||||
|
|
||||||
Dictionary<string, AIAgent> agentMap = patternDefinition.Agents
|
List<string> agentIds = ResolveAgentIds(workflowDefinition, workflowLibraryMap);
|
||||||
.Zip(agents, (definition, agent) => (definition.Id, agent))
|
Dictionary<string, AIAgent> agentMap = agentIds
|
||||||
.ToDictionary(pair => pair.Id, pair => pair.agent, StringComparer.Ordinal);
|
.Zip(agents, (agentId, agent) => (agentId, agent))
|
||||||
|
.ToDictionary(pair => pair.agentId, pair => pair.agent, StringComparer.Ordinal);
|
||||||
|
|
||||||
return BuildWorkflow(workflowDefinition, agentMap, workflowLibraryMap);
|
return BuildWorkflow(workflowDefinition, agentMap, workflowLibraryMap);
|
||||||
}
|
}
|
||||||
@@ -222,6 +221,47 @@ internal sealed class WorkflowRunner
|
|||||||
private static string? NormalizeOptionalString(string? value)
|
private static string? NormalizeOptionalString(string? value)
|
||||||
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
|
||||||
|
|
||||||
|
private static List<string> ResolveAgentIds(
|
||||||
|
WorkflowDefinitionDto workflowDefinition,
|
||||||
|
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary)
|
||||||
|
{
|
||||||
|
List<string> agentIds = [];
|
||||||
|
CollectAgentIds(workflowDefinition, workflowLibrary, agentIds, new HashSet<string>(StringComparer.Ordinal));
|
||||||
|
return agentIds;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void CollectAgentIds(
|
||||||
|
WorkflowDefinitionDto workflowDefinition,
|
||||||
|
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
|
||||||
|
List<string> agentIds,
|
||||||
|
ISet<string> visitedWorkflowIds)
|
||||||
|
{
|
||||||
|
string workflowKey = string.IsNullOrWhiteSpace(workflowDefinition.Id)
|
||||||
|
? Guid.NewGuid().ToString("N")
|
||||||
|
: workflowDefinition.Id;
|
||||||
|
if (!visitedWorkflowIds.Add(workflowKey))
|
||||||
|
{
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (WorkflowNodeDto node in workflowDefinition.Graph.Nodes)
|
||||||
|
{
|
||||||
|
if (string.Equals(node.Kind, "agent", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
agentIds.Add(!string.IsNullOrWhiteSpace(node.Config.Id) ? node.Config.Id : node.Id);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!string.Equals(node.Kind, "sub-workflow", StringComparison.OrdinalIgnoreCase))
|
||||||
|
{
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
WorkflowDefinitionDto subWorkflow = ResolveSubWorkflowDefinition(node, workflowLibrary);
|
||||||
|
CollectAgentIds(subWorkflow, workflowLibrary, agentIds, visitedWorkflowIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private sealed record WorkflowNodeRoute(
|
private sealed record WorkflowNodeRoute(
|
||||||
ExecutorBinding Entry,
|
ExecutorBinding Entry,
|
||||||
ExecutorBinding Exit,
|
ExecutorBinding Exit,
|
||||||
|
|||||||
@@ -73,7 +73,7 @@ internal static class WorkflowTranscriptProjector
|
|||||||
List<ChatMessageDto> projectedMessages = [];
|
List<ChatMessageDto> projectedMessages = [];
|
||||||
int fallbackOutputIndex = 0;
|
int fallbackOutputIndex = 0;
|
||||||
string createdAt = DateTimeOffset.UtcNow.ToString("O");
|
string createdAt = DateTimeOffset.UtcNow.ToString("O");
|
||||||
List<TranscriptSegment> preparedSegments = PrepareSegmentsForProjection(command.Pattern, segments);
|
List<TranscriptSegment> preparedSegments = PrepareSegmentsForProjection(command.Workflow, segments);
|
||||||
List<TranscriptSegment> remainingSegments = preparedSegments.ToList();
|
List<TranscriptSegment> remainingSegments = preparedSegments.ToList();
|
||||||
List<ChatMessage> assistantMessages = newMessages.Where(message => message.Role != ChatRole.User).ToList();
|
List<ChatMessage> assistantMessages = newMessages.Where(message => message.Role != ChatRole.User).ToList();
|
||||||
|
|
||||||
@@ -84,7 +84,7 @@ internal static class WorkflowTranscriptProjector
|
|||||||
message,
|
message,
|
||||||
remainingSegments,
|
remainingSegments,
|
||||||
assistantMessages.Count - messageIndex,
|
assistantMessages.Count - messageIndex,
|
||||||
command.Pattern,
|
command.Workflow,
|
||||||
fallbackAgent);
|
fallbackAgent);
|
||||||
string content = ResolveProjectedContent(message, matchedSegment);
|
string content = ResolveProjectedContent(message, matchedSegment);
|
||||||
if (string.IsNullOrWhiteSpace(content))
|
if (string.IsNullOrWhiteSpace(content))
|
||||||
@@ -133,7 +133,7 @@ internal static class WorkflowTranscriptProjector
|
|||||||
?? $"{command.RequestId}-final-{fallbackOutputIndex}",
|
?? $"{command.RequestId}-final-{fallbackOutputIndex}",
|
||||||
Role = message.Role == ChatRole.System ? "system" : "assistant",
|
Role = message.Role == ChatRole.System ? "system" : "assistant",
|
||||||
AuthorName = ResolveProjectedAuthorName(
|
AuthorName = ResolveProjectedAuthorName(
|
||||||
command.Pattern,
|
command.Workflow,
|
||||||
message.AuthorName,
|
message.AuthorName,
|
||||||
matchedSegment?.AuthorName,
|
matchedSegment?.AuthorName,
|
||||||
fallbackAgent),
|
fallbackAgent),
|
||||||
@@ -180,17 +180,17 @@ internal static class WorkflowTranscriptProjector
|
|||||||
{
|
{
|
||||||
Id = segment.MessageId,
|
Id = segment.MessageId,
|
||||||
Role = "assistant",
|
Role = "assistant",
|
||||||
AuthorName = AgentIdentityResolver.ResolveDisplayAuthorName(command.Pattern, segment.AuthorName),
|
AuthorName = AgentIdentityResolver.ResolveDisplayAuthorName(command.Workflow, segment.AuthorName),
|
||||||
Content = segment.Content,
|
Content = segment.Content,
|
||||||
CreatedAt = createdAt,
|
CreatedAt = createdAt,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static List<TranscriptSegment> PrepareSegmentsForProjection(
|
private static List<TranscriptSegment> PrepareSegmentsForProjection(
|
||||||
PatternDefinitionDto pattern,
|
WorkflowDefinitionDto workflow,
|
||||||
IReadOnlyList<TranscriptSegment> segments)
|
IReadOnlyList<TranscriptSegment> segments)
|
||||||
{
|
{
|
||||||
if (!string.Equals(pattern.Mode, "concurrent", StringComparison.Ordinal)
|
if (!workflow.IsOrchestrationMode("concurrent")
|
||||||
|| segments.Count <= 1)
|
|| segments.Count <= 1)
|
||||||
{
|
{
|
||||||
return segments.ToList();
|
return segments.ToList();
|
||||||
@@ -205,7 +205,7 @@ internal static class WorkflowTranscriptProjector
|
|||||||
for (int index = 0; index < segments.Count; index++)
|
for (int index = 0; index < segments.Count; index++)
|
||||||
{
|
{
|
||||||
TranscriptSegment segment = segments[index];
|
TranscriptSegment segment = segments[index];
|
||||||
string authorKey = AgentIdentityResolver.ResolveDisplayAuthorName(pattern, segment.AuthorName);
|
string authorKey = AgentIdentityResolver.ResolveDisplayAuthorName(workflow, segment.AuthorName);
|
||||||
latestSegmentByAuthor[authorKey] = (segment, index);
|
latestSegmentByAuthor[authorKey] = (segment, index);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -219,7 +219,7 @@ internal static class WorkflowTranscriptProjector
|
|||||||
ChatMessage message,
|
ChatMessage message,
|
||||||
IReadOnlyList<TranscriptSegment> remainingSegments,
|
IReadOnlyList<TranscriptSegment> remainingSegments,
|
||||||
int remainingMessageCount,
|
int remainingMessageCount,
|
||||||
PatternDefinitionDto pattern,
|
WorkflowDefinitionDto workflow,
|
||||||
AgentIdentity? fallbackAgent)
|
AgentIdentity? fallbackAgent)
|
||||||
{
|
{
|
||||||
if (remainingSegments.Count == 0)
|
if (remainingSegments.Count == 0)
|
||||||
@@ -231,7 +231,7 @@ internal static class WorkflowTranscriptProjector
|
|||||||
if (messageText is not null)
|
if (messageText is not null)
|
||||||
{
|
{
|
||||||
string resolvedAuthorName = ResolveProjectedAuthorName(
|
string resolvedAuthorName = ResolveProjectedAuthorName(
|
||||||
pattern,
|
workflow,
|
||||||
message.AuthorName,
|
message.AuthorName,
|
||||||
fallbackIdentifier: null,
|
fallbackIdentifier: null,
|
||||||
fallbackAgent);
|
fallbackAgent);
|
||||||
@@ -240,7 +240,7 @@ internal static class WorkflowTranscriptProjector
|
|||||||
remainingSegments,
|
remainingSegments,
|
||||||
segment => string.Equals(segment.Content, messageText, StringComparison.Ordinal)
|
segment => string.Equals(segment.Content, messageText, StringComparison.Ordinal)
|
||||||
&& string.Equals(
|
&& string.Equals(
|
||||||
AgentIdentityResolver.ResolveDisplayAuthorName(pattern, segment.AuthorName),
|
AgentIdentityResolver.ResolveDisplayAuthorName(workflow, segment.AuthorName),
|
||||||
resolvedAuthorName,
|
resolvedAuthorName,
|
||||||
StringComparison.Ordinal),
|
StringComparison.Ordinal),
|
||||||
out TranscriptSegment authorMatchedSegment))
|
out TranscriptSegment authorMatchedSegment))
|
||||||
@@ -264,7 +264,7 @@ internal static class WorkflowTranscriptProjector
|
|||||||
&& TryFindLastSegment(
|
&& TryFindLastSegment(
|
||||||
remainingSegments,
|
remainingSegments,
|
||||||
segment => string.Equals(
|
segment => string.Equals(
|
||||||
AgentIdentityResolver.ResolveDisplayAuthorName(pattern, segment.AuthorName),
|
AgentIdentityResolver.ResolveDisplayAuthorName(workflow, segment.AuthorName),
|
||||||
fallbackAgent.Value.AgentName,
|
fallbackAgent.Value.AgentName,
|
||||||
StringComparison.Ordinal),
|
StringComparison.Ordinal),
|
||||||
out TranscriptSegment fallbackMatchedSegment))
|
out TranscriptSegment fallbackMatchedSegment))
|
||||||
@@ -382,7 +382,7 @@ internal static class WorkflowTranscriptProjector
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static string ResolveProjectedAuthorName(
|
private static string ResolveProjectedAuthorName(
|
||||||
PatternDefinitionDto pattern,
|
WorkflowDefinitionDto workflow,
|
||||||
string? primaryIdentifier,
|
string? primaryIdentifier,
|
||||||
string? fallbackIdentifier,
|
string? fallbackIdentifier,
|
||||||
AgentIdentity? fallbackAgent)
|
AgentIdentity? fallbackAgent)
|
||||||
@@ -399,16 +399,17 @@ internal static class WorkflowTranscriptProjector
|
|||||||
return fallbackAgent.Value.AgentName;
|
return fallbackAgent.Value.AgentName;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pattern.Agents.Count == 1
|
IReadOnlyList<WorkflowNodeDto> agentNodes = workflow.GetAgentNodes();
|
||||||
|
if (agentNodes.Count == 1
|
||||||
&& string.IsNullOrWhiteSpace(primaryIdentifier)
|
&& string.IsNullOrWhiteSpace(primaryIdentifier)
|
||||||
&& string.IsNullOrWhiteSpace(fallbackIdentifier))
|
&& string.IsNullOrWhiteSpace(fallbackIdentifier))
|
||||||
{
|
{
|
||||||
PatternAgentDefinitionDto singleAgent = pattern.Agents[0];
|
WorkflowNodeDto singleAgent = agentNodes[0];
|
||||||
return AgentIdentityResolver.ResolveDisplayAuthorName(pattern, singleAgent.Id, singleAgent.Name);
|
return AgentIdentityResolver.ResolveDisplayAuthorName(workflow, singleAgent.GetAgentId(), singleAgent.GetAgentName());
|
||||||
}
|
}
|
||||||
|
|
||||||
return AgentIdentityResolver.ResolveDisplayAuthorName(
|
return AgentIdentityResolver.ResolveDisplayAuthorName(
|
||||||
pattern,
|
workflow,
|
||||||
primaryIdentifier,
|
primaryIdentifier,
|
||||||
fallbackIdentifier);
|
fallbackIdentifier);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,14 +8,14 @@ public sealed class AgentIdentityResolverTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void TryResolveKnownAgentIdentity_MatchesRuntimeExecutorIdentifier()
|
public void TryResolveKnownAgentIdentity_MatchesRuntimeExecutorIdentifier()
|
||||||
{
|
{
|
||||||
PatternDefinitionDto pattern = CreatePattern(
|
WorkflowDefinitionDto workflow = CreateWorkflow(
|
||||||
[
|
[
|
||||||
CreateAgent(id: "agent-concurrent-architect", name: "Architect"),
|
CreateAgent("agent-concurrent-architect", "Architect"),
|
||||||
CreateAgent(id: "agent-concurrent-product", name: "Product"),
|
CreateAgent("agent-concurrent-product", "Product"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity(
|
bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity(
|
||||||
pattern,
|
workflow,
|
||||||
"Architect_agent_concurrent_architect",
|
"Architect_agent_concurrent_architect",
|
||||||
out AgentIdentity agent);
|
out AgentIdentity agent);
|
||||||
|
|
||||||
@@ -27,14 +27,14 @@ public sealed class AgentIdentityResolverTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void TryResolveKnownAgentIdentity_MatchesSanitizedNameAndId()
|
public void TryResolveKnownAgentIdentity_MatchesSanitizedNameAndId()
|
||||||
{
|
{
|
||||||
PatternDefinitionDto pattern = CreatePattern(
|
WorkflowDefinitionDto workflow = CreateWorkflow(
|
||||||
[
|
[
|
||||||
CreateAgent(id: "agent-single-primary", name: "Primary Agent"),
|
CreateAgent("agent-single-primary", "Primary Agent"),
|
||||||
],
|
],
|
||||||
mode: "single");
|
orchestrationMode: "single");
|
||||||
|
|
||||||
bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity(
|
bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity(
|
||||||
pattern,
|
workflow,
|
||||||
"Primary_Agent_agent_single_primary",
|
"Primary_Agent_agent_single_primary",
|
||||||
out AgentIdentity agent);
|
out AgentIdentity agent);
|
||||||
|
|
||||||
@@ -46,14 +46,14 @@ public sealed class AgentIdentityResolverTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void TryResolveKnownAgentIdentity_MapsAssistantToSingleAgent()
|
public void TryResolveKnownAgentIdentity_MapsAssistantToSingleAgent()
|
||||||
{
|
{
|
||||||
PatternDefinitionDto pattern = CreatePattern(
|
WorkflowDefinitionDto workflow = CreateWorkflow(
|
||||||
[
|
[
|
||||||
CreateAgent(id: "agent-single-primary", name: "Primary Agent"),
|
CreateAgent("agent-single-primary", "Primary Agent"),
|
||||||
],
|
],
|
||||||
mode: "single");
|
orchestrationMode: "single");
|
||||||
|
|
||||||
bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity(
|
bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity(
|
||||||
pattern,
|
workflow,
|
||||||
"assistant",
|
"assistant",
|
||||||
out AgentIdentity agent);
|
out AgentIdentity agent);
|
||||||
|
|
||||||
@@ -63,16 +63,16 @@ public sealed class AgentIdentityResolverTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public void TryResolveKnownAgentIdentity_DoesNotGuessAssistantForMultiAgentPattern()
|
public void TryResolveKnownAgentIdentity_DoesNotGuessAssistantForMultiAgentWorkflow()
|
||||||
{
|
{
|
||||||
PatternDefinitionDto pattern = CreatePattern(
|
WorkflowDefinitionDto workflow = CreateWorkflow(
|
||||||
[
|
[
|
||||||
CreateAgent(id: "agent-concurrent-architect", name: "Architect"),
|
CreateAgent("agent-concurrent-architect", "Architect"),
|
||||||
CreateAgent(id: "agent-concurrent-product", name: "Product"),
|
CreateAgent("agent-concurrent-product", "Product"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity(
|
bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity(
|
||||||
pattern,
|
workflow,
|
||||||
"assistant",
|
"assistant",
|
||||||
out _);
|
out _);
|
||||||
|
|
||||||
@@ -82,14 +82,14 @@ public sealed class AgentIdentityResolverTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ResolveDisplayAuthorName_UsesCanonicalAgentName()
|
public void ResolveDisplayAuthorName_UsesCanonicalAgentName()
|
||||||
{
|
{
|
||||||
PatternDefinitionDto pattern = CreatePattern(
|
WorkflowDefinitionDto workflow = CreateWorkflow(
|
||||||
[
|
[
|
||||||
CreateAgent(id: "agent-concurrent-implementer", name: "Implementer"),
|
CreateAgent("agent-concurrent-implementer", "Implementer"),
|
||||||
],
|
],
|
||||||
mode: "single");
|
orchestrationMode: "single");
|
||||||
|
|
||||||
string authorName = AgentIdentityResolver.ResolveDisplayAuthorName(
|
string authorName = AgentIdentityResolver.ResolveDisplayAuthorName(
|
||||||
pattern,
|
workflow,
|
||||||
"Implementer_agent_concurrent_implementer");
|
"Implementer_agent_concurrent_implementer");
|
||||||
|
|
||||||
Assert.Equal("Implementer", authorName);
|
Assert.Equal("Implementer", authorName);
|
||||||
@@ -98,14 +98,14 @@ public sealed class AgentIdentityResolverTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void TryResolveObservedAgentIdentity_UsesFallbackAgentForGenericAssistant()
|
public void TryResolveObservedAgentIdentity_UsesFallbackAgentForGenericAssistant()
|
||||||
{
|
{
|
||||||
PatternDefinitionDto pattern = CreatePattern(
|
WorkflowDefinitionDto workflow = CreateWorkflow(
|
||||||
[
|
[
|
||||||
CreateAgent(id: "agent-handoff-ux", name: "UX Specialist"),
|
CreateAgent("agent-handoff-ux", "UX Specialist"),
|
||||||
CreateAgent(id: "agent-handoff-runtime", name: "Runtime Specialist"),
|
CreateAgent("agent-handoff-runtime", "Runtime Specialist"),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
bool resolved = AgentIdentityResolver.TryResolveObservedAgentIdentity(
|
bool resolved = AgentIdentityResolver.TryResolveObservedAgentIdentity(
|
||||||
pattern,
|
workflow,
|
||||||
"assistant",
|
"assistant",
|
||||||
new AgentIdentity("agent-handoff-ux", "UX Specialist"),
|
new AgentIdentity("agent-handoff-ux", "UX Specialist"),
|
||||||
out AgentIdentity agent);
|
out AgentIdentity agent);
|
||||||
@@ -115,28 +115,43 @@ public sealed class AgentIdentityResolverTests
|
|||||||
Assert.Equal("UX Specialist", agent.AgentName);
|
Assert.Equal("UX Specialist", agent.AgentName);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PatternDefinitionDto CreatePattern(
|
private static WorkflowDefinitionDto CreateWorkflow(
|
||||||
IReadOnlyList<PatternAgentDefinitionDto> agents,
|
IReadOnlyList<WorkflowNodeDto> agents,
|
||||||
string mode = "concurrent")
|
string orchestrationMode = "concurrent")
|
||||||
{
|
{
|
||||||
return new PatternDefinitionDto
|
return new WorkflowDefinitionDto
|
||||||
{
|
{
|
||||||
Id = $"{mode}-pattern",
|
Id = $"{orchestrationMode}-workflow",
|
||||||
Name = "Pattern",
|
Name = "Workflow",
|
||||||
Mode = mode,
|
Graph = new WorkflowGraphDto
|
||||||
Availability = "available",
|
{
|
||||||
Agents = agents,
|
Nodes =
|
||||||
|
[
|
||||||
|
.. agents,
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Settings = new WorkflowSettingsDto
|
||||||
|
{
|
||||||
|
OrchestrationMode = orchestrationMode,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PatternAgentDefinitionDto CreateAgent(string id, string name)
|
private static WorkflowNodeDto CreateAgent(string id, string name)
|
||||||
{
|
{
|
||||||
return new PatternAgentDefinitionDto
|
return new WorkflowNodeDto
|
||||||
{
|
{
|
||||||
Id = id,
|
Id = id,
|
||||||
Name = name,
|
Kind = "agent",
|
||||||
Model = "gpt-5.4",
|
Label = name,
|
||||||
Instructions = "Help with the request.",
|
Config = new WorkflowNodeConfigDto
|
||||||
|
{
|
||||||
|
Kind = "agent",
|
||||||
|
Id = id,
|
||||||
|
Name = name,
|
||||||
|
Model = "gpt-5.4",
|
||||||
|
Instructions = "Help with the request.",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,19 +8,10 @@ public sealed class AgentInstructionComposerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Compose_LeavesNonHandoffInstructionsUnchanged()
|
public void Compose_LeavesNonHandoffInstructionsUnchanged()
|
||||||
{
|
{
|
||||||
PatternDefinitionDto pattern = new()
|
WorkflowDefinitionDto workflow = CreateWorkflow("sequential");
|
||||||
{
|
WorkflowNodeDto agent = CreateAgent("agent-reviewer", "Reviewer", "Review the proposal.");
|
||||||
Id = "pattern-sequential",
|
|
||||||
Name = "Sequential",
|
|
||||||
Mode = "sequential",
|
|
||||||
Availability = "available",
|
|
||||||
};
|
|
||||||
PatternAgentDefinitionDto agent = CreateAgent(
|
|
||||||
id: "agent-reviewer",
|
|
||||||
name: "Reviewer",
|
|
||||||
instructions: "Review the proposal.");
|
|
||||||
|
|
||||||
string instructions = AgentInstructionComposer.Compose(pattern, agent, agentIndex: 0);
|
string instructions = AgentInstructionComposer.Compose(workflow, agent, agentIndex: 0);
|
||||||
|
|
||||||
Assert.Equal("Review the proposal.", instructions);
|
Assert.Equal("Review the proposal.", instructions);
|
||||||
}
|
}
|
||||||
@@ -28,24 +19,12 @@ public sealed class AgentInstructionComposerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Compose_StrengthensGroupChatCollaborationRoles()
|
public void Compose_StrengthensGroupChatCollaborationRoles()
|
||||||
{
|
{
|
||||||
PatternDefinitionDto pattern = new()
|
WorkflowDefinitionDto workflow = CreateWorkflow("group-chat");
|
||||||
{
|
WorkflowNodeDto writer = CreateAgent("agent-group-writer", "Writer", "Draft an answer.");
|
||||||
Id = "pattern-group-chat",
|
WorkflowNodeDto reviewer = CreateAgent("agent-group-reviewer", "Reviewer", "Review the draft.");
|
||||||
Name = "Group Chat",
|
|
||||||
Mode = "group-chat",
|
|
||||||
Availability = "available",
|
|
||||||
};
|
|
||||||
PatternAgentDefinitionDto writer = CreateAgent(
|
|
||||||
id: "agent-group-writer",
|
|
||||||
name: "Writer",
|
|
||||||
instructions: "Draft an answer.");
|
|
||||||
PatternAgentDefinitionDto reviewer = CreateAgent(
|
|
||||||
id: "agent-group-reviewer",
|
|
||||||
name: "Reviewer",
|
|
||||||
instructions: "Review the draft.");
|
|
||||||
|
|
||||||
string writerInstructions = AgentInstructionComposer.Compose(pattern, writer, agentIndex: 0);
|
string writerInstructions = AgentInstructionComposer.Compose(workflow, writer, agentIndex: 0);
|
||||||
string reviewerInstructions = AgentInstructionComposer.Compose(pattern, reviewer, agentIndex: 1);
|
string reviewerInstructions = AgentInstructionComposer.Compose(workflow, reviewer, agentIndex: 1);
|
||||||
|
|
||||||
Assert.Contains("collaborative multi-turn group chat", writerInstructions, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("collaborative multi-turn group chat", writerInstructions, StringComparison.OrdinalIgnoreCase);
|
||||||
Assert.Contains("refine your earlier draft", writerInstructions, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("refine your earlier draft", writerInstructions, StringComparison.OrdinalIgnoreCase);
|
||||||
@@ -56,19 +35,13 @@ public sealed class AgentInstructionComposerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Compose_LeavesHandoffTriagePromptFocusedOnAgentInstructions()
|
public void Compose_LeavesHandoffTriagePromptFocusedOnAgentInstructions()
|
||||||
{
|
{
|
||||||
PatternDefinitionDto pattern = new()
|
WorkflowDefinitionDto workflow = CreateWorkflow("handoff");
|
||||||
{
|
WorkflowNodeDto triage = CreateAgent(
|
||||||
Id = "pattern-handoff",
|
"agent-handoff-triage",
|
||||||
Name = "Handoff",
|
"Triage",
|
||||||
Mode = "handoff",
|
"You triage requests and must hand them off to the most appropriate specialist.");
|
||||||
Availability = "available",
|
|
||||||
};
|
|
||||||
PatternAgentDefinitionDto triage = CreateAgent(
|
|
||||||
id: "agent-handoff-triage",
|
|
||||||
name: "Triage",
|
|
||||||
instructions: "You triage requests and must hand them off to the most appropriate specialist.");
|
|
||||||
|
|
||||||
string instructions = AgentInstructionComposer.Compose(pattern, triage, agentIndex: 0);
|
string instructions = AgentInstructionComposer.Compose(workflow, triage, agentIndex: 0);
|
||||||
|
|
||||||
Assert.Equal("You triage requests and must hand them off to the most appropriate specialist.", instructions);
|
Assert.Equal("You triage requests and must hand them off to the most appropriate specialist.", instructions);
|
||||||
Assert.DoesNotContain("routing", instructions, StringComparison.OrdinalIgnoreCase);
|
Assert.DoesNotContain("routing", instructions, StringComparison.OrdinalIgnoreCase);
|
||||||
@@ -78,19 +51,13 @@ public sealed class AgentInstructionComposerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Compose_LeavesHandoffSpecialistPromptFocusedOnAgentInstructions()
|
public void Compose_LeavesHandoffSpecialistPromptFocusedOnAgentInstructions()
|
||||||
{
|
{
|
||||||
PatternDefinitionDto pattern = new()
|
WorkflowDefinitionDto workflow = CreateWorkflow("handoff");
|
||||||
{
|
WorkflowNodeDto specialist = CreateAgent(
|
||||||
Id = "pattern-handoff",
|
"agent-handoff-ux",
|
||||||
Name = "Handoff",
|
"UX Specialist",
|
||||||
Mode = "handoff",
|
"You focus on navigation, UX, and interaction details.");
|
||||||
Availability = "available",
|
|
||||||
};
|
|
||||||
PatternAgentDefinitionDto specialist = CreateAgent(
|
|
||||||
id: "agent-handoff-ux",
|
|
||||||
name: "UX Specialist",
|
|
||||||
instructions: "You focus on navigation, UX, and interaction details.");
|
|
||||||
|
|
||||||
string instructions = AgentInstructionComposer.Compose(pattern, specialist, agentIndex: 1);
|
string instructions = AgentInstructionComposer.Compose(workflow, specialist, agentIndex: 1);
|
||||||
|
|
||||||
Assert.Equal("You focus on navigation, UX, and interaction details.", instructions);
|
Assert.Equal("You focus on navigation, UX, and interaction details.", instructions);
|
||||||
Assert.DoesNotContain("triage agent", instructions, StringComparison.OrdinalIgnoreCase);
|
Assert.DoesNotContain("triage agent", instructions, StringComparison.OrdinalIgnoreCase);
|
||||||
@@ -100,20 +67,11 @@ public sealed class AgentInstructionComposerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Compose_AddsScratchpadGuidanceForProjectlessQaSessions()
|
public void Compose_AddsScratchpadGuidanceForProjectlessQaSessions()
|
||||||
{
|
{
|
||||||
PatternDefinitionDto pattern = new()
|
WorkflowDefinitionDto workflow = CreateWorkflow("single");
|
||||||
{
|
WorkflowNodeDto agent = CreateAgent("agent-primary", "Primary Agent", "You are a helpful assistant.");
|
||||||
Id = "pattern-single",
|
|
||||||
Name = "Single",
|
|
||||||
Mode = "single",
|
|
||||||
Availability = "available",
|
|
||||||
};
|
|
||||||
PatternAgentDefinitionDto agent = CreateAgent(
|
|
||||||
id: "agent-primary",
|
|
||||||
name: "Primary Agent",
|
|
||||||
instructions: "You are a helpful assistant.");
|
|
||||||
|
|
||||||
string instructions = AgentInstructionComposer.Compose(
|
string instructions = AgentInstructionComposer.Compose(
|
||||||
pattern,
|
workflow,
|
||||||
agent,
|
agent,
|
||||||
agentIndex: 0,
|
agentIndex: 0,
|
||||||
workspaceKind: "scratchpad");
|
workspaceKind: "scratchpad");
|
||||||
@@ -127,20 +85,11 @@ public sealed class AgentInstructionComposerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Compose_AddsPlanModeGuidanceWhenRequested()
|
public void Compose_AddsPlanModeGuidanceWhenRequested()
|
||||||
{
|
{
|
||||||
PatternDefinitionDto pattern = new()
|
WorkflowDefinitionDto workflow = CreateWorkflow("single");
|
||||||
{
|
WorkflowNodeDto agent = CreateAgent("agent-primary", "Primary Agent", "You are a helpful assistant.");
|
||||||
Id = "pattern-single",
|
|
||||||
Name = "Single",
|
|
||||||
Mode = "single",
|
|
||||||
Availability = "available",
|
|
||||||
};
|
|
||||||
PatternAgentDefinitionDto agent = CreateAgent(
|
|
||||||
id: "agent-primary",
|
|
||||||
name: "Primary Agent",
|
|
||||||
instructions: "You are a helpful assistant.");
|
|
||||||
|
|
||||||
string instructions = AgentInstructionComposer.Compose(
|
string instructions = AgentInstructionComposer.Compose(
|
||||||
pattern,
|
workflow,
|
||||||
agent,
|
agent,
|
||||||
agentIndex: 0,
|
agentIndex: 0,
|
||||||
interactionMode: "plan");
|
interactionMode: "plan");
|
||||||
@@ -154,20 +103,11 @@ public sealed class AgentInstructionComposerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Compose_InsertsProjectInstructionsBetweenBaseAndRuntimeGuidance()
|
public void Compose_InsertsProjectInstructionsBetweenBaseAndRuntimeGuidance()
|
||||||
{
|
{
|
||||||
PatternDefinitionDto pattern = new()
|
WorkflowDefinitionDto workflow = CreateWorkflow("single");
|
||||||
{
|
WorkflowNodeDto agent = CreateAgent("agent-primary", "Primary Agent", "You are a helpful assistant.");
|
||||||
Id = "pattern-single",
|
|
||||||
Name = "Single",
|
|
||||||
Mode = "single",
|
|
||||||
Availability = "available",
|
|
||||||
};
|
|
||||||
PatternAgentDefinitionDto agent = CreateAgent(
|
|
||||||
id: "agent-primary",
|
|
||||||
name: "Primary Agent",
|
|
||||||
instructions: "You are a helpful assistant.");
|
|
||||||
|
|
||||||
string instructions = AgentInstructionComposer.Compose(
|
string instructions = AgentInstructionComposer.Compose(
|
||||||
pattern,
|
workflow,
|
||||||
agent,
|
agent,
|
||||||
agentIndex: 0,
|
agentIndex: 0,
|
||||||
workspaceKind: "scratchpad",
|
workspaceKind: "scratchpad",
|
||||||
@@ -187,20 +127,11 @@ public sealed class AgentInstructionComposerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void Compose_AppendsPromptInvocationAsATaskDirective()
|
public void Compose_AppendsPromptInvocationAsATaskDirective()
|
||||||
{
|
{
|
||||||
PatternDefinitionDto pattern = new()
|
WorkflowDefinitionDto workflow = CreateWorkflow("single");
|
||||||
{
|
WorkflowNodeDto agent = CreateAgent("agent-primary", "Primary Agent", "You are a helpful assistant.");
|
||||||
Id = "pattern-single",
|
|
||||||
Name = "Single",
|
|
||||||
Mode = "single",
|
|
||||||
Availability = "available",
|
|
||||||
};
|
|
||||||
PatternAgentDefinitionDto agent = CreateAgent(
|
|
||||||
id: "agent-primary",
|
|
||||||
name: "Primary Agent",
|
|
||||||
instructions: "You are a helpful assistant.");
|
|
||||||
|
|
||||||
string instructions = AgentInstructionComposer.Compose(
|
string instructions = AgentInstructionComposer.Compose(
|
||||||
pattern,
|
workflow,
|
||||||
agent,
|
agent,
|
||||||
agentIndex: 0,
|
agentIndex: 0,
|
||||||
promptInvocation: new RunTurnPromptInvocationDto
|
promptInvocation: new RunTurnPromptInvocationDto
|
||||||
@@ -228,14 +159,34 @@ public sealed class AgentInstructionComposerTests
|
|||||||
StringComparison.Ordinal);
|
StringComparison.Ordinal);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PatternAgentDefinitionDto CreateAgent(string id, string name, string instructions)
|
private static WorkflowDefinitionDto CreateWorkflow(string orchestrationMode)
|
||||||
{
|
{
|
||||||
return new PatternAgentDefinitionDto
|
return new WorkflowDefinitionDto
|
||||||
|
{
|
||||||
|
Id = $"{orchestrationMode}-workflow",
|
||||||
|
Name = "Workflow",
|
||||||
|
Settings = new WorkflowSettingsDto
|
||||||
|
{
|
||||||
|
OrchestrationMode = orchestrationMode,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
private static WorkflowNodeDto CreateAgent(string id, string name, string instructions)
|
||||||
|
{
|
||||||
|
return new WorkflowNodeDto
|
||||||
{
|
{
|
||||||
Id = id,
|
Id = id,
|
||||||
Name = name,
|
Kind = "agent",
|
||||||
Instructions = instructions,
|
Label = name,
|
||||||
Model = "gpt-5.4",
|
Config = new WorkflowNodeConfigDto
|
||||||
|
{
|
||||||
|
Kind = "agent",
|
||||||
|
Id = id,
|
||||||
|
Name = name,
|
||||||
|
Instructions = instructions,
|
||||||
|
Model = "gpt-5.4",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -158,34 +158,17 @@ public sealed class CopilotAgentBundleTests
|
|||||||
Assert.Equal(HandoffWorkflowGuidance.CreateWorkflowInstructions(), builder.HandoffInstructions);
|
Assert.Equal(HandoffWorkflowGuidance.CreateWorkflowInstructions(), builder.HandoffInstructions);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
[Fact]
|
||||||
[InlineData("single", 1)]
|
public void CreateAgentHostOptions_UsesExpectedAryxDefaults()
|
||||||
[InlineData("sequential", 2)]
|
|
||||||
[InlineData("concurrent", 2)]
|
|
||||||
[InlineData("group-chat", 2)]
|
|
||||||
public void BuildWorkflow_ExplicitlyConfiguresAgentHostOptions(string mode, int agentCount)
|
|
||||||
{
|
{
|
||||||
CopilotAgentBundle bundle = new(CreateAgents(agentCount), hasConfiguredHooks: false);
|
AIAgentHostOptions options = CopilotAgentBundle.CreateAgentHostOptions();
|
||||||
PatternDefinitionDto pattern = CreatePattern(mode, agentCount);
|
|
||||||
|
|
||||||
Workflow workflow = bundle.BuildWorkflow(pattern);
|
Assert.Null(options.EmitAgentUpdateEvents);
|
||||||
|
Assert.False(options.EmitAgentResponseEvents);
|
||||||
AIAgentBinding[] bindings = workflow.ReflectExecutors().Values
|
Assert.False(options.InterceptUserInputRequests);
|
||||||
.OfType<AIAgentBinding>()
|
Assert.False(options.InterceptUnterminatedFunctionCalls);
|
||||||
.ToArray();
|
Assert.True(options.ReassignOtherAgentsAsUsers);
|
||||||
|
Assert.True(options.ForwardIncomingMessages);
|
||||||
Assert.Equal(agentCount, bindings.Length);
|
|
||||||
|
|
||||||
foreach (AIAgentBinding binding in bindings)
|
|
||||||
{
|
|
||||||
AIAgentHostOptions options = Assert.IsType<AIAgentHostOptions>(binding.Options);
|
|
||||||
Assert.Null(options.EmitAgentUpdateEvents);
|
|
||||||
Assert.False(options.EmitAgentResponseEvents);
|
|
||||||
Assert.False(options.InterceptUserInputRequests);
|
|
||||||
Assert.False(options.InterceptUnterminatedFunctionCalls);
|
|
||||||
Assert.True(options.ReassignOtherAgentsAsUsers);
|
|
||||||
Assert.True(options.ForwardIncomingMessages);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -387,28 +370,12 @@ public sealed class CopilotAgentBundleTests
|
|||||||
ProjectPath = @"C:\workspace\project",
|
ProjectPath = @"C:\workspace\project",
|
||||||
WorkspaceKind = "project",
|
WorkspaceKind = "project",
|
||||||
Mode = "interactive",
|
Mode = "interactive",
|
||||||
Pattern = new PatternDefinitionDto
|
Workflow = CreateWorkflow("single", 1),
|
||||||
{
|
|
||||||
Id = "pattern-1",
|
|
||||||
Name = "Pattern",
|
|
||||||
Mode = "single",
|
|
||||||
Availability = "available",
|
|
||||||
Agents =
|
|
||||||
[
|
|
||||||
new PatternAgentDefinitionDto
|
|
||||||
{
|
|
||||||
Id = "agent-1",
|
|
||||||
Name = "Primary",
|
|
||||||
Model = "gpt-5.4",
|
|
||||||
Instructions = "Help.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
SessionConfig sessionConfig = CopilotAgentBundle.CreateSessionConfig(
|
SessionConfig sessionConfig = CopilotAgentBundle.CreateSessionConfig(
|
||||||
command,
|
command,
|
||||||
command.Pattern.Agents[0],
|
command.Workflow.GetAgentNodes()[0],
|
||||||
agentIndex: 0);
|
agentIndex: 0);
|
||||||
|
|
||||||
Assert.Null(sessionConfig.SessionId);
|
Assert.Null(sessionConfig.SessionId);
|
||||||
@@ -427,28 +394,12 @@ public sealed class CopilotAgentBundleTests
|
|||||||
WorkspaceKind = "project",
|
WorkspaceKind = "project",
|
||||||
Mode = "interactive",
|
Mode = "interactive",
|
||||||
ProjectInstructions = "Follow repository guidance.",
|
ProjectInstructions = "Follow repository guidance.",
|
||||||
Pattern = new PatternDefinitionDto
|
Workflow = CreateWorkflow("single", 1),
|
||||||
{
|
|
||||||
Id = "pattern-1",
|
|
||||||
Name = "Pattern",
|
|
||||||
Mode = "single",
|
|
||||||
Availability = "available",
|
|
||||||
Agents =
|
|
||||||
[
|
|
||||||
new PatternAgentDefinitionDto
|
|
||||||
{
|
|
||||||
Id = "agent-1",
|
|
||||||
Name = "Primary",
|
|
||||||
Model = "gpt-5.4",
|
|
||||||
Instructions = "Help.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
SessionConfig sessionConfig = CopilotAgentBundle.CreateSessionConfig(
|
SessionConfig sessionConfig = CopilotAgentBundle.CreateSessionConfig(
|
||||||
command,
|
command,
|
||||||
command.Pattern.Agents[0],
|
command.Workflow.GetAgentNodes()[0],
|
||||||
agentIndex: 0);
|
agentIndex: 0);
|
||||||
|
|
||||||
Assert.Equal("Help.\n\nFollow repository guidance.", sessionConfig.SystemMessage?.Content);
|
Assert.Equal("Help.\n\nFollow repository guidance.", sessionConfig.SystemMessage?.Content);
|
||||||
@@ -471,28 +422,12 @@ public sealed class CopilotAgentBundleTests
|
|||||||
Agent = "designer",
|
Agent = "designer",
|
||||||
ResolvedPrompt = "Review the docs for missing steps.",
|
ResolvedPrompt = "Review the docs for missing steps.",
|
||||||
},
|
},
|
||||||
Pattern = new PatternDefinitionDto
|
Workflow = CreateWorkflow("single", 1),
|
||||||
{
|
|
||||||
Id = "pattern-1",
|
|
||||||
Name = "Pattern",
|
|
||||||
Mode = "single",
|
|
||||||
Availability = "available",
|
|
||||||
Agents =
|
|
||||||
[
|
|
||||||
new PatternAgentDefinitionDto
|
|
||||||
{
|
|
||||||
Id = "agent-1",
|
|
||||||
Name = "Primary",
|
|
||||||
Model = "gpt-5.4",
|
|
||||||
Instructions = "Help.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
SessionConfig sessionConfig = CopilotAgentBundle.CreateSessionConfig(
|
SessionConfig sessionConfig = CopilotAgentBundle.CreateSessionConfig(
|
||||||
command,
|
command,
|
||||||
command.Pattern.Agents[0],
|
command.Workflow.GetAgentNodes()[0],
|
||||||
agentIndex: 0);
|
agentIndex: 0);
|
||||||
|
|
||||||
Assert.Equal("designer", sessionConfig.Agent);
|
Assert.Equal("designer", sessionConfig.Agent);
|
||||||
@@ -516,28 +451,12 @@ public sealed class CopilotAgentBundleTests
|
|||||||
ResolvedPrompt = "Review the docs for missing steps.",
|
ResolvedPrompt = "Review the docs for missing steps.",
|
||||||
Tools = ["view"],
|
Tools = ["view"],
|
||||||
},
|
},
|
||||||
Pattern = new PatternDefinitionDto
|
Workflow = CreateWorkflow("single", 1),
|
||||||
{
|
|
||||||
Id = "pattern-1",
|
|
||||||
Name = "Pattern",
|
|
||||||
Mode = "single",
|
|
||||||
Availability = "available",
|
|
||||||
Agents =
|
|
||||||
[
|
|
||||||
new PatternAgentDefinitionDto
|
|
||||||
{
|
|
||||||
Id = "agent-1",
|
|
||||||
Name = "Primary",
|
|
||||||
Model = "gpt-5.4",
|
|
||||||
Instructions = "Help.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
SessionConfig sessionConfig = CopilotAgentBundle.CreateSessionConfig(
|
SessionConfig sessionConfig = CopilotAgentBundle.CreateSessionConfig(
|
||||||
command,
|
command,
|
||||||
command.Pattern.Agents[0],
|
command.Workflow.GetAgentNodes()[0],
|
||||||
agentIndex: 0);
|
agentIndex: 0);
|
||||||
|
|
||||||
Assert.Equal("agent", sessionConfig.Agent);
|
Assert.Equal("agent", sessionConfig.Agent);
|
||||||
@@ -550,13 +469,10 @@ public sealed class CopilotAgentBundleTests
|
|||||||
{
|
{
|
||||||
RequestId = "turn-1",
|
RequestId = "turn-1",
|
||||||
SessionId = "session-1",
|
SessionId = "session-1",
|
||||||
Pattern = new PatternDefinitionDto
|
Workflow = CreateWorkflow(
|
||||||
{
|
"single",
|
||||||
Id = "pattern-1",
|
1,
|
||||||
Name = "Pattern",
|
new ApprovalPolicyDto
|
||||||
Mode = "single",
|
|
||||||
Availability = "available",
|
|
||||||
ApprovalPolicy = new ApprovalPolicyDto
|
|
||||||
{
|
{
|
||||||
Rules =
|
Rules =
|
||||||
[
|
[
|
||||||
@@ -566,21 +482,10 @@ public sealed class CopilotAgentBundleTests
|
|||||||
AgentIds = ["agent-1"],
|
AgentIds = ["agent-1"],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
}),
|
||||||
Agents =
|
|
||||||
[
|
|
||||||
new PatternAgentDefinitionDto
|
|
||||||
{
|
|
||||||
Id = "agent-1",
|
|
||||||
Name = "Primary",
|
|
||||||
Model = "gpt-5.4",
|
|
||||||
Instructions = "Help.",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0]);
|
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0]);
|
||||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||||
new PreToolUseHookInput
|
new PreToolUseHookInput
|
||||||
{
|
{
|
||||||
@@ -630,25 +535,41 @@ public sealed class CopilotAgentBundleTests
|
|||||||
.Select(index => (AIAgent)CreateChatClientAgent($"agent-{index}", $"Agent {index}"))
|
.Select(index => (AIAgent)CreateChatClientAgent($"agent-{index}", $"Agent {index}"))
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
||||||
private static PatternDefinitionDto CreatePattern(string mode, int agentCount)
|
private static WorkflowDefinitionDto CreateWorkflow(
|
||||||
|
string mode,
|
||||||
|
int agentCount,
|
||||||
|
ApprovalPolicyDto? approvalPolicy = null)
|
||||||
{
|
{
|
||||||
return new PatternDefinitionDto
|
return new WorkflowDefinitionDto
|
||||||
{
|
{
|
||||||
Id = $"pattern-{mode}",
|
Id = $"workflow-{mode}",
|
||||||
Name = $"Pattern {mode}",
|
Name = $"Workflow {mode}",
|
||||||
Mode = mode,
|
Graph = new WorkflowGraphDto
|
||||||
Availability = "available",
|
{
|
||||||
Agents =
|
Nodes =
|
||||||
[
|
[
|
||||||
.. Enumerable.Range(1, agentCount).Select(index => new PatternAgentDefinitionDto
|
.. Enumerable.Range(1, agentCount).Select(index => new WorkflowNodeDto
|
||||||
{
|
{
|
||||||
Id = $"agent-{index}",
|
Id = $"agent-{index}",
|
||||||
Name = $"Agent {index}",
|
Kind = "agent",
|
||||||
Description = $"Agent {index} description.",
|
Label = $"Agent {index}",
|
||||||
Instructions = $"Agent {index} instructions.",
|
Config = new WorkflowNodeConfigDto
|
||||||
Model = "gpt-5.4",
|
{
|
||||||
}),
|
Kind = "agent",
|
||||||
],
|
Id = $"agent-{index}",
|
||||||
|
Name = $"Agent {index}",
|
||||||
|
Description = $"Agent {index} description.",
|
||||||
|
Instructions = "Help.",
|
||||||
|
Model = "gpt-5.4",
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Settings = new WorkflowSettingsDto
|
||||||
|
{
|
||||||
|
OrchestrationMode = mode,
|
||||||
|
ApprovalPolicy = approvalPolicy,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -697,3 +618,4 @@ public sealed class CopilotAgentBundleTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ public sealed class CopilotExitPlanModeCoordinatorTests
|
|||||||
|
|
||||||
ExitPlanModeRequestedEventDto exitPlanEvent = coordinator.RecordExitPlanModeRequest(
|
ExitPlanModeRequestedEventDto exitPlanEvent = coordinator.RecordExitPlanModeRequest(
|
||||||
command,
|
command,
|
||||||
command.Pattern.Agents[0],
|
command.Workflow.GetAgentNodes()[0],
|
||||||
new ExitPlanModeRequestedEvent
|
new ExitPlanModeRequestedEvent
|
||||||
{
|
{
|
||||||
Data = new ExitPlanModeRequestedData
|
Data = new ExitPlanModeRequestedData
|
||||||
@@ -50,23 +50,36 @@ public sealed class CopilotExitPlanModeCoordinatorTests
|
|||||||
{
|
{
|
||||||
RequestId = "turn-1",
|
RequestId = "turn-1",
|
||||||
SessionId = "session-1",
|
SessionId = "session-1",
|
||||||
Pattern = new PatternDefinitionDto
|
Workflow = new WorkflowDefinitionDto
|
||||||
{
|
{
|
||||||
Id = "pattern-1",
|
Id = "workflow-1",
|
||||||
Name = "Plan Mode Pattern",
|
Name = "Plan Mode Workflow",
|
||||||
Mode = "single",
|
Graph = new WorkflowGraphDto
|
||||||
Availability = "available",
|
{
|
||||||
Agents =
|
Nodes =
|
||||||
[
|
[
|
||||||
new PatternAgentDefinitionDto
|
new WorkflowNodeDto
|
||||||
{
|
{
|
||||||
Id = "agent-1",
|
Id = "agent-1",
|
||||||
Name = "Primary",
|
Kind = "agent",
|
||||||
Model = "gpt-5.4",
|
Label = "Primary",
|
||||||
Instructions = "Help with the request.",
|
Config = new WorkflowNodeConfigDto
|
||||||
},
|
{
|
||||||
],
|
Kind = "agent",
|
||||||
|
Id = "agent-1",
|
||||||
|
Name = "Primary",
|
||||||
|
Model = "gpt-5.4",
|
||||||
|
Instructions = "Help with the request.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Settings = new WorkflowSettingsDto
|
||||||
|
{
|
||||||
|
OrchestrationMode = "single",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ public sealed class CopilotMcpOAuthCoordinatorTests
|
|||||||
|
|
||||||
McpOauthRequiredEventDto oauthEvent = coordinator.BuildMcpOauthRequiredEvent(
|
McpOauthRequiredEventDto oauthEvent = coordinator.BuildMcpOauthRequiredEvent(
|
||||||
command,
|
command,
|
||||||
command.Pattern.Agents[0],
|
command.Workflow.GetAgentNodes()[0],
|
||||||
new McpOauthRequiredEvent
|
new McpOauthRequiredEvent
|
||||||
{
|
{
|
||||||
Data = new McpOauthRequiredData
|
Data = new McpOauthRequiredData
|
||||||
@@ -49,23 +49,36 @@ public sealed class CopilotMcpOAuthCoordinatorTests
|
|||||||
{
|
{
|
||||||
RequestId = "turn-1",
|
RequestId = "turn-1",
|
||||||
SessionId = "session-1",
|
SessionId = "session-1",
|
||||||
Pattern = new PatternDefinitionDto
|
Workflow = new WorkflowDefinitionDto
|
||||||
{
|
{
|
||||||
Id = "pattern-1",
|
Id = "workflow-1",
|
||||||
Name = "MCP OAuth Pattern",
|
Name = "MCP OAuth Workflow",
|
||||||
Mode = "single",
|
Graph = new WorkflowGraphDto
|
||||||
Availability = "available",
|
{
|
||||||
Agents =
|
Nodes =
|
||||||
[
|
[
|
||||||
new PatternAgentDefinitionDto
|
new WorkflowNodeDto
|
||||||
{
|
{
|
||||||
Id = "agent-1",
|
Id = "agent-1",
|
||||||
Name = "Primary",
|
Kind = "agent",
|
||||||
Model = "gpt-5.4",
|
Label = "Primary",
|
||||||
Instructions = "Help with the request.",
|
Config = new WorkflowNodeConfigDto
|
||||||
},
|
{
|
||||||
],
|
Kind = "agent",
|
||||||
|
Id = "agent-1",
|
||||||
|
Name = "Primary",
|
||||||
|
Model = "gpt-5.4",
|
||||||
|
Instructions = "Help with the request.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Settings = new WorkflowSettingsDto
|
||||||
|
{
|
||||||
|
OrchestrationMode = "single",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ public sealed class CopilotSessionHooksTests
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], configuredHooks, runner);
|
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], configuredHooks, runner);
|
||||||
|
|
||||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||||
new PreToolUseHookInput
|
new PreToolUseHookInput
|
||||||
@@ -64,7 +64,7 @@ public sealed class CopilotSessionHooksTests
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], configuredHooks, runner);
|
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], configuredHooks, runner);
|
||||||
|
|
||||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||||
new PreToolUseHookInput
|
new PreToolUseHookInput
|
||||||
@@ -93,7 +93,7 @@ public sealed class CopilotSessionHooksTests
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], configuredHooks, runner);
|
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], configuredHooks, runner);
|
||||||
|
|
||||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||||
new PreToolUseHookInput
|
new PreToolUseHookInput
|
||||||
@@ -123,7 +123,7 @@ public sealed class CopilotSessionHooksTests
|
|||||||
public async Task Create_PreToolUseAutoAllowsInternalOrchestrationTools(string toolName)
|
public async Task Create_PreToolUseAutoAllowsInternalOrchestrationTools(string toolName)
|
||||||
{
|
{
|
||||||
RunTurnCommandDto command = CreateCommandWithToolApproval();
|
RunTurnCommandDto command = CreateCommandWithToolApproval();
|
||||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
|
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
|
||||||
|
|
||||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||||
new PreToolUseHookInput
|
new PreToolUseHookInput
|
||||||
@@ -139,7 +139,7 @@ public sealed class CopilotSessionHooksTests
|
|||||||
public async Task Create_PreToolUseKeepsStoreMemoryUnderApprovalPolicy()
|
public async Task Create_PreToolUseKeepsStoreMemoryUnderApprovalPolicy()
|
||||||
{
|
{
|
||||||
RunTurnCommandDto command = CreateCommandWithToolApproval();
|
RunTurnCommandDto command = CreateCommandWithToolApproval();
|
||||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
|
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
|
||||||
|
|
||||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||||
new PreToolUseHookInput
|
new PreToolUseHookInput
|
||||||
@@ -159,7 +159,7 @@ public sealed class CopilotSessionHooksTests
|
|||||||
public async Task Create_PreToolUseAutoAllowsWhenCategoryIsApproved(string toolName, string category)
|
public async Task Create_PreToolUseAutoAllowsWhenCategoryIsApproved(string toolName, string category)
|
||||||
{
|
{
|
||||||
RunTurnCommandDto command = CreateCommandWithAutoApprovedCategory(category);
|
RunTurnCommandDto command = CreateCommandWithAutoApprovedCategory(category);
|
||||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
|
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
|
||||||
|
|
||||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||||
new PreToolUseHookInput
|
new PreToolUseHookInput
|
||||||
@@ -177,7 +177,7 @@ public sealed class CopilotSessionHooksTests
|
|||||||
RunTurnCommandDto command = CreateCommandWithConfiguredMcpServers(
|
RunTurnCommandDto command = CreateCommandWithConfiguredMcpServers(
|
||||||
["icm-mcp"],
|
["icm-mcp"],
|
||||||
["mcp_server:icm-mcp"]);
|
["mcp_server:icm-mcp"]);
|
||||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
|
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
|
||||||
|
|
||||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||||
new PreToolUseHookInput
|
new PreToolUseHookInput
|
||||||
@@ -193,7 +193,7 @@ public sealed class CopilotSessionHooksTests
|
|||||||
public async Task Create_PreToolUseRequiresApprovalWhenMcpServerIsNotApproved()
|
public async Task Create_PreToolUseRequiresApprovalWhenMcpServerIsNotApproved()
|
||||||
{
|
{
|
||||||
RunTurnCommandDto command = CreateCommandWithConfiguredMcpServers(["icm-mcp"]);
|
RunTurnCommandDto command = CreateCommandWithConfiguredMcpServers(["icm-mcp"]);
|
||||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
|
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
|
||||||
|
|
||||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||||
new PreToolUseHookInput
|
new PreToolUseHookInput
|
||||||
@@ -219,7 +219,7 @@ public sealed class CopilotSessionHooksTests
|
|||||||
ErrorOccurred = [CreateHookCommand("error-hook")],
|
ErrorOccurred = [CreateHookCommand("error-hook")],
|
||||||
};
|
};
|
||||||
|
|
||||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], configuredHooks, runner);
|
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], configuredHooks, runner);
|
||||||
|
|
||||||
await hooks.OnSessionStart!(
|
await hooks.OnSessionStart!(
|
||||||
new SessionStartHookInput
|
new SessionStartHookInput
|
||||||
@@ -293,7 +293,7 @@ public sealed class CopilotSessionHooksTests
|
|||||||
public async Task Create_WithoutConfiguredFileHooksPreservesExistingApprovalBehavior()
|
public async Task Create_WithoutConfiguredFileHooksPreservesExistingApprovalBehavior()
|
||||||
{
|
{
|
||||||
RunTurnCommandDto command = CreateCommandWithoutApprovalRules();
|
RunTurnCommandDto command = CreateCommandWithoutApprovalRules();
|
||||||
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Pattern.Agents[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
|
SessionHooks hooks = CopilotSessionHooks.Create(command, command.Workflow.GetAgentNodes()[0], ResolvedHookSet.Empty, new RecordingHookCommandRunner());
|
||||||
|
|
||||||
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
PreToolUseHookOutput? decision = await hooks.OnPreToolUse!(
|
||||||
new PreToolUseHookInput
|
new PreToolUseHookInput
|
||||||
@@ -312,34 +312,17 @@ public sealed class CopilotSessionHooksTests
|
|||||||
RequestId = "turn-1",
|
RequestId = "turn-1",
|
||||||
SessionId = "session-1",
|
SessionId = "session-1",
|
||||||
ProjectPath = @"C:\workspace\project",
|
ProjectPath = @"C:\workspace\project",
|
||||||
Pattern = new PatternDefinitionDto
|
Workflow = CreateWorkflow(new ApprovalPolicyDto
|
||||||
{
|
{
|
||||||
Id = "pattern-1",
|
Rules =
|
||||||
Name = "Pattern",
|
|
||||||
Mode = "single",
|
|
||||||
Availability = "available",
|
|
||||||
ApprovalPolicy = new ApprovalPolicyDto
|
|
||||||
{
|
|
||||||
Rules =
|
|
||||||
[
|
|
||||||
new ApprovalCheckpointRuleDto
|
|
||||||
{
|
|
||||||
Kind = "tool-call",
|
|
||||||
AgentIds = ["agent-1"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
Agents =
|
|
||||||
[
|
[
|
||||||
new PatternAgentDefinitionDto
|
new ApprovalCheckpointRuleDto
|
||||||
{
|
{
|
||||||
Id = "agent-1",
|
Kind = "tool-call",
|
||||||
Name = "Primary",
|
AgentIds = ["agent-1"],
|
||||||
Model = "gpt-5.4",
|
|
||||||
Instructions = "Help.",
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,15 +334,7 @@ public sealed class CopilotSessionHooksTests
|
|||||||
RequestId = command.RequestId,
|
RequestId = command.RequestId,
|
||||||
SessionId = command.SessionId,
|
SessionId = command.SessionId,
|
||||||
ProjectPath = command.ProjectPath,
|
ProjectPath = command.ProjectPath,
|
||||||
Pattern = new PatternDefinitionDto
|
Workflow = CreateWorkflow(new ApprovalPolicyDto()),
|
||||||
{
|
|
||||||
Id = command.Pattern.Id,
|
|
||||||
Name = command.Pattern.Name,
|
|
||||||
Mode = command.Pattern.Mode,
|
|
||||||
Availability = command.Pattern.Availability,
|
|
||||||
ApprovalPolicy = new ApprovalPolicyDto(),
|
|
||||||
Agents = command.Pattern.Agents,
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -370,35 +345,18 @@ public sealed class CopilotSessionHooksTests
|
|||||||
RequestId = "turn-1",
|
RequestId = "turn-1",
|
||||||
SessionId = "session-1",
|
SessionId = "session-1",
|
||||||
ProjectPath = @"C:\workspace\project",
|
ProjectPath = @"C:\workspace\project",
|
||||||
Pattern = new PatternDefinitionDto
|
Workflow = CreateWorkflow(new ApprovalPolicyDto
|
||||||
{
|
{
|
||||||
Id = "pattern-1",
|
Rules =
|
||||||
Name = "Pattern",
|
|
||||||
Mode = "single",
|
|
||||||
Availability = "available",
|
|
||||||
ApprovalPolicy = new ApprovalPolicyDto
|
|
||||||
{
|
|
||||||
Rules =
|
|
||||||
[
|
|
||||||
new ApprovalCheckpointRuleDto
|
|
||||||
{
|
|
||||||
Kind = "tool-call",
|
|
||||||
AgentIds = ["agent-1"],
|
|
||||||
},
|
|
||||||
],
|
|
||||||
AutoApprovedToolNames = [category],
|
|
||||||
},
|
|
||||||
Agents =
|
|
||||||
[
|
[
|
||||||
new PatternAgentDefinitionDto
|
new ApprovalCheckpointRuleDto
|
||||||
{
|
{
|
||||||
Id = "agent-1",
|
Kind = "tool-call",
|
||||||
Name = "Primary",
|
AgentIds = ["agent-1"],
|
||||||
Model = "gpt-5.4",
|
|
||||||
Instructions = "Help.",
|
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
AutoApprovedToolNames = [category],
|
||||||
|
}),
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -416,18 +374,44 @@ public sealed class CopilotSessionHooksTests
|
|||||||
{
|
{
|
||||||
McpServers = [.. serverNames.Select(CreateMcpServerConfig)],
|
McpServers = [.. serverNames.Select(CreateMcpServerConfig)],
|
||||||
},
|
},
|
||||||
Pattern = new PatternDefinitionDto
|
Workflow = CreateWorkflow(new ApprovalPolicyDto
|
||||||
{
|
{
|
||||||
Id = command.Pattern.Id,
|
Rules = command.Workflow.Settings.ApprovalPolicy?.Rules ?? [],
|
||||||
Name = command.Pattern.Name,
|
AutoApprovedToolNames = autoApprovedToolNames ?? [],
|
||||||
Mode = command.Pattern.Mode,
|
}),
|
||||||
Availability = command.Pattern.Availability,
|
};
|
||||||
ApprovalPolicy = new ApprovalPolicyDto
|
}
|
||||||
{
|
|
||||||
Rules = command.Pattern.ApprovalPolicy?.Rules ?? [],
|
private static WorkflowDefinitionDto CreateWorkflow(ApprovalPolicyDto approvalPolicy)
|
||||||
AutoApprovedToolNames = autoApprovedToolNames ?? [],
|
{
|
||||||
},
|
return new WorkflowDefinitionDto
|
||||||
Agents = command.Pattern.Agents,
|
{
|
||||||
|
Id = "workflow-1",
|
||||||
|
Name = "Workflow",
|
||||||
|
Graph = new WorkflowGraphDto
|
||||||
|
{
|
||||||
|
Nodes =
|
||||||
|
[
|
||||||
|
new WorkflowNodeDto
|
||||||
|
{
|
||||||
|
Id = "agent-1",
|
||||||
|
Kind = "agent",
|
||||||
|
Label = "Primary",
|
||||||
|
Config = new WorkflowNodeConfigDto
|
||||||
|
{
|
||||||
|
Kind = "agent",
|
||||||
|
Id = "agent-1",
|
||||||
|
Name = "Primary",
|
||||||
|
Model = "gpt-5.4",
|
||||||
|
Instructions = "Help.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Settings = new WorkflowSettingsDto
|
||||||
|
{
|
||||||
|
OrchestrationMode = "single",
|
||||||
|
ApprovalPolicy = approvalPolicy,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -477,3 +461,4 @@ public sealed class CopilotSessionHooksTests
|
|||||||
string InputJson,
|
string InputJson,
|
||||||
string ProjectPath);
|
string ProjectPath);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ public sealed class CopilotTurnExecutionStateTests
|
|||||||
CopilotTurnExecutionState state = new(command);
|
CopilotTurnExecutionState state = new(command);
|
||||||
|
|
||||||
state.ObserveSessionEvent(
|
state.ObserveSessionEvent(
|
||||||
command.Pattern.Agents[0],
|
command.Workflow.GetAgentNodes()[0],
|
||||||
new McpOauthRequiredEvent
|
new McpOauthRequiredEvent
|
||||||
{
|
{
|
||||||
Data = new McpOauthRequiredData
|
Data = new McpOauthRequiredData
|
||||||
@@ -37,7 +37,7 @@ public sealed class CopilotTurnExecutionStateTests
|
|||||||
CopilotTurnExecutionState state = new(command);
|
CopilotTurnExecutionState state = new(command);
|
||||||
|
|
||||||
state.ObserveSessionEvent(
|
state.ObserveSessionEvent(
|
||||||
command.Pattern.Agents[0],
|
command.Workflow.GetAgentNodes()[0],
|
||||||
SessionEvent.FromJson(
|
SessionEvent.FromJson(
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
@@ -66,7 +66,7 @@ public sealed class CopilotTurnExecutionStateTests
|
|||||||
CopilotTurnExecutionState state = new(command);
|
CopilotTurnExecutionState state = new(command);
|
||||||
|
|
||||||
state.ObserveSessionEvent(
|
state.ObserveSessionEvent(
|
||||||
command.Pattern.Agents[0],
|
command.Workflow.GetAgentNodes()[0],
|
||||||
SessionEvent.FromJson(
|
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"}"""));
|
"""{"type":"tool.execution_start","data":{"toolCallId":"tool-call-1","toolName":"view"},"id":"33333333-3333-3333-3333-333333333333","timestamp":"2026-03-27T00:00:00Z"}"""));
|
||||||
|
|
||||||
@@ -85,7 +85,7 @@ public sealed class CopilotTurnExecutionStateTests
|
|||||||
CopilotTurnExecutionState state = new(command);
|
CopilotTurnExecutionState state = new(command);
|
||||||
|
|
||||||
state.ObserveSessionEvent(
|
state.ObserveSessionEvent(
|
||||||
command.Pattern.Agents[0],
|
command.Workflow.GetAgentNodes()[0],
|
||||||
SessionEvent.FromJson(
|
SessionEvent.FromJson(
|
||||||
"""{"type":"tool.execution_start","data":{"toolCallId":"tool-call-1","toolName":"handoff_to_specialist"},"id":"1ce9d1dc-68f1-4df5-9728-f97017233279","timestamp":"2026-03-27T00:00:00Z"}"""));
|
"""{"type":"tool.execution_start","data":{"toolCallId":"tool-call-1","toolName":"handoff_to_specialist"},"id":"1ce9d1dc-68f1-4df5-9728-f97017233279","timestamp":"2026-03-27T00:00:00Z"}"""));
|
||||||
|
|
||||||
@@ -101,7 +101,7 @@ public sealed class CopilotTurnExecutionStateTests
|
|||||||
CopilotTurnExecutionState state = new(command);
|
CopilotTurnExecutionState state = new(command);
|
||||||
|
|
||||||
state.ObserveSessionEvent(
|
state.ObserveSessionEvent(
|
||||||
command.Pattern.Agents[0],
|
command.Workflow.GetAgentNodes()[0],
|
||||||
SessionEvent.FromJson(
|
SessionEvent.FromJson(
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
@@ -142,7 +142,7 @@ public sealed class CopilotTurnExecutionStateTests
|
|||||||
CopilotTurnExecutionState state = new(command);
|
CopilotTurnExecutionState state = new(command);
|
||||||
|
|
||||||
state.ObserveSessionEvent(
|
state.ObserveSessionEvent(
|
||||||
command.Pattern.Agents[0],
|
command.Workflow.GetAgentNodes()[0],
|
||||||
SessionEvent.FromJson(
|
SessionEvent.FromJson(
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
@@ -158,7 +158,7 @@ public sealed class CopilotTurnExecutionStateTests
|
|||||||
_ = state.DrainPendingEvents();
|
_ = state.DrainPendingEvents();
|
||||||
|
|
||||||
state.ObserveSessionEvent(
|
state.ObserveSessionEvent(
|
||||||
command.Pattern.Agents[0],
|
command.Workflow.GetAgentNodes()[0],
|
||||||
SessionEvent.FromJson(
|
SessionEvent.FromJson(
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
@@ -172,7 +172,7 @@ public sealed class CopilotTurnExecutionStateTests
|
|||||||
}
|
}
|
||||||
"""));
|
"""));
|
||||||
state.ObserveSessionEvent(
|
state.ObserveSessionEvent(
|
||||||
command.Pattern.Agents[0],
|
command.Workflow.GetAgentNodes()[0],
|
||||||
SessionEvent.FromJson(
|
SessionEvent.FromJson(
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
@@ -207,7 +207,7 @@ public sealed class CopilotTurnExecutionStateTests
|
|||||||
CopilotTurnExecutionState state = new(command);
|
CopilotTurnExecutionState state = new(command);
|
||||||
|
|
||||||
state.ObserveSessionEvent(
|
state.ObserveSessionEvent(
|
||||||
command.Pattern.Agents[0],
|
command.Workflow.GetAgentNodes()[0],
|
||||||
SessionEvent.FromJson(
|
SessionEvent.FromJson(
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
@@ -231,7 +231,7 @@ public sealed class CopilotTurnExecutionStateTests
|
|||||||
CopilotTurnExecutionState state = new(command);
|
CopilotTurnExecutionState state = new(command);
|
||||||
|
|
||||||
state.ObserveSessionEvent(
|
state.ObserveSessionEvent(
|
||||||
command.Pattern.Agents[0],
|
command.Workflow.GetAgentNodes()[0],
|
||||||
SessionEvent.FromJson(
|
SessionEvent.FromJson(
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
@@ -267,7 +267,7 @@ public sealed class CopilotTurnExecutionStateTests
|
|||||||
CopilotTurnExecutionState state = new(command);
|
CopilotTurnExecutionState state = new(command);
|
||||||
|
|
||||||
state.ObserveSessionEvent(
|
state.ObserveSessionEvent(
|
||||||
command.Pattern.Agents[0],
|
command.Workflow.GetAgentNodes()[0],
|
||||||
SessionEvent.FromJson(
|
SessionEvent.FromJson(
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
@@ -298,7 +298,7 @@ public sealed class CopilotTurnExecutionStateTests
|
|||||||
CopilotTurnExecutionState state = new(command);
|
CopilotTurnExecutionState state = new(command);
|
||||||
|
|
||||||
state.ObserveSessionEvent(
|
state.ObserveSessionEvent(
|
||||||
command.Pattern.Agents[0],
|
command.Workflow.GetAgentNodes()[0],
|
||||||
SessionEvent.FromJson(
|
SessionEvent.FromJson(
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
@@ -356,7 +356,7 @@ public sealed class CopilotTurnExecutionStateTests
|
|||||||
CopilotTurnExecutionState state = new(command);
|
CopilotTurnExecutionState state = new(command);
|
||||||
|
|
||||||
state.ObserveSessionEvent(
|
state.ObserveSessionEvent(
|
||||||
command.Pattern.Agents[0],
|
command.Workflow.GetAgentNodes()[0],
|
||||||
SessionEvent.FromJson(
|
SessionEvent.FromJson(
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
@@ -386,7 +386,7 @@ public sealed class CopilotTurnExecutionStateTests
|
|||||||
CopilotTurnExecutionState state = new(command);
|
CopilotTurnExecutionState state = new(command);
|
||||||
|
|
||||||
state.ObserveSessionEvent(
|
state.ObserveSessionEvent(
|
||||||
command.Pattern.Agents[0],
|
command.Workflow.GetAgentNodes()[0],
|
||||||
SessionEvent.FromJson(
|
SessionEvent.FromJson(
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
@@ -417,7 +417,7 @@ public sealed class CopilotTurnExecutionStateTests
|
|||||||
RunTurnCommandDto command = CreateCommand();
|
RunTurnCommandDto command = CreateCommand();
|
||||||
CopilotTurnExecutionState state = new(command);
|
CopilotTurnExecutionState state = new(command);
|
||||||
|
|
||||||
state.ObserveSessionEvent(command.Pattern.Agents[0], CreateHookStartEvent());
|
state.ObserveSessionEvent(command.Workflow.GetAgentNodes()[0], CreateHookStartEvent());
|
||||||
|
|
||||||
HookLifecycleEventDto evt = Assert.Single(state.DrainPendingEvents().OfType<HookLifecycleEventDto>());
|
HookLifecycleEventDto evt = Assert.Single(state.DrainPendingEvents().OfType<HookLifecycleEventDto>());
|
||||||
Assert.Equal("start", evt.Phase);
|
Assert.Equal("start", evt.Phase);
|
||||||
@@ -432,7 +432,7 @@ public sealed class CopilotTurnExecutionStateTests
|
|||||||
RunTurnCommandDto command = CreateCommand();
|
RunTurnCommandDto command = CreateCommand();
|
||||||
CopilotTurnExecutionState state = new(command);
|
CopilotTurnExecutionState state = new(command);
|
||||||
|
|
||||||
state.ObserveSessionEvent(command.Pattern.Agents[0], CreateHookEndEvent());
|
state.ObserveSessionEvent(command.Workflow.GetAgentNodes()[0], CreateHookEndEvent());
|
||||||
|
|
||||||
HookLifecycleEventDto evt = Assert.Single(state.DrainPendingEvents().OfType<HookLifecycleEventDto>());
|
HookLifecycleEventDto evt = Assert.Single(state.DrainPendingEvents().OfType<HookLifecycleEventDto>());
|
||||||
Assert.Equal("end", evt.Phase);
|
Assert.Equal("end", evt.Phase);
|
||||||
@@ -450,8 +450,8 @@ public sealed class CopilotTurnExecutionStateTests
|
|||||||
SuppressHookLifecycleEvents = true,
|
SuppressHookLifecycleEvents = true,
|
||||||
};
|
};
|
||||||
|
|
||||||
state.ObserveSessionEvent(command.Pattern.Agents[0], CreateHookStartEvent());
|
state.ObserveSessionEvent(command.Workflow.GetAgentNodes()[0], CreateHookStartEvent());
|
||||||
state.ObserveSessionEvent(command.Pattern.Agents[0], CreateHookEndEvent());
|
state.ObserveSessionEvent(command.Workflow.GetAgentNodes()[0], CreateHookEndEvent());
|
||||||
|
|
||||||
Assert.Empty(state.DrainPendingEvents());
|
Assert.Empty(state.DrainPendingEvents());
|
||||||
}
|
}
|
||||||
@@ -463,7 +463,7 @@ public sealed class CopilotTurnExecutionStateTests
|
|||||||
CopilotTurnExecutionState state = new(command);
|
CopilotTurnExecutionState state = new(command);
|
||||||
|
|
||||||
state.ObserveSessionEvent(
|
state.ObserveSessionEvent(
|
||||||
command.Pattern.Agents[0],
|
command.Workflow.GetAgentNodes()[0],
|
||||||
SessionEvent.FromJson(
|
SessionEvent.FromJson(
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
@@ -526,7 +526,7 @@ public sealed class CopilotTurnExecutionStateTests
|
|||||||
CopilotTurnExecutionState state = new(command);
|
CopilotTurnExecutionState state = new(command);
|
||||||
|
|
||||||
state.ObserveSessionEvent(
|
state.ObserveSessionEvent(
|
||||||
command.Pattern.Agents[0],
|
command.Workflow.GetAgentNodes()[0],
|
||||||
SessionEvent.FromJson(
|
SessionEvent.FromJson(
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
@@ -561,7 +561,7 @@ public sealed class CopilotTurnExecutionStateTests
|
|||||||
CopilotTurnExecutionState state = new(command);
|
CopilotTurnExecutionState state = new(command);
|
||||||
|
|
||||||
state.ObserveSessionEvent(
|
state.ObserveSessionEvent(
|
||||||
command.Pattern.Agents[0],
|
command.Workflow.GetAgentNodes()[0],
|
||||||
SessionEvent.FromJson(
|
SessionEvent.FromJson(
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
@@ -624,7 +624,7 @@ public sealed class CopilotTurnExecutionStateTests
|
|||||||
|
|
||||||
// Simulate assistant message with tool requests → triggers reclassification
|
// Simulate assistant message with tool requests → triggers reclassification
|
||||||
state.ObserveSessionEvent(
|
state.ObserveSessionEvent(
|
||||||
command.Pattern.Agents[0],
|
command.Workflow.GetAgentNodes()[0],
|
||||||
SessionEvent.FromJson(
|
SessionEvent.FromJson(
|
||||||
"""
|
"""
|
||||||
{
|
{
|
||||||
@@ -672,23 +672,36 @@ public sealed class CopilotTurnExecutionStateTests
|
|||||||
{
|
{
|
||||||
RequestId = "turn-1",
|
RequestId = "turn-1",
|
||||||
SessionId = "session-1",
|
SessionId = "session-1",
|
||||||
Pattern = new PatternDefinitionDto
|
Workflow = new WorkflowDefinitionDto
|
||||||
{
|
{
|
||||||
Id = "pattern-1",
|
Id = "workflow-1",
|
||||||
Name = "MCP OAuth Pattern",
|
Name = "Execution State Workflow",
|
||||||
Mode = "single",
|
Graph = new WorkflowGraphDto
|
||||||
Availability = "available",
|
{
|
||||||
Agents =
|
Nodes =
|
||||||
[
|
[
|
||||||
new PatternAgentDefinitionDto
|
new WorkflowNodeDto
|
||||||
{
|
{
|
||||||
Id = "agent-1",
|
Id = "agent-1",
|
||||||
Name = "Primary",
|
Kind = "agent",
|
||||||
Model = "gpt-5.4",
|
Label = "Primary",
|
||||||
Instructions = "Help with the request.",
|
Config = new WorkflowNodeConfigDto
|
||||||
},
|
{
|
||||||
],
|
Kind = "agent",
|
||||||
|
Id = "agent-1",
|
||||||
|
Name = "Primary",
|
||||||
|
Model = "gpt-5.4",
|
||||||
|
Instructions = "Help with the request.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Settings = new WorkflowSettingsDto
|
||||||
|
{
|
||||||
|
OrchestrationMode = "single",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ public sealed class CopilotUserInputCoordinatorTests
|
|||||||
|
|
||||||
Task<UserInputResponse> pending = coordinator.RequestUserInputAsync(
|
Task<UserInputResponse> pending = coordinator.RequestUserInputAsync(
|
||||||
command,
|
command,
|
||||||
command.Pattern.Agents[0],
|
command.Workflow.GetAgentNodes()[0],
|
||||||
new UserInputRequest
|
new UserInputRequest
|
||||||
{
|
{
|
||||||
Question = "How should I proceed?",
|
Question = "How should I proceed?",
|
||||||
@@ -76,33 +76,40 @@ public sealed class CopilotUserInputCoordinatorTests
|
|||||||
Assert.Contains("is not pending", error.Message);
|
Assert.Contains("is not pending", error.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PatternAgentDefinitionDto CreateAgent(string id, string name)
|
|
||||||
{
|
|
||||||
return new PatternAgentDefinitionDto
|
|
||||||
{
|
|
||||||
Id = id,
|
|
||||||
Name = name,
|
|
||||||
Model = "gpt-5.4",
|
|
||||||
Instructions = "Help with the request.",
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static RunTurnCommandDto CreateUserInputCommand()
|
private static RunTurnCommandDto CreateUserInputCommand()
|
||||||
{
|
{
|
||||||
return new RunTurnCommandDto
|
return new RunTurnCommandDto
|
||||||
{
|
{
|
||||||
RequestId = "turn-1",
|
RequestId = "turn-1",
|
||||||
SessionId = "session-1",
|
SessionId = "session-1",
|
||||||
Pattern = new PatternDefinitionDto
|
Workflow = new WorkflowDefinitionDto
|
||||||
{
|
{
|
||||||
Id = "pattern-1",
|
Id = "workflow-1",
|
||||||
Name = "User Input Pattern",
|
Name = "User Input Workflow",
|
||||||
Mode = "single",
|
Graph = new WorkflowGraphDto
|
||||||
Availability = "available",
|
{
|
||||||
Agents =
|
Nodes =
|
||||||
[
|
[
|
||||||
CreateAgent("agent-1", "Primary"),
|
new WorkflowNodeDto
|
||||||
],
|
{
|
||||||
|
Id = "agent-1",
|
||||||
|
Kind = "agent",
|
||||||
|
Label = "Primary",
|
||||||
|
Config = new WorkflowNodeConfigDto
|
||||||
|
{
|
||||||
|
Kind = "agent",
|
||||||
|
Id = "agent-1",
|
||||||
|
Name = "Primary",
|
||||||
|
Model = "gpt-5.4",
|
||||||
|
Instructions = "Help with the request.",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
|
Settings = new WorkflowSettingsDto
|
||||||
|
{
|
||||||
|
OrchestrationMode = "single",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,23 +95,10 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ProjectCompletedMessages_FallsBackToStreamingSegmentsWhenWorkflowOutputIsMissing()
|
public void ProjectCompletedMessages_FallsBackToStreamingSegmentsWhenWorkflowOutputIsMissing()
|
||||||
{
|
{
|
||||||
RunTurnCommandDto command = new()
|
RunTurnCommandDto command = CreateCommand(
|
||||||
{
|
"concurrent",
|
||||||
RequestId = "turn-1",
|
CreateAgent(id: "agent-concurrent-architect", name: "Architect"),
|
||||||
SessionId = "session-1",
|
CreateAgent(id: "agent-concurrent-implementer", name: "Implementer"));
|
||||||
Pattern = new PatternDefinitionDto
|
|
||||||
{
|
|
||||||
Id = "pattern-concurrent",
|
|
||||||
Name = "Concurrent Brainstorm",
|
|
||||||
Mode = "concurrent",
|
|
||||||
Availability = "available",
|
|
||||||
Agents =
|
|
||||||
[
|
|
||||||
CreateAgent(id: "agent-concurrent-architect", name: "Architect"),
|
|
||||||
CreateAgent(id: "agent-concurrent-implementer", name: "Implementer"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
||||||
command,
|
command,
|
||||||
@@ -140,22 +127,9 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ProjectCompletedMessages_CanonicalizesWorkflowOutputAuthorNames()
|
public void ProjectCompletedMessages_CanonicalizesWorkflowOutputAuthorNames()
|
||||||
{
|
{
|
||||||
RunTurnCommandDto command = new()
|
RunTurnCommandDto command = CreateCommand(
|
||||||
{
|
"single",
|
||||||
RequestId = "turn-1",
|
CreateAgent(id: "agent-single-primary", name: "Primary Agent"));
|
||||||
SessionId = "session-1",
|
|
||||||
Pattern = new PatternDefinitionDto
|
|
||||||
{
|
|
||||||
Id = "pattern-single",
|
|
||||||
Name = "Single Agent",
|
|
||||||
Mode = "single",
|
|
||||||
Availability = "available",
|
|
||||||
Agents =
|
|
||||||
[
|
|
||||||
CreateAgent(id: "agent-single-primary", name: "Primary Agent"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
||||||
command,
|
command,
|
||||||
@@ -178,22 +152,9 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ProjectCompletedMessages_UsesFinalAssistantPayloadWhenStreamingTextIsMissing()
|
public void ProjectCompletedMessages_UsesFinalAssistantPayloadWhenStreamingTextIsMissing()
|
||||||
{
|
{
|
||||||
RunTurnCommandDto command = new()
|
RunTurnCommandDto command = CreateCommand(
|
||||||
{
|
"single",
|
||||||
RequestId = "turn-1",
|
CreateAgent(id: "agent-single-primary", name: "Primary Agent"));
|
||||||
SessionId = "session-1",
|
|
||||||
Pattern = new PatternDefinitionDto
|
|
||||||
{
|
|
||||||
Id = "pattern-single",
|
|
||||||
Name = "Single Agent",
|
|
||||||
Mode = "single",
|
|
||||||
Availability = "available",
|
|
||||||
Agents =
|
|
||||||
[
|
|
||||||
CreateAgent(id: "agent-single-primary", name: "Primary Agent"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
||||||
command,
|
command,
|
||||||
@@ -222,24 +183,11 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ProjectCompletedMessages_PreservesSequentialConversationHistory()
|
public void ProjectCompletedMessages_PreservesSequentialConversationHistory()
|
||||||
{
|
{
|
||||||
RunTurnCommandDto command = new()
|
RunTurnCommandDto command = CreateCommand(
|
||||||
{
|
"sequential",
|
||||||
RequestId = "turn-1",
|
CreateAgent(id: "agent-sequential-analyst", name: "Analyst"),
|
||||||
SessionId = "session-1",
|
CreateAgent(id: "agent-sequential-builder", name: "Builder"),
|
||||||
Pattern = new PatternDefinitionDto
|
CreateAgent(id: "agent-sequential-reviewer", name: "Reviewer"));
|
||||||
{
|
|
||||||
Id = "pattern-sequential",
|
|
||||||
Name = "Sequential Trio Review",
|
|
||||||
Mode = "sequential",
|
|
||||||
Availability = "available",
|
|
||||||
Agents =
|
|
||||||
[
|
|
||||||
CreateAgent(id: "agent-sequential-analyst", name: "Analyst"),
|
|
||||||
CreateAgent(id: "agent-sequential-builder", name: "Builder"),
|
|
||||||
CreateAgent(id: "agent-sequential-reviewer", name: "Reviewer"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
||||||
command,
|
command,
|
||||||
@@ -272,24 +220,11 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ProjectCompletedMessages_PreservesConcurrentAggregatedResponses()
|
public void ProjectCompletedMessages_PreservesConcurrentAggregatedResponses()
|
||||||
{
|
{
|
||||||
RunTurnCommandDto command = new()
|
RunTurnCommandDto command = CreateCommand(
|
||||||
{
|
"concurrent",
|
||||||
RequestId = "turn-1",
|
CreateAgent(id: "agent-concurrent-architect", name: "Architect"),
|
||||||
SessionId = "session-1",
|
CreateAgent(id: "agent-concurrent-product", name: "Product"),
|
||||||
Pattern = new PatternDefinitionDto
|
CreateAgent(id: "agent-concurrent-implementer", name: "Implementer"));
|
||||||
{
|
|
||||||
Id = "pattern-concurrent",
|
|
||||||
Name = "Concurrent Brainstorm",
|
|
||||||
Mode = "concurrent",
|
|
||||||
Availability = "available",
|
|
||||||
Agents =
|
|
||||||
[
|
|
||||||
CreateAgent(id: "agent-concurrent-architect", name: "Architect"),
|
|
||||||
CreateAgent(id: "agent-concurrent-product", name: "Product"),
|
|
||||||
CreateAgent(id: "agent-concurrent-implementer", name: "Implementer"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
||||||
command,
|
command,
|
||||||
@@ -310,24 +245,11 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ProjectCompletedMessages_ConcurrentUsesLastStreamedMessagePerAgentForGenericOutput()
|
public void ProjectCompletedMessages_ConcurrentUsesLastStreamedMessagePerAgentForGenericOutput()
|
||||||
{
|
{
|
||||||
RunTurnCommandDto command = new()
|
RunTurnCommandDto command = CreateCommand(
|
||||||
{
|
"concurrent",
|
||||||
RequestId = "turn-1",
|
CreateAgent(id: "agent-concurrent-architect", name: "Architect"),
|
||||||
SessionId = "session-1",
|
CreateAgent(id: "agent-concurrent-product", name: "Product"),
|
||||||
Pattern = new PatternDefinitionDto
|
CreateAgent(id: "agent-concurrent-implementer", name: "Implementer"));
|
||||||
{
|
|
||||||
Id = "pattern-concurrent",
|
|
||||||
Name = "Concurrent Brainstorm",
|
|
||||||
Mode = "concurrent",
|
|
||||||
Availability = "available",
|
|
||||||
Agents =
|
|
||||||
[
|
|
||||||
CreateAgent(id: "agent-concurrent-architect", name: "Architect"),
|
|
||||||
CreateAgent(id: "agent-concurrent-product", name: "Product"),
|
|
||||||
CreateAgent(id: "agent-concurrent-implementer", name: "Implementer"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
||||||
command,
|
command,
|
||||||
@@ -379,23 +301,10 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ProjectCompletedMessages_PreservesGroupChatConversationHistory()
|
public void ProjectCompletedMessages_PreservesGroupChatConversationHistory()
|
||||||
{
|
{
|
||||||
RunTurnCommandDto command = new()
|
RunTurnCommandDto command = CreateCommand(
|
||||||
{
|
"group-chat",
|
||||||
RequestId = "turn-1",
|
CreateAgent(id: "agent-group-writer", name: "Writer"),
|
||||||
SessionId = "session-1",
|
CreateAgent(id: "agent-group-reviewer", name: "Reviewer"));
|
||||||
Pattern = new PatternDefinitionDto
|
|
||||||
{
|
|
||||||
Id = "pattern-group-chat",
|
|
||||||
Name = "Collaborative Group Chat",
|
|
||||||
Mode = "group-chat",
|
|
||||||
Availability = "available",
|
|
||||||
Agents =
|
|
||||||
[
|
|
||||||
CreateAgent(id: "agent-group-writer", name: "Writer"),
|
|
||||||
CreateAgent(id: "agent-group-reviewer", name: "Reviewer"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
||||||
command,
|
command,
|
||||||
@@ -428,23 +337,10 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ProjectCompletedMessages_FallsBackToPositionWhenOutputTextDiffers()
|
public void ProjectCompletedMessages_FallsBackToPositionWhenOutputTextDiffers()
|
||||||
{
|
{
|
||||||
RunTurnCommandDto command = new()
|
RunTurnCommandDto command = CreateCommand(
|
||||||
{
|
"group-chat",
|
||||||
RequestId = "turn-1",
|
CreateAgent(id: "agent-group-writer", name: "Writer"),
|
||||||
SessionId = "session-1",
|
CreateAgent(id: "agent-group-reviewer", name: "Reviewer"));
|
||||||
Pattern = new PatternDefinitionDto
|
|
||||||
{
|
|
||||||
Id = "pattern-group-chat",
|
|
||||||
Name = "Collaborative Group Chat",
|
|
||||||
Mode = "group-chat",
|
|
||||||
Availability = "available",
|
|
||||||
Agents =
|
|
||||||
[
|
|
||||||
CreateAgent(id: "agent-group-writer", name: "Writer"),
|
|
||||||
CreateAgent(id: "agent-group-reviewer", name: "Reviewer"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
||||||
command,
|
command,
|
||||||
@@ -482,23 +378,10 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ProjectCompletedMessages_UsesFallbackAgentForGenericAssistantOutput()
|
public void ProjectCompletedMessages_UsesFallbackAgentForGenericAssistantOutput()
|
||||||
{
|
{
|
||||||
RunTurnCommandDto command = new()
|
RunTurnCommandDto command = CreateCommand(
|
||||||
{
|
"handoff",
|
||||||
RequestId = "turn-1",
|
CreateAgent(id: "agent-handoff-triage", name: "Triage"),
|
||||||
SessionId = "session-1",
|
CreateAgent(id: "agent-handoff-ux", name: "UX Specialist"));
|
||||||
Pattern = new PatternDefinitionDto
|
|
||||||
{
|
|
||||||
Id = "pattern-handoff",
|
|
||||||
Name = "Handoff Support Flow",
|
|
||||||
Mode = "handoff",
|
|
||||||
Availability = "available",
|
|
||||||
Agents =
|
|
||||||
[
|
|
||||||
CreateAgent(id: "agent-handoff-triage", name: "Triage"),
|
|
||||||
CreateAgent(id: "agent-handoff-ux", name: "UX Specialist"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
||||||
command,
|
command,
|
||||||
@@ -519,23 +402,10 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ProjectCompletedMessages_PrefersContentMatchedStreamingSegmentOverPosition()
|
public void ProjectCompletedMessages_PrefersContentMatchedStreamingSegmentOverPosition()
|
||||||
{
|
{
|
||||||
RunTurnCommandDto command = new()
|
RunTurnCommandDto command = CreateCommand(
|
||||||
{
|
"handoff",
|
||||||
RequestId = "turn-1",
|
CreateAgent(id: "agent-handoff-triage", name: "Triage"),
|
||||||
SessionId = "session-1",
|
CreateAgent(id: "agent-handoff-runtime", name: "Runtime Specialist"));
|
||||||
Pattern = new PatternDefinitionDto
|
|
||||||
{
|
|
||||||
Id = "pattern-handoff",
|
|
||||||
Name = "Handoff Support Flow",
|
|
||||||
Mode = "handoff",
|
|
||||||
Availability = "available",
|
|
||||||
Agents =
|
|
||||||
[
|
|
||||||
CreateAgent(id: "agent-handoff-triage", name: "Triage"),
|
|
||||||
CreateAgent(id: "agent-handoff-runtime", name: "Runtime Specialist"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
||||||
command,
|
command,
|
||||||
@@ -560,23 +430,10 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ProjectCompletedMessages_UsesFallbackAgentForSingleGenericAssistantOutputWithMultipleSegments()
|
public void ProjectCompletedMessages_UsesFallbackAgentForSingleGenericAssistantOutputWithMultipleSegments()
|
||||||
{
|
{
|
||||||
RunTurnCommandDto command = new()
|
RunTurnCommandDto command = CreateCommand(
|
||||||
{
|
"handoff",
|
||||||
RequestId = "turn-1",
|
CreateAgent(id: "agent-handoff-triage", name: "Triage"),
|
||||||
SessionId = "session-1",
|
CreateAgent(id: "agent-handoff-runtime", name: "Runtime Specialist"));
|
||||||
Pattern = new PatternDefinitionDto
|
|
||||||
{
|
|
||||||
Id = "pattern-handoff",
|
|
||||||
Name = "Handoff Support Flow",
|
|
||||||
Mode = "handoff",
|
|
||||||
Availability = "available",
|
|
||||||
Agents =
|
|
||||||
[
|
|
||||||
CreateAgent(id: "agent-handoff-triage", name: "Triage"),
|
|
||||||
CreateAgent(id: "agent-handoff-runtime", name: "Runtime Specialist"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
||||||
command,
|
command,
|
||||||
@@ -601,23 +458,10 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void ProjectCompletedMessages_DropsBlankAssistantOutputMessages()
|
public void ProjectCompletedMessages_DropsBlankAssistantOutputMessages()
|
||||||
{
|
{
|
||||||
RunTurnCommandDto command = new()
|
RunTurnCommandDto command = CreateCommand(
|
||||||
{
|
"handoff",
|
||||||
RequestId = "turn-1",
|
CreateAgent(id: "agent-handoff-triage", name: "Triage"),
|
||||||
SessionId = "session-1",
|
CreateAgent(id: "agent-handoff-ux", name: "UX Specialist"));
|
||||||
Pattern = new PatternDefinitionDto
|
|
||||||
{
|
|
||||||
Id = "pattern-handoff",
|
|
||||||
Name = "Handoff Support Flow",
|
|
||||||
Mode = "handoff",
|
|
||||||
Availability = "available",
|
|
||||||
Agents =
|
|
||||||
[
|
|
||||||
CreateAgent(id: "agent-handoff-triage", name: "Triage"),
|
|
||||||
CreateAgent(id: "agent-handoff-ux", name: "UX Specialist"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
IReadOnlyList<ChatMessageDto> messages = WorkflowTranscriptProjector.ProjectCompletedMessages(
|
||||||
command,
|
command,
|
||||||
@@ -835,7 +679,7 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public async Task RunTurnAsync_RequestPortWorkflowUsesUserInputBridge()
|
public async Task RunTurnAsync_RequestPortWorkflowUsesUserInputBridge()
|
||||||
{
|
{
|
||||||
CopilotWorkflowRunner runner = new(new PatternValidator());
|
CopilotWorkflowRunner runner = new(new WorkflowValidator());
|
||||||
List<UserInputRequestedEventDto> requests = [];
|
List<UserInputRequestedEventDto> requests = [];
|
||||||
|
|
||||||
IReadOnlyList<ChatMessageDto> messages = await runner.RunTurnAsync(
|
IReadOnlyList<ChatMessageDto> messages = await runner.RunTurnAsync(
|
||||||
@@ -927,6 +771,25 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
public async Task HandleWorkflowEventAsync_EmitsWorkflowCheckpointSavedEvent()
|
public async Task HandleWorkflowEventAsync_EmitsWorkflowCheckpointSavedEvent()
|
||||||
{
|
{
|
||||||
RunTurnCommandDto command = CreateHandoffCommand();
|
RunTurnCommandDto command = CreateHandoffCommand();
|
||||||
|
command = new RunTurnCommandDto
|
||||||
|
{
|
||||||
|
RequestId = command.RequestId,
|
||||||
|
SessionId = command.SessionId,
|
||||||
|
Workflow = new WorkflowDefinitionDto
|
||||||
|
{
|
||||||
|
Id = command.Workflow.Id,
|
||||||
|
Name = command.Workflow.Name,
|
||||||
|
Graph = command.Workflow.Graph,
|
||||||
|
Settings = new WorkflowSettingsDto
|
||||||
|
{
|
||||||
|
OrchestrationMode = command.Workflow.Settings.OrchestrationMode,
|
||||||
|
Checkpointing = new WorkflowCheckpointSettingsDto
|
||||||
|
{
|
||||||
|
Enabled = true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
CopilotTurnExecutionState state = new(command);
|
CopilotTurnExecutionState state = new(command);
|
||||||
List<WorkflowCheckpointSavedEventDto> checkpoints = [];
|
List<WorkflowCheckpointSavedEventDto> checkpoints = [];
|
||||||
|
|
||||||
@@ -1831,7 +1694,7 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
|
|
||||||
Task<PermissionRequestResult> pending = coordinator.RequestApprovalAsync(
|
Task<PermissionRequestResult> pending = coordinator.RequestApprovalAsync(
|
||||||
command,
|
command,
|
||||||
command.Pattern.Agents[0],
|
command.Workflow.GetAgentNodes()[0],
|
||||||
new PermissionRequestCustomTool
|
new PermissionRequestCustomTool
|
||||||
{
|
{
|
||||||
Kind = "custom tool",
|
Kind = "custom tool",
|
||||||
@@ -1875,7 +1738,7 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
|
|
||||||
Task<PermissionRequestResult> pending = coordinator.RequestApprovalAsync(
|
Task<PermissionRequestResult> pending = coordinator.RequestApprovalAsync(
|
||||||
command,
|
command,
|
||||||
command.Pattern.Agents[0],
|
command.Workflow.GetAgentNodes()[0],
|
||||||
new PermissionRequestWrite
|
new PermissionRequestWrite
|
||||||
{
|
{
|
||||||
Kind = "write",
|
Kind = "write",
|
||||||
@@ -1938,7 +1801,7 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
|
|
||||||
PermissionRequestResult result = await coordinator.RequestApprovalAsync(
|
PermissionRequestResult result = await coordinator.RequestApprovalAsync(
|
||||||
command,
|
command,
|
||||||
command.Pattern.Agents[0],
|
command.Workflow.GetAgentNodes()[0],
|
||||||
new PermissionRequestCustomTool
|
new PermissionRequestCustomTool
|
||||||
{
|
{
|
||||||
Kind = "custom tool",
|
Kind = "custom tool",
|
||||||
@@ -1972,7 +1835,7 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
|
|
||||||
PermissionRequestResult result = await coordinator.RequestApprovalAsync(
|
PermissionRequestResult result = await coordinator.RequestApprovalAsync(
|
||||||
command,
|
command,
|
||||||
command.Pattern.Agents[0],
|
command.Workflow.GetAgentNodes()[0],
|
||||||
new PermissionRequestHook
|
new PermissionRequestHook
|
||||||
{
|
{
|
||||||
Kind = "hook",
|
Kind = "hook",
|
||||||
@@ -2007,7 +1870,7 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
|
|
||||||
Task<PermissionRequestResult> firstPending = coordinator.RequestApprovalAsync(
|
Task<PermissionRequestResult> firstPending = coordinator.RequestApprovalAsync(
|
||||||
command,
|
command,
|
||||||
command.Pattern.Agents[0],
|
command.Workflow.GetAgentNodes()[0],
|
||||||
new PermissionRequestRead
|
new PermissionRequestRead
|
||||||
{
|
{
|
||||||
Kind = "read",
|
Kind = "read",
|
||||||
@@ -2048,7 +1911,7 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
bool sawSecondApproval = false;
|
bool sawSecondApproval = false;
|
||||||
PermissionRequestResult secondResult = await coordinator.RequestApprovalAsync(
|
PermissionRequestResult secondResult = await coordinator.RequestApprovalAsync(
|
||||||
command,
|
command,
|
||||||
command.Pattern.Agents[0],
|
command.Workflow.GetAgentNodes()[0],
|
||||||
new PermissionRequestRead
|
new PermissionRequestRead
|
||||||
{
|
{
|
||||||
Kind = "read",
|
Kind = "read",
|
||||||
@@ -2084,7 +1947,7 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
|
|
||||||
Task<PermissionRequestResult> firstPending = coordinator.RequestApprovalAsync(
|
Task<PermissionRequestResult> firstPending = coordinator.RequestApprovalAsync(
|
||||||
firstCommand,
|
firstCommand,
|
||||||
firstCommand.Pattern.Agents[0],
|
firstCommand.Workflow.GetAgentNodes()[0],
|
||||||
new PermissionRequestRead
|
new PermissionRequestRead
|
||||||
{
|
{
|
||||||
Kind = "read",
|
Kind = "read",
|
||||||
@@ -2124,7 +1987,7 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
RunTurnCommandDto secondCommand = CreateApprovalCommand(requestId: "turn-2");
|
RunTurnCommandDto secondCommand = CreateApprovalCommand(requestId: "turn-2");
|
||||||
Task<PermissionRequestResult> secondPending = coordinator.RequestApprovalAsync(
|
Task<PermissionRequestResult> secondPending = coordinator.RequestApprovalAsync(
|
||||||
secondCommand,
|
secondCommand,
|
||||||
secondCommand.Pattern.Agents[0],
|
secondCommand.Workflow.GetAgentNodes()[0],
|
||||||
new PermissionRequestRead
|
new PermissionRequestRead
|
||||||
{
|
{
|
||||||
Kind = "read",
|
Kind = "read",
|
||||||
@@ -2179,38 +2042,56 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
Assert.Contains("is not pending", error.Message);
|
Assert.Contains("is not pending", error.Message);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PatternAgentDefinitionDto CreateAgent(string id, string name)
|
private static WorkflowNodeDto CreateAgent(string id, string name)
|
||||||
{
|
{
|
||||||
return new PatternAgentDefinitionDto
|
return new WorkflowNodeDto
|
||||||
{
|
{
|
||||||
Id = id,
|
Id = id,
|
||||||
Name = name,
|
Kind = "agent",
|
||||||
Model = "gpt-5.4",
|
Label = name,
|
||||||
Instructions = "Help with the request.",
|
Config = new WorkflowNodeConfigDto
|
||||||
|
{
|
||||||
|
Kind = "agent",
|
||||||
|
Id = id,
|
||||||
|
Name = name,
|
||||||
|
Model = "gpt-5.4",
|
||||||
|
Instructions = "Help with the request.",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static RunTurnCommandDto CreateHandoffCommand()
|
private static RunTurnCommandDto CreateCommand(
|
||||||
|
string orchestrationMode,
|
||||||
|
params WorkflowNodeDto[] agents)
|
||||||
{
|
{
|
||||||
return new RunTurnCommandDto
|
return new RunTurnCommandDto
|
||||||
{
|
{
|
||||||
RequestId = "turn-1",
|
RequestId = "turn-1",
|
||||||
SessionId = "session-1",
|
SessionId = "session-1",
|
||||||
Pattern = new PatternDefinitionDto
|
Workflow = new WorkflowDefinitionDto
|
||||||
{
|
{
|
||||||
Id = "pattern-handoff",
|
Id = $"workflow-{orchestrationMode}",
|
||||||
Name = "Handoff Flow",
|
Name = $"Workflow {orchestrationMode}",
|
||||||
Mode = "handoff",
|
Graph = new WorkflowGraphDto
|
||||||
Availability = "available",
|
{
|
||||||
Agents =
|
Nodes = [.. agents],
|
||||||
[
|
},
|
||||||
CreateAgent("agent-handoff-triage", "Triage"),
|
Settings = new WorkflowSettingsDto
|
||||||
CreateAgent("agent-handoff-ux", "UX Specialist"),
|
{
|
||||||
],
|
OrchestrationMode = orchestrationMode,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static RunTurnCommandDto CreateHandoffCommand()
|
||||||
|
{
|
||||||
|
return CreateCommand(
|
||||||
|
"handoff",
|
||||||
|
CreateAgent("agent-handoff-triage", "Triage"),
|
||||||
|
CreateAgent("agent-handoff-ux", "UX Specialist"));
|
||||||
|
}
|
||||||
|
|
||||||
private static RequestInfoEvent CreateRequestInfoEvent(object payload)
|
private static RequestInfoEvent CreateRequestInfoEvent(object payload)
|
||||||
{
|
{
|
||||||
RequestPort port = RequestPort.Create<object, object>("test-port");
|
RequestPort port = RequestPort.Create<object, object>("test-port");
|
||||||
@@ -2253,30 +2134,35 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
{
|
{
|
||||||
McpServers = [.. mcpServers],
|
McpServers = [.. mcpServers],
|
||||||
},
|
},
|
||||||
Pattern = new PatternDefinitionDto
|
Workflow = new WorkflowDefinitionDto
|
||||||
{
|
{
|
||||||
Id = "pattern-1",
|
Id = "workflow-1",
|
||||||
Name = "Approval Pattern",
|
Name = "Approval Workflow",
|
||||||
Mode = "single",
|
Graph = new WorkflowGraphDto
|
||||||
Availability = "available",
|
|
||||||
ApprovalPolicy = new ApprovalPolicyDto
|
|
||||||
{
|
{
|
||||||
Rules =
|
Nodes =
|
||||||
[
|
[
|
||||||
new ApprovalCheckpointRuleDto
|
CreateAgent("agent-1", "Primary"),
|
||||||
{
|
|
||||||
Kind = "tool-call",
|
|
||||||
AgentIds = ["agent-1"],
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
AutoApprovedToolNames = autoApprovedToolNames is null
|
|
||||||
? ["web_fetch"]
|
|
||||||
: [.. autoApprovedToolNames],
|
|
||||||
},
|
},
|
||||||
Agents =
|
Settings = new WorkflowSettingsDto
|
||||||
[
|
{
|
||||||
CreateAgent("agent-1", "Primary"),
|
OrchestrationMode = "single",
|
||||||
],
|
ApprovalPolicy = new ApprovalPolicyDto
|
||||||
|
{
|
||||||
|
Rules =
|
||||||
|
[
|
||||||
|
new ApprovalCheckpointRuleDto
|
||||||
|
{
|
||||||
|
Kind = "tool-call",
|
||||||
|
AgentIds = ["agent-1"],
|
||||||
|
},
|
||||||
|
],
|
||||||
|
AutoApprovedToolNames = autoApprovedToolNames is null
|
||||||
|
? ["web_fetch"]
|
||||||
|
: [.. autoApprovedToolNames],
|
||||||
|
},
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -2288,14 +2174,6 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
RequestId = "turn-request-port",
|
RequestId = "turn-request-port",
|
||||||
SessionId = "session-request-port",
|
SessionId = "session-request-port",
|
||||||
ProjectPath = "c:\\workspace\\personal\\projects\\aryx.worktrees\\copilot-powerful-vulture",
|
ProjectPath = "c:\\workspace\\personal\\projects\\aryx.worktrees\\copilot-powerful-vulture",
|
||||||
Pattern = new PatternDefinitionDto
|
|
||||||
{
|
|
||||||
Id = "pattern-request-port",
|
|
||||||
Name = "Request Port Pattern",
|
|
||||||
Mode = "single",
|
|
||||||
Availability = "available",
|
|
||||||
Agents = [],
|
|
||||||
},
|
|
||||||
Messages =
|
Messages =
|
||||||
[
|
[
|
||||||
new ChatMessageDto
|
new ChatMessageDto
|
||||||
@@ -2407,3 +2285,4 @@ public sealed class CopilotWorkflowRunnerTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -23,13 +23,20 @@ public sealed class HandoffWorkflowGuidanceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void CreateForwardReason_UsesTargetSpecialtyAndOwnership()
|
public void CreateForwardReason_UsesTargetSpecialtyAndOwnership()
|
||||||
{
|
{
|
||||||
PatternAgentDefinitionDto specialist = new()
|
WorkflowNodeDto specialist = new()
|
||||||
{
|
{
|
||||||
Id = "agent-handoff-ux",
|
Id = "agent-handoff-ux",
|
||||||
Name = "UX Specialist",
|
Kind = "agent",
|
||||||
Description = "Handles user experience questions.",
|
Label = "UX Specialist",
|
||||||
Instructions = "Focus on UX.",
|
Config = new WorkflowNodeConfigDto
|
||||||
Model = "claude-opus-4.5",
|
{
|
||||||
|
Kind = "agent",
|
||||||
|
Id = "agent-handoff-ux",
|
||||||
|
Name = "UX Specialist",
|
||||||
|
Description = "Handles user experience questions.",
|
||||||
|
Instructions = "Focus on UX.",
|
||||||
|
Model = "claude-opus-4.5",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
string reason = HandoffWorkflowGuidance.CreateForwardReason(specialist);
|
string reason = HandoffWorkflowGuidance.CreateForwardReason(specialist);
|
||||||
@@ -42,13 +49,20 @@ public sealed class HandoffWorkflowGuidanceTests
|
|||||||
[Fact]
|
[Fact]
|
||||||
public void CreateReturnReason_RestrictsReturnToReroutingCases()
|
public void CreateReturnReason_RestrictsReturnToReroutingCases()
|
||||||
{
|
{
|
||||||
PatternAgentDefinitionDto triage = new()
|
WorkflowNodeDto triage = new()
|
||||||
{
|
{
|
||||||
Id = "agent-handoff-triage",
|
Id = "agent-handoff-triage",
|
||||||
Name = "Triage",
|
Kind = "agent",
|
||||||
Description = "Routes the request to the right specialist.",
|
Label = "Triage",
|
||||||
Instructions = "Triages requests.",
|
Config = new WorkflowNodeConfigDto
|
||||||
Model = "gpt-5.4",
|
{
|
||||||
|
Kind = "agent",
|
||||||
|
Id = "agent-handoff-triage",
|
||||||
|
Name = "Triage",
|
||||||
|
Description = "Routes the request to the right specialist.",
|
||||||
|
Instructions = "Triages requests.",
|
||||||
|
Model = "gpt-5.4",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
string reason = HandoffWorkflowGuidance.CreateReturnReason(triage);
|
string reason = HandoffWorkflowGuidance.CreateReturnReason(triage);
|
||||||
|
|||||||
@@ -1,127 +0,0 @@
|
|||||||
using Aryx.AgentHost.Contracts;
|
|
||||||
using Aryx.AgentHost.Services;
|
|
||||||
|
|
||||||
namespace Aryx.AgentHost.Tests;
|
|
||||||
|
|
||||||
public sealed class PatternGraphResolverTests
|
|
||||||
{
|
|
||||||
[Fact]
|
|
||||||
public void ResolveOrderedAgentIds_UsesSequentialGraphPath()
|
|
||||||
{
|
|
||||||
PatternDefinitionDto pattern = CreatePattern(
|
|
||||||
"sequential",
|
|
||||||
[
|
|
||||||
CreateAgent("agent-1", "Analyst"),
|
|
||||||
CreateAgent("agent-2", "Builder"),
|
|
||||||
CreateAgent("agent-3", "Reviewer"),
|
|
||||||
],
|
|
||||||
new PatternGraphDto
|
|
||||||
{
|
|
||||||
Nodes =
|
|
||||||
[
|
|
||||||
CreateSystemNode("system-user-input", "user-input"),
|
|
||||||
CreateAgentNode("agent-1", 0),
|
|
||||||
CreateAgentNode("agent-2", 1),
|
|
||||||
CreateAgentNode("agent-3", 2),
|
|
||||||
CreateSystemNode("system-user-output", "user-output"),
|
|
||||||
],
|
|
||||||
Edges =
|
|
||||||
[
|
|
||||||
CreateEdge("system-user-input", "agent-node-agent-3"),
|
|
||||||
CreateEdge("agent-node-agent-3", "agent-node-agent-1"),
|
|
||||||
CreateEdge("agent-node-agent-1", "agent-node-agent-2"),
|
|
||||||
CreateEdge("agent-node-agent-2", "system-user-output"),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
IReadOnlyList<string> orderedAgentIds = PatternGraphResolver.ResolveOrderedAgentIds(pattern);
|
|
||||||
|
|
||||||
Assert.Equal(["agent-3", "agent-1", "agent-2"], orderedAgentIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void ResolveHandoff_UsesExplicitEntryAndRoutes()
|
|
||||||
{
|
|
||||||
PatternDefinitionDto pattern = CreatePattern(
|
|
||||||
"handoff",
|
|
||||||
[
|
|
||||||
CreateAgent("agent-1", "Triage"),
|
|
||||||
CreateAgent("agent-2", "UX"),
|
|
||||||
CreateAgent("agent-3", "Runtime"),
|
|
||||||
],
|
|
||||||
new PatternGraphDto
|
|
||||||
{
|
|
||||||
Nodes =
|
|
||||||
[
|
|
||||||
CreateSystemNode("system-user-input", "user-input"),
|
|
||||||
CreateSystemNode("system-user-output", "user-output"),
|
|
||||||
CreateAgentNode("agent-1", 0),
|
|
||||||
CreateAgentNode("agent-2", 1),
|
|
||||||
CreateAgentNode("agent-3", 2),
|
|
||||||
],
|
|
||||||
Edges =
|
|
||||||
[
|
|
||||||
CreateEdge("system-user-input", "agent-node-agent-3"),
|
|
||||||
CreateEdge("agent-node-agent-3", "agent-node-agent-2"),
|
|
||||||
CreateEdge("agent-node-agent-2", "agent-node-agent-1"),
|
|
||||||
CreateEdge("agent-node-agent-2", "system-user-output"),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
PatternHandoffTopology topology = PatternGraphResolver.ResolveHandoff(pattern);
|
|
||||||
|
|
||||||
Assert.Equal("agent-3", topology.EntryAgentId);
|
|
||||||
Assert.Contains(new PatternHandoffRoute("agent-3", "agent-2"), topology.Routes);
|
|
||||||
Assert.Contains(new PatternHandoffRoute("agent-2", "agent-1"), topology.Routes);
|
|
||||||
Assert.DoesNotContain(new PatternHandoffRoute("agent-1", "agent-2"), topology.Routes);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static PatternDefinitionDto CreatePattern(
|
|
||||||
string mode,
|
|
||||||
IReadOnlyList<PatternAgentDefinitionDto> agents,
|
|
||||||
PatternGraphDto graph)
|
|
||||||
=> new()
|
|
||||||
{
|
|
||||||
Id = $"{mode}-pattern",
|
|
||||||
Name = "Pattern",
|
|
||||||
Mode = mode,
|
|
||||||
Availability = "available",
|
|
||||||
Agents = agents,
|
|
||||||
Graph = graph,
|
|
||||||
};
|
|
||||||
|
|
||||||
private static PatternAgentDefinitionDto CreateAgent(string id, string name)
|
|
||||||
=> new()
|
|
||||||
{
|
|
||||||
Id = id,
|
|
||||||
Name = name,
|
|
||||||
Model = "gpt-5.4",
|
|
||||||
Instructions = "Help with the user's request.",
|
|
||||||
};
|
|
||||||
|
|
||||||
private static PatternGraphNodeDto CreateSystemNode(string id, string kind)
|
|
||||||
=> new()
|
|
||||||
{
|
|
||||||
Id = id,
|
|
||||||
Kind = kind,
|
|
||||||
Position = new PatternGraphPositionDto(),
|
|
||||||
};
|
|
||||||
|
|
||||||
private static PatternGraphNodeDto CreateAgentNode(string agentId, int order)
|
|
||||||
=> new()
|
|
||||||
{
|
|
||||||
Id = $"agent-node-{agentId}",
|
|
||||||
Kind = "agent",
|
|
||||||
AgentId = agentId,
|
|
||||||
Order = order,
|
|
||||||
Position = new PatternGraphPositionDto(),
|
|
||||||
};
|
|
||||||
|
|
||||||
private static PatternGraphEdgeDto CreateEdge(string source, string target)
|
|
||||||
=> new()
|
|
||||||
{
|
|
||||||
Id = $"edge-{source}-to-{target}",
|
|
||||||
Source = source,
|
|
||||||
Target = target,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -62,52 +62,6 @@ public sealed class SidecarProtocolHostTests
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public async Task ValidatePatternCommand_ReturnsIssuesAndCompletion()
|
|
||||||
{
|
|
||||||
IReadOnlyList<JsonElement> events = await RunHostAsync(new ValidatePatternCommandDto
|
|
||||||
{
|
|
||||||
Type = "validate-pattern",
|
|
||||||
RequestId = "validate-1",
|
|
||||||
Pattern = new PatternDefinitionDto
|
|
||||||
{
|
|
||||||
Id = "single-pattern",
|
|
||||||
Name = "",
|
|
||||||
Mode = "single",
|
|
||||||
Availability = "available",
|
|
||||||
Agents =
|
|
||||||
[
|
|
||||||
CreateAgent(),
|
|
||||||
CreateAgent(id: "agent-2", name: "Reviewer", model: ""),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
Assert.Collection(
|
|
||||||
events,
|
|
||||||
validationEvent =>
|
|
||||||
{
|
|
||||||
Assert.Equal("pattern-validation", validationEvent.GetProperty("type").GetString());
|
|
||||||
Assert.Equal("validate-1", validationEvent.GetProperty("requestId").GetString());
|
|
||||||
|
|
||||||
JsonElement[] issues = validationEvent.GetProperty("issues").EnumerateArray().ToArray();
|
|
||||||
Assert.Contains(issues, issue =>
|
|
||||||
issue.GetProperty("field").GetString() == "name"
|
|
||||||
&& issue.GetProperty("message").GetString() == "Pattern name is required.");
|
|
||||||
Assert.Contains(issues, issue =>
|
|
||||||
issue.GetProperty("field").GetString() == "agents"
|
|
||||||
&& issue.GetProperty("message").GetString() == "Single-agent chat requires exactly one agent.");
|
|
||||||
Assert.Contains(issues, issue =>
|
|
||||||
issue.GetProperty("field").GetString() == "agents.model"
|
|
||||||
&& issue.GetProperty("message").GetString() == "Agent \"Reviewer\" requires a model identifier.");
|
|
||||||
},
|
|
||||||
completionEvent =>
|
|
||||||
{
|
|
||||||
Assert.Equal("command-complete", completionEvent.GetProperty("type").GetString());
|
|
||||||
Assert.Equal("validate-1", completionEvent.GetProperty("requestId").GetString());
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task ValidateWorkflowCommand_ReturnsIssuesAndCompletion()
|
public async Task ValidateWorkflowCommand_ReturnsIssuesAndCompletion()
|
||||||
{
|
{
|
||||||
@@ -182,7 +136,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
public async Task RunTurnCommand_ReturnsActivityEventsAndCompletion()
|
public async Task RunTurnCommand_ReturnsActivityEventsAndCompletion()
|
||||||
{
|
{
|
||||||
SidecarProtocolHost host = new(
|
SidecarProtocolHost host = new(
|
||||||
new PatternValidator(),
|
new WorkflowValidator(),
|
||||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
|
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
|
||||||
{
|
{
|
||||||
await onActivity(new AgentActivityEventDto
|
await onActivity(new AgentActivityEventDto
|
||||||
@@ -230,37 +184,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
];
|
];
|
||||||
}));
|
}));
|
||||||
|
|
||||||
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
IReadOnlyList<JsonElement> events = await RunHostAsync(CreateRunTurnCommand(), host);
|
||||||
new RunTurnCommandDto
|
|
||||||
{
|
|
||||||
Type = "run-turn",
|
|
||||||
RequestId = "turn-1",
|
|
||||||
SessionId = "session-1",
|
|
||||||
ProjectPath = "C:\\workspace\\project",
|
|
||||||
Pattern = new PatternDefinitionDto
|
|
||||||
{
|
|
||||||
Id = "pattern-1",
|
|
||||||
Name = "Single Agent",
|
|
||||||
Mode = "single",
|
|
||||||
Availability = "available",
|
|
||||||
Agents =
|
|
||||||
[
|
|
||||||
CreateAgent(name: "Primary"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
Messages =
|
|
||||||
[
|
|
||||||
new ChatMessageDto
|
|
||||||
{
|
|
||||||
Id = "user-1",
|
|
||||||
Role = "user",
|
|
||||||
AuthorName = "You",
|
|
||||||
Content = "Hello",
|
|
||||||
CreatedAt = "2026-01-01T00:00:00.0000000Z",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
},
|
|
||||||
host);
|
|
||||||
|
|
||||||
Assert.Collection(
|
Assert.Collection(
|
||||||
events,
|
events,
|
||||||
@@ -306,7 +230,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
public async Task RunTurnCommand_ReturnsWorkflowDiagnosticEventsAndCompletion()
|
public async Task RunTurnCommand_ReturnsWorkflowDiagnosticEventsAndCompletion()
|
||||||
{
|
{
|
||||||
SidecarProtocolHost host = new(
|
SidecarProtocolHost host = new(
|
||||||
new PatternValidator(),
|
new WorkflowValidator(),
|
||||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
|
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
|
||||||
{
|
{
|
||||||
await onActivity(new WorkflowDiagnosticEventDto
|
await onActivity(new WorkflowDiagnosticEventDto
|
||||||
@@ -327,25 +251,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
||||||
new RunTurnCommandDto
|
CreateRunTurnCommand(requestId: "turn-diagnostic", messages: []),
|
||||||
{
|
|
||||||
Type = "run-turn",
|
|
||||||
RequestId = "turn-diagnostic",
|
|
||||||
SessionId = "session-1",
|
|
||||||
ProjectPath = "C:\\workspace\\project",
|
|
||||||
Pattern = new PatternDefinitionDto
|
|
||||||
{
|
|
||||||
Id = "pattern-1",
|
|
||||||
Name = "Single Agent",
|
|
||||||
Mode = "single",
|
|
||||||
Availability = "available",
|
|
||||||
Agents =
|
|
||||||
[
|
|
||||||
CreateAgent(name: "Primary"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
Messages = [],
|
|
||||||
},
|
|
||||||
host);
|
host);
|
||||||
|
|
||||||
Assert.Collection(
|
Assert.Collection(
|
||||||
@@ -378,7 +284,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
{
|
{
|
||||||
string? capturedMode = null;
|
string? capturedMode = null;
|
||||||
SidecarProtocolHost host = new(
|
SidecarProtocolHost host = new(
|
||||||
new PatternValidator(),
|
new WorkflowValidator(),
|
||||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
|
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
|
||||||
{
|
{
|
||||||
capturedMode = command.Mode;
|
capturedMode = command.Mode;
|
||||||
@@ -386,25 +292,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
await RunHostAsync(
|
await RunHostAsync(
|
||||||
new RunTurnCommandDto
|
CreateRunTurnCommand(requestId: "turn-plan", interactionMode: "plan"),
|
||||||
{
|
|
||||||
Type = "run-turn",
|
|
||||||
RequestId = "turn-plan",
|
|
||||||
SessionId = "session-1",
|
|
||||||
ProjectPath = "C:\\workspace\\project",
|
|
||||||
Mode = "plan",
|
|
||||||
Pattern = new PatternDefinitionDto
|
|
||||||
{
|
|
||||||
Id = "pattern-1",
|
|
||||||
Name = "Single Agent",
|
|
||||||
Mode = "single",
|
|
||||||
Availability = "available",
|
|
||||||
Agents =
|
|
||||||
[
|
|
||||||
CreateAgent(name: "Primary"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
host);
|
host);
|
||||||
|
|
||||||
Assert.Equal("plan", capturedMode);
|
Assert.Equal("plan", capturedMode);
|
||||||
@@ -414,7 +302,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
public async Task RunTurnCommand_ReturnsApprovalEvents()
|
public async Task RunTurnCommand_ReturnsApprovalEvents()
|
||||||
{
|
{
|
||||||
SidecarProtocolHost host = new(
|
SidecarProtocolHost host = new(
|
||||||
new PatternValidator(),
|
new WorkflowValidator(),
|
||||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
|
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
|
||||||
{
|
{
|
||||||
await onApproval(new ApprovalRequestedEventDto
|
await onApproval(new ApprovalRequestedEventDto
|
||||||
@@ -441,24 +329,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
||||||
new RunTurnCommandDto
|
CreateRunTurnCommand(requestId: "turn-approval"),
|
||||||
{
|
|
||||||
Type = "run-turn",
|
|
||||||
RequestId = "turn-approval",
|
|
||||||
SessionId = "session-1",
|
|
||||||
ProjectPath = "C:\\workspace\\project",
|
|
||||||
Pattern = new PatternDefinitionDto
|
|
||||||
{
|
|
||||||
Id = "pattern-1",
|
|
||||||
Name = "Single Agent",
|
|
||||||
Mode = "single",
|
|
||||||
Availability = "available",
|
|
||||||
Agents =
|
|
||||||
[
|
|
||||||
CreateAgent(name: "Primary"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
host);
|
host);
|
||||||
|
|
||||||
Assert.Collection(
|
Assert.Collection(
|
||||||
@@ -492,7 +363,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
public async Task RunTurnCommand_ReturnsUserInputEvents()
|
public async Task RunTurnCommand_ReturnsUserInputEvents()
|
||||||
{
|
{
|
||||||
SidecarProtocolHost host = new(
|
SidecarProtocolHost host = new(
|
||||||
new PatternValidator(),
|
new WorkflowValidator(),
|
||||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
|
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
|
||||||
{
|
{
|
||||||
await onUserInput(new UserInputRequestedEventDto
|
await onUserInput(new UserInputRequestedEventDto
|
||||||
@@ -512,24 +383,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
||||||
new RunTurnCommandDto
|
CreateRunTurnCommand(requestId: "turn-user-input"),
|
||||||
{
|
|
||||||
Type = "run-turn",
|
|
||||||
RequestId = "turn-user-input",
|
|
||||||
SessionId = "session-1",
|
|
||||||
ProjectPath = "C:\\workspace\\project",
|
|
||||||
Pattern = new PatternDefinitionDto
|
|
||||||
{
|
|
||||||
Id = "pattern-1",
|
|
||||||
Name = "Single Agent",
|
|
||||||
Mode = "single",
|
|
||||||
Availability = "available",
|
|
||||||
Agents =
|
|
||||||
[
|
|
||||||
CreateAgent(name: "Primary"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
host);
|
host);
|
||||||
|
|
||||||
Assert.Collection(
|
Assert.Collection(
|
||||||
@@ -565,7 +419,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
public async Task RunTurnCommand_ReturnsMcpOauthRequiredEvents()
|
public async Task RunTurnCommand_ReturnsMcpOauthRequiredEvents()
|
||||||
{
|
{
|
||||||
SidecarProtocolHost host = new(
|
SidecarProtocolHost host = new(
|
||||||
new PatternValidator(),
|
new WorkflowValidator(),
|
||||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
|
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
|
||||||
{
|
{
|
||||||
await onMcpOAuthRequired(new McpOauthRequiredEventDto
|
await onMcpOAuthRequired(new McpOauthRequiredEventDto
|
||||||
@@ -623,7 +477,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
public async Task RunTurnCommand_ReturnsExitPlanModeEvents()
|
public async Task RunTurnCommand_ReturnsExitPlanModeEvents()
|
||||||
{
|
{
|
||||||
SidecarProtocolHost host = new(
|
SidecarProtocolHost host = new(
|
||||||
new PatternValidator(),
|
new WorkflowValidator(),
|
||||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
|
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
|
||||||
{
|
{
|
||||||
await onExitPlanMode(new ExitPlanModeRequestedEventDto
|
await onExitPlanMode(new ExitPlanModeRequestedEventDto
|
||||||
@@ -644,25 +498,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
||||||
new RunTurnCommandDto
|
CreateRunTurnCommand(requestId: "turn-plan-mode", interactionMode: "plan"),
|
||||||
{
|
|
||||||
Type = "run-turn",
|
|
||||||
RequestId = "turn-plan-mode",
|
|
||||||
SessionId = "session-1",
|
|
||||||
ProjectPath = "C:\\workspace\\project",
|
|
||||||
Mode = "plan",
|
|
||||||
Pattern = new PatternDefinitionDto
|
|
||||||
{
|
|
||||||
Id = "pattern-1",
|
|
||||||
Name = "Single Agent",
|
|
||||||
Mode = "single",
|
|
||||||
Availability = "available",
|
|
||||||
Agents =
|
|
||||||
[
|
|
||||||
CreateAgent(name: "Primary"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
host);
|
host);
|
||||||
|
|
||||||
Assert.Collection(
|
Assert.Collection(
|
||||||
@@ -698,7 +534,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
public async Task CancelTurnCommand_CancelsInProgressTurnAndCompletesBothCommands()
|
public async Task CancelTurnCommand_CancelsInProgressTurnAndCompletesBothCommands()
|
||||||
{
|
{
|
||||||
SidecarProtocolHost host = new(
|
SidecarProtocolHost host = new(
|
||||||
new PatternValidator(),
|
new WorkflowValidator(),
|
||||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
|
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) =>
|
||||||
{
|
{
|
||||||
await Task.Delay(Timeout.Infinite, cancellationToken);
|
await Task.Delay(Timeout.Infinite, cancellationToken);
|
||||||
@@ -746,7 +582,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
public async Task CancelTurnCommand_AfterTurnCompletion_IsNoOp()
|
public async Task CancelTurnCommand_AfterTurnCompletion_IsNoOp()
|
||||||
{
|
{
|
||||||
SidecarProtocolHost host = new(
|
SidecarProtocolHost host = new(
|
||||||
new PatternValidator(),
|
new WorkflowValidator(),
|
||||||
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) => []));
|
new FakeWorkflowRunner(async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) => []));
|
||||||
|
|
||||||
await RunHostAsync(CreateRunTurnCommand(requestId: "turn-completed"), host);
|
await RunHostAsync(CreateRunTurnCommand(requestId: "turn-completed"), host);
|
||||||
@@ -768,7 +604,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
{
|
{
|
||||||
ResolveApprovalCommandDto? captured = null;
|
ResolveApprovalCommandDto? captured = null;
|
||||||
SidecarProtocolHost host = new(
|
SidecarProtocolHost host = new(
|
||||||
new PatternValidator(),
|
new WorkflowValidator(),
|
||||||
new FakeWorkflowRunner(
|
new FakeWorkflowRunner(
|
||||||
handler: async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) => [],
|
handler: async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) => [],
|
||||||
resolveApprovalHandler: (command, cancellationToken) =>
|
resolveApprovalHandler: (command, cancellationToken) =>
|
||||||
@@ -801,7 +637,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
{
|
{
|
||||||
ResolveUserInputCommandDto? captured = null;
|
ResolveUserInputCommandDto? captured = null;
|
||||||
SidecarProtocolHost host = new(
|
SidecarProtocolHost host = new(
|
||||||
new PatternValidator(),
|
new WorkflowValidator(),
|
||||||
new FakeWorkflowRunner(
|
new FakeWorkflowRunner(
|
||||||
handler: async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) => [],
|
handler: async (command, onDelta, onActivity, onApproval, onUserInput, onMcpOAuthRequired, onExitPlanMode, cancellationToken) => [],
|
||||||
resolveUserInputHandler: (command, cancellationToken) =>
|
resolveUserInputHandler: (command, cancellationToken) =>
|
||||||
@@ -925,7 +761,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
public async Task ListSessionsCommand_ReturnsSessionsListedEvent()
|
public async Task ListSessionsCommand_ReturnsSessionsListedEvent()
|
||||||
{
|
{
|
||||||
SidecarProtocolHost host = new(
|
SidecarProtocolHost host = new(
|
||||||
new PatternValidator(),
|
new WorkflowValidator(),
|
||||||
sessionManager: new FakeSessionManager
|
sessionManager: new FakeSessionManager
|
||||||
{
|
{
|
||||||
Sessions =
|
Sessions =
|
||||||
@@ -972,7 +808,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
],
|
],
|
||||||
};
|
};
|
||||||
SidecarProtocolHost host = new(
|
SidecarProtocolHost host = new(
|
||||||
new PatternValidator(),
|
new WorkflowValidator(),
|
||||||
sessionManager: sessionManager);
|
sessionManager: sessionManager);
|
||||||
|
|
||||||
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
||||||
@@ -995,7 +831,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
public async Task GetQuotaCommand_ReturnsQuotaResultEvent()
|
public async Task GetQuotaCommand_ReturnsQuotaResultEvent()
|
||||||
{
|
{
|
||||||
SidecarProtocolHost host = new(
|
SidecarProtocolHost host = new(
|
||||||
new PatternValidator(),
|
new WorkflowValidator(),
|
||||||
sessionManager: new FakeSessionManager
|
sessionManager: new FakeSessionManager
|
||||||
{
|
{
|
||||||
QuotaSnapshots = new Dictionary<string, QuotaSnapshotDto>(StringComparer.Ordinal)
|
QuotaSnapshots = new Dictionary<string, QuotaSnapshotDto>(StringComparer.Ordinal)
|
||||||
@@ -1037,7 +873,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
await Task.Delay(Timeout.InfiniteTimeSpan, cancellationToken);
|
||||||
return [];
|
return [];
|
||||||
});
|
});
|
||||||
SidecarProtocolHost host = new(new PatternValidator(), runner);
|
SidecarProtocolHost host = new(new WorkflowValidator(), runner);
|
||||||
|
|
||||||
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
IReadOnlyList<JsonElement> events = await RunHostAsync(
|
||||||
[
|
[
|
||||||
@@ -1098,7 +934,7 @@ public sealed class SidecarProtocolHostTests
|
|||||||
private static SidecarProtocolHost CreateHostForTests()
|
private static SidecarProtocolHost CreateHostForTests()
|
||||||
{
|
{
|
||||||
return new SidecarProtocolHost(
|
return new SidecarProtocolHost(
|
||||||
new PatternValidator(),
|
new WorkflowValidator(),
|
||||||
capabilitiesProvider: _ => Task.FromResult(new SidecarCapabilitiesDto
|
capabilitiesProvider: _ => Task.FromResult(new SidecarCapabilitiesDto
|
||||||
{
|
{
|
||||||
Modes = new Dictionary<string, SidecarModeCapabilityDto>(StringComparer.OrdinalIgnoreCase)
|
Modes = new Dictionary<string, SidecarModeCapabilityDto>(StringComparer.OrdinalIgnoreCase)
|
||||||
@@ -1176,24 +1012,35 @@ public sealed class SidecarProtocolHostTests
|
|||||||
return events;
|
return events;
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PatternAgentDefinitionDto CreateAgent(
|
private static WorkflowNodeDto CreateAgent(
|
||||||
string id = "agent-1",
|
string id = "agent-1",
|
||||||
string name = "Primary",
|
string name = "Primary",
|
||||||
string model = "gpt-5.4",
|
string model = "gpt-5.4",
|
||||||
string instructions = "Help with the user's request.")
|
string instructions = "Help with the user's request.")
|
||||||
{
|
{
|
||||||
return new PatternAgentDefinitionDto
|
return new WorkflowNodeDto
|
||||||
{
|
{
|
||||||
Id = id,
|
Id = id,
|
||||||
Name = name,
|
Kind = "agent",
|
||||||
Model = model,
|
Label = name,
|
||||||
Instructions = instructions,
|
Config = new WorkflowNodeConfigDto
|
||||||
|
{
|
||||||
|
Kind = "agent",
|
||||||
|
Id = id,
|
||||||
|
Name = name,
|
||||||
|
Model = model,
|
||||||
|
Instructions = instructions,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static RunTurnCommandDto CreateRunTurnCommand(
|
private static RunTurnCommandDto CreateRunTurnCommand(
|
||||||
string requestId = "turn-1",
|
string requestId = "turn-1",
|
||||||
string sessionId = "session-1")
|
string sessionId = "session-1",
|
||||||
|
string mode = "single",
|
||||||
|
string interactionMode = "interactive",
|
||||||
|
IReadOnlyList<WorkflowNodeDto>? agents = null,
|
||||||
|
IReadOnlyList<ChatMessageDto>? messages = null)
|
||||||
{
|
{
|
||||||
return new RunTurnCommandDto
|
return new RunTurnCommandDto
|
||||||
{
|
{
|
||||||
@@ -1201,18 +1048,9 @@ public sealed class SidecarProtocolHostTests
|
|||||||
RequestId = requestId,
|
RequestId = requestId,
|
||||||
SessionId = sessionId,
|
SessionId = sessionId,
|
||||||
ProjectPath = "C:\\workspace\\project",
|
ProjectPath = "C:\\workspace\\project",
|
||||||
Pattern = new PatternDefinitionDto
|
Mode = interactionMode,
|
||||||
{
|
Workflow = CreateWorkflow(mode, agents),
|
||||||
Id = "pattern-1",
|
Messages = messages ??
|
||||||
Name = "Single Agent",
|
|
||||||
Mode = "single",
|
|
||||||
Availability = "available",
|
|
||||||
Agents =
|
|
||||||
[
|
|
||||||
CreateAgent(name: "Primary"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
Messages =
|
|
||||||
[
|
[
|
||||||
new ChatMessageDto
|
new ChatMessageDto
|
||||||
{
|
{
|
||||||
@@ -1226,6 +1064,25 @@ public sealed class SidecarProtocolHostTests
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static WorkflowDefinitionDto CreateWorkflow(
|
||||||
|
string mode = "single",
|
||||||
|
IReadOnlyList<WorkflowNodeDto>? agents = null)
|
||||||
|
{
|
||||||
|
return new WorkflowDefinitionDto
|
||||||
|
{
|
||||||
|
Id = $"workflow-{mode}",
|
||||||
|
Name = "Single Agent",
|
||||||
|
Graph = new WorkflowGraphDto
|
||||||
|
{
|
||||||
|
Nodes = [.. agents ?? [CreateAgent(name: "Primary")]],
|
||||||
|
},
|
||||||
|
Settings = new WorkflowSettingsDto
|
||||||
|
{
|
||||||
|
OrchestrationMode = mode,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class FakeWorkflowRunner : ITurnWorkflowRunner
|
private sealed class FakeWorkflowRunner : ITurnWorkflowRunner
|
||||||
{
|
{
|
||||||
private readonly Func<
|
private readonly Func<
|
||||||
@@ -1325,3 +1182,4 @@ public sealed class SidecarProtocolHostTests
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,169 +0,0 @@
|
|||||||
using Aryx.AgentHost.Contracts;
|
|
||||||
using Aryx.AgentHost.Services;
|
|
||||||
|
|
||||||
namespace Aryx.AgentHost.Tests;
|
|
||||||
|
|
||||||
public sealed class PatternValidatorTests
|
|
||||||
{
|
|
||||||
private readonly PatternValidator _validator = new();
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void SingleAgentPattern_WithExactlyOneAgent_IsValid()
|
|
||||||
{
|
|
||||||
IReadOnlyList<PatternValidationIssueDto> issues = _validator.Validate(
|
|
||||||
CreatePattern(
|
|
||||||
"single",
|
|
||||||
[CreateAgent()]));
|
|
||||||
|
|
||||||
Assert.Empty(issues);
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void HandoffPattern_WithSingleAgent_IsReportedAsInvalid()
|
|
||||||
{
|
|
||||||
IReadOnlyList<PatternValidationIssueDto> issues = _validator.Validate(
|
|
||||||
CreatePattern(
|
|
||||||
"handoff",
|
|
||||||
[CreateAgent()]));
|
|
||||||
|
|
||||||
Assert.Contains(issues, issue =>
|
|
||||||
issue.Field == "agents"
|
|
||||||
&& issue.Message == "Handoff orchestration requires at least two agents.");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void AgentWithoutModel_IsReportedAsInvalid()
|
|
||||||
{
|
|
||||||
IReadOnlyList<PatternValidationIssueDto> issues = _validator.Validate(
|
|
||||||
CreatePattern(
|
|
||||||
"sequential",
|
|
||||||
[
|
|
||||||
CreateAgent(model: ""),
|
|
||||||
CreateAgent(id: "agent-2", name: "Reviewer"),
|
|
||||||
]));
|
|
||||||
|
|
||||||
Assert.Contains(issues, issue =>
|
|
||||||
issue.Field == "agents.model"
|
|
||||||
&& issue.Message == "Agent \"Primary\" requires a model identifier.");
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void MagenticPattern_IsReportedAsUnavailable()
|
|
||||||
{
|
|
||||||
IReadOnlyList<PatternValidationIssueDto> issues = _validator.Validate(
|
|
||||||
CreatePattern(
|
|
||||||
"magentic",
|
|
||||||
[
|
|
||||||
CreateAgent(id: "agent-1", name: "Planner", instructions: "Plan the task."),
|
|
||||||
CreateAgent(
|
|
||||||
id: "agent-2",
|
|
||||||
name: "Specialist",
|
|
||||||
model: "claude-opus-4.5",
|
|
||||||
instructions: "Complete the task."),
|
|
||||||
],
|
|
||||||
availability: "unavailable",
|
|
||||||
unavailabilityReason: "Unsupported in C#.",
|
|
||||||
name: "Magentic"));
|
|
||||||
|
|
||||||
Assert.Contains(issues, issue =>
|
|
||||||
issue.Field == "availability"
|
|
||||||
&& issue.Message.Contains("Unsupported", StringComparison.OrdinalIgnoreCase));
|
|
||||||
Assert.Contains(issues, issue =>
|
|
||||||
issue.Field == "mode"
|
|
||||||
&& issue.Message.Contains("Unsupported", StringComparison.OrdinalIgnoreCase));
|
|
||||||
}
|
|
||||||
|
|
||||||
[Fact]
|
|
||||||
public void SequentialPattern_WithBranchedGraph_IsReportedAsInvalid()
|
|
||||||
{
|
|
||||||
IReadOnlyList<PatternValidationIssueDto> issues = _validator.Validate(
|
|
||||||
CreatePattern(
|
|
||||||
"sequential",
|
|
||||||
[
|
|
||||||
CreateAgent(id: "agent-1", name: "Analyst"),
|
|
||||||
CreateAgent(id: "agent-2", name: "Builder"),
|
|
||||||
],
|
|
||||||
graph: new PatternGraphDto
|
|
||||||
{
|
|
||||||
Nodes =
|
|
||||||
[
|
|
||||||
CreateSystemNode("system-user-input", "user-input"),
|
|
||||||
CreateAgentNode("agent-1", 0),
|
|
||||||
CreateAgentNode("agent-2", 1),
|
|
||||||
CreateSystemNode("system-user-output", "user-output"),
|
|
||||||
],
|
|
||||||
Edges =
|
|
||||||
[
|
|
||||||
CreateEdge("system-user-input", "agent-node-agent-1"),
|
|
||||||
CreateEdge("system-user-input", "agent-node-agent-2"),
|
|
||||||
CreateEdge("agent-node-agent-1", "agent-node-agent-2"),
|
|
||||||
CreateEdge("agent-node-agent-2", "system-user-output"),
|
|
||||||
],
|
|
||||||
}));
|
|
||||||
|
|
||||||
Assert.Contains(issues, issue =>
|
|
||||||
issue.Field == "graph"
|
|
||||||
&& issue.Message.Contains("single path", StringComparison.OrdinalIgnoreCase));
|
|
||||||
}
|
|
||||||
|
|
||||||
private static PatternDefinitionDto CreatePattern(
|
|
||||||
string mode,
|
|
||||||
IReadOnlyList<PatternAgentDefinitionDto> agents,
|
|
||||||
string availability = "available",
|
|
||||||
string? unavailabilityReason = null,
|
|
||||||
string name = "Pattern",
|
|
||||||
PatternGraphDto? graph = null)
|
|
||||||
{
|
|
||||||
return new PatternDefinitionDto
|
|
||||||
{
|
|
||||||
Id = $"{mode}-pattern",
|
|
||||||
Name = name,
|
|
||||||
Mode = mode,
|
|
||||||
Availability = availability,
|
|
||||||
UnavailabilityReason = unavailabilityReason,
|
|
||||||
Agents = agents,
|
|
||||||
Graph = graph,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static PatternAgentDefinitionDto CreateAgent(
|
|
||||||
string id = "agent-1",
|
|
||||||
string name = "Primary",
|
|
||||||
string model = "gpt-5.4",
|
|
||||||
string instructions = "Help with the user's request.")
|
|
||||||
{
|
|
||||||
return new PatternAgentDefinitionDto
|
|
||||||
{
|
|
||||||
Id = id,
|
|
||||||
Name = name,
|
|
||||||
Model = model,
|
|
||||||
Instructions = instructions,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static PatternGraphNodeDto CreateSystemNode(string id, string kind)
|
|
||||||
=> new()
|
|
||||||
{
|
|
||||||
Id = id,
|
|
||||||
Kind = kind,
|
|
||||||
Position = new PatternGraphPositionDto(),
|
|
||||||
};
|
|
||||||
|
|
||||||
private static PatternGraphNodeDto CreateAgentNode(string agentId, int order)
|
|
||||||
=> new()
|
|
||||||
{
|
|
||||||
Id = $"agent-node-{agentId}",
|
|
||||||
Kind = "agent",
|
|
||||||
AgentId = agentId,
|
|
||||||
Order = order,
|
|
||||||
Position = new PatternGraphPositionDto(),
|
|
||||||
};
|
|
||||||
|
|
||||||
private static PatternGraphEdgeDto CreateEdge(string source, string target)
|
|
||||||
=> new()
|
|
||||||
{
|
|
||||||
Id = $"edge-{source}-to-{target}",
|
|
||||||
Source = source,
|
|
||||||
Target = target,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
@@ -186,54 +186,52 @@ public sealed class WorkflowRequestInfoInterpreterTests
|
|||||||
}
|
}
|
||||||
|
|
||||||
private static RunTurnCommandDto CreateSingleAgentCommand()
|
private static RunTurnCommandDto CreateSingleAgentCommand()
|
||||||
{
|
=> CreateCommand("single", [CreateAgent("agent-1", "Primary")]);
|
||||||
return new RunTurnCommandDto
|
|
||||||
{
|
|
||||||
RequestId = "turn-1",
|
|
||||||
SessionId = "session-1",
|
|
||||||
Pattern = new PatternDefinitionDto
|
|
||||||
{
|
|
||||||
Id = "pattern-single",
|
|
||||||
Name = "Single Agent",
|
|
||||||
Mode = "single",
|
|
||||||
Availability = "available",
|
|
||||||
Agents =
|
|
||||||
[
|
|
||||||
CreateAgent("agent-1", "Primary"),
|
|
||||||
],
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static RunTurnCommandDto CreateHandoffCommand()
|
private static RunTurnCommandDto CreateHandoffCommand()
|
||||||
|
=> CreateCommand("handoff",
|
||||||
|
[
|
||||||
|
CreateAgent("agent-handoff-triage", "Triage"),
|
||||||
|
CreateAgent("agent-handoff-ux", "UX Specialist"),
|
||||||
|
]);
|
||||||
|
|
||||||
|
private static RunTurnCommandDto CreateCommand(string orchestrationMode, IReadOnlyList<WorkflowNodeDto> agents)
|
||||||
{
|
{
|
||||||
return new RunTurnCommandDto
|
return new RunTurnCommandDto
|
||||||
{
|
{
|
||||||
RequestId = "turn-1",
|
RequestId = "turn-1",
|
||||||
SessionId = "session-1",
|
SessionId = "session-1",
|
||||||
Pattern = new PatternDefinitionDto
|
Workflow = new WorkflowDefinitionDto
|
||||||
{
|
{
|
||||||
Id = "pattern-handoff",
|
Id = $"{orchestrationMode}-workflow",
|
||||||
Name = "Handoff Flow",
|
Name = "Workflow",
|
||||||
Mode = "handoff",
|
Graph = new WorkflowGraphDto
|
||||||
Availability = "available",
|
{
|
||||||
Agents =
|
Nodes = [.. agents],
|
||||||
[
|
},
|
||||||
CreateAgent("agent-handoff-triage", "Triage"),
|
Settings = new WorkflowSettingsDto
|
||||||
CreateAgent("agent-handoff-ux", "UX Specialist"),
|
{
|
||||||
],
|
OrchestrationMode = orchestrationMode,
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PatternAgentDefinitionDto CreateAgent(string id, string name)
|
private static WorkflowNodeDto CreateAgent(string id, string name)
|
||||||
{
|
{
|
||||||
return new PatternAgentDefinitionDto
|
return new WorkflowNodeDto
|
||||||
{
|
{
|
||||||
Id = id,
|
Id = id,
|
||||||
Name = name,
|
Kind = "agent",
|
||||||
Model = "gpt-5.4",
|
Label = name,
|
||||||
Instructions = "Help with the request.",
|
Config = new WorkflowNodeConfigDto
|
||||||
|
{
|
||||||
|
Kind = "agent",
|
||||||
|
Id = id,
|
||||||
|
Name = name,
|
||||||
|
Model = "gpt-5.4",
|
||||||
|
Instructions = "Help with the request.",
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,6 @@ public sealed class WorkflowRunnerTests
|
|||||||
WorkflowRunner runner = new();
|
WorkflowRunner runner = new();
|
||||||
Workflow workflow = runner.BuildWorkflow(
|
Workflow workflow = runner.BuildWorkflow(
|
||||||
CreateSubworkflowParent(inlineWorkflow: CreateAgentWorkflow("child-inline", "agent-child")),
|
CreateSubworkflowParent(inlineWorkflow: CreateAgentWorkflow("child-inline", "agent-child")),
|
||||||
CreatePattern("agent-child"),
|
|
||||||
[CreateChatClientAgent("agent-child", "Child Agent")]);
|
[CreateChatClientAgent("agent-child", "Child Agent")]);
|
||||||
|
|
||||||
ProtocolDescriptor descriptor = await workflow.DescribeProtocolAsync();
|
ProtocolDescriptor descriptor = await workflow.DescribeProtocolAsync();
|
||||||
@@ -30,7 +29,6 @@ public sealed class WorkflowRunnerTests
|
|||||||
WorkflowDefinitionDto childWorkflow = CreateAgentWorkflow("child-ref", "agent-child");
|
WorkflowDefinitionDto childWorkflow = CreateAgentWorkflow("child-ref", "agent-child");
|
||||||
Workflow workflow = runner.BuildWorkflow(
|
Workflow workflow = runner.BuildWorkflow(
|
||||||
CreateSubworkflowParent(workflowId: childWorkflow.Id),
|
CreateSubworkflowParent(workflowId: childWorkflow.Id),
|
||||||
CreatePattern("agent-child"),
|
|
||||||
[CreateChatClientAgent("agent-child", "Child Agent")],
|
[CreateChatClientAgent("agent-child", "Child Agent")],
|
||||||
[childWorkflow]);
|
[childWorkflow]);
|
||||||
|
|
||||||
@@ -46,7 +44,6 @@ public sealed class WorkflowRunnerTests
|
|||||||
|
|
||||||
InvalidOperationException error = Assert.Throws<InvalidOperationException>(() => runner.BuildWorkflow(
|
InvalidOperationException error = Assert.Throws<InvalidOperationException>(() => runner.BuildWorkflow(
|
||||||
CreateSubworkflowParent(workflowId: "missing-child"),
|
CreateSubworkflowParent(workflowId: "missing-child"),
|
||||||
CreatePattern("agent-child"),
|
|
||||||
[CreateChatClientAgent("agent-child", "Child Agent")],
|
[CreateChatClientAgent("agent-child", "Child Agent")],
|
||||||
[]));
|
[]));
|
||||||
|
|
||||||
@@ -65,7 +62,6 @@ public sealed class WorkflowRunnerTests
|
|||||||
Kind = "code-executor",
|
Kind = "code-executor",
|
||||||
Implementation = "return-text:done",
|
Implementation = "return-text:done",
|
||||||
}),
|
}),
|
||||||
CreateEmptyPattern(),
|
|
||||||
[]);
|
[]);
|
||||||
|
|
||||||
List<ChatMessage> output = await RunWorkflowToOutputAsync(workflow);
|
List<ChatMessage> output = await RunWorkflowToOutputAsync(workflow);
|
||||||
@@ -81,7 +77,6 @@ public sealed class WorkflowRunnerTests
|
|||||||
WorkflowRunner runner = new();
|
WorkflowRunner runner = new();
|
||||||
Workflow workflow = runner.BuildWorkflow(
|
Workflow workflow = runner.BuildWorkflow(
|
||||||
CreateStatefulFunctionWorkflow(),
|
CreateStatefulFunctionWorkflow(),
|
||||||
CreateEmptyPattern(),
|
|
||||||
[]);
|
[]);
|
||||||
|
|
||||||
List<ChatMessage> output = await RunWorkflowToOutputAsync(workflow);
|
List<ChatMessage> output = await RunWorkflowToOutputAsync(workflow);
|
||||||
@@ -105,7 +100,6 @@ public sealed class WorkflowRunnerTests
|
|||||||
ResponseType = "string",
|
ResponseType = "string",
|
||||||
Prompt = "Approve the workflow?",
|
Prompt = "Approve the workflow?",
|
||||||
}),
|
}),
|
||||||
CreateEmptyPattern(),
|
|
||||||
[]);
|
[]);
|
||||||
|
|
||||||
ChatMessage[] input =
|
ChatMessage[] input =
|
||||||
@@ -153,33 +147,11 @@ public sealed class WorkflowRunnerTests
|
|||||||
Kind = "function-executor",
|
Kind = "function-executor",
|
||||||
FunctionRef = "missing-function",
|
FunctionRef = "missing-function",
|
||||||
}),
|
}),
|
||||||
CreateEmptyPattern(),
|
|
||||||
[]));
|
[]));
|
||||||
|
|
||||||
Assert.Contains("unsupported functionRef", error.Message, StringComparison.OrdinalIgnoreCase);
|
Assert.Contains("unsupported functionRef", error.Message, StringComparison.OrdinalIgnoreCase);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PatternDefinitionDto CreatePattern(string agentId)
|
|
||||||
{
|
|
||||||
return new PatternDefinitionDto
|
|
||||||
{
|
|
||||||
Id = "pattern-1",
|
|
||||||
Name = "Workflow Pattern",
|
|
||||||
Mode = "single",
|
|
||||||
Availability = "available",
|
|
||||||
Agents =
|
|
||||||
[
|
|
||||||
new PatternAgentDefinitionDto
|
|
||||||
{
|
|
||||||
Id = agentId,
|
|
||||||
Name = "Child Agent",
|
|
||||||
Instructions = "Help with the request.",
|
|
||||||
Model = "gpt-5.4",
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static WorkflowDefinitionDto CreateAgentWorkflow(string id, string agentId)
|
private static WorkflowDefinitionDto CreateAgentWorkflow(string id, string agentId)
|
||||||
{
|
{
|
||||||
return new WorkflowDefinitionDto
|
return new WorkflowDefinitionDto
|
||||||
@@ -243,18 +215,6 @@ public sealed class WorkflowRunnerTests
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private static PatternDefinitionDto CreateEmptyPattern()
|
|
||||||
{
|
|
||||||
return new PatternDefinitionDto
|
|
||||||
{
|
|
||||||
Id = "pattern-empty",
|
|
||||||
Name = "Workflow Pattern",
|
|
||||||
Mode = "single",
|
|
||||||
Availability = "available",
|
|
||||||
Agents = [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
private static WorkflowDefinitionDto CreateSubworkflowParent(
|
private static WorkflowDefinitionDto CreateSubworkflowParent(
|
||||||
string? workflowId = null,
|
string? workflowId = null,
|
||||||
WorkflowDefinitionDto? inlineWorkflow = null)
|
WorkflowDefinitionDto? inlineWorkflow = null)
|
||||||
|
|||||||
+157
-267
@@ -26,20 +26,16 @@ import {
|
|||||||
buildAvailableModelCatalog,
|
buildAvailableModelCatalog,
|
||||||
findModel,
|
findModel,
|
||||||
findModelByReference,
|
findModelByReference,
|
||||||
normalizePatternModels,
|
normalizeWorkflowModels,
|
||||||
resolveReasoningEffort,
|
resolveReasoningEffort,
|
||||||
} from '@shared/domain/models';
|
} from '@shared/domain/models';
|
||||||
import {
|
import {
|
||||||
|
buildWorkflowExecutionDefinition,
|
||||||
isReasoningEffort,
|
isReasoningEffort,
|
||||||
syncPatternGraph,
|
|
||||||
type PatternDefinition,
|
|
||||||
type ReasoningEffort,
|
|
||||||
validatePatternDefinition,
|
|
||||||
} from '@shared/domain/pattern';
|
|
||||||
import {
|
|
||||||
buildWorkflowExecutionPattern,
|
|
||||||
normalizeWorkflowDefinition,
|
normalizeWorkflowDefinition,
|
||||||
|
resolveWorkflowAgentNodes,
|
||||||
validateWorkflowDefinition,
|
validateWorkflowDefinition,
|
||||||
|
type ReasoningEffort,
|
||||||
type WorkflowDefinition,
|
type WorkflowDefinition,
|
||||||
type WorkflowReference,
|
type WorkflowReference,
|
||||||
} from '@shared/domain/workflow';
|
} from '@shared/domain/workflow';
|
||||||
@@ -51,7 +47,6 @@ import {
|
|||||||
} from '@shared/domain/workflowSerialization';
|
} from '@shared/domain/workflowSerialization';
|
||||||
import {
|
import {
|
||||||
applyWorkflowTemplate,
|
applyWorkflowTemplate,
|
||||||
buildWorkflowFromPattern,
|
|
||||||
createWorkflowTemplateFromWorkflow,
|
createWorkflowTemplateFromWorkflow,
|
||||||
normalizeWorkflowTemplateDefinition,
|
normalizeWorkflowTemplateDefinition,
|
||||||
type WorkflowTemplateCategory,
|
type WorkflowTemplateCategory,
|
||||||
@@ -59,7 +54,7 @@ import {
|
|||||||
} from '@shared/domain/workflowTemplate';
|
} from '@shared/domain/workflowTemplate';
|
||||||
import {
|
import {
|
||||||
normalizeWorkspaceAgentDefinition,
|
normalizeWorkspaceAgentDefinition,
|
||||||
resolvePatternAgents,
|
resolveWorkflowAgents as resolveWorkspaceWorkflowAgents,
|
||||||
type WorkspaceAgentDefinition,
|
type WorkspaceAgentDefinition,
|
||||||
} from '@shared/domain/workspaceAgent';
|
} from '@shared/domain/workspaceAgent';
|
||||||
import {
|
import {
|
||||||
@@ -82,6 +77,7 @@ import {
|
|||||||
type ProjectCustomizationState,
|
type ProjectCustomizationState,
|
||||||
} from '@shared/domain/projectCustomization';
|
} from '@shared/domain/projectCustomization';
|
||||||
import {
|
import {
|
||||||
|
applyDefaultToolApprovalPolicy,
|
||||||
approvalPolicyRequiresCheckpoint,
|
approvalPolicyRequiresCheckpoint,
|
||||||
dequeuePendingApprovalState,
|
dequeuePendingApprovalState,
|
||||||
enqueuePendingApprovalState,
|
enqueuePendingApprovalState,
|
||||||
@@ -138,6 +134,7 @@ import {
|
|||||||
upsertRunApprovalEvent,
|
upsertRunApprovalEvent,
|
||||||
upsertRunMessageEvent,
|
upsertRunMessageEvent,
|
||||||
upsertSessionRunRecord,
|
upsertSessionRunRecord,
|
||||||
|
type CreateSessionRunRecordInput,
|
||||||
type RunTimelineEventRecord,
|
type RunTimelineEventRecord,
|
||||||
type SessionRunRecord,
|
type SessionRunRecord,
|
||||||
} from '@shared/domain/runTimeline';
|
} from '@shared/domain/runTimeline';
|
||||||
@@ -217,10 +214,6 @@ type WorkflowCheckpointRecoveryState = {
|
|||||||
|
|
||||||
type DiscoveredToolingResolution = 'accept' | 'dismiss';
|
type DiscoveredToolingResolution = 'accept' | 'dismiss';
|
||||||
|
|
||||||
function isBuiltinPattern(patternId: string): boolean {
|
|
||||||
return patternId.startsWith('pattern-');
|
|
||||||
}
|
|
||||||
|
|
||||||
function equalStringArrays(left?: readonly string[], right?: readonly string[]): boolean {
|
function equalStringArrays(left?: readonly string[], right?: readonly string[]): boolean {
|
||||||
const normalizedLeft = left ?? [];
|
const normalizedLeft = left ?? [];
|
||||||
const normalizedRight = right ?? [];
|
const normalizedRight = right ?? [];
|
||||||
@@ -613,37 +606,6 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
return result;
|
return result;
|
||||||
}
|
}
|
||||||
|
|
||||||
async savePattern(pattern: PatternDefinition): Promise<WorkspaceState> {
|
|
||||||
const workspace = await this.loadWorkspace();
|
|
||||||
const knownApprovalToolNames = await this.listKnownApprovalToolNames(workspace);
|
|
||||||
const synchronizedPattern = pattern.graph ? pattern : syncPatternGraph(pattern);
|
|
||||||
const issues = validatePatternDefinition(
|
|
||||||
synchronizedPattern,
|
|
||||||
knownApprovalToolNames,
|
|
||||||
).filter((issue) => issue.level === 'error');
|
|
||||||
if (issues.length > 0) {
|
|
||||||
throw new Error(issues[0].message);
|
|
||||||
}
|
|
||||||
|
|
||||||
const existingIndex = workspace.patterns.findIndex((current) => current.id === pattern.id);
|
|
||||||
const candidate: PatternDefinition = {
|
|
||||||
...synchronizedPattern,
|
|
||||||
approvalPolicy: normalizeApprovalPolicy(synchronizedPattern.approvalPolicy),
|
|
||||||
isFavorite: pattern.isFavorite ?? workspace.patterns[existingIndex]?.isFavorite,
|
|
||||||
createdAt: existingIndex >= 0 ? workspace.patterns[existingIndex].createdAt : nowIso(),
|
|
||||||
updatedAt: nowIso(),
|
|
||||||
};
|
|
||||||
|
|
||||||
if (existingIndex >= 0) {
|
|
||||||
workspace.patterns[existingIndex] = candidate;
|
|
||||||
} else {
|
|
||||||
workspace.patterns.push(candidate);
|
|
||||||
}
|
|
||||||
|
|
||||||
workspace.selectedPatternId = candidate.id;
|
|
||||||
return this.persistAndBroadcast(workspace);
|
|
||||||
}
|
|
||||||
|
|
||||||
async saveWorkflow(workflow: WorkflowDefinition): Promise<WorkspaceState> {
|
async saveWorkflow(workflow: WorkflowDefinition): Promise<WorkspaceState> {
|
||||||
const workspace = await this.loadWorkspace();
|
const workspace = await this.loadWorkspace();
|
||||||
const normalizedWorkflow = normalizeWorkflowDefinition(workflow);
|
const normalizedWorkflow = normalizeWorkflowDefinition(workflow);
|
||||||
@@ -747,48 +709,6 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async upgradePatternToWorkflow(
|
|
||||||
patternId: string,
|
|
||||||
options?: {
|
|
||||||
workflowId?: string;
|
|
||||||
name?: string;
|
|
||||||
description?: string;
|
|
||||||
save?: boolean;
|
|
||||||
},
|
|
||||||
): Promise<{ workflow: WorkflowDefinition; workspace?: WorkspaceState }> {
|
|
||||||
const workspace = await this.loadWorkspace();
|
|
||||||
const pattern = this.requirePattern(workspace, patternId);
|
|
||||||
const baseWorkflow = buildWorkflowFromPattern(pattern);
|
|
||||||
const workflowId = options?.workflowId?.trim()
|
|
||||||
|| this.createUniqueWorkflowId(workspace, baseWorkflow.id);
|
|
||||||
const workflow = normalizeWorkflowDefinition({
|
|
||||||
...baseWorkflow,
|
|
||||||
id: workflowId,
|
|
||||||
name: options?.name?.trim() || baseWorkflow.name,
|
|
||||||
description: options?.description?.trim() ?? baseWorkflow.description,
|
|
||||||
createdAt: nowIso(),
|
|
||||||
updatedAt: nowIso(),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!options?.save) {
|
|
||||||
return { workflow };
|
|
||||||
}
|
|
||||||
|
|
||||||
const savedWorkspace = await this.saveWorkflow(workflow);
|
|
||||||
return {
|
|
||||||
workflow,
|
|
||||||
workspace: savedWorkspace,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
async setPatternFavorite(patternId: string, isFavorite: boolean): Promise<WorkspaceState> {
|
|
||||||
const workspace = await this.loadWorkspace();
|
|
||||||
const pattern = this.requirePattern(workspace, patternId);
|
|
||||||
pattern.isFavorite = isFavorite;
|
|
||||||
pattern.updatedAt = nowIso();
|
|
||||||
return this.persistAndBroadcast(workspace);
|
|
||||||
}
|
|
||||||
|
|
||||||
async setTheme(theme: AppearanceTheme): Promise<WorkspaceState> {
|
async setTheme(theme: AppearanceTheme): Promise<WorkspaceState> {
|
||||||
const workspace = await this.loadWorkspace();
|
const workspace = await this.loadWorkspace();
|
||||||
workspace.settings.theme = normalizeTheme(theme);
|
workspace.settings.theme = normalizeTheme(theme);
|
||||||
@@ -867,23 +787,6 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
this.ptyManager.resize(cols, rows);
|
this.ptyManager.resize(cols, rows);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deletePattern(patternId: string): Promise<WorkspaceState> {
|
|
||||||
const workspace = await this.loadWorkspace();
|
|
||||||
workspace.patterns = workspace.patterns.filter((pattern) => pattern.id !== patternId);
|
|
||||||
|
|
||||||
if (isBuiltinPattern(patternId)) {
|
|
||||||
const deletedIds = new Set(workspace.deletedBuiltinPatternIds ?? []);
|
|
||||||
deletedIds.add(patternId);
|
|
||||||
workspace.deletedBuiltinPatternIds = [...deletedIds];
|
|
||||||
}
|
|
||||||
|
|
||||||
if (workspace.selectedPatternId === patternId) {
|
|
||||||
workspace.selectedPatternId = workspace.patterns[0]?.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.persistAndBroadcast(workspace);
|
|
||||||
}
|
|
||||||
|
|
||||||
async deleteWorkflow(workflowId: string): Promise<WorkspaceState> {
|
async deleteWorkflow(workflowId: string): Promise<WorkspaceState> {
|
||||||
const workspace = await this.loadWorkspace();
|
const workspace = await this.loadWorkspace();
|
||||||
const workflow = this.requireWorkflow(workspace, workflowId);
|
const workflow = this.requireWorkflow(workspace, workflowId);
|
||||||
@@ -1038,50 +941,19 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
return this.persistAndBroadcast(workspace);
|
return this.persistAndBroadcast(workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
async createSession(projectId: string, patternId: string): Promise<WorkspaceState> {
|
async createSession(projectId: string, workflowId: string): Promise<WorkspaceState> {
|
||||||
const workspace = await this.loadWorkspace();
|
|
||||||
const project = this.requireProject(workspace, projectId);
|
|
||||||
const pattern = this.requirePattern(workspace, patternId);
|
|
||||||
const modelCatalog = await this.loadAvailableModelCatalog();
|
|
||||||
const normalizedPattern = normalizePatternModels(pattern, modelCatalog);
|
|
||||||
|
|
||||||
const session: SessionRecord = {
|
|
||||||
id: createId('session'),
|
|
||||||
projectId: project.id,
|
|
||||||
patternId: pattern.id,
|
|
||||||
title: pattern.name,
|
|
||||||
titleSource: 'auto',
|
|
||||||
createdAt: nowIso(),
|
|
||||||
updatedAt: nowIso(),
|
|
||||||
status: 'idle',
|
|
||||||
messages: [],
|
|
||||||
sessionModelConfig: normalizedPattern.agents.length === 1
|
|
||||||
? createSessionModelConfig(normalizedPattern)
|
|
||||||
: undefined,
|
|
||||||
tooling: createSessionToolingSelection(),
|
|
||||||
runs: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
await this.ensureScratchpadSessionDirectory(session);
|
|
||||||
workspace.sessions.unshift(session);
|
|
||||||
workspace.selectedProjectId = project.id;
|
|
||||||
workspace.selectedPatternId = pattern.id;
|
|
||||||
workspace.selectedWorkflowId = undefined;
|
|
||||||
workspace.selectedSessionId = session.id;
|
|
||||||
return this.persistAndBroadcast(workspace);
|
|
||||||
}
|
|
||||||
|
|
||||||
async createWorkflowSession(projectId: string, workflowId: string): Promise<WorkspaceState> {
|
|
||||||
const workspace = await this.loadWorkspace();
|
const workspace = await this.loadWorkspace();
|
||||||
const project = this.requireProject(workspace, projectId);
|
const project = this.requireProject(workspace, projectId);
|
||||||
const workflow = this.requireWorkflow(workspace, workflowId);
|
const workflow = this.requireWorkflow(workspace, workflowId);
|
||||||
const modelCatalog = await this.loadAvailableModelCatalog();
|
const modelCatalog = await this.loadAvailableModelCatalog();
|
||||||
const executionPattern = normalizePatternModels(this.buildResolvedWorkflowExecutionPattern(workspace, workflow), modelCatalog);
|
const executionWorkflow = normalizeWorkflowModels(
|
||||||
|
this.buildResolvedExecutionWorkflow(workspace, workflow),
|
||||||
|
modelCatalog,
|
||||||
|
);
|
||||||
|
|
||||||
const session: SessionRecord = {
|
const session: SessionRecord = {
|
||||||
id: createId('session'),
|
id: createId('session'),
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
patternId: workflow.id,
|
|
||||||
workflowId: workflow.id,
|
workflowId: workflow.id,
|
||||||
title: workflow.name,
|
title: workflow.name,
|
||||||
titleSource: 'auto',
|
titleSource: 'auto',
|
||||||
@@ -1089,9 +961,10 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
updatedAt: nowIso(),
|
updatedAt: nowIso(),
|
||||||
status: 'idle',
|
status: 'idle',
|
||||||
messages: [],
|
messages: [],
|
||||||
sessionModelConfig: executionPattern.agents.length === 1
|
sessionModelConfig: createSessionModelConfig(
|
||||||
? createSessionModelConfig(executionPattern)
|
executionWorkflow,
|
||||||
: undefined,
|
this.createWorkflowResolutionOptions(workspace),
|
||||||
|
),
|
||||||
tooling: createSessionToolingSelection(),
|
tooling: createSessionToolingSelection(),
|
||||||
runs: [],
|
runs: [],
|
||||||
};
|
};
|
||||||
@@ -1099,12 +972,15 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
await this.ensureScratchpadSessionDirectory(session);
|
await this.ensureScratchpadSessionDirectory(session);
|
||||||
workspace.sessions.unshift(session);
|
workspace.sessions.unshift(session);
|
||||||
workspace.selectedProjectId = project.id;
|
workspace.selectedProjectId = project.id;
|
||||||
workspace.selectedPatternId = undefined;
|
|
||||||
workspace.selectedWorkflowId = workflow.id;
|
workspace.selectedWorkflowId = workflow.id;
|
||||||
workspace.selectedSessionId = session.id;
|
workspace.selectedSessionId = session.id;
|
||||||
return this.persistAndBroadcast(workspace);
|
return this.persistAndBroadcast(workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async createWorkflowSession(projectId: string, workflowId: string): Promise<WorkspaceState> {
|
||||||
|
return this.createSession(projectId, workflowId);
|
||||||
|
}
|
||||||
|
|
||||||
async duplicateSession(sessionId: string): Promise<WorkspaceState> {
|
async duplicateSession(sessionId: string): Promise<WorkspaceState> {
|
||||||
const workspace = await this.loadWorkspace();
|
const workspace = await this.loadWorkspace();
|
||||||
const session = this.requireSession(workspace, sessionId);
|
const session = this.requireSession(workspace, sessionId);
|
||||||
@@ -1116,7 +992,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
await this.ensureScratchpadSessionDirectory(duplicate);
|
await this.ensureScratchpadSessionDirectory(duplicate);
|
||||||
workspace.sessions.unshift(duplicate);
|
workspace.sessions.unshift(duplicate);
|
||||||
workspace.selectedProjectId = duplicate.projectId;
|
workspace.selectedProjectId = duplicate.projectId;
|
||||||
workspace.selectedPatternId = duplicate.patternId;
|
workspace.selectedWorkflowId = duplicate.workflowId;
|
||||||
workspace.selectedSessionId = duplicate.id;
|
workspace.selectedSessionId = duplicate.id;
|
||||||
return this.persistAndBroadcast(workspace);
|
return this.persistAndBroadcast(workspace);
|
||||||
}
|
}
|
||||||
@@ -1124,8 +1000,8 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
async branchSession(sessionId: string, messageId: string): Promise<WorkspaceState> {
|
async branchSession(sessionId: string, messageId: string): Promise<WorkspaceState> {
|
||||||
const workspace = await this.loadWorkspace();
|
const workspace = await this.loadWorkspace();
|
||||||
const session = this.requireSession(workspace, sessionId);
|
const session = this.requireSession(workspace, sessionId);
|
||||||
const pattern = this.requirePattern(workspace, session.patternId);
|
const workflow = this.requireWorkflow(workspace, session.workflowId);
|
||||||
const branch = branchSessionRecord(session, pattern, createId('session'), messageId, nowIso());
|
const branch = branchSessionRecord(session, workflow, createId('session'), messageId, nowIso());
|
||||||
if (isScratchpadProject(branch.projectId)) {
|
if (isScratchpadProject(branch.projectId)) {
|
||||||
branch.cwd = undefined;
|
branch.cwd = undefined;
|
||||||
}
|
}
|
||||||
@@ -1133,7 +1009,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
await this.ensureScratchpadSessionDirectory(branch);
|
await this.ensureScratchpadSessionDirectory(branch);
|
||||||
workspace.sessions.unshift(branch);
|
workspace.sessions.unshift(branch);
|
||||||
workspace.selectedProjectId = branch.projectId;
|
workspace.selectedProjectId = branch.projectId;
|
||||||
workspace.selectedPatternId = branch.patternId;
|
workspace.selectedWorkflowId = branch.workflowId;
|
||||||
workspace.selectedSessionId = branch.id;
|
workspace.selectedSessionId = branch.id;
|
||||||
return this.persistAndBroadcast(workspace);
|
return this.persistAndBroadcast(workspace);
|
||||||
}
|
}
|
||||||
@@ -1213,16 +1089,16 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const project = this.requireProject(workspace, session.projectId);
|
const project = this.requireProject(workspace, session.projectId);
|
||||||
const { pattern, workflow } = this.resolveSessionExecutionDefinition(workspace, session);
|
const workflow = this.resolveSessionWorkflow(workspace, session);
|
||||||
const effectivePattern = this.applyProjectCustomizationToPattern(
|
const effectiveWorkflow = this.applyProjectCustomizationToWorkflow(
|
||||||
await this.buildEffectivePattern(pattern, session, workspace.settings.agents ?? []),
|
await this.buildEffectiveWorkflow(workflow, session, workspace.settings.agents ?? []),
|
||||||
project,
|
project,
|
||||||
);
|
);
|
||||||
const projectInstructions = resolveProjectInstructionsContent(project.customization);
|
const projectInstructions = resolveProjectInstructionsContent(project.customization);
|
||||||
const occurredAt = nowIso();
|
const occurredAt = nowIso();
|
||||||
const regeneratedSession = regenerateSessionRecord(
|
const regeneratedSession = regenerateSessionRecord(
|
||||||
session,
|
session,
|
||||||
effectivePattern,
|
effectiveWorkflow,
|
||||||
createId('session'),
|
createId('session'),
|
||||||
messageId,
|
messageId,
|
||||||
occurredAt,
|
occurredAt,
|
||||||
@@ -1234,7 +1110,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
await this.ensureScratchpadSessionDirectory(regeneratedSession);
|
await this.ensureScratchpadSessionDirectory(regeneratedSession);
|
||||||
workspace.sessions.unshift(regeneratedSession);
|
workspace.sessions.unshift(regeneratedSession);
|
||||||
workspace.selectedProjectId = regeneratedSession.projectId;
|
workspace.selectedProjectId = regeneratedSession.projectId;
|
||||||
workspace.selectedPatternId = regeneratedSession.patternId;
|
workspace.selectedWorkflowId = regeneratedSession.workflowId;
|
||||||
workspace.selectedSessionId = regeneratedSession.id;
|
workspace.selectedSessionId = regeneratedSession.id;
|
||||||
|
|
||||||
const triggerMessage = regeneratedSession.messages.at(-1);
|
const triggerMessage = regeneratedSession.messages.at(-1);
|
||||||
@@ -1246,14 +1122,13 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
workspace,
|
workspace,
|
||||||
regeneratedSession,
|
regeneratedSession,
|
||||||
project,
|
project,
|
||||||
effectivePattern,
|
effectiveWorkflow,
|
||||||
projectInstructions,
|
projectInstructions,
|
||||||
{
|
{
|
||||||
occurredAt,
|
occurredAt,
|
||||||
requestId: createId('turn'),
|
requestId: createId('turn'),
|
||||||
triggerMessageId: triggerMessage.id,
|
triggerMessageId: triggerMessage.id,
|
||||||
},
|
},
|
||||||
workflow,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1280,9 +1155,9 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const project = this.requireProject(workspace, session.projectId);
|
const project = this.requireProject(workspace, session.projectId);
|
||||||
const { pattern, workflow } = this.resolveSessionExecutionDefinition(workspace, session);
|
const workflow = this.resolveSessionWorkflow(workspace, session);
|
||||||
const effectivePattern = this.applyProjectCustomizationToPattern(
|
const effectiveWorkflow = this.applyProjectCustomizationToWorkflow(
|
||||||
await this.buildEffectivePattern(pattern, session, workspace.settings.agents ?? []),
|
await this.buildEffectiveWorkflow(workflow, session, workspace.settings.agents ?? []),
|
||||||
project,
|
project,
|
||||||
);
|
);
|
||||||
const projectInstructions = resolveProjectInstructionsContent(project.customization);
|
const projectInstructions = resolveProjectInstructionsContent(project.customization);
|
||||||
@@ -1290,7 +1165,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
const nextAttachments = attachments === undefined ? sourceMessage.attachments : attachments;
|
const nextAttachments = attachments === undefined ? sourceMessage.attachments : attachments;
|
||||||
const editedSession = editAndResendSessionRecord(
|
const editedSession = editAndResendSessionRecord(
|
||||||
session,
|
session,
|
||||||
effectivePattern,
|
effectiveWorkflow,
|
||||||
createId('session'),
|
createId('session'),
|
||||||
messageId,
|
messageId,
|
||||||
preparedContent,
|
preparedContent,
|
||||||
@@ -1304,7 +1179,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
await this.ensureScratchpadSessionDirectory(editedSession);
|
await this.ensureScratchpadSessionDirectory(editedSession);
|
||||||
workspace.sessions.unshift(editedSession);
|
workspace.sessions.unshift(editedSession);
|
||||||
workspace.selectedProjectId = editedSession.projectId;
|
workspace.selectedProjectId = editedSession.projectId;
|
||||||
workspace.selectedPatternId = editedSession.patternId;
|
workspace.selectedWorkflowId = editedSession.workflowId;
|
||||||
workspace.selectedSessionId = editedSession.id;
|
workspace.selectedSessionId = editedSession.id;
|
||||||
|
|
||||||
const triggerMessage = editedSession.messages.at(-1);
|
const triggerMessage = editedSession.messages.at(-1);
|
||||||
@@ -1316,14 +1191,13 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
workspace,
|
workspace,
|
||||||
editedSession,
|
editedSession,
|
||||||
project,
|
project,
|
||||||
effectivePattern,
|
effectiveWorkflow,
|
||||||
projectInstructions,
|
projectInstructions,
|
||||||
{
|
{
|
||||||
occurredAt,
|
occurredAt,
|
||||||
requestId: createId('turn'),
|
requestId: createId('turn'),
|
||||||
triggerMessageId: triggerMessage.id,
|
triggerMessageId: triggerMessage.id,
|
||||||
},
|
},
|
||||||
workflow,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1342,9 +1216,9 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
throw new Error('Wait for the current response or approval checkpoint to finish before sending another message.');
|
throw new Error('Wait for the current response or approval checkpoint to finish before sending another message.');
|
||||||
}
|
}
|
||||||
const project = this.requireProject(workspace, session.projectId);
|
const project = this.requireProject(workspace, session.projectId);
|
||||||
const { pattern, workflow } = this.resolveSessionExecutionDefinition(workspace, session);
|
const workflow = this.resolveSessionWorkflow(workspace, session);
|
||||||
const effectivePattern = this.applyProjectCustomizationToPattern(
|
const effectiveWorkflow = this.applyProjectCustomizationToWorkflow(
|
||||||
await this.buildEffectivePattern(pattern, session, workspace.settings.agents ?? []),
|
await this.buildEffectiveWorkflow(workflow, session, workspace.settings.agents ?? []),
|
||||||
project,
|
project,
|
||||||
);
|
);
|
||||||
const projectInstructions = resolveProjectInstructionsContent(project.customization);
|
const projectInstructions = resolveProjectInstructionsContent(project.customization);
|
||||||
@@ -1375,7 +1249,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
workspace,
|
workspace,
|
||||||
session,
|
session,
|
||||||
project,
|
project,
|
||||||
effectivePattern,
|
effectiveWorkflow,
|
||||||
projectInstructions,
|
projectInstructions,
|
||||||
{
|
{
|
||||||
occurredAt,
|
occurredAt,
|
||||||
@@ -1384,7 +1258,6 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
messageMode,
|
messageMode,
|
||||||
attachments,
|
attachments,
|
||||||
},
|
},
|
||||||
workflow,
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1572,10 +1445,13 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
const session = this.requireSession(workspace, sessionId);
|
const session = this.requireSession(workspace, sessionId);
|
||||||
const project = this.requireProject(workspace, session.projectId);
|
const project = this.requireProject(workspace, session.projectId);
|
||||||
const modelCatalog = await this.loadAvailableModelCatalog();
|
const modelCatalog = await this.loadAvailableModelCatalog();
|
||||||
const { pattern } = this.resolveSessionExecutionDefinition(workspace, session);
|
const workflow = normalizeWorkflowModels(
|
||||||
const effectivePattern = normalizePatternModels(pattern, modelCatalog);
|
this.buildResolvedExecutionWorkflow(workspace, this.requireWorkflow(workspace, session.workflowId)),
|
||||||
|
modelCatalog,
|
||||||
|
);
|
||||||
|
const agentNodes = resolveWorkflowAgentNodes(workflow);
|
||||||
|
|
||||||
if (effectivePattern.agents.length !== 1) {
|
if (agentNodes.length !== 1) {
|
||||||
throw new Error('Model override is only supported for single-agent sessions.');
|
throw new Error('Model override is only supported for single-agent sessions.');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1732,7 +1608,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
workspace: WorkspaceState,
|
workspace: WorkspaceState,
|
||||||
session: SessionRecord,
|
session: SessionRecord,
|
||||||
project: ProjectRecord,
|
project: ProjectRecord,
|
||||||
effectivePattern: PatternDefinition,
|
effectiveWorkflow: WorkflowDefinition,
|
||||||
projectInstructions: string | undefined,
|
projectInstructions: string | undefined,
|
||||||
options: {
|
options: {
|
||||||
occurredAt: string;
|
occurredAt: string;
|
||||||
@@ -1741,12 +1617,11 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
messageMode?: MessageMode;
|
messageMode?: MessageMode;
|
||||||
attachments?: ChatMessageAttachment[];
|
attachments?: ChatMessageAttachment[];
|
||||||
},
|
},
|
||||||
effectiveWorkflow?: WorkflowDefinition,
|
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const workspaceKind = isScratchpadProject(project) ? 'scratchpad' : 'project';
|
const workspaceKind = isScratchpadProject(project) ? 'scratchpad' : 'project';
|
||||||
const { occurredAt, requestId, triggerMessageId, messageMode, attachments } = options;
|
const { occurredAt, requestId, triggerMessageId, messageMode, attachments } = options;
|
||||||
const promptInvocation = this.resolveRunTurnPromptInvocation(session, triggerMessageId);
|
const promptInvocation = this.resolveRunTurnPromptInvocation(session, triggerMessageId);
|
||||||
const patternForTurn = await this.applyPromptInvocationToPattern(effectivePattern, promptInvocation);
|
const workflowForTurn = await this.applyPromptInvocationToWorkflow(effectiveWorkflow, promptInvocation);
|
||||||
const interactionMode: InteractionMode = isPlanPromptInvocation(promptInvocation)
|
const interactionMode: InteractionMode = isPlanPromptInvocation(promptInvocation)
|
||||||
? 'plan'
|
? 'plan'
|
||||||
: session.interactionMode ?? 'interactive';
|
: session.interactionMode ?? 'interactive';
|
||||||
@@ -1761,7 +1636,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
console.warn(`[aryx git] Failed to capture pre-run git snapshot for project "${project.id}".`);
|
console.warn(`[aryx git] Failed to capture pre-run git snapshot for project "${project.id}".`);
|
||||||
}
|
}
|
||||||
|
|
||||||
session.title = resolveSessionTitle(session, patternForTurn, session.messages);
|
session.title = resolveSessionTitle(session, workflowForTurn, session.messages);
|
||||||
session.status = 'running';
|
session.status = 'running';
|
||||||
session.lastError = undefined;
|
session.lastError = undefined;
|
||||||
session.pendingPlanReview = undefined;
|
session.pendingPlanReview = undefined;
|
||||||
@@ -1773,8 +1648,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
project,
|
project,
|
||||||
workingDirectory: runWorkingDirectory,
|
workingDirectory: runWorkingDirectory,
|
||||||
workspaceKind,
|
workspaceKind,
|
||||||
pattern: patternForTurn,
|
workflow: workflowForTurn,
|
||||||
workflow: effectiveWorkflow ? { id: effectiveWorkflow.id, name: effectiveWorkflow.name } : undefined,
|
|
||||||
triggerMessageId,
|
triggerMessageId,
|
||||||
startedAt: occurredAt,
|
startedAt: occurredAt,
|
||||||
preRunGitSnapshot,
|
preRunGitSnapshot,
|
||||||
@@ -1803,9 +1677,8 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
mode: interactionMode,
|
mode: interactionMode,
|
||||||
messageMode,
|
messageMode,
|
||||||
projectInstructions,
|
projectInstructions,
|
||||||
pattern: patternForTurn,
|
workflow: workflowForTurn,
|
||||||
workflow: effectiveWorkflow,
|
workflowLibrary: workspace.workflows,
|
||||||
workflowLibrary: effectiveWorkflow ? workspace.workflows : undefined,
|
|
||||||
messages: session.messages,
|
messages: session.messages,
|
||||||
attachments: attachments?.length ? attachments : undefined,
|
attachments: attachments?.length ? attachments : undefined,
|
||||||
promptInvocation,
|
promptInvocation,
|
||||||
@@ -1846,7 +1719,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.awaitFinalResponseApproval(workspace, session.id, requestId, effectivePattern, responseMessages);
|
await this.awaitFinalResponseApproval(workspace, session.id, requestId, workflowForTurn, responseMessages);
|
||||||
this.finalizeTurn(workspace, session.id, requestId, responseMessages);
|
this.finalizeTurn(workspace, session.id, requestId, responseMessages);
|
||||||
if (workspaceKind === 'project') {
|
if (workspaceKind === 'project') {
|
||||||
const completedRun = await this.refreshSessionRunGitSummary(session, project, requestId, nowIso());
|
const completedRun = await this.refreshSessionRunGitSummary(session, project, requestId, nowIso());
|
||||||
@@ -2194,13 +2067,6 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
return this.persistAndBroadcast(workspace);
|
return this.persistAndBroadcast(workspace);
|
||||||
}
|
}
|
||||||
|
|
||||||
async selectPattern(patternId?: string): Promise<WorkspaceState> {
|
|
||||||
const workspace = await this.loadWorkspace();
|
|
||||||
workspace.selectedPatternId = patternId;
|
|
||||||
workspace.selectedSessionId = workspace.selectedSessionId;
|
|
||||||
return this.persistAndBroadcast(workspace);
|
|
||||||
}
|
|
||||||
|
|
||||||
async selectSession(sessionId?: string): Promise<WorkspaceState> {
|
async selectSession(sessionId?: string): Promise<WorkspaceState> {
|
||||||
const workspace = await this.loadWorkspace();
|
const workspace = await this.loadWorkspace();
|
||||||
if (sessionId) {
|
if (sessionId) {
|
||||||
@@ -2365,15 +2231,6 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private requirePattern(workspace: WorkspaceState, patternId: string): PatternDefinition {
|
|
||||||
const pattern = workspace.patterns.find((current) => current.id === patternId);
|
|
||||||
if (!pattern) {
|
|
||||||
throw new Error(`Pattern "${patternId}" was not found.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return pattern;
|
|
||||||
}
|
|
||||||
|
|
||||||
private requireWorkflowTemplate(workspace: WorkspaceState, templateId: string): WorkflowTemplateDefinition {
|
private requireWorkflowTemplate(workspace: WorkspaceState, templateId: string): WorkflowTemplateDefinition {
|
||||||
const template = workspace.workflowTemplates.find((current) => current.id === templateId);
|
const template = workspace.workflowTemplates.find((current) => current.id === templateId);
|
||||||
if (!template) {
|
if (!template) {
|
||||||
@@ -2417,32 +2274,32 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
return normalized || createId(fallbackPrefix);
|
return normalized || createId(fallbackPrefix);
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveSessionExecutionDefinition(
|
private resolveSessionWorkflow(
|
||||||
workspace: WorkspaceState,
|
workspace: WorkspaceState,
|
||||||
session: SessionRecord,
|
session: SessionRecord,
|
||||||
): { pattern: PatternDefinition; workflow?: WorkflowDefinition } {
|
): WorkflowDefinition {
|
||||||
if (session.workflowId) {
|
return this.requireWorkflow(workspace, session.workflowId);
|
||||||
const workflow = this.requireWorkflow(workspace, session.workflowId);
|
|
||||||
return {
|
|
||||||
workflow,
|
|
||||||
pattern: this.buildResolvedWorkflowExecutionPattern(workspace, workflow),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
pattern: this.requirePattern(workspace, session.patternId),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private buildResolvedWorkflowExecutionPattern(
|
private buildResolvedExecutionWorkflow(
|
||||||
workspace: WorkspaceState,
|
workspace: WorkspaceState,
|
||||||
workflow: WorkflowDefinition,
|
workflow: WorkflowDefinition,
|
||||||
): PatternDefinition {
|
): WorkflowDefinition {
|
||||||
return buildWorkflowExecutionPattern(workflow, {
|
return normalizeWorkflowDefinition({
|
||||||
resolveWorkflow: (workflowId) => workspace.workflows.find((candidate) => candidate.id === workflowId),
|
...workflow,
|
||||||
|
settings: {
|
||||||
|
...workflow.settings,
|
||||||
|
approvalPolicy: applyDefaultToolApprovalPolicy(workflow.settings.approvalPolicy),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private createWorkflowResolutionOptions(workspace: WorkspaceState) {
|
||||||
|
return {
|
||||||
|
resolveWorkflow: (workflowId: string) => workspace.workflows.find((candidate) => candidate.id === workflowId),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
private validateWorkflowReferences(
|
private validateWorkflowReferences(
|
||||||
workspace: WorkspaceState,
|
workspace: WorkspaceState,
|
||||||
workflow: WorkflowDefinition,
|
workflow: WorkflowDefinition,
|
||||||
@@ -2712,15 +2569,19 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
|
|
||||||
private emitCompletedActivity(
|
private emitCompletedActivity(
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
pattern: PatternDefinition,
|
workflow: WorkflowDefinition,
|
||||||
message: ChatMessageRecord,
|
message: ChatMessageRecord,
|
||||||
): void {
|
): void {
|
||||||
if (message.role !== 'assistant') {
|
if (message.role !== 'assistant') {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const agent = pattern.agents.find((candidate) =>
|
const agentNode = resolveWorkflowAgentNodes(workflow)
|
||||||
candidate.id === message.authorName || candidate.name === message.authorName);
|
.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) {
|
if (!agent) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -2742,7 +2603,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
messages: ChatMessageRecord[],
|
messages: ChatMessageRecord[],
|
||||||
): void {
|
): void {
|
||||||
const session = this.requireSession(workspace, sessionId);
|
const session = this.requireSession(workspace, sessionId);
|
||||||
const { pattern } = this.resolveSessionExecutionDefinition(workspace, session);
|
const workflow = this.resolveSessionWorkflow(workspace, session);
|
||||||
const incomingIds = new Set(messages.map((message) => message.id));
|
const incomingIds = new Set(messages.map((message) => message.id));
|
||||||
|
|
||||||
// Messages that were streamed during the turn already exist in session.messages
|
// Messages that were streamed during the turn already exist in session.messages
|
||||||
@@ -2807,7 +2668,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
this.emitRunUpdated(sessionId, occurredAt, nextRun);
|
this.emitRunUpdated(sessionId, occurredAt, nextRun);
|
||||||
}
|
}
|
||||||
|
|
||||||
this.emitCompletedActivity(sessionId, pattern, message);
|
this.emitCompletedActivity(sessionId, workflow, message);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const message of session.messages) {
|
for (const message of session.messages) {
|
||||||
@@ -3275,10 +3136,10 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
workspace: WorkspaceState,
|
workspace: WorkspaceState,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
requestId: string,
|
requestId: string,
|
||||||
pattern: PatternDefinition,
|
workflow: WorkflowDefinition,
|
||||||
messages: ChatMessageRecord[],
|
messages: ChatMessageRecord[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const pendingApproval = this.buildFinalResponseApproval(pattern, messages);
|
const pendingApproval = this.buildFinalResponseApproval(workflow, messages);
|
||||||
if (!pendingApproval) {
|
if (!pendingApproval) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -3305,7 +3166,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private buildFinalResponseApproval(
|
private buildFinalResponseApproval(
|
||||||
pattern: PatternDefinition,
|
workflow: WorkflowDefinition,
|
||||||
messages: ChatMessageRecord[],
|
messages: ChatMessageRecord[],
|
||||||
): PendingApprovalRecord | undefined {
|
): PendingApprovalRecord | undefined {
|
||||||
const assistantMessages = messages.filter((message) => message.role === 'assistant');
|
const assistantMessages = messages.filter((message) => message.role === 'assistant');
|
||||||
@@ -3325,9 +3186,13 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const agent = pattern.agents.find((candidate) =>
|
const agentNode = resolveWorkflowAgentNodes(workflow)
|
||||||
candidate.id === message.authorName || candidate.name === message.authorName);
|
.find((candidate) =>
|
||||||
if (!approvalPolicyRequiresCheckpoint(pattern.approvalPolicy, 'final-response', agent?.id)) {
|
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;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3363,28 +3228,28 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async buildEffectivePattern(
|
private async buildEffectiveWorkflow(
|
||||||
pattern: PatternDefinition,
|
workflow: WorkflowDefinition,
|
||||||
session: SessionRecord,
|
session: SessionRecord,
|
||||||
workspaceAgents: ReadonlyArray<WorkspaceAgentDefinition>,
|
workspaceAgents: ReadonlyArray<WorkspaceAgentDefinition>,
|
||||||
): Promise<PatternDefinition> {
|
): Promise<WorkflowDefinition> {
|
||||||
const resolvedPattern = resolvePatternAgents(pattern, workspaceAgents);
|
const resolvedWorkflow = resolveWorkspaceWorkflowAgents(workflow, workspaceAgents);
|
||||||
const patternWithSessionConfig = session.sessionModelConfig
|
const workflowWithSessionConfig = session.sessionModelConfig
|
||||||
? applySessionModelConfig(resolvedPattern, session)
|
? applySessionModelConfig(resolvedWorkflow, session)
|
||||||
: resolvedPattern;
|
: resolvedWorkflow;
|
||||||
const patternWithApprovalSettings = applySessionApprovalSettings(patternWithSessionConfig, session);
|
const workflowWithApprovalSettings = applySessionApprovalSettings(workflowWithSessionConfig, session);
|
||||||
|
|
||||||
const modelCatalog = await this.loadAvailableModelCatalog();
|
const modelCatalog = await this.loadAvailableModelCatalog();
|
||||||
return normalizePatternModels(patternWithApprovalSettings, modelCatalog);
|
return normalizeWorkflowModels(workflowWithApprovalSettings, modelCatalog);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async applyPromptInvocationToPattern(
|
private async applyPromptInvocationToWorkflow(
|
||||||
pattern: PatternDefinition,
|
workflow: WorkflowDefinition,
|
||||||
promptInvocation?: ProjectPromptInvocation,
|
promptInvocation?: ProjectPromptInvocation,
|
||||||
): Promise<PatternDefinition> {
|
): Promise<WorkflowDefinition> {
|
||||||
const requestedModel = promptInvocation?.model?.trim();
|
const requestedModel = promptInvocation?.model?.trim();
|
||||||
if (!requestedModel) {
|
if (!requestedModel) {
|
||||||
return pattern;
|
return workflow;
|
||||||
}
|
}
|
||||||
|
|
||||||
const modelCatalog = await this.loadAvailableModelCatalog();
|
const modelCatalog = await this.loadAvailableModelCatalog();
|
||||||
@@ -3392,7 +3257,12 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
const effectiveModelId = resolvedModel?.id ?? requestedModel;
|
const effectiveModelId = resolvedModel?.id ?? requestedModel;
|
||||||
|
|
||||||
let didChange = false;
|
let didChange = false;
|
||||||
const agents = pattern.agents.map((agent) => {
|
const nodes = workflow.graph.nodes.map((node) => {
|
||||||
|
if (node.kind !== 'agent' || node.config.kind !== 'agent') {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
const agent = node.config;
|
||||||
// When overriding the model, re-normalize reasoning effort for the target model.
|
// When overriding the model, re-normalize reasoning effort for the target model.
|
||||||
// If the target model's reasoning capabilities are unknown (supportedReasoningEfforts
|
// If the target model's reasoning capabilities are unknown (supportedReasoningEfforts
|
||||||
// is undefined — common for dynamically-discovered models), strip reasoning effort
|
// is undefined — common for dynamically-discovered models), strip reasoning effort
|
||||||
@@ -3405,39 +3275,50 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (agent.model === effectiveModelId && agent.reasoningEffort === reasoningEffort) {
|
if (agent.model === effectiveModelId && agent.reasoningEffort === reasoningEffort) {
|
||||||
return agent;
|
return node;
|
||||||
}
|
}
|
||||||
|
|
||||||
didChange = true;
|
didChange = true;
|
||||||
return {
|
return {
|
||||||
...agent,
|
...node,
|
||||||
model: effectiveModelId,
|
config: {
|
||||||
reasoningEffort,
|
...agent,
|
||||||
|
model: effectiveModelId,
|
||||||
|
reasoningEffort,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return didChange ? { ...pattern, agents } : pattern;
|
return didChange
|
||||||
|
? {
|
||||||
|
...workflow,
|
||||||
|
graph: {
|
||||||
|
...workflow.graph,
|
||||||
|
nodes,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: workflow;
|
||||||
}
|
}
|
||||||
|
|
||||||
private applyProjectCustomizationToPattern(
|
private applyProjectCustomizationToWorkflow(
|
||||||
pattern: PatternDefinition,
|
workflow: WorkflowDefinition,
|
||||||
project: ProjectRecord,
|
project: ProjectRecord,
|
||||||
): PatternDefinition {
|
): WorkflowDefinition {
|
||||||
if (isScratchpadProject(project)) {
|
if (isScratchpadProject(project)) {
|
||||||
return pattern;
|
return workflow;
|
||||||
}
|
}
|
||||||
|
|
||||||
const projectCustomAgents = this.buildProjectCustomAgents(project.customization);
|
const projectCustomAgents = this.buildProjectCustomAgents(project.customization);
|
||||||
if (projectCustomAgents.length === 0) {
|
if (projectCustomAgents.length === 0) {
|
||||||
return pattern;
|
return workflow;
|
||||||
}
|
}
|
||||||
|
|
||||||
const [primaryAgent, ...remainingAgents] = pattern.agents;
|
const primaryAgentNode = resolveWorkflowAgentNodes(workflow)[0];
|
||||||
if (!primaryAgent) {
|
if (!primaryAgentNode || primaryAgentNode.config.kind !== 'agent') {
|
||||||
return pattern;
|
return workflow;
|
||||||
}
|
}
|
||||||
|
|
||||||
const existingCustomAgents = primaryAgent.copilot?.customAgents ?? [];
|
const existingCustomAgents = primaryAgentNode.config.copilot?.customAgents ?? [];
|
||||||
const existingAgentNames = new Set(existingCustomAgents.map((agent) => agent.name.toLowerCase()));
|
const existingAgentNames = new Set(existingCustomAgents.map((agent) => agent.name.toLowerCase()));
|
||||||
const mergedCustomAgents = [
|
const mergedCustomAgents = [
|
||||||
...existingCustomAgents,
|
...existingCustomAgents,
|
||||||
@@ -3445,17 +3326,26 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
];
|
];
|
||||||
|
|
||||||
return {
|
return {
|
||||||
...pattern,
|
...workflow,
|
||||||
agents: [
|
graph: {
|
||||||
{
|
...workflow.graph,
|
||||||
...primaryAgent,
|
nodes: workflow.graph.nodes.map((node) => {
|
||||||
copilot: {
|
if (node.id !== primaryAgentNode.id || node.kind !== 'agent' || node.config.kind !== 'agent') {
|
||||||
...primaryAgent.copilot,
|
return node;
|
||||||
customAgents: mergedCustomAgents,
|
}
|
||||||
},
|
|
||||||
},
|
return {
|
||||||
...remainingAgents,
|
...node,
|
||||||
],
|
config: {
|
||||||
|
...node.config,
|
||||||
|
copilot: {
|
||||||
|
...node.config.copilot,
|
||||||
|
customAgents: mergedCustomAgents,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -3511,13 +3401,13 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
|
|||||||
);
|
);
|
||||||
let changed = false;
|
let changed = false;
|
||||||
|
|
||||||
for (const pattern of workspace.patterns) {
|
for (const workflow of workspace.workflows) {
|
||||||
const nextPolicy = pruneApprovalPolicyTools(pattern.approvalPolicy, workspaceKnownToolNames);
|
const nextPolicy = pruneApprovalPolicyTools(workflow.settings.approvalPolicy, workspaceKnownToolNames);
|
||||||
if (!equalStringArrays(
|
if (!equalStringArrays(
|
||||||
pattern.approvalPolicy?.autoApprovedToolNames,
|
workflow.settings.approvalPolicy?.autoApprovedToolNames,
|
||||||
nextPolicy?.autoApprovedToolNames,
|
nextPolicy?.autoApprovedToolNames,
|
||||||
)) {
|
)) {
|
||||||
pattern.approvalPolicy = nextPolicy;
|
workflow.settings.approvalPolicy = nextPolicy;
|
||||||
changed = true;
|
changed = true;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,12 +37,10 @@ import type {
|
|||||||
ResolveSessionUserInputInput,
|
ResolveSessionUserInputInput,
|
||||||
SaveLspProfileInput,
|
SaveLspProfileInput,
|
||||||
SaveMcpServerInput,
|
SaveMcpServerInput,
|
||||||
SavePatternInput,
|
|
||||||
SaveWorkflowInput,
|
SaveWorkflowInput,
|
||||||
SaveWorkflowTemplateInput,
|
SaveWorkflowTemplateInput,
|
||||||
SaveWorkspaceAgentInput,
|
SaveWorkspaceAgentInput,
|
||||||
SendSessionMessageInput,
|
SendSessionMessageInput,
|
||||||
SetPatternFavoriteInput,
|
|
||||||
SetProjectAgentProfileEnabledInput,
|
SetProjectAgentProfileEnabledInput,
|
||||||
SetSessionArchivedInput,
|
SetSessionArchivedInput,
|
||||||
SetSessionInteractionModeInput,
|
SetSessionInteractionModeInput,
|
||||||
@@ -53,7 +51,6 @@ import type {
|
|||||||
UpdateSessionModelConfigInput,
|
UpdateSessionModelConfigInput,
|
||||||
UpdateSessionApprovalSettingsInput,
|
UpdateSessionApprovalSettingsInput,
|
||||||
UpdateSessionToolingInput,
|
UpdateSessionToolingInput,
|
||||||
UpgradePatternToWorkflowInput,
|
|
||||||
} from '@shared/contracts/ipc';
|
} from '@shared/contracts/ipc';
|
||||||
import type { QuerySessionsInput } from '@shared/domain/sessionLibrary';
|
import type { QuerySessionsInput } from '@shared/domain/sessionLibrary';
|
||||||
import type { AppearanceTheme } from '@shared/domain/tooling';
|
import type { AppearanceTheme } from '@shared/domain/tooling';
|
||||||
@@ -114,11 +111,6 @@ export function registerIpcHandlers(
|
|||||||
(_event, input: SetProjectAgentProfileEnabledInput) =>
|
(_event, input: SetProjectAgentProfileEnabledInput) =>
|
||||||
service.setProjectAgentProfileEnabled(input.projectId, input.agentProfileId, input.enabled),
|
service.setProjectAgentProfileEnabled(input.projectId, input.agentProfileId, input.enabled),
|
||||||
);
|
);
|
||||||
ipcMain.handle(ipcChannels.savePattern, (_event, input: SavePatternInput) => service.savePattern(input.pattern));
|
|
||||||
ipcMain.handle(ipcChannels.deletePattern, (_event, patternId: string) => service.deletePattern(patternId));
|
|
||||||
ipcMain.handle(ipcChannels.setPatternFavorite, (_event, input: SetPatternFavoriteInput) =>
|
|
||||||
service.setPatternFavorite(input.patternId, input.isFavorite),
|
|
||||||
);
|
|
||||||
ipcMain.handle(ipcChannels.saveWorkflow, (_event, input: SaveWorkflowInput) => service.saveWorkflow(input.workflow));
|
ipcMain.handle(ipcChannels.saveWorkflow, (_event, input: SaveWorkflowInput) => service.saveWorkflow(input.workflow));
|
||||||
ipcMain.handle(ipcChannels.saveWorkflowTemplate, (_event, input: SaveWorkflowTemplateInput) =>
|
ipcMain.handle(ipcChannels.saveWorkflowTemplate, (_event, input: SaveWorkflowTemplateInput) =>
|
||||||
service.saveWorkflowTemplate(input.workflowId, input.options),
|
service.saveWorkflowTemplate(input.workflowId, input.options),
|
||||||
@@ -136,9 +128,6 @@ export function registerIpcHandlers(
|
|||||||
ipcMain.handle(ipcChannels.importWorkflow, (_event, input: ImportWorkflowInput) =>
|
ipcMain.handle(ipcChannels.importWorkflow, (_event, input: ImportWorkflowInput) =>
|
||||||
service.importWorkflow(input.content, input.format, input.options),
|
service.importWorkflow(input.content, input.format, input.options),
|
||||||
);
|
);
|
||||||
ipcMain.handle(ipcChannels.upgradePatternToWorkflow, (_event, input: UpgradePatternToWorkflowInput) =>
|
|
||||||
service.upgradePatternToWorkflow(input.patternId, input.options),
|
|
||||||
);
|
|
||||||
ipcMain.handle(ipcChannels.setTheme, async (_event, theme: AppearanceTheme) => {
|
ipcMain.handle(ipcChannels.setTheme, async (_event, theme: AppearanceTheme) => {
|
||||||
const result = await service.setTheme(theme);
|
const result = await service.setTheme(theme);
|
||||||
applyTitleBarTheme(window, theme);
|
applyTitleBarTheme(window, theme);
|
||||||
@@ -205,7 +194,7 @@ export function registerIpcHandlers(
|
|||||||
service.updateSessionApprovalSettings(input.sessionId, input.autoApprovedToolNames),
|
service.updateSessionApprovalSettings(input.sessionId, input.autoApprovedToolNames),
|
||||||
);
|
);
|
||||||
ipcMain.handle(ipcChannels.createSession, (_event, input: CreateSessionInput) =>
|
ipcMain.handle(ipcChannels.createSession, (_event, input: CreateSessionInput) =>
|
||||||
service.createSession(input.projectId, input.patternId),
|
service.createSession(input.projectId, input.workflowId),
|
||||||
);
|
);
|
||||||
ipcMain.handle(ipcChannels.createWorkflowSession, (_event, input: CreateWorkflowSessionInput) =>
|
ipcMain.handle(ipcChannels.createWorkflowSession, (_event, input: CreateWorkflowSessionInput) =>
|
||||||
service.createWorkflowSession(input.projectId, input.workflowId),
|
service.createWorkflowSession(input.projectId, input.workflowId),
|
||||||
@@ -321,7 +310,6 @@ export function registerIpcHandlers(
|
|||||||
);
|
);
|
||||||
ipcMain.handle(ipcChannels.querySessions, (_event, input: QuerySessionsInput) => service.querySessions(input));
|
ipcMain.handle(ipcChannels.querySessions, (_event, input: QuerySessionsInput) => service.querySessions(input));
|
||||||
ipcMain.handle(ipcChannels.selectProject, (_event, projectId?: string) => service.selectProject(projectId));
|
ipcMain.handle(ipcChannels.selectProject, (_event, projectId?: string) => service.selectProject(projectId));
|
||||||
ipcMain.handle(ipcChannels.selectPattern, (_event, patternId?: string) => service.selectPattern(patternId));
|
|
||||||
ipcMain.handle(ipcChannels.selectSession, (_event, sessionId?: string) => service.selectSession(sessionId));
|
ipcMain.handle(ipcChannels.selectSession, (_event, sessionId?: string) => service.selectSession(sessionId));
|
||||||
ipcMain.handle(ipcChannels.openAppDataFolder, () => service.openAppDataFolder());
|
ipcMain.handle(ipcChannels.openAppDataFolder, () => service.openAppDataFolder());
|
||||||
ipcMain.handle(ipcChannels.resetLocalWorkspace, () => service.resetLocalWorkspace());
|
ipcMain.handle(ipcChannels.resetLocalWorkspace, () => service.resetLocalWorkspace());
|
||||||
|
|||||||
@@ -1,8 +1,5 @@
|
|||||||
import { mkdir } from 'node:fs/promises';
|
import { mkdir } from 'node:fs/promises';
|
||||||
|
|
||||||
import { createBuiltinPatterns, resolvePatternGraph } from '@shared/domain/pattern';
|
|
||||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
|
||||||
import { isScratchpadProject, mergeScratchpadProject } from '@shared/domain/project';
|
|
||||||
import { normalizeDiscoveredToolingState } from '@shared/domain/discoveredTooling';
|
import { normalizeDiscoveredToolingState } from '@shared/domain/discoveredTooling';
|
||||||
import { normalizeProjectCustomizationState } from '@shared/domain/projectCustomization';
|
import { normalizeProjectCustomizationState } from '@shared/domain/projectCustomization';
|
||||||
import { normalizeSessionRunRecords } from '@shared/domain/runTimeline';
|
import { normalizeSessionRunRecords } from '@shared/domain/runTimeline';
|
||||||
@@ -20,13 +17,18 @@ import {
|
|||||||
normalizeWorkflowTemplateDefinition,
|
normalizeWorkflowTemplateDefinition,
|
||||||
type WorkflowTemplateDefinition,
|
type WorkflowTemplateDefinition,
|
||||||
} from '@shared/domain/workflowTemplate';
|
} from '@shared/domain/workflowTemplate';
|
||||||
import { normalizeWorkflowDefinition } from '@shared/domain/workflow';
|
import {
|
||||||
|
createBuiltinWorkflows,
|
||||||
|
normalizeWorkflowDefinition,
|
||||||
|
type WorkflowDefinition,
|
||||||
|
} from '@shared/domain/workflow';
|
||||||
import {
|
import {
|
||||||
applyDefaultToolApprovalPolicy,
|
applyDefaultToolApprovalPolicy,
|
||||||
normalizePendingApprovalState,
|
normalizePendingApprovalState,
|
||||||
normalizeSessionApprovalSettings,
|
normalizeSessionApprovalSettings,
|
||||||
} from '@shared/domain/approval';
|
} from '@shared/domain/approval';
|
||||||
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
||||||
|
import { isScratchpadProject, mergeScratchpadProject } from '@shared/domain/project';
|
||||||
import { nowIso } from '@shared/utils/ids';
|
import { nowIso } from '@shared/utils/ids';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
@@ -36,31 +38,31 @@ import {
|
|||||||
} from '@main/persistence/appPaths';
|
} from '@main/persistence/appPaths';
|
||||||
import { readJsonFile, writeJsonFile } from '@main/persistence/jsonStore';
|
import { readJsonFile, writeJsonFile } from '@main/persistence/jsonStore';
|
||||||
|
|
||||||
function mergePatterns(existingPatterns: PatternDefinition[], deletedBuiltinIds: string[]): PatternDefinition[] {
|
function mergeBuiltinWorkflows(existingWorkflows: WorkflowDefinition[]): WorkflowDefinition[] {
|
||||||
const builtinTimestamp = nowIso();
|
const builtinWorkflows = createBuiltinWorkflows(nowIso());
|
||||||
const builtinPatterns = createBuiltinPatterns(builtinTimestamp);
|
const builtinIds = new Set(builtinWorkflows.map((workflow) => workflow.id));
|
||||||
const builtinIds = new Set(builtinPatterns.map((pattern) => pattern.id));
|
const existingMap = new Map(existingWorkflows.map((workflow) => [workflow.id, workflow]));
|
||||||
const deletedSet = new Set(deletedBuiltinIds);
|
|
||||||
const existingMap = new Map(existingPatterns.map((pattern) => [pattern.id, pattern]));
|
|
||||||
|
|
||||||
const mergedBuiltins = builtinPatterns
|
const mergedBuiltins = builtinWorkflows.map((builtin) => {
|
||||||
.filter((builtin) => !deletedSet.has(builtin.id))
|
|
||||||
.map((builtin) => {
|
|
||||||
const existing = existingMap.get(builtin.id);
|
const existing = existingMap.get(builtin.id);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
return builtin;
|
return builtin;
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return normalizeWorkflowDefinition({
|
||||||
...existing,
|
...existing,
|
||||||
availability: builtin.availability,
|
settings: {
|
||||||
unavailabilityReason: builtin.unavailabilityReason,
|
...existing.settings,
|
||||||
mode: builtin.mode,
|
orchestrationMode: builtin.settings.orchestrationMode,
|
||||||
};
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
const customPatterns = existingPatterns.filter((pattern) => !builtinIds.has(pattern.id));
|
const customWorkflows = existingWorkflows
|
||||||
return [...mergedBuiltins, ...customPatterns];
|
.filter((workflow) => !builtinIds.has(workflow.id))
|
||||||
|
.map(normalizeWorkflowDefinition);
|
||||||
|
|
||||||
|
return [...mergedBuiltins, ...customWorkflows];
|
||||||
}
|
}
|
||||||
|
|
||||||
function mergeWorkflowTemplates(existingTemplates: WorkflowTemplateDefinition[]): WorkflowTemplateDefinition[] {
|
function mergeWorkflowTemplates(existingTemplates: WorkflowTemplateDefinition[]): WorkflowTemplateDefinition[] {
|
||||||
@@ -73,6 +75,28 @@ function mergeWorkflowTemplates(existingTemplates: WorkflowTemplateDefinition[])
|
|||||||
return [...builtinTemplates, ...customTemplates];
|
return [...builtinTemplates, ...customTemplates];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function migrateLegacySessions(
|
||||||
|
sessions: SessionRecord[],
|
||||||
|
workflows: WorkflowDefinition[],
|
||||||
|
): SessionRecord[] {
|
||||||
|
const workflowIds = new Set(workflows.map((workflow) => workflow.id));
|
||||||
|
const fallbackWorkflowId = workflows[0]?.id;
|
||||||
|
|
||||||
|
return sessions.flatMap((session) => {
|
||||||
|
const workflowId = session.workflowId && workflowIds.has(session.workflowId)
|
||||||
|
? session.workflowId
|
||||||
|
: fallbackWorkflowId;
|
||||||
|
if (!workflowId) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [{
|
||||||
|
...session,
|
||||||
|
workflowId,
|
||||||
|
}];
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
export class WorkspaceRepository {
|
export class WorkspaceRepository {
|
||||||
readonly filePath = getWorkspaceFilePath();
|
readonly filePath = getWorkspaceFilePath();
|
||||||
readonly scratchpadPath = getScratchpadDirectoryPath();
|
readonly scratchpadPath = getScratchpadDirectoryPath();
|
||||||
@@ -80,7 +104,7 @@ export class WorkspaceRepository {
|
|||||||
async load(): Promise<WorkspaceState> {
|
async load(): Promise<WorkspaceState> {
|
||||||
await mkdir(this.scratchpadPath, { recursive: true });
|
await mkdir(this.scratchpadPath, { recursive: true });
|
||||||
|
|
||||||
const stored = await readJsonFile<WorkspaceState>(this.filePath);
|
const stored = await readJsonFile<WorkspaceState & { patterns?: unknown[] }>(this.filePath);
|
||||||
if (!stored) {
|
if (!stored) {
|
||||||
const seededBase = createWorkspaceSeed();
|
const seededBase = createWorkspaceSeed();
|
||||||
const projects = mergeScratchpadProject([], this.scratchpadPath);
|
const projects = mergeScratchpadProject([], this.scratchpadPath);
|
||||||
@@ -101,50 +125,58 @@ export class WorkspaceRepository {
|
|||||||
})),
|
})),
|
||||||
this.scratchpadPath,
|
this.scratchpadPath,
|
||||||
);
|
);
|
||||||
const sessions = await Promise.all((stored.sessions ?? []).map(async (session): Promise<SessionRecord> => {
|
|
||||||
const normalizedSession: SessionRecord = {
|
|
||||||
...session,
|
|
||||||
messages: (session.messages ?? []).map(normalizeChatMessageRecord),
|
|
||||||
branchOrigin: normalizeSessionBranchOrigin(session.branchOrigin),
|
|
||||||
runs: normalizeSessionRunRecords(session.runs),
|
|
||||||
tooling: normalizeSessionToolingSelection(session.tooling),
|
|
||||||
approvalSettings: normalizeSessionApprovalSettings(session.approvalSettings),
|
|
||||||
...normalizePendingApprovalState({
|
|
||||||
pendingApproval: session.pendingApproval,
|
|
||||||
pendingApprovalQueue: session.pendingApprovalQueue,
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
if (!isScratchpadProject(normalizedSession.projectId)) {
|
|
||||||
return normalizedSession;
|
|
||||||
}
|
|
||||||
|
|
||||||
const cwd = normalizedSession.cwd ?? getScratchpadSessionPath(normalizedSession.id);
|
const workflows = mergeBuiltinWorkflows((stored.workflows ?? []).map(normalizeWorkflowDefinition))
|
||||||
await mkdir(cwd, { recursive: true });
|
.map((workflow) => ({
|
||||||
return {
|
...workflow,
|
||||||
...normalizedSession,
|
settings: {
|
||||||
cwd,
|
...workflow.settings,
|
||||||
};
|
approvalPolicy: applyDefaultToolApprovalPolicy(workflow.settings.approvalPolicy),
|
||||||
}));
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
const sessions = migrateLegacySessions(
|
||||||
|
await Promise.all((stored.sessions ?? []).map(async (session): Promise<SessionRecord> => {
|
||||||
|
const normalizedSession: SessionRecord = {
|
||||||
|
...session,
|
||||||
|
messages: (session.messages ?? []).map(normalizeChatMessageRecord),
|
||||||
|
branchOrigin: normalizeSessionBranchOrigin(session.branchOrigin),
|
||||||
|
runs: normalizeSessionRunRecords(session.runs),
|
||||||
|
tooling: normalizeSessionToolingSelection(session.tooling),
|
||||||
|
approvalSettings: normalizeSessionApprovalSettings(session.approvalSettings),
|
||||||
|
...normalizePendingApprovalState({
|
||||||
|
pendingApproval: session.pendingApproval,
|
||||||
|
pendingApprovalQueue: session.pendingApprovalQueue,
|
||||||
|
}),
|
||||||
|
};
|
||||||
|
if (!isScratchpadProject(normalizedSession.projectId)) {
|
||||||
|
return normalizedSession;
|
||||||
|
}
|
||||||
|
|
||||||
|
const cwd = normalizedSession.cwd ?? getScratchpadSessionPath(normalizedSession.id);
|
||||||
|
await mkdir(cwd, { recursive: true });
|
||||||
|
return {
|
||||||
|
...normalizedSession,
|
||||||
|
cwd,
|
||||||
|
};
|
||||||
|
})),
|
||||||
|
workflows,
|
||||||
|
);
|
||||||
|
|
||||||
const settings = normalizeWorkspaceSettings(stored.settings);
|
const settings = normalizeWorkspaceSettings(stored.settings);
|
||||||
|
|
||||||
const deletedBuiltinPatternIds = stored.deletedBuiltinPatternIds ?? [];
|
|
||||||
|
|
||||||
const workspace: WorkspaceState = {
|
const workspace: WorkspaceState = {
|
||||||
...stored,
|
...stored,
|
||||||
patterns: mergePatterns(stored.patterns ?? [], deletedBuiltinPatternIds).map((pattern) => ({
|
workflows,
|
||||||
...pattern,
|
|
||||||
approvalPolicy: applyDefaultToolApprovalPolicy(pattern.approvalPolicy),
|
|
||||||
graph: resolvePatternGraph(pattern),
|
|
||||||
})),
|
|
||||||
workflows: (stored.workflows ?? []).map(normalizeWorkflowDefinition),
|
|
||||||
workflowTemplates: mergeWorkflowTemplates(stored.workflowTemplates ?? []),
|
workflowTemplates: mergeWorkflowTemplates(stored.workflowTemplates ?? []),
|
||||||
projects,
|
projects,
|
||||||
sessions,
|
sessions,
|
||||||
settings,
|
settings,
|
||||||
deletedBuiltinPatternIds,
|
|
||||||
selectedProjectId: projects.some((project) => project.id === stored.selectedProjectId)
|
selectedProjectId: projects.some((project) => project.id === stored.selectedProjectId)
|
||||||
? stored.selectedProjectId
|
? stored.selectedProjectId
|
||||||
: projects[0]?.id,
|
: projects[0]?.id,
|
||||||
|
selectedWorkflowId: workflows.some((workflow) => workflow.id === stored.selectedWorkflowId)
|
||||||
|
? stored.selectedWorkflowId
|
||||||
|
: workflows[0]?.id,
|
||||||
lastUpdatedAt: stored.lastUpdatedAt ?? nowIso(),
|
lastUpdatedAt: stored.lastUpdatedAt ?? nowIso(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,6 @@ import type {
|
|||||||
UserInputRequestedEvent,
|
UserInputRequestedEvent,
|
||||||
McpOauthRequiredEvent,
|
McpOauthRequiredEvent,
|
||||||
ExitPlanModeRequestedEvent,
|
ExitPlanModeRequestedEvent,
|
||||||
ValidatePatternCommand,
|
|
||||||
ValidateWorkflowCommand,
|
ValidateWorkflowCommand,
|
||||||
RunTurnCommand,
|
RunTurnCommand,
|
||||||
CopilotSessionListFilter,
|
CopilotSessionListFilter,
|
||||||
@@ -41,12 +40,6 @@ type PendingCommand =
|
|||||||
resolve: (capabilities: SidecarCapabilities) => void;
|
resolve: (capabilities: SidecarCapabilities) => void;
|
||||||
reject: (error: Error) => void;
|
reject: (error: Error) => void;
|
||||||
})
|
})
|
||||||
| ({
|
|
||||||
processId: number;
|
|
||||||
kind: 'validate-pattern';
|
|
||||||
resolve: (issues: ValidatePatternCommand['pattern'] extends never ? never : unknown) => void;
|
|
||||||
reject: (error: Error) => void;
|
|
||||||
})
|
|
||||||
| ({
|
| ({
|
||||||
processId: number;
|
processId: number;
|
||||||
kind: 'validate-workflow';
|
kind: 'validate-workflow';
|
||||||
@@ -126,14 +119,6 @@ export class SidecarClient {
|
|||||||
return command;
|
return command;
|
||||||
}
|
}
|
||||||
|
|
||||||
async validatePattern(pattern: ValidatePatternCommand['pattern']): Promise<unknown> {
|
|
||||||
return this.dispatch<unknown>({
|
|
||||||
type: 'validate-pattern',
|
|
||||||
requestId: `validate-${Date.now()}`,
|
|
||||||
pattern,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async validateWorkflow(
|
async validateWorkflow(
|
||||||
workflow: ValidateWorkflowCommand['workflow'],
|
workflow: ValidateWorkflowCommand['workflow'],
|
||||||
workflowLibrary?: ValidateWorkflowCommand['workflowLibrary'],
|
workflowLibrary?: ValidateWorkflowCommand['workflowLibrary'],
|
||||||
@@ -329,13 +314,6 @@ export class SidecarClient {
|
|||||||
onTurnScopedEvent: onTurnScopedEvent ?? (() => undefined),
|
onTurnScopedEvent: onTurnScopedEvent ?? (() => undefined),
|
||||||
errored: false,
|
errored: false,
|
||||||
});
|
});
|
||||||
} else if (command.type === 'validate-pattern') {
|
|
||||||
this.pending.set(command.requestId, {
|
|
||||||
processId: state.id,
|
|
||||||
kind: 'validate-pattern',
|
|
||||||
resolve: resolve as (issues: unknown) => void,
|
|
||||||
reject,
|
|
||||||
});
|
|
||||||
} else if (command.type === 'validate-workflow') {
|
} else if (command.type === 'validate-workflow') {
|
||||||
this.pending.set(command.requestId, {
|
this.pending.set(command.requestId, {
|
||||||
processId: state.id,
|
processId: state.id,
|
||||||
@@ -433,12 +411,6 @@ export class SidecarClient {
|
|||||||
this.pending.delete(event.requestId);
|
this.pending.delete(event.requestId);
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
case 'pattern-validation':
|
|
||||||
if (pending.kind === 'validate-pattern') {
|
|
||||||
pending.resolve(event.issues);
|
|
||||||
this.pending.delete(event.requestId);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
case 'workflow-validation':
|
case 'workflow-validation':
|
||||||
if (pending.kind === 'validate-workflow') {
|
if (pending.kind === 'validate-workflow') {
|
||||||
pending.resolve(event.issues);
|
pending.resolve(event.issues);
|
||||||
|
|||||||
@@ -23,9 +23,6 @@ const api: ElectronApi = {
|
|||||||
ipcRenderer.invoke(ipcChannels.resolveProjectDiscoveredTooling, input),
|
ipcRenderer.invoke(ipcChannels.resolveProjectDiscoveredTooling, input),
|
||||||
setProjectAgentProfileEnabled: (input) =>
|
setProjectAgentProfileEnabled: (input) =>
|
||||||
ipcRenderer.invoke(ipcChannels.setProjectAgentProfileEnabled, input),
|
ipcRenderer.invoke(ipcChannels.setProjectAgentProfileEnabled, input),
|
||||||
savePattern: (input) => ipcRenderer.invoke(ipcChannels.savePattern, input),
|
|
||||||
deletePattern: (patternId) => ipcRenderer.invoke(ipcChannels.deletePattern, patternId),
|
|
||||||
setPatternFavorite: (input) => ipcRenderer.invoke(ipcChannels.setPatternFavorite, input),
|
|
||||||
saveWorkflow: (input) => ipcRenderer.invoke(ipcChannels.saveWorkflow, input),
|
saveWorkflow: (input) => ipcRenderer.invoke(ipcChannels.saveWorkflow, input),
|
||||||
saveWorkflowTemplate: (input) => ipcRenderer.invoke(ipcChannels.saveWorkflowTemplate, input),
|
saveWorkflowTemplate: (input) => ipcRenderer.invoke(ipcChannels.saveWorkflowTemplate, input),
|
||||||
deleteWorkflow: (workflowId) => ipcRenderer.invoke(ipcChannels.deleteWorkflow, workflowId),
|
deleteWorkflow: (workflowId) => ipcRenderer.invoke(ipcChannels.deleteWorkflow, workflowId),
|
||||||
@@ -33,7 +30,6 @@ const api: ElectronApi = {
|
|||||||
createWorkflowFromTemplate: (input) => ipcRenderer.invoke(ipcChannels.createWorkflowFromTemplate, input),
|
createWorkflowFromTemplate: (input) => ipcRenderer.invoke(ipcChannels.createWorkflowFromTemplate, input),
|
||||||
exportWorkflow: (input) => ipcRenderer.invoke(ipcChannels.exportWorkflow, input),
|
exportWorkflow: (input) => ipcRenderer.invoke(ipcChannels.exportWorkflow, input),
|
||||||
importWorkflow: (input) => ipcRenderer.invoke(ipcChannels.importWorkflow, input),
|
importWorkflow: (input) => ipcRenderer.invoke(ipcChannels.importWorkflow, input),
|
||||||
upgradePatternToWorkflow: (input) => ipcRenderer.invoke(ipcChannels.upgradePatternToWorkflow, input),
|
|
||||||
createWorkflowSession: (input) => ipcRenderer.invoke(ipcChannels.createWorkflowSession, input),
|
createWorkflowSession: (input) => ipcRenderer.invoke(ipcChannels.createWorkflowSession, input),
|
||||||
setTheme: (theme) => ipcRenderer.invoke(ipcChannels.setTheme, theme),
|
setTheme: (theme) => ipcRenderer.invoke(ipcChannels.setTheme, theme),
|
||||||
setTerminalHeight: (input) => ipcRenderer.invoke(ipcChannels.setTerminalHeight, input),
|
setTerminalHeight: (input) => ipcRenderer.invoke(ipcChannels.setTerminalHeight, input),
|
||||||
@@ -94,7 +90,6 @@ const api: ElectronApi = {
|
|||||||
switchProjectGitBranch: (input) => ipcRenderer.invoke(ipcChannels.switchProjectGitBranch, input),
|
switchProjectGitBranch: (input) => ipcRenderer.invoke(ipcChannels.switchProjectGitBranch, input),
|
||||||
deleteProjectGitBranch: (input) => ipcRenderer.invoke(ipcChannels.deleteProjectGitBranch, input),
|
deleteProjectGitBranch: (input) => ipcRenderer.invoke(ipcChannels.deleteProjectGitBranch, input),
|
||||||
selectProject: (projectId) => ipcRenderer.invoke(ipcChannels.selectProject, projectId),
|
selectProject: (projectId) => ipcRenderer.invoke(ipcChannels.selectProject, projectId),
|
||||||
selectPattern: (patternId) => ipcRenderer.invoke(ipcChannels.selectPattern, patternId),
|
|
||||||
selectSession: (sessionId) => ipcRenderer.invoke(ipcChannels.selectSession, sessionId),
|
selectSession: (sessionId) => ipcRenderer.invoke(ipcChannels.selectSession, sessionId),
|
||||||
openAppDataFolder: () => ipcRenderer.invoke(ipcChannels.openAppDataFolder),
|
openAppDataFolder: () => ipcRenderer.invoke(ipcChannels.openAppDataFolder),
|
||||||
resetLocalWorkspace: () => ipcRenderer.invoke(ipcChannels.resetLocalWorkspace),
|
resetLocalWorkspace: () => ipcRenderer.invoke(ipcChannels.resetLocalWorkspace),
|
||||||
|
|||||||
@@ -556,7 +556,7 @@ export function InlineApprovalPill({
|
|||||||
|
|
||||||
{open && !disabled && (
|
{open && !disabled && (
|
||||||
<div className="absolute bottom-full left-0 z-40 mb-1.5 max-h-[28rem] w-80 overflow-y-auto rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] shadow-2xl">
|
<div className="absolute bottom-full left-0 z-40 mb-1.5 max-h-[28rem] w-80 overflow-y-auto rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-1)] shadow-2xl">
|
||||||
{/* Header: session override / pattern defaults */}
|
{/* Header: session override / workflow defaults */}
|
||||||
<div className="sticky top-0 z-10 border-b border-[var(--color-border)] bg-[var(--color-surface-1)]">
|
<div className="sticky top-0 z-10 border-b border-[var(--color-border)] bg-[var(--color-surface-1)]">
|
||||||
<div className="flex items-center gap-2 px-3 py-2">
|
<div className="flex items-center gap-2 px-3 py-2">
|
||||||
<span className={`rounded-full px-2 py-0.5 text-[9px] font-semibold uppercase tracking-wider ${
|
<span className={`rounded-full px-2 py-0.5 text-[9px] font-semibold uppercase tracking-wider ${
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { ModelSelect, ReasoningEffortSelect } from '@renderer/components/AgentCo
|
|||||||
import { findModel, type ModelDefinition } from '@shared/domain/models';
|
import { findModel, type ModelDefinition } from '@shared/domain/models';
|
||||||
import { resolveReasoningEffort } from '@shared/domain/models';
|
import { resolveReasoningEffort } from '@shared/domain/models';
|
||||||
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
|
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
|
||||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
import type { WorkflowDefinition } from '@shared/domain/workflow';
|
||||||
import { findWorkspaceAgentUsages } from '@shared/domain/workspaceAgent';
|
import { findWorkspaceAgentUsages } from '@shared/domain/workspaceAgent';
|
||||||
import { ToolingEditorShell } from './ToolingEditorShell';
|
import { ToolingEditorShell } from './ToolingEditorShell';
|
||||||
import { Link2, Workflow } from 'lucide-react';
|
import { Link2, Workflow } from 'lucide-react';
|
||||||
@@ -21,7 +21,7 @@ export function WorkspaceAgentEditor({
|
|||||||
onSave,
|
onSave,
|
||||||
onDelete,
|
onDelete,
|
||||||
availableModels,
|
availableModels,
|
||||||
patterns,
|
workflows,
|
||||||
}: {
|
}: {
|
||||||
agent: WorkspaceAgentDefinition;
|
agent: WorkspaceAgentDefinition;
|
||||||
onChange: (agent: WorkspaceAgentDefinition) => void;
|
onChange: (agent: WorkspaceAgentDefinition) => void;
|
||||||
@@ -29,10 +29,10 @@ export function WorkspaceAgentEditor({
|
|||||||
onSave: () => Promise<void>;
|
onSave: () => Promise<void>;
|
||||||
onDelete?: () => Promise<void>;
|
onDelete?: () => Promise<void>;
|
||||||
availableModels: ReadonlyArray<ModelDefinition>;
|
availableModels: ReadonlyArray<ModelDefinition>;
|
||||||
patterns: PatternDefinition[];
|
workflows: WorkflowDefinition[];
|
||||||
}) {
|
}) {
|
||||||
const validationError = validateWorkspaceAgent(agent);
|
const validationError = validateWorkspaceAgent(agent);
|
||||||
const usages = findWorkspaceAgentUsages(agent.id, patterns);
|
const usages = findWorkspaceAgentUsages(agent.id, workflows);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ToolingEditorShell
|
<ToolingEditorShell
|
||||||
@@ -117,22 +117,22 @@ export function WorkspaceAgentEditor({
|
|||||||
{usages.map((usage) => (
|
{usages.map((usage) => (
|
||||||
<div
|
<div
|
||||||
className="flex items-center gap-2.5 border-b border-[var(--color-border)] px-3.5 py-2.5 last:border-b-0"
|
className="flex items-center gap-2.5 border-b border-[var(--color-border)] px-3.5 py-2.5 last:border-b-0"
|
||||||
key={usage.patternId}
|
key={usage.workflowId}
|
||||||
>
|
>
|
||||||
<Workflow className="size-3.5 shrink-0 text-[var(--color-text-muted)]" />
|
<Workflow className="size-3.5 shrink-0 text-[var(--color-text-muted)]" />
|
||||||
<span className="text-[13px] text-[var(--color-text-secondary)]">
|
<span className="text-[13px] text-[var(--color-text-secondary)]">
|
||||||
{usage.patternName}
|
{usage.workflowName}
|
||||||
</span>
|
</span>
|
||||||
<Link2 className="ml-auto size-3 text-[var(--color-accent)]" />
|
<Link2 className="ml-auto size-3 text-[var(--color-accent)]" />
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-[var(--color-text-muted)]">
|
<p className="text-[11px] text-[var(--color-text-muted)]">
|
||||||
Referenced by {usages.length} pattern{usages.length === 1 ? '' : 's'}.
|
Referenced by {usages.length} workflow{usages.length === 1 ? '' : 's'}.
|
||||||
Changes to this agent will affect all linked patterns.
|
Changes to this agent will affect all linked workflows.
|
||||||
</p>
|
</p>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
</ToolingEditorShell>
|
</ToolingEditorShell>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,9 +12,6 @@ export const ipcChannels = {
|
|||||||
rescanProjectCustomization: 'project:rescan-customization',
|
rescanProjectCustomization: 'project:rescan-customization',
|
||||||
resolveProjectDiscoveredTooling: 'project:resolve-discovered-tooling',
|
resolveProjectDiscoveredTooling: 'project:resolve-discovered-tooling',
|
||||||
setProjectAgentProfileEnabled: 'project:set-agent-profile-enabled',
|
setProjectAgentProfileEnabled: 'project:set-agent-profile-enabled',
|
||||||
savePattern: 'patterns:save',
|
|
||||||
deletePattern: 'patterns:delete',
|
|
||||||
setPatternFavorite: 'patterns:set-favorite',
|
|
||||||
saveWorkflow: 'workflows:save',
|
saveWorkflow: 'workflows:save',
|
||||||
saveWorkflowTemplate: 'workflows:save-template',
|
saveWorkflowTemplate: 'workflows:save-template',
|
||||||
deleteWorkflow: 'workflows:delete',
|
deleteWorkflow: 'workflows:delete',
|
||||||
@@ -22,7 +19,6 @@ export const ipcChannels = {
|
|||||||
createWorkflowFromTemplate: 'workflows:create-from-template',
|
createWorkflowFromTemplate: 'workflows:create-from-template',
|
||||||
exportWorkflow: 'workflows:export',
|
exportWorkflow: 'workflows:export',
|
||||||
importWorkflow: 'workflows:import',
|
importWorkflow: 'workflows:import',
|
||||||
upgradePatternToWorkflow: 'workflows:upgrade-pattern',
|
|
||||||
createWorkflowSession: 'workflows:create-session',
|
createWorkflowSession: 'workflows:create-session',
|
||||||
setTheme: 'settings:set-theme',
|
setTheme: 'settings:set-theme',
|
||||||
setTerminalHeight: 'settings:set-terminal-height',
|
setTerminalHeight: 'settings:set-terminal-height',
|
||||||
@@ -77,7 +73,6 @@ export const ipcChannels = {
|
|||||||
switchProjectGitBranch: 'git:switch-branch',
|
switchProjectGitBranch: 'git:switch-branch',
|
||||||
deleteProjectGitBranch: 'git:delete-branch',
|
deleteProjectGitBranch: 'git:delete-branch',
|
||||||
selectProject: 'selection:project',
|
selectProject: 'selection:project',
|
||||||
selectPattern: 'selection:pattern',
|
|
||||||
selectSession: 'selection:session',
|
selectSession: 'selection:session',
|
||||||
openAppDataFolder: 'troubleshooting:open-app-data-folder',
|
openAppDataFolder: 'troubleshooting:open-app-data-folder',
|
||||||
resetLocalWorkspace: 'troubleshooting:reset-local-workspace',
|
resetLocalWorkspace: 'troubleshooting:reset-local-workspace',
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
import type { ApprovalDecision } from '@shared/domain/approval';
|
import type { ApprovalDecision } from '@shared/domain/approval';
|
||||||
import type { SidecarCapabilities, InteractionMode, MessageMode, QuotaSnapshot } from '@shared/contracts/sidecar';
|
import type { SidecarCapabilities, InteractionMode, MessageMode, QuotaSnapshot } from '@shared/contracts/sidecar';
|
||||||
import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
|
|
||||||
import type { WorkflowExportFormat, WorkflowExportResult } from '@shared/domain/workflowSerialization';
|
import type { WorkflowExportFormat, WorkflowExportResult } from '@shared/domain/workflowSerialization';
|
||||||
import type { WorkflowTemplateCategory } from '@shared/domain/workflowTemplate';
|
import type { WorkflowTemplateCategory } from '@shared/domain/workflowTemplate';
|
||||||
import type { WorkflowDefinition, WorkflowReference } from '@shared/domain/workflow';
|
import type { ReasoningEffort, WorkflowDefinition, WorkflowReference } from '@shared/domain/workflow';
|
||||||
import type {
|
import type {
|
||||||
ProjectGitBranchSummary,
|
ProjectGitBranchSummary,
|
||||||
ProjectGitCommitMessageSuggestion,
|
ProjectGitCommitMessageSuggestion,
|
||||||
@@ -27,18 +26,11 @@ import type { ProjectPromptInvocation } from '@shared/domain/projectCustomizatio
|
|||||||
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
|
import type { WorkspaceAgentDefinition } from '@shared/domain/workspaceAgent';
|
||||||
|
|
||||||
export interface CreateSessionInput {
|
export interface CreateSessionInput {
|
||||||
projectId: string;
|
|
||||||
patternId: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface CreateWorkflowSessionInput {
|
|
||||||
projectId: string;
|
projectId: string;
|
||||||
workflowId: string;
|
workflowId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SavePatternInput {
|
export type CreateWorkflowSessionInput = CreateSessionInput;
|
||||||
pattern: PatternDefinition;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SaveWorkflowInput {
|
export interface SaveWorkflowInput {
|
||||||
workflow: WorkflowDefinition;
|
workflow: WorkflowDefinition;
|
||||||
@@ -76,16 +68,6 @@ export interface ImportWorkflowInput {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface UpgradePatternToWorkflowInput {
|
|
||||||
patternId: string;
|
|
||||||
options?: {
|
|
||||||
workflowId?: string;
|
|
||||||
name?: string;
|
|
||||||
description?: string;
|
|
||||||
save?: boolean;
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImportWorkflowResult {
|
export interface ImportWorkflowResult {
|
||||||
workflow: WorkflowDefinition;
|
workflow: WorkflowDefinition;
|
||||||
workspace?: WorkspaceState;
|
workspace?: WorkspaceState;
|
||||||
@@ -165,11 +147,6 @@ export interface SetSessionArchivedInput {
|
|||||||
isArchived: boolean;
|
isArchived: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SetPatternFavoriteInput {
|
|
||||||
patternId: string;
|
|
||||||
isFavorite: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface SaveMcpServerInput {
|
export interface SaveMcpServerInput {
|
||||||
server: McpServerDefinition;
|
server: McpServerDefinition;
|
||||||
}
|
}
|
||||||
@@ -331,8 +308,6 @@ export interface ElectronApi {
|
|||||||
rescanProjectCustomization(input: RescanProjectCustomizationInput): Promise<WorkspaceState>;
|
rescanProjectCustomization(input: RescanProjectCustomizationInput): Promise<WorkspaceState>;
|
||||||
resolveProjectDiscoveredTooling(input: ResolveProjectDiscoveredToolingInput): Promise<WorkspaceState>;
|
resolveProjectDiscoveredTooling(input: ResolveProjectDiscoveredToolingInput): Promise<WorkspaceState>;
|
||||||
setProjectAgentProfileEnabled(input: SetProjectAgentProfileEnabledInput): Promise<WorkspaceState>;
|
setProjectAgentProfileEnabled(input: SetProjectAgentProfileEnabledInput): Promise<WorkspaceState>;
|
||||||
savePattern(input: SavePatternInput): Promise<WorkspaceState>;
|
|
||||||
deletePattern(patternId: string): Promise<WorkspaceState>;
|
|
||||||
saveWorkflow(input: SaveWorkflowInput): Promise<WorkspaceState>;
|
saveWorkflow(input: SaveWorkflowInput): Promise<WorkspaceState>;
|
||||||
saveWorkflowTemplate(input: SaveWorkflowTemplateInput): Promise<WorkspaceState>;
|
saveWorkflowTemplate(input: SaveWorkflowTemplateInput): Promise<WorkspaceState>;
|
||||||
deleteWorkflow(workflowId: string): Promise<WorkspaceState>;
|
deleteWorkflow(workflowId: string): Promise<WorkspaceState>;
|
||||||
@@ -340,7 +315,6 @@ export interface ElectronApi {
|
|||||||
createWorkflowFromTemplate(input: CreateWorkflowFromTemplateInput): Promise<WorkspaceState>;
|
createWorkflowFromTemplate(input: CreateWorkflowFromTemplateInput): Promise<WorkspaceState>;
|
||||||
exportWorkflow(input: ExportWorkflowInput): Promise<WorkflowExportResult>;
|
exportWorkflow(input: ExportWorkflowInput): Promise<WorkflowExportResult>;
|
||||||
importWorkflow(input: ImportWorkflowInput): Promise<ImportWorkflowResult>;
|
importWorkflow(input: ImportWorkflowInput): Promise<ImportWorkflowResult>;
|
||||||
upgradePatternToWorkflow(input: UpgradePatternToWorkflowInput): Promise<ImportWorkflowResult>;
|
|
||||||
saveMcpServer(input: SaveMcpServerInput): Promise<WorkspaceState>;
|
saveMcpServer(input: SaveMcpServerInput): Promise<WorkspaceState>;
|
||||||
deleteMcpServer(serverId: string): Promise<WorkspaceState>;
|
deleteMcpServer(serverId: string): Promise<WorkspaceState>;
|
||||||
saveLspProfile(input: SaveLspProfileInput): Promise<WorkspaceState>;
|
saveLspProfile(input: SaveLspProfileInput): Promise<WorkspaceState>;
|
||||||
@@ -371,9 +345,7 @@ export interface ElectronApi {
|
|||||||
updateSessionModelConfig(input: UpdateSessionModelConfigInput): Promise<WorkspaceState>;
|
updateSessionModelConfig(input: UpdateSessionModelConfigInput): Promise<WorkspaceState>;
|
||||||
querySessions(input: QuerySessionsInput): Promise<SessionQueryResult[]>;
|
querySessions(input: QuerySessionsInput): Promise<SessionQueryResult[]>;
|
||||||
selectProject(projectId?: string): Promise<WorkspaceState>;
|
selectProject(projectId?: string): Promise<WorkspaceState>;
|
||||||
selectPattern(patternId?: string): Promise<WorkspaceState>;
|
|
||||||
selectSession(sessionId?: string): Promise<WorkspaceState>;
|
selectSession(sessionId?: string): Promise<WorkspaceState>;
|
||||||
setPatternFavorite(input: SetPatternFavoriteInput): Promise<WorkspaceState>;
|
|
||||||
setTheme(theme: AppearanceTheme): Promise<WorkspaceState>;
|
setTheme(theme: AppearanceTheme): Promise<WorkspaceState>;
|
||||||
setTerminalHeight(input: SetTerminalHeightInput): Promise<WorkspaceState>;
|
setTerminalHeight(input: SetTerminalHeightInput): Promise<WorkspaceState>;
|
||||||
setNotificationsEnabled(enabled: boolean): Promise<WorkspaceState>;
|
setNotificationsEnabled(enabled: boolean): Promise<WorkspaceState>;
|
||||||
@@ -413,5 +385,5 @@ export interface ElectronApi {
|
|||||||
|
|
||||||
export interface RendererSelectionState {
|
export interface RendererSelectionState {
|
||||||
selectedProject?: ProjectRecord;
|
selectedProject?: ProjectRecord;
|
||||||
selectedPattern?: PatternDefinition;
|
selectedWorkflow?: WorkflowDefinition;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
import type { PatternDefinition, PatternValidationIssue, ReasoningEffort } from '@shared/domain/pattern';
|
|
||||||
import type { ApprovalCheckpointKind, ApprovalDecision } from '@shared/domain/approval';
|
import type { ApprovalCheckpointKind, ApprovalDecision } from '@shared/domain/approval';
|
||||||
import type { ChatMessageRecord } from '@shared/domain/session';
|
import type { ChatMessageRecord } from '@shared/domain/session';
|
||||||
import type { RuntimeToolDefinition } from '@shared/domain/tooling';
|
import type { RuntimeToolDefinition } from '@shared/domain/tooling';
|
||||||
import type { ChatMessageAttachment } from '@shared/domain/attachment';
|
import type { ChatMessageAttachment } from '@shared/domain/attachment';
|
||||||
import type { ProjectPromptInvocation } from '@shared/domain/projectCustomization';
|
import type { ProjectPromptInvocation } from '@shared/domain/projectCustomization';
|
||||||
import type { WorkflowDefinition, WorkflowValidationIssue } from '@shared/domain/workflow';
|
import type {
|
||||||
|
ReasoningEffort,
|
||||||
|
WorkflowDefinition,
|
||||||
|
WorkflowOrchestrationMode,
|
||||||
|
WorkflowValidationIssue,
|
||||||
|
} from '@shared/domain/workflow';
|
||||||
|
|
||||||
export interface SidecarModeCapability {
|
export interface SidecarModeCapability {
|
||||||
available: boolean;
|
available: boolean;
|
||||||
@@ -54,7 +58,7 @@ export interface SidecarModelCapability {
|
|||||||
|
|
||||||
export interface SidecarCapabilities {
|
export interface SidecarCapabilities {
|
||||||
runtime: 'dotnet-maf';
|
runtime: 'dotnet-maf';
|
||||||
modes: Record<PatternDefinition['mode'], SidecarModeCapability>;
|
modes: Record<WorkflowOrchestrationMode | 'magentic', SidecarModeCapability>;
|
||||||
models: SidecarModelCapability[];
|
models: SidecarModelCapability[];
|
||||||
runtimeTools: RuntimeToolDefinition[];
|
runtimeTools: RuntimeToolDefinition[];
|
||||||
connection: SidecarConnectionDiagnostics;
|
connection: SidecarConnectionDiagnostics;
|
||||||
@@ -65,12 +69,6 @@ export interface DescribeCapabilitiesCommand {
|
|||||||
requestId: string;
|
requestId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface ValidatePatternCommand {
|
|
||||||
type: 'validate-pattern';
|
|
||||||
requestId: string;
|
|
||||||
pattern: PatternDefinition;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ValidateWorkflowCommand {
|
export interface ValidateWorkflowCommand {
|
||||||
type: 'validate-workflow';
|
type: 'validate-workflow';
|
||||||
requestId: string;
|
requestId: string;
|
||||||
@@ -96,8 +94,7 @@ export interface RunTurnCommand {
|
|||||||
mode?: InteractionMode;
|
mode?: InteractionMode;
|
||||||
messageMode?: MessageMode;
|
messageMode?: MessageMode;
|
||||||
projectInstructions?: string;
|
projectInstructions?: string;
|
||||||
pattern: PatternDefinition;
|
workflow: WorkflowDefinition;
|
||||||
workflow?: WorkflowDefinition;
|
|
||||||
workflowLibrary?: WorkflowDefinition[];
|
workflowLibrary?: WorkflowDefinition[];
|
||||||
messages: ChatMessageRecord[];
|
messages: ChatMessageRecord[];
|
||||||
attachments?: ChatMessageAttachment[];
|
attachments?: ChatMessageAttachment[];
|
||||||
@@ -156,7 +153,6 @@ export interface CopilotSessionListFilter {
|
|||||||
|
|
||||||
export type SidecarCommand =
|
export type SidecarCommand =
|
||||||
| DescribeCapabilitiesCommand
|
| DescribeCapabilitiesCommand
|
||||||
| ValidatePatternCommand
|
|
||||||
| ValidateWorkflowCommand
|
| ValidateWorkflowCommand
|
||||||
| RunTurnCommand
|
| RunTurnCommand
|
||||||
| CancelTurnCommand
|
| CancelTurnCommand
|
||||||
@@ -235,12 +231,6 @@ export interface CapabilitiesEvent {
|
|||||||
capabilities: SidecarCapabilities;
|
capabilities: SidecarCapabilities;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PatternValidationEvent {
|
|
||||||
type: 'pattern-validation';
|
|
||||||
requestId: string;
|
|
||||||
issues: PatternValidationIssue[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkflowValidationEvent {
|
export interface WorkflowValidationEvent {
|
||||||
type: 'workflow-validation';
|
type: 'workflow-validation';
|
||||||
requestId: string;
|
requestId: string;
|
||||||
@@ -608,7 +598,6 @@ export interface CommandCompleteEvent {
|
|||||||
|
|
||||||
export type SidecarEvent =
|
export type SidecarEvent =
|
||||||
| CapabilitiesEvent
|
| CapabilitiesEvent
|
||||||
| PatternValidationEvent
|
|
||||||
| WorkflowValidationEvent
|
| WorkflowValidationEvent
|
||||||
| TurnDeltaEvent
|
| TurnDeltaEvent
|
||||||
| TurnCompleteEvent
|
| TurnCompleteEvent
|
||||||
|
|||||||
+24
-10
@@ -1,5 +1,5 @@
|
|||||||
import type { SidecarModelCapability } from '@shared/contracts/sidecar';
|
import type { SidecarModelCapability } from '@shared/contracts/sidecar';
|
||||||
import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
|
import type { ReasoningEffort, WorkflowDefinition } from '@shared/domain/workflow';
|
||||||
|
|
||||||
export type ModelProvider = 'openai' | 'anthropic' | 'google';
|
export type ModelProvider = 'openai' | 'anthropic' | 'google';
|
||||||
|
|
||||||
@@ -270,17 +270,31 @@ export function resolveReasoningEffort(
|
|||||||
return supported[0];
|
return supported[0];
|
||||||
}
|
}
|
||||||
|
|
||||||
export function normalizePatternModels(
|
export function normalizeWorkflowModels(
|
||||||
pattern: PatternDefinition,
|
workflow: WorkflowDefinition,
|
||||||
models: ReadonlyArray<ModelDefinition>,
|
models: ReadonlyArray<ModelDefinition>,
|
||||||
): PatternDefinition {
|
): WorkflowDefinition {
|
||||||
return {
|
return {
|
||||||
...pattern,
|
...workflow,
|
||||||
agents: pattern.agents.map((agent) => {
|
graph: {
|
||||||
const model = findModel(agent.model, models);
|
...workflow.graph,
|
||||||
const reasoningEffort = resolveReasoningEffort(model, agent.reasoningEffort);
|
nodes: workflow.graph.nodes.map((node) => {
|
||||||
|
if (node.kind !== 'agent' || node.config.kind !== 'agent') {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
return reasoningEffort === agent.reasoningEffort ? agent : { ...agent, reasoningEffort };
|
const model = findModel(node.config.model, models);
|
||||||
}),
|
const reasoningEffort = resolveReasoningEffort(model, node.config.reasoningEffort);
|
||||||
|
return reasoningEffort === node.config.reasoningEffort
|
||||||
|
? node
|
||||||
|
: {
|
||||||
|
...node,
|
||||||
|
config: {
|
||||||
|
...node.config,
|
||||||
|
reasoningEffort,
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import type {
|
|||||||
PendingApprovalRecord,
|
PendingApprovalRecord,
|
||||||
} from '@shared/domain/approval';
|
} from '@shared/domain/approval';
|
||||||
import type { ToolCallFileChangePreview } from '@shared/contracts/sidecar';
|
import type { ToolCallFileChangePreview } from '@shared/contracts/sidecar';
|
||||||
import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
|
|
||||||
import type {
|
import type {
|
||||||
ProjectGitBaselineFile,
|
ProjectGitBaselineFile,
|
||||||
ProjectGitChangeSummary,
|
ProjectGitChangeSummary,
|
||||||
@@ -17,7 +16,13 @@ import type {
|
|||||||
ProjectGitWorkingTreeSnapshot,
|
ProjectGitWorkingTreeSnapshot,
|
||||||
ProjectRecord,
|
ProjectRecord,
|
||||||
} from '@shared/domain/project';
|
} from '@shared/domain/project';
|
||||||
import type { WorkflowDefinition } from '@shared/domain/workflow';
|
import type {
|
||||||
|
AgentNodeConfig,
|
||||||
|
ReasoningEffort,
|
||||||
|
WorkflowDefinition,
|
||||||
|
WorkflowOrchestrationMode,
|
||||||
|
} from '@shared/domain/workflow';
|
||||||
|
import { resolveWorkflowAgents } from '@shared/domain/workflow';
|
||||||
import { createId } from '@shared/utils/ids';
|
import { createId } from '@shared/utils/ids';
|
||||||
|
|
||||||
export type SessionRunStatus = 'running' | 'completed' | 'cancelled' | 'error';
|
export type SessionRunStatus = 'running' | 'completed' | 'cancelled' | 'error';
|
||||||
@@ -74,11 +79,9 @@ export interface SessionRunRecord {
|
|||||||
projectPath: string;
|
projectPath: string;
|
||||||
workingDirectory?: string;
|
workingDirectory?: string;
|
||||||
workspaceKind: SessionRunWorkspaceKind;
|
workspaceKind: SessionRunWorkspaceKind;
|
||||||
patternId: string;
|
workflowId: string;
|
||||||
patternName: string;
|
workflowName: string;
|
||||||
patternMode: PatternDefinition['mode'];
|
workflowMode: WorkflowOrchestrationMode;
|
||||||
workflowId?: string;
|
|
||||||
workflowName?: string;
|
|
||||||
triggerMessageId: string;
|
triggerMessageId: string;
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
completedAt?: string;
|
completedAt?: string;
|
||||||
@@ -90,13 +93,18 @@ export interface SessionRunRecord {
|
|||||||
postRunGitSummary?: ProjectGitRunChangeSummary;
|
postRunGitSummary?: ProjectGitRunChangeSummary;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SessionRunWorkflowInput =
|
||||||
|
| WorkflowDefinition
|
||||||
|
| (Pick<WorkflowDefinition, 'id' | 'name' | 'settings'> & {
|
||||||
|
agents: Pick<AgentNodeConfig, 'id' | 'name' | 'model' | 'reasoningEffort'>[];
|
||||||
|
});
|
||||||
|
|
||||||
export interface CreateSessionRunRecordInput {
|
export interface CreateSessionRunRecordInput {
|
||||||
requestId: string;
|
requestId: string;
|
||||||
project: Pick<ProjectRecord, 'id' | 'path'>;
|
project: Pick<ProjectRecord, 'id' | 'path'>;
|
||||||
workingDirectory?: string;
|
workingDirectory?: string;
|
||||||
workspaceKind: SessionRunWorkspaceKind;
|
workspaceKind: SessionRunWorkspaceKind;
|
||||||
pattern: Pick<PatternDefinition, 'id' | 'name' | 'mode' | 'agents'>;
|
workflow: SessionRunWorkflowInput;
|
||||||
workflow?: Pick<WorkflowDefinition, 'id' | 'name'>;
|
|
||||||
triggerMessageId: string;
|
triggerMessageId: string;
|
||||||
startedAt: string;
|
startedAt: string;
|
||||||
preRunGitSnapshot?: ProjectGitWorkingTreeSnapshot;
|
preRunGitSnapshot?: ProjectGitWorkingTreeSnapshot;
|
||||||
@@ -603,7 +611,23 @@ function settleOpenMessageEvents(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function resolveSessionRunWorkflowAgents(
|
||||||
|
workflow: SessionRunWorkflowInput,
|
||||||
|
): ReadonlyArray<Pick<AgentNodeConfig, 'id' | 'name' | 'model' | 'reasoningEffort'>> {
|
||||||
|
if ('graph' in workflow) {
|
||||||
|
return resolveWorkflowAgents(workflow).map((agent) => ({
|
||||||
|
id: agent.id,
|
||||||
|
name: agent.name,
|
||||||
|
model: agent.model,
|
||||||
|
reasoningEffort: agent.reasoningEffort,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
return workflow.agents;
|
||||||
|
}
|
||||||
|
|
||||||
export function createSessionRunRecord(input: CreateSessionRunRecordInput): SessionRunRecord {
|
export function createSessionRunRecord(input: CreateSessionRunRecordInput): SessionRunRecord {
|
||||||
|
const agents = resolveSessionRunWorkflowAgents(input.workflow);
|
||||||
return {
|
return {
|
||||||
id: createId('run'),
|
id: createId('run'),
|
||||||
requestId: input.requestId,
|
requestId: input.requestId,
|
||||||
@@ -611,11 +635,9 @@ export function createSessionRunRecord(input: CreateSessionRunRecordInput): Sess
|
|||||||
projectPath: input.project.path,
|
projectPath: input.project.path,
|
||||||
workingDirectory: normalizeOptionalString(input.workingDirectory),
|
workingDirectory: normalizeOptionalString(input.workingDirectory),
|
||||||
workspaceKind: input.workspaceKind,
|
workspaceKind: input.workspaceKind,
|
||||||
patternId: input.pattern.id,
|
workflowId: input.workflow.id,
|
||||||
patternName: input.pattern.name,
|
workflowName: input.workflow.name,
|
||||||
patternMode: input.pattern.mode,
|
workflowMode: input.workflow.settings.orchestrationMode ?? 'single',
|
||||||
workflowId: normalizeOptionalString(input.workflow?.id),
|
|
||||||
workflowName: normalizeOptionalString(input.workflow?.name),
|
|
||||||
triggerMessageId: input.triggerMessageId,
|
triggerMessageId: input.triggerMessageId,
|
||||||
startedAt: input.startedAt,
|
startedAt: input.startedAt,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
@@ -623,7 +645,7 @@ export function createSessionRunRecord(input: CreateSessionRunRecordInput): Sess
|
|||||||
preRunGitSnapshot: normalizeWorkingTreeSnapshot(input.preRunGitSnapshot),
|
preRunGitSnapshot: normalizeWorkingTreeSnapshot(input.preRunGitSnapshot),
|
||||||
preRunGitBaselineFiles: normalizeGitBaselineFiles(input.preRunGitBaselineFiles),
|
preRunGitBaselineFiles: normalizeGitBaselineFiles(input.preRunGitBaselineFiles),
|
||||||
postRunGitSummary: undefined,
|
postRunGitSummary: undefined,
|
||||||
agents: input.pattern.agents
|
agents: agents
|
||||||
.map((agent): RunTimelineAgentRecord => ({
|
.map((agent): RunTimelineAgentRecord => ({
|
||||||
agentId: agent.id,
|
agentId: agent.id,
|
||||||
agentName: agent.name,
|
agentName: agent.name,
|
||||||
@@ -659,13 +681,11 @@ export function normalizeSessionRunRecords(
|
|||||||
const projectId = normalizeOptionalString(run.projectId);
|
const projectId = normalizeOptionalString(run.projectId);
|
||||||
const projectPath = normalizeOptionalString(run.projectPath);
|
const projectPath = normalizeOptionalString(run.projectPath);
|
||||||
const workingDirectory = normalizeOptionalString(run.workingDirectory);
|
const workingDirectory = normalizeOptionalString(run.workingDirectory);
|
||||||
const patternId = normalizeOptionalString(run.patternId);
|
|
||||||
const patternName = normalizeOptionalString(run.patternName);
|
|
||||||
const workflowId = normalizeOptionalString(run.workflowId);
|
const workflowId = normalizeOptionalString(run.workflowId);
|
||||||
const workflowName = normalizeOptionalString(run.workflowName);
|
const workflowName = normalizeOptionalString(run.workflowName);
|
||||||
const triggerMessageId = normalizeOptionalString(run.triggerMessageId);
|
const triggerMessageId = normalizeOptionalString(run.triggerMessageId);
|
||||||
const startedAt = normalizeOptionalString(run.startedAt);
|
const startedAt = normalizeOptionalString(run.startedAt);
|
||||||
if (!id || !requestId || !projectId || !projectPath || !patternId || !patternName || !triggerMessageId || !startedAt) {
|
if (!id || !requestId || !projectId || !projectPath || !workflowId || !workflowName || !triggerMessageId || !startedAt) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -677,11 +697,14 @@ export function normalizeSessionRunRecords(
|
|||||||
projectPath,
|
projectPath,
|
||||||
workingDirectory,
|
workingDirectory,
|
||||||
workspaceKind: run.workspaceKind === 'scratchpad' ? 'scratchpad' : 'project',
|
workspaceKind: run.workspaceKind === 'scratchpad' ? 'scratchpad' : 'project',
|
||||||
patternId,
|
|
||||||
patternName,
|
|
||||||
patternMode: run.patternMode,
|
|
||||||
workflowId,
|
workflowId,
|
||||||
workflowName,
|
workflowName,
|
||||||
|
workflowMode: run.workflowMode === 'concurrent'
|
||||||
|
|| run.workflowMode === 'handoff'
|
||||||
|
|| run.workflowMode === 'group-chat'
|
||||||
|
|| run.workflowMode === 'single'
|
||||||
|
? run.workflowMode
|
||||||
|
: 'sequential',
|
||||||
triggerMessageId,
|
triggerMessageId,
|
||||||
startedAt,
|
startedAt,
|
||||||
completedAt: normalizeOptionalString(run.completedAt),
|
completedAt: normalizeOptionalString(run.completedAt),
|
||||||
|
|||||||
@@ -1,25 +1,32 @@
|
|||||||
import { buildSessionTitle, type PatternDefinition, type ReasoningEffort } from '@shared/domain/pattern';
|
|
||||||
import {
|
|
||||||
createSessionToolingSelection,
|
|
||||||
normalizeSessionToolingSelection,
|
|
||||||
type SessionToolingSelection,
|
|
||||||
} from '@shared/domain/tooling';
|
|
||||||
import {
|
import {
|
||||||
normalizeSessionApprovalSettings,
|
normalizeSessionApprovalSettings,
|
||||||
resolveEffectiveApprovalPolicy,
|
resolveEffectiveApprovalPolicy,
|
||||||
type PendingApprovalRecord,
|
type PendingApprovalRecord,
|
||||||
type SessionApprovalSettings,
|
type SessionApprovalSettings,
|
||||||
} from '@shared/domain/approval';
|
} from '@shared/domain/approval';
|
||||||
import type { SessionRunRecord } from '@shared/domain/runTimeline';
|
|
||||||
import type { PendingUserInputRecord } from '@shared/domain/userInput';
|
|
||||||
import type { PendingPlanReviewRecord } from '@shared/domain/planReview';
|
|
||||||
import type { PendingMcpAuthRecord } from '@shared/domain/mcpAuth';
|
|
||||||
import type { ChatMessageAttachment } from '@shared/domain/attachment';
|
import type { ChatMessageAttachment } from '@shared/domain/attachment';
|
||||||
import type { InteractionMode } from '@shared/contracts/sidecar';
|
import type { PendingMcpAuthRecord } from '@shared/domain/mcpAuth';
|
||||||
|
import type { PendingPlanReviewRecord } from '@shared/domain/planReview';
|
||||||
import {
|
import {
|
||||||
normalizeProjectPromptInvocation,
|
normalizeProjectPromptInvocation,
|
||||||
type ProjectPromptInvocation,
|
type ProjectPromptInvocation,
|
||||||
} from '@shared/domain/projectCustomization';
|
} from '@shared/domain/projectCustomization';
|
||||||
|
import type { SessionRunRecord } from '@shared/domain/runTimeline';
|
||||||
|
import {
|
||||||
|
createSessionToolingSelection,
|
||||||
|
normalizeSessionToolingSelection,
|
||||||
|
type SessionToolingSelection,
|
||||||
|
} from '@shared/domain/tooling';
|
||||||
|
import type { PendingUserInputRecord } from '@shared/domain/userInput';
|
||||||
|
import {
|
||||||
|
buildWorkflowExecutionDefinition,
|
||||||
|
resolveWorkflowAgentNodes,
|
||||||
|
type ReasoningEffort,
|
||||||
|
type WorkflowDefinition,
|
||||||
|
type WorkflowResolutionOptions,
|
||||||
|
} from '@shared/domain/workflow';
|
||||||
|
import type { InteractionMode } from '@shared/contracts/sidecar';
|
||||||
|
import { buildMarkdownExcerpt } from '@shared/utils/markdownText';
|
||||||
|
|
||||||
export type ChatRole = 'system' | 'user' | 'assistant';
|
export type ChatRole = 'system' | 'user' | 'assistant';
|
||||||
export type ChatMessageKind = 'response' | 'thinking';
|
export type ChatMessageKind = 'response' | 'thinking';
|
||||||
@@ -56,8 +63,7 @@ export interface SessionBranchOrigin {
|
|||||||
export interface SessionRecord {
|
export interface SessionRecord {
|
||||||
id: string;
|
id: string;
|
||||||
projectId: string;
|
projectId: string;
|
||||||
patternId: string;
|
workflowId: string;
|
||||||
workflowId?: string;
|
|
||||||
title: string;
|
title: string;
|
||||||
titleSource?: SessionTitleSource;
|
titleSource?: SessionTitleSource;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
@@ -120,20 +126,26 @@ export function normalizeSessionBranchOrigin(
|
|||||||
|
|
||||||
export function resolveSessionTitle(
|
export function resolveSessionTitle(
|
||||||
session: Pick<SessionRecord, 'title' | 'titleSource'>,
|
session: Pick<SessionRecord, 'title' | 'titleSource'>,
|
||||||
pattern: PatternDefinition,
|
workflow: Pick<WorkflowDefinition, 'name'>,
|
||||||
messages: ChatMessageRecord[],
|
messages: ChatMessageRecord[],
|
||||||
): string {
|
): string {
|
||||||
if (session.titleSource === 'manual') {
|
if (session.titleSource === 'manual') {
|
||||||
return session.title;
|
return session.title;
|
||||||
}
|
}
|
||||||
|
|
||||||
return buildSessionTitle(pattern, messages);
|
const firstUserMessage = messages.find((message) => message.role === 'user');
|
||||||
|
if (!firstUserMessage) {
|
||||||
|
return workflow.name;
|
||||||
|
}
|
||||||
|
|
||||||
|
return buildMarkdownExcerpt(firstUserMessage.content, 48) ?? workflow.name;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function createSessionModelConfig(
|
export function createSessionModelConfig(
|
||||||
pattern: PatternDefinition,
|
workflow: WorkflowDefinition,
|
||||||
|
options?: WorkflowResolutionOptions,
|
||||||
): SessionModelConfig | undefined {
|
): SessionModelConfig | undefined {
|
||||||
const primaryAgent = pattern.agents[0];
|
const primaryAgent = buildWorkflowExecutionDefinition(workflow, options).agents[0];
|
||||||
if (!primaryAgent) {
|
if (!primaryAgent) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -158,9 +170,9 @@ export function resolveSessionApprovalSettings(
|
|||||||
|
|
||||||
export function resolveSessionModelConfig(
|
export function resolveSessionModelConfig(
|
||||||
session: SessionRecord,
|
session: SessionRecord,
|
||||||
pattern: PatternDefinition,
|
workflow: WorkflowDefinition,
|
||||||
): SessionModelConfig | undefined {
|
): SessionModelConfig | undefined {
|
||||||
const defaults = createSessionModelConfig(pattern);
|
const defaults = createSessionModelConfig(workflow);
|
||||||
if (!defaults) {
|
if (!defaults) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
@@ -187,38 +199,56 @@ export function normalizeChatMessageRecord(message: ChatMessageRecord): ChatMess
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function applySessionModelConfig(
|
export function applySessionModelConfig(
|
||||||
pattern: PatternDefinition,
|
workflow: WorkflowDefinition,
|
||||||
session: SessionRecord,
|
session: SessionRecord,
|
||||||
): PatternDefinition {
|
): WorkflowDefinition {
|
||||||
const config = resolveSessionModelConfig(session, pattern);
|
const config = resolveSessionModelConfig(session, workflow);
|
||||||
const primaryAgent = pattern.agents[0];
|
if (!config) {
|
||||||
if (!config || !primaryAgent) {
|
return workflow;
|
||||||
return pattern;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
let applied = false;
|
||||||
...pattern,
|
const nodes = workflow.graph.nodes.map((node, index) => {
|
||||||
agents: [
|
if (applied || node.kind !== 'agent' || node.config.kind !== 'agent') {
|
||||||
{
|
return node;
|
||||||
...primaryAgent,
|
}
|
||||||
|
|
||||||
|
applied = true;
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
config: {
|
||||||
|
...node.config,
|
||||||
model: config.model,
|
model: config.model,
|
||||||
reasoningEffort: config.reasoningEffort,
|
reasoningEffort: config.reasoningEffort,
|
||||||
},
|
},
|
||||||
...pattern.agents.slice(1),
|
};
|
||||||
],
|
});
|
||||||
};
|
|
||||||
|
return applied
|
||||||
|
? {
|
||||||
|
...workflow,
|
||||||
|
graph: {
|
||||||
|
...workflow.graph,
|
||||||
|
nodes,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
: workflow;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function applySessionApprovalSettings(
|
export function applySessionApprovalSettings(
|
||||||
pattern: PatternDefinition,
|
workflow: WorkflowDefinition,
|
||||||
session: Pick<SessionRecord, 'approvalSettings'>,
|
session: Pick<SessionRecord, 'approvalSettings'>,
|
||||||
): PatternDefinition {
|
): WorkflowDefinition {
|
||||||
if (session.approvalSettings === undefined) {
|
if (session.approvalSettings === undefined) {
|
||||||
return pattern;
|
return workflow;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const execution = buildWorkflowExecutionDefinition(workflow);
|
||||||
return {
|
return {
|
||||||
...pattern,
|
...workflow,
|
||||||
approvalPolicy: resolveEffectiveApprovalPolicy(pattern.approvalPolicy, session.approvalSettings),
|
settings: {
|
||||||
|
...workflow.settings,
|
||||||
|
approvalPolicy: resolveEffectiveApprovalPolicy(execution.approvalPolicy, session.approvalSettings),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
|
||||||
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
|
import { isScratchpadProject, type ProjectRecord } from '@shared/domain/project';
|
||||||
import type { ChatMessageAttachment } from '@shared/domain/attachment';
|
import type { ChatMessageAttachment } from '@shared/domain/attachment';
|
||||||
import {
|
import {
|
||||||
@@ -14,16 +13,16 @@ import {
|
|||||||
type SessionStatus,
|
type SessionStatus,
|
||||||
} from '@shared/domain/session';
|
} from '@shared/domain/session';
|
||||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||||
import { buildWorkflowExecutionPattern } from '@shared/domain/workflow';
|
import type { WorkflowDefinition } from '@shared/domain/workflow';
|
||||||
|
|
||||||
export type SessionQueryMatchField = 'title' | 'message' | 'project' | 'pattern';
|
export type SessionQueryMatchField = 'title' | 'message' | 'project' | 'workflow';
|
||||||
export type SessionWorkspaceKind = 'project' | 'scratchpad';
|
export type SessionWorkspaceKind = 'project' | 'scratchpad';
|
||||||
|
|
||||||
export interface QuerySessionsInput {
|
export interface QuerySessionsInput {
|
||||||
searchText?: string;
|
searchText?: string;
|
||||||
statuses?: SessionStatus[];
|
statuses?: SessionStatus[];
|
||||||
projectIds?: string[];
|
projectIds?: string[];
|
||||||
patternIds?: string[];
|
workflowIds?: string[];
|
||||||
workspaceKinds?: SessionWorkspaceKind[];
|
workspaceKinds?: SessionWorkspaceKind[];
|
||||||
includeArchived?: boolean;
|
includeArchived?: boolean;
|
||||||
onlyPinned?: boolean;
|
onlyPinned?: boolean;
|
||||||
@@ -38,17 +37,17 @@ export interface SessionQueryResult {
|
|||||||
const fieldWeights: Record<SessionQueryMatchField, number> = {
|
const fieldWeights: Record<SessionQueryMatchField, number> = {
|
||||||
title: 12,
|
title: 12,
|
||||||
project: 8,
|
project: 8,
|
||||||
pattern: 7,
|
workflow: 7,
|
||||||
message: 4,
|
message: 4,
|
||||||
};
|
};
|
||||||
|
|
||||||
const orderedMatchFields: SessionQueryMatchField[] = ['title', 'message', 'project', 'pattern'];
|
const orderedMatchFields: SessionQueryMatchField[] = ['title', 'message', 'project', 'workflow'];
|
||||||
|
|
||||||
interface SessionSearchFields {
|
interface SessionSearchFields {
|
||||||
title: string;
|
title: string;
|
||||||
message: string;
|
message: string;
|
||||||
project: string;
|
project: string;
|
||||||
pattern: string;
|
workflow: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeSearchText(value: string): string {
|
function normalizeSearchText(value: string): string {
|
||||||
@@ -68,18 +67,21 @@ function tokenizeSearchText(value?: string): string[] {
|
|||||||
function buildSearchFields(
|
function buildSearchFields(
|
||||||
session: SessionRecord,
|
session: SessionRecord,
|
||||||
project?: ProjectRecord,
|
project?: ProjectRecord,
|
||||||
pattern?: PatternDefinition,
|
workflow?: WorkflowDefinition,
|
||||||
): SessionSearchFields {
|
): SessionSearchFields {
|
||||||
return {
|
return {
|
||||||
title: normalizeSearchText(session.title),
|
title: normalizeSearchText(session.title),
|
||||||
message: normalizeSearchText(session.messages.map((message) => message.content).join('\n')),
|
message: normalizeSearchText(session.messages.map((message) => message.content).join('\n')),
|
||||||
project: normalizeSearchText([project?.name, project?.path].filter(Boolean).join('\n')),
|
project: normalizeSearchText([project?.name, project?.path].filter(Boolean).join('\n')),
|
||||||
pattern: normalizeSearchText(
|
workflow: normalizeSearchText(
|
||||||
[
|
[
|
||||||
pattern?.name,
|
workflow?.name,
|
||||||
pattern?.description,
|
workflow?.description,
|
||||||
pattern?.mode,
|
workflow?.settings.orchestrationMode,
|
||||||
...(pattern?.agents.flatMap((agent) => [agent.name, agent.description]) ?? []),
|
...(workflow?.graph.nodes.flatMap((node) =>
|
||||||
|
node.kind === 'agent' && node.config.kind === 'agent'
|
||||||
|
? [node.config.name, node.config.description]
|
||||||
|
: []) ?? []),
|
||||||
]
|
]
|
||||||
.filter(Boolean)
|
.filter(Boolean)
|
||||||
.join('\n'),
|
.join('\n'),
|
||||||
@@ -146,7 +148,7 @@ function matchesFilters(
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.patternIds && input.patternIds.length > 0 && !input.patternIds.includes(session.patternId)) {
|
if (input.workflowIds && input.workflowIds.length > 0 && !input.workflowIds.includes(session.workflowId)) {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -280,7 +282,7 @@ export function duplicateSessionRecord(
|
|||||||
|
|
||||||
export function branchSessionRecord(
|
export function branchSessionRecord(
|
||||||
session: SessionRecord,
|
session: SessionRecord,
|
||||||
pattern: PatternDefinition,
|
workflow: WorkflowDefinition,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
messageId: string,
|
messageId: string,
|
||||||
branchedAt: string,
|
branchedAt: string,
|
||||||
@@ -299,7 +301,7 @@ export function branchSessionRecord(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...createDerivedSessionRecord(session, sessionId, branchedAt),
|
...createDerivedSessionRecord(session, sessionId, branchedAt),
|
||||||
title: resolveSessionTitle(session, pattern, branchedMessages),
|
title: resolveSessionTitle(session, workflow, branchedMessages),
|
||||||
messages: branchedMessages,
|
messages: branchedMessages,
|
||||||
branchOrigin: createBranchOrigin(session, messageId, sourceMessageIndex, branchedAt, 'branch'),
|
branchOrigin: createBranchOrigin(session, messageId, sourceMessageIndex, branchedAt, 'branch'),
|
||||||
};
|
};
|
||||||
@@ -338,7 +340,7 @@ export function setSessionMessagePinnedRecord(
|
|||||||
|
|
||||||
export function regenerateSessionRecord(
|
export function regenerateSessionRecord(
|
||||||
session: SessionRecord,
|
session: SessionRecord,
|
||||||
pattern: PatternDefinition,
|
workflow: WorkflowDefinition,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
messageId: string,
|
messageId: string,
|
||||||
regeneratedAt: string,
|
regeneratedAt: string,
|
||||||
@@ -373,7 +375,7 @@ export function regenerateSessionRecord(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...createDerivedSessionRecord(session, sessionId, regeneratedAt),
|
...createDerivedSessionRecord(session, sessionId, regeneratedAt),
|
||||||
title: resolveSessionTitle(session, pattern, regeneratedMessages),
|
title: resolveSessionTitle(session, workflow, regeneratedMessages),
|
||||||
messages: regeneratedMessages,
|
messages: regeneratedMessages,
|
||||||
branchOrigin: createBranchOrigin(session, messageId, sourceMessageIndex, regeneratedAt, 'regenerate'),
|
branchOrigin: createBranchOrigin(session, messageId, sourceMessageIndex, regeneratedAt, 'regenerate'),
|
||||||
};
|
};
|
||||||
@@ -381,7 +383,7 @@ export function regenerateSessionRecord(
|
|||||||
|
|
||||||
export function editAndResendSessionRecord(
|
export function editAndResendSessionRecord(
|
||||||
session: SessionRecord,
|
session: SessionRecord,
|
||||||
pattern: PatternDefinition,
|
workflow: WorkflowDefinition,
|
||||||
sessionId: string,
|
sessionId: string,
|
||||||
messageId: string,
|
messageId: string,
|
||||||
content: string,
|
content: string,
|
||||||
@@ -410,7 +412,7 @@ export function editAndResendSessionRecord(
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
...createDerivedSessionRecord(session, sessionId, editedAt),
|
...createDerivedSessionRecord(session, sessionId, editedAt),
|
||||||
title: resolveSessionTitle(session, pattern, editedMessages),
|
title: resolveSessionTitle(session, workflow, editedMessages),
|
||||||
messages: editedMessages,
|
messages: editedMessages,
|
||||||
branchOrigin: createBranchOrigin(session, messageId, sourceMessageIndex, editedAt, 'edit-and-resend'),
|
branchOrigin: createBranchOrigin(session, messageId, sourceMessageIndex, editedAt, 'edit-and-resend'),
|
||||||
};
|
};
|
||||||
@@ -456,7 +458,6 @@ export function listPinnedMessages(workspace: WorkspaceState): PinnedMessageHit[
|
|||||||
|
|
||||||
export function querySessions(workspace: WorkspaceState, input: QuerySessionsInput): SessionQueryResult[] {
|
export function querySessions(workspace: WorkspaceState, input: QuerySessionsInput): SessionQueryResult[] {
|
||||||
const projectsById = new Map<string, ProjectRecord>(workspace.projects.map((project) => [project.id, project]));
|
const projectsById = new Map<string, ProjectRecord>(workspace.projects.map((project) => [project.id, project]));
|
||||||
const patternsById = new Map<string, PatternDefinition>(workspace.patterns.map((pattern) => [pattern.id, pattern]));
|
|
||||||
const workflowsById = new Map(workspace.workflows.map((workflow) => [workflow.id, workflow]));
|
const workflowsById = new Map(workspace.workflows.map((workflow) => [workflow.id, workflow]));
|
||||||
const searchTokens = tokenizeSearchText(input.searchText);
|
const searchTokens = tokenizeSearchText(input.searchText);
|
||||||
|
|
||||||
@@ -471,13 +472,7 @@ export function querySessions(workspace: WorkspaceState, input: QuerySessionsInp
|
|||||||
buildSearchFields(
|
buildSearchFields(
|
||||||
session,
|
session,
|
||||||
projectsById.get(session.projectId),
|
projectsById.get(session.projectId),
|
||||||
patternsById.get(session.patternId)
|
workflowsById.get(session.workflowId),
|
||||||
?? (session.workflowId
|
|
||||||
? (() => {
|
|
||||||
const workflow = workflowsById.get(session.workflowId);
|
|
||||||
return workflow ? buildWorkflowExecutionPattern(workflow) : undefined;
|
|
||||||
})()
|
|
||||||
: undefined),
|
|
||||||
),
|
),
|
||||||
searchTokens,
|
searchTokens,
|
||||||
);
|
);
|
||||||
|
|||||||
+416
-20
@@ -1,10 +1,23 @@
|
|||||||
|
import type { PatternAgentCopilotConfig } from '@shared/contracts/sidecar';
|
||||||
import type { ApprovalPolicy } from '@shared/domain/approval';
|
import type { ApprovalPolicy } from '@shared/domain/approval';
|
||||||
import {
|
|
||||||
syncPatternGraph,
|
export type ReasoningEffort = 'low' | 'medium' | 'high' | 'xhigh';
|
||||||
type PatternDefinition,
|
export type WorkflowOrchestrationMode = 'single' | 'sequential' | 'concurrent' | 'handoff' | 'group-chat';
|
||||||
type PatternGraph,
|
|
||||||
type PatternAgentDefinition,
|
export interface WorkflowAgentOverrides {
|
||||||
} from '@shared/domain/pattern';
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
instructions?: string;
|
||||||
|
model?: string;
|
||||||
|
reasoningEffort?: ReasoningEffort;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const reasoningEffortOptions: ReadonlyArray<{ value: ReasoningEffort; label: string }> = [
|
||||||
|
{ value: 'low', label: 'Low' },
|
||||||
|
{ value: 'medium', label: 'Medium' },
|
||||||
|
{ value: 'high', label: 'High' },
|
||||||
|
{ value: 'xhigh', label: 'Very High' },
|
||||||
|
];
|
||||||
|
|
||||||
export type WorkflowNodeKind =
|
export type WorkflowNodeKind =
|
||||||
| 'start'
|
| 'start'
|
||||||
@@ -40,6 +53,7 @@ export interface WorkflowStateScope {
|
|||||||
export interface WorkflowSettings {
|
export interface WorkflowSettings {
|
||||||
checkpointing: WorkflowCheckpointSettings;
|
checkpointing: WorkflowCheckpointSettings;
|
||||||
executionMode: WorkflowExecutionMode;
|
executionMode: WorkflowExecutionMode;
|
||||||
|
orchestrationMode?: WorkflowOrchestrationMode;
|
||||||
maxIterations?: number;
|
maxIterations?: number;
|
||||||
approvalPolicy?: ApprovalPolicy;
|
approvalPolicy?: ApprovalPolicy;
|
||||||
stateScopes?: WorkflowStateScope[];
|
stateScopes?: WorkflowStateScope[];
|
||||||
@@ -56,8 +70,17 @@ export interface EndNodeConfig {
|
|||||||
outputType?: string;
|
outputType?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface AgentNodeConfig extends PatternAgentDefinition {
|
export interface AgentNodeConfig {
|
||||||
kind: 'agent';
|
kind: 'agent';
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
instructions: string;
|
||||||
|
model: string;
|
||||||
|
reasoningEffort?: ReasoningEffort;
|
||||||
|
copilot?: PatternAgentCopilotConfig;
|
||||||
|
workspaceAgentId?: string;
|
||||||
|
overrides?: WorkflowAgentOverrides;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface InvokeFunctionConfig {
|
export interface InvokeFunctionConfig {
|
||||||
@@ -163,6 +186,16 @@ export interface WorkflowResolutionOptions {
|
|||||||
resolveWorkflow?: (workflowId: string) => WorkflowDefinition | undefined;
|
resolveWorkflow?: (workflowId: string) => WorkflowDefinition | undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface WorkflowExecutionDefinition {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
orchestrationMode: WorkflowOrchestrationMode;
|
||||||
|
maxIterations: number;
|
||||||
|
approvalPolicy?: ApprovalPolicy;
|
||||||
|
agents: AgentNodeConfig[];
|
||||||
|
}
|
||||||
|
|
||||||
const executableNodeKinds = new Set<WorkflowNodeKind>([
|
const executableNodeKinds = new Set<WorkflowNodeKind>([
|
||||||
'start',
|
'start',
|
||||||
'end',
|
'end',
|
||||||
@@ -177,6 +210,25 @@ function normalizeOptionalString(value?: string): string | undefined {
|
|||||||
return trimmed ? trimmed : undefined;
|
return trimmed ? trimmed : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeWorkflowOrchestrationMode(
|
||||||
|
mode?: WorkflowOrchestrationMode,
|
||||||
|
): WorkflowOrchestrationMode | undefined {
|
||||||
|
switch (mode) {
|
||||||
|
case 'single':
|
||||||
|
case 'sequential':
|
||||||
|
case 'concurrent':
|
||||||
|
case 'handoff':
|
||||||
|
case 'group-chat':
|
||||||
|
return mode;
|
||||||
|
default:
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isReasoningEffort(value: string | undefined): value is ReasoningEffort {
|
||||||
|
return reasoningEffortOptions.some((option) => option.value === value);
|
||||||
|
}
|
||||||
|
|
||||||
function normalizePosition(position?: Partial<WorkflowPosition>): WorkflowPosition {
|
function normalizePosition(position?: Partial<WorkflowPosition>): WorkflowPosition {
|
||||||
return {
|
return {
|
||||||
x: typeof position?.x === 'number' && Number.isFinite(position.x) ? position.x : 0,
|
x: typeof position?.x === 'number' && Number.isFinite(position.x) ? position.x : 0,
|
||||||
@@ -311,6 +363,7 @@ export function normalizeWorkflowDefinition(workflow: WorkflowDefinition): Workf
|
|||||||
enabled: workflow.settings?.checkpointing?.enabled ?? false,
|
enabled: workflow.settings?.checkpointing?.enabled ?? false,
|
||||||
},
|
},
|
||||||
executionMode: workflow.settings?.executionMode === 'lockstep' ? 'lockstep' : 'off-thread',
|
executionMode: workflow.settings?.executionMode === 'lockstep' ? 'lockstep' : 'off-thread',
|
||||||
|
orchestrationMode: normalizeWorkflowOrchestrationMode(workflow.settings?.orchestrationMode),
|
||||||
maxIterations:
|
maxIterations:
|
||||||
typeof workflow.settings?.maxIterations === 'number' && workflow.settings.maxIterations > 0
|
typeof workflow.settings?.maxIterations === 'number' && workflow.settings.maxIterations > 0
|
||||||
? Math.round(workflow.settings.maxIterations)
|
? Math.round(workflow.settings.maxIterations)
|
||||||
@@ -346,13 +399,14 @@ export function resolveWorkflowAgentNodes(workflow: WorkflowDefinition): Workflo
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
export function resolveWorkflowAgents(workflow: WorkflowDefinition): PatternAgentDefinition[] {
|
export function resolveWorkflowAgents(workflow: WorkflowDefinition): AgentNodeConfig[] {
|
||||||
return resolveWorkflowAgentNodes(workflow).flatMap((node) => {
|
return resolveWorkflowAgentNodes(workflow).flatMap((node) => {
|
||||||
if (node.config.kind !== 'agent') {
|
if (node.config.kind !== 'agent') {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
return [{
|
return [{
|
||||||
|
kind: 'agent',
|
||||||
id: node.config.id || node.id,
|
id: node.config.id || node.id,
|
||||||
name: node.config.name,
|
name: node.config.name,
|
||||||
description: node.config.description,
|
description: node.config.description,
|
||||||
@@ -410,7 +464,7 @@ function resolveWorkflowExecutionAgents(
|
|||||||
options?: WorkflowResolutionOptions,
|
options?: WorkflowResolutionOptions,
|
||||||
visitedReferencedWorkflowIds = new Set<string>([workflow.id]),
|
visitedReferencedWorkflowIds = new Set<string>([workflow.id]),
|
||||||
visitedInlineWorkflows = new Set<WorkflowDefinition>(),
|
visitedInlineWorkflows = new Set<WorkflowDefinition>(),
|
||||||
): PatternAgentDefinition[] {
|
): AgentNodeConfig[] {
|
||||||
const agents = [...resolveWorkflowAgents(workflow)];
|
const agents = [...resolveWorkflowAgents(workflow)];
|
||||||
|
|
||||||
for (const node of workflow.graph.nodes) {
|
for (const node of workflow.graph.nodes) {
|
||||||
@@ -448,10 +502,15 @@ function resolveWorkflowExecutionAgents(
|
|||||||
return agents;
|
return agents;
|
||||||
}
|
}
|
||||||
|
|
||||||
function inferWorkflowPatternMode(
|
export function inferWorkflowOrchestrationMode(
|
||||||
workflow: WorkflowDefinition,
|
workflow: WorkflowDefinition,
|
||||||
options?: WorkflowResolutionOptions,
|
options?: WorkflowResolutionOptions,
|
||||||
): PatternDefinition['mode'] {
|
): WorkflowOrchestrationMode {
|
||||||
|
const configuredMode = normalizeWorkflowOrchestrationMode(workflow.settings?.orchestrationMode);
|
||||||
|
if (configuredMode) {
|
||||||
|
return configuredMode;
|
||||||
|
}
|
||||||
|
|
||||||
const hasFanEdges = hasWorkflowExecutionFanEdges(workflow, options);
|
const hasFanEdges = hasWorkflowExecutionFanEdges(workflow, options);
|
||||||
const agentCount = resolveWorkflowExecutionAgents(workflow, options).length;
|
const agentCount = resolveWorkflowExecutionAgents(workflow, options).length;
|
||||||
if (hasFanEdges) {
|
if (hasFanEdges) {
|
||||||
@@ -461,30 +520,366 @@ function inferWorkflowPatternMode(
|
|||||||
return agentCount <= 1 ? 'single' : 'sequential';
|
return agentCount <= 1 ? 'single' : 'sequential';
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildWorkflowExecutionPattern(
|
export function buildWorkflowExecutionDefinition(
|
||||||
workflow: WorkflowDefinition,
|
workflow: WorkflowDefinition,
|
||||||
options?: WorkflowResolutionOptions,
|
options?: WorkflowResolutionOptions,
|
||||||
): PatternDefinition {
|
): WorkflowExecutionDefinition {
|
||||||
const agents = resolveWorkflowExecutionAgents(workflow, options);
|
const agents = resolveWorkflowExecutionAgents(workflow, options);
|
||||||
const pattern: PatternDefinition = {
|
|
||||||
|
return {
|
||||||
id: workflow.id,
|
id: workflow.id,
|
||||||
name: workflow.name,
|
name: workflow.name,
|
||||||
description: workflow.description,
|
description: workflow.description,
|
||||||
mode: inferWorkflowPatternMode(workflow, options),
|
orchestrationMode: inferWorkflowOrchestrationMode(workflow, options),
|
||||||
availability: 'available',
|
|
||||||
maxIterations: workflow.settings.maxIterations ?? 5,
|
maxIterations: workflow.settings.maxIterations ?? 5,
|
||||||
approvalPolicy: workflow.settings.approvalPolicy,
|
approvalPolicy: workflow.settings.approvalPolicy,
|
||||||
agents,
|
agents,
|
||||||
createdAt: workflow.createdAt,
|
|
||||||
updatedAt: workflow.updatedAt,
|
|
||||||
};
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
const builtinWorkflowModels = {
|
||||||
|
claude: 'claude-opus-4.5',
|
||||||
|
gpt54: 'gpt-5.4',
|
||||||
|
gpt53: 'gpt-5.3-codex',
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
function createStartNode(x: number, y: number): WorkflowNode {
|
||||||
|
return { id: 'start', kind: 'start', label: 'Start', position: { x, y }, config: { kind: 'start' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createEndNode(x: number, y: number): WorkflowNode {
|
||||||
|
return { id: 'end', kind: 'end', label: 'End', position: { x, y }, config: { kind: 'end' } };
|
||||||
|
}
|
||||||
|
|
||||||
|
function createAgentNode(
|
||||||
|
id: string,
|
||||||
|
label: string,
|
||||||
|
description: string,
|
||||||
|
instructions: string,
|
||||||
|
model: string,
|
||||||
|
reasoningEffort: ReasoningEffort | undefined,
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
order: number,
|
||||||
|
): WorkflowNode {
|
||||||
return {
|
return {
|
||||||
...pattern,
|
id,
|
||||||
graph: syncPatternGraph(pattern).graph as PatternGraph,
|
kind: 'agent',
|
||||||
|
label,
|
||||||
|
position: { x, y },
|
||||||
|
order,
|
||||||
|
config: {
|
||||||
|
kind: 'agent',
|
||||||
|
id,
|
||||||
|
name: label,
|
||||||
|
description,
|
||||||
|
instructions,
|
||||||
|
model,
|
||||||
|
reasoningEffort,
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createWorkflowEdge(
|
||||||
|
id: string,
|
||||||
|
source: string,
|
||||||
|
target: string,
|
||||||
|
kind: WorkflowEdgeKind = 'direct',
|
||||||
|
overrides?: Partial<Omit<WorkflowEdge, 'id' | 'source' | 'target' | 'kind'>>,
|
||||||
|
): WorkflowEdge {
|
||||||
|
return {
|
||||||
|
id,
|
||||||
|
source,
|
||||||
|
target,
|
||||||
|
kind,
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function createBuiltinWorkflow(
|
||||||
|
workflow: Omit<WorkflowDefinition, 'createdAt' | 'updatedAt'>,
|
||||||
|
timestamp: string,
|
||||||
|
): WorkflowDefinition {
|
||||||
|
return normalizeWorkflowDefinition({
|
||||||
|
...workflow,
|
||||||
|
createdAt: timestamp,
|
||||||
|
updatedAt: timestamp,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createBuiltinWorkflows(timestamp: string): WorkflowDefinition[] {
|
||||||
|
return [
|
||||||
|
createBuiltinWorkflow({
|
||||||
|
id: 'workflow-single-chat',
|
||||||
|
name: '1-on-1 Copilot Chat',
|
||||||
|
description: 'Direct human-agent conversation for a selected project.',
|
||||||
|
graph: {
|
||||||
|
nodes: [
|
||||||
|
createStartNode(0, 0),
|
||||||
|
createAgentNode(
|
||||||
|
'agent-single-primary',
|
||||||
|
'Primary Agent',
|
||||||
|
'General-purpose project assistant.',
|
||||||
|
'You are a helpful coding assistant working inside the selected project.',
|
||||||
|
builtinWorkflowModels.gpt54,
|
||||||
|
'high',
|
||||||
|
220,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
createEndNode(440, 0),
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
createWorkflowEdge('edge-start-to-agent-single-primary', 'start', 'agent-single-primary'),
|
||||||
|
createWorkflowEdge('edge-agent-single-primary-to-end', 'agent-single-primary', 'end'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
checkpointing: { enabled: false },
|
||||||
|
executionMode: 'off-thread',
|
||||||
|
orchestrationMode: 'single',
|
||||||
|
maxIterations: 1,
|
||||||
|
},
|
||||||
|
}, timestamp),
|
||||||
|
createBuiltinWorkflow({
|
||||||
|
id: 'workflow-sequential-review',
|
||||||
|
name: 'Sequential Trio Review',
|
||||||
|
description: 'Agents execute in order, each seeing the full conversation and appending to a shared transcript.',
|
||||||
|
graph: {
|
||||||
|
nodes: [
|
||||||
|
createStartNode(0, 0),
|
||||||
|
createAgentNode(
|
||||||
|
'agent-sequential-analyst',
|
||||||
|
'Analyst',
|
||||||
|
'Breaks the task down and captures risks.',
|
||||||
|
'Analyze the request, identify constraints, and produce a short working plan.',
|
||||||
|
builtinWorkflowModels.gpt54,
|
||||||
|
'high',
|
||||||
|
220,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
createAgentNode(
|
||||||
|
'agent-sequential-builder',
|
||||||
|
'Builder',
|
||||||
|
'Translates the plan into a practical implementation.',
|
||||||
|
'Use the prior context to propose a concrete implementation.',
|
||||||
|
builtinWorkflowModels.gpt53,
|
||||||
|
'medium',
|
||||||
|
440,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
createAgentNode(
|
||||||
|
'agent-sequential-reviewer',
|
||||||
|
'Reviewer',
|
||||||
|
'Checks the proposal for gaps and edge cases.',
|
||||||
|
'Review the previous answer, tighten it, and call out any missing edge cases.',
|
||||||
|
builtinWorkflowModels.claude,
|
||||||
|
'medium',
|
||||||
|
660,
|
||||||
|
0,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
createEndNode(880, 0),
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
createWorkflowEdge('edge-start-to-agent-sequential-analyst', 'start', 'agent-sequential-analyst'),
|
||||||
|
createWorkflowEdge('edge-agent-sequential-analyst-to-agent-sequential-builder', 'agent-sequential-analyst', 'agent-sequential-builder'),
|
||||||
|
createWorkflowEdge('edge-agent-sequential-builder-to-agent-sequential-reviewer', 'agent-sequential-builder', 'agent-sequential-reviewer'),
|
||||||
|
createWorkflowEdge('edge-agent-sequential-reviewer-to-end', 'agent-sequential-reviewer', 'end'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
checkpointing: { enabled: false },
|
||||||
|
executionMode: 'off-thread',
|
||||||
|
orchestrationMode: 'sequential',
|
||||||
|
maxIterations: 1,
|
||||||
|
},
|
||||||
|
}, timestamp),
|
||||||
|
createBuiltinWorkflow({
|
||||||
|
id: 'workflow-concurrent-brainstorm',
|
||||||
|
name: 'Concurrent Brainstorm',
|
||||||
|
description: 'Agents work independently in parallel and the final conversation aggregates every response.',
|
||||||
|
graph: {
|
||||||
|
nodes: [
|
||||||
|
createStartNode(0, 120),
|
||||||
|
createAgentNode(
|
||||||
|
'agent-concurrent-architect',
|
||||||
|
'Architect',
|
||||||
|
'Focuses on architecture and boundaries.',
|
||||||
|
'Answer from an architecture-first perspective.',
|
||||||
|
builtinWorkflowModels.gpt54,
|
||||||
|
'high',
|
||||||
|
260,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
createAgentNode(
|
||||||
|
'agent-concurrent-product',
|
||||||
|
'Product',
|
||||||
|
'Focuses on UX and scope.',
|
||||||
|
'Answer from a product and UX perspective.',
|
||||||
|
builtinWorkflowModels.claude,
|
||||||
|
'medium',
|
||||||
|
260,
|
||||||
|
120,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
createAgentNode(
|
||||||
|
'agent-concurrent-implementer',
|
||||||
|
'Implementer',
|
||||||
|
'Focuses on practical delivery.',
|
||||||
|
'Answer from an implementation and testing perspective.',
|
||||||
|
builtinWorkflowModels.gpt53,
|
||||||
|
'medium',
|
||||||
|
260,
|
||||||
|
240,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
createEndNode(520, 120),
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
createWorkflowEdge('edge-start-to-agent-concurrent-architect', 'start', 'agent-concurrent-architect', 'fan-out', { fanOutConfig: { strategy: 'broadcast' } }),
|
||||||
|
createWorkflowEdge('edge-start-to-agent-concurrent-product', 'start', 'agent-concurrent-product', 'fan-out', { fanOutConfig: { strategy: 'broadcast' } }),
|
||||||
|
createWorkflowEdge('edge-start-to-agent-concurrent-implementer', 'start', 'agent-concurrent-implementer', 'fan-out', { fanOutConfig: { strategy: 'broadcast' } }),
|
||||||
|
createWorkflowEdge('edge-agent-concurrent-architect-to-end', 'agent-concurrent-architect', 'end', 'fan-in'),
|
||||||
|
createWorkflowEdge('edge-agent-concurrent-product-to-end', 'agent-concurrent-product', 'end', 'fan-in'),
|
||||||
|
createWorkflowEdge('edge-agent-concurrent-implementer-to-end', 'agent-concurrent-implementer', 'end', 'fan-in'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
checkpointing: { enabled: false },
|
||||||
|
executionMode: 'off-thread',
|
||||||
|
orchestrationMode: 'concurrent',
|
||||||
|
maxIterations: 1,
|
||||||
|
},
|
||||||
|
}, timestamp),
|
||||||
|
createBuiltinWorkflow({
|
||||||
|
id: 'workflow-handoff-support',
|
||||||
|
name: 'Handoff Support Flow',
|
||||||
|
description: 'A triage agent routes work to specialists, and the next user turn continues when more input is needed.',
|
||||||
|
graph: {
|
||||||
|
nodes: [
|
||||||
|
createStartNode(0, 120),
|
||||||
|
createAgentNode(
|
||||||
|
'agent-handoff-triage',
|
||||||
|
'Triage',
|
||||||
|
'Routes the request to the right specialist.',
|
||||||
|
'You triage requests and must hand them off to the most appropriate specialist. For any substantive task, hand off before inspecting files, calling tools, or drafting the implementation yourself. Do not claim that you delegated unless you actually executed the handoff.',
|
||||||
|
builtinWorkflowModels.gpt54,
|
||||||
|
'medium',
|
||||||
|
240,
|
||||||
|
120,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
createAgentNode(
|
||||||
|
'agent-handoff-ux',
|
||||||
|
'UX Specialist',
|
||||||
|
'Handles user experience questions.',
|
||||||
|
'You focus on navigation, UX, and interaction details. Once triage hands work to you, you own the substantive answer.',
|
||||||
|
builtinWorkflowModels.claude,
|
||||||
|
'medium',
|
||||||
|
520,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
createAgentNode(
|
||||||
|
'agent-handoff-runtime',
|
||||||
|
'Runtime Specialist',
|
||||||
|
'Handles backend and execution details.',
|
||||||
|
'You focus on runtime, orchestration, and backend integration details. Once triage hands work to you, you own the substantive answer.',
|
||||||
|
builtinWorkflowModels.gpt53,
|
||||||
|
'medium',
|
||||||
|
520,
|
||||||
|
240,
|
||||||
|
2,
|
||||||
|
),
|
||||||
|
createEndNode(800, 120),
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
createWorkflowEdge('edge-start-to-agent-handoff-triage', 'start', 'agent-handoff-triage'),
|
||||||
|
createWorkflowEdge('edge-agent-handoff-triage-to-end', 'agent-handoff-triage', 'end'),
|
||||||
|
createWorkflowEdge('edge-agent-handoff-triage-to-agent-handoff-ux', 'agent-handoff-triage', 'agent-handoff-ux'),
|
||||||
|
createWorkflowEdge('edge-agent-handoff-triage-to-agent-handoff-runtime', 'agent-handoff-triage', 'agent-handoff-runtime'),
|
||||||
|
createWorkflowEdge('edge-agent-handoff-ux-to-agent-handoff-triage', 'agent-handoff-ux', 'agent-handoff-triage', 'direct', {
|
||||||
|
isLoop: true,
|
||||||
|
maxIterations: 4,
|
||||||
|
condition: { type: 'always' },
|
||||||
|
}),
|
||||||
|
createWorkflowEdge('edge-agent-handoff-runtime-to-agent-handoff-triage', 'agent-handoff-runtime', 'agent-handoff-triage', 'direct', {
|
||||||
|
isLoop: true,
|
||||||
|
maxIterations: 4,
|
||||||
|
condition: { type: 'always' },
|
||||||
|
}),
|
||||||
|
createWorkflowEdge('edge-agent-handoff-ux-to-end', 'agent-handoff-ux', 'end'),
|
||||||
|
createWorkflowEdge('edge-agent-handoff-runtime-to-end', 'agent-handoff-runtime', 'end'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
checkpointing: { enabled: true },
|
||||||
|
executionMode: 'off-thread',
|
||||||
|
orchestrationMode: 'handoff',
|
||||||
|
maxIterations: 4,
|
||||||
|
},
|
||||||
|
}, timestamp),
|
||||||
|
createBuiltinWorkflow({
|
||||||
|
id: 'workflow-group-chat',
|
||||||
|
name: 'Collaborative Group Chat',
|
||||||
|
description: 'Agents take turns under a round-robin manager, iteratively refining a shared conversation.',
|
||||||
|
graph: {
|
||||||
|
nodes: [
|
||||||
|
createStartNode(0, 0),
|
||||||
|
createAgentNode(
|
||||||
|
'agent-group-writer',
|
||||||
|
'Writer',
|
||||||
|
'Produces candidate answers.',
|
||||||
|
'You draft a concise, useful answer for the task. On later turns, refine your earlier draft based on peer feedback rather than restarting.',
|
||||||
|
builtinWorkflowModels.gpt54,
|
||||||
|
'medium',
|
||||||
|
240,
|
||||||
|
0,
|
||||||
|
0,
|
||||||
|
),
|
||||||
|
createAgentNode(
|
||||||
|
'agent-group-reviewer',
|
||||||
|
'Reviewer',
|
||||||
|
'Critiques and refines the answer.',
|
||||||
|
'You review the latest draft and offer specific improvements. Focus on critique and refinement instead of restarting the conversation.',
|
||||||
|
builtinWorkflowModels.claude,
|
||||||
|
'medium',
|
||||||
|
480,
|
||||||
|
0,
|
||||||
|
1,
|
||||||
|
),
|
||||||
|
createEndNode(720, 0),
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
createWorkflowEdge('edge-start-to-agent-group-writer', 'start', 'agent-group-writer'),
|
||||||
|
createWorkflowEdge('edge-agent-group-writer-to-agent-group-reviewer', 'agent-group-writer', 'agent-group-reviewer', 'direct', {
|
||||||
|
isLoop: true,
|
||||||
|
maxIterations: 5,
|
||||||
|
condition: { type: 'always' },
|
||||||
|
}),
|
||||||
|
createWorkflowEdge('edge-agent-group-reviewer-to-agent-group-writer', 'agent-group-reviewer', 'agent-group-writer', 'direct', {
|
||||||
|
isLoop: true,
|
||||||
|
maxIterations: 5,
|
||||||
|
condition: { type: 'always' },
|
||||||
|
label: 'Loop',
|
||||||
|
}),
|
||||||
|
createWorkflowEdge('edge-agent-group-reviewer-to-end', 'agent-group-reviewer', 'end'),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
checkpointing: { enabled: false },
|
||||||
|
executionMode: 'off-thread',
|
||||||
|
orchestrationMode: 'group-chat',
|
||||||
|
maxIterations: 5,
|
||||||
|
},
|
||||||
|
}, timestamp),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function addIssue(
|
function addIssue(
|
||||||
issues: WorkflowValidationIssue[],
|
issues: WorkflowValidationIssue[],
|
||||||
issue: WorkflowValidationIssue,
|
issue: WorkflowValidationIssue,
|
||||||
@@ -1091,3 +1486,4 @@ export function validateWorkflowDefinition(workflow: WorkflowDefinition): Workfl
|
|||||||
|
|
||||||
return issues;
|
return issues;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,3 @@
|
|||||||
import {
|
|
||||||
resolvePatternGraph,
|
|
||||||
type PatternAgentDefinition,
|
|
||||||
type PatternDefinition,
|
|
||||||
type PatternGraphNode,
|
|
||||||
} from '@shared/domain/pattern';
|
|
||||||
import {
|
import {
|
||||||
normalizeWorkflowDefinition,
|
normalizeWorkflowDefinition,
|
||||||
type WorkflowDefinition,
|
type WorkflowDefinition,
|
||||||
@@ -45,47 +39,6 @@ function normalizeRequiredString(value: string | undefined, fallback: string): s
|
|||||||
return normalizeOptionalString(value) ?? fallback;
|
return normalizeOptionalString(value) ?? fallback;
|
||||||
}
|
}
|
||||||
|
|
||||||
function toWorkflowIdFromPatternId(patternId: string): string {
|
|
||||||
const trimmed = patternId.trim();
|
|
||||||
return trimmed.startsWith('pattern-')
|
|
||||||
? `workflow-${trimmed.slice('pattern-'.length)}`
|
|
||||||
: `${trimmed}-workflow`;
|
|
||||||
}
|
|
||||||
|
|
||||||
function createWorkflowEdge(
|
|
||||||
source: string,
|
|
||||||
target: string,
|
|
||||||
kind: WorkflowEdge['kind'],
|
|
||||||
overrides?: Partial<WorkflowEdge>,
|
|
||||||
): WorkflowEdge {
|
|
||||||
return {
|
|
||||||
id: overrides?.id?.trim() || `workflow-edge-${source}-to-${target}`,
|
|
||||||
source,
|
|
||||||
target,
|
|
||||||
kind,
|
|
||||||
label: normalizeOptionalString(overrides?.label),
|
|
||||||
condition: overrides?.condition,
|
|
||||||
fanOutConfig: overrides?.fanOutConfig,
|
|
||||||
isLoop: overrides?.isLoop,
|
|
||||||
maxIterations: overrides?.maxIterations,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function sortAgentNodes(nodes: ReadonlyArray<PatternGraphNode>): PatternGraphNode[] {
|
|
||||||
return nodes
|
|
||||||
.filter((node) => node.kind === 'agent' && node.agentId)
|
|
||||||
.slice()
|
|
||||||
.sort((left, right) => {
|
|
||||||
const leftOrder = left.order ?? Number.MAX_SAFE_INTEGER;
|
|
||||||
const rightOrder = right.order ?? Number.MAX_SAFE_INTEGER;
|
|
||||||
if (leftOrder !== rightOrder) {
|
|
||||||
return leftOrder - rightOrder;
|
|
||||||
}
|
|
||||||
|
|
||||||
return left.id.localeCompare(right.id);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeTemplateWorkflow(
|
function normalizeTemplateWorkflow(
|
||||||
workflow: WorkflowDefinition | Partial<WorkflowDefinition> | undefined,
|
workflow: WorkflowDefinition | Partial<WorkflowDefinition> | undefined,
|
||||||
): WorkflowDefinition {
|
): WorkflowDefinition {
|
||||||
@@ -115,115 +68,6 @@ function normalizeTemplateWorkflow(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function createWorkflowAgentNode(
|
|
||||||
patternNode: PatternGraphNode,
|
|
||||||
agent: PatternAgentDefinition,
|
|
||||||
fallbackOrder: number,
|
|
||||||
): WorkflowNode {
|
|
||||||
return {
|
|
||||||
id: patternNode.id.trim(),
|
|
||||||
kind: 'agent',
|
|
||||||
label: normalizeRequiredString(agent.name, `Agent ${fallbackOrder + 1}`),
|
|
||||||
position: {
|
|
||||||
x: patternNode.position.x,
|
|
||||||
y: patternNode.position.y,
|
|
||||||
},
|
|
||||||
order: patternNode.order ?? fallbackOrder,
|
|
||||||
config: {
|
|
||||||
kind: 'agent',
|
|
||||||
...agent,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mapPatternNodeId(node: PatternGraphNode | undefined): string | undefined {
|
|
||||||
if (!node) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
|
|
||||||
switch (node.kind) {
|
|
||||||
case 'user-input':
|
|
||||||
return 'start';
|
|
||||||
case 'user-output':
|
|
||||||
return 'end';
|
|
||||||
case 'agent':
|
|
||||||
return node.id.trim();
|
|
||||||
default:
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function buildWorkflowAdjacency(
|
|
||||||
edges: ReadonlyArray<WorkflowEdge>,
|
|
||||||
excludedEdgeId?: string,
|
|
||||||
): Map<string, string[]> {
|
|
||||||
const adjacency = new Map<string, string[]>();
|
|
||||||
for (const edge of edges) {
|
|
||||||
if (edge.id === excludedEdgeId) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const next = adjacency.get(edge.source);
|
|
||||||
if (next) {
|
|
||||||
next.push(edge.target);
|
|
||||||
} else {
|
|
||||||
adjacency.set(edge.source, [edge.target]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return adjacency;
|
|
||||||
}
|
|
||||||
|
|
||||||
function canReachWorkflowNode(
|
|
||||||
edges: ReadonlyArray<WorkflowEdge>,
|
|
||||||
startNodeId: string,
|
|
||||||
targetNodeId: string,
|
|
||||||
excludedEdgeId?: string,
|
|
||||||
): boolean {
|
|
||||||
if (startNodeId === targetNodeId) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
const adjacency = buildWorkflowAdjacency(edges, excludedEdgeId);
|
|
||||||
const queue = [startNodeId];
|
|
||||||
const visited = new Set<string>();
|
|
||||||
while (queue.length > 0) {
|
|
||||||
const current = queue.shift()!;
|
|
||||||
if (visited.has(current)) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
visited.add(current);
|
|
||||||
for (const candidate of adjacency.get(current) ?? []) {
|
|
||||||
if (candidate === targetNodeId) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
queue.push(candidate);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
|
|
||||||
function markLoopEdges(
|
|
||||||
edges: ReadonlyArray<WorkflowEdge>,
|
|
||||||
maxIterations: number,
|
|
||||||
): WorkflowEdge[] {
|
|
||||||
return edges.map((edge) => {
|
|
||||||
const participatesInCycle = canReachWorkflowNode(edges, edge.target, edge.source, edge.id);
|
|
||||||
if (!participatesInCycle) {
|
|
||||||
return edge;
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...edge,
|
|
||||||
isLoop: true,
|
|
||||||
maxIterations,
|
|
||||||
condition: edge.condition ?? { type: 'always' },
|
|
||||||
};
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function normalizeWorkflowTemplateDefinition(template: WorkflowTemplateDefinition): WorkflowTemplateDefinition {
|
export function normalizeWorkflowTemplateDefinition(template: WorkflowTemplateDefinition): WorkflowTemplateDefinition {
|
||||||
const workflow = normalizeTemplateWorkflow(template.workflow);
|
const workflow = normalizeTemplateWorkflow(template.workflow);
|
||||||
@@ -240,141 +84,6 @@ export function normalizeWorkflowTemplateDefinition(template: WorkflowTemplateDe
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function buildWorkflowFromPattern(pattern: PatternDefinition): WorkflowDefinition {
|
|
||||||
const resolvedGraph = resolvePatternGraph(pattern);
|
|
||||||
const patternNodesById = new Map(resolvedGraph.nodes.map((node) => [node.id, node]));
|
|
||||||
const agentsById = new Map(pattern.agents.map((agent) => [agent.id, agent]));
|
|
||||||
const orderedAgentNodes = sortAgentNodes(resolvedGraph.nodes);
|
|
||||||
const startPosition = patternNodesById.get('system-user-input')?.position ?? { x: 0, y: 0 };
|
|
||||||
const endPosition = patternNodesById.get('system-user-output')?.position ?? { x: 500, y: 0 };
|
|
||||||
const workflowNodes: WorkflowNode[] = [
|
|
||||||
{
|
|
||||||
id: 'start',
|
|
||||||
kind: 'start',
|
|
||||||
label: 'Start',
|
|
||||||
position: { x: startPosition.x, y: startPosition.y },
|
|
||||||
config: { kind: 'start' },
|
|
||||||
},
|
|
||||||
...orderedAgentNodes.map((node, index) => {
|
|
||||||
const agent = node.agentId ? agentsById.get(node.agentId) : undefined;
|
|
||||||
if (!agent) {
|
|
||||||
throw new Error(`Pattern "${pattern.id}" references missing agent "${node.agentId ?? node.id}".`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return createWorkflowAgentNode(node, agent, index);
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
id: 'end',
|
|
||||||
kind: 'end',
|
|
||||||
label: 'End',
|
|
||||||
position: { x: endPosition.x, y: endPosition.y },
|
|
||||||
config: { kind: 'end' },
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const orderedAgentIds = workflowNodes.filter((node) => node.kind === 'agent').map((node) => node.id);
|
|
||||||
const edges: WorkflowEdge[] = [];
|
|
||||||
const seenEdges = new Set<string>();
|
|
||||||
const pushEdge = (edge: WorkflowEdge) => {
|
|
||||||
const dedupeKey = `${edge.source}:${edge.target}:${edge.kind}:${edge.isLoop === true ? 'loop' : 'plain'}`;
|
|
||||||
if (seenEdges.has(dedupeKey)) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
seenEdges.add(dedupeKey);
|
|
||||||
edges.push(edge);
|
|
||||||
};
|
|
||||||
|
|
||||||
switch (pattern.mode) {
|
|
||||||
case 'single':
|
|
||||||
case 'sequential':
|
|
||||||
case 'magentic': {
|
|
||||||
const path = ['start', ...orderedAgentIds, 'end'];
|
|
||||||
for (let index = 0; index < path.length - 1; index += 1) {
|
|
||||||
pushEdge(createWorkflowEdge(path[index]!, path[index + 1]!, 'direct'));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'concurrent': {
|
|
||||||
for (const agentId of orderedAgentIds) {
|
|
||||||
pushEdge(createWorkflowEdge('start', agentId, 'fan-out', {
|
|
||||||
fanOutConfig: { strategy: 'broadcast' },
|
|
||||||
}));
|
|
||||||
pushEdge(createWorkflowEdge(agentId, 'end', 'fan-in'));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'handoff': {
|
|
||||||
for (const patternEdge of resolvedGraph.edges) {
|
|
||||||
const sourceNode = patternNodesById.get(patternEdge.source);
|
|
||||||
const targetNode = patternNodesById.get(patternEdge.target);
|
|
||||||
const source = mapPatternNodeId(sourceNode);
|
|
||||||
const target = mapPatternNodeId(targetNode);
|
|
||||||
if (!source || !target) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
pushEdge(createWorkflowEdge(source, target, 'direct', {
|
|
||||||
id: `workflow-${patternEdge.id.trim()}`,
|
|
||||||
}));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
case 'group-chat': {
|
|
||||||
const path = ['start', ...orderedAgentIds, 'end'];
|
|
||||||
for (let index = 0; index < path.length - 1; index += 1) {
|
|
||||||
const source = path[index]!;
|
|
||||||
const target = path[index + 1]!;
|
|
||||||
pushEdge(createWorkflowEdge(source, target, 'direct', source !== 'start' && target !== 'end'
|
|
||||||
? {
|
|
||||||
isLoop: true,
|
|
||||||
maxIterations: pattern.maxIterations,
|
|
||||||
condition: { type: 'always' },
|
|
||||||
}
|
|
||||||
: undefined));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (orderedAgentIds.length > 0) {
|
|
||||||
pushEdge(createWorkflowEdge(
|
|
||||||
orderedAgentIds[orderedAgentIds.length - 1]!,
|
|
||||||
orderedAgentIds[0]!,
|
|
||||||
'direct',
|
|
||||||
{
|
|
||||||
id: `workflow-edge-${orderedAgentIds[orderedAgentIds.length - 1]}-loop-${orderedAgentIds[0]}`,
|
|
||||||
isLoop: true,
|
|
||||||
maxIterations: pattern.maxIterations,
|
|
||||||
condition: { type: 'always' },
|
|
||||||
label: 'Loop',
|
|
||||||
},
|
|
||||||
));
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const finalizedEdges = pattern.mode === 'handoff'
|
|
||||||
? markLoopEdges(edges, Math.max(pattern.maxIterations, 1))
|
|
||||||
: edges;
|
|
||||||
|
|
||||||
return normalizeWorkflowDefinition({
|
|
||||||
id: toWorkflowIdFromPatternId(pattern.id),
|
|
||||||
name: pattern.name,
|
|
||||||
description: pattern.description,
|
|
||||||
graph: {
|
|
||||||
nodes: workflowNodes,
|
|
||||||
edges: finalizedEdges,
|
|
||||||
},
|
|
||||||
settings: {
|
|
||||||
checkpointing: { enabled: false },
|
|
||||||
executionMode: 'off-thread',
|
|
||||||
maxIterations: pattern.maxIterations,
|
|
||||||
approvalPolicy: pattern.approvalPolicy,
|
|
||||||
},
|
|
||||||
createdAt: pattern.createdAt,
|
|
||||||
updatedAt: pattern.updatedAt,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export function createBuiltinWorkflowTemplates(timestamp: string): WorkflowTemplateDefinition[] {
|
export function createBuiltinWorkflowTemplates(timestamp: string): WorkflowTemplateDefinition[] {
|
||||||
return [
|
return [
|
||||||
createCodeReviewPipeline(timestamp),
|
createCodeReviewPipeline(timestamp),
|
||||||
|
|||||||
@@ -1,5 +1,3 @@
|
|||||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
|
||||||
import { createBuiltinPatterns } from '@shared/domain/pattern';
|
|
||||||
import type { ProjectRecord } from '@shared/domain/project';
|
import type { ProjectRecord } from '@shared/domain/project';
|
||||||
import type { SessionRecord } from '@shared/domain/session';
|
import type { SessionRecord } from '@shared/domain/session';
|
||||||
import { createWorkspaceSettings, type WorkspaceSettings } from '@shared/domain/tooling';
|
import { createWorkspaceSettings, type WorkspaceSettings } from '@shared/domain/tooling';
|
||||||
@@ -7,22 +5,18 @@ import {
|
|||||||
createBuiltinWorkflowTemplates,
|
createBuiltinWorkflowTemplates,
|
||||||
type WorkflowTemplateDefinition,
|
type WorkflowTemplateDefinition,
|
||||||
} from '@shared/domain/workflowTemplate';
|
} from '@shared/domain/workflowTemplate';
|
||||||
import type { WorkflowDefinition } from '@shared/domain/workflow';
|
import { createBuiltinWorkflows, type WorkflowDefinition } from '@shared/domain/workflow';
|
||||||
import { nowIso } from '@shared/utils/ids';
|
import { nowIso } from '@shared/utils/ids';
|
||||||
|
|
||||||
export interface WorkspaceState {
|
export interface WorkspaceState {
|
||||||
projects: ProjectRecord[];
|
projects: ProjectRecord[];
|
||||||
patterns: PatternDefinition[];
|
|
||||||
workflows: WorkflowDefinition[];
|
workflows: WorkflowDefinition[];
|
||||||
workflowTemplates: WorkflowTemplateDefinition[];
|
workflowTemplates: WorkflowTemplateDefinition[];
|
||||||
sessions: SessionRecord[];
|
sessions: SessionRecord[];
|
||||||
settings: WorkspaceSettings;
|
settings: WorkspaceSettings;
|
||||||
/** IDs of built-in patterns the user has deleted. Prevents re-adding on load. */
|
|
||||||
deletedBuiltinPatternIds?: string[];
|
|
||||||
/** Runtime-only MCP probe progress for live UI updates. */
|
/** Runtime-only MCP probe progress for live UI updates. */
|
||||||
mcpProbingServerIds?: string[];
|
mcpProbingServerIds?: string[];
|
||||||
selectedProjectId?: string;
|
selectedProjectId?: string;
|
||||||
selectedPatternId?: string;
|
|
||||||
selectedWorkflowId?: string;
|
selectedWorkflowId?: string;
|
||||||
selectedSessionId?: string;
|
selectedSessionId?: string;
|
||||||
lastUpdatedAt: string;
|
lastUpdatedAt: string;
|
||||||
@@ -32,8 +26,7 @@ export function createWorkspaceSeed(): WorkspaceState {
|
|||||||
const timestamp = nowIso();
|
const timestamp = nowIso();
|
||||||
return {
|
return {
|
||||||
projects: [],
|
projects: [],
|
||||||
patterns: createBuiltinPatterns(timestamp),
|
workflows: createBuiltinWorkflows(timestamp),
|
||||||
workflows: [],
|
|
||||||
workflowTemplates: createBuiltinWorkflowTemplates(timestamp),
|
workflowTemplates: createBuiltinWorkflowTemplates(timestamp),
|
||||||
sessions: [],
|
sessions: [],
|
||||||
settings: createWorkspaceSettings(),
|
settings: createWorkspaceSettings(),
|
||||||
|
|||||||
@@ -1,5 +1,9 @@
|
|||||||
import type { PatternAgentCopilotConfig } from '@shared/contracts/sidecar';
|
import type { PatternAgentCopilotConfig } from '@shared/contracts/sidecar';
|
||||||
import type { PatternAgentDefinition, PatternDefinition, ReasoningEffort } from '@shared/domain/pattern';
|
import type {
|
||||||
|
AgentNodeConfig,
|
||||||
|
ReasoningEffort,
|
||||||
|
WorkflowDefinition,
|
||||||
|
} from '@shared/domain/workflow';
|
||||||
|
|
||||||
export interface WorkspaceAgentDefinition {
|
export interface WorkspaceAgentDefinition {
|
||||||
id: string;
|
id: string;
|
||||||
@@ -13,38 +17,32 @@ export interface WorkspaceAgentDefinition {
|
|||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PatternAgentOverrides {
|
export interface WorkflowAgentUsage {
|
||||||
name?: string;
|
workflowId: string;
|
||||||
description?: string;
|
workflowName: string;
|
||||||
instructions?: string;
|
|
||||||
model?: string;
|
|
||||||
reasoningEffort?: ReasoningEffort;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface WorkspaceAgentUsage {
|
function normalizeOptionalString(value?: string): string | undefined {
|
||||||
patternId: string;
|
const trimmed = value?.trim();
|
||||||
patternName: string;
|
return trimmed ? trimmed : undefined;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function resolveWorkflowAgentNode(
|
||||||
* Resolves a single pattern agent by merging its workspace agent base with
|
agent: AgentNodeConfig,
|
||||||
* per-pattern overrides. Returns the agent unchanged if it is inline.
|
|
||||||
*/
|
|
||||||
export function resolvePatternAgent(
|
|
||||||
agent: PatternAgentDefinition,
|
|
||||||
workspaceAgents: ReadonlyArray<WorkspaceAgentDefinition>,
|
workspaceAgents: ReadonlyArray<WorkspaceAgentDefinition>,
|
||||||
): PatternAgentDefinition {
|
): AgentNodeConfig {
|
||||||
if (!agent.workspaceAgentId) {
|
if (!agent.workspaceAgentId) {
|
||||||
return agent;
|
return agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
const base = workspaceAgents.find((wa) => wa.id === agent.workspaceAgentId);
|
const base = workspaceAgents.find((workspaceAgent) => workspaceAgent.id === agent.workspaceAgentId);
|
||||||
if (!base) {
|
if (!base) {
|
||||||
return agent;
|
return agent;
|
||||||
}
|
}
|
||||||
|
|
||||||
const overrides = agent.overrides ?? {};
|
const overrides = agent.overrides ?? {};
|
||||||
return {
|
return {
|
||||||
|
...agent,
|
||||||
id: agent.id,
|
id: agent.id,
|
||||||
name: overrides.name ?? base.name,
|
name: overrides.name ?? base.name,
|
||||||
description: overrides.description ?? base.description,
|
description: overrides.description ?? base.description,
|
||||||
@@ -57,40 +55,45 @@ export function resolvePatternAgent(
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
export function resolveWorkflowAgents(
|
||||||
* Resolves all agents in a pattern, producing a new pattern whose agents
|
workflow: WorkflowDefinition,
|
||||||
* have workspace-agent references merged with their base definitions.
|
|
||||||
*/
|
|
||||||
export function resolvePatternAgents(
|
|
||||||
pattern: PatternDefinition,
|
|
||||||
workspaceAgents: ReadonlyArray<WorkspaceAgentDefinition>,
|
workspaceAgents: ReadonlyArray<WorkspaceAgentDefinition>,
|
||||||
): PatternDefinition {
|
): WorkflowDefinition {
|
||||||
return {
|
return {
|
||||||
...pattern,
|
...workflow,
|
||||||
agents: pattern.agents.map((agent) => resolvePatternAgent(agent, workspaceAgents)),
|
graph: {
|
||||||
|
...workflow.graph,
|
||||||
|
nodes: workflow.graph.nodes.map((node) => {
|
||||||
|
if (node.kind !== 'agent' || node.config.kind !== 'agent') {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
config: resolveWorkflowAgentNode(node.config, workspaceAgents),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Returns every pattern that references the given workspace agent.
|
|
||||||
*/
|
|
||||||
export function findWorkspaceAgentUsages(
|
export function findWorkspaceAgentUsages(
|
||||||
agentId: string,
|
agentId: string,
|
||||||
patterns: ReadonlyArray<PatternDefinition>,
|
workflows: ReadonlyArray<WorkflowDefinition>,
|
||||||
): WorkspaceAgentUsage[] {
|
): WorkflowAgentUsage[] {
|
||||||
const usages: WorkspaceAgentUsage[] = [];
|
const usages: WorkflowAgentUsage[] = [];
|
||||||
for (const pattern of patterns) {
|
for (const workflow of workflows) {
|
||||||
if (pattern.agents.some((a) => a.workspaceAgentId === agentId)) {
|
if (workflow.graph.nodes.some((node) =>
|
||||||
usages.push({ patternId: pattern.id, patternName: pattern.name });
|
node.kind === 'agent'
|
||||||
|
&& node.config.kind === 'agent'
|
||||||
|
&& node.config.workspaceAgentId === agentId)) {
|
||||||
|
usages.push({ workflowId: workflow.id, workflowName: workflow.name });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return usages;
|
return usages;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Normalizes a workspace agent definition, trimming string fields and
|
|
||||||
* ensuring consistent shape.
|
|
||||||
*/
|
|
||||||
export function normalizeWorkspaceAgentDefinition(
|
export function normalizeWorkspaceAgentDefinition(
|
||||||
agent: WorkspaceAgentDefinition,
|
agent: WorkspaceAgentDefinition,
|
||||||
): WorkspaceAgentDefinition {
|
): WorkspaceAgentDefinition {
|
||||||
@@ -100,5 +103,6 @@ export function normalizeWorkspaceAgentDefinition(
|
|||||||
description: agent.description.trim(),
|
description: agent.description.trim(),
|
||||||
instructions: agent.instructions.trim(),
|
instructions: agent.instructions.trim(),
|
||||||
model: agent.model.trim(),
|
model: agent.model.trim(),
|
||||||
|
reasoningEffort: normalizeOptionalString(agent.reasoningEffort) as ReasoningEffort | undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, mock, test } from 'bun:test';
|
import { describe, expect, mock, test } from 'bun:test';
|
||||||
|
|
||||||
import type { RunTurnCommand } from '@shared/contracts/sidecar';
|
import type { RunTurnCommand } from '@shared/contracts/sidecar';
|
||||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
import type { WorkflowDefinition } from '@shared/domain/workflow';
|
||||||
import type { ProjectRecord } from '@shared/domain/project';
|
import type { ProjectRecord } from '@shared/domain/project';
|
||||||
import type { SessionRecord } from '@shared/domain/session';
|
import type { SessionRecord } from '@shared/domain/session';
|
||||||
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
||||||
@@ -49,11 +49,11 @@ function createProject(overrides?: Partial<ProjectRecord>): ProjectRecord {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSession(projectId: string, patternId: string, overrides?: Partial<SessionRecord>): SessionRecord {
|
function createSession(projectId: string, workflowId: string, overrides?: Partial<SessionRecord>): SessionRecord {
|
||||||
return {
|
return {
|
||||||
id: 'session-alpha',
|
id: 'session-alpha',
|
||||||
projectId,
|
projectId,
|
||||||
patternId,
|
workflowId,
|
||||||
title: 'Alpha session',
|
title: 'Alpha session',
|
||||||
createdAt: TIMESTAMP,
|
createdAt: TIMESTAMP,
|
||||||
updatedAt: TIMESTAMP,
|
updatedAt: TIMESTAMP,
|
||||||
@@ -66,7 +66,7 @@ function createSession(projectId: string, patternId: string, overrides?: Partial
|
|||||||
|
|
||||||
function createService(
|
function createService(
|
||||||
workspace: WorkspaceState,
|
workspace: WorkspaceState,
|
||||||
pattern: PatternDefinition,
|
pattern: WorkflowDefinition,
|
||||||
options?: {
|
options?: {
|
||||||
captureRunTurn?: (command: RunTurnCommand) => void;
|
captureRunTurn?: (command: RunTurnCommand) => void;
|
||||||
},
|
},
|
||||||
@@ -107,7 +107,7 @@ function createService(
|
|||||||
describe('AryxAppService discovered tooling', () => {
|
describe('AryxAppService discovered tooling', () => {
|
||||||
test('allows project-discovered MCP servers to be accepted and used in project sessions', async () => {
|
test('allows project-discovered MCP servers to be accepted and used in project sessions', async () => {
|
||||||
const workspace = createWorkspaceSeed();
|
const workspace = createWorkspaceSeed();
|
||||||
const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single');
|
const pattern = workspace.workflows.find((candidate) => candidate.settings.orchestrationMode === 'single');
|
||||||
if (!pattern) {
|
if (!pattern) {
|
||||||
throw new Error('Expected a single-agent pattern in the workspace seed.');
|
throw new Error('Expected a single-agent pattern in the workspace seed.');
|
||||||
}
|
}
|
||||||
@@ -139,7 +139,7 @@ describe('AryxAppService discovered tooling', () => {
|
|||||||
workspace.projects = [project];
|
workspace.projects = [project];
|
||||||
workspace.sessions = [session];
|
workspace.sessions = [session];
|
||||||
workspace.selectedProjectId = project.id;
|
workspace.selectedProjectId = project.id;
|
||||||
workspace.selectedPatternId = pattern.id;
|
workspace.selectedWorkflowId = pattern.id;
|
||||||
workspace.selectedSessionId = session.id;
|
workspace.selectedSessionId = session.id;
|
||||||
|
|
||||||
let command: RunTurnCommand | undefined;
|
let command: RunTurnCommand | undefined;
|
||||||
@@ -168,7 +168,7 @@ describe('AryxAppService discovered tooling', () => {
|
|||||||
|
|
||||||
test('allows accepted user-discovered MCP servers to be used across projects', async () => {
|
test('allows accepted user-discovered MCP servers to be used across projects', async () => {
|
||||||
const workspace = createWorkspaceSeed();
|
const workspace = createWorkspaceSeed();
|
||||||
const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single');
|
const pattern = workspace.workflows.find((candidate) => candidate.settings.orchestrationMode === 'single');
|
||||||
if (!pattern) {
|
if (!pattern) {
|
||||||
throw new Error('Expected a single-agent pattern in the workspace seed.');
|
throw new Error('Expected a single-agent pattern in the workspace seed.');
|
||||||
}
|
}
|
||||||
@@ -199,7 +199,7 @@ describe('AryxAppService discovered tooling', () => {
|
|||||||
workspace.projects = [project];
|
workspace.projects = [project];
|
||||||
workspace.sessions = [session];
|
workspace.sessions = [session];
|
||||||
workspace.selectedProjectId = project.id;
|
workspace.selectedProjectId = project.id;
|
||||||
workspace.selectedPatternId = pattern.id;
|
workspace.selectedWorkflowId = pattern.id;
|
||||||
workspace.selectedSessionId = session.id;
|
workspace.selectedSessionId = session.id;
|
||||||
|
|
||||||
let command: RunTurnCommand | undefined;
|
let command: RunTurnCommand | undefined;
|
||||||
@@ -234,7 +234,7 @@ describe('AryxAppService discovered tooling', () => {
|
|||||||
|
|
||||||
test('selecting a session also selects that session project', async () => {
|
test('selecting a session also selects that session project', async () => {
|
||||||
const workspace = createWorkspaceSeed();
|
const workspace = createWorkspaceSeed();
|
||||||
const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single');
|
const pattern = workspace.workflows.find((candidate) => candidate.settings.orchestrationMode === 'single');
|
||||||
if (!pattern) {
|
if (!pattern) {
|
||||||
throw new Error('Expected a single-agent pattern in the workspace seed.');
|
throw new Error('Expected a single-agent pattern in the workspace seed.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, mock, test } from 'bun:test';
|
import { describe, expect, mock, test } from 'bun:test';
|
||||||
|
|
||||||
import type { RunTurnCommand } from '@shared/contracts/sidecar';
|
import type { RunTurnCommand } from '@shared/contracts/sidecar';
|
||||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
import type { WorkflowDefinition } from '@shared/domain/workflow';
|
||||||
import type {
|
import type {
|
||||||
ProjectGitRunChangeSummary,
|
ProjectGitRunChangeSummary,
|
||||||
ProjectGitWorkingTreeSnapshot,
|
ProjectGitWorkingTreeSnapshot,
|
||||||
@@ -68,11 +68,11 @@ function createProject(overrides?: Partial<ProjectRecord>): ProjectRecord {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSession(projectId: string, patternId: string, overrides?: Partial<SessionRecord>): SessionRecord {
|
function createSession(projectId: string, workflowId: string, overrides?: Partial<SessionRecord>): SessionRecord {
|
||||||
return {
|
return {
|
||||||
id: 'session-alpha',
|
id: 'session-alpha',
|
||||||
projectId,
|
projectId,
|
||||||
patternId,
|
workflowId,
|
||||||
title: 'Alpha session',
|
title: 'Alpha session',
|
||||||
createdAt: TIMESTAMP,
|
createdAt: TIMESTAMP,
|
||||||
updatedAt: TIMESTAMP,
|
updatedAt: TIMESTAMP,
|
||||||
@@ -88,12 +88,12 @@ function createFixture(overrides?: {
|
|||||||
session?: Partial<SessionRecord>;
|
session?: Partial<SessionRecord>;
|
||||||
}): {
|
}): {
|
||||||
workspace: WorkspaceState;
|
workspace: WorkspaceState;
|
||||||
pattern: PatternDefinition;
|
pattern: WorkflowDefinition;
|
||||||
project: ProjectRecord;
|
project: ProjectRecord;
|
||||||
session: SessionRecord;
|
session: SessionRecord;
|
||||||
} {
|
} {
|
||||||
const workspace = createWorkspaceSeed();
|
const workspace = createWorkspaceSeed();
|
||||||
const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single');
|
const pattern = workspace.workflows.find((candidate) => candidate.settings.orchestrationMode === 'single');
|
||||||
if (!pattern) {
|
if (!pattern) {
|
||||||
throw new Error('Expected the workspace seed to include a single-agent pattern.');
|
throw new Error('Expected the workspace seed to include a single-agent pattern.');
|
||||||
}
|
}
|
||||||
@@ -104,7 +104,7 @@ function createFixture(overrides?: {
|
|||||||
workspace.projects = [project];
|
workspace.projects = [project];
|
||||||
workspace.sessions = [session];
|
workspace.sessions = [session];
|
||||||
workspace.selectedProjectId = project.id;
|
workspace.selectedProjectId = project.id;
|
||||||
workspace.selectedPatternId = pattern.id;
|
workspace.selectedWorkflowId = pattern.id;
|
||||||
workspace.selectedSessionId = session.id;
|
workspace.selectedSessionId = session.id;
|
||||||
|
|
||||||
return { workspace, pattern, project, session };
|
return { workspace, pattern, project, session };
|
||||||
@@ -173,7 +173,7 @@ function createRunSummary(): ProjectGitRunChangeSummary {
|
|||||||
|
|
||||||
function createService(
|
function createService(
|
||||||
workspace: WorkspaceState,
|
workspace: WorkspaceState,
|
||||||
pattern: PatternDefinition,
|
pattern: WorkflowDefinition,
|
||||||
options?: {
|
options?: {
|
||||||
snapshot?: ProjectGitWorkingTreeSnapshot;
|
snapshot?: ProjectGitWorkingTreeSnapshot;
|
||||||
runSummary?: ProjectGitRunChangeSummary;
|
runSummary?: ProjectGitRunChangeSummary;
|
||||||
|
|||||||
@@ -2,12 +2,12 @@ import { describe, expect, mock, test } from 'bun:test';
|
|||||||
|
|
||||||
import type { PendingApprovalRecord } from '@shared/domain/approval';
|
import type { PendingApprovalRecord } from '@shared/domain/approval';
|
||||||
import type { PendingMcpAuthRecord } from '@shared/domain/mcpAuth';
|
import type { PendingMcpAuthRecord } from '@shared/domain/mcpAuth';
|
||||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
|
||||||
import type { PendingPlanReviewRecord } from '@shared/domain/planReview';
|
import type { PendingPlanReviewRecord } from '@shared/domain/planReview';
|
||||||
import type { SessionRunRecord } from '@shared/domain/runTimeline';
|
import type { SessionRunRecord } from '@shared/domain/runTimeline';
|
||||||
import type { SessionRecord } from '@shared/domain/session';
|
import type { SessionRecord } from '@shared/domain/session';
|
||||||
import type { PendingUserInputRecord } from '@shared/domain/userInput';
|
import type { PendingUserInputRecord } from '@shared/domain/userInput';
|
||||||
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
||||||
|
import type { WorkflowDefinition } from '@shared/domain/workflow';
|
||||||
|
|
||||||
const TIMESTAMP = '2026-03-28T00:00:00.000Z';
|
const TIMESTAMP = '2026-03-28T00:00:00.000Z';
|
||||||
const INTERRUPTED_RUN_ERROR =
|
const INTERRUPTED_RUN_ERROR =
|
||||||
@@ -44,20 +44,20 @@ mock.module('keytar', () => ({
|
|||||||
|
|
||||||
const { AryxAppService } = await import('@main/AryxAppService');
|
const { AryxAppService } = await import('@main/AryxAppService');
|
||||||
|
|
||||||
function requireSinglePattern(workspace: WorkspaceState): PatternDefinition {
|
function requireSingleWorkflow(workspace: WorkspaceState): WorkflowDefinition {
|
||||||
const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single');
|
const workflow = workspace.workflows.find((candidate) => candidate.settings.orchestrationMode === 'single');
|
||||||
if (!pattern) {
|
if (!workflow) {
|
||||||
throw new Error('Expected the workspace seed to include a single-agent pattern.');
|
throw new Error('Expected the workspace seed to include a single-agent workflow.');
|
||||||
}
|
}
|
||||||
|
|
||||||
return pattern;
|
return workflow;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSession(patternId: string, overrides?: Partial<SessionRecord>): SessionRecord {
|
function createSession(workflowId: string, overrides?: Partial<SessionRecord>): SessionRecord {
|
||||||
return {
|
return {
|
||||||
id: 'session-1',
|
id: 'session-1',
|
||||||
projectId: 'project-1',
|
projectId: 'project-1',
|
||||||
patternId,
|
workflowId,
|
||||||
title: 'Session',
|
title: 'Session',
|
||||||
createdAt: TIMESTAMP,
|
createdAt: TIMESTAMP,
|
||||||
updatedAt: TIMESTAMP,
|
updatedAt: TIMESTAMP,
|
||||||
@@ -69,7 +69,7 @@ function createSession(patternId: string, overrides?: Partial<SessionRecord>): S
|
|||||||
}
|
}
|
||||||
|
|
||||||
function createRun(
|
function createRun(
|
||||||
pattern: PatternDefinition,
|
workflow: WorkflowDefinition,
|
||||||
overrides?: Partial<SessionRunRecord>,
|
overrides?: Partial<SessionRunRecord>,
|
||||||
): SessionRunRecord {
|
): SessionRunRecord {
|
||||||
return {
|
return {
|
||||||
@@ -78,9 +78,9 @@ function createRun(
|
|||||||
projectId: 'project-1',
|
projectId: 'project-1',
|
||||||
projectPath: 'C:\\workspace\\personal\\repositories\\aryx',
|
projectPath: 'C:\\workspace\\personal\\repositories\\aryx',
|
||||||
workspaceKind: 'project',
|
workspaceKind: 'project',
|
||||||
patternId: pattern.id,
|
workflowId: workflow.id,
|
||||||
patternName: pattern.name,
|
workflowName: workflow.name,
|
||||||
patternMode: pattern.mode,
|
workflowMode: workflow.settings.orchestrationMode ?? 'single',
|
||||||
triggerMessageId: 'message-1',
|
triggerMessageId: 'message-1',
|
||||||
startedAt: TIMESTAMP,
|
startedAt: TIMESTAMP,
|
||||||
status: 'running',
|
status: 'running',
|
||||||
@@ -171,9 +171,9 @@ function createService(
|
|||||||
describe('AryxAppService interrupted session cleanup', () => {
|
describe('AryxAppService interrupted session cleanup', () => {
|
||||||
test('clears stale approvals and user input, then fails the interrupted run on load', async () => {
|
test('clears stale approvals and user input, then fails the interrupted run on load', async () => {
|
||||||
const workspace = createWorkspaceSeed();
|
const workspace = createWorkspaceSeed();
|
||||||
const pattern = requireSinglePattern(workspace);
|
const workflow = requireSingleWorkflow(workspace);
|
||||||
const runningRun = createRun(pattern);
|
const runningRun = createRun(workflow);
|
||||||
const session = createSession(pattern.id, {
|
const session = createSession(workflow.id, {
|
||||||
status: 'running',
|
status: 'running',
|
||||||
pendingApproval: createPendingApproval('approval-1', 'Approve reading the repo'),
|
pendingApproval: createPendingApproval('approval-1', 'Approve reading the repo'),
|
||||||
pendingApprovalQueue: [createPendingApproval('approval-2', 'Approve writing the repo')],
|
pendingApprovalQueue: [createPendingApproval('approval-2', 'Approve writing the repo')],
|
||||||
@@ -212,10 +212,10 @@ describe('AryxAppService interrupted session cleanup', () => {
|
|||||||
|
|
||||||
test('fails sessions that were left in running state even without pending interaction records', async () => {
|
test('fails sessions that were left in running state even without pending interaction records', async () => {
|
||||||
const workspace = createWorkspaceSeed();
|
const workspace = createWorkspaceSeed();
|
||||||
const pattern = requireSinglePattern(workspace);
|
const workflow = requireSingleWorkflow(workspace);
|
||||||
const session = createSession(pattern.id, {
|
const session = createSession(workflow.id, {
|
||||||
status: 'running',
|
status: 'running',
|
||||||
runs: [createRun(pattern)],
|
runs: [createRun(workflow)],
|
||||||
});
|
});
|
||||||
workspace.sessions = [session];
|
workspace.sessions = [session];
|
||||||
|
|
||||||
@@ -235,10 +235,10 @@ describe('AryxAppService interrupted session cleanup', () => {
|
|||||||
|
|
||||||
test('preserves restart-safe plan review and MCP auth prompts', async () => {
|
test('preserves restart-safe plan review and MCP auth prompts', async () => {
|
||||||
const workspace = createWorkspaceSeed();
|
const workspace = createWorkspaceSeed();
|
||||||
const pattern = requireSinglePattern(workspace);
|
const workflow = requireSingleWorkflow(workspace);
|
||||||
const planReview = createPendingPlanReview();
|
const planReview = createPendingPlanReview();
|
||||||
const mcpAuth = createPendingMcpAuth();
|
const mcpAuth = createPendingMcpAuth();
|
||||||
const session = createSession(pattern.id, {
|
const session = createSession(workflow.id, {
|
||||||
pendingPlanReview: planReview,
|
pendingPlanReview: planReview,
|
||||||
pendingMcpAuth: mcpAuth,
|
pendingMcpAuth: mcpAuth,
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, mock, test } from 'bun:test';
|
import { describe, expect, mock, test } from 'bun:test';
|
||||||
|
|
||||||
import type { RunTurnCommand } from '@shared/contracts/sidecar';
|
import type { RunTurnCommand } from '@shared/contracts/sidecar';
|
||||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
import type { WorkflowDefinition } from '@shared/domain/workflow';
|
||||||
import type { ProjectRecord } from '@shared/domain/project';
|
import type { ProjectRecord } from '@shared/domain/project';
|
||||||
import type { SessionRecord } from '@shared/domain/session';
|
import type { SessionRecord } from '@shared/domain/session';
|
||||||
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
||||||
@@ -49,11 +49,11 @@ function createProject(overrides?: Partial<ProjectRecord>): ProjectRecord {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSession(projectId: string, patternId: string, overrides?: Partial<SessionRecord>): SessionRecord {
|
function createSession(projectId: string, workflowId: string, overrides?: Partial<SessionRecord>): SessionRecord {
|
||||||
return {
|
return {
|
||||||
id: 'session-alpha',
|
id: 'session-alpha',
|
||||||
projectId,
|
projectId,
|
||||||
patternId,
|
workflowId,
|
||||||
title: 'Alpha session',
|
title: 'Alpha session',
|
||||||
createdAt: TIMESTAMP,
|
createdAt: TIMESTAMP,
|
||||||
updatedAt: TIMESTAMP,
|
updatedAt: TIMESTAMP,
|
||||||
@@ -102,12 +102,12 @@ function createSession(projectId: string, patternId: string, overrides?: Partial
|
|||||||
|
|
||||||
function createFixture(): {
|
function createFixture(): {
|
||||||
workspace: WorkspaceState;
|
workspace: WorkspaceState;
|
||||||
pattern: PatternDefinition;
|
pattern: WorkflowDefinition;
|
||||||
project: ProjectRecord;
|
project: ProjectRecord;
|
||||||
session: SessionRecord;
|
session: SessionRecord;
|
||||||
} {
|
} {
|
||||||
const workspace = createWorkspaceSeed();
|
const workspace = createWorkspaceSeed();
|
||||||
const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single');
|
const pattern = workspace.workflows.find((candidate) => candidate.settings.orchestrationMode === 'single');
|
||||||
if (!pattern) {
|
if (!pattern) {
|
||||||
throw new Error('Expected the workspace seed to include a single-agent pattern.');
|
throw new Error('Expected the workspace seed to include a single-agent pattern.');
|
||||||
}
|
}
|
||||||
@@ -118,7 +118,7 @@ function createFixture(): {
|
|||||||
workspace.projects = [project];
|
workspace.projects = [project];
|
||||||
workspace.sessions = [session];
|
workspace.sessions = [session];
|
||||||
workspace.selectedProjectId = project.id;
|
workspace.selectedProjectId = project.id;
|
||||||
workspace.selectedPatternId = pattern.id;
|
workspace.selectedWorkflowId = pattern.id;
|
||||||
workspace.selectedSessionId = session.id;
|
workspace.selectedSessionId = session.id;
|
||||||
|
|
||||||
return { workspace, pattern, project, session };
|
return { workspace, pattern, project, session };
|
||||||
@@ -126,7 +126,7 @@ function createFixture(): {
|
|||||||
|
|
||||||
function createService(
|
function createService(
|
||||||
workspace: WorkspaceState,
|
workspace: WorkspaceState,
|
||||||
pattern: PatternDefinition,
|
pattern: WorkflowDefinition,
|
||||||
options?: {
|
options?: {
|
||||||
captureRunTurn?: (command: RunTurnCommand) => void;
|
captureRunTurn?: (command: RunTurnCommand) => void;
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -1,144 +0,0 @@
|
|||||||
import { describe, expect, mock, test } from 'bun:test';
|
|
||||||
|
|
||||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
|
||||||
import { resolvePatternGraph } from '@shared/domain/pattern';
|
|
||||||
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
|
||||||
|
|
||||||
const TIMESTAMP = '2026-03-27T00:00:00.000Z';
|
|
||||||
|
|
||||||
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');
|
|
||||||
|
|
||||||
function createService(
|
|
||||||
workspace: WorkspaceState,
|
|
||||||
options?: { knownApprovalToolNames?: string[] },
|
|
||||||
): InstanceType<typeof AryxAppService> {
|
|
||||||
const service = new AryxAppService();
|
|
||||||
const internals = service as unknown as Record<string, unknown>;
|
|
||||||
internals.loadWorkspace = async () => workspace;
|
|
||||||
internals.persistAndBroadcast = async (nextWorkspace: WorkspaceState) => nextWorkspace;
|
|
||||||
internals.listKnownApprovalToolNames = async () => options?.knownApprovalToolNames ?? ['read', 'write', 'shell'];
|
|
||||||
|
|
||||||
return service;
|
|
||||||
}
|
|
||||||
|
|
||||||
function requirePattern(workspace: WorkspaceState, mode: PatternDefinition['mode']): PatternDefinition {
|
|
||||||
const pattern = workspace.patterns.find((candidate) => candidate.mode === mode);
|
|
||||||
if (!pattern) {
|
|
||||||
throw new Error(`Expected workspace seed to include a ${mode} pattern.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return pattern;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('AryxAppService deletePattern', () => {
|
|
||||||
test('deletes a built-in pattern and tracks its ID', async () => {
|
|
||||||
const workspace = createWorkspaceSeed();
|
|
||||||
const builtinPattern = requirePattern(workspace, 'sequential');
|
|
||||||
const service = createService(workspace);
|
|
||||||
|
|
||||||
const result = await service.deletePattern(builtinPattern.id);
|
|
||||||
|
|
||||||
expect(result.patterns.find((p) => p.id === builtinPattern.id)).toBeUndefined();
|
|
||||||
expect(result.deletedBuiltinPatternIds).toContain(builtinPattern.id);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('deletes a custom pattern without tracking its ID', async () => {
|
|
||||||
const workspace = createWorkspaceSeed();
|
|
||||||
const customPattern: PatternDefinition = {
|
|
||||||
...requirePattern(workspace, 'single'),
|
|
||||||
id: 'custom-pattern',
|
|
||||||
name: 'My Custom Pattern',
|
|
||||||
};
|
|
||||||
workspace.patterns.push(customPattern);
|
|
||||||
const service = createService(workspace);
|
|
||||||
|
|
||||||
const result = await service.deletePattern('custom-pattern');
|
|
||||||
|
|
||||||
expect(result.patterns.find((p) => p.id === 'custom-pattern')).toBeUndefined();
|
|
||||||
expect(result.deletedBuiltinPatternIds ?? []).not.toContain('custom-pattern');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('selects first remaining pattern when the active pattern is deleted', async () => {
|
|
||||||
const workspace = createWorkspaceSeed();
|
|
||||||
const targetPattern = requirePattern(workspace, 'single');
|
|
||||||
workspace.selectedPatternId = targetPattern.id;
|
|
||||||
const service = createService(workspace);
|
|
||||||
|
|
||||||
const result = await service.deletePattern(targetPattern.id);
|
|
||||||
|
|
||||||
expect(result.selectedPatternId).toBe(result.patterns[0]?.id);
|
|
||||||
expect(result.selectedPatternId).not.toBe(targetPattern.id);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('AryxAppService savePattern', () => {
|
|
||||||
test('preserves a provided custom graph instead of re-syncing it', async () => {
|
|
||||||
const workspace = createWorkspaceSeed();
|
|
||||||
const pattern = requirePattern(workspace, 'sequential');
|
|
||||||
const baseGraph = resolvePatternGraph(pattern);
|
|
||||||
const customGraph = {
|
|
||||||
...baseGraph,
|
|
||||||
nodes: baseGraph.nodes.map((node, index) => ({
|
|
||||||
...node,
|
|
||||||
position: {
|
|
||||||
x: node.position.x + 37 + index,
|
|
||||||
y: node.position.y + 19 + index * 7,
|
|
||||||
},
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
const service = createService(workspace);
|
|
||||||
|
|
||||||
const result = await service.savePattern({
|
|
||||||
...pattern,
|
|
||||||
graph: customGraph,
|
|
||||||
updatedAt: TIMESTAMP,
|
|
||||||
});
|
|
||||||
|
|
||||||
const savedPattern = requirePattern(result, 'sequential');
|
|
||||||
expect(savedPattern.graph).toEqual(customGraph);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('still seeds a default graph when the pattern graph is missing', async () => {
|
|
||||||
const workspace = createWorkspaceSeed();
|
|
||||||
const pattern = requirePattern(workspace, 'group-chat');
|
|
||||||
const service = createService(workspace);
|
|
||||||
|
|
||||||
const result = await service.savePattern({
|
|
||||||
...pattern,
|
|
||||||
graph: undefined,
|
|
||||||
updatedAt: TIMESTAMP,
|
|
||||||
});
|
|
||||||
|
|
||||||
const savedPattern = requirePattern(result, 'group-chat');
|
|
||||||
expect(savedPattern.graph).toEqual(resolvePatternGraph(pattern));
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -2,10 +2,10 @@ import { describe, expect, mock, test } from 'bun:test';
|
|||||||
|
|
||||||
import type { RunTurnCommand } from '@shared/contracts/sidecar';
|
import type { RunTurnCommand } from '@shared/contracts/sidecar';
|
||||||
import { buildAvailableModelCatalog } from '@shared/domain/models';
|
import { buildAvailableModelCatalog } from '@shared/domain/models';
|
||||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
|
||||||
import type { ProjectRecord } from '@shared/domain/project';
|
import type { ProjectRecord } from '@shared/domain/project';
|
||||||
import type { SessionRecord } from '@shared/domain/session';
|
import type { SessionRecord } from '@shared/domain/session';
|
||||||
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
||||||
|
import { resolveWorkflowAgentNodes, type WorkflowDefinition } from '@shared/domain/workflow';
|
||||||
|
|
||||||
const TIMESTAMP = '2026-03-28T00:00:00.000Z';
|
const TIMESTAMP = '2026-03-28T00:00:00.000Z';
|
||||||
|
|
||||||
@@ -50,11 +50,11 @@ function createProject(overrides?: Partial<ProjectRecord>): ProjectRecord {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSession(projectId: string, patternId: string, overrides?: Partial<SessionRecord>): SessionRecord {
|
function createSession(projectId: string, workflowId: string, overrides?: Partial<SessionRecord>): SessionRecord {
|
||||||
return {
|
return {
|
||||||
id: 'session-alpha',
|
id: 'session-alpha',
|
||||||
projectId,
|
projectId,
|
||||||
patternId,
|
workflowId,
|
||||||
title: 'Alpha session',
|
title: 'Alpha session',
|
||||||
createdAt: TIMESTAMP,
|
createdAt: TIMESTAMP,
|
||||||
updatedAt: TIMESTAMP,
|
updatedAt: TIMESTAMP,
|
||||||
@@ -67,7 +67,7 @@ function createSession(projectId: string, patternId: string, overrides?: Partial
|
|||||||
|
|
||||||
function createService(
|
function createService(
|
||||||
workspace: WorkspaceState,
|
workspace: WorkspaceState,
|
||||||
pattern: PatternDefinition,
|
workflow: WorkflowDefinition,
|
||||||
options?: {
|
options?: {
|
||||||
captureRunTurn?: (command: RunTurnCommand) => void;
|
captureRunTurn?: (command: RunTurnCommand) => void;
|
||||||
},
|
},
|
||||||
@@ -76,7 +76,7 @@ function createService(
|
|||||||
const internals = service as unknown as Record<string, unknown>;
|
const internals = service as unknown as Record<string, unknown>;
|
||||||
internals.loadWorkspace = async () => workspace;
|
internals.loadWorkspace = async () => workspace;
|
||||||
internals.persistAndBroadcast = async (nextWorkspace: WorkspaceState) => nextWorkspace;
|
internals.persistAndBroadcast = async (nextWorkspace: WorkspaceState) => nextWorkspace;
|
||||||
internals.buildEffectivePattern = async () => pattern;
|
internals.buildEffectiveWorkflow = async () => workflow;
|
||||||
internals.loadAvailableModelCatalog = async () => buildAvailableModelCatalog();
|
internals.loadAvailableModelCatalog = async () => buildAvailableModelCatalog();
|
||||||
internals.awaitFinalResponseApproval = async () => undefined;
|
internals.awaitFinalResponseApproval = async () => undefined;
|
||||||
internals.finalizeTurn = () => undefined;
|
internals.finalizeTurn = () => undefined;
|
||||||
@@ -101,19 +101,45 @@ function createService(
|
|||||||
return service;
|
return service;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function updatePrimaryAgent(
|
||||||
|
workflow: WorkflowDefinition,
|
||||||
|
update: (agent: NonNullable<ReturnType<typeof resolveWorkflowAgentNodes>[number]>['config']) => NonNullable<ReturnType<typeof resolveWorkflowAgentNodes>[number]>['config'],
|
||||||
|
): WorkflowDefinition {
|
||||||
|
let updated = false;
|
||||||
|
return {
|
||||||
|
...workflow,
|
||||||
|
graph: {
|
||||||
|
...workflow.graph,
|
||||||
|
nodes: workflow.graph.nodes.map((node) => {
|
||||||
|
if (updated || node.kind !== 'agent' || node.config.kind !== 'agent') {
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
updated = true;
|
||||||
|
return {
|
||||||
|
...node,
|
||||||
|
config: update(node.config),
|
||||||
|
};
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function getPrimaryAgentConfig(workflow: WorkflowDefinition | undefined) {
|
||||||
|
const node = workflow ? resolveWorkflowAgentNodes(workflow)[0] : undefined;
|
||||||
|
return node?.config.kind === 'agent' ? node.config : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
describe('AryxAppService project customization', () => {
|
describe('AryxAppService project customization', () => {
|
||||||
test('sendSessionMessage injects project instructions and enabled project agent profiles', async () => {
|
test('sendSessionMessage injects project instructions and enabled project agent profiles', async () => {
|
||||||
const workspace = createWorkspaceSeed();
|
const workspace = createWorkspaceSeed();
|
||||||
const basePattern = workspace.patterns.find((candidate) => candidate.mode === 'single');
|
const baseWorkflow = workspace.workflows.find((candidate) => candidate.settings.orchestrationMode === 'single');
|
||||||
if (!basePattern) {
|
if (!baseWorkflow) {
|
||||||
throw new Error('Expected a single-agent pattern in the workspace seed.');
|
throw new Error('Expected a single-agent workflow in the workspace seed.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const pattern: PatternDefinition = {
|
const workflow = updatePrimaryAgent(baseWorkflow, (agent) => ({
|
||||||
...basePattern,
|
...agent,
|
||||||
agents: [
|
|
||||||
{
|
|
||||||
...basePattern.agents[0]!,
|
|
||||||
copilot: {
|
copilot: {
|
||||||
customAgents: [
|
customAgents: [
|
||||||
{
|
{
|
||||||
@@ -122,9 +148,7 @@ describe('AryxAppService project customization', () => {
|
|||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
},
|
}));
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const project = createProject({
|
const project = createProject({
|
||||||
customization: {
|
customization: {
|
||||||
@@ -171,16 +195,16 @@ describe('AryxAppService project customization', () => {
|
|||||||
lastScannedAt: TIMESTAMP,
|
lastScannedAt: TIMESTAMP,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const session = createSession(project.id, pattern.id);
|
const session = createSession(project.id, workflow.id);
|
||||||
|
|
||||||
workspace.projects = [project];
|
workspace.projects = [project];
|
||||||
workspace.sessions = [session];
|
workspace.sessions = [session];
|
||||||
workspace.selectedProjectId = project.id;
|
workspace.selectedProjectId = project.id;
|
||||||
workspace.selectedPatternId = pattern.id;
|
workspace.selectedWorkflowId = workflow.id;
|
||||||
workspace.selectedSessionId = session.id;
|
workspace.selectedSessionId = session.id;
|
||||||
|
|
||||||
let command: RunTurnCommand | undefined;
|
let command: RunTurnCommand | undefined;
|
||||||
const service = createService(workspace, pattern, {
|
const service = createService(workspace, workflow, {
|
||||||
captureRunTurn: (capturedCommand) => {
|
captureRunTurn: (capturedCommand) => {
|
||||||
command = capturedCommand;
|
command = capturedCommand;
|
||||||
},
|
},
|
||||||
@@ -189,7 +213,7 @@ describe('AryxAppService project customization', () => {
|
|||||||
await service.sendSessionMessage(session.id, 'Use the repository guidance.');
|
await service.sendSessionMessage(session.id, 'Use the repository guidance.');
|
||||||
|
|
||||||
expect(command?.projectInstructions).toBe('Use TypeScript.\n\nPrefer focused tests.');
|
expect(command?.projectInstructions).toBe('Use TypeScript.\n\nPrefer focused tests.');
|
||||||
expect(command?.pattern.agents[0]?.copilot?.customAgents).toEqual([
|
expect(getPrimaryAgentConfig(command?.workflow)?.copilot?.customAgents).toEqual([
|
||||||
{
|
{
|
||||||
name: 'reviewer',
|
name: 'reviewer',
|
||||||
prompt: 'Built-in reviewer prompt.',
|
prompt: 'Built-in reviewer prompt.',
|
||||||
@@ -206,9 +230,9 @@ describe('AryxAppService project customization', () => {
|
|||||||
|
|
||||||
test('sendSessionMessage formats file-scoped and task-scoped project instructions for the sidecar', async () => {
|
test('sendSessionMessage formats file-scoped and task-scoped project instructions for the sidecar', async () => {
|
||||||
const workspace = createWorkspaceSeed();
|
const workspace = createWorkspaceSeed();
|
||||||
const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single');
|
const workflow = workspace.workflows.find((candidate) => candidate.settings.orchestrationMode === 'single');
|
||||||
if (!pattern) {
|
if (!workflow) {
|
||||||
throw new Error('Expected a single-agent pattern in the workspace seed.');
|
throw new Error('Expected a single-agent workflow in the workspace seed.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const project = createProject({
|
const project = createProject({
|
||||||
@@ -248,16 +272,16 @@ describe('AryxAppService project customization', () => {
|
|||||||
lastScannedAt: TIMESTAMP,
|
lastScannedAt: TIMESTAMP,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const session = createSession(project.id, pattern.id);
|
const session = createSession(project.id, workflow.id);
|
||||||
|
|
||||||
workspace.projects = [project];
|
workspace.projects = [project];
|
||||||
workspace.sessions = [session];
|
workspace.sessions = [session];
|
||||||
workspace.selectedProjectId = project.id;
|
workspace.selectedProjectId = project.id;
|
||||||
workspace.selectedPatternId = pattern.id;
|
workspace.selectedWorkflowId = workflow.id;
|
||||||
workspace.selectedSessionId = session.id;
|
workspace.selectedSessionId = session.id;
|
||||||
|
|
||||||
let command: RunTurnCommand | undefined;
|
let command: RunTurnCommand | undefined;
|
||||||
const service = createService(workspace, pattern, {
|
const service = createService(workspace, workflow, {
|
||||||
captureRunTurn: (capturedCommand) => {
|
captureRunTurn: (capturedCommand) => {
|
||||||
command = capturedCommand;
|
command = capturedCommand;
|
||||||
},
|
},
|
||||||
@@ -286,22 +310,22 @@ describe('AryxAppService project customization', () => {
|
|||||||
|
|
||||||
test('sendSessionMessage carries structured prompt invocations and uses prompt agent plan mode', async () => {
|
test('sendSessionMessage carries structured prompt invocations and uses prompt agent plan mode', async () => {
|
||||||
const workspace = createWorkspaceSeed();
|
const workspace = createWorkspaceSeed();
|
||||||
const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single');
|
const workflow = workspace.workflows.find((candidate) => candidate.settings.orchestrationMode === 'single');
|
||||||
if (!pattern) {
|
if (!workflow) {
|
||||||
throw new Error('Expected a single-agent pattern in the workspace seed.');
|
throw new Error('Expected a single-agent workflow in the workspace seed.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const project = createProject();
|
const project = createProject();
|
||||||
const session = createSession(project.id, pattern.id);
|
const session = createSession(project.id, workflow.id);
|
||||||
|
|
||||||
workspace.projects = [project];
|
workspace.projects = [project];
|
||||||
workspace.sessions = [session];
|
workspace.sessions = [session];
|
||||||
workspace.selectedProjectId = project.id;
|
workspace.selectedProjectId = project.id;
|
||||||
workspace.selectedPatternId = pattern.id;
|
workspace.selectedWorkflowId = workflow.id;
|
||||||
workspace.selectedSessionId = session.id;
|
workspace.selectedSessionId = session.id;
|
||||||
|
|
||||||
let command: RunTurnCommand | undefined;
|
let command: RunTurnCommand | undefined;
|
||||||
const service = createService(workspace, pattern, {
|
const service = createService(workspace, workflow, {
|
||||||
captureRunTurn: (capturedCommand) => {
|
captureRunTurn: (capturedCommand) => {
|
||||||
command = capturedCommand;
|
command = capturedCommand;
|
||||||
},
|
},
|
||||||
@@ -348,21 +372,16 @@ describe('AryxAppService project customization', () => {
|
|||||||
|
|
||||||
test('sendSessionMessage hydrates prompt model metadata and overrides the turn pattern model', async () => {
|
test('sendSessionMessage hydrates prompt model metadata and overrides the turn pattern model', async () => {
|
||||||
const workspace = createWorkspaceSeed();
|
const workspace = createWorkspaceSeed();
|
||||||
const basePattern = workspace.patterns.find((candidate) => candidate.mode === 'single');
|
const baseWorkflow = workspace.workflows.find((candidate) => candidate.settings.orchestrationMode === 'single');
|
||||||
if (!basePattern) {
|
if (!baseWorkflow) {
|
||||||
throw new Error('Expected a single-agent pattern in the workspace seed.');
|
throw new Error('Expected a single-agent workflow in the workspace seed.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const pattern: PatternDefinition = {
|
const workflow = updatePrimaryAgent(baseWorkflow, (agent) => ({
|
||||||
...basePattern,
|
...agent,
|
||||||
agents: [
|
|
||||||
{
|
|
||||||
...basePattern.agents[0]!,
|
|
||||||
model: 'gpt-5.4',
|
model: 'gpt-5.4',
|
||||||
reasoningEffort: 'high',
|
reasoningEffort: 'high',
|
||||||
},
|
}));
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
const project = createProject({
|
const project = createProject({
|
||||||
customization: {
|
customization: {
|
||||||
@@ -382,16 +401,16 @@ describe('AryxAppService project customization', () => {
|
|||||||
lastScannedAt: TIMESTAMP,
|
lastScannedAt: TIMESTAMP,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const session = createSession(project.id, pattern.id);
|
const session = createSession(project.id, workflow.id);
|
||||||
|
|
||||||
workspace.projects = [project];
|
workspace.projects = [project];
|
||||||
workspace.sessions = [session];
|
workspace.sessions = [session];
|
||||||
workspace.selectedProjectId = project.id;
|
workspace.selectedProjectId = project.id;
|
||||||
workspace.selectedPatternId = pattern.id;
|
workspace.selectedWorkflowId = workflow.id;
|
||||||
workspace.selectedSessionId = session.id;
|
workspace.selectedSessionId = session.id;
|
||||||
|
|
||||||
let command: RunTurnCommand | undefined;
|
let command: RunTurnCommand | undefined;
|
||||||
const service = createService(workspace, pattern, {
|
const service = createService(workspace, workflow, {
|
||||||
captureRunTurn: (capturedCommand) => {
|
captureRunTurn: (capturedCommand) => {
|
||||||
command = capturedCommand;
|
command = capturedCommand;
|
||||||
},
|
},
|
||||||
@@ -412,8 +431,8 @@ describe('AryxAppService project customization', () => {
|
|||||||
model: 'Claude Sonnet 4.5',
|
model: 'Claude Sonnet 4.5',
|
||||||
resolvedPrompt: 'Review the REST API for security gaps.',
|
resolvedPrompt: 'Review the REST API for security gaps.',
|
||||||
});
|
});
|
||||||
expect(command?.pattern.agents[0]?.model).toBe('claude-sonnet-4.5');
|
expect(getPrimaryAgentConfig(command?.workflow)?.model).toBe('claude-sonnet-4.5');
|
||||||
expect(command?.pattern.agents[0]?.reasoningEffort).toBeUndefined();
|
expect(getPrimaryAgentConfig(command?.workflow)?.reasoningEffort).toBeUndefined();
|
||||||
expect(command?.promptInvocation).toEqual({
|
expect(command?.promptInvocation).toEqual({
|
||||||
id: 'project_customization_prompt_rest_review',
|
id: 'project_customization_prompt_rest_review',
|
||||||
name: 'rest-review',
|
name: 'rest-review',
|
||||||
@@ -426,9 +445,9 @@ describe('AryxAppService project customization', () => {
|
|||||||
|
|
||||||
test('setProjectAgentProfileEnabled persists the updated enabled state', async () => {
|
test('setProjectAgentProfileEnabled persists the updated enabled state', async () => {
|
||||||
const workspace = createWorkspaceSeed();
|
const workspace = createWorkspaceSeed();
|
||||||
const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single');
|
const workflow = workspace.workflows.find((candidate) => candidate.settings.orchestrationMode === 'single');
|
||||||
if (!pattern) {
|
if (!workflow) {
|
||||||
throw new Error('Expected a single-agent pattern in the workspace seed.');
|
throw new Error('Expected a single-agent workflow in the workspace seed.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const project = createProject({
|
const project = createProject({
|
||||||
@@ -447,12 +466,12 @@ describe('AryxAppService project customization', () => {
|
|||||||
lastScannedAt: TIMESTAMP,
|
lastScannedAt: TIMESTAMP,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const session = createSession(project.id, pattern.id);
|
const session = createSession(project.id, workflow.id);
|
||||||
|
|
||||||
workspace.projects = [project];
|
workspace.projects = [project];
|
||||||
workspace.sessions = [session];
|
workspace.sessions = [session];
|
||||||
|
|
||||||
const service = createService(workspace, pattern);
|
const service = createService(workspace, workflow);
|
||||||
const updated = await service.setProjectAgentProfileEnabled(project.id, 'agent-readme', false);
|
const updated = await service.setProjectAgentProfileEnabled(project.id, 'agent-readme', false);
|
||||||
|
|
||||||
expect(updated.projects[0]?.customization?.agentProfiles).toEqual([
|
expect(updated.projects[0]?.customization?.agentProfiles).toEqual([
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { afterEach, beforeEach, describe, expect, mock, test } from 'bun:test';
|
|||||||
import { access, mkdir, rm } from 'node:fs/promises';
|
import { access, mkdir, rm } from 'node:fs/promises';
|
||||||
import { join } from 'node:path';
|
import { join } from 'node:path';
|
||||||
|
|
||||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
import type { WorkflowDefinition } from '@shared/domain/workflow';
|
||||||
import { createScratchpadProject } from '@shared/domain/project';
|
import { createScratchpadProject } from '@shared/domain/project';
|
||||||
import type { SessionRecord } from '@shared/domain/session';
|
import type { SessionRecord } from '@shared/domain/session';
|
||||||
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
||||||
@@ -50,8 +50,8 @@ async function pathExists(path: string): Promise<boolean> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function requireSinglePattern(workspace: WorkspaceState): PatternDefinition {
|
function requireSinglePattern(workspace: WorkspaceState): WorkflowDefinition {
|
||||||
const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single');
|
const pattern = workspace.workflows.find((candidate) => candidate.settings.orchestrationMode === 'single');
|
||||||
if (!pattern) {
|
if (!pattern) {
|
||||||
throw new Error('Expected the workspace seed to include a single-agent pattern.');
|
throw new Error('Expected the workspace seed to include a single-agent pattern.');
|
||||||
}
|
}
|
||||||
@@ -59,11 +59,11 @@ function requireSinglePattern(workspace: WorkspaceState): PatternDefinition {
|
|||||||
return pattern;
|
return pattern;
|
||||||
}
|
}
|
||||||
|
|
||||||
function createScratchpadSession(patternId: string, overrides?: Partial<SessionRecord>): SessionRecord {
|
function createScratchpadSession(workflowId: string, overrides?: Partial<SessionRecord>): SessionRecord {
|
||||||
return {
|
return {
|
||||||
id: 'session-scratchpad',
|
id: 'session-scratchpad',
|
||||||
projectId: 'project-scratchpad',
|
projectId: 'project-scratchpad',
|
||||||
patternId,
|
workflowId,
|
||||||
title: 'Scratchpad',
|
title: 'Scratchpad',
|
||||||
createdAt: TIMESTAMP,
|
createdAt: TIMESTAMP,
|
||||||
updatedAt: TIMESTAMP,
|
updatedAt: TIMESTAMP,
|
||||||
@@ -116,7 +116,7 @@ describe('AryxAppService scratchpad directories', () => {
|
|||||||
const scratchpadProject = createScratchpadProject(join(USER_DATA_PATH, 'scratchpad'), TIMESTAMP);
|
const scratchpadProject = createScratchpadProject(join(USER_DATA_PATH, 'scratchpad'), TIMESTAMP);
|
||||||
workspace.projects = [scratchpadProject];
|
workspace.projects = [scratchpadProject];
|
||||||
workspace.selectedProjectId = scratchpadProject.id;
|
workspace.selectedProjectId = scratchpadProject.id;
|
||||||
workspace.selectedPatternId = pattern.id;
|
workspace.selectedWorkflowId = pattern.id;
|
||||||
|
|
||||||
const service = createService(workspace);
|
const service = createService(workspace);
|
||||||
|
|
||||||
@@ -152,7 +152,7 @@ describe('AryxAppService scratchpad directories', () => {
|
|||||||
workspace.projects = [scratchpadProject];
|
workspace.projects = [scratchpadProject];
|
||||||
workspace.sessions = [originalSession];
|
workspace.sessions = [originalSession];
|
||||||
workspace.selectedProjectId = scratchpadProject.id;
|
workspace.selectedProjectId = scratchpadProject.id;
|
||||||
workspace.selectedPatternId = pattern.id;
|
workspace.selectedWorkflowId = pattern.id;
|
||||||
workspace.selectedSessionId = originalSession.id;
|
workspace.selectedSessionId = originalSession.id;
|
||||||
|
|
||||||
const service = createService(workspace);
|
const service = createService(workspace);
|
||||||
@@ -212,7 +212,7 @@ describe('AryxAppService scratchpad directories', () => {
|
|||||||
workspace.projects = [scratchpadProject];
|
workspace.projects = [scratchpadProject];
|
||||||
workspace.sessions = [originalSession];
|
workspace.sessions = [originalSession];
|
||||||
workspace.selectedProjectId = scratchpadProject.id;
|
workspace.selectedProjectId = scratchpadProject.id;
|
||||||
workspace.selectedPatternId = pattern.id;
|
workspace.selectedWorkflowId = pattern.id;
|
||||||
workspace.selectedSessionId = originalSession.id;
|
workspace.selectedSessionId = originalSession.id;
|
||||||
|
|
||||||
const service = createService(workspace);
|
const service = createService(workspace);
|
||||||
@@ -248,7 +248,7 @@ describe('AryxAppService scratchpad directories', () => {
|
|||||||
workspace.projects = [scratchpadProject];
|
workspace.projects = [scratchpadProject];
|
||||||
workspace.sessions = [session];
|
workspace.sessions = [session];
|
||||||
workspace.selectedProjectId = scratchpadProject.id;
|
workspace.selectedProjectId = scratchpadProject.id;
|
||||||
workspace.selectedPatternId = pattern.id;
|
workspace.selectedWorkflowId = pattern.id;
|
||||||
workspace.selectedSessionId = session.id;
|
workspace.selectedSessionId = session.id;
|
||||||
|
|
||||||
const service = createService(workspace);
|
const service = createService(workspace);
|
||||||
|
|||||||
@@ -201,7 +201,7 @@ describe('AryxAppService sub-workflow operations', () => {
|
|||||||
expect(session?.workflowId).toBe('parent');
|
expect(session?.workflowId).toBe('parent');
|
||||||
expect(session?.sessionModelConfig).toEqual({
|
expect(session?.sessionModelConfig).toEqual({
|
||||||
model: 'gpt-5.4',
|
model: 'gpt-5.4',
|
||||||
reasoningEffort: 'medium',
|
reasoningEffort: undefined,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -48,11 +48,11 @@ function createProject(overrides?: Partial<ProjectRecord>): ProjectRecord {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSession(patternId: string, overrides?: Partial<SessionRecord>): SessionRecord {
|
function createSession(workflowId: string, overrides?: Partial<SessionRecord>): SessionRecord {
|
||||||
return {
|
return {
|
||||||
id: 'session-1',
|
id: 'session-1',
|
||||||
projectId: 'project-1',
|
projectId: 'project-1',
|
||||||
patternId,
|
workflowId,
|
||||||
title: 'Terminal Session',
|
title: 'Terminal Session',
|
||||||
createdAt: TIMESTAMP,
|
createdAt: TIMESTAMP,
|
||||||
updatedAt: TIMESTAMP,
|
updatedAt: TIMESTAMP,
|
||||||
@@ -133,7 +133,7 @@ function createService(workspace: WorkspaceState, terminalSnapshot = createTermi
|
|||||||
describe('AryxAppService terminal integration', () => {
|
describe('AryxAppService terminal integration', () => {
|
||||||
test('uses the selected session cwd when creating and restarting the terminal', async () => {
|
test('uses the selected session cwd when creating and restarting the terminal', async () => {
|
||||||
const workspace = createWorkspaceSeed();
|
const workspace = createWorkspaceSeed();
|
||||||
const pattern = workspace.patterns[0];
|
const pattern = workspace.workflows[0];
|
||||||
if (!pattern) {
|
if (!pattern) {
|
||||||
throw new Error('Expected a seeded pattern.');
|
throw new Error('Expected a seeded pattern.');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, mock, test } from 'bun:test';
|
import { describe, expect, mock, test } from 'bun:test';
|
||||||
|
|
||||||
import type { RunTurnCommand } from '@shared/contracts/sidecar';
|
import type { RunTurnCommand } from '@shared/contracts/sidecar';
|
||||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
import type { WorkflowDefinition } from '@shared/domain/workflow';
|
||||||
import { createScratchpadProject } from '@shared/domain/project';
|
import { createScratchpadProject } from '@shared/domain/project';
|
||||||
import type { SessionRecord } from '@shared/domain/session';
|
import type { SessionRecord } from '@shared/domain/session';
|
||||||
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
|
||||||
@@ -43,11 +43,11 @@ const { AryxAppService } = await import('@main/AryxAppService');
|
|||||||
|
|
||||||
function createWorkspaceFixture(): {
|
function createWorkspaceFixture(): {
|
||||||
workspace: WorkspaceState;
|
workspace: WorkspaceState;
|
||||||
pattern: PatternDefinition;
|
pattern: WorkflowDefinition;
|
||||||
session: SessionRecord;
|
session: SessionRecord;
|
||||||
} {
|
} {
|
||||||
const workspace = createWorkspaceSeed();
|
const workspace = createWorkspaceSeed();
|
||||||
const pattern = workspace.patterns.find((candidate) => candidate.mode === 'single');
|
const pattern = workspace.workflows.find((candidate) => candidate.settings.orchestrationMode === 'single');
|
||||||
if (!pattern) {
|
if (!pattern) {
|
||||||
throw new Error('Expected the workspace seed to include a single-agent pattern.');
|
throw new Error('Expected the workspace seed to include a single-agent pattern.');
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,7 @@ function createWorkspaceFixture(): {
|
|||||||
const session: SessionRecord = {
|
const session: SessionRecord = {
|
||||||
id: 'session-scratchpad',
|
id: 'session-scratchpad',
|
||||||
projectId: project.id,
|
projectId: project.id,
|
||||||
patternId: pattern.id,
|
workflowId: pattern.id,
|
||||||
title: 'Scratchpad',
|
title: 'Scratchpad',
|
||||||
createdAt: TIMESTAMP,
|
createdAt: TIMESTAMP,
|
||||||
updatedAt: TIMESTAMP,
|
updatedAt: TIMESTAMP,
|
||||||
@@ -69,7 +69,7 @@ function createWorkspaceFixture(): {
|
|||||||
workspace.projects = [project];
|
workspace.projects = [project];
|
||||||
workspace.sessions = [session];
|
workspace.sessions = [session];
|
||||||
workspace.selectedProjectId = project.id;
|
workspace.selectedProjectId = project.id;
|
||||||
workspace.selectedPatternId = pattern.id;
|
workspace.selectedWorkflowId = pattern.id;
|
||||||
workspace.selectedSessionId = session.id;
|
workspace.selectedSessionId = session.id;
|
||||||
workspace.settings.tooling = {
|
workspace.settings.tooling = {
|
||||||
mcpServers: [
|
mcpServers: [
|
||||||
@@ -105,7 +105,7 @@ function createWorkspaceFixture(): {
|
|||||||
|
|
||||||
function createService(
|
function createService(
|
||||||
workspace: WorkspaceState,
|
workspace: WorkspaceState,
|
||||||
pattern: PatternDefinition,
|
pattern: WorkflowDefinition,
|
||||||
options?: {
|
options?: {
|
||||||
captureRunTurn?: (command: RunTurnCommand) => void;
|
captureRunTurn?: (command: RunTurnCommand) => void;
|
||||||
knownApprovalToolNames?: string[];
|
knownApprovalToolNames?: string[];
|
||||||
|
|||||||
@@ -87,11 +87,13 @@ function createWorkflow(): WorkflowDefinition {
|
|||||||
describe('AryxAppService workflow operations', () => {
|
describe('AryxAppService workflow operations', () => {
|
||||||
test('saves workflows into workspace state', async () => {
|
test('saves workflows into workspace state', async () => {
|
||||||
const workspace = createWorkspaceSeed();
|
const workspace = createWorkspaceSeed();
|
||||||
|
const initialWorkflowCount = workspace.workflows.length;
|
||||||
const service = createService(workspace);
|
const service = createService(workspace);
|
||||||
|
|
||||||
const result = await service.saveWorkflow(createWorkflow());
|
const result = await service.saveWorkflow(createWorkflow());
|
||||||
|
|
||||||
expect(result.workflows).toHaveLength(1);
|
expect(result.workflows).toHaveLength(initialWorkflowCount + 1);
|
||||||
|
expect(result.workflows.some((workflow) => workflow.id === 'workflow-test')).toBe(true);
|
||||||
expect(result.selectedWorkflowId).toBe('workflow-test');
|
expect(result.selectedWorkflowId).toBe('workflow-test');
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -115,40 +117,28 @@ describe('AryxAppService workflow operations', () => {
|
|||||||
|
|
||||||
test('creates workflows from templates and selects the new workflow', async () => {
|
test('creates workflows from templates and selects the new workflow', async () => {
|
||||||
const workspace = createWorkspaceSeed();
|
const workspace = createWorkspaceSeed();
|
||||||
|
const initialWorkflowCount = workspace.workflows.length;
|
||||||
const service = createService(workspace);
|
const service = createService(workspace);
|
||||||
|
|
||||||
const result = await service.createWorkflowFromTemplate('workflow-template-code-review', {
|
const result = await service.createWorkflowFromTemplate('workflow-template-code-review', {
|
||||||
name: 'Template Copy',
|
name: 'Template Copy',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.workflows).toHaveLength(1);
|
expect(result.workflows).toHaveLength(initialWorkflowCount + 1);
|
||||||
expect(result.workflows[0]?.name).toBe('Template Copy');
|
const createdWorkflow = result.workflows.find((workflow) => workflow.name === 'Template Copy');
|
||||||
expect(result.selectedWorkflowId).toBe(result.workflows[0]?.id);
|
expect(createdWorkflow).toBeDefined();
|
||||||
});
|
expect(result.selectedWorkflowId).toBe(createdWorkflow?.id);
|
||||||
|
|
||||||
test('upgrades a pattern to a saved workflow without removing the pattern', async () => {
|
|
||||||
const workspace = createWorkspaceSeed();
|
|
||||||
const pattern = workspace.patterns.find((candidate) => candidate.mode === 'handoff');
|
|
||||||
if (!pattern) {
|
|
||||||
throw new Error('Expected a handoff pattern.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const service = createService(workspace);
|
|
||||||
const result = await service.upgradePatternToWorkflow(pattern.id, { save: true });
|
|
||||||
|
|
||||||
expect(result.workspace?.workflows).toHaveLength(1);
|
|
||||||
expect(result.workspace?.patterns.some((candidate) => candidate.id === pattern.id)).toBe(true);
|
|
||||||
expect(result.workflow.id).toBe('workflow-handoff-support');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
test('imports and saves workflows from yaml', async () => {
|
test('imports and saves workflows from yaml', async () => {
|
||||||
const workspace = createWorkspaceSeed();
|
const workspace = createWorkspaceSeed();
|
||||||
|
const initialWorkflowCount = workspace.workflows.length;
|
||||||
const service = createService(workspace);
|
const service = createService(workspace);
|
||||||
const yaml = exportWorkflowDefinition(createWorkflow(), 'yaml').content;
|
const yaml = exportWorkflowDefinition(createWorkflow(), 'yaml').content;
|
||||||
|
|
||||||
const result = await service.importWorkflow(yaml, 'yaml', { save: true });
|
const result = await service.importWorkflow(yaml, 'yaml', { save: true });
|
||||||
|
|
||||||
expect(result.workspace?.workflows).toHaveLength(1);
|
expect(result.workspace?.workflows).toHaveLength(initialWorkflowCount + 1);
|
||||||
expect(result.workspace?.selectedWorkflowId).toBe('workflow-test');
|
expect(result.workspace?.selectedWorkflowId).toBe('workflow-test');
|
||||||
expect(result.workflow.id).toBe('workflow-test');
|
expect(result.workflow.id).toBe('workflow-test');
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
type RunTimelineEventRecord,
|
type RunTimelineEventRecord,
|
||||||
type SessionRunRecord,
|
type SessionRunRecord,
|
||||||
} from '@shared/domain/runTimeline';
|
} from '@shared/domain/runTimeline';
|
||||||
|
import type { WorkflowDefinition } from '@shared/domain/workflow';
|
||||||
|
|
||||||
mock.module('electron', () => {
|
mock.module('electron', () => {
|
||||||
const electronMock = {
|
const electronMock = {
|
||||||
@@ -200,7 +201,7 @@ describe('AryxAppService workflow checkpointing', () => {
|
|||||||
workspaceKind: 'scratchpad',
|
workspaceKind: 'scratchpad',
|
||||||
mode: 'interactive',
|
mode: 'interactive',
|
||||||
messageMode: 'enqueue',
|
messageMode: 'enqueue',
|
||||||
pattern: createPattern(),
|
workflow: createWorkflow(),
|
||||||
messages: session.messages,
|
messages: session.messages,
|
||||||
resumeFromCheckpoint,
|
resumeFromCheckpoint,
|
||||||
}),
|
}),
|
||||||
@@ -230,7 +231,7 @@ describe('AryxAppService workflow checkpointing', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
function createRunningSession(): { session: SessionRecord; run: SessionRunRecord } {
|
function createRunningSession(): { session: SessionRecord; run: SessionRunRecord } {
|
||||||
const pattern = createPattern();
|
const workflow = createWorkflow();
|
||||||
const run = createSessionRunRecord({
|
const run = createSessionRunRecord({
|
||||||
requestId: 'turn-1',
|
requestId: 'turn-1',
|
||||||
project: {
|
project: {
|
||||||
@@ -239,14 +240,14 @@ function createRunningSession(): { session: SessionRecord; run: SessionRunRecord
|
|||||||
},
|
},
|
||||||
workingDirectory: 'C:\\scratchpad',
|
workingDirectory: 'C:\\scratchpad',
|
||||||
workspaceKind: 'scratchpad',
|
workspaceKind: 'scratchpad',
|
||||||
pattern,
|
workflow,
|
||||||
triggerMessageId: 'msg-user-1',
|
triggerMessageId: 'msg-user-1',
|
||||||
startedAt: '2026-04-01T12:00:00.000Z',
|
startedAt: '2026-04-01T12:00:00.000Z',
|
||||||
});
|
});
|
||||||
const session: SessionRecord = {
|
const session: SessionRecord = {
|
||||||
id: 'session-1',
|
id: 'session-1',
|
||||||
projectId: SCRATCHPAD_PROJECT_ID,
|
projectId: SCRATCHPAD_PROJECT_ID,
|
||||||
patternId: pattern.id,
|
workflowId: workflow.id,
|
||||||
title: 'Checkpoint session',
|
title: 'Checkpoint session',
|
||||||
createdAt: '2026-04-01T12:00:00.000Z',
|
createdAt: '2026-04-01T12:00:00.000Z',
|
||||||
updatedAt: '2026-04-01T12:00:00.000Z',
|
updatedAt: '2026-04-01T12:00:00.000Z',
|
||||||
@@ -274,23 +275,42 @@ function createRunningSession(): { session: SessionRecord; run: SessionRunRecord
|
|||||||
return { session, run };
|
return { session, run };
|
||||||
}
|
}
|
||||||
|
|
||||||
function createPattern() {
|
function createWorkflow(): WorkflowDefinition {
|
||||||
return {
|
return {
|
||||||
id: 'pattern-handoff',
|
id: 'workflow-handoff',
|
||||||
name: 'Checkpointing flow',
|
name: 'Checkpointing flow',
|
||||||
description: '',
|
description: '',
|
||||||
mode: 'handoff' as const,
|
graph: {
|
||||||
availability: 'available' as const,
|
nodes: [
|
||||||
maxIterations: 4,
|
{ id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
|
||||||
agents: [
|
{
|
||||||
{
|
id: 'agent-1',
|
||||||
id: 'agent-1',
|
kind: 'agent',
|
||||||
name: 'Primary',
|
label: 'Primary',
|
||||||
description: '',
|
position: { x: 200, y: 0 },
|
||||||
instructions: 'Help with the request.',
|
order: 0,
|
||||||
model: 'gpt-5.4',
|
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' as const,
|
||||||
|
orchestrationMode: 'handoff' as const,
|
||||||
|
maxIterations: 4,
|
||||||
|
},
|
||||||
createdAt: '2026-04-01T00:00:00.000Z',
|
createdAt: '2026-04-01T00:00:00.000Z',
|
||||||
updatedAt: '2026-04-01T00:00:00.000Z',
|
updatedAt: '2026-04-01T00:00:00.000Z',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -157,22 +157,41 @@ describe('SidecarClient', () => {
|
|||||||
requestId: 'turn-1',
|
requestId: 'turn-1',
|
||||||
sessionId: 'session-1',
|
sessionId: 'session-1',
|
||||||
projectPath: 'C:\\workspace\\project',
|
projectPath: 'C:\\workspace\\project',
|
||||||
pattern: {
|
workflow: {
|
||||||
id: 'pattern-1',
|
id: 'workflow-1',
|
||||||
name: 'Single Agent',
|
name: 'Single Agent',
|
||||||
description: '',
|
description: '',
|
||||||
mode: 'single',
|
graph: {
|
||||||
availability: 'available',
|
nodes: [
|
||||||
maxIterations: 1,
|
{ id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
|
||||||
agents: [
|
{
|
||||||
{
|
id: 'agent-1',
|
||||||
id: 'agent-1',
|
kind: 'agent',
|
||||||
name: 'Primary',
|
label: 'Primary',
|
||||||
description: '',
|
position: { x: 200, y: 0 },
|
||||||
instructions: 'Help with the request.',
|
order: 0,
|
||||||
model: 'gpt-5.4',
|
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: false },
|
||||||
|
executionMode: 'off-thread',
|
||||||
|
orchestrationMode: 'single',
|
||||||
|
maxIterations: 1,
|
||||||
|
},
|
||||||
createdAt: '2026-04-01T00:00:00.000Z',
|
createdAt: '2026-04-01T00:00:00.000Z',
|
||||||
updatedAt: '2026-04-01T00:00:00.000Z',
|
updatedAt: '2026-04-01T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
@@ -253,22 +272,41 @@ describe('SidecarClient', () => {
|
|||||||
requestId: 'turn-1',
|
requestId: 'turn-1',
|
||||||
sessionId: 'session-1',
|
sessionId: 'session-1',
|
||||||
projectPath: 'C:\\workspace\\project',
|
projectPath: 'C:\\workspace\\project',
|
||||||
pattern: {
|
workflow: {
|
||||||
id: 'pattern-1',
|
id: 'workflow-1',
|
||||||
name: 'Handoff',
|
name: 'Handoff',
|
||||||
description: '',
|
description: '',
|
||||||
mode: 'handoff',
|
graph: {
|
||||||
availability: 'available',
|
nodes: [
|
||||||
maxIterations: 1,
|
{ id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
|
||||||
agents: [
|
{
|
||||||
{
|
id: 'agent-1',
|
||||||
id: 'agent-1',
|
kind: 'agent',
|
||||||
name: 'Primary',
|
label: 'Primary',
|
||||||
description: '',
|
position: { x: 200, y: 0 },
|
||||||
instructions: 'Help with the request.',
|
order: 0,
|
||||||
model: 'gpt-5.4',
|
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: 1,
|
||||||
|
},
|
||||||
createdAt: '2026-04-01T00:00:00.000Z',
|
createdAt: '2026-04-01T00:00:00.000Z',
|
||||||
updatedAt: '2026-04-01T00:00:00.000Z',
|
updatedAt: '2026-04-01T00:00:00.000Z',
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -27,15 +27,15 @@ const { WorkspaceRepository } = await import('@main/persistence/workspaceReposit
|
|||||||
function createStoredWorkspace(): WorkspaceState {
|
function createStoredWorkspace(): WorkspaceState {
|
||||||
const workspace = createWorkspaceSeed();
|
const workspace = createWorkspaceSeed();
|
||||||
const scratchpadProject = createScratchpadProject('C:\\legacy\\scratchpad', TIMESTAMP);
|
const scratchpadProject = createScratchpadProject('C:\\legacy\\scratchpad', TIMESTAMP);
|
||||||
const patternId = workspace.patterns[0]?.id;
|
const workflowId = workspace.workflows[0]?.id;
|
||||||
if (!patternId) {
|
if (!workflowId) {
|
||||||
throw new Error('Expected workspace seed to include at least one pattern.');
|
throw new Error('Expected workspace seed to include at least one pattern.');
|
||||||
}
|
}
|
||||||
|
|
||||||
const session: SessionRecord = {
|
const session: SessionRecord = {
|
||||||
id: 'session-scratchpad',
|
id: 'session-scratchpad',
|
||||||
projectId: scratchpadProject.id,
|
projectId: scratchpadProject.id,
|
||||||
patternId,
|
workflowId,
|
||||||
title: 'Scratchpad',
|
title: 'Scratchpad',
|
||||||
createdAt: TIMESTAMP,
|
createdAt: TIMESTAMP,
|
||||||
updatedAt: TIMESTAMP,
|
updatedAt: TIMESTAMP,
|
||||||
@@ -49,7 +49,7 @@ function createStoredWorkspace(): WorkspaceState {
|
|||||||
projects: [scratchpadProject],
|
projects: [scratchpadProject],
|
||||||
sessions: [session],
|
sessions: [session],
|
||||||
selectedProjectId: scratchpadProject.id,
|
selectedProjectId: scratchpadProject.id,
|
||||||
selectedPatternId: patternId,
|
selectedWorkflowId: workflowId,
|
||||||
selectedSessionId: session.id,
|
selectedSessionId: session.id,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ function createSession(
|
|||||||
return {
|
return {
|
||||||
id: 'session-1',
|
id: 'session-1',
|
||||||
projectId: 'project-1',
|
projectId: 'project-1',
|
||||||
patternId: 'pattern-1',
|
workflowId: 'pattern-1',
|
||||||
title: 'Test session',
|
title: 'Test session',
|
||||||
createdAt: '2026-03-23T00:00:00.000Z',
|
createdAt: '2026-03-23T00:00:00.000Z',
|
||||||
updatedAt: '2026-03-23T00:00:00.000Z',
|
updatedAt: '2026-03-23T00:00:00.000Z',
|
||||||
|
|||||||
@@ -16,11 +16,11 @@ import {
|
|||||||
type SessionActivityMap,
|
type SessionActivityMap,
|
||||||
type SessionRequestUsageMap,
|
type SessionRequestUsageMap,
|
||||||
} from '@renderer/lib/sessionActivity';
|
} from '@renderer/lib/sessionActivity';
|
||||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
import type { WorkflowDefinition } from '@shared/domain/workflow';
|
||||||
import type { SessionEventRecord } from '@shared/domain/event';
|
import type { SessionEventRecord } from '@shared/domain/event';
|
||||||
|
|
||||||
describe('session activity helpers', () => {
|
describe('session activity helpers', () => {
|
||||||
const agents: PatternDefinition['agents'] = [
|
const agents = [
|
||||||
{
|
{
|
||||||
id: 'architect',
|
id: 'architect',
|
||||||
name: 'Architect',
|
name: 'Architect',
|
||||||
@@ -37,7 +37,14 @@ describe('session activity helpers', () => {
|
|||||||
model: 'gpt-5.4',
|
model: 'gpt-5.4',
|
||||||
reasoningEffort: 'medium',
|
reasoningEffort: 'medium',
|
||||||
},
|
},
|
||||||
];
|
] satisfies Array<{
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
instructions: string;
|
||||||
|
model: string;
|
||||||
|
reasoningEffort?: 'high' | 'medium';
|
||||||
|
}>;
|
||||||
|
|
||||||
test('stores activity per session and per agent', () => {
|
test('stores activity per session and per agent', () => {
|
||||||
const architectEvent: SessionEventRecord = {
|
const architectEvent: SessionEventRecord = {
|
||||||
@@ -247,9 +254,9 @@ describe('session activity helpers', () => {
|
|||||||
projectId: 'project-1',
|
projectId: 'project-1',
|
||||||
projectPath: 'C:\\workspace\\project',
|
projectPath: 'C:\\workspace\\project',
|
||||||
workspaceKind: 'project',
|
workspaceKind: 'project',
|
||||||
patternId: 'pattern-1',
|
workflowId: 'workflow-1',
|
||||||
patternName: 'Pattern',
|
workflowName: 'Pattern',
|
||||||
patternMode: 'single',
|
workflowMode: 'single',
|
||||||
triggerMessageId: 'msg-1',
|
triggerMessageId: 'msg-1',
|
||||||
startedAt: '2026-03-23T00:00:00.000Z',
|
startedAt: '2026-03-23T00:00:00.000Z',
|
||||||
completedAt: '2026-03-23T00:00:01.000Z',
|
completedAt: '2026-03-23T00:00:01.000Z',
|
||||||
|
|||||||
@@ -10,7 +10,6 @@ describe('session workspace helpers', () => {
|
|||||||
function createWorkspace(): WorkspaceState {
|
function createWorkspace(): WorkspaceState {
|
||||||
return {
|
return {
|
||||||
projects: [],
|
projects: [],
|
||||||
patterns: [],
|
|
||||||
workflows: [],
|
workflows: [],
|
||||||
workflowTemplates: [],
|
workflowTemplates: [],
|
||||||
settings: createWorkspaceSettings(),
|
settings: createWorkspaceSettings(),
|
||||||
@@ -18,7 +17,7 @@ describe('session workspace helpers', () => {
|
|||||||
{
|
{
|
||||||
id: 'session-1',
|
id: 'session-1',
|
||||||
projectId: 'project-1',
|
projectId: 'project-1',
|
||||||
patternId: 'pattern-1',
|
workflowId: 'workflow-1',
|
||||||
title: 'Session',
|
title: 'Session',
|
||||||
createdAt: '2026-03-23T00:00:00.000Z',
|
createdAt: '2026-03-23T00:00:00.000Z',
|
||||||
updatedAt: '2026-03-23T00:00:00.000Z',
|
updatedAt: '2026-03-23T00:00:00.000Z',
|
||||||
@@ -196,9 +195,9 @@ describe('session workspace helpers', () => {
|
|||||||
projectId: 'project-1',
|
projectId: 'project-1',
|
||||||
projectPath: 'C:\\workspace\\alpha',
|
projectPath: 'C:\\workspace\\alpha',
|
||||||
workspaceKind: 'project',
|
workspaceKind: 'project',
|
||||||
patternId: 'pattern-1',
|
workflowId: 'workflow-1',
|
||||||
patternName: 'Sequential Review',
|
workflowName: 'Sequential Review',
|
||||||
patternMode: 'sequential',
|
workflowMode: 'sequential',
|
||||||
triggerMessageId: 'msg-user-1',
|
triggerMessageId: 'msg-user-1',
|
||||||
startedAt: '2026-03-23T00:00:01.000Z',
|
startedAt: '2026-03-23T00:00:01.000Z',
|
||||||
status: 'running',
|
status: 'running',
|
||||||
|
|||||||
+40
-20
@@ -5,10 +5,10 @@ import {
|
|||||||
buildAvailableModelCatalog,
|
buildAvailableModelCatalog,
|
||||||
findModel,
|
findModel,
|
||||||
findModelByReference,
|
findModelByReference,
|
||||||
normalizePatternModels,
|
normalizeWorkflowModels,
|
||||||
resolveReasoningEffort,
|
resolveReasoningEffort,
|
||||||
} from '@shared/domain/models';
|
} from '@shared/domain/models';
|
||||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
import type { WorkflowDefinition } from '@shared/domain/workflow';
|
||||||
|
|
||||||
const availableModels: SidecarModelCapability[] = [
|
const availableModels: SidecarModelCapability[] = [
|
||||||
{
|
{
|
||||||
@@ -24,24 +24,43 @@ const availableModels: SidecarModelCapability[] = [
|
|||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
function createPattern(): PatternDefinition {
|
function createWorkflow(): WorkflowDefinition {
|
||||||
return {
|
return {
|
||||||
id: 'pattern-1',
|
id: 'pattern-1',
|
||||||
name: 'Pattern',
|
name: 'Pattern',
|
||||||
description: '',
|
description: '',
|
||||||
mode: 'single',
|
graph: {
|
||||||
availability: 'available',
|
nodes: [
|
||||||
maxIterations: 1,
|
{ id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
|
||||||
agents: [
|
{
|
||||||
{
|
id: 'agent-1',
|
||||||
id: 'agent-1',
|
kind: 'agent',
|
||||||
name: 'Primary Agent',
|
label: 'Primary Agent',
|
||||||
description: 'Helpful assistant',
|
position: { x: 200, y: 0 },
|
||||||
instructions: 'Help the user.',
|
order: 0,
|
||||||
model: 'claude-sonnet-4.5',
|
config: {
|
||||||
reasoningEffort: 'high',
|
kind: 'agent',
|
||||||
},
|
id: 'agent-1',
|
||||||
],
|
name: 'Primary Agent',
|
||||||
|
description: 'Helpful assistant',
|
||||||
|
instructions: 'Help the user.',
|
||||||
|
model: 'claude-sonnet-4.5',
|
||||||
|
reasoningEffort: 'high',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ 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: false },
|
||||||
|
executionMode: 'off-thread',
|
||||||
|
orchestrationMode: 'single',
|
||||||
|
maxIterations: 1,
|
||||||
|
},
|
||||||
createdAt: '2026-03-23T00:00:00.000Z',
|
createdAt: '2026-03-23T00:00:00.000Z',
|
||||||
updatedAt: '2026-03-23T00:00:00.000Z',
|
updatedAt: '2026-03-23T00:00:00.000Z',
|
||||||
};
|
};
|
||||||
@@ -70,12 +89,13 @@ describe('dynamic model catalog', () => {
|
|||||||
expect(findModelByReference('Claude Sonnet 4.5', catalog)?.id).toBe('claude-sonnet-4.5');
|
expect(findModelByReference('Claude Sonnet 4.5', catalog)?.id).toBe('claude-sonnet-4.5');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('normalizes pattern agents before runtime execution', () => {
|
test('normalizes workflow agent reasoning effort before runtime execution', () => {
|
||||||
const normalized = normalizePatternModels(
|
const normalized = normalizeWorkflowModels(
|
||||||
createPattern(),
|
createWorkflow(),
|
||||||
buildAvailableModelCatalog(availableModels),
|
buildAvailableModelCatalog(availableModels),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(normalized.agents[0].reasoningEffort).toBeUndefined();
|
const primaryAgent = normalized.graph.nodes.find((node) => node.kind === 'agent');
|
||||||
|
expect(primaryAgent?.config.kind === 'agent' ? primaryAgent.config.reasoningEffort : undefined).toBeUndefined();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
import type { WorkflowDefinition } from '@shared/domain/workflow';
|
||||||
import {
|
import {
|
||||||
appendRunActivityEvent,
|
appendRunActivityEvent,
|
||||||
completeSessionRunRecord,
|
completeSessionRunRecord,
|
||||||
@@ -12,30 +12,58 @@ import {
|
|||||||
import type { ProjectRecord } from '@shared/domain/project';
|
import type { ProjectRecord } from '@shared/domain/project';
|
||||||
import type { PendingApprovalRecord } from '@shared/domain/approval';
|
import type { PendingApprovalRecord } from '@shared/domain/approval';
|
||||||
|
|
||||||
function createPattern(): PatternDefinition {
|
function createWorkflow(): WorkflowDefinition {
|
||||||
return {
|
return {
|
||||||
id: 'pattern-sequential',
|
id: 'workflow-sequential',
|
||||||
name: 'Sequential Trio Review',
|
name: 'Sequential Trio Review',
|
||||||
description: 'Sequential handoff review flow.',
|
description: 'Sequential handoff review flow.',
|
||||||
mode: 'sequential',
|
graph: {
|
||||||
availability: 'available',
|
nodes: [
|
||||||
maxIterations: 1,
|
{ id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
|
||||||
agents: [
|
{
|
||||||
{
|
id: 'agent-writer',
|
||||||
id: 'agent-writer',
|
kind: 'agent',
|
||||||
name: 'Writer',
|
label: 'Writer',
|
||||||
description: 'Writes the draft.',
|
position: { x: 200, y: 0 },
|
||||||
instructions: 'Write.',
|
order: 0,
|
||||||
model: 'gpt-5.4',
|
config: {
|
||||||
},
|
kind: 'agent',
|
||||||
{
|
id: 'agent-writer',
|
||||||
id: 'agent-reviewer',
|
name: 'Writer',
|
||||||
name: 'Reviewer',
|
description: 'Writes the draft.',
|
||||||
description: 'Reviews the draft.',
|
instructions: 'Write.',
|
||||||
instructions: 'Review.',
|
model: 'gpt-5.4',
|
||||||
model: 'claude-sonnet-4.5',
|
},
|
||||||
},
|
},
|
||||||
],
|
{
|
||||||
|
id: 'agent-reviewer',
|
||||||
|
kind: 'agent',
|
||||||
|
label: 'Reviewer',
|
||||||
|
position: { x: 400, y: 0 },
|
||||||
|
order: 1,
|
||||||
|
config: {
|
||||||
|
kind: 'agent',
|
||||||
|
id: 'agent-reviewer',
|
||||||
|
name: 'Reviewer',
|
||||||
|
description: 'Reviews the draft.',
|
||||||
|
instructions: 'Review.',
|
||||||
|
model: 'claude-sonnet-4.5',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ id: 'end', kind: 'end', label: 'End', position: { x: 600, y: 0 }, config: { kind: 'end' } },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ id: 'edge-start-writer', source: 'start', target: 'agent-writer', kind: 'direct' },
|
||||||
|
{ id: 'edge-writer-reviewer', source: 'agent-writer', target: 'agent-reviewer', kind: 'direct' },
|
||||||
|
{ id: 'edge-reviewer-end', source: 'agent-reviewer', target: 'end', kind: 'direct' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
checkpointing: { enabled: false },
|
||||||
|
executionMode: 'off-thread',
|
||||||
|
orchestrationMode: 'sequential',
|
||||||
|
maxIterations: 1,
|
||||||
|
},
|
||||||
createdAt: '2026-03-23T00:00:00.000Z',
|
createdAt: '2026-03-23T00:00:00.000Z',
|
||||||
updatedAt: '2026-03-23T00:00:00.000Z',
|
updatedAt: '2026-03-23T00:00:00.000Z',
|
||||||
};
|
};
|
||||||
@@ -57,7 +85,7 @@ describe('run timeline helpers', () => {
|
|||||||
project: createProject(),
|
project: createProject(),
|
||||||
workingDirectory: 'C:\\workspace\\alpha\\packages\\app',
|
workingDirectory: 'C:\\workspace\\alpha\\packages\\app',
|
||||||
workspaceKind: 'project',
|
workspaceKind: 'project',
|
||||||
pattern: createPattern(),
|
workflow: createWorkflow(),
|
||||||
triggerMessageId: 'msg-user-1',
|
triggerMessageId: 'msg-user-1',
|
||||||
startedAt: '2026-03-23T00:00:01.000Z',
|
startedAt: '2026-03-23T00:00:01.000Z',
|
||||||
preRunGitBaselineFiles: [
|
preRunGitBaselineFiles: [
|
||||||
@@ -73,9 +101,9 @@ describe('run timeline helpers', () => {
|
|||||||
projectId: 'project-1',
|
projectId: 'project-1',
|
||||||
projectPath: 'C:\\workspace\\alpha',
|
projectPath: 'C:\\workspace\\alpha',
|
||||||
workingDirectory: 'C:\\workspace\\alpha\\packages\\app',
|
workingDirectory: 'C:\\workspace\\alpha\\packages\\app',
|
||||||
patternId: 'pattern-sequential',
|
workflowId: 'workflow-sequential',
|
||||||
patternName: 'Sequential Trio Review',
|
workflowName: 'Sequential Trio Review',
|
||||||
patternMode: 'sequential',
|
workflowMode: 'sequential',
|
||||||
triggerMessageId: 'msg-user-1',
|
triggerMessageId: 'msg-user-1',
|
||||||
status: 'running',
|
status: 'running',
|
||||||
});
|
});
|
||||||
@@ -112,7 +140,7 @@ describe('run timeline helpers', () => {
|
|||||||
requestId: 'turn-1',
|
requestId: 'turn-1',
|
||||||
project: createProject(),
|
project: createProject(),
|
||||||
workspaceKind: 'project',
|
workspaceKind: 'project',
|
||||||
pattern: createPattern(),
|
workflow: createWorkflow(),
|
||||||
triggerMessageId: 'msg-user-1',
|
triggerMessageId: 'msg-user-1',
|
||||||
startedAt: '2026-03-23T00:00:01.000Z',
|
startedAt: '2026-03-23T00:00:01.000Z',
|
||||||
});
|
});
|
||||||
@@ -159,7 +187,7 @@ describe('run timeline helpers', () => {
|
|||||||
requestId: 'turn-1',
|
requestId: 'turn-1',
|
||||||
project: createProject(),
|
project: createProject(),
|
||||||
workspaceKind: 'project',
|
workspaceKind: 'project',
|
||||||
pattern: createPattern(),
|
workflow: createWorkflow(),
|
||||||
triggerMessageId: 'msg-user-1',
|
triggerMessageId: 'msg-user-1',
|
||||||
startedAt: '2026-03-23T00:00:01.000Z',
|
startedAt: '2026-03-23T00:00:01.000Z',
|
||||||
}),
|
}),
|
||||||
@@ -187,7 +215,7 @@ describe('run timeline helpers', () => {
|
|||||||
requestId: 'turn-1',
|
requestId: 'turn-1',
|
||||||
project: createProject(),
|
project: createProject(),
|
||||||
workspaceKind: 'project',
|
workspaceKind: 'project',
|
||||||
pattern: createPattern(),
|
workflow: createWorkflow(),
|
||||||
triggerMessageId: 'msg-user-1',
|
triggerMessageId: 'msg-user-1',
|
||||||
startedAt: '2026-03-23T00:00:01.000Z',
|
startedAt: '2026-03-23T00:00:01.000Z',
|
||||||
});
|
});
|
||||||
@@ -242,7 +270,7 @@ describe('run timeline helpers', () => {
|
|||||||
requestId: 'turn-1',
|
requestId: 'turn-1',
|
||||||
project: createProject(),
|
project: createProject(),
|
||||||
workspaceKind: 'project',
|
workspaceKind: 'project',
|
||||||
pattern: createPattern(),
|
workflow: createWorkflow(),
|
||||||
triggerMessageId: 'msg-user-1',
|
triggerMessageId: 'msg-user-1',
|
||||||
startedAt: '2026-03-23T00:00:01.000Z',
|
startedAt: '2026-03-23T00:00:01.000Z',
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
import type { WorkflowDefinition } from '@shared/domain/workflow';
|
||||||
import {
|
import {
|
||||||
applySessionApprovalSettings,
|
applySessionApprovalSettings,
|
||||||
applySessionModelConfig,
|
applySessionModelConfig,
|
||||||
@@ -12,42 +12,70 @@ import {
|
|||||||
type SessionRecord,
|
type SessionRecord,
|
||||||
} from '@shared/domain/session';
|
} from '@shared/domain/session';
|
||||||
|
|
||||||
function createPattern(): PatternDefinition {
|
function createWorkflow(): WorkflowDefinition {
|
||||||
return {
|
return {
|
||||||
id: 'pattern-single',
|
id: 'workflow-single',
|
||||||
name: '1-on-1 Copilot Chat',
|
name: '1-on-1 Copilot Chat',
|
||||||
description: 'Single agent chat',
|
description: 'Single agent chat',
|
||||||
mode: 'single',
|
graph: {
|
||||||
availability: 'available',
|
nodes: [
|
||||||
maxIterations: 1,
|
{ id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
|
||||||
agents: [
|
{
|
||||||
{
|
id: 'agent-primary-node',
|
||||||
id: 'agent-primary',
|
kind: 'agent',
|
||||||
name: 'Primary Agent',
|
label: 'Primary Agent',
|
||||||
description: 'Helpful assistant',
|
position: { x: 200, y: 0 },
|
||||||
instructions: 'Help the user.',
|
order: 0,
|
||||||
model: 'gpt-5.4',
|
config: {
|
||||||
reasoningEffort: 'high',
|
kind: 'agent',
|
||||||
},
|
id: 'agent-primary',
|
||||||
{
|
name: 'Primary Agent',
|
||||||
id: 'agent-secondary',
|
description: 'Helpful assistant',
|
||||||
name: 'Secondary Agent',
|
instructions: 'Help the user.',
|
||||||
description: 'Unused here',
|
model: 'gpt-5.4',
|
||||||
instructions: 'Review.',
|
reasoningEffort: 'high',
|
||||||
model: 'claude-sonnet-4.5',
|
},
|
||||||
reasoningEffort: 'medium',
|
},
|
||||||
},
|
{
|
||||||
],
|
id: 'agent-secondary-node',
|
||||||
|
kind: 'agent',
|
||||||
|
label: 'Secondary Agent',
|
||||||
|
position: { x: 400, y: 0 },
|
||||||
|
order: 1,
|
||||||
|
config: {
|
||||||
|
kind: 'agent',
|
||||||
|
id: 'agent-secondary',
|
||||||
|
name: 'Secondary Agent',
|
||||||
|
description: 'Unused here',
|
||||||
|
instructions: 'Review.',
|
||||||
|
model: 'claude-sonnet-4.5',
|
||||||
|
reasoningEffort: 'medium',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ id: 'end', kind: 'end', label: 'End', position: { x: 600, y: 0 }, config: { kind: 'end' } },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ id: 'edge-start-primary', source: 'start', target: 'agent-primary-node', kind: 'direct' },
|
||||||
|
{ id: 'edge-primary-secondary', source: 'agent-primary-node', target: 'agent-secondary-node', kind: 'direct' },
|
||||||
|
{ id: 'edge-secondary-end', source: 'agent-secondary-node', target: 'end', kind: 'direct' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
checkpointing: { enabled: false },
|
||||||
|
executionMode: 'off-thread',
|
||||||
|
orchestrationMode: 'single',
|
||||||
|
maxIterations: 1,
|
||||||
|
},
|
||||||
createdAt: '2026-03-23T00:00:00.000Z',
|
createdAt: '2026-03-23T00:00:00.000Z',
|
||||||
updatedAt: '2026-03-23T00:00:00.000Z',
|
updatedAt: '2026-03-23T00:00:00.000Z',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSession(overrides?: Partial<SessionRecord>): SessionRecord {
|
function createSession(overrides?: Partial<SessionRecord>): SessionRecord {
|
||||||
return {
|
return {
|
||||||
id: 'session-1',
|
id: 'session-1',
|
||||||
projectId: 'project-scratchpad',
|
projectId: 'project-scratchpad',
|
||||||
patternId: 'pattern-single',
|
workflowId: 'workflow-single',
|
||||||
title: 'Scratchpad',
|
title: 'Scratchpad',
|
||||||
createdAt: '2026-03-23T00:00:00.000Z',
|
createdAt: '2026-03-23T00:00:00.000Z',
|
||||||
updatedAt: '2026-03-23T00:00:00.000Z',
|
updatedAt: '2026-03-23T00:00:00.000Z',
|
||||||
@@ -60,13 +88,13 @@ function createSession(overrides?: Partial<SessionRecord>): SessionRecord {
|
|||||||
|
|
||||||
describe('session model config helpers', () => {
|
describe('session model config helpers', () => {
|
||||||
test('captures the initial model settings from the primary agent', () => {
|
test('captures the initial model settings from the primary agent', () => {
|
||||||
expect(createSessionModelConfig(createPattern())).toEqual({
|
expect(createSessionModelConfig(createWorkflow())).toEqual({
|
||||||
model: 'gpt-5.4',
|
model: 'gpt-5.4',
|
||||||
reasoningEffort: 'high',
|
reasoningEffort: 'high',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
test('resolves persisted session overrides over the pattern defaults', () => {
|
test('resolves persisted session overrides over the workflow defaults', () => {
|
||||||
const config = resolveSessionModelConfig(
|
const config = resolveSessionModelConfig(
|
||||||
createSession({
|
createSession({
|
||||||
sessionModelConfig: {
|
sessionModelConfig: {
|
||||||
@@ -74,7 +102,7 @@ describe('session model config helpers', () => {
|
|||||||
reasoningEffort: 'medium',
|
reasoningEffort: 'medium',
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
createPattern(),
|
createWorkflow(),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(config).toEqual({
|
expect(config).toEqual({
|
||||||
@@ -84,9 +112,9 @@ describe('session model config helpers', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('applies session model settings only to the primary agent', () => {
|
test('applies session model settings only to the primary agent', () => {
|
||||||
const pattern = createPattern();
|
const workflow = createWorkflow();
|
||||||
const updated = applySessionModelConfig(
|
const updated = applySessionModelConfig(
|
||||||
pattern,
|
workflow,
|
||||||
createSession({
|
createSession({
|
||||||
sessionModelConfig: {
|
sessionModelConfig: {
|
||||||
model: 'gpt-5.4-mini',
|
model: 'gpt-5.4-mini',
|
||||||
@@ -95,22 +123,25 @@ describe('session model config helpers', () => {
|
|||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(updated.agents[0].model).toBe('gpt-5.4-mini');
|
const primaryAgent = updated.graph.nodes[1];
|
||||||
expect(updated.agents[0].reasoningEffort).toBe('low');
|
const secondaryAgent = updated.graph.nodes[2];
|
||||||
expect(updated.agents[1]).toEqual(pattern.agents[1]);
|
const originalSecondaryAgent = workflow.graph.nodes[2];
|
||||||
|
expect(primaryAgent?.config.kind === 'agent' ? primaryAgent.config.model : undefined).toBe('gpt-5.4-mini');
|
||||||
|
expect(primaryAgent?.config.kind === 'agent' ? primaryAgent.config.reasoningEffort : undefined).toBe('low');
|
||||||
|
expect(secondaryAgent).toEqual(originalSecondaryAgent);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('session title helpers', () => {
|
describe('session title helpers', () => {
|
||||||
test('keeps a manual title instead of recomputing it from the first user message', () => {
|
test('keeps a manual title instead of recomputing it from the first user message', () => {
|
||||||
const pattern = createPattern();
|
const workflow = createWorkflow();
|
||||||
const session = createSession({
|
const session = createSession({
|
||||||
title: 'Release readiness review',
|
title: 'Release readiness review',
|
||||||
titleSource: 'manual',
|
titleSource: 'manual',
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
resolveSessionTitle(session, pattern, [
|
resolveSessionTitle(session, workflow, [
|
||||||
{
|
{
|
||||||
id: 'msg-1',
|
id: 'msg-1',
|
||||||
role: 'user',
|
role: 'user',
|
||||||
@@ -123,11 +154,11 @@ describe('session title helpers', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
test('builds auto titles from markdown-heavy first messages', () => {
|
test('builds auto titles from markdown-heavy first messages', () => {
|
||||||
const pattern = createPattern();
|
const workflow = createWorkflow();
|
||||||
const session = createSession();
|
const session = createSession();
|
||||||
|
|
||||||
expect(
|
expect(
|
||||||
resolveSessionTitle(session, pattern, [
|
resolveSessionTitle(session, workflow, [
|
||||||
{
|
{
|
||||||
id: 'msg-1',
|
id: 'msg-1',
|
||||||
role: 'user',
|
role: 'user',
|
||||||
@@ -164,25 +195,28 @@ describe('session tooling helpers', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
describe('session approval helpers', () => {
|
describe('session approval helpers', () => {
|
||||||
test('normalizes session approval overrides and applies them over pattern defaults', () => {
|
test('normalizes session approval overrides and applies them over workflow defaults', () => {
|
||||||
const pattern = {
|
const workflow = {
|
||||||
...createPattern(),
|
...createWorkflow(),
|
||||||
approvalPolicy: {
|
settings: {
|
||||||
rules: [{ kind: 'tool-call' as const }],
|
...createWorkflow().settings,
|
||||||
autoApprovedToolNames: ['git.status'],
|
approvalPolicy: {
|
||||||
|
rules: [{ kind: 'tool-call' as const }],
|
||||||
|
autoApprovedToolNames: ['git.status'],
|
||||||
|
},
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(resolveSessionApprovalSettings(createSession())).toBeUndefined();
|
expect(resolveSessionApprovalSettings(createSession())).toBeUndefined();
|
||||||
expect(
|
expect(
|
||||||
applySessionApprovalSettings(
|
applySessionApprovalSettings(
|
||||||
pattern,
|
workflow,
|
||||||
createSession({
|
createSession({
|
||||||
approvalSettings: {
|
approvalSettings: {
|
||||||
autoApprovedToolNames: ['git.diff', ' git.diff '],
|
autoApprovedToolNames: ['git.diff', ' git.diff '],
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
).approvalPolicy,
|
).settings.approvalPolicy,
|
||||||
).toEqual({
|
).toEqual({
|
||||||
rules: [{ kind: 'tool-call' }],
|
rules: [{ kind: 'tool-call' }],
|
||||||
autoApprovedToolNames: ['git.diff'],
|
autoApprovedToolNames: ['git.diff'],
|
||||||
|
|||||||
@@ -9,30 +9,49 @@ import {
|
|||||||
renameSessionRecord,
|
renameSessionRecord,
|
||||||
setSessionMessagePinnedRecord,
|
setSessionMessagePinnedRecord,
|
||||||
} from '@shared/domain/sessionLibrary';
|
} from '@shared/domain/sessionLibrary';
|
||||||
import type { PatternDefinition } from '@shared/domain/pattern';
|
import type { WorkflowDefinition } from '@shared/domain/workflow';
|
||||||
import type { ProjectRecord } from '@shared/domain/project';
|
import type { ProjectRecord } from '@shared/domain/project';
|
||||||
import type { SessionRecord } from '@shared/domain/session';
|
import type { SessionRecord } from '@shared/domain/session';
|
||||||
import { createWorkspaceSettings } from '@shared/domain/tooling';
|
import { createWorkspaceSettings } from '@shared/domain/tooling';
|
||||||
import type { WorkspaceState } from '@shared/domain/workspace';
|
import type { WorkspaceState } from '@shared/domain/workspace';
|
||||||
|
|
||||||
function createPattern(overrides?: Partial<PatternDefinition>): PatternDefinition {
|
function createWorkflow(overrides?: Partial<WorkflowDefinition>): WorkflowDefinition {
|
||||||
return {
|
return {
|
||||||
id: 'pattern-sequential-review',
|
id: 'workflow-sequential-review',
|
||||||
name: 'Sequential Review',
|
name: 'Sequential Review',
|
||||||
description: 'Multi-agent review workflow.',
|
description: 'Multi-agent review workflow.',
|
||||||
mode: 'sequential',
|
graph: {
|
||||||
availability: 'available',
|
nodes: [
|
||||||
maxIterations: 1,
|
{ id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
|
||||||
agents: [
|
{
|
||||||
{
|
id: 'agent-analyst-node',
|
||||||
id: 'agent-analyst',
|
kind: 'agent',
|
||||||
name: 'Analyst',
|
label: 'Analyst',
|
||||||
description: 'Reviews the request and finds issues.',
|
position: { x: 200, y: 0 },
|
||||||
instructions: 'Analyze the request.',
|
order: 0,
|
||||||
model: 'gpt-5.4',
|
config: {
|
||||||
reasoningEffort: 'high',
|
kind: 'agent',
|
||||||
},
|
id: 'agent-analyst',
|
||||||
],
|
name: 'Analyst',
|
||||||
|
description: 'Reviews the request and finds issues.',
|
||||||
|
instructions: 'Analyze the request.',
|
||||||
|
model: 'gpt-5.4',
|
||||||
|
reasoningEffort: 'high',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{ id: 'end', kind: 'end', label: 'End', position: { x: 400, y: 0 }, config: { kind: 'end' } },
|
||||||
|
],
|
||||||
|
edges: [
|
||||||
|
{ id: 'edge-start-agent', source: 'start', target: 'agent-analyst-node', kind: 'direct' },
|
||||||
|
{ id: 'edge-agent-end', source: 'agent-analyst-node', target: 'end', kind: 'direct' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
settings: {
|
||||||
|
checkpointing: { enabled: false },
|
||||||
|
executionMode: 'off-thread',
|
||||||
|
orchestrationMode: 'sequential',
|
||||||
|
maxIterations: 1,
|
||||||
|
},
|
||||||
createdAt: '2026-03-23T00:00:00.000Z',
|
createdAt: '2026-03-23T00:00:00.000Z',
|
||||||
updatedAt: '2026-03-23T00:00:00.000Z',
|
updatedAt: '2026-03-23T00:00:00.000Z',
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -53,7 +72,7 @@ function createSession(overrides?: Partial<SessionRecord>): SessionRecord {
|
|||||||
return {
|
return {
|
||||||
id: 'session-1',
|
id: 'session-1',
|
||||||
projectId: 'project-alpha',
|
projectId: 'project-alpha',
|
||||||
patternId: 'pattern-sequential-review',
|
workflowId: 'workflow-sequential-review',
|
||||||
title: 'Investigate Copilot refresh bug',
|
title: 'Investigate Copilot refresh bug',
|
||||||
createdAt: '2026-03-23T00:00:00.000Z',
|
createdAt: '2026-03-23T00:00:00.000Z',
|
||||||
updatedAt: '2026-03-23T00:05:00.000Z',
|
updatedAt: '2026-03-23T00:05:00.000Z',
|
||||||
@@ -75,8 +94,7 @@ function createSession(overrides?: Partial<SessionRecord>): SessionRecord {
|
|||||||
function createWorkspace(overrides?: Partial<WorkspaceState>): WorkspaceState {
|
function createWorkspace(overrides?: Partial<WorkspaceState>): WorkspaceState {
|
||||||
const workspace: WorkspaceState = {
|
const workspace: WorkspaceState = {
|
||||||
projects: [createProject(), createProject({ id: 'project-scratchpad', name: 'Scratchpad', path: 'C:\\scratchpad' })],
|
projects: [createProject(), createProject({ id: 'project-scratchpad', name: 'Scratchpad', path: 'C:\\scratchpad' })],
|
||||||
patterns: [createPattern(), createPattern({ id: 'pattern-single-chat', name: '1-on-1 Copilot Chat', mode: 'single' })],
|
workflows: [createWorkflow(), createWorkflow({ id: 'workflow-single-chat', name: '1-on-1 Copilot Chat', settings: { checkpointing: { enabled: false }, executionMode: 'off-thread', orchestrationMode: 'single', maxIterations: 1 } })],
|
||||||
workflows: [],
|
|
||||||
workflowTemplates: [],
|
workflowTemplates: [],
|
||||||
settings: createWorkspaceSettings(),
|
settings: createWorkspaceSettings(),
|
||||||
sessions: [
|
sessions: [
|
||||||
@@ -84,7 +102,7 @@ function createWorkspace(overrides?: Partial<WorkspaceState>): WorkspaceState {
|
|||||||
createSession({
|
createSession({
|
||||||
id: 'session-2',
|
id: 'session-2',
|
||||||
projectId: 'project-scratchpad',
|
projectId: 'project-scratchpad',
|
||||||
patternId: 'pattern-single-chat',
|
workflowId: 'workflow-single-chat',
|
||||||
title: 'Scratchpad brainstorm',
|
title: 'Scratchpad brainstorm',
|
||||||
status: 'running',
|
status: 'running',
|
||||||
updatedAt: '2026-03-23T00:10:00.000Z',
|
updatedAt: '2026-03-23T00:10:00.000Z',
|
||||||
@@ -109,7 +127,7 @@ function createWorkspace(overrides?: Partial<WorkspaceState>): WorkspaceState {
|
|||||||
}),
|
}),
|
||||||
],
|
],
|
||||||
selectedProjectId: 'project-alpha',
|
selectedProjectId: 'project-alpha',
|
||||||
selectedPatternId: 'pattern-sequential-review',
|
selectedWorkflowId: 'workflow-sequential-review',
|
||||||
selectedSessionId: 'session-1',
|
selectedSessionId: 'session-1',
|
||||||
lastUpdatedAt: '2026-03-23T00:10:00.000Z',
|
lastUpdatedAt: '2026-03-23T00:10:00.000Z',
|
||||||
...overrides,
|
...overrides,
|
||||||
@@ -291,7 +309,7 @@ describe('session library helpers', () => {
|
|||||||
|
|
||||||
const branch = branchSessionRecord(
|
const branch = branchSessionRecord(
|
||||||
sourceSession,
|
sourceSession,
|
||||||
createPattern(),
|
createWorkflow(),
|
||||||
'session-branch',
|
'session-branch',
|
||||||
'msg-3',
|
'msg-3',
|
||||||
'2026-03-23T00:04:00.000Z',
|
'2026-03-23T00:04:00.000Z',
|
||||||
@@ -341,7 +359,7 @@ describe('session library helpers', () => {
|
|||||||
expect(() =>
|
expect(() =>
|
||||||
branchSessionRecord(
|
branchSessionRecord(
|
||||||
sourceSession,
|
sourceSession,
|
||||||
createPattern(),
|
createWorkflow(),
|
||||||
'session-branch',
|
'session-branch',
|
||||||
'msg-1',
|
'msg-1',
|
||||||
'2026-03-23T00:04:00.000Z',
|
'2026-03-23T00:04:00.000Z',
|
||||||
@@ -384,7 +402,7 @@ describe('session library helpers', () => {
|
|||||||
|
|
||||||
const branch = branchSessionRecord(
|
const branch = branchSessionRecord(
|
||||||
sourceSession,
|
sourceSession,
|
||||||
createPattern(),
|
createWorkflow(),
|
||||||
'session-branch',
|
'session-branch',
|
||||||
'msg-2',
|
'msg-2',
|
||||||
'2026-03-23T00:05:00.000Z',
|
'2026-03-23T00:05:00.000Z',
|
||||||
@@ -436,7 +454,7 @@ describe('session library helpers', () => {
|
|||||||
|
|
||||||
const regenerated = regenerateSessionRecord(
|
const regenerated = regenerateSessionRecord(
|
||||||
sourceSession,
|
sourceSession,
|
||||||
createPattern(),
|
createWorkflow(),
|
||||||
'session-regenerated',
|
'session-regenerated',
|
||||||
'msg-4',
|
'msg-4',
|
||||||
'2026-03-23T00:06:00.000Z',
|
'2026-03-23T00:06:00.000Z',
|
||||||
@@ -483,7 +501,7 @@ describe('session library helpers', () => {
|
|||||||
expect(() =>
|
expect(() =>
|
||||||
regenerateSessionRecord(
|
regenerateSessionRecord(
|
||||||
sourceSession,
|
sourceSession,
|
||||||
createPattern(),
|
createWorkflow(),
|
||||||
'session-regenerated',
|
'session-regenerated',
|
||||||
'msg-2',
|
'msg-2',
|
||||||
'2026-03-23T00:06:00.000Z',
|
'2026-03-23T00:06:00.000Z',
|
||||||
@@ -541,7 +559,7 @@ describe('session library helpers', () => {
|
|||||||
|
|
||||||
const edited = editAndResendSessionRecord(
|
const edited = editAndResendSessionRecord(
|
||||||
sourceSession,
|
sourceSession,
|
||||||
createPattern(),
|
createWorkflow(),
|
||||||
'session-edited',
|
'session-edited',
|
||||||
'msg-3',
|
'msg-3',
|
||||||
'Focus on session state only.',
|
'Focus on session state only.',
|
||||||
@@ -585,7 +603,7 @@ describe('session library helpers', () => {
|
|||||||
{
|
{
|
||||||
sessionId: 'session-1',
|
sessionId: 'session-1',
|
||||||
score: 31,
|
score: 31,
|
||||||
matchedFields: ['title', 'message', 'project', 'pattern'],
|
matchedFields: ['title', 'message', 'project', 'workflow'],
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
import { describe, expect, test } from 'bun:test';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
buildWorkflowExecutionPattern,
|
buildWorkflowExecutionDefinition,
|
||||||
validateWorkflowDefinition,
|
validateWorkflowDefinition,
|
||||||
type WorkflowDefinition,
|
type WorkflowDefinition,
|
||||||
} from '@shared/domain/workflow';
|
} from '@shared/domain/workflow';
|
||||||
@@ -245,16 +245,16 @@ describe('workflow validation', () => {
|
|||||||
expect(issues.some((issue) => issue.message.includes('exactly one of workflowId or inlineWorkflow'))).toBe(true);
|
expect(issues.some((issue) => issue.message.includes('exactly one of workflowId or inlineWorkflow'))).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('builds a synthetic execution pattern from workflow agents', () => {
|
test('builds an execution definition from workflow agents', () => {
|
||||||
const pattern = buildWorkflowExecutionPattern(createWorkflow());
|
const execution = buildWorkflowExecutionDefinition(createWorkflow());
|
||||||
|
|
||||||
expect(pattern.id).toBe('workflow-1');
|
expect(execution.id).toBe('workflow-1');
|
||||||
expect(pattern.agents).toHaveLength(1);
|
expect(execution.agents).toHaveLength(1);
|
||||||
expect(pattern.agents[0]?.name).toBe('Primary Agent');
|
expect(execution.agents[0]?.name).toBe('Primary Agent');
|
||||||
expect(pattern.graph?.nodes.map((node) => node.kind)).toEqual(['user-input', 'agent', 'user-output']);
|
expect(execution.orchestrationMode).toBe('single');
|
||||||
});
|
});
|
||||||
|
|
||||||
test('includes referenced sub-workflow agents when building execution patterns', () => {
|
test('includes referenced sub-workflow agents when building execution definitions', () => {
|
||||||
const childWorkflow = createReferencedSubWorkflow();
|
const childWorkflow = createReferencedSubWorkflow();
|
||||||
const workflow = createWorkflow();
|
const workflow = createWorkflow();
|
||||||
workflow.graph.nodes[1] = {
|
workflow.graph.nodes[1] = {
|
||||||
@@ -270,12 +270,12 @@ describe('workflow validation', () => {
|
|||||||
workflow.graph.edges[0] = { id: 'edge-start-sub', source: 'start', target: 'sub-workflow', kind: 'direct' };
|
workflow.graph.edges[0] = { id: 'edge-start-sub', source: 'start', target: 'sub-workflow', kind: 'direct' };
|
||||||
workflow.graph.edges[1] = { id: 'edge-sub-end', source: 'sub-workflow', target: 'end', kind: 'direct' };
|
workflow.graph.edges[1] = { id: 'edge-sub-end', source: 'sub-workflow', target: 'end', kind: 'direct' };
|
||||||
|
|
||||||
const pattern = buildWorkflowExecutionPattern(workflow, {
|
const execution = buildWorkflowExecutionDefinition(workflow, {
|
||||||
resolveWorkflow: (workflowId) => workflowId === childWorkflow.id ? childWorkflow : undefined,
|
resolveWorkflow: (workflowId: string) => workflowId === childWorkflow.id ? childWorkflow : undefined,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(pattern.mode).toBe('single');
|
expect(execution.orchestrationMode).toBe('single');
|
||||||
expect(pattern.agents.map((agent) => agent.id)).toEqual(['agent-reviewer']);
|
expect(execution.agents.map((agent) => agent.id)).toEqual(['agent-reviewer']);
|
||||||
});
|
});
|
||||||
|
|
||||||
test('accepts simple property and expression conditions', () => {
|
test('accepts simple property and expression conditions', () => {
|
||||||
|
|||||||
@@ -1,193 +0,0 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
|
||||||
|
|
||||||
import { createBuiltinPatterns } from '@shared/domain/pattern';
|
|
||||||
import {
|
|
||||||
exportWorkflowDefinition,
|
|
||||||
importWorkflowDefinition,
|
|
||||||
} from '@shared/domain/workflowSerialization';
|
|
||||||
import {
|
|
||||||
buildWorkflowFromPattern,
|
|
||||||
createBuiltinWorkflowTemplates,
|
|
||||||
} from '@shared/domain/workflowTemplate';
|
|
||||||
import { validateWorkflowDefinition } from '@shared/domain/workflow';
|
|
||||||
|
|
||||||
const TIMESTAMP = '2026-04-05T00:00:00.000Z';
|
|
||||||
|
|
||||||
function requirePattern(mode: 'sequential' | 'concurrent' | 'handoff' | 'group-chat') {
|
|
||||||
const pattern = createBuiltinPatterns(TIMESTAMP).find((candidate) => candidate.mode === mode);
|
|
||||||
if (!pattern) {
|
|
||||||
throw new Error(`Expected built-in ${mode} pattern.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
return pattern;
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('workflow templates', () => {
|
|
||||||
test('builds a valid sequential workflow from a pattern', () => {
|
|
||||||
const workflow = buildWorkflowFromPattern(requirePattern('sequential'));
|
|
||||||
|
|
||||||
expect(workflow.id).toBe('workflow-sequential-review');
|
|
||||||
expect(workflow.graph.nodes.map((node) => node.kind)).toEqual(['start', 'agent', 'agent', 'agent', 'end']);
|
|
||||||
expect(workflow.graph.edges.map((edge) => `${edge.source}->${edge.target}:${edge.kind}`)).toEqual([
|
|
||||||
'start->agent-node-agent-sequential-analyst:direct',
|
|
||||||
'agent-node-agent-sequential-analyst->agent-node-agent-sequential-builder:direct',
|
|
||||||
'agent-node-agent-sequential-builder->agent-node-agent-sequential-reviewer:direct',
|
|
||||||
'agent-node-agent-sequential-reviewer->end:direct',
|
|
||||||
]);
|
|
||||||
expect(validateWorkflowDefinition(workflow)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('builds a valid concurrent workflow from a pattern', () => {
|
|
||||||
const workflow = buildWorkflowFromPattern(requirePattern('concurrent'));
|
|
||||||
|
|
||||||
expect(workflow.graph.edges.filter((edge) => edge.kind === 'fan-out')).toHaveLength(3);
|
|
||||||
expect(workflow.graph.edges.filter((edge) => edge.kind === 'fan-in')).toHaveLength(3);
|
|
||||||
expect(validateWorkflowDefinition(workflow)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('builds a valid handoff workflow from a pattern graph', () => {
|
|
||||||
const workflow = buildWorkflowFromPattern(requirePattern('handoff'));
|
|
||||||
|
|
||||||
expect(workflow.graph.edges.map((edge) => `${edge.source}->${edge.target}`)).toContain('start->agent-node-agent-handoff-triage');
|
|
||||||
expect(workflow.graph.edges.map((edge) => `${edge.source}->${edge.target}`)).toContain(
|
|
||||||
'agent-node-agent-handoff-triage->agent-node-agent-handoff-ux',
|
|
||||||
);
|
|
||||||
expect(workflow.graph.edges.map((edge) => `${edge.source}->${edge.target}`)).toContain(
|
|
||||||
'agent-node-agent-handoff-runtime->agent-node-agent-handoff-triage',
|
|
||||||
);
|
|
||||||
expect(validateWorkflowDefinition(workflow)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('builds a group-chat workflow with a loop approximation', () => {
|
|
||||||
const workflow = buildWorkflowFromPattern(requirePattern('group-chat'));
|
|
||||||
const loopEdge = workflow.graph.edges.find((edge) =>
|
|
||||||
edge.source === 'agent-node-agent-group-reviewer' && edge.target === 'agent-node-agent-group-writer');
|
|
||||||
const entryEdge = workflow.graph.edges.find((edge) =>
|
|
||||||
edge.source === 'start' && edge.target === 'agent-node-agent-group-writer');
|
|
||||||
const exitEdge = workflow.graph.edges.find((edge) =>
|
|
||||||
edge.source === 'agent-node-agent-group-reviewer' && edge.target === 'end');
|
|
||||||
|
|
||||||
expect(loopEdge).toEqual(expect.objectContaining({
|
|
||||||
source: 'agent-node-agent-group-reviewer',
|
|
||||||
target: 'agent-node-agent-group-writer',
|
|
||||||
isLoop: true,
|
|
||||||
maxIterations: 5,
|
|
||||||
condition: { type: 'always' },
|
|
||||||
}));
|
|
||||||
expect(entryEdge?.isLoop).not.toBe(true);
|
|
||||||
expect(exitEdge?.isLoop).not.toBe(true);
|
|
||||||
expect(validateWorkflowDefinition(workflow)).toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('creates 8 hand-crafted builtin workflow templates', () => {
|
|
||||||
const templates = createBuiltinWorkflowTemplates(TIMESTAMP);
|
|
||||||
|
|
||||||
expect(templates).toHaveLength(8);
|
|
||||||
expect(templates.map((template) => template.id)).toEqual([
|
|
||||||
'workflow-template-code-review',
|
|
||||||
'workflow-template-research-summarize',
|
|
||||||
'workflow-template-customer-support',
|
|
||||||
'workflow-template-content-creation',
|
|
||||||
'workflow-template-multi-agent-debate',
|
|
||||||
'workflow-template-data-processing',
|
|
||||||
'workflow-template-approval',
|
|
||||||
'workflow-template-nested-orchestrator',
|
|
||||||
]);
|
|
||||||
expect(templates.every((template) => template.source === 'builtin')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('builtin templates span all categories', () => {
|
|
||||||
const templates = createBuiltinWorkflowTemplates(TIMESTAMP);
|
|
||||||
const categories = new Set(templates.map((template) => template.category));
|
|
||||||
|
|
||||||
expect(categories).toContain('orchestration');
|
|
||||||
expect(categories).toContain('data-pipeline');
|
|
||||||
expect(categories).toContain('human-in-loop');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('builtin templates produce valid workflows', () => {
|
|
||||||
const templates = createBuiltinWorkflowTemplates(TIMESTAMP);
|
|
||||||
|
|
||||||
for (const template of templates) {
|
|
||||||
const issues = validateWorkflowDefinition(template.workflow);
|
|
||||||
expect(issues.filter((issue) => issue.level === 'error')).toEqual([]);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('builtin templates use the provided timestamp', () => {
|
|
||||||
const templates = createBuiltinWorkflowTemplates(TIMESTAMP);
|
|
||||||
|
|
||||||
for (const template of templates) {
|
|
||||||
expect(template.createdAt).toBe(TIMESTAMP);
|
|
||||||
expect(template.updatedAt).toBe(TIMESTAMP);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
test('research-summarize template uses fan-out and fan-in edges', () => {
|
|
||||||
const templates = createBuiltinWorkflowTemplates(TIMESTAMP);
|
|
||||||
const research = templates.find((t) => t.id === 'workflow-template-research-summarize')!;
|
|
||||||
|
|
||||||
expect(research.workflow.graph.edges.filter((e) => e.kind === 'fan-out')).toHaveLength(3);
|
|
||||||
expect(research.workflow.graph.edges.filter((e) => e.kind === 'fan-in')).toHaveLength(3);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('content-creation template has a loop edge with maxIterations', () => {
|
|
||||||
const templates = createBuiltinWorkflowTemplates(TIMESTAMP);
|
|
||||||
const content = templates.find((t) => t.id === 'workflow-template-content-creation')!;
|
|
||||||
const loopEdge = content.workflow.graph.edges.find((e) => e.isLoop && e.source === 'editor' && e.target === 'writer');
|
|
||||||
|
|
||||||
expect(loopEdge).toBeDefined();
|
|
||||||
expect(loopEdge!.maxIterations).toBe(3);
|
|
||||||
expect(loopEdge!.source).toBe('editor');
|
|
||||||
expect(loopEdge!.target).toBe('writer');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('data-processing template includes invoke-function nodes', () => {
|
|
||||||
const templates = createBuiltinWorkflowTemplates(TIMESTAMP);
|
|
||||||
const dataProc = templates.find((t) => t.id === 'workflow-template-data-processing')!;
|
|
||||||
const funcNodes = dataProc.workflow.graph.nodes.filter((n) => n.kind === 'invoke-function');
|
|
||||||
|
|
||||||
expect(funcNodes).toHaveLength(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('approval template includes a request-port node', () => {
|
|
||||||
const templates = createBuiltinWorkflowTemplates(TIMESTAMP);
|
|
||||||
const approval = templates.find((t) => t.id === 'workflow-template-approval')!;
|
|
||||||
const portNode = approval.workflow.graph.nodes.find((n) => n.kind === 'request-port');
|
|
||||||
|
|
||||||
expect(portNode).toBeDefined();
|
|
||||||
expect(portNode!.config).toEqual(expect.objectContaining({
|
|
||||||
kind: 'request-port',
|
|
||||||
portId: 'review',
|
|
||||||
requestType: 'ReviewRequest',
|
|
||||||
responseType: 'ReviewDecision',
|
|
||||||
}));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('nested-orchestrator template includes a sub-workflow node', () => {
|
|
||||||
const templates = createBuiltinWorkflowTemplates(TIMESTAMP);
|
|
||||||
const nested = templates.find((t) => t.id === 'workflow-template-nested-orchestrator')!;
|
|
||||||
const subNode = nested.workflow.graph.nodes.find((n) => n.kind === 'sub-workflow');
|
|
||||||
|
|
||||||
expect(subNode).toBeDefined();
|
|
||||||
expect(subNode!.config).toEqual(expect.objectContaining({ kind: 'sub-workflow' }));
|
|
||||||
});
|
|
||||||
|
|
||||||
test('round trips workflow yaml import and export', () => {
|
|
||||||
const workflow = buildWorkflowFromPattern(requirePattern('sequential'));
|
|
||||||
const exported = exportWorkflowDefinition(workflow, 'yaml');
|
|
||||||
const imported = importWorkflowDefinition(exported.content, 'yaml');
|
|
||||||
|
|
||||||
expect(exported.format).toBe('yaml');
|
|
||||||
expect(imported).toEqual(workflow);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('exports mermaid flowcharts with expected edges', () => {
|
|
||||||
const workflow = buildWorkflowFromPattern(requirePattern('sequential'));
|
|
||||||
const exported = exportWorkflowDefinition(workflow, 'mermaid');
|
|
||||||
|
|
||||||
expect(exported.content.startsWith('flowchart LR')).toBe(true);
|
|
||||||
expect(exported.content).toContain('-->');
|
|
||||||
expect(exported.content).toContain('n0 --> n1');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -3,7 +3,7 @@ import { describe, expect, test } from 'bun:test';
|
|||||||
import { createWorkspaceSeed } from '@shared/domain/workspace';
|
import { createWorkspaceSeed } from '@shared/domain/workspace';
|
||||||
|
|
||||||
describe('workspace seed', () => {
|
describe('workspace seed', () => {
|
||||||
test('starts empty and seeds built-in patterns and workflow templates with a shared timestamp', () => {
|
test('starts empty and seeds built-in workflows and workflow templates with a shared timestamp', () => {
|
||||||
const workspace = createWorkspaceSeed();
|
const workspace = createWorkspaceSeed();
|
||||||
|
|
||||||
expect(workspace.projects).toEqual([]);
|
expect(workspace.projects).toEqual([]);
|
||||||
@@ -19,16 +19,15 @@ describe('workspace seed', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(workspace.selectedProjectId).toBeUndefined();
|
expect(workspace.selectedProjectId).toBeUndefined();
|
||||||
expect(workspace.selectedPatternId).toBeUndefined();
|
expect(workspace.selectedWorkflowId).toBeUndefined();
|
||||||
expect(workspace.selectedSessionId).toBeUndefined();
|
expect(workspace.selectedSessionId).toBeUndefined();
|
||||||
|
|
||||||
expect(workspace.patterns.map((pattern) => pattern.mode)).toEqual([
|
expect(workspace.workflows.map((workflow) => workflow.settings.orchestrationMode)).toEqual([
|
||||||
'single',
|
'single',
|
||||||
'sequential',
|
'sequential',
|
||||||
'concurrent',
|
'concurrent',
|
||||||
'handoff',
|
'handoff',
|
||||||
'group-chat',
|
'group-chat',
|
||||||
'magentic',
|
|
||||||
]);
|
]);
|
||||||
expect(workspace.workflowTemplates.map((template) => template.id)).toEqual([
|
expect(workspace.workflowTemplates.map((template) => template.id)).toEqual([
|
||||||
'workflow-template-code-review',
|
'workflow-template-code-review',
|
||||||
@@ -41,16 +40,12 @@ describe('workspace seed', () => {
|
|||||||
'workflow-template-nested-orchestrator',
|
'workflow-template-nested-orchestrator',
|
||||||
]);
|
]);
|
||||||
|
|
||||||
for (const pattern of workspace.patterns) {
|
for (const workflow of workspace.workflows) {
|
||||||
expect(pattern.createdAt).toBe(workspace.lastUpdatedAt);
|
expect(workflow.createdAt).toBe(workspace.lastUpdatedAt);
|
||||||
expect(pattern.updatedAt).toBe(workspace.lastUpdatedAt);
|
expect(workflow.updatedAt).toBe(workspace.lastUpdatedAt);
|
||||||
expect(pattern.approvalPolicy?.rules).toContainEqual({ kind: 'tool-call' });
|
expect(workflow.settings.approvalPolicy).toBeUndefined();
|
||||||
}
|
}
|
||||||
|
|
||||||
const magentic = workspace.patterns.find((pattern) => pattern.mode === 'magentic');
|
|
||||||
|
|
||||||
expect(magentic?.availability).toBe('unavailable');
|
|
||||||
expect(magentic?.unavailabilityReason).toContain('unsupported');
|
|
||||||
for (const template of workspace.workflowTemplates) {
|
for (const template of workspace.workflowTemplates) {
|
||||||
expect(template.createdAt).toBe(workspace.lastUpdatedAt);
|
expect(template.createdAt).toBe(workspace.lastUpdatedAt);
|
||||||
expect(template.updatedAt).toBe(workspace.lastUpdatedAt);
|
expect(template.updatedAt).toBe(workspace.lastUpdatedAt);
|
||||||
|
|||||||
@@ -1,182 +0,0 @@
|
|||||||
import { describe, expect, test } from 'bun:test';
|
|
||||||
|
|
||||||
import {
|
|
||||||
resolvePatternAgent,
|
|
||||||
resolvePatternAgents,
|
|
||||||
findWorkspaceAgentUsages,
|
|
||||||
normalizeWorkspaceAgentDefinition,
|
|
||||||
type WorkspaceAgentDefinition,
|
|
||||||
} from '@shared/domain/workspaceAgent';
|
|
||||||
import type { PatternAgentDefinition, PatternDefinition } from '@shared/domain/pattern';
|
|
||||||
|
|
||||||
const TIMESTAMP = '2026-04-01T00:00:00.000Z';
|
|
||||||
|
|
||||||
function makeWorkspaceAgent(overrides: Partial<WorkspaceAgentDefinition> = {}): WorkspaceAgentDefinition {
|
|
||||||
return {
|
|
||||||
id: 'wa-1',
|
|
||||||
name: 'Code Reviewer',
|
|
||||||
description: 'Reviews code for quality',
|
|
||||||
instructions: 'Review all code carefully',
|
|
||||||
model: 'gpt-5.4',
|
|
||||||
reasoningEffort: 'high',
|
|
||||||
createdAt: TIMESTAMP,
|
|
||||||
updatedAt: TIMESTAMP,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeInlineAgent(overrides: Partial<PatternAgentDefinition> = {}): PatternAgentDefinition {
|
|
||||||
return {
|
|
||||||
id: 'agent-1',
|
|
||||||
name: 'Inline Agent',
|
|
||||||
description: 'An inline agent',
|
|
||||||
instructions: 'Do stuff',
|
|
||||||
model: 'claude-sonnet-4',
|
|
||||||
reasoningEffort: 'medium',
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makeLinkedAgent(overrides: Partial<PatternAgentDefinition> = {}): PatternAgentDefinition {
|
|
||||||
return {
|
|
||||||
id: 'agent-linked',
|
|
||||||
name: 'Code Reviewer',
|
|
||||||
description: 'Reviews code for quality',
|
|
||||||
instructions: 'Review all code carefully',
|
|
||||||
model: 'gpt-5.4',
|
|
||||||
reasoningEffort: 'high',
|
|
||||||
workspaceAgentId: 'wa-1',
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function makePattern(agents: PatternAgentDefinition[], overrides: Partial<PatternDefinition> = {}): PatternDefinition {
|
|
||||||
return {
|
|
||||||
id: 'pattern-1',
|
|
||||||
name: 'Test Pattern',
|
|
||||||
description: '',
|
|
||||||
mode: 'sequential',
|
|
||||||
availability: 'available',
|
|
||||||
maxIterations: 10,
|
|
||||||
agents,
|
|
||||||
createdAt: TIMESTAMP,
|
|
||||||
updatedAt: TIMESTAMP,
|
|
||||||
...overrides,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
describe('resolvePatternAgent', () => {
|
|
||||||
const workspaceAgents = [makeWorkspaceAgent()];
|
|
||||||
|
|
||||||
test('returns inline agent unchanged', () => {
|
|
||||||
const agent = makeInlineAgent();
|
|
||||||
const resolved = resolvePatternAgent(agent, workspaceAgents);
|
|
||||||
expect(resolved).toEqual(agent);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('resolves linked agent from workspace agent base', () => {
|
|
||||||
const agent = makeLinkedAgent();
|
|
||||||
const resolved = resolvePatternAgent(agent, workspaceAgents);
|
|
||||||
expect(resolved.name).toBe('Code Reviewer');
|
|
||||||
expect(resolved.model).toBe('gpt-5.4');
|
|
||||||
expect(resolved.instructions).toBe('Review all code carefully');
|
|
||||||
expect(resolved.workspaceAgentId).toBe('wa-1');
|
|
||||||
expect(resolved.id).toBe('agent-linked');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('applies per-pattern overrides on top of workspace agent', () => {
|
|
||||||
const agent = makeLinkedAgent({
|
|
||||||
overrides: { model: 'claude-opus-4', instructions: 'Override instructions' },
|
|
||||||
});
|
|
||||||
const resolved = resolvePatternAgent(agent, workspaceAgents);
|
|
||||||
expect(resolved.model).toBe('claude-opus-4');
|
|
||||||
expect(resolved.instructions).toBe('Override instructions');
|
|
||||||
expect(resolved.name).toBe('Code Reviewer');
|
|
||||||
expect(resolved.description).toBe('Reviews code for quality');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('falls back to inline fields when workspace agent is missing', () => {
|
|
||||||
const agent = makeLinkedAgent({ workspaceAgentId: 'nonexistent' });
|
|
||||||
const resolved = resolvePatternAgent(agent, workspaceAgents);
|
|
||||||
expect(resolved).toEqual(agent);
|
|
||||||
});
|
|
||||||
|
|
||||||
test('partial overrides only replace specified fields', () => {
|
|
||||||
const agent = makeLinkedAgent({
|
|
||||||
overrides: { name: 'Custom Name' },
|
|
||||||
});
|
|
||||||
const resolved = resolvePatternAgent(agent, workspaceAgents);
|
|
||||||
expect(resolved.name).toBe('Custom Name');
|
|
||||||
expect(resolved.model).toBe('gpt-5.4');
|
|
||||||
expect(resolved.reasoningEffort).toBe('high');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('resolvePatternAgents', () => {
|
|
||||||
const workspaceAgents = [makeWorkspaceAgent()];
|
|
||||||
|
|
||||||
test('resolves all agents in a pattern', () => {
|
|
||||||
const pattern = makePattern([
|
|
||||||
makeInlineAgent(),
|
|
||||||
makeLinkedAgent(),
|
|
||||||
]);
|
|
||||||
const resolved = resolvePatternAgents(pattern, workspaceAgents);
|
|
||||||
expect(resolved.agents[0].name).toBe('Inline Agent');
|
|
||||||
expect(resolved.agents[1].name).toBe('Code Reviewer');
|
|
||||||
expect(resolved.agents[1].workspaceAgentId).toBe('wa-1');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('preserves pattern metadata', () => {
|
|
||||||
const pattern = makePattern([makeInlineAgent()], { id: 'p-custom', name: 'Custom' });
|
|
||||||
const resolved = resolvePatternAgents(pattern, workspaceAgents);
|
|
||||||
expect(resolved.id).toBe('p-custom');
|
|
||||||
expect(resolved.name).toBe('Custom');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('findWorkspaceAgentUsages', () => {
|
|
||||||
test('finds patterns referencing a workspace agent', () => {
|
|
||||||
const patterns = [
|
|
||||||
makePattern([makeLinkedAgent()], { id: 'p1', name: 'Pattern 1' }),
|
|
||||||
makePattern([makeInlineAgent()], { id: 'p2', name: 'Pattern 2' }),
|
|
||||||
makePattern(
|
|
||||||
[makeInlineAgent(), makeLinkedAgent({ id: 'agent-linked-2' })],
|
|
||||||
{ id: 'p3', name: 'Pattern 3' },
|
|
||||||
),
|
|
||||||
];
|
|
||||||
const usages = findWorkspaceAgentUsages('wa-1', patterns);
|
|
||||||
expect(usages).toHaveLength(2);
|
|
||||||
expect(usages[0].patternId).toBe('p1');
|
|
||||||
expect(usages[1].patternId).toBe('p3');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('returns empty when no patterns reference the agent', () => {
|
|
||||||
const patterns = [makePattern([makeInlineAgent()])];
|
|
||||||
const usages = findWorkspaceAgentUsages('wa-1', patterns);
|
|
||||||
expect(usages).toHaveLength(0);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('normalizeWorkspaceAgentDefinition', () => {
|
|
||||||
test('trims string fields', () => {
|
|
||||||
const agent = makeWorkspaceAgent({
|
|
||||||
name: ' Code Reviewer ',
|
|
||||||
description: ' Reviews code ',
|
|
||||||
instructions: ' Review carefully ',
|
|
||||||
model: ' gpt-5.4 ',
|
|
||||||
});
|
|
||||||
const normalized = normalizeWorkspaceAgentDefinition(agent);
|
|
||||||
expect(normalized.name).toBe('Code Reviewer');
|
|
||||||
expect(normalized.description).toBe('Reviews code');
|
|
||||||
expect(normalized.instructions).toBe('Review carefully');
|
|
||||||
expect(normalized.model).toBe('gpt-5.4');
|
|
||||||
});
|
|
||||||
|
|
||||||
test('preserves non-string fields', () => {
|
|
||||||
const agent = makeWorkspaceAgent({ reasoningEffort: 'xhigh' });
|
|
||||||
const normalized = normalizeWorkspaceAgentDefinition(agent);
|
|
||||||
expect(normalized.reasoningEffort).toBe('xhigh');
|
|
||||||
expect(normalized.id).toBe('wa-1');
|
|
||||||
expect(normalized.createdAt).toBe(TIMESTAMP);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
Reference in New Issue
Block a user