From c70a5c66128eeb6c39a50d9d67756a16da571c56 Mon Sep 17 00:00:00 2001 From: David Kaya Date: Wed, 8 Apr 2026 18:57:23 +0200 Subject: [PATCH] feat: show sub-workflow agents and lifecycle in the Activity panel Deep agent resolution in the sidecar now walks sub-workflow nodes so nested agents carry subworkflowNodeId and subworkflowName on activity events. New subworkflow-started / subworkflow-completed activity types let the frontend track sub-workflow lifecycle. The Activity panel groups nested agents under collapsible sub-workflow cards with status badges, accent-colored left borders, and smooth expand/collapse transitions. Cards auto-expand when a sub-workflow starts running. Workflows without sub-workflow nodes render identically to before. Extracted AgentRow, SubWorkflowGroup, and shared accent constants to a new components/activity/ feature directory. Added resolveWorkflowAgentHierarchy and buildGroupedActivityRows for hierarchical activity grouping with dynamic fallback for unresolved sub-workflow agents. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> --- ARCHITECTURE.md | 6 +- .../Contracts/ProtocolModels.cs | 2 + .../Services/AgentIdentityResolver.cs | 293 ++++++++++++++-- .../Services/AgentWorkflowTurnRunner.cs | 48 ++- .../Services/TurnExecutionState.cs | 83 ++++- .../Services/WorkflowDefinitionExtensions.cs | 117 ++++++- .../WorkflowRequestInfoInterpreter.cs | 17 +- .../AgentIdentityResolverTests.cs | 93 ++++- .../CopilotTurnExecutionStateTests.cs | 108 ++++++ .../CopilotWorkflowRunnerTests.cs | 131 +++++++- .../WorkflowRequestInfoInterpreterTests.cs | 104 +++++- src/main/AryxAppService.ts | 4 +- src/main/services/sessionTurnExecutor.ts | 4 +- src/renderer/App.tsx | 1 + src/renderer/components/ActivityPanel.tsx | 228 ++++--------- src/renderer/components/activity/AgentRow.tsx | 111 ++++++ .../components/activity/SubWorkflowGroup.tsx | 145 ++++++++ src/renderer/components/activity/constants.ts | 34 ++ src/renderer/lib/sessionActivity.ts | 119 ++++++- src/shared/contracts/sidecar.ts | 10 +- src/shared/domain/event.ts | 10 +- src/shared/domain/workflow.ts | 62 ++++ .../sessionTurnExecutorAgentActivity.test.ts | 255 ++++++++++++++ tests/renderer/sessionActivity.test.ts | 318 ++++++++++++++++++ 24 files changed, 2063 insertions(+), 240 deletions(-) create mode 100644 src/renderer/components/activity/AgentRow.tsx create mode 100644 src/renderer/components/activity/SubWorkflowGroup.tsx create mode 100644 src/renderer/components/activity/constants.ts create mode 100644 tests/main/sessionTurnExecutorAgentActivity.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 35bc4a3..e5a492c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -220,6 +220,8 @@ This protocol boundary keeps the AI execution runtime replaceable and prevents t The protocol also carries **turn-scoped lifecycle events** alongside output deltas. These events let the UI visualize execution internals without the main process having to interpret AI workflow semantics: +- **Workflow activity events**: agent activity records preserve agent, tool, and optional sub-workflow context (`subworkflowNodeId`, `subworkflowName`) so the UI can distinguish root-level activity from nested execution without rebuilding workflow ancestry in Electron +- **Sub-workflow lifecycle events**: `subworkflow-started` and `subworkflow-completed` are emitted when nested workflow executors begin and finish, so the Activity panel can surface sub-workflow groups as first-class runtime activity - **Sub-agent events**: started, completed, failed, selected, deselected — surfaced when custom agents are defined - **Skill invocation events**: emitted when an agent-side skill is triggered - **Message reclassification events**: let the sidecar retroactively mark a streamed assistant message as `thinking` once the SDK confirms that message requested tool work, so the UI can separate intermediate planning chatter from the final response without sacrificing live streaming @@ -318,8 +320,8 @@ For git-backed projects, the renderer surfaces three specialized components. `Ru The architecture treats execution as observable by design: - partial output is streamed -- agent activity is surfaced -- turn-scoped lifecycle events (sub-agent, hook, skill, compaction, usage) are streamed +- agent activity is surfaced with optional sub-workflow context +- turn-scoped lifecycle events (sub-agent, sub-workflow, hook, skill, compaction, usage) are streamed - runs are surfaced inline as collapsible turn activity panels - failures are represented explicitly diff --git a/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs b/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs index 90c6549..82ae5eb 100644 --- a/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs +++ b/sidecar/src/Aryx.AgentHost/Contracts/ProtocolModels.cs @@ -452,6 +452,8 @@ public sealed class AgentActivityEventDto : SidecarEventDto public string ActivityType { get; init; } = string.Empty; public string? AgentId { get; init; } public string? AgentName { get; init; } + public string? SubworkflowNodeId { get; init; } + public string? SubworkflowName { get; init; } public string? SourceAgentId { get; init; } public string? SourceAgentName { get; init; } public string? ToolName { get; init; } diff --git a/sidecar/src/Aryx.AgentHost/Services/AgentIdentityResolver.cs b/sidecar/src/Aryx.AgentHost/Services/AgentIdentityResolver.cs index 44bcb21..bb2f4b7 100644 --- a/sidecar/src/Aryx.AgentHost/Services/AgentIdentityResolver.cs +++ b/sidecar/src/Aryx.AgentHost/Services/AgentIdentityResolver.cs @@ -3,7 +3,12 @@ using Aryx.AgentHost.Contracts; namespace Aryx.AgentHost.Services; -internal readonly record struct AgentIdentity(string AgentId, string AgentName); +internal readonly record struct SubworkflowContext(string SubworkflowNodeId, string SubworkflowName); + +internal readonly record struct AgentIdentity( + string AgentId, + string AgentName, + SubworkflowContext? Subworkflow = null); internal static class AgentIdentityResolver { @@ -13,17 +18,69 @@ internal static class AgentIdentityResolver WorkflowDefinitionDto workflow, string? agentIdentifier, out AgentIdentity agent) + { + return TryResolveKnownAgentIdentity( + workflow, + WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(null), + agentIdentifier, + agentSubworkflowIndex: null, + out agent); + } + + public static bool TryResolveKnownAgentIdentity( + WorkflowDefinitionDto workflow, + IReadOnlyList? workflowLibrary, + string? agentIdentifier, + out AgentIdentity agent) + { + return TryResolveKnownAgentIdentity( + workflow, + WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(workflowLibrary), + agentIdentifier, + agentSubworkflowIndex: null, + out agent); + } + + internal static bool TryResolveKnownAgentIdentity( + WorkflowDefinitionDto workflow, + IReadOnlyDictionary workflowLibrary, + string? agentIdentifier, + IReadOnlyDictionary? agentSubworkflowIndex, + out AgentIdentity agent) { agent = default; - WorkflowNodeDto? match = FindKnownAgent(workflow, agentIdentifier) - ?? ResolveSingleAgentAssistantAlias(workflow, agentIdentifier); - if (match is null) + WorkflowNodeDto? shallowMatch = FindKnownAgent(workflow.GetAgentNodes(), agentIdentifier); + if (shallowMatch is not null) + { + agent = ToAgentIdentity(shallowMatch); + return true; + } + + WorkflowNodeDto? deepMatch = FindKnownAgent(workflow.GetAllAgentNodes(workflowLibrary), agentIdentifier); + if (deepMatch is not null) + { + IReadOnlyDictionary subworkflowIndex = agentSubworkflowIndex + ?? BuildAgentSubworkflowIndex(workflow, workflowLibrary); + agent = ToAgentIdentity(deepMatch, subworkflowIndex); + return true; + } + + WorkflowNodeDto? aliasMatch = ResolveSingleAgentAssistantAlias(workflow, workflowLibrary, agentIdentifier); + if (aliasMatch is null) { return false; } - agent = ToAgentIdentity(match); + if (workflow.GetAgentNodes().Contains(aliasMatch)) + { + agent = ToAgentIdentity(aliasMatch); + return true; + } + + IReadOnlyDictionary aliasSubworkflowIndex = agentSubworkflowIndex + ?? BuildAgentSubworkflowIndex(workflow, workflowLibrary); + agent = ToAgentIdentity(aliasMatch, aliasSubworkflowIndex); return true; } @@ -33,7 +90,24 @@ internal static class AgentIdentityResolver AgentIdentity? fallbackAgent, out AgentIdentity agent) { - if (TryResolveKnownAgentIdentity(workflow, agentIdentifier, out agent)) + return TryResolveObservedAgentIdentity( + workflow, + WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(null), + agentIdentifier, + fallbackAgent, + agentSubworkflowIndex: null, + out agent); + } + + internal static bool TryResolveObservedAgentIdentity( + WorkflowDefinitionDto workflow, + IReadOnlyDictionary workflowLibrary, + string? agentIdentifier, + AgentIdentity? fallbackAgent, + IReadOnlyDictionary? agentSubworkflowIndex, + out AgentIdentity agent) + { + if (TryResolveKnownAgentIdentity(workflow, workflowLibrary, agentIdentifier, agentSubworkflowIndex, out agent)) { return true; } @@ -53,13 +127,46 @@ internal static class AgentIdentityResolver string? agentId, string? agentName) { - WorkflowNodeDto? match = FindKnownAgent(workflow, agentId) - ?? FindKnownAgent(workflow, agentName) - ?? ResolveSingleAgentAssistantAlias(workflow, agentId, agentName); + return ResolveAgentIdentity( + workflow, + WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(null), + agentId, + agentName, + agentSubworkflowIndex: null); + } - return match is not null - ? ToAgentIdentity(match) - : CreateFallbackIdentity(agentId, agentName); + public static AgentIdentity ResolveAgentIdentity( + WorkflowDefinitionDto workflow, + IReadOnlyList? workflowLibrary, + string? agentId, + string? agentName) + { + return ResolveAgentIdentity( + workflow, + WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(workflowLibrary), + agentId, + agentName, + agentSubworkflowIndex: null); + } + + internal static AgentIdentity ResolveAgentIdentity( + WorkflowDefinitionDto workflow, + IReadOnlyDictionary workflowLibrary, + string? agentId, + string? agentName, + IReadOnlyDictionary? agentSubworkflowIndex) + { + if (TryResolveKnownAgentIdentity(workflow, workflowLibrary, agentId, agentSubworkflowIndex, out AgentIdentity resolvedById)) + { + return resolvedById; + } + + if (TryResolveKnownAgentIdentity(workflow, workflowLibrary, agentName, agentSubworkflowIndex, out AgentIdentity resolvedByName)) + { + return resolvedByName; + } + + return CreateFallbackIdentity(agentId, agentName, agentSubworkflowIndex); } public static string ResolveDisplayAuthorName( @@ -90,6 +197,53 @@ internal static class AgentIdentityResolver return GenericAssistantIdentifier; } + public static IReadOnlyDictionary BuildAgentSubworkflowIndex( + WorkflowDefinitionDto workflow, + IReadOnlyList? workflowLibrary = null) + { + return BuildAgentSubworkflowIndex( + workflow, + WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(workflowLibrary)); + } + + internal static IReadOnlyDictionary BuildAgentSubworkflowIndex( + WorkflowDefinitionDto workflow, + IReadOnlyDictionary workflowLibrary) + { + ArgumentNullException.ThrowIfNull(workflow); + ArgumentNullException.ThrowIfNull(workflowLibrary); + + Dictionary index = new(StringComparer.Ordinal); + CollectAgentSubworkflowContexts( + workflow, + workflowLibrary, + currentSubworkflow: null, + index, + new HashSet(StringComparer.Ordinal), + new HashSet(ReferenceEqualityComparer.Instance)); + return index; + } + + internal static bool TryResolveSubworkflowContext( + WorkflowDefinitionDto workflow, + IReadOnlyDictionary workflowLibrary, + string? subworkflowNodeId, + out SubworkflowContext context) + { + ArgumentNullException.ThrowIfNull(workflow); + ArgumentNullException.ThrowIfNull(workflowLibrary); + + context = default; + WorkflowNodeDto? node = workflow.FindSubWorkflowNode(subworkflowNodeId, workflowLibrary); + if (node is null) + { + return false; + } + + context = CreateSubworkflowContext(node, workflowLibrary); + return true; + } + internal static bool IsGenericAssistantIdentifier(string? candidate) { return string.Equals( @@ -100,34 +254,120 @@ internal static class AgentIdentityResolver private static WorkflowNodeDto? ResolveSingleAgentAssistantAlias( WorkflowDefinitionDto workflow, + IReadOnlyDictionary workflowLibrary, params string?[] agentIdentifiers) { - IReadOnlyList agentNodes = workflow.GetAgentNodes(); - return agentNodes.Count == 1 && agentIdentifiers.Any(IsGenericAssistantIdentifier) - ? agentNodes[0] - : null; + if (!agentIdentifiers.Any(IsGenericAssistantIdentifier)) + { + return null; + } + + IReadOnlyList topLevelAgents = workflow.GetAgentNodes(); + if (topLevelAgents.Count == 1) + { + return topLevelAgents[0]; + } + + IReadOnlyList allAgents = workflow.GetAllAgentNodes(workflowLibrary); + return allAgents.Count == 1 ? allAgents[0] : null; } - private static WorkflowNodeDto? FindKnownAgent(WorkflowDefinitionDto workflow, string? candidate) + private static WorkflowNodeDto? FindKnownAgent( + IEnumerable agents, + string? candidate) { - return workflow.GetAgentNodes().FirstOrDefault(agent => MatchesAgent(agent, candidate)); + return agents.FirstOrDefault(agent => MatchesAgent(agent, candidate)); } private static AgentIdentity ToAgentIdentity(WorkflowNodeDto agent) => new(agent.GetAgentId(), agent.GetAgentName()); - private static AgentIdentity CreateFallbackIdentity(string? agentId, string? agentName) + private static AgentIdentity ToAgentIdentity( + WorkflowNodeDto agent, + IReadOnlyDictionary agentSubworkflowIndex) { - string resolvedAgentId = !string.IsNullOrWhiteSpace(agentId) - ? agentId - : agentName ?? "agent"; - string resolvedAgentName = !string.IsNullOrWhiteSpace(agentName) - ? agentName - : resolvedAgentId; + string agentId = agent.GetAgentId(); + return agentSubworkflowIndex.TryGetValue(agentId, out SubworkflowContext subworkflow) + ? new AgentIdentity(agentId, agent.GetAgentName(), subworkflow) + : new AgentIdentity(agentId, agent.GetAgentName()); + } + + private static AgentIdentity CreateFallbackIdentity( + string? agentId, + string? agentName, + IReadOnlyDictionary? agentSubworkflowIndex) + { + string resolvedAgentId = NormalizeOptionalString(agentId) + ?? NormalizeOptionalString(agentName) + ?? "agent"; + string resolvedAgentName = NormalizeOptionalString(agentName) + ?? resolvedAgentId; + + if (agentSubworkflowIndex is not null + && agentSubworkflowIndex.TryGetValue(resolvedAgentId, out SubworkflowContext subworkflow)) + { + return new AgentIdentity(resolvedAgentId, resolvedAgentName, subworkflow); + } return new AgentIdentity(resolvedAgentId, resolvedAgentName); } + private static void CollectAgentSubworkflowContexts( + WorkflowDefinitionDto workflowDefinition, + IReadOnlyDictionary workflowLibrary, + SubworkflowContext? currentSubworkflow, + Dictionary index, + ISet visitedWorkflowIds, + ISet visitedAnonymousWorkflows) + { + string? workflowId = NormalizeOptionalString(workflowDefinition.Id); + if (workflowId is not null) + { + if (!visitedWorkflowIds.Add(workflowId)) + { + return; + } + } + else if (!visitedAnonymousWorkflows.Add(workflowDefinition)) + { + return; + } + + foreach (WorkflowNodeDto node in workflowDefinition.Graph.Nodes) + { + if (node.IsAgentNode()) + { + if (currentSubworkflow.HasValue) + { + index[node.GetAgentId()] = currentSubworkflow.Value; + } + + continue; + } + + if (!node.IsSubWorkflowNode()) + { + continue; + } + + WorkflowDefinitionDto subWorkflow = node.ResolveSubWorkflowDefinition(workflowLibrary); + CollectAgentSubworkflowContexts( + subWorkflow, + workflowLibrary, + CreateSubworkflowContext(node, workflowLibrary), + index, + visitedWorkflowIds, + visitedAnonymousWorkflows); + } + } + + private static SubworkflowContext CreateSubworkflowContext( + WorkflowNodeDto node, + IReadOnlyDictionary workflowLibrary) + { + return new SubworkflowContext(node.Id, node.GetSubworkflowDisplayName(workflowLibrary)); + } + private static bool MatchesAgent(WorkflowNodeDto agent, string? candidate) { if (string.IsNullOrWhiteSpace(candidate)) @@ -164,6 +404,9 @@ internal static class AgentIdentityResolver && normalizedCandidate.Contains(normalizedName, StringComparison.Ordinal)); } + private static string? NormalizeOptionalString(string? value) + => string.IsNullOrWhiteSpace(value) ? null : value.Trim(); + private static string NormalizeComparisonKey(string? value) { if (string.IsNullOrWhiteSpace(value)) diff --git a/sidecar/src/Aryx.AgentHost/Services/AgentWorkflowTurnRunner.cs b/sidecar/src/Aryx.AgentHost/Services/AgentWorkflowTurnRunner.cs index f1364e7..3b0a025 100644 --- a/sidecar/src/Aryx.AgentHost/Services/AgentWorkflowTurnRunner.cs +++ b/sidecar/src/Aryx.AgentHost/Services/AgentWorkflowTurnRunner.cs @@ -297,14 +297,21 @@ public class AgentWorkflowTurnRunner : ITurnWorkflowRunner { if (evt is ExecutorInvokedEvent invoked) { - if (AgentIdentityResolver.TryResolveKnownAgentIdentity( - command.Workflow, - invoked.ExecutorId, - out AgentIdentity invokedAgent)) + if (state.TryResolveKnownAgentIdentity(invoked.ExecutorId, out AgentIdentity invokedAgent)) { TraceHandoff(command, $"Executor invoked: {invoked.ExecutorId} -> {invokedAgent.AgentName} ({invokedAgent.AgentId})."); await state.EmitThinkingIfNeeded(invokedAgent, onEvent).ConfigureAwait(false); } + else if (state.TryCreateSubworkflowLifecycleActivity( + "subworkflow-started", + invoked.ExecutorId, + out AgentActivityEventDto subworkflowStarted)) + { + TraceHandoff( + command, + $"Sub-workflow executor invoked: {invoked.ExecutorId} -> {subworkflowStarted.SubworkflowName ?? subworkflowStarted.SubworkflowNodeId ?? ""}."); + await EmitActivityAsync(command, state, subworkflowStarted, onEvent).ConfigureAwait(false); + } else { TraceHandoff(command, $"Executor invoked without a known agent match: {invoked.ExecutorId}."); @@ -355,16 +362,22 @@ public class AgentWorkflowTurnRunner : ITurnWorkflowRunner if (evt is ExecutorCompletedEvent completed) { - if (AgentIdentityResolver.TryResolveObservedAgentIdentity( - command.Workflow, - completed.ExecutorId, - state.ActiveAgent, - out AgentIdentity completedAgent)) + if (state.TryResolveObservedAgentIdentity(completed.ExecutorId, state.ActiveAgent, out AgentIdentity completedAgent)) { TraceHandoff(command, $"Executor completed: {completed.ExecutorId} -> {completedAgent.AgentName} ({completedAgent.AgentId})."); state.QueueCompletedActivity(completedAgent); state.ClearActiveAgentIfMatching(completedAgent); } + else if (state.TryCreateSubworkflowLifecycleActivity( + "subworkflow-completed", + completed.ExecutorId, + out AgentActivityEventDto subworkflowCompleted)) + { + TraceHandoff( + command, + $"Sub-workflow executor completed: {completed.ExecutorId} -> {subworkflowCompleted.SubworkflowName ?? subworkflowCompleted.SubworkflowNodeId ?? ""}."); + await EmitActivityAsync(command, state, subworkflowCompleted, onEvent).ConfigureAwait(false); + } else { TraceHandoff(command, $"Executor completed without a known agent match: {completed.ExecutorId}."); @@ -470,11 +483,10 @@ public class AgentWorkflowTurnRunner : ITurnWorkflowRunner updateAgent = observedMessageAgent; authorName = observedMessageAgent.AgentName; } - else if (AgentIdentityResolver.TryResolveObservedAgentIdentity( - command.Workflow, - update.ExecutorId, - state.ActiveAgent, - out AgentIdentity resolvedUpdateAgent)) + else if (state.TryResolveObservedAgentIdentity( + update.ExecutorId, + state.ActiveAgent, + out AgentIdentity resolvedUpdateAgent)) { updateAgent = resolvedUpdateAgent; authorName = resolvedUpdateAgent.AgentName; @@ -545,11 +557,12 @@ public class AgentWorkflowTurnRunner : ITurnWorkflowRunner && !string.IsNullOrWhiteSpace(activity.AgentId) && !string.IsNullOrWhiteSpace(activity.AgentName)) { + AgentIdentity promotedAgent = state.ResolveAgentIdentity(activity.AgentId, activity.AgentName); TraceHandoff( command, - $"Promoting handoff target to thinking: {activity.AgentName} ({activity.AgentId})."); + $"Promoting handoff target to thinking: {promotedAgent.AgentName} ({promotedAgent.AgentId})."); await state.EmitThinkingIfNeeded( - new AgentIdentity(activity.AgentId, activity.AgentName), + promotedAgent, onEvent).ConfigureAwait(false); } } @@ -593,8 +606,7 @@ public class AgentWorkflowTurnRunner : ITurnWorkflowRunner { case ExecutorFailedEvent executorFailed: { - AgentIdentity? agent = AgentIdentityResolver.TryResolveObservedAgentIdentity( - command.Workflow, + AgentIdentity? agent = state.TryResolveObservedAgentIdentity( executorFailed.ExecutorId, state.ActiveAgent, out AgentIdentity resolvedAgent) diff --git a/sidecar/src/Aryx.AgentHost/Services/TurnExecutionState.cs b/sidecar/src/Aryx.AgentHost/Services/TurnExecutionState.cs index 00bdb6c..5b7592c 100644 --- a/sidecar/src/Aryx.AgentHost/Services/TurnExecutionState.cs +++ b/sidecar/src/Aryx.AgentHost/Services/TurnExecutionState.cs @@ -7,6 +7,8 @@ namespace Aryx.AgentHost.Services; internal class TurnExecutionState { private readonly RunTurnCommandDto _command; + private readonly IReadOnlyDictionary _workflowLibrary; + private readonly IReadOnlyDictionary _agentSubworkflowIndex; private readonly HashSet _startedAgents = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet _reclassifiedMessageIds = new(StringComparer.Ordinal); private readonly ConcurrentQueue _pendingEvents = new(); @@ -19,6 +21,8 @@ internal class TurnExecutionState public TurnExecutionState(RunTurnCommandDto command) { _command = command; + _workflowLibrary = WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(command.WorkflowLibrary); + _agentSubworkflowIndex = AgentIdentityResolver.BuildAgentSubworkflowIndex(command.Workflow, _workflowLibrary); } public ConcurrentDictionary ToolNamesByCallId { get; } = new(StringComparer.Ordinal); @@ -33,6 +37,59 @@ internal class TurnExecutionState public bool SuppressHookLifecycleEvents { get; set; } + public AgentIdentity ResolveAgentIdentity(string? agentId, string? agentName) + { + return AgentIdentityResolver.ResolveAgentIdentity( + _command.Workflow, + _workflowLibrary, + agentId, + agentName, + _agentSubworkflowIndex); + } + + public bool TryResolveKnownAgentIdentity(string? agentIdentifier, out AgentIdentity agent) + { + return AgentIdentityResolver.TryResolveKnownAgentIdentity( + _command.Workflow, + _workflowLibrary, + agentIdentifier, + _agentSubworkflowIndex, + out agent); + } + + public bool TryResolveObservedAgentIdentity( + string? agentIdentifier, + AgentIdentity? fallbackAgent, + out AgentIdentity agent) + { + return AgentIdentityResolver.TryResolveObservedAgentIdentity( + _command.Workflow, + _workflowLibrary, + agentIdentifier, + fallbackAgent, + _agentSubworkflowIndex, + out agent); + } + + public bool TryCreateSubworkflowLifecycleActivity( + string activityType, + string? executorId, + out AgentActivityEventDto activity) + { + activity = default!; + if (!AgentIdentityResolver.TryResolveSubworkflowContext( + _command.Workflow, + _workflowLibrary, + executorId, + out SubworkflowContext subworkflow)) + { + return false; + } + + activity = CreateSubworkflowActivity(activityType, subworkflow); + return true; + } + public async Task EmitThinkingIfNeeded( AgentIdentity agent, Func onEvent) @@ -67,14 +124,13 @@ internal class TurnExecutionState && !string.IsNullOrWhiteSpace(activity.AgentId) && !string.IsNullOrWhiteSpace(activity.AgentName)) { - ActiveAgent = new AgentIdentity(activity.AgentId, activity.AgentName); + ActiveAgent = ResolveAgentIdentity(activity.AgentId, activity.AgentName); } } public void ObserveSessionEvent(WorkflowNodeDto agentDefinition, ProviderSessionEvent sessionEvent) { - AgentIdentity agent = AgentIdentityResolver.ResolveAgentIdentity( - _command.Workflow, + AgentIdentity agent = ResolveAgentIdentity( agentDefinition.GetAgentId(), agentDefinition.GetAgentName()); @@ -313,6 +369,8 @@ internal class TurnExecutionState ActivityType = "thinking", AgentId = agent.AgentId, AgentName = agent.AgentName, + SubworkflowNodeId = agent.Subworkflow?.SubworkflowNodeId, + SubworkflowName = agent.Subworkflow?.SubworkflowName, }; } @@ -335,6 +393,8 @@ internal class TurnExecutionState ActivityType = "tool-calling", AgentId = agent.AgentId, AgentName = agent.AgentName, + SubworkflowNodeId = agent.Subworkflow?.SubworkflowNodeId, + SubworkflowName = agent.Subworkflow?.SubworkflowName, ToolName = toolName, ToolCallId = toolCallId, ToolArguments = toolArguments, @@ -351,6 +411,23 @@ internal class TurnExecutionState ActivityType = "completed", AgentId = agent.AgentId, AgentName = agent.AgentName, + SubworkflowNodeId = agent.Subworkflow?.SubworkflowNodeId, + SubworkflowName = agent.Subworkflow?.SubworkflowName, + }; + } + + private AgentActivityEventDto CreateSubworkflowActivity( + string activityType, + SubworkflowContext subworkflow) + { + return new AgentActivityEventDto + { + Type = "agent-activity", + RequestId = _command.RequestId, + SessionId = _command.SessionId, + ActivityType = activityType, + SubworkflowNodeId = subworkflow.SubworkflowNodeId, + SubworkflowName = subworkflow.SubworkflowName, }; } diff --git a/sidecar/src/Aryx.AgentHost/Services/WorkflowDefinitionExtensions.cs b/sidecar/src/Aryx.AgentHost/Services/WorkflowDefinitionExtensions.cs index e22c645..ca732b3 100644 --- a/sidecar/src/Aryx.AgentHost/Services/WorkflowDefinitionExtensions.cs +++ b/sidecar/src/Aryx.AgentHost/Services/WorkflowDefinitionExtensions.cs @@ -45,6 +45,12 @@ internal static class WorkflowDefinitionExtensions return string.Equals(node.Kind, "agent", StringComparison.OrdinalIgnoreCase); } + public static bool IsSubWorkflowNode(this WorkflowNodeDto node) + { + ArgumentNullException.ThrowIfNull(node); + return string.Equals(node.Kind, "sub-workflow", StringComparison.OrdinalIgnoreCase); + } + public static string GetAgentId(this WorkflowNodeDto node) { ArgumentNullException.ThrowIfNull(node); @@ -87,6 +93,67 @@ internal static class WorkflowDefinitionExtensions return string.Equals(workflow.Settings.OrchestrationMode, mode, StringComparison.OrdinalIgnoreCase); } + public static WorkflowNodeDto? FindSubWorkflowNode( + this WorkflowDefinitionDto workflow, + string? nodeId, + IReadOnlyList? workflowLibrary = null) + { + ArgumentNullException.ThrowIfNull(workflow); + string? normalizedNodeId = NormalizeOptionalString(nodeId); + if (normalizedNodeId is null) + { + return null; + } + + return FindSubWorkflowNode( + workflow, + normalizedNodeId, + CreateWorkflowLibraryMap(workflowLibrary), + new HashSet(StringComparer.Ordinal), + new HashSet(ReferenceEqualityComparer.Instance)); + } + + internal static WorkflowNodeDto? FindSubWorkflowNode( + this WorkflowDefinitionDto workflow, + string? nodeId, + IReadOnlyDictionary? workflowLibrary) + { + ArgumentNullException.ThrowIfNull(workflow); + string? normalizedNodeId = NormalizeOptionalString(nodeId); + if (normalizedNodeId is null) + { + return null; + } + + return FindSubWorkflowNode( + workflow, + normalizedNodeId, + workflowLibrary ?? EmptyWorkflowLibrary, + new HashSet(StringComparer.Ordinal), + new HashSet(ReferenceEqualityComparer.Instance)); + } + + internal static string GetSubworkflowDisplayName( + this WorkflowNodeDto node, + IReadOnlyDictionary? workflowLibrary) + { + ArgumentNullException.ThrowIfNull(node); + + WorkflowDefinitionDto? resolvedWorkflow = null; + if (node.Config.InlineWorkflow is not null) + { + resolvedWorkflow = node.Config.InlineWorkflow; + } + else if (!string.IsNullOrWhiteSpace(node.Config.WorkflowId) + && workflowLibrary is not null + && workflowLibrary.TryGetValue(node.Config.WorkflowId, out WorkflowDefinitionDto? workflow)) + { + resolvedWorkflow = workflow; + } + + return FirstNonBlank(node.Label, resolvedWorkflow?.Name, node.Config.WorkflowId, node.Id) ?? "sub-workflow"; + } + private static readonly IReadOnlyDictionary EmptyWorkflowLibrary = new Dictionary(StringComparer.Ordinal); @@ -128,7 +195,55 @@ internal static class WorkflowDefinitionExtensions } } - private static Dictionary CreateWorkflowLibraryMap( + private static WorkflowNodeDto? FindSubWorkflowNode( + WorkflowDefinitionDto workflowDefinition, + string nodeId, + IReadOnlyDictionary workflowLibrary, + ISet visitedWorkflowIds, + ISet visitedAnonymousWorkflows) + { + string? workflowId = NormalizeOptionalString(workflowDefinition.Id); + if (workflowId is not null) + { + if (!visitedWorkflowIds.Add(workflowId)) + { + return null; + } + } + else if (!visitedAnonymousWorkflows.Add(workflowDefinition)) + { + return null; + } + + foreach (WorkflowNodeDto node in workflowDefinition.Graph.Nodes) + { + if (!node.IsSubWorkflowNode()) + { + continue; + } + + if (string.Equals(node.Id, nodeId, StringComparison.OrdinalIgnoreCase)) + { + return node; + } + + WorkflowDefinitionDto subWorkflow = node.ResolveSubWorkflowDefinition(workflowLibrary); + WorkflowNodeDto? match = FindSubWorkflowNode( + subWorkflow, + nodeId, + workflowLibrary, + visitedWorkflowIds, + visitedAnonymousWorkflows); + if (match is not null) + { + return match; + } + } + + return null; + } + + internal static Dictionary CreateWorkflowLibraryMap( IReadOnlyList? workflowLibrary) { return workflowLibrary? diff --git a/sidecar/src/Aryx.AgentHost/Services/WorkflowRequestInfoInterpreter.cs b/sidecar/src/Aryx.AgentHost/Services/WorkflowRequestInfoInterpreter.cs index 4bcaa06..a55ebd6 100644 --- a/sidecar/src/Aryx.AgentHost/Services/WorkflowRequestInfoInterpreter.cs +++ b/sidecar/src/Aryx.AgentHost/Services/WorkflowRequestInfoInterpreter.cs @@ -23,7 +23,7 @@ internal static class WorkflowRequestInfoInterpreter ConcurrentDictionary toolNamesByCallId, ConcurrentDictionary toolCallHasArgumentsById) { - RequestInterpretation interpretation = InterpretRequest(command.Workflow, requestInfo); + RequestInterpretation interpretation = InterpretRequest(command, requestInfo); return interpretation switch { HandoffRequestInterpretation handoff => @@ -39,7 +39,7 @@ internal static class WorkflowRequestInfoInterpreter RequestInfoEvent requestInfo) { return command.Workflow.IsOrchestrationMode("handoff") - && InterpretRequest(command.Workflow, requestInfo) is UnknownRequestInterpretation; + && InterpretRequest(command, requestInfo) is UnknownRequestInterpretation; } private static AgentActivityEventDto CreateHandoffActivity( @@ -55,6 +55,8 @@ internal static class WorkflowRequestInfoInterpreter ActivityType = HandoffActivityType, AgentId = handoffAgent.AgentId, AgentName = handoffAgent.AgentName, + SubworkflowNodeId = handoffAgent.Subworkflow?.SubworkflowNodeId, + SubworkflowName = handoffAgent.Subworkflow?.SubworkflowName, SourceAgentId = activeAgent?.AgentId, SourceAgentName = activeAgent?.AgentName, }; @@ -88,6 +90,8 @@ internal static class WorkflowRequestInfoInterpreter ActivityType = ToolCallingActivityType, AgentId = activeAgent.AgentId, AgentName = activeAgent.AgentName, + SubworkflowNodeId = activeAgent.Subworkflow?.SubworkflowNodeId, + SubworkflowName = activeAgent.Subworkflow?.SubworkflowName, ToolName = tool.ToolName, ToolCallId = tool.ToolCallId, ToolArguments = tool.ToolArguments, @@ -109,10 +113,10 @@ internal static class WorkflowRequestInfoInterpreter } private static RequestInterpretation InterpretRequest( - WorkflowDefinitionDto workflow, + RunTurnCommandDto command, RequestInfoEvent requestInfo) { - if (TryGetHandoffTarget(workflow, requestInfo, out AgentIdentity handoffAgent)) + if (TryGetHandoffTarget(command, requestInfo, out AgentIdentity handoffAgent)) { return new HandoffRequestInterpretation(handoffAgent); } @@ -123,7 +127,7 @@ internal static class WorkflowRequestInfoInterpreter } private static bool TryGetHandoffTarget( - WorkflowDefinitionDto workflow, + RunTurnCommandDto command, RequestInfoEvent requestInfo, out AgentIdentity agent) { @@ -142,7 +146,8 @@ internal static class WorkflowRequestInfoInterpreter } agent = AgentIdentityResolver.ResolveAgentIdentity( - workflow, + command.Workflow, + command.WorkflowLibrary, target.Id, target.Name); return !string.IsNullOrWhiteSpace(agent.AgentName); diff --git a/sidecar/tests/Aryx.AgentHost.Tests/AgentIdentityResolverTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/AgentIdentityResolverTests.cs index 77c2df8..ef40495 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/AgentIdentityResolverTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/AgentIdentityResolverTests.cs @@ -115,19 +115,86 @@ public sealed class AgentIdentityResolverTests Assert.Equal("UX Specialist", agent.AgentName); } + [Fact] + public void TryResolveKnownAgentIdentity_ResolvesReferencedSubworkflowAgentWithContext() + { + WorkflowDefinitionDto nestedWorkflow = CreateWorkflow( + "nested-review-workflow", + [ + CreateAgent("agent-reviewer", "Reviewer"), + ], + orchestrationMode: "single"); + WorkflowDefinitionDto workflow = CreateWorkflow( + "parent-workflow", + [ + CreateSubworkflow("subworkflow-review", "Review Lane", workflowId: nestedWorkflow.Id), + ], + orchestrationMode: "single"); + + bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity( + workflow, + [nestedWorkflow], + "Reviewer_agent_reviewer", + out AgentIdentity agent); + + Assert.True(resolved); + Assert.Equal("agent-reviewer", agent.AgentId); + Assert.Equal("Reviewer", agent.AgentName); + Assert.Equal("subworkflow-review", agent.Subworkflow?.SubworkflowNodeId); + Assert.Equal("Review Lane", agent.Subworkflow?.SubworkflowName); + } + + [Fact] + public void BuildAgentSubworkflowIndex_UsesImmediateNestedSubworkflowContext() + { + WorkflowDefinitionDto innerWorkflow = CreateWorkflow( + "inner-workflow", + [ + CreateAgent("agent-inner-reviewer", "Inner Reviewer"), + ], + orchestrationMode: "single"); + WorkflowDefinitionDto outerWorkflow = CreateWorkflow( + "outer-workflow", + [ + CreateSubworkflow("subworkflow-inner", "Inner Review", inlineWorkflow: innerWorkflow), + ], + orchestrationMode: "single"); + WorkflowDefinitionDto workflow = CreateWorkflow( + "parent-workflow", + [ + CreateSubworkflow("subworkflow-outer", "Outer Review", inlineWorkflow: outerWorkflow), + ], + orchestrationMode: "single"); + + IReadOnlyDictionary index = + AgentIdentityResolver.BuildAgentSubworkflowIndex(workflow); + + Assert.True(index.TryGetValue("agent-inner-reviewer", out SubworkflowContext subworkflow)); + Assert.Equal("subworkflow-inner", subworkflow.SubworkflowNodeId); + Assert.Equal("Inner Review", subworkflow.SubworkflowName); + } + private static WorkflowDefinitionDto CreateWorkflow( - IReadOnlyList agents, + IReadOnlyList nodes, + string orchestrationMode = "concurrent") + { + return CreateWorkflow($"{orchestrationMode}-workflow", nodes, orchestrationMode); + } + + private static WorkflowDefinitionDto CreateWorkflow( + string id, + IReadOnlyList nodes, string orchestrationMode = "concurrent") { return new WorkflowDefinitionDto { - Id = $"{orchestrationMode}-workflow", + Id = id, Name = "Workflow", Graph = new WorkflowGraphDto { Nodes = [ - .. agents, + .. nodes, ], }, Settings = new WorkflowSettingsDto @@ -154,4 +221,24 @@ public sealed class AgentIdentityResolverTests }, }; } + + private static WorkflowNodeDto CreateSubworkflow( + string id, + string label, + string? workflowId = null, + WorkflowDefinitionDto? inlineWorkflow = null) + { + return new WorkflowNodeDto + { + Id = id, + Kind = "sub-workflow", + Label = label, + Config = new WorkflowNodeConfigDto + { + Kind = "sub-workflow", + WorkflowId = workflowId, + InlineWorkflow = inlineWorkflow, + }, + }; + } } diff --git a/sidecar/tests/Aryx.AgentHost.Tests/CopilotTurnExecutionStateTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/CopilotTurnExecutionStateTests.cs index 9a957f7..f8b617f 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/CopilotTurnExecutionStateTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/CopilotTurnExecutionStateTests.cs @@ -59,6 +59,40 @@ public sealed class CopilotTurnExecutionStateTests Assert.Equal("agent-1", observedAgent.AgentId); } + [Fact] + public void ObserveSessionEvent_AssistantMessageDelta_ForNestedAgent_IncludesSubworkflowContext() + { + RunTurnCommandDto command = CreateCommandWithReferencedSubworkflow(); + CopilotTurnExecutionState state = new(command); + WorkflowDefinitionDto nestedWorkflow = Assert.Single(command.WorkflowLibrary!); + WorkflowNodeDto nestedAgent = Assert.Single(nestedWorkflow.GetAgentNodes()); + + state.ObserveSessionEvent( + nestedAgent, + SessionEvent.FromJson( + """ + { + "type": "assistant.message_delta", + "data": { + "messageId": "msg-nested-1", + "deltaContent": "Reviewing" + }, + "id": "7ef95d90-7ee7-45e2-ac38-cf749caf4f69", + "timestamp": "2026-03-27T00:00:00Z" + } + """)); + + AgentActivityEventDto activity = Assert.Single(state.DrainPendingEvents().OfType()); + Assert.Equal("thinking", activity.ActivityType); + Assert.Equal("agent-reviewer", activity.AgentId); + Assert.Equal("Reviewer", activity.AgentName); + Assert.Equal("subworkflow-review", activity.SubworkflowNodeId); + Assert.Equal("Review Lane", activity.SubworkflowName); + Assert.True(state.ActiveAgent.HasValue); + Assert.Equal("subworkflow-review", state.ActiveAgent.Value.Subworkflow?.SubworkflowNodeId); + Assert.Equal("Review Lane", state.ActiveAgent.Value.Subworkflow?.SubworkflowName); + } + [Fact] public void ObserveSessionEvent_ToolExecutionStart_TracksToolNameByCallIdAndQueuesToolActivity() { @@ -746,5 +780,79 @@ public sealed class CopilotTurnExecutionStateTests }, }; } + + private static RunTurnCommandDto CreateCommandWithReferencedSubworkflow() + { + WorkflowDefinitionDto nestedWorkflow = CreateWorkflow( + "nested-review-workflow", + [ + CreateAgent("agent-reviewer", "Reviewer"), + ]); + + return new RunTurnCommandDto + { + RequestId = "turn-1", + SessionId = "session-1", + WorkflowLibrary = [nestedWorkflow], + Workflow = CreateWorkflow( + "workflow-parent", + [ + CreateSubworkflow("subworkflow-review", "Review Lane", workflowId: nestedWorkflow.Id), + ]), + }; + } + + private static WorkflowDefinitionDto CreateWorkflow(string id, IReadOnlyList nodes) + { + return new WorkflowDefinitionDto + { + Id = id, + Name = "Execution State Workflow", + Graph = new WorkflowGraphDto + { + Nodes = [.. nodes], + }, + Settings = new WorkflowSettingsDto + { + OrchestrationMode = "single", + }, + }; + } + + private static WorkflowNodeDto CreateAgent(string id, string name) + { + return new WorkflowNodeDto + { + Id = id, + Kind = "agent", + Label = name, + Config = new WorkflowNodeConfigDto + { + Kind = "agent", + Id = id, + Name = name, + Model = "gpt-5.4", + Instructions = "Help with the request.", + }, + }; + } + + private static WorkflowNodeDto CreateSubworkflow( + string id, + string label, + string? workflowId = null) + { + return new WorkflowNodeDto + { + Id = id, + Kind = "sub-workflow", + Label = label, + Config = new WorkflowNodeConfigDto + { + Kind = "sub-workflow", + WorkflowId = workflowId, + }, + }; + } } diff --git a/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs index b4bbdb1..4bb83e9 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/CopilotWorkflowRunnerTests.cs @@ -1052,6 +1052,78 @@ public sealed class CopilotWorkflowRunnerTests Assert.Equal("Primary", completed.AgentName); } + [Fact] + public async Task HandleWorkflowEventAsync_EmitsSubworkflowStartedActivityForSubworkflowExecutor() + { + RunTurnCommandDto command = CreateReferencedSubworkflowCommand(); + CopilotTurnExecutionState state = new(command); + List activities = []; + + MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod( + "HandleWorkflowEventAsync", + BindingFlags.NonPublic | BindingFlags.Static)!; + Task handleTask = (Task)handleWorkflowEvent.Invoke( + null, + [ + command, + new ExecutorInvokedEvent("subworkflow-review", null!), + Array.Empty(), + state, + (Func)(_ => Task.CompletedTask), + (Func)(sidecarEvent => + { + activities.Add(Assert.IsType(sidecarEvent)); + return Task.CompletedTask; + }), + ])!; + + bool shouldEndTurn = await handleTask; + + Assert.False(shouldEndTurn); + AgentActivityEventDto activity = Assert.Single(activities); + Assert.Equal("subworkflow-started", activity.ActivityType); + Assert.Null(activity.AgentId); + Assert.Null(activity.AgentName); + Assert.Equal("subworkflow-review", activity.SubworkflowNodeId); + Assert.Equal("Review Lane", activity.SubworkflowName); + } + + [Fact] + public async Task HandleWorkflowEventAsync_EmitsSubworkflowCompletedActivityForSubworkflowExecutor() + { + RunTurnCommandDto command = CreateReferencedSubworkflowCommand(); + CopilotTurnExecutionState state = new(command); + List activities = []; + + MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod( + "HandleWorkflowEventAsync", + BindingFlags.NonPublic | BindingFlags.Static)!; + Task handleTask = (Task)handleWorkflowEvent.Invoke( + null, + [ + command, + new ExecutorCompletedEvent("subworkflow-review", null), + Array.Empty(), + state, + (Func)(_ => Task.CompletedTask), + (Func)(sidecarEvent => + { + activities.Add(Assert.IsType(sidecarEvent)); + return Task.CompletedTask; + }), + ])!; + + bool shouldEndTurn = await handleTask; + + Assert.False(shouldEndTurn); + AgentActivityEventDto activity = Assert.Single(activities); + Assert.Equal("subworkflow-completed", activity.ActivityType); + Assert.Null(activity.AgentId); + Assert.Null(activity.AgentName); + Assert.Equal("subworkflow-review", activity.SubworkflowNodeId); + Assert.Equal("Review Lane", activity.SubworkflowName); + } + [Fact] public async Task HandleWorkflowEventAsync_EmitsWorkflowWarningDiagnostic() { @@ -2243,11 +2315,37 @@ public sealed class CopilotWorkflowRunnerTests }; } + private static WorkflowNodeDto CreateSubworkflow( + string id, + string label, + string? workflowId = null, + WorkflowDefinitionDto? inlineWorkflow = null) + { + return new WorkflowNodeDto + { + Id = id, + Kind = "sub-workflow", + Label = label, + Config = new WorkflowNodeConfigDto + { + Kind = "sub-workflow", + WorkflowId = workflowId, + InlineWorkflow = inlineWorkflow, + }, + }; + } + private static RunTurnCommandDto CreateCommand( string orchestrationMode, params WorkflowNodeDto[] agents) { - return CreateCommand(orchestrationMode, modeSettings: null, workflowName: null, workflowDescription: null, agents); + return CreateCommand( + orchestrationMode, + modeSettings: null, + workflowName: null, + workflowDescription: null, + workflowLibrary: null, + agents: agents); } private static RunTurnCommandDto CreateCommand( @@ -2255,12 +2353,14 @@ public sealed class CopilotWorkflowRunnerTests OrchestrationModeSettingsDto? modeSettings = null, string? workflowName = null, string? workflowDescription = null, + IReadOnlyList? workflowLibrary = null, params WorkflowNodeDto[] agents) { return new RunTurnCommandDto { RequestId = "turn-1", SessionId = "session-1", + WorkflowLibrary = workflowLibrary ?? [], Workflow = new WorkflowDefinitionDto { Id = $"workflow-{orchestrationMode}", @@ -2336,6 +2436,35 @@ public sealed class CopilotWorkflowRunnerTests }; } + private static RunTurnCommandDto CreateReferencedSubworkflowCommand() + { + WorkflowDefinitionDto nestedWorkflow = new() + { + Id = "nested-review-workflow", + Name = "Nested Review Workflow", + Graph = new WorkflowGraphDto + { + Nodes = + [ + CreateAgent("agent-reviewer", "Reviewer"), + ], + }, + Settings = new WorkflowSettingsDto + { + OrchestrationMode = "single", + }, + }; + + return CreateCommand( + "single", + workflowName: "Parent Workflow", + workflowLibrary: [nestedWorkflow], + agents: + [ + CreateSubworkflow("subworkflow-review", "Review Lane", workflowId: nestedWorkflow.Id), + ]); + } + private static RunTurnCommandDto CreateHandoffCommand() { return CreateCommand( diff --git a/sidecar/tests/Aryx.AgentHost.Tests/WorkflowRequestInfoInterpreterTests.cs b/sidecar/tests/Aryx.AgentHost.Tests/WorkflowRequestInfoInterpreterTests.cs index b1308fe..173eead 100644 --- a/sidecar/tests/Aryx.AgentHost.Tests/WorkflowRequestInfoInterpreterTests.cs +++ b/sidecar/tests/Aryx.AgentHost.Tests/WorkflowRequestInfoInterpreterTests.cs @@ -246,6 +246,54 @@ public sealed class WorkflowRequestInfoInterpreterTests Assert.Empty(tracking.ToolCallHasArgumentsById); } + [Fact] + public void TryCreateActivityFromRequest_IncludesSubworkflowContextForToolCallingAgent() + { + var tracking = CreateToolTracking(); + RequestInfoEvent requestInfo = CreateRequestInfoEvent( + new FunctionCallContent("call-1", "view", new Dictionary + { + ["path"] = @"C:\workspace\file.txt", + })); + + AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest( + CreateSingleAgentCommand(), + requestInfo, + new AgentIdentity( + "agent-reviewer", + "Reviewer", + new SubworkflowContext("subworkflow-review", "Review Lane")), + tracking.ToolNamesByCallId, + tracking.ToolCallHasArgumentsById); + + Assert.NotNull(activity); + Assert.Equal("tool-calling", activity.ActivityType); + Assert.Equal("subworkflow-review", activity.SubworkflowNodeId); + Assert.Equal("Review Lane", activity.SubworkflowName); + } + + [Fact] + public void TryCreateActivityFromRequest_ResolvesReferencedSubworkflowContextForHandoffTargets() + { + var tracking = CreateToolTracking(); + RequestInfoEvent requestInfo = CreateRequestInfoEvent( + CreateHandoffTarget("agent-handoff-ux", "UX Specialist")); + + AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest( + CreateHandoffCommandWithReferencedSubworkflow(), + requestInfo, + new AgentIdentity("agent-handoff-triage", "Triage"), + tracking.ToolNamesByCallId, + tracking.ToolCallHasArgumentsById); + + Assert.NotNull(activity); + Assert.Equal("handoff", activity.ActivityType); + Assert.Equal("agent-handoff-ux", activity.AgentId); + Assert.Equal("UX Specialist", activity.AgentName); + Assert.Equal("subworkflow-review", activity.SubworkflowNodeId); + Assert.Equal("Review Lane", activity.SubworkflowName); + } + [Fact] public void RequiresUserInputTurnBoundary_ReturnsTrueForUnhandledHandoffRequests() { @@ -341,24 +389,56 @@ public sealed class WorkflowRequestInfoInterpreterTests CreateAgent("agent-handoff-ux", "UX Specialist"), ]); + private static RunTurnCommandDto CreateHandoffCommandWithReferencedSubworkflow() + { + WorkflowDefinitionDto nestedWorkflow = new() + { + Id = "nested-review-workflow", + Name = "Nested Review Workflow", + Graph = new WorkflowGraphDto + { + Nodes = + [ + CreateAgent("agent-handoff-ux", "UX Specialist"), + ], + }, + Settings = new WorkflowSettingsDto + { + OrchestrationMode = "single", + }, + }; + + return CreateCommand( + "handoff", + [ + CreateAgent("agent-handoff-triage", "Triage"), + CreateSubworkflow("subworkflow-review", "Review Lane", workflowId: nestedWorkflow.Id), + ], + workflowLibrary: [nestedWorkflow]); + } + private static ( ConcurrentDictionary ToolNamesByCallId, ConcurrentDictionary ToolCallHasArgumentsById) CreateToolTracking() => (new(StringComparer.Ordinal), new(StringComparer.Ordinal)); - private static RunTurnCommandDto CreateCommand(string orchestrationMode, IReadOnlyList agents) + private static RunTurnCommandDto CreateCommand( + string orchestrationMode, + IReadOnlyList nodes, + IReadOnlyList? workflowLibrary = null) { return new RunTurnCommandDto { RequestId = "turn-1", SessionId = "session-1", + WorkflowLibrary = workflowLibrary ?? [], Workflow = new WorkflowDefinitionDto { Id = $"{orchestrationMode}-workflow", Name = "Workflow", Graph = new WorkflowGraphDto { - Nodes = [.. agents], + Nodes = [.. nodes], }, Settings = new WorkflowSettingsDto { @@ -386,6 +466,26 @@ public sealed class WorkflowRequestInfoInterpreterTests }; } + private static WorkflowNodeDto CreateSubworkflow( + string id, + string label, + string? workflowId = null, + WorkflowDefinitionDto? inlineWorkflow = null) + { + return new WorkflowNodeDto + { + Id = id, + Kind = "sub-workflow", + Label = label, + Config = new WorkflowNodeConfigDto + { + Kind = "sub-workflow", + WorkflowId = workflowId, + InlineWorkflow = inlineWorkflow, + }, + }; + } + private static RequestInfoEvent CreateRequestInfoEvent(object payload) { RequestPort port = RequestPort.Create("test-port"); diff --git a/src/main/AryxAppService.ts b/src/main/AryxAppService.ts index 8964494..d743e88 100644 --- a/src/main/AryxAppService.ts +++ b/src/main/AryxAppService.ts @@ -2004,7 +2004,7 @@ export class AryxAppService extends EventEmitter { const session = this.requireSession(workspace, sessionId); const activityType = event.activityType; let nextRun: SessionRunRecord | undefined; - if (activityType !== 'completed') { + if (activityType === 'thinking' || activityType === 'tool-calling' || activityType === 'handoff') { nextRun = this.updateSessionRun(session, requestId, (run) => appendRunActivityEvent(run, { activityType, @@ -2032,6 +2032,8 @@ export class AryxAppService extends EventEmitter { activityType: event.activityType, agentId: event.agentId, agentName: event.agentName, + subworkflowNodeId: event.subworkflowNodeId, + subworkflowName: event.subworkflowName, sourceAgentId: event.sourceAgentId, sourceAgentName: event.sourceAgentName, toolName: event.toolName, diff --git a/src/main/services/sessionTurnExecutor.ts b/src/main/services/sessionTurnExecutor.ts index d092c5e..e1256ec 100644 --- a/src/main/services/sessionTurnExecutor.ts +++ b/src/main/services/sessionTurnExecutor.ts @@ -675,7 +675,7 @@ export class SessionTurnExecutor { const session = this.requireSession(workspace, sessionId); const activityType = event.activityType; let nextRun: SessionRunRecord | undefined; - if (activityType !== 'completed') { + if (activityType === 'thinking' || activityType === 'tool-calling' || activityType === 'handoff') { nextRun = this.updateSessionRun(session, requestId, (run) => appendRunActivityEvent(run, { activityType, @@ -703,6 +703,8 @@ export class SessionTurnExecutor { activityType: event.activityType, agentId: event.agentId, agentName: event.agentName, + subworkflowNodeId: event.subworkflowNodeId, + subworkflowName: event.subworkflowName, sourceAgentId: event.sourceAgentId, sourceAgentName: event.sourceAgentName, toolName: event.toolName, diff --git a/src/renderer/App.tsx b/src/renderer/App.tsx index 334f116..9ff374d 100644 --- a/src/renderer/App.tsx +++ b/src/renderer/App.tsx @@ -745,6 +745,7 @@ export default function App() { = { - single: { dot: 'bg-[#245CF9]', bar: 'bg-[#245CF9] opacity-60', label: 'text-[#245CF9]' }, - sequential: { dot: 'bg-[var(--color-status-warning)]', bar: 'bg-[var(--color-status-warning)] opacity-60', label: 'text-[var(--color-status-warning)]' }, - concurrent: { dot: 'bg-[var(--color-status-success)]', bar: 'bg-[var(--color-status-success)] opacity-60', label: 'text-[var(--color-status-success)]' }, - handoff: { dot: 'bg-[var(--color-accent-sky)]', bar: 'bg-[var(--color-accent-sky)] opacity-60', label: 'text-[var(--color-accent-sky)]' }, - 'group-chat': { dot: 'bg-[var(--color-accent-purple)]', bar: 'bg-[var(--color-accent-purple)] opacity-60', label: 'text-[var(--color-accent-purple)]' }, -}; - -/* ── Helpers ───────────────────────────────────────────────── */ - -function formatModel(model: string): string { - return model.replace(/-/g, '\u2011'); -} - -function formatEffort(effort: string | undefined): string | undefined { - if (!effort) return undefined; - const labels: Record = { - low: 'Low', - medium: 'Medium', - high: 'High', - xhigh: 'Max', - }; - return labels[effort] ?? effort; -} - -const modeLabels: Record = { - single: 'Single agent', - sequential: 'Sequential', - concurrent: 'Concurrent', - handoff: 'Handoff', - 'group-chat': 'Group chat', -}; +import { AgentRow } from './activity/AgentRow'; +import { SubWorkflowGroup } from './activity/SubWorkflowGroup'; +import { modeAccent, modeLabels } from './activity/constants'; /* ── Section header ────────────────────────────────────────── */ @@ -65,112 +27,6 @@ function SectionHeader({ children }: { children: ReactNode }) { ); } -/* ── Agent row ─────────────────────────────────────────────── */ - -function AgentRow({ - row, - agent, - accent, - isLast, - agentUsage, -}: { - row: AgentActivityRow; - agent?: AgentNodeConfig; - accent: (typeof modeAccent)[WorkflowOrchestrationMode]; - isLast: boolean; - agentUsage?: AgentUsageAccumulator; -}) { - const isActive = isAgentActivityActive(row.activity); - const isCompleted = isAgentActivityCompleted(row.activity); - - return ( -
- {/* Left accent bar — visible only when this agent is actively working */} - {isActive && ( -
- )} - - {/* Status dot */} -
- -
- - {/* Content */} -
-
- {row.agentName} -
- - {/* Model + effort inline */} - {agent && ( -
- - {(() => { - const prov = inferProvider(agent.model); - return prov ? : null; - })()} - {formatModel(agent.model)} - - {agent.reasoningEffort && ( - <> - · - - - {formatEffort(agent.reasoningEffort)} - - - )} -
- )} - - {/* Activity label */} -
- - {formatAgentActivityLabel(row.activity)} - -
- - {/* Per-agent usage summary */} - {agentUsage && agentUsage.requestCount > 0 && ( -
- {formatTokenCount(agentUsage.inputTokens)} in - · - {formatTokenCount(agentUsage.outputTokens)} out - {agentUsage.cost > 0 && ( - <> - · - {agentUsage.cost.toFixed(2)} cost - - )} - {agentUsage.durationMs > 0 && ( - <> - · - {formatDuration(agentUsage.durationMs)} - - )} -
- )} -
-
- ); -} - /* ── Turn event helpers ─────────────────────────────────────── */ import type { SessionEventKind } from '@shared/domain/event'; @@ -178,6 +34,8 @@ import type { SessionEventKind } from '@shared/domain/event'; function TurnEventIcon({ kind, phase, success }: { kind: SessionEventKind; phase?: string; success?: boolean }) { const base = 'size-3'; switch (kind) { + case 'agent-activity': + return ; case 'subagent': return ; case 'hook-lifecycle': @@ -207,6 +65,7 @@ function formatTurnEventTimestamp(iso: string): string { interface ActivityPanelProps { activity?: SessionActivityState; workflow: WorkflowDefinition; + workflows?: ReadonlyArray; session: SessionRecord; sessionRequestUsage?: SessionRequestUsageState; turnEvents?: TurnEventLog; @@ -215,32 +74,40 @@ interface ActivityPanelProps { export function ActivityPanel({ activity, workflow, + workflows, session, sessionRequestUsage, turnEvents, }: ActivityPanelProps) { - const workflowAgents = useMemo( - () => resolveWorkflowAgentNodes(workflow) - .map((n) => n.config) - .filter((c): c is AgentNodeConfig => c.kind === 'agent'), - [workflow], - ); - const workflowMode = workflow.settings.orchestrationMode ?? 'single'; + const resolveOptions = useMemo(() => ({ + resolveWorkflow: (id: string) => workflows?.find((w) => w.id === id), + }), [workflows]); - const activityRows = useMemo( - () => buildAgentActivityRows(activity, workflowAgents), - [activity, workflowAgents], + const hierarchy = useMemo( + () => resolveWorkflowAgentHierarchy(workflow, resolveOptions), + [workflow, resolveOptions], ); + const groupedRows = useMemo( + () => buildGroupedActivityRows(activity, hierarchy), + [activity, hierarchy], + ); + + const workflowMode = workflow.settings.orchestrationMode ?? 'single'; + const totalAgentCount = hierarchy.topLevelAgents.length + + hierarchy.subWorkflows.reduce((sum, sw) => sum + sw.agents.length, 0); + const isBusy = session.status === 'running'; const hasPendingApproval = session.pendingApproval?.status === 'pending'; const queuedCount = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending').length; const totalApprovalCount = (hasPendingApproval ? 1 : 0) + queuedCount; const accent = modeAccent[workflowMode] ?? modeAccent.single; + const hasSubWorkflows = groupedRows.subWorkflows.length > 0; + return (
- {/* Header — top padding clears the title bar overlay zone */} + {/* Header */}
@@ -267,32 +134,53 @@ export function ActivityPanel({ Agents - {activityRows.length} + {totalAgentCount} {modeLabels[workflowMode]} - {activityRows.length > 0 ? ( + {/* Top-level agents */} + {groupedRows.topLevelAgents.length > 0 && (
- {activityRows.map((row, index) => { + {groupedRows.topLevelAgents.map((row, index) => { + const agent = hierarchy.topLevelAgents.find((a) => a.id === row.key || a.name === row.agentName); const agentKey = row.activity?.agentId ?? row.key; const agentUsage = sessionRequestUsage?.perAgent[agentKey] ?? sessionRequestUsage?.perAgent[row.agentName]; return ( ); })}
- ) : ( + )} + + {/* Sub-workflow groups */} + {hasSubWorkflows && ( +
0 ? 'mt-2' : ''}`}> + {groupedRows.subWorkflows.map((group) => { + const subDef = hierarchy.subWorkflows.find((sw) => sw.nodeId === group.nodeId); + return ( + + ); + })} +
+ )} + + {totalAgentCount === 0 && !hasSubWorkflows && (

No agents configured

)}
@@ -376,5 +264,3 @@ export function ActivityPanel({
); } - - diff --git a/src/renderer/components/activity/AgentRow.tsx b/src/renderer/components/activity/AgentRow.tsx new file mode 100644 index 0000000..9bcebe5 --- /dev/null +++ b/src/renderer/components/activity/AgentRow.tsx @@ -0,0 +1,111 @@ +import { Sparkles } from 'lucide-react'; + +import type { AgentNodeConfig } from '@shared/domain/workflow'; +import { inferProvider } from '@shared/domain/models'; +import { + formatAgentActivityLabel, + formatDuration, + formatTokenCount, + isAgentActivityActive, + isAgentActivityCompleted, + type AgentActivityRow, + type AgentUsageAccumulator, +} from '@renderer/lib/sessionActivity'; +import { ProviderIcon } from '@renderer/components/ProviderIcons'; +import { type ModeAccent, formatEffort, formatModel } from './constants'; + +interface AgentRowProps { + row: AgentActivityRow; + agent?: AgentNodeConfig; + accent: ModeAccent; + isLast: boolean; + agentUsage?: AgentUsageAccumulator; +} + +export function AgentRow({ row, agent, accent, isLast, agentUsage }: AgentRowProps) { + const isActive = isAgentActivityActive(row.activity); + const isCompleted = isAgentActivityCompleted(row.activity); + + return ( +
+ {isActive && ( +
+ )} + + {/* Status dot */} +
+ +
+ + {/* Content */} +
+
+ {row.agentName} +
+ + {agent && ( +
+ + {(() => { + const prov = inferProvider(agent.model); + return prov ? : null; + })()} + {formatModel(agent.model)} + + {agent.reasoningEffort && ( + <> + · + + + {formatEffort(agent.reasoningEffort)} + + + )} +
+ )} + +
+ + {formatAgentActivityLabel(row.activity)} + +
+ + {agentUsage && agentUsage.requestCount > 0 && ( +
+ {formatTokenCount(agentUsage.inputTokens)} in + · + {formatTokenCount(agentUsage.outputTokens)} out + {agentUsage.cost > 0 && ( + <> + · + {agentUsage.cost.toFixed(2)} cost + + )} + {agentUsage.durationMs > 0 && ( + <> + · + {formatDuration(agentUsage.durationMs)} + + )} +
+ )} +
+
+ ); +} diff --git a/src/renderer/components/activity/SubWorkflowGroup.tsx b/src/renderer/components/activity/SubWorkflowGroup.tsx new file mode 100644 index 0000000..2ee8896 --- /dev/null +++ b/src/renderer/components/activity/SubWorkflowGroup.tsx @@ -0,0 +1,145 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { ChevronDown, GitBranch } from 'lucide-react'; + +import type { AgentNodeConfig } from '@shared/domain/workflow'; +import { + isAgentActivityActive, + type AgentUsageAccumulator, + type SubWorkflowActivityGroup, +} from '@renderer/lib/sessionActivity'; +import { AgentRow } from './AgentRow'; +import { modeAccent, modeLabels } from './constants'; + +interface SubWorkflowGroupProps { + group: SubWorkflowActivityGroup; + agentConfigs: ReadonlyArray; + agentUsage?: Record; +} + +const statusPresentation = { + idle: { + dot: 'bg-[var(--color-surface-3)]', + text: 'text-[var(--color-text-muted)]', + label: 'Idle', + }, + running: { + dot: 'animate-pulse', + text: '', + label: 'Running', + }, + completed: { + dot: 'bg-[var(--color-status-success)]', + text: 'text-[var(--color-status-success)]', + label: 'Done', + }, +} as const; + +export function SubWorkflowGroup({ group, agentConfigs, agentUsage }: SubWorkflowGroupProps) { + const [isExpanded, setIsExpanded] = useState(false); + const prevStatusRef = useRef(group.status); + + useEffect(() => { + if (prevStatusRef.current !== 'running' && group.status === 'running') { + setIsExpanded(true); + } + prevStatusRef.current = group.status; + }, [group.status]); + + const toggle = useCallback(() => setIsExpanded((prev) => !prev), []); + + const accent = modeAccent[group.orchestrationMode] ?? modeAccent.single; + const status = statusPresentation[group.status]; + const hasActiveAgent = group.agents.some((a) => isAgentActivityActive(a.activity)); + + return ( +
+ {/* Collapsible header */} + + + {/* Expandable agent list */} +
+
+
+ {/* Connecting vertical accent line */} + {hasActiveAgent && ( +
+ )} + + {group.agents.map((row, index) => { + const agent = agentConfigs.find((c) => c.id === row.key || c.name === row.agentName); + const usage = agentUsage?.[row.activity?.agentId ?? row.key] ?? agentUsage?.[row.agentName]; + + return ( + + ); + })} + + {/* Mode label */} +
+ + {modeLabels[group.orchestrationMode]} + +
+
+
+
+
+ ); +} diff --git a/src/renderer/components/activity/constants.ts b/src/renderer/components/activity/constants.ts new file mode 100644 index 0000000..91055fd --- /dev/null +++ b/src/renderer/components/activity/constants.ts @@ -0,0 +1,34 @@ +import type { WorkflowOrchestrationMode } from '@shared/domain/workflow'; + +export interface ModeAccent { + dot: string; + bar: string; + label: string; + color: string; +} + +export const modeAccent: Record = { + single: { dot: 'bg-[#245CF9]', bar: 'bg-[#245CF9] opacity-60', label: 'text-[#245CF9]', color: '#245CF9' }, + sequential: { dot: 'bg-[var(--color-status-warning)]', bar: 'bg-[var(--color-status-warning)] opacity-60', label: 'text-[var(--color-status-warning)]', color: 'var(--color-status-warning)' }, + concurrent: { dot: 'bg-[var(--color-status-success)]', bar: 'bg-[var(--color-status-success)] opacity-60', label: 'text-[var(--color-status-success)]', color: 'var(--color-status-success)' }, + handoff: { dot: 'bg-[var(--color-accent-sky)]', bar: 'bg-[var(--color-accent-sky)] opacity-60', label: 'text-[var(--color-accent-sky)]', color: 'var(--color-accent-sky)' }, + 'group-chat': { dot: 'bg-[var(--color-accent-purple)]', bar: 'bg-[var(--color-accent-purple)] opacity-60', label: 'text-[var(--color-accent-purple)]', color: 'var(--color-accent-purple)' }, +}; + +export const modeLabels: Record = { + single: 'Single agent', + sequential: 'Sequential', + concurrent: 'Concurrent', + handoff: 'Handoff', + 'group-chat': 'Group chat', +}; + +export function formatModel(model: string): string { + return model.replace(/-/g, '\u2011'); +} + +export function formatEffort(effort: string | undefined): string | undefined { + if (!effort) return undefined; + const labels: Record = { low: 'Low', medium: 'Medium', high: 'High', xhigh: 'Max' }; + return labels[effort] ?? effort; +} diff --git a/src/renderer/lib/sessionActivity.ts b/src/renderer/lib/sessionActivity.ts index 73d2cc7..5dccf2c 100644 --- a/src/renderer/lib/sessionActivity.ts +++ b/src/renderer/lib/sessionActivity.ts @@ -1,4 +1,4 @@ -import type { AgentNodeConfig } from '@shared/domain/workflow'; +import type { AgentNodeConfig, WorkflowAgentHierarchy, WorkflowOrchestrationMode } from '@shared/domain/workflow'; import type { SessionEventRecord } from '@shared/domain/event'; import type { QuotaSnapshot, WorkflowDiagnosticKind, WorkflowDiagnosticSeverity } from '@shared/contracts/sidecar'; @@ -8,6 +8,8 @@ export interface AgentActivityState { activityType?: SessionEventRecord['activityType']; toolName?: string; toolArguments?: Record; + subworkflowNodeId?: string; + subworkflowName?: string; } export interface SessionUsageState { @@ -31,9 +33,14 @@ export function applySessionEventActivity( event: SessionEventRecord, ): SessionActivityMap { if (event.kind === 'agent-activity') { - const agentKey = resolveAgentKey(event); + const isSubworkflowLifecycle = + event.activityType === 'subworkflow-started' || event.activityType === 'subworkflow-completed'; + const agentKey = isSubworkflowLifecycle + ? event.subworkflowNodeId?.trim() + : resolveAgentKey(event); + if (!agentKey) { - console.warn('[aryx activity] Dropping agent-activity event without agentId/agentName.', event); + console.warn('[aryx activity] Dropping agent-activity event without key.', event); return current; } @@ -43,10 +50,12 @@ export function applySessionEventActivity( ...(current[event.sessionId] ?? {}), [agentKey]: { agentId: event.agentId ?? agentKey, - agentName: event.agentName?.trim() || event.agentId?.trim() || agentKey, + agentName: event.agentName?.trim() || event.subworkflowName?.trim() || event.agentId?.trim() || agentKey, activityType: event.activityType, toolName: event.toolName, toolArguments: event.toolArguments, + subworkflowNodeId: event.subworkflowNodeId, + subworkflowName: event.subworkflowName, }, }, }; @@ -146,6 +155,88 @@ export function isAgentActivityCompleted(activity: AgentActivityState | undefine return activity?.activityType === 'completed'; } +export type SubWorkflowGroupStatus = 'idle' | 'running' | 'completed'; + +export interface SubWorkflowActivityGroup { + nodeId: string; + name: string; + workflowId?: string; + orchestrationMode: WorkflowOrchestrationMode; + status: SubWorkflowGroupStatus; + agents: AgentActivityRow[]; +} + +export interface GroupedActivityRows { + topLevelAgents: AgentActivityRow[]; + subWorkflows: SubWorkflowActivityGroup[]; +} + +function resolveSubWorkflowGroupStatus( + agents: AgentActivityRow[], + lifecycleEntry: AgentActivityState | undefined, +): SubWorkflowGroupStatus { + if (lifecycleEntry?.activityType === 'subworkflow-completed') return 'completed'; + if (lifecycleEntry?.activityType === 'subworkflow-started') return 'running'; + if (agents.some((a) => isAgentActivityActive(a.activity))) return 'running'; + if (agents.some((a) => isAgentActivityCompleted(a.activity))) return 'completed'; + return 'idle'; +} + +export function buildGroupedActivityRows( + current: SessionActivityState | undefined, + hierarchy: WorkflowAgentHierarchy, +): GroupedActivityRows { + const topLevelAgents = buildAgentActivityRows(current, hierarchy.topLevelAgents); + const subWorkflows: SubWorkflowActivityGroup[] = hierarchy.subWorkflows.map((sub) => { + const agents = buildAgentActivityRows(current, sub.agents); + const lifecycleEntry = current?.[sub.nodeId]; + + return { + nodeId: sub.nodeId, + name: sub.workflowName || sub.nodeLabel, + workflowId: sub.workflowId, + orchestrationMode: sub.orchestrationMode, + status: resolveSubWorkflowGroupStatus(agents, lifecycleEntry), + agents, + }; + }); + + // Pick up agents that arrived via activity events with a subworkflowNodeId + // but whose sub-workflow isn't in the statically-resolved hierarchy (e.g. + // a referenced workflow that couldn't be resolved at design time). + if (current) { + const knownKeys = new Set([ + ...topLevelAgents.map((a) => a.key), + ...subWorkflows.flatMap((sw) => [sw.nodeId, ...sw.agents.map((a) => a.key)]), + ]); + + const dynamicGroups = new Map(); + for (const [key, state] of Object.entries(current)) { + if (knownKeys.has(key)) continue; + if (!state.subworkflowNodeId) continue; + if (state.activityType === 'subworkflow-started' || state.activityType === 'subworkflow-completed') continue; + + const group = dynamicGroups.get(state.subworkflowNodeId) + ?? { name: state.subworkflowName ?? state.subworkflowNodeId, agents: [] }; + group.agents.push({ key, agentName: state.agentName, activity: state }); + dynamicGroups.set(state.subworkflowNodeId, group); + } + + for (const [nodeId, group] of dynamicGroups) { + const lifecycleEntry = current[nodeId]; + subWorkflows.push({ + nodeId, + name: group.name, + orchestrationMode: 'sequential', + status: resolveSubWorkflowGroupStatus(group.agents, lifecycleEntry), + agents: group.agents, + }); + } + } + + return { topLevelAgents, subWorkflows }; +} + function removeSessionActivity( current: SessionActivityMap, sessionId: string, @@ -280,6 +371,26 @@ function formatDiagnosticLabel( function formatTurnEventEntry(event: SessionEventRecord): TurnEventEntry | undefined { switch (event.kind) { + case 'agent-activity': { + if (event.activityType === 'subworkflow-started') { + return { + kind: event.kind, + occurredAt: event.occurredAt, + label: `Sub-workflow started: ${event.subworkflowName ?? event.subworkflowNodeId ?? 'unknown'}`, + phase: 'start', + }; + } + if (event.activityType === 'subworkflow-completed') { + return { + kind: event.kind, + occurredAt: event.occurredAt, + label: `Sub-workflow completed: ${event.subworkflowName ?? event.subworkflowNodeId ?? 'unknown'}`, + phase: 'end', + success: true, + }; + } + return undefined; + } case 'subagent': return { kind: event.kind, diff --git a/src/shared/contracts/sidecar.ts b/src/shared/contracts/sidecar.ts index b9f25e4..b984936 100644 --- a/src/shared/contracts/sidecar.ts +++ b/src/shared/contracts/sidecar.ts @@ -271,7 +271,13 @@ export interface MessageReclassifiedEvent { newKind: 'thinking'; } -export type AgentActivityType = 'thinking' | 'tool-calling' | 'handoff' | 'completed'; +export type AgentActivityType = + | 'thinking' + | 'tool-calling' + | 'handoff' + | 'completed' + | 'subworkflow-started' + | 'subworkflow-completed'; export interface ToolCallFileChangePreview { path: string; @@ -286,6 +292,8 @@ export interface AgentActivityEvent { activityType: AgentActivityType; agentId?: string; agentName?: string; + subworkflowNodeId?: string; + subworkflowName?: string; sourceAgentId?: string; sourceAgentName?: string; toolName?: string; diff --git a/src/shared/domain/event.ts b/src/shared/domain/event.ts index bb566fc..df8719e 100644 --- a/src/shared/domain/event.ts +++ b/src/shared/domain/event.ts @@ -8,7 +8,13 @@ import type { WorkflowDiagnosticSeverity, } from '@shared/contracts/sidecar'; -export type SessionActivityType = 'thinking' | 'tool-calling' | 'handoff' | 'completed'; +export type SessionActivityType = + | 'thinking' + | 'tool-calling' + | 'handoff' + | 'completed' + | 'subworkflow-started' + | 'subworkflow-completed'; export type SessionEventKind = | 'status' @@ -42,6 +48,8 @@ export interface SessionEventRecord { activityType?: SessionActivityType; agentId?: string; agentName?: string; + subworkflowNodeId?: string; + subworkflowName?: string; sourceAgentId?: string; sourceAgentName?: string; toolName?: string; diff --git a/src/shared/domain/workflow.ts b/src/shared/domain/workflow.ts index 00de09d..cd6e9b8 100644 --- a/src/shared/domain/workflow.ts +++ b/src/shared/domain/workflow.ts @@ -536,6 +536,68 @@ export function resolveWorkflowAgents(workflow: WorkflowDefinition): AgentNodeCo }); } +export interface SubWorkflowGroupDescriptor { + nodeId: string; + nodeLabel: string; + workflowId?: string; + workflowName: string; + orchestrationMode: WorkflowOrchestrationMode; + agents: AgentNodeConfig[]; +} + +export interface WorkflowAgentHierarchy { + topLevelAgents: AgentNodeConfig[]; + subWorkflows: SubWorkflowGroupDescriptor[]; +} + +export function resolveWorkflowAgentHierarchy( + workflow: WorkflowDefinition, + options?: WorkflowResolutionOptions, +): WorkflowAgentHierarchy { + const topLevelAgents = resolveWorkflowAgents(workflow); + const subWorkflows: SubWorkflowGroupDescriptor[] = []; + + const subWorkflowNodes = workflow.graph.nodes + .filter((node): node is WorkflowNode & { config: SubWorkflowConfig } => node.kind === 'sub-workflow') + .slice() + .sort((a, b) => { + const orderA = a.order ?? Number.MAX_SAFE_INTEGER; + const orderB = b.order ?? Number.MAX_SAFE_INTEGER; + if (orderA !== orderB) return orderA - orderB; + return a.label.localeCompare(b.label); + }); + + for (const node of subWorkflowNodes) { + const subWorkflowDef = resolveSubWorkflowDefinition(node, options); + if (!subWorkflowDef) continue; + + const agents = resolveWorkflowAgents(subWorkflowDef); + const mode = inferWorkflowOrchestrationMode(subWorkflowDef, options); + + subWorkflows.push({ + nodeId: node.id, + nodeLabel: node.label, + workflowId: node.config.workflowId, + workflowName: subWorkflowDef.name || node.label, + orchestrationMode: mode, + agents, + }); + } + + return { topLevelAgents, subWorkflows }; +} + +function resolveSubWorkflowDefinition( + node: WorkflowNode & { config: SubWorkflowConfig }, + options?: WorkflowResolutionOptions, +): WorkflowDefinition | undefined { + if (node.config.inlineWorkflow) return node.config.inlineWorkflow; + if (node.config.workflowId && options?.resolveWorkflow) { + return options.resolveWorkflow(node.config.workflowId); + } + return undefined; +} + function hasWorkflowExecutionFanEdges( workflow: WorkflowDefinition, options?: WorkflowResolutionOptions, diff --git a/tests/main/sessionTurnExecutorAgentActivity.test.ts b/tests/main/sessionTurnExecutorAgentActivity.test.ts new file mode 100644 index 0000000..f94d26d --- /dev/null +++ b/tests/main/sessionTurnExecutorAgentActivity.test.ts @@ -0,0 +1,255 @@ +import { describe, expect, test } from 'bun:test'; + +import type { AgentActivityEvent } from '@shared/contracts/sidecar'; +import type { SessionEventRecord } from '@shared/domain/event'; +import { SCRATCHPAD_PROJECT_ID } from '@shared/domain/project'; +import { + createSessionRunRecord, + type SessionRunRecord, +} from '@shared/domain/runTimeline'; +import type { SessionRecord } from '@shared/domain/session'; +import type { WorkflowDefinition } from '@shared/domain/workflow'; +import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace'; + +import { SessionTurnExecutor } from '@main/services/sessionTurnExecutor'; + +describe('SessionTurnExecutor agent activity', () => { + test('preserves subworkflow context on nested agent activity session events', async () => { + const { workspace, session, run } = createRunningContext(); + const harness = createExecutor(); + const { executor, emittedEvents, runUpdates } = harness; + const initialEventCount = run.events.length; + const activityEvent: AgentActivityEvent = { + type: 'agent-activity', + requestId: run.requestId, + sessionId: session.id, + activityType: 'thinking', + agentId: 'agent-1', + agentName: 'Primary', + subworkflowNodeId: 'nested-flow', + subworkflowName: 'Nested flow', + }; + + await ( + executor as unknown as { + applyAgentActivity: ( + workspace: WorkspaceState, + sessionId: string, + requestId: string, + event: AgentActivityEvent, + ) => Promise; + } + ).applyAgentActivity(workspace, session.id, run.requestId, activityEvent); + + expect(harness.saveCalls).toBe(1); + expect(runUpdates).toHaveLength(1); + expect(session.runs[0]?.events).toHaveLength(initialEventCount + 1); + expect(session.runs[0]?.events.at(-1)).toMatchObject({ + kind: 'thinking', + agentId: 'agent-1', + agentName: 'Primary', + status: 'completed', + }); + expect(emittedEvents).toHaveLength(1); + expect(emittedEvents[0]).toMatchObject({ + sessionId: session.id, + kind: 'agent-activity', + activityType: 'thinking', + agentId: 'agent-1', + agentName: 'Primary', + subworkflowNodeId: 'nested-flow', + subworkflowName: 'Nested flow', + }); + }); + + test('emits subworkflow lifecycle events without appending run timeline activity', async () => { + const { workspace, session, run } = createRunningContext(); + const harness = createExecutor(); + const { executor, emittedEvents, runUpdates } = harness; + const initialEventCount = run.events.length; + const activityEvent: AgentActivityEvent = { + type: 'agent-activity', + requestId: run.requestId, + sessionId: session.id, + activityType: 'subworkflow-started', + subworkflowNodeId: 'nested-flow', + subworkflowName: 'Nested flow', + }; + + await ( + executor as unknown as { + applyAgentActivity: ( + workspace: WorkspaceState, + sessionId: string, + requestId: string, + event: AgentActivityEvent, + ) => Promise; + } + ).applyAgentActivity(workspace, session.id, run.requestId, activityEvent); + + expect(harness.saveCalls).toBe(0); + expect(runUpdates).toHaveLength(0); + expect(session.runs[0]?.events).toHaveLength(initialEventCount); + expect(emittedEvents).toHaveLength(1); + expect(emittedEvents[0]).toMatchObject({ + sessionId: session.id, + kind: 'agent-activity', + activityType: 'subworkflow-started', + subworkflowNodeId: 'nested-flow', + subworkflowName: 'Nested flow', + }); + }); +}); + +function createExecutor(): { + executor: SessionTurnExecutor; + emittedEvents: SessionEventRecord[]; + runUpdates: SessionRunRecord[]; + saveCalls: number; +} { + const emittedEvents: SessionEventRecord[] = []; + const runUpdates: SessionRunRecord[] = []; + let saveCalls = 0; + + const executor = new SessionTurnExecutor({ + saveWorkspace: async () => { + saveCalls += 1; + }, + persistWorkspace: async (workspace) => workspace, + requireSession: (workspace, sessionId) => { + const session = workspace.sessions.find((candidate) => candidate.id === sessionId); + if (!session) { + throw new Error(`Missing session ${sessionId}`); + } + + return session; + }, + resolveSessionWorkflow: () => createWorkflow(), + updateSessionRun: (session, requestId, updater) => { + const runIndex = session.runs.findIndex((candidate) => candidate.requestId === requestId); + if (runIndex < 0) { + return undefined; + } + + const nextRun = updater(session.runs[runIndex]!); + session.runs[runIndex] = nextRun; + return nextRun; + }, + emitRunUpdated: (_sessionId, _occurredAt, run) => { + runUpdates.push(run); + }, + emitSessionEvent: (event) => { + emittedEvents.push(event); + }, + rejectPendingApprovals: () => [], + buildRunTurnToolingConfig: () => undefined, + runSidecarTurnWithCheckpointRecovery: async () => [], + handleApprovalRequested: async () => undefined, + handleUserInputRequested: async () => undefined, + handleMcpOAuthRequired: async () => undefined, + handleExitPlanModeRequested: async () => undefined, + handleTurnScopedEvent: async () => undefined, + sidecarResolveApproval: async () => undefined, + sidecarResolveUserInput: async () => undefined, + captureWorkingTreeSnapshot: async () => undefined, + captureWorkingTreeBaseline: async () => [], + refreshSessionRunGitSummary: async () => undefined, + cleanupWorkflowCheckpointRecovery: async () => undefined, + scheduleProjectGitRefresh: () => undefined, + loadAvailableModelCatalog: async () => [], + }); + + return { + executor, + emittedEvents, + runUpdates, + get saveCalls() { + return saveCalls; + }, + }; +} + +function createRunningContext(): { + workspace: WorkspaceState; + session: SessionRecord; + run: SessionRunRecord; +} { + const workflow = createWorkflow(); + const run = createSessionRunRecord({ + requestId: 'turn-1', + project: { + id: SCRATCHPAD_PROJECT_ID, + path: 'C:\\scratchpad', + }, + workingDirectory: 'C:\\scratchpad', + workspaceKind: 'scratchpad', + workflow, + triggerMessageId: 'msg-user-1', + startedAt: '2026-04-01T12:00:00.000Z', + }); + const session: SessionRecord = { + id: 'session-1', + projectId: SCRATCHPAD_PROJECT_ID, + workflowId: workflow.id, + title: 'Activity session', + createdAt: '2026-04-01T12:00:00.000Z', + updatedAt: '2026-04-01T12:00:00.000Z', + status: 'running', + messages: [ + { + id: 'msg-user-1', + role: 'user', + authorName: 'You', + content: 'Continue the workflow.', + createdAt: '2026-04-01T12:00:00.000Z', + }, + ], + runs: [run], + }; + const workspace = createWorkspaceSeed(); + workspace.sessions = [session]; + workspace.workflows = [workflow]; + + return { workspace, session, run }; +} + +function createWorkflow(): WorkflowDefinition { + return { + id: 'workflow-handoff', + name: 'Activity flow', + description: '', + graph: { + nodes: [ + { id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } }, + { + id: 'agent-1', + kind: 'agent', + label: 'Primary', + position: { x: 200, y: 0 }, + order: 0, + config: { + kind: 'agent', + id: 'agent-1', + name: 'Primary', + description: '', + instructions: 'Help with the request.', + model: 'gpt-5.4', + }, + }, + { id: 'end', kind: 'end', label: 'End', position: { x: 400, y: 0 }, config: { kind: 'end' } }, + ], + edges: [ + { id: 'edge-start-agent', source: 'start', target: 'agent-1', kind: 'direct' }, + { id: 'edge-agent-end', source: 'agent-1', target: 'end', kind: 'direct' }, + ], + }, + settings: { + checkpointing: { enabled: true }, + executionMode: 'off-thread', + orchestrationMode: 'handoff', + maxIterations: 4, + }, + createdAt: '2026-04-01T00:00:00.000Z', + updatedAt: '2026-04-01T00:00:00.000Z', + }; +} diff --git a/tests/renderer/sessionActivity.test.ts b/tests/renderer/sessionActivity.test.ts index 13b126a..4748205 100644 --- a/tests/renderer/sessionActivity.test.ts +++ b/tests/renderer/sessionActivity.test.ts @@ -736,4 +736,322 @@ describe('workflow diagnostic turn events', () => { const entries = result['session-1']!; expect(entries[0].label).toBe('Workflow warning'); }); + + test('formats subworkflow-started lifecycle event', () => { + const result = applyTurnEventLog({}, { + sessionId: 'session-1', + kind: 'agent-activity', + occurredAt: '2026-03-23T00:00:00.000Z', + activityType: 'subworkflow-started', + subworkflowNodeId: 'data-pipeline', + subworkflowName: 'Data Pipeline', + }); + const entries = result['session-1']!; + expect(entries).toHaveLength(1); + expect(entries[0].label).toBe('Sub-workflow started: Data Pipeline'); + expect(entries[0].phase).toBe('start'); + }); + + test('formats subworkflow-completed lifecycle event', () => { + const result = applyTurnEventLog({}, { + sessionId: 'session-1', + kind: 'agent-activity', + occurredAt: '2026-03-23T00:00:00.000Z', + activityType: 'subworkflow-completed', + subworkflowNodeId: 'data-pipeline', + subworkflowName: 'Data Pipeline', + }); + const entries = result['session-1']!; + expect(entries).toHaveLength(1); + expect(entries[0].label).toBe('Sub-workflow completed: Data Pipeline'); + expect(entries[0].phase).toBe('end'); + expect(entries[0].success).toBe(true); + }); +}); + +/* ── Sub-workflow grouping tests ───────────────────────────── */ + +import { + buildGroupedActivityRows, + type SubWorkflowActivityGroup, +} from '@renderer/lib/sessionActivity'; +import { + resolveWorkflowAgentHierarchy, + type SubWorkflowGroupDescriptor, + type WorkflowAgentHierarchy, +} from '@shared/domain/workflow'; + +describe('sub-workflow activity grouping', () => { + function makeWorkflow(overrides?: Partial): WorkflowDefinition { + return { + id: 'wf-1', + name: 'Test Workflow', + description: 'A test workflow.', + createdAt: '2026-01-01T00:00:00Z', + updatedAt: '2026-01-01T00:00:00Z', + settings: { orchestrationMode: 'sequential', checkpointing: { enabled: false }, executionMode: 'off-thread' }, + graph: { + nodes: [ + { id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } }, + { id: 'agent-a', kind: 'agent', label: 'Agent A', position: { x: 100, y: 0 }, order: 1, config: { kind: 'agent', id: 'agent-a', name: 'Agent A', description: '', instructions: '', model: 'gpt-5.4' } }, + { id: 'end', kind: 'end', label: 'End', position: { x: 200, y: 0 }, config: { kind: 'end' } }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'agent-a', kind: 'direct' }, + { id: 'e2', source: 'agent-a', target: 'end', kind: 'direct' }, + ], + }, + ...overrides, + }; + } + + function makeSubWorkflow(): WorkflowDefinition { + return makeWorkflow({ + id: 'sub-wf-1', + name: 'Sub Pipeline', + graph: { + nodes: [ + { id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } }, + { id: 'inner-agent', kind: 'agent', label: 'Inner Agent', position: { x: 100, y: 0 }, order: 1, config: { kind: 'agent', id: 'inner-agent', name: 'Inner Agent', description: '', instructions: '', model: 'gpt-5.4' } }, + { id: 'inner-agent-2', kind: 'agent', label: 'Inner Agent 2', position: { x: 200, y: 0 }, order: 2, config: { kind: 'agent', id: 'inner-agent-2', name: 'Inner Agent 2', description: '', instructions: '', model: 'gpt-5.4' } }, + { id: 'end', kind: 'end', label: 'End', position: { x: 300, y: 0 }, config: { kind: 'end' } }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'inner-agent', kind: 'direct' }, + { id: 'e2', source: 'inner-agent', target: 'inner-agent-2', kind: 'direct' }, + { id: 'e3', source: 'inner-agent-2', target: 'end', kind: 'direct' }, + ], + }, + }); + } + + test('flat workflow produces no sub-workflow groups', () => { + const workflow = makeWorkflow(); + const hierarchy = resolveWorkflowAgentHierarchy(workflow); + const result = buildGroupedActivityRows(undefined, hierarchy); + + expect(result.topLevelAgents).toHaveLength(1); + expect(result.topLevelAgents[0].agentName).toBe('Agent A'); + expect(result.subWorkflows).toHaveLength(0); + }); + + test('workflow with inline sub-workflow produces grouped agents', () => { + const subWf = makeSubWorkflow(); + const workflow = makeWorkflow({ + graph: { + nodes: [ + { id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } }, + { id: 'agent-a', kind: 'agent', label: 'Agent A', position: { x: 100, y: 0 }, order: 1, config: { kind: 'agent', id: 'agent-a', name: 'Agent A', description: '', instructions: '', model: 'gpt-5.4' } }, + { id: 'sub-node', kind: 'sub-workflow', label: 'Sub Pipeline', position: { x: 200, y: 0 }, order: 2, config: { kind: 'sub-workflow', inlineWorkflow: subWf } }, + { id: 'end', kind: 'end', label: 'End', position: { x: 300, y: 0 }, config: { kind: 'end' } }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'agent-a', kind: 'direct' }, + { id: 'e2', source: 'agent-a', target: 'sub-node', kind: 'direct' }, + { id: 'e3', source: 'sub-node', target: 'end', kind: 'direct' }, + ], + }, + }); + + const hierarchy = resolveWorkflowAgentHierarchy(workflow); + expect(hierarchy.topLevelAgents).toHaveLength(1); + expect(hierarchy.subWorkflows).toHaveLength(1); + expect(hierarchy.subWorkflows[0].workflowName).toBe('Sub Pipeline'); + expect(hierarchy.subWorkflows[0].agents).toHaveLength(2); + + const result = buildGroupedActivityRows(undefined, hierarchy); + expect(result.topLevelAgents).toHaveLength(1); + expect(result.subWorkflows).toHaveLength(1); + expect(result.subWorkflows[0].agents).toHaveLength(2); + expect(result.subWorkflows[0].status).toBe('idle'); + }); + + test('sub-workflow group status reflects agent activity', () => { + const subWf = makeSubWorkflow(); + const workflow = makeWorkflow({ + graph: { + nodes: [ + { id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } }, + { id: 'sub-node', kind: 'sub-workflow', label: 'Pipeline', position: { x: 100, y: 0 }, config: { kind: 'sub-workflow', inlineWorkflow: subWf } }, + { id: 'end', kind: 'end', label: 'End', position: { x: 200, y: 0 }, config: { kind: 'end' } }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'sub-node', kind: 'direct' }, + { id: 'e2', source: 'sub-node', target: 'end', kind: 'direct' }, + ], + }, + }); + + const hierarchy = resolveWorkflowAgentHierarchy(workflow); + + // Running: when lifecycle event says 'subworkflow-started' + const runningActivity = { + 'sub-node': { agentId: 'sub-node', agentName: 'Pipeline', activityType: 'subworkflow-started' as const }, + 'inner-agent': { agentId: 'inner-agent', agentName: 'Inner Agent', activityType: 'thinking' as const }, + }; + const running = buildGroupedActivityRows(runningActivity, hierarchy); + expect(running.subWorkflows[0].status).toBe('running'); + + // Completed: when lifecycle event says 'subworkflow-completed' + const completedActivity = { + 'sub-node': { agentId: 'sub-node', agentName: 'Pipeline', activityType: 'subworkflow-completed' as const }, + 'inner-agent': { agentId: 'inner-agent', agentName: 'Inner Agent', activityType: 'completed' as const }, + }; + const completed = buildGroupedActivityRows(completedActivity, hierarchy); + expect(completed.subWorkflows[0].status).toBe('completed'); + }); + + test('sub-workflow group derives running status from active agents', () => { + const subWf = makeSubWorkflow(); + const workflow = makeWorkflow({ + graph: { + nodes: [ + { id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } }, + { id: 'sub-node', kind: 'sub-workflow', label: 'Pipeline', position: { x: 100, y: 0 }, config: { kind: 'sub-workflow', inlineWorkflow: subWf } }, + { id: 'end', kind: 'end', label: 'End', position: { x: 200, y: 0 }, config: { kind: 'end' } }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'sub-node', kind: 'direct' }, + { id: 'e2', source: 'sub-node', target: 'end', kind: 'direct' }, + ], + }, + }); + + const hierarchy = resolveWorkflowAgentHierarchy(workflow); + + // No lifecycle event, but agent is active — derive running status + const activity = { + 'inner-agent': { agentId: 'inner-agent', agentName: 'Inner Agent', activityType: 'thinking' as const }, + }; + const result = buildGroupedActivityRows(activity, hierarchy); + expect(result.subWorkflows[0].status).toBe('running'); + }); + + test('referenced sub-workflow resolves via options', () => { + const subWf = makeSubWorkflow(); + const workflow = makeWorkflow({ + graph: { + nodes: [ + { id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } }, + { id: 'sub-node', kind: 'sub-workflow', label: 'Ref Pipeline', position: { x: 100, y: 0 }, config: { kind: 'sub-workflow', workflowId: 'sub-wf-1' } }, + { id: 'end', kind: 'end', label: 'End', position: { x: 200, y: 0 }, config: { kind: 'end' } }, + ], + edges: [ + { id: 'e1', source: 'start', target: 'sub-node', kind: 'direct' }, + { id: 'e2', source: 'sub-node', target: 'end', kind: 'direct' }, + ], + }, + }); + + const hierarchy = resolveWorkflowAgentHierarchy(workflow, { + resolveWorkflow: (id) => (id === 'sub-wf-1' ? subWf : undefined), + }); + expect(hierarchy.subWorkflows).toHaveLength(1); + expect(hierarchy.subWorkflows[0].agents).toHaveLength(2); + expect(hierarchy.subWorkflows[0].workflowId).toBe('sub-wf-1'); + }); + + test('dynamic grouping picks up unresolved sub-workflow agents from activity', () => { + const workflow = makeWorkflow(); // flat workflow, no sub-workflow nodes + const hierarchy = resolveWorkflowAgentHierarchy(workflow); + + // Activity events arrive with subworkflowNodeId for agents not in the hierarchy + const activity = { + 'agent-a': { agentId: 'agent-a', agentName: 'Agent A', activityType: 'thinking' as const }, + 'dynamic-agent': { + agentId: 'dynamic-agent', + agentName: 'Dynamic Agent', + activityType: 'tool-calling' as const, + subworkflowNodeId: 'dynamic-sub', + subworkflowName: 'Dynamic Sub', + toolName: 'search', + }, + }; + const result = buildGroupedActivityRows(activity, hierarchy); + expect(result.topLevelAgents).toHaveLength(1); + expect(result.subWorkflows).toHaveLength(1); + expect(result.subWorkflows[0].nodeId).toBe('dynamic-sub'); + expect(result.subWorkflows[0].name).toBe('Dynamic Sub'); + expect(result.subWorkflows[0].agents).toHaveLength(1); + expect(result.subWorkflows[0].agents[0].agentName).toBe('Dynamic Agent'); + expect(result.subWorkflows[0].status).toBe('running'); + }); +}); + +describe('sub-workflow activity event handling', () => { + test('stores subworkflow-started lifecycle event keyed by subworkflowNodeId', () => { + const event: SessionEventRecord = { + sessionId: 'session-1', + kind: 'agent-activity', + occurredAt: '2026-03-23T00:00:00.000Z', + activityType: 'subworkflow-started', + subworkflowNodeId: 'pipeline-node', + subworkflowName: 'Data Pipeline', + }; + + const result = applySessionEventActivity({}, event); + expect(result['session-1']).toBeDefined(); + expect(result['session-1']!['pipeline-node']).toEqual({ + agentId: 'pipeline-node', + agentName: 'Data Pipeline', + activityType: 'subworkflow-started', + subworkflowNodeId: 'pipeline-node', + subworkflowName: 'Data Pipeline', + }); + }); + + test('stores subworkflow-completed lifecycle event replacing started', () => { + const startEvent: SessionEventRecord = { + sessionId: 'session-1', + kind: 'agent-activity', + occurredAt: '2026-03-23T00:00:00.000Z', + activityType: 'subworkflow-started', + subworkflowNodeId: 'pipeline-node', + subworkflowName: 'Data Pipeline', + }; + const completeEvent: SessionEventRecord = { + sessionId: 'session-1', + kind: 'agent-activity', + occurredAt: '2026-03-23T00:00:01.000Z', + activityType: 'subworkflow-completed', + subworkflowNodeId: 'pipeline-node', + subworkflowName: 'Data Pipeline', + }; + + let state = applySessionEventActivity({}, startEvent); + state = applySessionEventActivity(state, completeEvent); + expect(state['session-1']!['pipeline-node']?.activityType).toBe('subworkflow-completed'); + }); + + test('propagates subworkflow context on regular agent activity events', () => { + const event: SessionEventRecord = { + sessionId: 'session-1', + kind: 'agent-activity', + occurredAt: '2026-03-23T00:00:00.000Z', + activityType: 'thinking', + agentId: 'inner-agent', + agentName: 'Inner Agent', + subworkflowNodeId: 'pipeline-node', + subworkflowName: 'Data Pipeline', + }; + + const result = applySessionEventActivity({}, event); + const agentState = result['session-1']!['inner-agent']; + expect(agentState).toBeDefined(); + expect(agentState.subworkflowNodeId).toBe('pipeline-node'); + expect(agentState.subworkflowName).toBe('Data Pipeline'); + }); + + test('drops lifecycle event without subworkflowNodeId', () => { + const event: SessionEventRecord = { + sessionId: 'session-1', + kind: 'agent-activity', + occurredAt: '2026-03-23T00:00:00.000Z', + activityType: 'subworkflow-started', + // No subworkflowNodeId + }; + + const result = applySessionEventActivity({}, event); + expect(result['session-1']).toBeUndefined(); + }); });