From 41e74c2fa968f7b3881e90653159d6ba5c11d022 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Sun, 5 Apr 2026 19:31:56 +0200 Subject: [PATCH] feat(workflows): add sub-workflow backend support Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- .../Contracts/ProtocolModels.cs | 2 + .../Services/CopilotWorkflowRunner.cs | 4 +- .../Services/SidecarProtocolHost.cs | 2 +- .../Aryx.AgentHost/Services/WorkflowRunner.cs | 54 +++- .../Services/WorkflowValidator.cs | 77 +++++- .../SidecarProtocolHostTests.cs | 2 +- .../WorkflowRunnerTests.cs | 242 ++++++++++++++++++ .../WorkflowValidatorTests.cs | 98 ++++++- src/main/AryxAppService.ts | 122 ++++++++- src/main/ipc/registerIpcHandlers.ts | 3 + src/main/sidecar/sidecarProcess.ts | 6 +- src/preload/index.ts | 1 + src/shared/contracts/channels.ts | 1 + src/shared/contracts/ipc.ts | 3 +- src/shared/contracts/sidecar.ts | 2 + src/shared/domain/workflow.ts | 155 ++++++++++- tests/main/appServiceSubworkflow.test.ts | 207 +++++++++++++++ tests/shared/workflow.test.ts | 112 ++++++++ 18 files changed, 1064 insertions(+), 29 deletions(-) create mode 100644 sidecar/tests/Aryx.AgentHost.Tests/WorkflowRunnerTests.cs create mode 100644 tests/main/appServiceSubworkflow.test.ts diff --git a/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs b/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs index ef53f4b..dfe7475 100644 --- a/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs +++ b/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs @@ -311,6 +311,7 @@ public sealed class ValidatePatternCommandDto : SidecarCommandEnvelope public sealed class ValidateWorkflowCommandDto : SidecarCommandEnvelope { public WorkflowDefinitionDto Workflow { get; init; } = new(); + public IReadOnlyList WorkflowLibrary { get; init; } = []; } public sealed class RunTurnCommandDto : SidecarCommandEnvelope @@ -323,6 +324,7 @@ public sealed class RunTurnCommandDto : SidecarCommandEnvelope public string? ProjectInstructions { get; init; } public PatternDefinitionDto Pattern { get; init; } = new(); public WorkflowDefinitionDto? Workflow { get; init; } + public IReadOnlyList WorkflowLibrary { get; init; } = []; public IReadOnlyList Messages { get; init; } = []; public RunTurnPromptInvocationDto? PromptInvocation { get; init; } public RunTurnToolingConfigDto? Tooling { get; init; } diff --git a/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs b/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs index dc64c8e..e01ab32 100644 --- a/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs +++ b/sidecar/src/Aryx.AgentHost/Services/CopilotWorkflowRunner.cs @@ -37,7 +37,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner { string? validationError = command.Workflow is null ? _patternValidator.Validate(command.Pattern).FirstOrDefault()?.Message - : _workflowValidator.Validate(command.Workflow).FirstOrDefault()?.Message; + : _workflowValidator.Validate(command.Workflow, command.WorkflowLibrary).FirstOrDefault()?.Message; if (validationError is not null) { throw new InvalidOperationException(validationError); @@ -86,7 +86,7 @@ public sealed class CopilotWorkflowRunner : ITurnWorkflowRunner ConfigureHookLifecycleEventSuppression(state, bundle); Workflow workflow = command.Workflow is null ? bundle.BuildWorkflow(command.Pattern) - : _workflowRunner.BuildWorkflow(command.Workflow, command.Pattern, bundle.Agents); + : _workflowRunner.BuildWorkflow(command.Workflow, command.Pattern, bundle.Agents, command.WorkflowLibrary); List inputMessages = command.Messages.Select(WorkflowTranscriptProjector.ToChatMessage).ToList(); WorkflowTranscriptProjector.AttachMessageMode(inputMessages, command.MessageMode); diff --git a/sidecar/src/Aryx.AgentHost/Services/SidecarProtocolHost.cs b/sidecar/src/Aryx.AgentHost/Services/SidecarProtocolHost.cs index 5e3cd31..c6e3069 100644 --- a/sidecar/src/Aryx.AgentHost/Services/SidecarProtocolHost.cs +++ b/sidecar/src/Aryx.AgentHost/Services/SidecarProtocolHost.cs @@ -202,7 +202,7 @@ public sealed class SidecarProtocolHost { Type = "workflow-validation", RequestId = context.Envelope.RequestId, - Issues = _workflowValidator.Validate(command.Workflow), + Issues = _workflowValidator.Validate(command.Workflow, command.WorkflowLibrary), }, context.CancellationToken).ConfigureAwait(false); } diff --git a/sidecar/src/Aryx.AgentHost/Services/WorkflowRunner.cs b/sidecar/src/Aryx.AgentHost/Services/WorkflowRunner.cs index d69b32e..80dafa4 100644 --- a/sidecar/src/Aryx.AgentHost/Services/WorkflowRunner.cs +++ b/sidecar/src/Aryx.AgentHost/Services/WorkflowRunner.cs @@ -1,7 +1,6 @@ using Aryx.AgentHost.Contracts; using Microsoft.Agents.AI; using Microsoft.Agents.AI.Workflows; -using Microsoft.Agents.AI.Workflows.Specialized; namespace Aryx.AgentHost.Services; @@ -10,12 +9,19 @@ internal sealed class WorkflowRunner public Workflow BuildWorkflow( WorkflowDefinitionDto workflowDefinition, PatternDefinitionDto patternDefinition, - IReadOnlyList agents) + IReadOnlyList agents, + IReadOnlyList? workflowLibrary = null) { ArgumentNullException.ThrowIfNull(workflowDefinition); ArgumentNullException.ThrowIfNull(patternDefinition); ArgumentNullException.ThrowIfNull(agents); + Dictionary workflowLibraryMap = workflowLibrary? + .Where(candidate => !string.IsNullOrWhiteSpace(candidate.Id)) + .GroupBy(candidate => candidate.Id, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.Last(), StringComparer.Ordinal) + ?? new Dictionary(StringComparer.Ordinal); + WorkflowNodeDto startNode = workflowDefinition.Graph.Nodes.Single(node => string.Equals(node.Kind, "start", StringComparison.OrdinalIgnoreCase)); WorkflowNodeDto endNode = workflowDefinition.Graph.Nodes.Single(node => @@ -25,10 +31,23 @@ internal sealed class WorkflowRunner .Zip(agents, (definition, agent) => (definition.Id, agent)) .ToDictionary(pair => pair.Id, pair => pair.agent, StringComparer.Ordinal); + return BuildWorkflow(workflowDefinition, agentMap, workflowLibraryMap); + } + + private Workflow BuildWorkflow( + WorkflowDefinitionDto workflowDefinition, + IReadOnlyDictionary agentMap, + IReadOnlyDictionary workflowLibrary) + { + WorkflowNodeDto startNode = workflowDefinition.Graph.Nodes.Single(node => + string.Equals(node.Kind, "start", StringComparison.OrdinalIgnoreCase)); + WorkflowNodeDto endNode = workflowDefinition.Graph.Nodes.Single(node => + string.Equals(node.Kind, "end", StringComparison.OrdinalIgnoreCase)); + Dictionary bindings = new(StringComparer.Ordinal); foreach (WorkflowNodeDto node in workflowDefinition.Graph.Nodes) { - bindings[node.Id] = CreateExecutorBinding(node, agentMap); + bindings[node.Id] = CreateExecutorBinding(node, agentMap, workflowLibrary); } WorkflowBuilder builder = new(bindings[startNode.Id]); @@ -92,7 +111,8 @@ internal sealed class WorkflowRunner private static ExecutorBinding CreateExecutorBinding( WorkflowNodeDto node, - IReadOnlyDictionary agentMap) + IReadOnlyDictionary agentMap, + IReadOnlyDictionary workflowLibrary) { if (string.Equals(node.Kind, "start", StringComparison.OrdinalIgnoreCase)) { @@ -115,6 +135,32 @@ internal sealed class WorkflowRunner return agent.BindAsExecutor(CopilotAgentBundle.CreateAgentHostOptions()); } + if (string.Equals(node.Kind, "sub-workflow", StringComparison.OrdinalIgnoreCase)) + { + WorkflowDefinitionDto subWorkflowDefinition = ResolveSubWorkflowDefinition(node, workflowLibrary); + Workflow subWorkflow = new WorkflowRunner().BuildWorkflow(subWorkflowDefinition, agentMap, workflowLibrary); + return subWorkflow.BindAsExecutor(node.Id); + } + throw new NotSupportedException($"Workflow node kind \"{node.Kind}\" is not executable yet."); } + + private static WorkflowDefinitionDto ResolveSubWorkflowDefinition( + WorkflowNodeDto node, + IReadOnlyDictionary workflowLibrary) + { + if (node.Config.InlineWorkflow is not null) + { + return node.Config.InlineWorkflow; + } + + if (!string.IsNullOrWhiteSpace(node.Config.WorkflowId) + && workflowLibrary.TryGetValue(node.Config.WorkflowId, out WorkflowDefinitionDto? workflow)) + { + return workflow; + } + + throw new InvalidOperationException( + $"Sub-workflow node \"{node.Id}\" references unknown workflow \"{node.Config.WorkflowId}\"."); + } } diff --git a/sidecar/src/Aryx.AgentHost/Services/WorkflowValidator.cs b/sidecar/src/Aryx.AgentHost/Services/WorkflowValidator.cs index 9e359cb..491449e 100644 --- a/sidecar/src/Aryx.AgentHost/Services/WorkflowValidator.cs +++ b/sidecar/src/Aryx.AgentHost/Services/WorkflowValidator.cs @@ -1,3 +1,4 @@ +using System.Linq; using Aryx.AgentHost.Contracts; namespace Aryx.AgentHost.Services; @@ -9,11 +10,18 @@ public sealed class WorkflowValidator "start", "end", "agent", + "sub-workflow", }; - public IReadOnlyList Validate(WorkflowDefinitionDto workflow) + public IReadOnlyList Validate( + WorkflowDefinitionDto workflow, + IReadOnlyList? workflowLibrary = null) { List issues = []; + Dictionary? workflowLibraryById = workflowLibrary? + .Where(candidate => !string.IsNullOrWhiteSpace(candidate.Id)) + .GroupBy(candidate => candidate.Id, StringComparer.Ordinal) + .ToDictionary(group => group.Key, group => group.Last(), StringComparer.Ordinal); if (string.IsNullOrWhiteSpace(workflow.Name)) { @@ -94,6 +102,8 @@ public sealed class WorkflowValidator }); } } + + ValidateSubWorkflowNode(node, workflowLibraryById, issues); } foreach (WorkflowEdgeDto edge in workflow.Graph.Edges) @@ -145,8 +155,10 @@ public sealed class WorkflowValidator List endNodes = workflow.Graph.Nodes .Where(node => string.Equals(node.Kind, "end", StringComparison.OrdinalIgnoreCase)) .ToList(); - List agentNodes = workflow.Graph.Nodes - .Where(node => string.Equals(node.Kind, "agent", StringComparison.OrdinalIgnoreCase)) + List executableWorkNodes = workflow.Graph.Nodes + .Where(node => + string.Equals(node.Kind, "agent", StringComparison.OrdinalIgnoreCase) + || string.Equals(node.Kind, "sub-workflow", StringComparison.OrdinalIgnoreCase)) .ToList(); if (startNodes.Count != 1) @@ -167,12 +179,12 @@ public sealed class WorkflowValidator }); } - if (agentNodes.Count == 0) + if (executableWorkNodes.Count == 0) { issues.Add(new WorkflowValidationIssueDto { Field = "graph.nodes", - Message = "Workflow graphs must contain at least one agent node.", + Message = "Workflow graphs must contain at least one agent or sub-workflow node.", }); } @@ -338,6 +350,61 @@ public sealed class WorkflowValidator return issues; } + private void ValidateSubWorkflowNode( + WorkflowNodeDto node, + IReadOnlyDictionary? workflowLibraryById, + List issues) + { + if (!string.Equals(node.Kind, "sub-workflow", StringComparison.OrdinalIgnoreCase)) + { + return; + } + + bool hasWorkflowId = !string.IsNullOrWhiteSpace(node.Config.WorkflowId); + bool hasInlineWorkflow = node.Config.InlineWorkflow is not null; + if (hasWorkflowId == hasInlineWorkflow) + { + issues.Add(new WorkflowValidationIssueDto + { + Field = "graph.nodes.config", + NodeId = node.Id, + Message = "Sub-workflow nodes must specify exactly one of workflowId or inlineWorkflow.", + }); + return; + } + + if (hasWorkflowId + && workflowLibraryById is not null + && !workflowLibraryById.ContainsKey(node.Config.WorkflowId!)) + { + issues.Add(new WorkflowValidationIssueDto + { + Field = "graph.nodes.config.workflowId", + NodeId = node.Id, + Message = $"Sub-workflow node \"{node.Label}\" references unknown workflow \"{node.Config.WorkflowId}\".", + }); + } + + if (node.Config.InlineWorkflow is null) + { + return; + } + + foreach (WorkflowValidationIssueDto inlineIssue in Validate(node.Config.InlineWorkflow, workflowLibraryById?.Values.ToList())) + { + issues.Add(new WorkflowValidationIssueDto + { + Level = inlineIssue.Level, + Field = inlineIssue.Field is null + ? "graph.nodes.config.inlineWorkflow" + : $"graph.nodes.config.inlineWorkflow.{inlineIssue.Field}", + NodeId = node.Id, + EdgeId = inlineIssue.EdgeId, + Message = $"Inline workflow for node \"{node.Label}\": {inlineIssue.Message}", + }); + } + } + private static void ValidateEdgeCondition(WorkflowEdgeDto edge, List issues) { if (edge.Condition is null) diff --git a/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs index e963229..a1d2be1 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/SidecarProtocolHostTests.cs @@ -169,7 +169,7 @@ public sealed class SidecarProtocolHostTests && issue.GetProperty("message").GetString() == "Workflow name is required."); Assert.Contains(issues, issue => issue.GetProperty("field").GetString() == "graph.nodes" - && issue.GetProperty("message").GetString() == "Workflow graphs must contain at least one agent node."); + && issue.GetProperty("message").GetString() == "Workflow graphs must contain at least one agent or sub-workflow node."); }, completionEvent => { diff --git a/sidecar/tests/Aryx.AgentHost.Tests/WorkflowRunnerTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/WorkflowRunnerTests.cs new file mode 100644 index 0000000..a332083 --- /dev/null +++ b/sidecar/tests/Aryx.AgentHost.Tests/WorkflowRunnerTests.cs @@ -0,0 +1,242 @@ +using Aryx.AgentHost.Contracts; +using Aryx.AgentHost.Services; +using Microsoft.Agents.AI; +using Microsoft.Agents.AI.Workflows; +using Microsoft.Extensions.AI; + +namespace Aryx.AgentHost.Tests; + +public sealed class WorkflowRunnerTests +{ + [Fact] + public async Task BuildWorkflow_AcceptsInlineSubworkflows() + { + WorkflowRunner runner = new(); + Workflow workflow = runner.BuildWorkflow( + CreateSubworkflowParent(inlineWorkflow: CreateAgentWorkflow("child-inline", "agent-child")), + CreatePattern("agent-child"), + [CreateChatClientAgent("agent-child", "Child Agent")]); + + ProtocolDescriptor descriptor = await workflow.DescribeProtocolAsync(); + + Assert.Contains(descriptor.Yields, candidate => candidate == typeof(List)); + } + + [Fact] + public async Task BuildWorkflow_AcceptsReferencedSubworkflowsFromWorkflowLibrary() + { + WorkflowRunner runner = new(); + WorkflowDefinitionDto childWorkflow = CreateAgentWorkflow("child-ref", "agent-child"); + Workflow workflow = runner.BuildWorkflow( + CreateSubworkflowParent(workflowId: childWorkflow.Id), + CreatePattern("agent-child"), + [CreateChatClientAgent("agent-child", "Child Agent")], + [childWorkflow]); + + ProtocolDescriptor descriptor = await workflow.DescribeProtocolAsync(); + + Assert.Contains(descriptor.Yields, candidate => candidate == typeof(List)); + } + + [Fact] + public void BuildWorkflow_RejectsUnknownReferencedSubworkflows() + { + WorkflowRunner runner = new(); + + InvalidOperationException error = Assert.Throws(() => runner.BuildWorkflow( + CreateSubworkflowParent(workflowId: "missing-child"), + CreatePattern("agent-child"), + [CreateChatClientAgent("agent-child", "Child Agent")], + [])); + + Assert.Contains("unknown workflow", 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) + { + return new WorkflowDefinitionDto + { + Id = id, + Name = "Child Workflow", + Graph = new WorkflowGraphDto + { + Nodes = + [ + new WorkflowNodeDto + { + Id = "start", + Kind = "start", + Label = "Start", + Config = new WorkflowNodeConfigDto { Kind = "start" }, + }, + new WorkflowNodeDto + { + Id = agentId, + Kind = "agent", + Label = "Child Agent", + Config = new WorkflowNodeConfigDto + { + Kind = "agent", + Id = agentId, + Name = "Child Agent", + Model = "gpt-5.4", + }, + }, + new WorkflowNodeDto + { + Id = "end", + Kind = "end", + Label = "End", + Config = new WorkflowNodeConfigDto { Kind = "end" }, + }, + ], + Edges = + [ + new WorkflowEdgeDto + { + Id = "edge-start-agent", + Source = "start", + Target = agentId, + Kind = "direct", + }, + new WorkflowEdgeDto + { + Id = "edge-agent-end", + Source = agentId, + Target = "end", + Kind = "direct", + }, + ], + }, + Settings = new WorkflowSettingsDto + { + Checkpointing = new WorkflowCheckpointSettingsDto(), + }, + }; + } + + private static WorkflowDefinitionDto CreateSubworkflowParent( + string? workflowId = null, + WorkflowDefinitionDto? inlineWorkflow = null) + { + return new WorkflowDefinitionDto + { + Id = "parent-workflow", + Name = "Parent Workflow", + Graph = new WorkflowGraphDto + { + Nodes = + [ + new WorkflowNodeDto + { + Id = "start", + Kind = "start", + Label = "Start", + Config = new WorkflowNodeConfigDto { Kind = "start" }, + }, + new WorkflowNodeDto + { + Id = "sub-workflow", + Kind = "sub-workflow", + Label = "Nested Workflow", + Config = new WorkflowNodeConfigDto + { + Kind = "sub-workflow", + WorkflowId = workflowId, + InlineWorkflow = inlineWorkflow, + }, + }, + new WorkflowNodeDto + { + Id = "end", + Kind = "end", + Label = "End", + Config = new WorkflowNodeConfigDto { Kind = "end" }, + }, + ], + Edges = + [ + new WorkflowEdgeDto + { + Id = "edge-start-sub", + Source = "start", + Target = "sub-workflow", + Kind = "direct", + }, + new WorkflowEdgeDto + { + Id = "edge-sub-end", + Source = "sub-workflow", + Target = "end", + Kind = "direct", + }, + ], + }, + Settings = new WorkflowSettingsDto + { + Checkpointing = new WorkflowCheckpointSettingsDto(), + }, + }; + } + + private static ChatClientAgent CreateChatClientAgent(string id, string name) + { + return new ChatClientAgent( + new StubChatClient(), + id, + name, + "Stub agent for workflow runner tests.", + [], + null!, + null!); + } + + private sealed class StubChatClient : IChatClient + { + public void Dispose() + { + } + + public Task GetResponseAsync( + IEnumerable messages, + ChatOptions? options, + CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + + public object? GetService(Type serviceType, object? serviceKey = null) + { + return null; + } + + public IAsyncEnumerable GetStreamingResponseAsync( + IEnumerable messages, + ChatOptions? options, + CancellationToken cancellationToken) + { + throw new NotSupportedException(); + } + } +} diff --git a/sidecar/tests/Aryx.AgentHost.Tests/WorkflowValidatorTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/WorkflowValidatorTests.cs index 4936c6c..7e2d9a1 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/WorkflowValidatorTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/WorkflowValidatorTests.cs @@ -7,6 +7,36 @@ public sealed class WorkflowValidatorTests { private readonly WorkflowValidator _validator = new(); + [Fact] + public void Validate_AcceptsInlineSubworkflowNodes() + { + WorkflowDefinitionDto workflow = CreateSubworkflowParent(inlineWorkflow: CreateWorkflow(id: "child")); + + IReadOnlyList issues = _validator.Validate(workflow); + + Assert.DoesNotContain(issues, issue => issue.Level == "error"); + } + + [Fact] + public void Validate_RejectsSubworkflowNodesWithoutSingleSource() + { + WorkflowDefinitionDto workflow = CreateSubworkflowParent(); + + IReadOnlyList issues = _validator.Validate(workflow); + + Assert.Contains(issues, issue => issue.Field == "graph.nodes.config"); + } + + [Fact] + public void Validate_RejectsUnknownReferencedWorkflowIdsWhenLibraryProvided() + { + WorkflowDefinitionDto workflow = CreateSubworkflowParent(workflowId: "missing-child"); + + IReadOnlyList issues = _validator.Validate(workflow, []); + + Assert.Contains(issues, issue => issue.Field == "graph.nodes.config.workflowId"); + } + [Fact] public void Validate_RejectsInvalidConditionOperator() { @@ -122,11 +152,11 @@ public sealed class WorkflowValidatorTests Assert.DoesNotContain(issues, issue => issue.Level == "error"); } - private static WorkflowDefinitionDto CreateWorkflow() + private static WorkflowDefinitionDto CreateWorkflow(string id = "workflow-1") { return new WorkflowDefinitionDto { - Id = "workflow-1", + Id = id, Name = "Loop Workflow", Graph = new WorkflowGraphDto { @@ -184,4 +214,68 @@ public sealed class WorkflowValidatorTests }, }; } + + private static WorkflowDefinitionDto CreateSubworkflowParent( + string? workflowId = null, + WorkflowDefinitionDto? inlineWorkflow = null) + { + return new WorkflowDefinitionDto + { + Id = "workflow-parent", + Name = "Parent Workflow", + Graph = new WorkflowGraphDto + { + Nodes = + [ + new WorkflowNodeDto + { + Id = "start", + Kind = "start", + Label = "Start", + Config = new WorkflowNodeConfigDto { Kind = "start" }, + }, + new WorkflowNodeDto + { + Id = "sub-workflow", + Kind = "sub-workflow", + Label = "Nested Workflow", + Config = new WorkflowNodeConfigDto + { + Kind = "sub-workflow", + WorkflowId = workflowId, + InlineWorkflow = inlineWorkflow, + }, + }, + new WorkflowNodeDto + { + Id = "end", + Kind = "end", + Label = "End", + Config = new WorkflowNodeConfigDto { Kind = "end" }, + }, + ], + Edges = + [ + new WorkflowEdgeDto + { + Id = "edge-start-sub", + Source = "start", + Target = "sub-workflow", + Kind = "direct", + }, + new WorkflowEdgeDto + { + Id = "edge-sub-end", + Source = "sub-workflow", + Target = "end", + Kind = "direct", + }, + ], + }, + Settings = new WorkflowSettingsDto + { + Checkpointing = new WorkflowCheckpointSettingsDto(), + }, + }; + } } diff --git a/src/main/AryxAppService.ts b/src/main/AryxAppService.ts index abcb987..59c14af 100644 --- a/src/main/AryxAppService.ts +++ b/src/main/AryxAppService.ts @@ -41,6 +41,7 @@ import { normalizeWorkflowDefinition, validateWorkflowDefinition, type WorkflowDefinition, + type WorkflowReference, } from '@shared/domain/workflow'; import { normalizeWorkspaceAgentDefinition, @@ -644,6 +645,7 @@ export class AryxAppService extends EventEmitter { createdAt: existingIndex >= 0 ? workspace.workflows[existingIndex].createdAt : nowIso(), updatedAt: nowIso(), }; + this.validateWorkflowReferences(workspace, candidate); if (existingIndex >= 0) { workspace.workflows[existingIndex] = candidate; @@ -760,6 +762,16 @@ export class AryxAppService extends EventEmitter { async deleteWorkflow(workflowId: string): Promise { const workspace = await this.loadWorkspace(); + const workflow = this.requireWorkflow(workspace, workflowId); + const references = this.listWorkflowReferencesInWorkspace(workspace, workflowId) + .filter((reference) => reference.referencingWorkflowId !== workflowId); + if (references.length > 0) { + const blockingReference = references[0]; + throw new Error( + `Workflow "${workflow.name}" cannot be deleted because workflow "${blockingReference.referencingWorkflowName}" references it from node "${blockingReference.nodeLabel}".`, + ); + } + workspace.workflows = workspace.workflows.filter((workflow) => workflow.id !== workflowId); if (workspace.selectedWorkflowId === workflowId) { @@ -769,6 +781,12 @@ export class AryxAppService extends EventEmitter { return this.persistAndBroadcast(workspace); } + async listWorkflowReferences(workflowId: string): Promise { + const workspace = await this.loadWorkspace(); + this.requireWorkflow(workspace, workflowId); + return this.listWorkflowReferencesInWorkspace(workspace, workflowId); + } + async saveMcpServer(server: McpServerDefinition): Promise { const workspace = await this.loadWorkspace(); const existingIndex = workspace.settings.tooling.mcpServers.findIndex( @@ -934,7 +952,7 @@ export class AryxAppService extends EventEmitter { const project = this.requireProject(workspace, projectId); const workflow = this.requireWorkflow(workspace, workflowId); const modelCatalog = await this.loadAvailableModelCatalog(); - const executionPattern = normalizePatternModels(buildWorkflowExecutionPattern(workflow), modelCatalog); + const executionPattern = normalizePatternModels(this.buildResolvedWorkflowExecutionPattern(workspace, workflow), modelCatalog); const session: SessionRecord = { id: createId('session'), @@ -1663,6 +1681,7 @@ export class AryxAppService extends EventEmitter { projectInstructions, pattern: patternForTurn, workflow: effectiveWorkflow, + workflowLibrary: effectiveWorkflow ? workspace.workflows : undefined, messages: session.messages, attachments: attachments?.length ? attachments : undefined, promptInvocation, @@ -2248,7 +2267,7 @@ export class AryxAppService extends EventEmitter { const workflow = this.requireWorkflow(workspace, session.workflowId); return { workflow, - pattern: buildWorkflowExecutionPattern(workflow), + pattern: this.buildResolvedWorkflowExecutionPattern(workspace, workflow), }; } @@ -2257,6 +2276,105 @@ export class AryxAppService extends EventEmitter { }; } + private buildResolvedWorkflowExecutionPattern( + workspace: WorkspaceState, + workflow: WorkflowDefinition, + ): PatternDefinition { + return buildWorkflowExecutionPattern(workflow, { + resolveWorkflow: (workflowId) => workspace.workflows.find((candidate) => candidate.id === workflowId), + }); + } + + private validateWorkflowReferences( + workspace: WorkspaceState, + workflow: WorkflowDefinition, + ): void { + const workflowLibrary = new Map(); + for (const candidate of workspace.workflows) { + if (candidate.id !== workflow.id) { + workflowLibrary.set(candidate.id, candidate); + } + } + workflowLibrary.set(workflow.id, workflow); + + const visitWorkflow = ( + currentWorkflow: WorkflowDefinition, + path: string[], + visitedInlineWorkflows: Set, + ): void => { + for (const node of currentWorkflow.graph.nodes) { + if (node.kind !== 'sub-workflow' || node.config.kind !== 'sub-workflow') { + continue; + } + + const { inlineWorkflow, workflowId } = node.config; + if (workflowId) { + const referencedWorkflow = workflowLibrary.get(workflowId); + if (!referencedWorkflow) { + throw new Error( + `Sub-workflow node "${node.label || node.id}" references unknown workflow "${workflowId}".`, + ); + } + + if (path.includes(workflowId)) { + throw new Error( + `Saving workflow "${workflow.name}" would create a circular sub-workflow reference: ${[...path, workflowId].join(' -> ')}.`, + ); + } + + visitWorkflow(referencedWorkflow, [...path, workflowId], visitedInlineWorkflows); + } + + if (inlineWorkflow && !visitedInlineWorkflows.has(inlineWorkflow)) { + visitedInlineWorkflows.add(inlineWorkflow); + visitWorkflow(inlineWorkflow, path, visitedInlineWorkflows); + } + } + }; + + visitWorkflow(workflow, [workflow.id], new Set()); + } + + private listWorkflowReferencesInWorkspace( + workspace: WorkspaceState, + workflowId: string, + ): WorkflowReference[] { + const references: WorkflowReference[] = []; + + const visitWorkflow = ( + referencingWorkflow: WorkflowDefinition, + currentWorkflow: WorkflowDefinition, + visitedInlineWorkflows: Set, + ): void => { + for (const node of currentWorkflow.graph.nodes) { + if (node.kind !== 'sub-workflow' || node.config.kind !== 'sub-workflow') { + continue; + } + + const { inlineWorkflow, workflowId: referencedWorkflowId } = node.config; + if (referencedWorkflowId === workflowId) { + references.push({ + referencingWorkflowId: referencingWorkflow.id, + referencingWorkflowName: referencingWorkflow.name, + nodeId: node.id, + nodeLabel: node.label || node.id, + }); + } + + if (inlineWorkflow && !visitedInlineWorkflows.has(inlineWorkflow)) { + visitedInlineWorkflows.add(inlineWorkflow); + visitWorkflow(referencingWorkflow, inlineWorkflow, visitedInlineWorkflows); + } + } + }; + + for (const referencingWorkflow of workspace.workflows) { + visitWorkflow(referencingWorkflow, referencingWorkflow, new Set()); + } + + return references; + } + private requireSession(workspace: WorkspaceState, sessionId: string): SessionRecord { const session = workspace.sessions.find((current) => current.id === sessionId); if (!session) { diff --git a/src/main/ipc/registerIpcHandlers.ts b/src/main/ipc/registerIpcHandlers.ts index 84e0f19..4cc117b 100644 --- a/src/main/ipc/registerIpcHandlers.ts +++ b/src/main/ipc/registerIpcHandlers.ts @@ -116,6 +116,9 @@ export function registerIpcHandlers( ); ipcMain.handle(ipcChannels.saveWorkflow, (_event, input: SaveWorkflowInput) => service.saveWorkflow(input.workflow)); ipcMain.handle(ipcChannels.deleteWorkflow, (_event, workflowId: string) => service.deleteWorkflow(workflowId)); + ipcMain.handle(ipcChannels.listWorkflowReferences, (_event, workflowId: string) => + service.listWorkflowReferences(workflowId), + ); ipcMain.handle(ipcChannels.setTheme, async (_event, theme: AppearanceTheme) => { const result = await service.setTheme(theme); applyTitleBarTheme(window, theme); diff --git a/src/main/sidecar/sidecarProcess.ts b/src/main/sidecar/sidecarProcess.ts index 172ffc5..05917d3 100644 --- a/src/main/sidecar/sidecarProcess.ts +++ b/src/main/sidecar/sidecarProcess.ts @@ -134,11 +134,15 @@ export class SidecarClient { }); } - async validateWorkflow(workflow: ValidateWorkflowCommand['workflow']): Promise { + async validateWorkflow( + workflow: ValidateWorkflowCommand['workflow'], + workflowLibrary?: ValidateWorkflowCommand['workflowLibrary'], + ): Promise { return this.dispatch({ type: 'validate-workflow', requestId: `validate-workflow-${Date.now()}`, workflow, + workflowLibrary, }); } diff --git a/src/preload/index.ts b/src/preload/index.ts index b5d9829..7ba593c 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -28,6 +28,7 @@ const api: ElectronApi = { setPatternFavorite: (input) => ipcRenderer.invoke(ipcChannels.setPatternFavorite, input), saveWorkflow: (input) => ipcRenderer.invoke(ipcChannels.saveWorkflow, input), deleteWorkflow: (workflowId) => ipcRenderer.invoke(ipcChannels.deleteWorkflow, workflowId), + listWorkflowReferences: (workflowId) => ipcRenderer.invoke(ipcChannels.listWorkflowReferences, workflowId), createWorkflowSession: (input) => ipcRenderer.invoke(ipcChannels.createWorkflowSession, input), setTheme: (theme) => ipcRenderer.invoke(ipcChannels.setTheme, theme), setTerminalHeight: (input) => ipcRenderer.invoke(ipcChannels.setTerminalHeight, input), diff --git a/src/shared/contracts/channels.ts b/src/shared/contracts/channels.ts index e183e4c..96afc06 100644 --- a/src/shared/contracts/channels.ts +++ b/src/shared/contracts/channels.ts @@ -17,6 +17,7 @@ export const ipcChannels = { setPatternFavorite: 'patterns:set-favorite', saveWorkflow: 'workflows:save', deleteWorkflow: 'workflows:delete', + listWorkflowReferences: 'workflows:list-references', createWorkflowSession: 'workflows:create-session', setTheme: 'settings:set-theme', setTerminalHeight: 'settings:set-terminal-height', diff --git a/src/shared/contracts/ipc.ts b/src/shared/contracts/ipc.ts index a90b8d7..167061b 100644 --- a/src/shared/contracts/ipc.ts +++ b/src/shared/contracts/ipc.ts @@ -1,7 +1,7 @@ import type { ApprovalDecision } from '@shared/domain/approval'; import type { SidecarCapabilities, InteractionMode, MessageMode, QuotaSnapshot } from '@shared/contracts/sidecar'; import type { PatternDefinition, ReasoningEffort } from '@shared/domain/pattern'; -import type { WorkflowDefinition } from '@shared/domain/workflow'; +import type { WorkflowDefinition, WorkflowReference } from '@shared/domain/workflow'; import type { ProjectGitBranchSummary, ProjectGitCommitMessageSuggestion, @@ -286,6 +286,7 @@ export interface ElectronApi { deletePattern(patternId: string): Promise; saveWorkflow(input: SaveWorkflowInput): Promise; deleteWorkflow(workflowId: string): Promise; + listWorkflowReferences(workflowId: string): Promise; saveMcpServer(input: SaveMcpServerInput): Promise; deleteMcpServer(serverId: string): Promise; saveLspProfile(input: SaveLspProfileInput): Promise; diff --git a/src/shared/contracts/sidecar.ts b/src/shared/contracts/sidecar.ts index 2e3b1f9..3d296a8 100644 --- a/src/shared/contracts/sidecar.ts +++ b/src/shared/contracts/sidecar.ts @@ -75,6 +75,7 @@ export interface ValidateWorkflowCommand { type: 'validate-workflow'; requestId: string; workflow: WorkflowDefinition; + workflowLibrary?: WorkflowDefinition[]; } export type InteractionMode = 'interactive' | 'plan'; @@ -97,6 +98,7 @@ export interface RunTurnCommand { projectInstructions?: string; pattern: PatternDefinition; workflow?: WorkflowDefinition; + workflowLibrary?: WorkflowDefinition[]; messages: ChatMessageRecord[]; attachments?: ChatMessageAttachment[]; promptInvocation?: ProjectPromptInvocation; diff --git a/src/shared/domain/workflow.ts b/src/shared/domain/workflow.ts index 34b393a..331fabb 100644 --- a/src/shared/domain/workflow.ts +++ b/src/shared/domain/workflow.ts @@ -159,7 +159,18 @@ export interface WorkflowValidationIssue { edgeId?: string; } -const executableNodeKinds = new Set(['start', 'end', 'agent']); +export interface WorkflowReference { + referencingWorkflowId: string; + referencingWorkflowName: string; + nodeId: string; + nodeLabel: string; +} + +export interface WorkflowResolutionOptions { + resolveWorkflow?: (workflowId: string) => WorkflowDefinition | undefined; +} + +const executableNodeKinds = new Set(['start', 'end', 'agent', 'sub-workflow']); function normalizeOptionalString(value?: string): string | undefined { const trimmed = value?.trim(); @@ -362,9 +373,94 @@ export function resolveWorkflowAgents(workflow: WorkflowDefinition): PatternAgen }); } -function inferWorkflowPatternMode(workflow: WorkflowDefinition): PatternDefinition['mode'] { - const hasFanEdges = workflow.graph.edges.some((edge) => edge.kind !== 'direct'); - const agentCount = resolveWorkflowAgents(workflow).length; +function hasWorkflowExecutionFanEdges( + workflow: WorkflowDefinition, + options?: WorkflowResolutionOptions, + visitedReferencedWorkflowIds = new Set([workflow.id]), + visitedInlineWorkflows = new Set(), +): boolean { + if (workflow.graph.edges.some((edge) => edge.kind !== 'direct')) { + return true; + } + + for (const node of workflow.graph.nodes) { + if (node.kind !== 'sub-workflow' || node.config.kind !== 'sub-workflow') { + continue; + } + + const { inlineWorkflow, workflowId } = node.config; + if (inlineWorkflow) { + if (!visitedInlineWorkflows.has(inlineWorkflow)) { + visitedInlineWorkflows.add(inlineWorkflow); + if (hasWorkflowExecutionFanEdges(inlineWorkflow, options, visitedReferencedWorkflowIds, visitedInlineWorkflows)) { + return true; + } + } + } + + if (workflowId && options?.resolveWorkflow && !visitedReferencedWorkflowIds.has(workflowId)) { + const referencedWorkflow = options.resolveWorkflow(workflowId); + if (referencedWorkflow) { + visitedReferencedWorkflowIds.add(workflowId); + if (hasWorkflowExecutionFanEdges(referencedWorkflow, options, visitedReferencedWorkflowIds, visitedInlineWorkflows)) { + return true; + } + } + } + } + + return false; +} + +function resolveWorkflowExecutionAgents( + workflow: WorkflowDefinition, + options?: WorkflowResolutionOptions, + visitedReferencedWorkflowIds = new Set([workflow.id]), + visitedInlineWorkflows = new Set(), +): PatternAgentDefinition[] { + const agents = [...resolveWorkflowAgents(workflow)]; + + for (const node of workflow.graph.nodes) { + if (node.kind !== 'sub-workflow' || node.config.kind !== 'sub-workflow') { + continue; + } + + const { inlineWorkflow, workflowId } = node.config; + if (inlineWorkflow) { + if (!visitedInlineWorkflows.has(inlineWorkflow)) { + visitedInlineWorkflows.add(inlineWorkflow); + agents.push(...resolveWorkflowExecutionAgents( + inlineWorkflow, + options, + visitedReferencedWorkflowIds, + visitedInlineWorkflows, + )); + } + } + + if (workflowId && options?.resolveWorkflow && !visitedReferencedWorkflowIds.has(workflowId)) { + const referencedWorkflow = options.resolveWorkflow(workflowId); + if (referencedWorkflow) { + visitedReferencedWorkflowIds.add(workflowId); + agents.push(...resolveWorkflowExecutionAgents( + referencedWorkflow, + options, + visitedReferencedWorkflowIds, + visitedInlineWorkflows, + )); + } + } + } + + return agents; +} + +function inferWorkflowPatternMode( + workflow: WorkflowDefinition, + options?: WorkflowResolutionOptions, +): PatternDefinition['mode'] { + const hasFanEdges = hasWorkflowExecutionFanEdges(workflow, options); + const agentCount = resolveWorkflowExecutionAgents(workflow, options).length; if (hasFanEdges) { return 'concurrent'; } @@ -372,13 +468,16 @@ function inferWorkflowPatternMode(workflow: WorkflowDefinition): PatternDefiniti return agentCount <= 1 ? 'single' : 'sequential'; } -export function buildWorkflowExecutionPattern(workflow: WorkflowDefinition): PatternDefinition { - const agents = resolveWorkflowAgents(workflow); +export function buildWorkflowExecutionPattern( + workflow: WorkflowDefinition, + options?: WorkflowResolutionOptions, +): PatternDefinition { + const agents = resolveWorkflowExecutionAgents(workflow, options); const pattern: PatternDefinition = { id: workflow.id, name: workflow.name, description: workflow.description, - mode: inferWorkflowPatternMode(workflow), + mode: inferWorkflowPatternMode(workflow, options), availability: 'available', maxIterations: workflow.settings.maxIterations ?? 5, approvalPolicy: workflow.settings.approvalPolicy, @@ -608,6 +707,39 @@ function validateEdgeCondition(edge: WorkflowEdge, issues: WorkflowValidationIss } } +function validateSubWorkflowNode(node: WorkflowNode, issues: WorkflowValidationIssue[]): void { + if (node.kind !== 'sub-workflow' || node.config.kind !== 'sub-workflow') { + return; + } + + const hasWorkflowId = Boolean(node.config.workflowId); + const hasInlineWorkflow = Boolean(node.config.inlineWorkflow); + if (hasWorkflowId === hasInlineWorkflow) { + addIssue(issues, { + level: 'error', + field: 'graph.nodes.config', + nodeId: node.id, + message: 'Sub-workflow nodes must specify exactly one of workflowId or inlineWorkflow.', + }); + return; + } + + if (!node.config.inlineWorkflow) { + return; + } + + for (const inlineIssue of validateWorkflowDefinition(node.config.inlineWorkflow)) { + addIssue(issues, { + ...inlineIssue, + field: inlineIssue.field + ? `graph.nodes.config.inlineWorkflow.${inlineIssue.field}` + : 'graph.nodes.config.inlineWorkflow', + nodeId: node.id, + message: `Inline workflow for node "${node.label || node.id}": ${inlineIssue.message}`, + }); + } +} + export function validateWorkflowDefinition(workflow: WorkflowDefinition): WorkflowValidationIssue[] { const normalized = normalizeWorkflowDefinition(workflow); const issues: WorkflowValidationIssue[] = []; @@ -685,6 +817,8 @@ export function validateWorkflowDefinition(workflow: WorkflowDefinition): Workfl }); } } + + validateSubWorkflowNode(node, issues); } for (const edge of normalized.graph.edges) { if (!edge.id) { @@ -725,7 +859,8 @@ export function validateWorkflowDefinition(workflow: WorkflowDefinition): Workfl } const startNodes = normalized.graph.nodes.filter((node) => node.kind === 'start'); const endNodes = normalized.graph.nodes.filter((node) => node.kind === 'end'); - const agentNodes = normalized.graph.nodes.filter((node) => node.kind === 'agent'); + const executableWorkNodes = normalized.graph.nodes.filter((node) => + node.kind === 'agent' || node.kind === 'sub-workflow'); if (startNodes.length !== 1) { addIssue(issues, { @@ -743,11 +878,11 @@ export function validateWorkflowDefinition(workflow: WorkflowDefinition): Workfl }); } - if (agentNodes.length === 0) { + if (executableWorkNodes.length === 0) { addIssue(issues, { level: 'error', field: 'graph.nodes', - message: 'Workflow graphs must contain at least one agent node.', + message: 'Workflow graphs must contain at least one agent or sub-workflow node.', }); } diff --git a/tests/main/appServiceSubworkflow.test.ts b/tests/main/appServiceSubworkflow.test.ts new file mode 100644 index 0000000..fc6c3a6 --- /dev/null +++ b/tests/main/appServiceSubworkflow.test.ts @@ -0,0 +1,207 @@ +import { describe, expect, mock, test } from 'bun:test'; + +import { buildAvailableModelCatalog } from '@shared/domain/models'; +import type { WorkflowDefinition } from '@shared/domain/workflow'; +import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace'; + +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): InstanceType { + const service = new AryxAppService(); + const internals = service as unknown as Record; + internals.loadWorkspace = async () => workspace; + internals.persistAndBroadcast = async (nextWorkspace: WorkspaceState) => nextWorkspace; + internals.loadAvailableModelCatalog = async () => buildAvailableModelCatalog(); + return service; +} + +function createAgentWorkflow(id: string, name: string, agentId: string): WorkflowDefinition { + return { + id, + name, + description: `${name} description`, + createdAt: '2026-04-05T00:00:00.000Z', + updatedAt: '2026-04-05T00:00:00.000Z', + graph: { + nodes: [ + { id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } }, + { + id: agentId, + kind: 'agent', + label: name, + position: { x: 200, y: 0 }, + order: 0, + config: { + kind: 'agent', + id: agentId, + name, + description: `${name} description`, + instructions: `Run ${name}`, + 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: agentId, kind: 'direct' }, + { id: 'edge-agent-end', source: agentId, target: 'end', kind: 'direct' }, + ], + }, + settings: { + checkpointing: { enabled: false }, + executionMode: 'off-thread', + }, + }; +} + +function createSubWorkflow( + id: string, + name: string, + config: { workflowId?: string; inlineWorkflow?: WorkflowDefinition }, +): WorkflowDefinition { + return { + id, + name, + description: `${name} description`, + createdAt: '2026-04-05T00:00:00.000Z', + updatedAt: '2026-04-05T00:00:00.000Z', + graph: { + nodes: [ + { id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } }, + { + id: 'sub-workflow', + kind: 'sub-workflow', + label: 'Nested Workflow', + position: { x: 200, y: 0 }, + config: { + kind: 'sub-workflow', + workflowId: config.workflowId, + inlineWorkflow: config.inlineWorkflow, + }, + }, + { id: 'end', kind: 'end', label: 'End', position: { x: 400, y: 0 }, config: { kind: 'end' } }, + ], + edges: [ + { id: 'edge-start-sub', source: 'start', target: 'sub-workflow', kind: 'direct' }, + { id: 'edge-sub-end', source: 'sub-workflow', target: 'end', kind: 'direct' }, + ], + }, + settings: { + checkpointing: { enabled: false }, + executionMode: 'off-thread', + }, + }; +} + +describe('AryxAppService sub-workflow operations', () => { + test('rejects saving workflows with missing referenced workflows', async () => { + const workspace = createWorkspaceSeed(); + const service = createService(workspace); + + await expect(service.saveWorkflow(createSubWorkflow('parent', 'Parent', { workflowId: 'missing-child' }))) + .rejects.toThrow('references unknown workflow "missing-child"'); + }); + + test('rejects circular workflow references across saved workflows', async () => { + const workspace = createWorkspaceSeed(); + workspace.workflows.push(createSubWorkflow('workflow-b', 'Workflow B', { workflowId: 'workflow-a' })); + const service = createService(workspace); + + await expect(service.saveWorkflow(createSubWorkflow('workflow-a', 'Workflow A', { workflowId: 'workflow-b' }))) + .rejects.toThrow('circular sub-workflow reference'); + }); + + test('prevents deleting workflows that are still referenced', async () => { + const workspace = createWorkspaceSeed(); + workspace.workflows.push( + createAgentWorkflow('child', 'Child Agent', 'agent-child'), + createSubWorkflow('parent', 'Parent Workflow', { workflowId: 'child' }), + ); + const service = createService(workspace); + + await expect(service.deleteWorkflow('child')).rejects.toThrow('cannot be deleted'); + }); + + test('lists workflow references for referenced sub-workflows', async () => { + const workspace = createWorkspaceSeed(); + workspace.workflows.push( + createAgentWorkflow('child', 'Child Agent', 'agent-child'), + createSubWorkflow('parent', 'Parent Workflow', { workflowId: 'child' }), + createSubWorkflow('inline-parent', 'Inline Parent', { + inlineWorkflow: createSubWorkflow('inline-child', 'Inline Child', { workflowId: 'child' }), + }), + ); + const service = createService(workspace); + + const references = await service.listWorkflowReferences('child'); + + expect(references).toEqual([ + { + referencingWorkflowId: 'parent', + referencingWorkflowName: 'Parent Workflow', + nodeId: 'sub-workflow', + nodeLabel: 'Nested Workflow', + }, + { + referencingWorkflowId: 'inline-parent', + referencingWorkflowName: 'Inline Parent', + nodeId: 'sub-workflow', + nodeLabel: 'Nested Workflow', + }, + ]); + }); + + test('creates workflow-backed sessions using nested sub-workflow agents for model defaults', async () => { + const workspace = createWorkspaceSeed(); + const project = { + id: 'project-1', + name: 'Project', + path: 'C:\\workspace\\project-1', + addedAt: '2026-04-05T00:00:00.000Z', + }; + workspace.projects.push(project); + workspace.workflows.push( + createAgentWorkflow('child', 'Child Agent', 'agent-child'), + createSubWorkflow('parent', 'Parent Workflow', { workflowId: 'child' }), + ); + const service = createService(workspace); + + const result = await service.createWorkflowSession(project.id, 'parent'); + const session = result.sessions[0]; + + expect(session?.workflowId).toBe('parent'); + expect(session?.sessionModelConfig).toEqual({ + model: 'gpt-5.4', + reasoningEffort: 'medium', + }); + }); +}); diff --git a/tests/shared/workflow.test.ts b/tests/shared/workflow.test.ts index 3b48aab..20b8272 100644 --- a/tests/shared/workflow.test.ts +++ b/tests/shared/workflow.test.ts @@ -60,6 +60,57 @@ function createWorkflow(): WorkflowDefinition { }; } +function createReferencedSubWorkflow(): WorkflowDefinition { + return { + id: 'child-workflow', + name: 'Child Workflow', + description: 'Nested child workflow.', + createdAt: TIMESTAMP, + updatedAt: TIMESTAMP, + graph: { + nodes: [ + { + id: 'start', + kind: 'start', + label: 'Start', + position: { x: 0, y: 0 }, + config: { kind: 'start' }, + }, + { + id: 'agent-reviewer', + kind: 'agent', + label: 'Reviewer Agent', + position: { x: 200, y: 0 }, + order: 0, + config: { + kind: 'agent', + id: 'agent-reviewer', + name: 'Reviewer Agent', + description: 'Reviews the task.', + instructions: 'Review the task.', + model: 'gpt-5.4', + }, + }, + { + id: 'end', + kind: 'end', + label: 'End', + position: { x: 400, y: 0 }, + config: { kind: 'end' }, + }, + ], + edges: [ + { id: 'edge-start-reviewer', source: 'start', target: 'agent-reviewer', kind: 'direct' }, + { id: 'edge-reviewer-end', source: 'agent-reviewer', target: 'end', kind: 'direct' }, + ], + }, + settings: { + checkpointing: { enabled: false }, + executionMode: 'off-thread', + }, + }; +} + describe('workflow validation', () => { test('accepts a simple start-agent-end workflow', () => { expect(validateWorkflowDefinition(createWorkflow())).toEqual([]); @@ -84,6 +135,43 @@ describe('workflow validation', () => { issue.message.includes('not executable yet'))).toBe(true); }); + test('accepts sub-workflow nodes with inline workflows', () => { + const inlineWorkflow = createReferencedSubWorkflow(); + const workflow = createWorkflow(); + workflow.graph.nodes[1] = { + id: 'sub-workflow', + kind: 'sub-workflow', + label: 'Nested Workflow', + position: { x: 200, y: 0 }, + config: { + kind: 'sub-workflow', + inlineWorkflow, + }, + }; + 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' }; + + expect(validateWorkflowDefinition(workflow)).toEqual([]); + }); + + test('rejects sub-workflow nodes without exactly one workflow source', () => { + const workflow = createWorkflow(); + workflow.graph.nodes[1] = { + id: 'sub-workflow', + kind: 'sub-workflow', + label: 'Nested Workflow', + position: { x: 200, y: 0 }, + config: { + kind: 'sub-workflow', + }, + }; + 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' }; + + const issues = validateWorkflowDefinition(workflow); + expect(issues.some((issue) => issue.message.includes('exactly one of workflowId or inlineWorkflow'))).toBe(true); + }); + test('builds a synthetic execution pattern from workflow agents', () => { const pattern = buildWorkflowExecutionPattern(createWorkflow()); @@ -93,6 +181,30 @@ describe('workflow validation', () => { expect(pattern.graph?.nodes.map((node) => node.kind)).toEqual(['user-input', 'agent', 'user-output']); }); + test('includes referenced sub-workflow agents when building execution patterns', () => { + const childWorkflow = createReferencedSubWorkflow(); + const workflow = createWorkflow(); + workflow.graph.nodes[1] = { + id: 'sub-workflow', + kind: 'sub-workflow', + label: 'Nested Workflow', + position: { x: 200, y: 0 }, + config: { + kind: 'sub-workflow', + workflowId: childWorkflow.id, + }, + }; + 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' }; + + const pattern = buildWorkflowExecutionPattern(workflow, { + resolveWorkflow: (workflowId) => workflowId === childWorkflow.id ? childWorkflow : undefined, + }); + + expect(pattern.mode).toBe('single'); + expect(pattern.agents.map((agent) => agent.id)).toEqual(['agent-reviewer']); + }); + test('accepts simple property and expression conditions', () => { const workflow = createWorkflow(); workflow.graph.edges[0] = {