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>
This commit is contained in:
David Kaya
2026-04-08 18:57:23 +02:00
co-authored by Copilot
parent fa8f6ef4b3
commit c70a5c6612
24 changed files with 2063 additions and 240 deletions
+4 -2
View File
@@ -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: 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 - **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 - **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 - **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: The architecture treats execution as observable by design:
- partial output is streamed - partial output is streamed
- agent activity is surfaced - agent activity is surfaced with optional sub-workflow context
- turn-scoped lifecycle events (sub-agent, hook, skill, compaction, usage) are streamed - turn-scoped lifecycle events (sub-agent, sub-workflow, hook, skill, compaction, usage) are streamed
- runs are surfaced inline as collapsible turn activity panels - runs are surfaced inline as collapsible turn activity panels
- failures are represented explicitly - failures are represented explicitly
@@ -452,6 +452,8 @@ public sealed class AgentActivityEventDto : SidecarEventDto
public string ActivityType { get; init; } = string.Empty; public string ActivityType { get; init; } = string.Empty;
public string? AgentId { get; init; } public string? AgentId { get; init; }
public string? AgentName { get; init; } public string? AgentName { get; init; }
public string? SubworkflowNodeId { get; init; }
public string? SubworkflowName { get; init; }
public string? SourceAgentId { get; init; } public string? SourceAgentId { get; init; }
public string? SourceAgentName { get; init; } public string? SourceAgentName { get; init; }
public string? ToolName { get; init; } public string? ToolName { get; init; }
@@ -3,7 +3,12 @@ using Aryx.AgentHost.Contracts;
namespace Aryx.AgentHost.Services; 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 internal static class AgentIdentityResolver
{ {
@@ -13,17 +18,69 @@ internal static class AgentIdentityResolver
WorkflowDefinitionDto workflow, WorkflowDefinitionDto workflow,
string? agentIdentifier, string? agentIdentifier,
out AgentIdentity agent) out AgentIdentity agent)
{
return TryResolveKnownAgentIdentity(
workflow,
WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(null),
agentIdentifier,
agentSubworkflowIndex: null,
out agent);
}
public static bool TryResolveKnownAgentIdentity(
WorkflowDefinitionDto workflow,
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary,
string? agentIdentifier,
out AgentIdentity agent)
{
return TryResolveKnownAgentIdentity(
workflow,
WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(workflowLibrary),
agentIdentifier,
agentSubworkflowIndex: null,
out agent);
}
internal static bool TryResolveKnownAgentIdentity(
WorkflowDefinitionDto workflow,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
string? agentIdentifier,
IReadOnlyDictionary<string, SubworkflowContext>? agentSubworkflowIndex,
out AgentIdentity agent)
{ {
agent = default; agent = default;
WorkflowNodeDto? match = FindKnownAgent(workflow, agentIdentifier) WorkflowNodeDto? shallowMatch = FindKnownAgent(workflow.GetAgentNodes(), agentIdentifier);
?? ResolveSingleAgentAssistantAlias(workflow, agentIdentifier); if (shallowMatch is not null)
if (match is null) {
agent = ToAgentIdentity(shallowMatch);
return true;
}
WorkflowNodeDto? deepMatch = FindKnownAgent(workflow.GetAllAgentNodes(workflowLibrary), agentIdentifier);
if (deepMatch is not null)
{
IReadOnlyDictionary<string, SubworkflowContext> subworkflowIndex = agentSubworkflowIndex
?? BuildAgentSubworkflowIndex(workflow, workflowLibrary);
agent = ToAgentIdentity(deepMatch, subworkflowIndex);
return true;
}
WorkflowNodeDto? aliasMatch = ResolveSingleAgentAssistantAlias(workflow, workflowLibrary, agentIdentifier);
if (aliasMatch is null)
{ {
return false; return false;
} }
agent = ToAgentIdentity(match); if (workflow.GetAgentNodes().Contains(aliasMatch))
{
agent = ToAgentIdentity(aliasMatch);
return true;
}
IReadOnlyDictionary<string, SubworkflowContext> aliasSubworkflowIndex = agentSubworkflowIndex
?? BuildAgentSubworkflowIndex(workflow, workflowLibrary);
agent = ToAgentIdentity(aliasMatch, aliasSubworkflowIndex);
return true; return true;
} }
@@ -33,7 +90,24 @@ internal static class AgentIdentityResolver
AgentIdentity? fallbackAgent, AgentIdentity? fallbackAgent,
out AgentIdentity agent) out AgentIdentity agent)
{ {
if (TryResolveKnownAgentIdentity(workflow, agentIdentifier, out agent)) return TryResolveObservedAgentIdentity(
workflow,
WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(null),
agentIdentifier,
fallbackAgent,
agentSubworkflowIndex: null,
out agent);
}
internal static bool TryResolveObservedAgentIdentity(
WorkflowDefinitionDto workflow,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
string? agentIdentifier,
AgentIdentity? fallbackAgent,
IReadOnlyDictionary<string, SubworkflowContext>? agentSubworkflowIndex,
out AgentIdentity agent)
{
if (TryResolveKnownAgentIdentity(workflow, workflowLibrary, agentIdentifier, agentSubworkflowIndex, out agent))
{ {
return true; return true;
} }
@@ -53,13 +127,46 @@ internal static class AgentIdentityResolver
string? agentId, string? agentId,
string? agentName) string? agentName)
{ {
WorkflowNodeDto? match = FindKnownAgent(workflow, agentId) return ResolveAgentIdentity(
?? FindKnownAgent(workflow, agentName) workflow,
?? ResolveSingleAgentAssistantAlias(workflow, agentId, agentName); WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(null),
agentId,
agentName,
agentSubworkflowIndex: null);
}
return match is not null public static AgentIdentity ResolveAgentIdentity(
? ToAgentIdentity(match) WorkflowDefinitionDto workflow,
: CreateFallbackIdentity(agentId, agentName); IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary,
string? agentId,
string? agentName)
{
return ResolveAgentIdentity(
workflow,
WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(workflowLibrary),
agentId,
agentName,
agentSubworkflowIndex: null);
}
internal static AgentIdentity ResolveAgentIdentity(
WorkflowDefinitionDto workflow,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
string? agentId,
string? agentName,
IReadOnlyDictionary<string, SubworkflowContext>? agentSubworkflowIndex)
{
if (TryResolveKnownAgentIdentity(workflow, workflowLibrary, agentId, agentSubworkflowIndex, out AgentIdentity resolvedById))
{
return resolvedById;
}
if (TryResolveKnownAgentIdentity(workflow, workflowLibrary, agentName, agentSubworkflowIndex, out AgentIdentity resolvedByName))
{
return resolvedByName;
}
return CreateFallbackIdentity(agentId, agentName, agentSubworkflowIndex);
} }
public static string ResolveDisplayAuthorName( public static string ResolveDisplayAuthorName(
@@ -90,6 +197,53 @@ internal static class AgentIdentityResolver
return GenericAssistantIdentifier; return GenericAssistantIdentifier;
} }
public static IReadOnlyDictionary<string, SubworkflowContext> BuildAgentSubworkflowIndex(
WorkflowDefinitionDto workflow,
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null)
{
return BuildAgentSubworkflowIndex(
workflow,
WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(workflowLibrary));
}
internal static IReadOnlyDictionary<string, SubworkflowContext> BuildAgentSubworkflowIndex(
WorkflowDefinitionDto workflow,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary)
{
ArgumentNullException.ThrowIfNull(workflow);
ArgumentNullException.ThrowIfNull(workflowLibrary);
Dictionary<string, SubworkflowContext> index = new(StringComparer.Ordinal);
CollectAgentSubworkflowContexts(
workflow,
workflowLibrary,
currentSubworkflow: null,
index,
new HashSet<string>(StringComparer.Ordinal),
new HashSet<WorkflowDefinitionDto>(ReferenceEqualityComparer.Instance));
return index;
}
internal static bool TryResolveSubworkflowContext(
WorkflowDefinitionDto workflow,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
string? subworkflowNodeId,
out SubworkflowContext context)
{
ArgumentNullException.ThrowIfNull(workflow);
ArgumentNullException.ThrowIfNull(workflowLibrary);
context = default;
WorkflowNodeDto? node = workflow.FindSubWorkflowNode(subworkflowNodeId, workflowLibrary);
if (node is null)
{
return false;
}
context = CreateSubworkflowContext(node, workflowLibrary);
return true;
}
internal static bool IsGenericAssistantIdentifier(string? candidate) internal static bool IsGenericAssistantIdentifier(string? candidate)
{ {
return string.Equals( return string.Equals(
@@ -100,34 +254,120 @@ internal static class AgentIdentityResolver
private static WorkflowNodeDto? ResolveSingleAgentAssistantAlias( private static WorkflowNodeDto? ResolveSingleAgentAssistantAlias(
WorkflowDefinitionDto workflow, WorkflowDefinitionDto workflow,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
params string?[] agentIdentifiers) params string?[] agentIdentifiers)
{ {
IReadOnlyList<WorkflowNodeDto> agentNodes = workflow.GetAgentNodes(); if (!agentIdentifiers.Any(IsGenericAssistantIdentifier))
return agentNodes.Count == 1 && agentIdentifiers.Any(IsGenericAssistantIdentifier) {
? agentNodes[0] return null;
: null; }
IReadOnlyList<WorkflowNodeDto> topLevelAgents = workflow.GetAgentNodes();
if (topLevelAgents.Count == 1)
{
return topLevelAgents[0];
}
IReadOnlyList<WorkflowNodeDto> allAgents = workflow.GetAllAgentNodes(workflowLibrary);
return allAgents.Count == 1 ? allAgents[0] : null;
} }
private static WorkflowNodeDto? FindKnownAgent(WorkflowDefinitionDto workflow, string? candidate) private static WorkflowNodeDto? FindKnownAgent(
IEnumerable<WorkflowNodeDto> agents,
string? candidate)
{ {
return workflow.GetAgentNodes().FirstOrDefault(agent => MatchesAgent(agent, candidate)); return agents.FirstOrDefault(agent => MatchesAgent(agent, candidate));
} }
private static AgentIdentity ToAgentIdentity(WorkflowNodeDto agent) private static AgentIdentity ToAgentIdentity(WorkflowNodeDto agent)
=> new(agent.GetAgentId(), agent.GetAgentName()); => new(agent.GetAgentId(), agent.GetAgentName());
private static AgentIdentity CreateFallbackIdentity(string? agentId, string? agentName) private static AgentIdentity ToAgentIdentity(
WorkflowNodeDto agent,
IReadOnlyDictionary<string, SubworkflowContext> agentSubworkflowIndex)
{ {
string resolvedAgentId = !string.IsNullOrWhiteSpace(agentId) string agentId = agent.GetAgentId();
? agentId return agentSubworkflowIndex.TryGetValue(agentId, out SubworkflowContext subworkflow)
: agentName ?? "agent"; ? new AgentIdentity(agentId, agent.GetAgentName(), subworkflow)
string resolvedAgentName = !string.IsNullOrWhiteSpace(agentName) : new AgentIdentity(agentId, agent.GetAgentName());
? agentName }
: resolvedAgentId;
private static AgentIdentity CreateFallbackIdentity(
string? agentId,
string? agentName,
IReadOnlyDictionary<string, SubworkflowContext>? agentSubworkflowIndex)
{
string resolvedAgentId = NormalizeOptionalString(agentId)
?? NormalizeOptionalString(agentName)
?? "agent";
string resolvedAgentName = NormalizeOptionalString(agentName)
?? resolvedAgentId;
if (agentSubworkflowIndex is not null
&& agentSubworkflowIndex.TryGetValue(resolvedAgentId, out SubworkflowContext subworkflow))
{
return new AgentIdentity(resolvedAgentId, resolvedAgentName, subworkflow);
}
return new AgentIdentity(resolvedAgentId, resolvedAgentName); return new AgentIdentity(resolvedAgentId, resolvedAgentName);
} }
private static void CollectAgentSubworkflowContexts(
WorkflowDefinitionDto workflowDefinition,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
SubworkflowContext? currentSubworkflow,
Dictionary<string, SubworkflowContext> index,
ISet<string> visitedWorkflowIds,
ISet<WorkflowDefinitionDto> visitedAnonymousWorkflows)
{
string? workflowId = NormalizeOptionalString(workflowDefinition.Id);
if (workflowId is not null)
{
if (!visitedWorkflowIds.Add(workflowId))
{
return;
}
}
else if (!visitedAnonymousWorkflows.Add(workflowDefinition))
{
return;
}
foreach (WorkflowNodeDto node in workflowDefinition.Graph.Nodes)
{
if (node.IsAgentNode())
{
if (currentSubworkflow.HasValue)
{
index[node.GetAgentId()] = currentSubworkflow.Value;
}
continue;
}
if (!node.IsSubWorkflowNode())
{
continue;
}
WorkflowDefinitionDto subWorkflow = node.ResolveSubWorkflowDefinition(workflowLibrary);
CollectAgentSubworkflowContexts(
subWorkflow,
workflowLibrary,
CreateSubworkflowContext(node, workflowLibrary),
index,
visitedWorkflowIds,
visitedAnonymousWorkflows);
}
}
private static SubworkflowContext CreateSubworkflowContext(
WorkflowNodeDto node,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary)
{
return new SubworkflowContext(node.Id, node.GetSubworkflowDisplayName(workflowLibrary));
}
private static bool MatchesAgent(WorkflowNodeDto agent, string? candidate) private static bool MatchesAgent(WorkflowNodeDto agent, string? candidate)
{ {
if (string.IsNullOrWhiteSpace(candidate)) if (string.IsNullOrWhiteSpace(candidate))
@@ -164,6 +404,9 @@ internal static class AgentIdentityResolver
&& normalizedCandidate.Contains(normalizedName, StringComparison.Ordinal)); && normalizedCandidate.Contains(normalizedName, StringComparison.Ordinal));
} }
private static string? NormalizeOptionalString(string? value)
=> string.IsNullOrWhiteSpace(value) ? null : value.Trim();
private static string NormalizeComparisonKey(string? value) private static string NormalizeComparisonKey(string? value)
{ {
if (string.IsNullOrWhiteSpace(value)) if (string.IsNullOrWhiteSpace(value))
@@ -297,14 +297,21 @@ public class AgentWorkflowTurnRunner : ITurnWorkflowRunner
{ {
if (evt is ExecutorInvokedEvent invoked) if (evt is ExecutorInvokedEvent invoked)
{ {
if (AgentIdentityResolver.TryResolveKnownAgentIdentity( if (state.TryResolveKnownAgentIdentity(invoked.ExecutorId, out AgentIdentity invokedAgent))
command.Workflow,
invoked.ExecutorId,
out AgentIdentity invokedAgent))
{ {
TraceHandoff(command, $"Executor invoked: {invoked.ExecutorId} -> {invokedAgent.AgentName} ({invokedAgent.AgentId})."); TraceHandoff(command, $"Executor invoked: {invoked.ExecutorId} -> {invokedAgent.AgentName} ({invokedAgent.AgentId}).");
await state.EmitThinkingIfNeeded(invokedAgent, onEvent).ConfigureAwait(false); await state.EmitThinkingIfNeeded(invokedAgent, onEvent).ConfigureAwait(false);
} }
else if (state.TryCreateSubworkflowLifecycleActivity(
"subworkflow-started",
invoked.ExecutorId,
out AgentActivityEventDto subworkflowStarted))
{
TraceHandoff(
command,
$"Sub-workflow executor invoked: {invoked.ExecutorId} -> {subworkflowStarted.SubworkflowName ?? subworkflowStarted.SubworkflowNodeId ?? "<unknown>"}.");
await EmitActivityAsync(command, state, subworkflowStarted, onEvent).ConfigureAwait(false);
}
else else
{ {
TraceHandoff(command, $"Executor invoked without a known agent match: {invoked.ExecutorId}."); 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 (evt is ExecutorCompletedEvent completed)
{ {
if (AgentIdentityResolver.TryResolveObservedAgentIdentity( if (state.TryResolveObservedAgentIdentity(completed.ExecutorId, state.ActiveAgent, out AgentIdentity completedAgent))
command.Workflow,
completed.ExecutorId,
state.ActiveAgent,
out AgentIdentity completedAgent))
{ {
TraceHandoff(command, $"Executor completed: {completed.ExecutorId} -> {completedAgent.AgentName} ({completedAgent.AgentId})."); TraceHandoff(command, $"Executor completed: {completed.ExecutorId} -> {completedAgent.AgentName} ({completedAgent.AgentId}).");
state.QueueCompletedActivity(completedAgent); state.QueueCompletedActivity(completedAgent);
state.ClearActiveAgentIfMatching(completedAgent); state.ClearActiveAgentIfMatching(completedAgent);
} }
else if (state.TryCreateSubworkflowLifecycleActivity(
"subworkflow-completed",
completed.ExecutorId,
out AgentActivityEventDto subworkflowCompleted))
{
TraceHandoff(
command,
$"Sub-workflow executor completed: {completed.ExecutorId} -> {subworkflowCompleted.SubworkflowName ?? subworkflowCompleted.SubworkflowNodeId ?? "<unknown>"}.");
await EmitActivityAsync(command, state, subworkflowCompleted, onEvent).ConfigureAwait(false);
}
else else
{ {
TraceHandoff(command, $"Executor completed without a known agent match: {completed.ExecutorId}."); TraceHandoff(command, $"Executor completed without a known agent match: {completed.ExecutorId}.");
@@ -470,11 +483,10 @@ public class AgentWorkflowTurnRunner : ITurnWorkflowRunner
updateAgent = observedMessageAgent; updateAgent = observedMessageAgent;
authorName = observedMessageAgent.AgentName; authorName = observedMessageAgent.AgentName;
} }
else if (AgentIdentityResolver.TryResolveObservedAgentIdentity( else if (state.TryResolveObservedAgentIdentity(
command.Workflow, update.ExecutorId,
update.ExecutorId, state.ActiveAgent,
state.ActiveAgent, out AgentIdentity resolvedUpdateAgent))
out AgentIdentity resolvedUpdateAgent))
{ {
updateAgent = resolvedUpdateAgent; updateAgent = resolvedUpdateAgent;
authorName = resolvedUpdateAgent.AgentName; authorName = resolvedUpdateAgent.AgentName;
@@ -545,11 +557,12 @@ public class AgentWorkflowTurnRunner : ITurnWorkflowRunner
&& !string.IsNullOrWhiteSpace(activity.AgentId) && !string.IsNullOrWhiteSpace(activity.AgentId)
&& !string.IsNullOrWhiteSpace(activity.AgentName)) && !string.IsNullOrWhiteSpace(activity.AgentName))
{ {
AgentIdentity promotedAgent = state.ResolveAgentIdentity(activity.AgentId, activity.AgentName);
TraceHandoff( TraceHandoff(
command, command,
$"Promoting handoff target to thinking: {activity.AgentName} ({activity.AgentId})."); $"Promoting handoff target to thinking: {promotedAgent.AgentName} ({promotedAgent.AgentId}).");
await state.EmitThinkingIfNeeded( await state.EmitThinkingIfNeeded(
new AgentIdentity(activity.AgentId, activity.AgentName), promotedAgent,
onEvent).ConfigureAwait(false); onEvent).ConfigureAwait(false);
} }
} }
@@ -593,8 +606,7 @@ public class AgentWorkflowTurnRunner : ITurnWorkflowRunner
{ {
case ExecutorFailedEvent executorFailed: case ExecutorFailedEvent executorFailed:
{ {
AgentIdentity? agent = AgentIdentityResolver.TryResolveObservedAgentIdentity( AgentIdentity? agent = state.TryResolveObservedAgentIdentity(
command.Workflow,
executorFailed.ExecutorId, executorFailed.ExecutorId,
state.ActiveAgent, state.ActiveAgent,
out AgentIdentity resolvedAgent) out AgentIdentity resolvedAgent)
@@ -7,6 +7,8 @@ namespace Aryx.AgentHost.Services;
internal class TurnExecutionState internal class TurnExecutionState
{ {
private readonly RunTurnCommandDto _command; private readonly RunTurnCommandDto _command;
private readonly IReadOnlyDictionary<string, WorkflowDefinitionDto> _workflowLibrary;
private readonly IReadOnlyDictionary<string, SubworkflowContext> _agentSubworkflowIndex;
private readonly HashSet<string> _startedAgents = new(StringComparer.OrdinalIgnoreCase); private readonly HashSet<string> _startedAgents = new(StringComparer.OrdinalIgnoreCase);
private readonly HashSet<string> _reclassifiedMessageIds = new(StringComparer.Ordinal); private readonly HashSet<string> _reclassifiedMessageIds = new(StringComparer.Ordinal);
private readonly ConcurrentQueue<SidecarEventDto> _pendingEvents = new(); private readonly ConcurrentQueue<SidecarEventDto> _pendingEvents = new();
@@ -19,6 +21,8 @@ internal class TurnExecutionState
public TurnExecutionState(RunTurnCommandDto command) public TurnExecutionState(RunTurnCommandDto command)
{ {
_command = command; _command = command;
_workflowLibrary = WorkflowDefinitionExtensions.CreateWorkflowLibraryMap(command.WorkflowLibrary);
_agentSubworkflowIndex = AgentIdentityResolver.BuildAgentSubworkflowIndex(command.Workflow, _workflowLibrary);
} }
public ConcurrentDictionary<string, string> ToolNamesByCallId { get; } = new(StringComparer.Ordinal); public ConcurrentDictionary<string, string> ToolNamesByCallId { get; } = new(StringComparer.Ordinal);
@@ -33,6 +37,59 @@ internal class TurnExecutionState
public bool SuppressHookLifecycleEvents { get; set; } public bool SuppressHookLifecycleEvents { get; set; }
public AgentIdentity ResolveAgentIdentity(string? agentId, string? agentName)
{
return AgentIdentityResolver.ResolveAgentIdentity(
_command.Workflow,
_workflowLibrary,
agentId,
agentName,
_agentSubworkflowIndex);
}
public bool TryResolveKnownAgentIdentity(string? agentIdentifier, out AgentIdentity agent)
{
return AgentIdentityResolver.TryResolveKnownAgentIdentity(
_command.Workflow,
_workflowLibrary,
agentIdentifier,
_agentSubworkflowIndex,
out agent);
}
public bool TryResolveObservedAgentIdentity(
string? agentIdentifier,
AgentIdentity? fallbackAgent,
out AgentIdentity agent)
{
return AgentIdentityResolver.TryResolveObservedAgentIdentity(
_command.Workflow,
_workflowLibrary,
agentIdentifier,
fallbackAgent,
_agentSubworkflowIndex,
out agent);
}
public bool TryCreateSubworkflowLifecycleActivity(
string activityType,
string? executorId,
out AgentActivityEventDto activity)
{
activity = default!;
if (!AgentIdentityResolver.TryResolveSubworkflowContext(
_command.Workflow,
_workflowLibrary,
executorId,
out SubworkflowContext subworkflow))
{
return false;
}
activity = CreateSubworkflowActivity(activityType, subworkflow);
return true;
}
public async Task EmitThinkingIfNeeded( public async Task EmitThinkingIfNeeded(
AgentIdentity agent, AgentIdentity agent,
Func<SidecarEventDto, Task> onEvent) Func<SidecarEventDto, Task> onEvent)
@@ -67,14 +124,13 @@ internal class TurnExecutionState
&& !string.IsNullOrWhiteSpace(activity.AgentId) && !string.IsNullOrWhiteSpace(activity.AgentId)
&& !string.IsNullOrWhiteSpace(activity.AgentName)) && !string.IsNullOrWhiteSpace(activity.AgentName))
{ {
ActiveAgent = new AgentIdentity(activity.AgentId, activity.AgentName); ActiveAgent = ResolveAgentIdentity(activity.AgentId, activity.AgentName);
} }
} }
public void ObserveSessionEvent(WorkflowNodeDto agentDefinition, ProviderSessionEvent sessionEvent) public void ObserveSessionEvent(WorkflowNodeDto agentDefinition, ProviderSessionEvent sessionEvent)
{ {
AgentIdentity agent = AgentIdentityResolver.ResolveAgentIdentity( AgentIdentity agent = ResolveAgentIdentity(
_command.Workflow,
agentDefinition.GetAgentId(), agentDefinition.GetAgentId(),
agentDefinition.GetAgentName()); agentDefinition.GetAgentName());
@@ -313,6 +369,8 @@ internal class TurnExecutionState
ActivityType = "thinking", ActivityType = "thinking",
AgentId = agent.AgentId, AgentId = agent.AgentId,
AgentName = agent.AgentName, AgentName = agent.AgentName,
SubworkflowNodeId = agent.Subworkflow?.SubworkflowNodeId,
SubworkflowName = agent.Subworkflow?.SubworkflowName,
}; };
} }
@@ -335,6 +393,8 @@ internal class TurnExecutionState
ActivityType = "tool-calling", ActivityType = "tool-calling",
AgentId = agent.AgentId, AgentId = agent.AgentId,
AgentName = agent.AgentName, AgentName = agent.AgentName,
SubworkflowNodeId = agent.Subworkflow?.SubworkflowNodeId,
SubworkflowName = agent.Subworkflow?.SubworkflowName,
ToolName = toolName, ToolName = toolName,
ToolCallId = toolCallId, ToolCallId = toolCallId,
ToolArguments = toolArguments, ToolArguments = toolArguments,
@@ -351,6 +411,23 @@ internal class TurnExecutionState
ActivityType = "completed", ActivityType = "completed",
AgentId = agent.AgentId, AgentId = agent.AgentId,
AgentName = agent.AgentName, 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,
}; };
} }
@@ -45,6 +45,12 @@ internal static class WorkflowDefinitionExtensions
return string.Equals(node.Kind, "agent", StringComparison.OrdinalIgnoreCase); 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) public static string GetAgentId(this WorkflowNodeDto node)
{ {
ArgumentNullException.ThrowIfNull(node); ArgumentNullException.ThrowIfNull(node);
@@ -87,6 +93,67 @@ internal static class WorkflowDefinitionExtensions
return string.Equals(workflow.Settings.OrchestrationMode, mode, StringComparison.OrdinalIgnoreCase); return string.Equals(workflow.Settings.OrchestrationMode, mode, StringComparison.OrdinalIgnoreCase);
} }
public static WorkflowNodeDto? FindSubWorkflowNode(
this WorkflowDefinitionDto workflow,
string? nodeId,
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null)
{
ArgumentNullException.ThrowIfNull(workflow);
string? normalizedNodeId = NormalizeOptionalString(nodeId);
if (normalizedNodeId is null)
{
return null;
}
return FindSubWorkflowNode(
workflow,
normalizedNodeId,
CreateWorkflowLibraryMap(workflowLibrary),
new HashSet<string>(StringComparer.Ordinal),
new HashSet<WorkflowDefinitionDto>(ReferenceEqualityComparer.Instance));
}
internal static WorkflowNodeDto? FindSubWorkflowNode(
this WorkflowDefinitionDto workflow,
string? nodeId,
IReadOnlyDictionary<string, WorkflowDefinitionDto>? workflowLibrary)
{
ArgumentNullException.ThrowIfNull(workflow);
string? normalizedNodeId = NormalizeOptionalString(nodeId);
if (normalizedNodeId is null)
{
return null;
}
return FindSubWorkflowNode(
workflow,
normalizedNodeId,
workflowLibrary ?? EmptyWorkflowLibrary,
new HashSet<string>(StringComparer.Ordinal),
new HashSet<WorkflowDefinitionDto>(ReferenceEqualityComparer.Instance));
}
internal static string GetSubworkflowDisplayName(
this WorkflowNodeDto node,
IReadOnlyDictionary<string, WorkflowDefinitionDto>? workflowLibrary)
{
ArgumentNullException.ThrowIfNull(node);
WorkflowDefinitionDto? resolvedWorkflow = null;
if (node.Config.InlineWorkflow is not null)
{
resolvedWorkflow = node.Config.InlineWorkflow;
}
else if (!string.IsNullOrWhiteSpace(node.Config.WorkflowId)
&& workflowLibrary is not null
&& workflowLibrary.TryGetValue(node.Config.WorkflowId, out WorkflowDefinitionDto? workflow))
{
resolvedWorkflow = workflow;
}
return FirstNonBlank(node.Label, resolvedWorkflow?.Name, node.Config.WorkflowId, node.Id) ?? "sub-workflow";
}
private static readonly IReadOnlyDictionary<string, WorkflowDefinitionDto> EmptyWorkflowLibrary = private static readonly IReadOnlyDictionary<string, WorkflowDefinitionDto> EmptyWorkflowLibrary =
new Dictionary<string, WorkflowDefinitionDto>(StringComparer.Ordinal); new Dictionary<string, WorkflowDefinitionDto>(StringComparer.Ordinal);
@@ -128,7 +195,55 @@ internal static class WorkflowDefinitionExtensions
} }
} }
private static Dictionary<string, WorkflowDefinitionDto> CreateWorkflowLibraryMap( private static WorkflowNodeDto? FindSubWorkflowNode(
WorkflowDefinitionDto workflowDefinition,
string nodeId,
IReadOnlyDictionary<string, WorkflowDefinitionDto> workflowLibrary,
ISet<string> visitedWorkflowIds,
ISet<WorkflowDefinitionDto> visitedAnonymousWorkflows)
{
string? workflowId = NormalizeOptionalString(workflowDefinition.Id);
if (workflowId is not null)
{
if (!visitedWorkflowIds.Add(workflowId))
{
return null;
}
}
else if (!visitedAnonymousWorkflows.Add(workflowDefinition))
{
return null;
}
foreach (WorkflowNodeDto node in workflowDefinition.Graph.Nodes)
{
if (!node.IsSubWorkflowNode())
{
continue;
}
if (string.Equals(node.Id, nodeId, StringComparison.OrdinalIgnoreCase))
{
return node;
}
WorkflowDefinitionDto subWorkflow = node.ResolveSubWorkflowDefinition(workflowLibrary);
WorkflowNodeDto? match = FindSubWorkflowNode(
subWorkflow,
nodeId,
workflowLibrary,
visitedWorkflowIds,
visitedAnonymousWorkflows);
if (match is not null)
{
return match;
}
}
return null;
}
internal static Dictionary<string, WorkflowDefinitionDto> CreateWorkflowLibraryMap(
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary) IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary)
{ {
return workflowLibrary? return workflowLibrary?
@@ -23,7 +23,7 @@ internal static class WorkflowRequestInfoInterpreter
ConcurrentDictionary<string, string> toolNamesByCallId, ConcurrentDictionary<string, string> toolNamesByCallId,
ConcurrentDictionary<string, bool> toolCallHasArgumentsById) ConcurrentDictionary<string, bool> toolCallHasArgumentsById)
{ {
RequestInterpretation interpretation = InterpretRequest(command.Workflow, requestInfo); RequestInterpretation interpretation = InterpretRequest(command, requestInfo);
return interpretation switch return interpretation switch
{ {
HandoffRequestInterpretation handoff => HandoffRequestInterpretation handoff =>
@@ -39,7 +39,7 @@ internal static class WorkflowRequestInfoInterpreter
RequestInfoEvent requestInfo) RequestInfoEvent requestInfo)
{ {
return command.Workflow.IsOrchestrationMode("handoff") return command.Workflow.IsOrchestrationMode("handoff")
&& InterpretRequest(command.Workflow, requestInfo) is UnknownRequestInterpretation; && InterpretRequest(command, requestInfo) is UnknownRequestInterpretation;
} }
private static AgentActivityEventDto CreateHandoffActivity( private static AgentActivityEventDto CreateHandoffActivity(
@@ -55,6 +55,8 @@ internal static class WorkflowRequestInfoInterpreter
ActivityType = HandoffActivityType, ActivityType = HandoffActivityType,
AgentId = handoffAgent.AgentId, AgentId = handoffAgent.AgentId,
AgentName = handoffAgent.AgentName, AgentName = handoffAgent.AgentName,
SubworkflowNodeId = handoffAgent.Subworkflow?.SubworkflowNodeId,
SubworkflowName = handoffAgent.Subworkflow?.SubworkflowName,
SourceAgentId = activeAgent?.AgentId, SourceAgentId = activeAgent?.AgentId,
SourceAgentName = activeAgent?.AgentName, SourceAgentName = activeAgent?.AgentName,
}; };
@@ -88,6 +90,8 @@ internal static class WorkflowRequestInfoInterpreter
ActivityType = ToolCallingActivityType, ActivityType = ToolCallingActivityType,
AgentId = activeAgent.AgentId, AgentId = activeAgent.AgentId,
AgentName = activeAgent.AgentName, AgentName = activeAgent.AgentName,
SubworkflowNodeId = activeAgent.Subworkflow?.SubworkflowNodeId,
SubworkflowName = activeAgent.Subworkflow?.SubworkflowName,
ToolName = tool.ToolName, ToolName = tool.ToolName,
ToolCallId = tool.ToolCallId, ToolCallId = tool.ToolCallId,
ToolArguments = tool.ToolArguments, ToolArguments = tool.ToolArguments,
@@ -109,10 +113,10 @@ internal static class WorkflowRequestInfoInterpreter
} }
private static RequestInterpretation InterpretRequest( private static RequestInterpretation InterpretRequest(
WorkflowDefinitionDto workflow, RunTurnCommandDto command,
RequestInfoEvent requestInfo) RequestInfoEvent requestInfo)
{ {
if (TryGetHandoffTarget(workflow, requestInfo, out AgentIdentity handoffAgent)) if (TryGetHandoffTarget(command, requestInfo, out AgentIdentity handoffAgent))
{ {
return new HandoffRequestInterpretation(handoffAgent); return new HandoffRequestInterpretation(handoffAgent);
} }
@@ -123,7 +127,7 @@ internal static class WorkflowRequestInfoInterpreter
} }
private static bool TryGetHandoffTarget( private static bool TryGetHandoffTarget(
WorkflowDefinitionDto workflow, RunTurnCommandDto command,
RequestInfoEvent requestInfo, RequestInfoEvent requestInfo,
out AgentIdentity agent) out AgentIdentity agent)
{ {
@@ -142,7 +146,8 @@ internal static class WorkflowRequestInfoInterpreter
} }
agent = AgentIdentityResolver.ResolveAgentIdentity( agent = AgentIdentityResolver.ResolveAgentIdentity(
workflow, command.Workflow,
command.WorkflowLibrary,
target.Id, target.Id,
target.Name); target.Name);
return !string.IsNullOrWhiteSpace(agent.AgentName); return !string.IsNullOrWhiteSpace(agent.AgentName);
@@ -115,19 +115,86 @@ public sealed class AgentIdentityResolverTests
Assert.Equal("UX Specialist", agent.AgentName); Assert.Equal("UX Specialist", agent.AgentName);
} }
[Fact]
public void TryResolveKnownAgentIdentity_ResolvesReferencedSubworkflowAgentWithContext()
{
WorkflowDefinitionDto nestedWorkflow = CreateWorkflow(
"nested-review-workflow",
[
CreateAgent("agent-reviewer", "Reviewer"),
],
orchestrationMode: "single");
WorkflowDefinitionDto workflow = CreateWorkflow(
"parent-workflow",
[
CreateSubworkflow("subworkflow-review", "Review Lane", workflowId: nestedWorkflow.Id),
],
orchestrationMode: "single");
bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity(
workflow,
[nestedWorkflow],
"Reviewer_agent_reviewer",
out AgentIdentity agent);
Assert.True(resolved);
Assert.Equal("agent-reviewer", agent.AgentId);
Assert.Equal("Reviewer", agent.AgentName);
Assert.Equal("subworkflow-review", agent.Subworkflow?.SubworkflowNodeId);
Assert.Equal("Review Lane", agent.Subworkflow?.SubworkflowName);
}
[Fact]
public void BuildAgentSubworkflowIndex_UsesImmediateNestedSubworkflowContext()
{
WorkflowDefinitionDto innerWorkflow = CreateWorkflow(
"inner-workflow",
[
CreateAgent("agent-inner-reviewer", "Inner Reviewer"),
],
orchestrationMode: "single");
WorkflowDefinitionDto outerWorkflow = CreateWorkflow(
"outer-workflow",
[
CreateSubworkflow("subworkflow-inner", "Inner Review", inlineWorkflow: innerWorkflow),
],
orchestrationMode: "single");
WorkflowDefinitionDto workflow = CreateWorkflow(
"parent-workflow",
[
CreateSubworkflow("subworkflow-outer", "Outer Review", inlineWorkflow: outerWorkflow),
],
orchestrationMode: "single");
IReadOnlyDictionary<string, SubworkflowContext> index =
AgentIdentityResolver.BuildAgentSubworkflowIndex(workflow);
Assert.True(index.TryGetValue("agent-inner-reviewer", out SubworkflowContext subworkflow));
Assert.Equal("subworkflow-inner", subworkflow.SubworkflowNodeId);
Assert.Equal("Inner Review", subworkflow.SubworkflowName);
}
private static WorkflowDefinitionDto CreateWorkflow( private static WorkflowDefinitionDto CreateWorkflow(
IReadOnlyList<WorkflowNodeDto> agents, IReadOnlyList<WorkflowNodeDto> nodes,
string orchestrationMode = "concurrent")
{
return CreateWorkflow($"{orchestrationMode}-workflow", nodes, orchestrationMode);
}
private static WorkflowDefinitionDto CreateWorkflow(
string id,
IReadOnlyList<WorkflowNodeDto> nodes,
string orchestrationMode = "concurrent") string orchestrationMode = "concurrent")
{ {
return new WorkflowDefinitionDto return new WorkflowDefinitionDto
{ {
Id = $"{orchestrationMode}-workflow", Id = id,
Name = "Workflow", Name = "Workflow",
Graph = new WorkflowGraphDto Graph = new WorkflowGraphDto
{ {
Nodes = Nodes =
[ [
.. agents, .. nodes,
], ],
}, },
Settings = new WorkflowSettingsDto 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,
},
};
}
} }
@@ -59,6 +59,40 @@ public sealed class CopilotTurnExecutionStateTests
Assert.Equal("agent-1", observedAgent.AgentId); Assert.Equal("agent-1", observedAgent.AgentId);
} }
[Fact]
public void ObserveSessionEvent_AssistantMessageDelta_ForNestedAgent_IncludesSubworkflowContext()
{
RunTurnCommandDto command = CreateCommandWithReferencedSubworkflow();
CopilotTurnExecutionState state = new(command);
WorkflowDefinitionDto nestedWorkflow = Assert.Single(command.WorkflowLibrary!);
WorkflowNodeDto nestedAgent = Assert.Single(nestedWorkflow.GetAgentNodes());
state.ObserveSessionEvent(
nestedAgent,
SessionEvent.FromJson(
"""
{
"type": "assistant.message_delta",
"data": {
"messageId": "msg-nested-1",
"deltaContent": "Reviewing"
},
"id": "7ef95d90-7ee7-45e2-ac38-cf749caf4f69",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
AgentActivityEventDto activity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
Assert.Equal("thinking", activity.ActivityType);
Assert.Equal("agent-reviewer", activity.AgentId);
Assert.Equal("Reviewer", activity.AgentName);
Assert.Equal("subworkflow-review", activity.SubworkflowNodeId);
Assert.Equal("Review Lane", activity.SubworkflowName);
Assert.True(state.ActiveAgent.HasValue);
Assert.Equal("subworkflow-review", state.ActiveAgent.Value.Subworkflow?.SubworkflowNodeId);
Assert.Equal("Review Lane", state.ActiveAgent.Value.Subworkflow?.SubworkflowName);
}
[Fact] [Fact]
public void ObserveSessionEvent_ToolExecutionStart_TracksToolNameByCallIdAndQueuesToolActivity() 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<WorkflowNodeDto> nodes)
{
return new WorkflowDefinitionDto
{
Id = id,
Name = "Execution State Workflow",
Graph = new WorkflowGraphDto
{
Nodes = [.. nodes],
},
Settings = new WorkflowSettingsDto
{
OrchestrationMode = "single",
},
};
}
private static WorkflowNodeDto CreateAgent(string id, string name)
{
return new WorkflowNodeDto
{
Id = id,
Kind = "agent",
Label = name,
Config = new WorkflowNodeConfigDto
{
Kind = "agent",
Id = id,
Name = name,
Model = "gpt-5.4",
Instructions = "Help with the request.",
},
};
}
private static WorkflowNodeDto CreateSubworkflow(
string id,
string label,
string? workflowId = null)
{
return new WorkflowNodeDto
{
Id = id,
Kind = "sub-workflow",
Label = label,
Config = new WorkflowNodeConfigDto
{
Kind = "sub-workflow",
WorkflowId = workflowId,
},
};
}
} }
@@ -1052,6 +1052,78 @@ public sealed class CopilotWorkflowRunnerTests
Assert.Equal("Primary", completed.AgentName); Assert.Equal("Primary", completed.AgentName);
} }
[Fact]
public async Task HandleWorkflowEventAsync_EmitsSubworkflowStartedActivityForSubworkflowExecutor()
{
RunTurnCommandDto command = CreateReferencedSubworkflowCommand();
CopilotTurnExecutionState state = new(command);
List<AgentActivityEventDto> activities = [];
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
"HandleWorkflowEventAsync",
BindingFlags.NonPublic | BindingFlags.Static)!;
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
null,
[
command,
new ExecutorInvokedEvent("subworkflow-review", null!),
Array.Empty<ChatMessage>(),
state,
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
(Func<SidecarEventDto, Task>)(sidecarEvent =>
{
activities.Add(Assert.IsType<AgentActivityEventDto>(sidecarEvent));
return Task.CompletedTask;
}),
])!;
bool shouldEndTurn = await handleTask;
Assert.False(shouldEndTurn);
AgentActivityEventDto activity = Assert.Single(activities);
Assert.Equal("subworkflow-started", activity.ActivityType);
Assert.Null(activity.AgentId);
Assert.Null(activity.AgentName);
Assert.Equal("subworkflow-review", activity.SubworkflowNodeId);
Assert.Equal("Review Lane", activity.SubworkflowName);
}
[Fact]
public async Task HandleWorkflowEventAsync_EmitsSubworkflowCompletedActivityForSubworkflowExecutor()
{
RunTurnCommandDto command = CreateReferencedSubworkflowCommand();
CopilotTurnExecutionState state = new(command);
List<AgentActivityEventDto> activities = [];
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
"HandleWorkflowEventAsync",
BindingFlags.NonPublic | BindingFlags.Static)!;
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
null,
[
command,
new ExecutorCompletedEvent("subworkflow-review", null),
Array.Empty<ChatMessage>(),
state,
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
(Func<SidecarEventDto, Task>)(sidecarEvent =>
{
activities.Add(Assert.IsType<AgentActivityEventDto>(sidecarEvent));
return Task.CompletedTask;
}),
])!;
bool shouldEndTurn = await handleTask;
Assert.False(shouldEndTurn);
AgentActivityEventDto activity = Assert.Single(activities);
Assert.Equal("subworkflow-completed", activity.ActivityType);
Assert.Null(activity.AgentId);
Assert.Null(activity.AgentName);
Assert.Equal("subworkflow-review", activity.SubworkflowNodeId);
Assert.Equal("Review Lane", activity.SubworkflowName);
}
[Fact] [Fact]
public async Task HandleWorkflowEventAsync_EmitsWorkflowWarningDiagnostic() 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( private static RunTurnCommandDto CreateCommand(
string orchestrationMode, string orchestrationMode,
params WorkflowNodeDto[] agents) 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( private static RunTurnCommandDto CreateCommand(
@@ -2255,12 +2353,14 @@ public sealed class CopilotWorkflowRunnerTests
OrchestrationModeSettingsDto? modeSettings = null, OrchestrationModeSettingsDto? modeSettings = null,
string? workflowName = null, string? workflowName = null,
string? workflowDescription = null, string? workflowDescription = null,
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null,
params WorkflowNodeDto[] agents) params WorkflowNodeDto[] agents)
{ {
return new RunTurnCommandDto return new RunTurnCommandDto
{ {
RequestId = "turn-1", RequestId = "turn-1",
SessionId = "session-1", SessionId = "session-1",
WorkflowLibrary = workflowLibrary ?? [],
Workflow = new WorkflowDefinitionDto Workflow = new WorkflowDefinitionDto
{ {
Id = $"workflow-{orchestrationMode}", 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() private static RunTurnCommandDto CreateHandoffCommand()
{ {
return CreateCommand( return CreateCommand(
@@ -246,6 +246,54 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.Empty(tracking.ToolCallHasArgumentsById); Assert.Empty(tracking.ToolCallHasArgumentsById);
} }
[Fact]
public void TryCreateActivityFromRequest_IncludesSubworkflowContextForToolCallingAgent()
{
var tracking = CreateToolTracking();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("call-1", "view", new Dictionary<string, object?>
{
["path"] = @"C:\workspace\file.txt",
}));
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
CreateSingleAgentCommand(),
requestInfo,
new AgentIdentity(
"agent-reviewer",
"Reviewer",
new SubworkflowContext("subworkflow-review", "Review Lane")),
tracking.ToolNamesByCallId,
tracking.ToolCallHasArgumentsById);
Assert.NotNull(activity);
Assert.Equal("tool-calling", activity.ActivityType);
Assert.Equal("subworkflow-review", activity.SubworkflowNodeId);
Assert.Equal("Review Lane", activity.SubworkflowName);
}
[Fact]
public void TryCreateActivityFromRequest_ResolvesReferencedSubworkflowContextForHandoffTargets()
{
var tracking = CreateToolTracking();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
CreateHandoffTarget("agent-handoff-ux", "UX Specialist"));
AgentActivityEventDto? activity = WorkflowRequestInfoInterpreter.TryCreateActivityFromRequest(
CreateHandoffCommandWithReferencedSubworkflow(),
requestInfo,
new AgentIdentity("agent-handoff-triage", "Triage"),
tracking.ToolNamesByCallId,
tracking.ToolCallHasArgumentsById);
Assert.NotNull(activity);
Assert.Equal("handoff", activity.ActivityType);
Assert.Equal("agent-handoff-ux", activity.AgentId);
Assert.Equal("UX Specialist", activity.AgentName);
Assert.Equal("subworkflow-review", activity.SubworkflowNodeId);
Assert.Equal("Review Lane", activity.SubworkflowName);
}
[Fact] [Fact]
public void RequiresUserInputTurnBoundary_ReturnsTrueForUnhandledHandoffRequests() public void RequiresUserInputTurnBoundary_ReturnsTrueForUnhandledHandoffRequests()
{ {
@@ -341,24 +389,56 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateAgent("agent-handoff-ux", "UX Specialist"), 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 ( private static (
ConcurrentDictionary<string, string> ToolNamesByCallId, ConcurrentDictionary<string, string> ToolNamesByCallId,
ConcurrentDictionary<string, bool> ToolCallHasArgumentsById) CreateToolTracking() ConcurrentDictionary<string, bool> ToolCallHasArgumentsById) CreateToolTracking()
=> (new(StringComparer.Ordinal), new(StringComparer.Ordinal)); => (new(StringComparer.Ordinal), new(StringComparer.Ordinal));
private static RunTurnCommandDto CreateCommand(string orchestrationMode, IReadOnlyList<WorkflowNodeDto> agents) private static RunTurnCommandDto CreateCommand(
string orchestrationMode,
IReadOnlyList<WorkflowNodeDto> nodes,
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null)
{ {
return new RunTurnCommandDto return new RunTurnCommandDto
{ {
RequestId = "turn-1", RequestId = "turn-1",
SessionId = "session-1", SessionId = "session-1",
WorkflowLibrary = workflowLibrary ?? [],
Workflow = new WorkflowDefinitionDto Workflow = new WorkflowDefinitionDto
{ {
Id = $"{orchestrationMode}-workflow", Id = $"{orchestrationMode}-workflow",
Name = "Workflow", Name = "Workflow",
Graph = new WorkflowGraphDto Graph = new WorkflowGraphDto
{ {
Nodes = [.. agents], Nodes = [.. nodes],
}, },
Settings = new WorkflowSettingsDto 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) private static RequestInfoEvent CreateRequestInfoEvent(object payload)
{ {
RequestPort port = RequestPort.Create<object, object>("test-port"); RequestPort port = RequestPort.Create<object, object>("test-port");
+3 -1
View File
@@ -2004,7 +2004,7 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
const session = this.requireSession(workspace, sessionId); const session = this.requireSession(workspace, sessionId);
const activityType = event.activityType; const activityType = event.activityType;
let nextRun: SessionRunRecord | undefined; let nextRun: SessionRunRecord | undefined;
if (activityType !== 'completed') { if (activityType === 'thinking' || activityType === 'tool-calling' || activityType === 'handoff') {
nextRun = this.updateSessionRun(session, requestId, (run) => nextRun = this.updateSessionRun(session, requestId, (run) =>
appendRunActivityEvent(run, { appendRunActivityEvent(run, {
activityType, activityType,
@@ -2032,6 +2032,8 @@ export class AryxAppService extends EventEmitter<AppServiceEvents> {
activityType: event.activityType, activityType: event.activityType,
agentId: event.agentId, agentId: event.agentId,
agentName: event.agentName, agentName: event.agentName,
subworkflowNodeId: event.subworkflowNodeId,
subworkflowName: event.subworkflowName,
sourceAgentId: event.sourceAgentId, sourceAgentId: event.sourceAgentId,
sourceAgentName: event.sourceAgentName, sourceAgentName: event.sourceAgentName,
toolName: event.toolName, toolName: event.toolName,
+3 -1
View File
@@ -675,7 +675,7 @@ export class SessionTurnExecutor {
const session = this.requireSession(workspace, sessionId); const session = this.requireSession(workspace, sessionId);
const activityType = event.activityType; const activityType = event.activityType;
let nextRun: SessionRunRecord | undefined; let nextRun: SessionRunRecord | undefined;
if (activityType !== 'completed') { if (activityType === 'thinking' || activityType === 'tool-calling' || activityType === 'handoff') {
nextRun = this.updateSessionRun(session, requestId, (run) => nextRun = this.updateSessionRun(session, requestId, (run) =>
appendRunActivityEvent(run, { appendRunActivityEvent(run, {
activityType, activityType,
@@ -703,6 +703,8 @@ export class SessionTurnExecutor {
activityType: event.activityType, activityType: event.activityType,
agentId: event.agentId, agentId: event.agentId,
agentName: event.agentName, agentName: event.agentName,
subworkflowNodeId: event.subworkflowNodeId,
subworkflowName: event.subworkflowName,
sourceAgentId: event.sourceAgentId, sourceAgentId: event.sourceAgentId,
sourceAgentName: event.sourceAgentName, sourceAgentName: event.sourceAgentName,
toolName: event.toolName, toolName: event.toolName,
+1
View File
@@ -745,6 +745,7 @@ export default function App() {
<ActivityPanel <ActivityPanel
activity={activityForSession} activity={activityForSession}
workflow={workflowForSession} workflow={workflowForSession}
workflows={workspace?.workflows}
session={selectedSession} session={selectedSession}
sessionRequestUsage={requestUsageForSession} sessionRequestUsage={requestUsageForSession}
turnEvents={turnEventsForSession} turnEvents={turnEventsForSession}
+57 -171
View File
@@ -1,59 +1,21 @@
import { useMemo, type ReactNode } from 'react'; import { useMemo, type ReactNode } from 'react';
import { Activity, AlertTriangle, ArrowRight, BarChart3, CheckCircle2, Cog, ShieldAlert, Sparkles, Users, Zap } from 'lucide-react'; import { Activity, AlertTriangle, ArrowRight, BarChart3, CheckCircle2, Cog, GitBranch, ShieldAlert, Sparkles, Users, Zap } from 'lucide-react';
import { import {
buildAgentActivityRows, buildAgentActivityRows,
formatAgentActivityLabel, buildGroupedActivityRows,
formatDuration, formatDuration,
formatNanoAiu, formatNanoAiu,
formatTokenCount, formatTokenCount,
isAgentActivityActive,
isAgentActivityCompleted,
type AgentActivityRow,
type AgentUsageAccumulator,
type SessionActivityState, type SessionActivityState,
type SessionRequestUsageState, type SessionRequestUsageState,
type TurnEventLog, type TurnEventLog,
} from '@renderer/lib/sessionActivity'; } from '@renderer/lib/sessionActivity';
import { inferProvider } from '@shared/domain/models'; import { resolveWorkflowAgentHierarchy, type AgentNodeConfig, type WorkflowDefinition } from '@shared/domain/workflow';
import { resolveWorkflowAgentNodes, type AgentNodeConfig, type WorkflowDefinition, type WorkflowOrchestrationMode } from '@shared/domain/workflow';
import type { SessionRecord } from '@shared/domain/session'; import type { SessionRecord } from '@shared/domain/session';
import { ProviderIcon } from './ProviderIcons'; import { AgentRow } from './activity/AgentRow';
import { SubWorkflowGroup } from './activity/SubWorkflowGroup';
/* ── Mode accent colours ───────────────────────────────────── */ import { modeAccent, modeLabels } from './activity/constants';
const modeAccent: Record<WorkflowOrchestrationMode, { dot: string; bar: string; label: string }> = {
single: { dot: 'bg-[#245CF9]', bar: 'bg-[#245CF9] opacity-60', label: 'text-[#245CF9]' },
sequential: { dot: 'bg-[var(--color-status-warning)]', bar: 'bg-[var(--color-status-warning)] opacity-60', label: 'text-[var(--color-status-warning)]' },
concurrent: { dot: 'bg-[var(--color-status-success)]', bar: 'bg-[var(--color-status-success)] opacity-60', label: 'text-[var(--color-status-success)]' },
handoff: { dot: 'bg-[var(--color-accent-sky)]', bar: 'bg-[var(--color-accent-sky)] opacity-60', label: 'text-[var(--color-accent-sky)]' },
'group-chat': { dot: 'bg-[var(--color-accent-purple)]', bar: 'bg-[var(--color-accent-purple)] opacity-60', label: 'text-[var(--color-accent-purple)]' },
};
/* ── Helpers ───────────────────────────────────────────────── */
function formatModel(model: string): string {
return model.replace(/-/g, '\u2011');
}
function formatEffort(effort: string | undefined): string | undefined {
if (!effort) return undefined;
const labels: Record<string, string> = {
low: 'Low',
medium: 'Medium',
high: 'High',
xhigh: 'Max',
};
return labels[effort] ?? effort;
}
const modeLabels: Record<WorkflowOrchestrationMode, string> = {
single: 'Single agent',
sequential: 'Sequential',
concurrent: 'Concurrent',
handoff: 'Handoff',
'group-chat': 'Group chat',
};
/* ── Section header ────────────────────────────────────────── */ /* ── Section header ────────────────────────────────────────── */
@@ -65,112 +27,6 @@ function SectionHeader({ children }: { children: ReactNode }) {
); );
} }
/* ── Agent row ─────────────────────────────────────────────── */
function AgentRow({
row,
agent,
accent,
isLast,
agentUsage,
}: {
row: AgentActivityRow;
agent?: AgentNodeConfig;
accent: (typeof modeAccent)[WorkflowOrchestrationMode];
isLast: boolean;
agentUsage?: AgentUsageAccumulator;
}) {
const isActive = isAgentActivityActive(row.activity);
const isCompleted = isAgentActivityCompleted(row.activity);
return (
<div className={`relative flex gap-2.5 py-2.5 ${isLast ? '' : 'border-b border-[var(--color-border-subtle)]'}`}>
{/* Left accent bar — visible only when this agent is actively working */}
{isActive && (
<div className={`absolute -left-3 bottom-2 top-2 w-[3px] rounded-full ${accent.bar}`} />
)}
{/* Status dot */}
<div className="flex shrink-0 pt-0.5">
<span
className={`size-2 rounded-full transition-all duration-200 ${
isActive
? `animate-pulse ${accent.dot} ring-2 ring-[var(--color-border-glow)]`
: isCompleted
? 'bg-[var(--color-status-success)]'
: 'bg-[var(--color-surface-3)]'
}`}
/>
</div>
{/* Content */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-[12px] font-medium text-[var(--color-text-primary)]">{row.agentName}</span>
</div>
{/* Model + effort inline */}
{agent && (
<div className="mt-1 flex flex-wrap items-center gap-1">
<span className="inline-flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
{(() => {
const prov = inferProvider(agent.model);
return prov ? <ProviderIcon provider={prov} className="size-2.5" /> : null;
})()}
{formatModel(agent.model)}
</span>
{agent.reasoningEffort && (
<>
<span className="text-[10px] text-[var(--color-text-muted)]">·</span>
<span className="inline-flex items-center gap-0.5 text-[10px] text-[var(--color-text-muted)]">
<Sparkles className="size-2" />
{formatEffort(agent.reasoningEffort)}
</span>
</>
)}
</div>
)}
{/* Activity label */}
<div className="mt-1 flex items-center gap-1">
<span
className={`text-[10px] ${
isActive
? accent.label
: isCompleted
? 'text-[var(--color-status-success)]'
: 'text-[var(--color-text-muted)]'
}`}
>
{formatAgentActivityLabel(row.activity)}
</span>
</div>
{/* Per-agent usage summary */}
{agentUsage && agentUsage.requestCount > 0 && (
<div className="mt-0.5 flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
<span className="font-mono tabular-nums">{formatTokenCount(agentUsage.inputTokens)} in</span>
<span className="text-[var(--color-text-muted)]">·</span>
<span className="font-mono tabular-nums">{formatTokenCount(agentUsage.outputTokens)} out</span>
{agentUsage.cost > 0 && (
<>
<span className="text-[var(--color-text-muted)]">·</span>
<span className="font-mono tabular-nums">{agentUsage.cost.toFixed(2)} cost</span>
</>
)}
{agentUsage.durationMs > 0 && (
<>
<span className="text-[var(--color-text-muted)]">·</span>
<span className="font-mono tabular-nums">{formatDuration(agentUsage.durationMs)}</span>
</>
)}
</div>
)}
</div>
</div>
);
}
/* ── Turn event helpers ─────────────────────────────────────── */ /* ── Turn event helpers ─────────────────────────────────────── */
import type { SessionEventKind } from '@shared/domain/event'; 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 }) { function TurnEventIcon({ kind, phase, success }: { kind: SessionEventKind; phase?: string; success?: boolean }) {
const base = 'size-3'; const base = 'size-3';
switch (kind) { switch (kind) {
case 'agent-activity':
return <GitBranch className={`${base} ${phase === 'start' ? 'text-[var(--color-accent-sky)]' : 'text-[var(--color-status-success)]'}`} />;
case 'subagent': case 'subagent':
return <ArrowRight className={`${base} ${success === false ? 'text-[var(--color-status-error)]' : 'text-[var(--color-accent-sky)]'}`} />; return <ArrowRight className={`${base} ${success === false ? 'text-[var(--color-status-error)]' : 'text-[var(--color-accent-sky)]'}`} />;
case 'hook-lifecycle': case 'hook-lifecycle':
@@ -207,6 +65,7 @@ function formatTurnEventTimestamp(iso: string): string {
interface ActivityPanelProps { interface ActivityPanelProps {
activity?: SessionActivityState; activity?: SessionActivityState;
workflow: WorkflowDefinition; workflow: WorkflowDefinition;
workflows?: ReadonlyArray<WorkflowDefinition>;
session: SessionRecord; session: SessionRecord;
sessionRequestUsage?: SessionRequestUsageState; sessionRequestUsage?: SessionRequestUsageState;
turnEvents?: TurnEventLog; turnEvents?: TurnEventLog;
@@ -215,32 +74,40 @@ interface ActivityPanelProps {
export function ActivityPanel({ export function ActivityPanel({
activity, activity,
workflow, workflow,
workflows,
session, session,
sessionRequestUsage, sessionRequestUsage,
turnEvents, turnEvents,
}: ActivityPanelProps) { }: ActivityPanelProps) {
const workflowAgents = useMemo( const resolveOptions = useMemo(() => ({
() => resolveWorkflowAgentNodes(workflow) resolveWorkflow: (id: string) => workflows?.find((w) => w.id === id),
.map((n) => n.config) }), [workflows]);
.filter((c): c is AgentNodeConfig => c.kind === 'agent'),
[workflow],
);
const workflowMode = workflow.settings.orchestrationMode ?? 'single';
const activityRows = useMemo( const hierarchy = useMemo(
() => buildAgentActivityRows(activity, workflowAgents), () => resolveWorkflowAgentHierarchy(workflow, resolveOptions),
[activity, workflowAgents], [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 isBusy = session.status === 'running';
const hasPendingApproval = session.pendingApproval?.status === 'pending'; const hasPendingApproval = session.pendingApproval?.status === 'pending';
const queuedCount = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending').length; const queuedCount = (session.pendingApprovalQueue ?? []).filter((a) => a.status === 'pending').length;
const totalApprovalCount = (hasPendingApproval ? 1 : 0) + queuedCount; const totalApprovalCount = (hasPendingApproval ? 1 : 0) + queuedCount;
const accent = modeAccent[workflowMode] ?? modeAccent.single; const accent = modeAccent[workflowMode] ?? modeAccent.single;
const hasSubWorkflows = groupedRows.subWorkflows.length > 0;
return ( return (
<div className="flex h-full flex-col"> <div className="flex h-full flex-col">
{/* Header — top padding clears the title bar overlay zone */} {/* Header */}
<div className="drag-region border-b border-[var(--color-border)] px-4 pb-3 pt-3"> <div className="drag-region border-b border-[var(--color-border)] px-4 pb-3 pt-3">
<div className="flex min-h-8 items-center gap-2"> <div className="flex min-h-8 items-center gap-2">
<Activity className="size-4 text-[var(--color-text-muted)]" /> <Activity className="size-4 text-[var(--color-text-muted)]" />
@@ -267,32 +134,53 @@ export function ActivityPanel({
<Users className="size-3" /> <Users className="size-3" />
<span>Agents</span> <span>Agents</span>
<span className="font-mono rounded-full bg-[var(--color-surface-2)] px-1.5 py-0.5 text-[9px] tabular-nums text-[var(--color-text-muted)]"> <span className="font-mono rounded-full bg-[var(--color-surface-2)] px-1.5 py-0.5 text-[9px] tabular-nums text-[var(--color-text-muted)]">
{activityRows.length} {totalAgentCount}
</span> </span>
<span className={`ml-auto text-[9px] font-medium normal-case tracking-normal ${accent.label}`}> <span className={`ml-auto text-[9px] font-medium normal-case tracking-normal ${accent.label}`}>
{modeLabels[workflowMode]} {modeLabels[workflowMode]}
</span> </span>
</SectionHeader> </SectionHeader>
{activityRows.length > 0 ? ( {/* Top-level agents */}
{groupedRows.topLevelAgents.length > 0 && (
<div className="glass-surface rounded-lg px-3"> <div className="glass-surface rounded-lg px-3">
{activityRows.map((row, index) => { {groupedRows.topLevelAgents.map((row, index) => {
const agent = hierarchy.topLevelAgents.find((a) => a.id === row.key || a.name === row.agentName);
const agentKey = row.activity?.agentId ?? row.key; const agentKey = row.activity?.agentId ?? row.key;
const agentUsage = sessionRequestUsage?.perAgent[agentKey] const agentUsage = sessionRequestUsage?.perAgent[agentKey]
?? sessionRequestUsage?.perAgent[row.agentName]; ?? sessionRequestUsage?.perAgent[row.agentName];
return ( return (
<AgentRow <AgentRow
accent={accent}
agent={workflowAgents[index]}
agentUsage={agentUsage}
isLast={index === activityRows.length - 1}
key={row.key} key={row.key}
row={row} row={row}
agent={agent}
accent={accent}
isLast={!hasSubWorkflows && index === groupedRows.topLevelAgents.length - 1}
agentUsage={agentUsage}
/> />
); );
})} })}
</div> </div>
) : ( )}
{/* Sub-workflow groups */}
{hasSubWorkflows && (
<div className={`space-y-2 ${groupedRows.topLevelAgents.length > 0 ? 'mt-2' : ''}`}>
{groupedRows.subWorkflows.map((group) => {
const subDef = hierarchy.subWorkflows.find((sw) => sw.nodeId === group.nodeId);
return (
<SubWorkflowGroup
key={group.nodeId}
group={group}
agentConfigs={subDef?.agents ?? []}
agentUsage={sessionRequestUsage?.perAgent}
/>
);
})}
</div>
)}
{totalAgentCount === 0 && !hasSubWorkflows && (
<p className="py-4 text-center text-[11px] text-[var(--color-text-muted)]">No agents configured</p> <p className="py-4 text-center text-[11px] text-[var(--color-text-muted)]">No agents configured</p>
)} )}
</div> </div>
@@ -376,5 +264,3 @@ export function ActivityPanel({
</div> </div>
); );
} }
@@ -0,0 +1,111 @@
import { Sparkles } from 'lucide-react';
import type { AgentNodeConfig } from '@shared/domain/workflow';
import { inferProvider } from '@shared/domain/models';
import {
formatAgentActivityLabel,
formatDuration,
formatTokenCount,
isAgentActivityActive,
isAgentActivityCompleted,
type AgentActivityRow,
type AgentUsageAccumulator,
} from '@renderer/lib/sessionActivity';
import { ProviderIcon } from '@renderer/components/ProviderIcons';
import { type ModeAccent, formatEffort, formatModel } from './constants';
interface AgentRowProps {
row: AgentActivityRow;
agent?: AgentNodeConfig;
accent: ModeAccent;
isLast: boolean;
agentUsage?: AgentUsageAccumulator;
}
export function AgentRow({ row, agent, accent, isLast, agentUsage }: AgentRowProps) {
const isActive = isAgentActivityActive(row.activity);
const isCompleted = isAgentActivityCompleted(row.activity);
return (
<div className={`relative flex gap-2.5 py-2.5 ${isLast ? '' : 'border-b border-[var(--color-border-subtle)]'}`}>
{isActive && (
<div className={`absolute -left-3 bottom-2 top-2 w-[3px] rounded-full ${accent.bar}`} />
)}
{/* Status dot */}
<div className="flex shrink-0 pt-0.5">
<span
className={`size-2 rounded-full transition-all duration-200 ${
isActive
? `animate-pulse ${accent.dot} ring-2 ring-[var(--color-border-glow)]`
: isCompleted
? 'bg-[var(--color-status-success)]'
: 'bg-[var(--color-surface-3)]'
}`}
/>
</div>
{/* Content */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="truncate text-[12px] font-medium text-[var(--color-text-primary)]">{row.agentName}</span>
</div>
{agent && (
<div className="mt-1 flex flex-wrap items-center gap-1">
<span className="inline-flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
{(() => {
const prov = inferProvider(agent.model);
return prov ? <ProviderIcon provider={prov} className="size-2.5" /> : null;
})()}
{formatModel(agent.model)}
</span>
{agent.reasoningEffort && (
<>
<span className="text-[10px] text-[var(--color-text-muted)]">·</span>
<span className="inline-flex items-center gap-0.5 text-[10px] text-[var(--color-text-muted)]">
<Sparkles className="size-2" />
{formatEffort(agent.reasoningEffort)}
</span>
</>
)}
</div>
)}
<div className="mt-1 flex items-center gap-1">
<span
className={`text-[10px] ${
isActive
? accent.label
: isCompleted
? 'text-[var(--color-status-success)]'
: 'text-[var(--color-text-muted)]'
}`}
>
{formatAgentActivityLabel(row.activity)}
</span>
</div>
{agentUsage && agentUsage.requestCount > 0 && (
<div className="mt-0.5 flex items-center gap-1 text-[10px] text-[var(--color-text-muted)]">
<span className="font-mono tabular-nums">{formatTokenCount(agentUsage.inputTokens)} in</span>
<span className="text-[var(--color-text-muted)]">·</span>
<span className="font-mono tabular-nums">{formatTokenCount(agentUsage.outputTokens)} out</span>
{agentUsage.cost > 0 && (
<>
<span className="text-[var(--color-text-muted)]">·</span>
<span className="font-mono tabular-nums">{agentUsage.cost.toFixed(2)} cost</span>
</>
)}
{agentUsage.durationMs > 0 && (
<>
<span className="text-[var(--color-text-muted)]">·</span>
<span className="font-mono tabular-nums">{formatDuration(agentUsage.durationMs)}</span>
</>
)}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,145 @@
import { useCallback, useEffect, useRef, useState } from 'react';
import { ChevronDown, GitBranch } from 'lucide-react';
import type { AgentNodeConfig } from '@shared/domain/workflow';
import {
isAgentActivityActive,
type AgentUsageAccumulator,
type SubWorkflowActivityGroup,
} from '@renderer/lib/sessionActivity';
import { AgentRow } from './AgentRow';
import { modeAccent, modeLabels } from './constants';
interface SubWorkflowGroupProps {
group: SubWorkflowActivityGroup;
agentConfigs: ReadonlyArray<AgentNodeConfig>;
agentUsage?: Record<string, AgentUsageAccumulator>;
}
const statusPresentation = {
idle: {
dot: 'bg-[var(--color-surface-3)]',
text: 'text-[var(--color-text-muted)]',
label: 'Idle',
},
running: {
dot: 'animate-pulse',
text: '',
label: 'Running',
},
completed: {
dot: 'bg-[var(--color-status-success)]',
text: 'text-[var(--color-status-success)]',
label: 'Done',
},
} as const;
export function SubWorkflowGroup({ group, agentConfigs, agentUsage }: SubWorkflowGroupProps) {
const [isExpanded, setIsExpanded] = useState(false);
const prevStatusRef = useRef(group.status);
useEffect(() => {
if (prevStatusRef.current !== 'running' && group.status === 'running') {
setIsExpanded(true);
}
prevStatusRef.current = group.status;
}, [group.status]);
const toggle = useCallback(() => setIsExpanded((prev) => !prev), []);
const accent = modeAccent[group.orchestrationMode] ?? modeAccent.single;
const status = statusPresentation[group.status];
const hasActiveAgent = group.agents.some((a) => isAgentActivityActive(a.activity));
return (
<div
className="overflow-hidden rounded-lg border border-[var(--color-border-subtle)] border-l-[3px] bg-[var(--color-surface-1)]"
style={{ borderLeftColor: accent.color }}
role="group"
aria-label={`Sub-workflow: ${group.name}`}
>
{/* Collapsible header */}
<button
className="flex w-full items-center gap-2 px-3 py-2.5 text-left transition-colors hover:bg-[var(--color-surface-2)]"
onClick={toggle}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
toggle();
}
}}
aria-expanded={isExpanded}
type="button"
>
<GitBranch className="size-3.5 shrink-0 text-[var(--color-text-muted)]" />
<span className="min-w-0 flex-1 truncate text-[12px] font-medium text-[var(--color-text-primary)]">
{group.name}
</span>
{/* Status badge */}
<span className="flex items-center gap-1" aria-live="polite">
<span
className={`size-1.5 rounded-full ${
group.status === 'running'
? `${accent.dot} ${status.dot} ring-1 ring-[var(--color-border-glow)]`
: status.dot
}`}
/>
<span className={`text-[9px] font-medium ${group.status === 'running' ? accent.label : status.text}`}>
{status.label}
</span>
</span>
{/* Agent count pill */}
<span className="font-mono rounded-full bg-[var(--color-surface-2)] px-1.5 py-0.5 text-[9px] tabular-nums text-[var(--color-text-muted)]">
{group.agents.length}
</span>
<ChevronDown
className={`size-3 shrink-0 text-[var(--color-text-muted)] transition-transform duration-150 ${
isExpanded ? 'rotate-180' : ''
}`}
/>
</button>
{/* Expandable agent list */}
<div
className="grid transition-[grid-template-rows] duration-150 ease-out"
style={{ gridTemplateRows: isExpanded ? '1fr' : '0fr' }}
>
<div className="overflow-hidden">
<div className="relative border-t border-[var(--color-border-subtle)] py-1 pl-5 pr-3">
{/* Connecting vertical accent line */}
{hasActiveAgent && (
<div className={`absolute bottom-3 left-[11px] top-3 w-px ${accent.bar}`} />
)}
{group.agents.map((row, index) => {
const agent = agentConfigs.find((c) => c.id === row.key || c.name === row.agentName);
const usage = agentUsage?.[row.activity?.agentId ?? row.key] ?? agentUsage?.[row.agentName];
return (
<AgentRow
key={row.key}
row={row}
agent={agent}
accent={accent}
isLast={index === group.agents.length - 1}
agentUsage={usage}
/>
);
})}
{/* Mode label */}
<div className="flex items-center gap-1 pb-1 pt-0.5">
<span className={`text-[9px] font-medium ${accent.label}`}>
{modeLabels[group.orchestrationMode]}
</span>
</div>
</div>
</div>
</div>
</div>
);
}
@@ -0,0 +1,34 @@
import type { WorkflowOrchestrationMode } from '@shared/domain/workflow';
export interface ModeAccent {
dot: string;
bar: string;
label: string;
color: string;
}
export const modeAccent: Record<WorkflowOrchestrationMode, ModeAccent> = {
single: { dot: 'bg-[#245CF9]', bar: 'bg-[#245CF9] opacity-60', label: 'text-[#245CF9]', color: '#245CF9' },
sequential: { dot: 'bg-[var(--color-status-warning)]', bar: 'bg-[var(--color-status-warning)] opacity-60', label: 'text-[var(--color-status-warning)]', color: 'var(--color-status-warning)' },
concurrent: { dot: 'bg-[var(--color-status-success)]', bar: 'bg-[var(--color-status-success)] opacity-60', label: 'text-[var(--color-status-success)]', color: 'var(--color-status-success)' },
handoff: { dot: 'bg-[var(--color-accent-sky)]', bar: 'bg-[var(--color-accent-sky)] opacity-60', label: 'text-[var(--color-accent-sky)]', color: 'var(--color-accent-sky)' },
'group-chat': { dot: 'bg-[var(--color-accent-purple)]', bar: 'bg-[var(--color-accent-purple)] opacity-60', label: 'text-[var(--color-accent-purple)]', color: 'var(--color-accent-purple)' },
};
export const modeLabels: Record<WorkflowOrchestrationMode, string> = {
single: 'Single agent',
sequential: 'Sequential',
concurrent: 'Concurrent',
handoff: 'Handoff',
'group-chat': 'Group chat',
};
export function formatModel(model: string): string {
return model.replace(/-/g, '\u2011');
}
export function formatEffort(effort: string | undefined): string | undefined {
if (!effort) return undefined;
const labels: Record<string, string> = { low: 'Low', medium: 'Medium', high: 'High', xhigh: 'Max' };
return labels[effort] ?? effort;
}
+115 -4
View File
@@ -1,4 +1,4 @@
import type { AgentNodeConfig } from '@shared/domain/workflow'; import type { AgentNodeConfig, WorkflowAgentHierarchy, WorkflowOrchestrationMode } from '@shared/domain/workflow';
import type { SessionEventRecord } from '@shared/domain/event'; import type { SessionEventRecord } from '@shared/domain/event';
import type { QuotaSnapshot, WorkflowDiagnosticKind, WorkflowDiagnosticSeverity } from '@shared/contracts/sidecar'; import type { QuotaSnapshot, WorkflowDiagnosticKind, WorkflowDiagnosticSeverity } from '@shared/contracts/sidecar';
@@ -8,6 +8,8 @@ export interface AgentActivityState {
activityType?: SessionEventRecord['activityType']; activityType?: SessionEventRecord['activityType'];
toolName?: string; toolName?: string;
toolArguments?: Record<string, unknown>; toolArguments?: Record<string, unknown>;
subworkflowNodeId?: string;
subworkflowName?: string;
} }
export interface SessionUsageState { export interface SessionUsageState {
@@ -31,9 +33,14 @@ export function applySessionEventActivity(
event: SessionEventRecord, event: SessionEventRecord,
): SessionActivityMap { ): SessionActivityMap {
if (event.kind === 'agent-activity') { 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) { 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; return current;
} }
@@ -43,10 +50,12 @@ export function applySessionEventActivity(
...(current[event.sessionId] ?? {}), ...(current[event.sessionId] ?? {}),
[agentKey]: { [agentKey]: {
agentId: event.agentId ?? 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, activityType: event.activityType,
toolName: event.toolName, toolName: event.toolName,
toolArguments: event.toolArguments, toolArguments: event.toolArguments,
subworkflowNodeId: event.subworkflowNodeId,
subworkflowName: event.subworkflowName,
}, },
}, },
}; };
@@ -146,6 +155,88 @@ export function isAgentActivityCompleted(activity: AgentActivityState | undefine
return activity?.activityType === 'completed'; return activity?.activityType === 'completed';
} }
export type SubWorkflowGroupStatus = 'idle' | 'running' | 'completed';
export interface SubWorkflowActivityGroup {
nodeId: string;
name: string;
workflowId?: string;
orchestrationMode: WorkflowOrchestrationMode;
status: SubWorkflowGroupStatus;
agents: AgentActivityRow[];
}
export interface GroupedActivityRows {
topLevelAgents: AgentActivityRow[];
subWorkflows: SubWorkflowActivityGroup[];
}
function resolveSubWorkflowGroupStatus(
agents: AgentActivityRow[],
lifecycleEntry: AgentActivityState | undefined,
): SubWorkflowGroupStatus {
if (lifecycleEntry?.activityType === 'subworkflow-completed') return 'completed';
if (lifecycleEntry?.activityType === 'subworkflow-started') return 'running';
if (agents.some((a) => isAgentActivityActive(a.activity))) return 'running';
if (agents.some((a) => isAgentActivityCompleted(a.activity))) return 'completed';
return 'idle';
}
export function buildGroupedActivityRows(
current: SessionActivityState | undefined,
hierarchy: WorkflowAgentHierarchy,
): GroupedActivityRows {
const topLevelAgents = buildAgentActivityRows(current, hierarchy.topLevelAgents);
const subWorkflows: SubWorkflowActivityGroup[] = hierarchy.subWorkflows.map((sub) => {
const agents = buildAgentActivityRows(current, sub.agents);
const lifecycleEntry = current?.[sub.nodeId];
return {
nodeId: sub.nodeId,
name: sub.workflowName || sub.nodeLabel,
workflowId: sub.workflowId,
orchestrationMode: sub.orchestrationMode,
status: resolveSubWorkflowGroupStatus(agents, lifecycleEntry),
agents,
};
});
// Pick up agents that arrived via activity events with a subworkflowNodeId
// but whose sub-workflow isn't in the statically-resolved hierarchy (e.g.
// a referenced workflow that couldn't be resolved at design time).
if (current) {
const knownKeys = new Set([
...topLevelAgents.map((a) => a.key),
...subWorkflows.flatMap((sw) => [sw.nodeId, ...sw.agents.map((a) => a.key)]),
]);
const dynamicGroups = new Map<string, { name: string; agents: AgentActivityRow[] }>();
for (const [key, state] of Object.entries(current)) {
if (knownKeys.has(key)) continue;
if (!state.subworkflowNodeId) continue;
if (state.activityType === 'subworkflow-started' || state.activityType === 'subworkflow-completed') continue;
const group = dynamicGroups.get(state.subworkflowNodeId)
?? { name: state.subworkflowName ?? state.subworkflowNodeId, agents: [] };
group.agents.push({ key, agentName: state.agentName, activity: state });
dynamicGroups.set(state.subworkflowNodeId, group);
}
for (const [nodeId, group] of dynamicGroups) {
const lifecycleEntry = current[nodeId];
subWorkflows.push({
nodeId,
name: group.name,
orchestrationMode: 'sequential',
status: resolveSubWorkflowGroupStatus(group.agents, lifecycleEntry),
agents: group.agents,
});
}
}
return { topLevelAgents, subWorkflows };
}
function removeSessionActivity( function removeSessionActivity(
current: SessionActivityMap, current: SessionActivityMap,
sessionId: string, sessionId: string,
@@ -280,6 +371,26 @@ function formatDiagnosticLabel(
function formatTurnEventEntry(event: SessionEventRecord): TurnEventEntry | undefined { function formatTurnEventEntry(event: SessionEventRecord): TurnEventEntry | undefined {
switch (event.kind) { 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': case 'subagent':
return { return {
kind: event.kind, kind: event.kind,
+9 -1
View File
@@ -271,7 +271,13 @@ export interface MessageReclassifiedEvent {
newKind: 'thinking'; 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 { export interface ToolCallFileChangePreview {
path: string; path: string;
@@ -286,6 +292,8 @@ export interface AgentActivityEvent {
activityType: AgentActivityType; activityType: AgentActivityType;
agentId?: string; agentId?: string;
agentName?: string; agentName?: string;
subworkflowNodeId?: string;
subworkflowName?: string;
sourceAgentId?: string; sourceAgentId?: string;
sourceAgentName?: string; sourceAgentName?: string;
toolName?: string; toolName?: string;
+9 -1
View File
@@ -8,7 +8,13 @@ import type {
WorkflowDiagnosticSeverity, WorkflowDiagnosticSeverity,
} from '@shared/contracts/sidecar'; } 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 = export type SessionEventKind =
| 'status' | 'status'
@@ -42,6 +48,8 @@ export interface SessionEventRecord {
activityType?: SessionActivityType; activityType?: SessionActivityType;
agentId?: string; agentId?: string;
agentName?: string; agentName?: string;
subworkflowNodeId?: string;
subworkflowName?: string;
sourceAgentId?: string; sourceAgentId?: string;
sourceAgentName?: string; sourceAgentName?: string;
toolName?: string; toolName?: string;
+62
View File
@@ -536,6 +536,68 @@ export function resolveWorkflowAgents(workflow: WorkflowDefinition): AgentNodeCo
}); });
} }
export interface SubWorkflowGroupDescriptor {
nodeId: string;
nodeLabel: string;
workflowId?: string;
workflowName: string;
orchestrationMode: WorkflowOrchestrationMode;
agents: AgentNodeConfig[];
}
export interface WorkflowAgentHierarchy {
topLevelAgents: AgentNodeConfig[];
subWorkflows: SubWorkflowGroupDescriptor[];
}
export function resolveWorkflowAgentHierarchy(
workflow: WorkflowDefinition,
options?: WorkflowResolutionOptions,
): WorkflowAgentHierarchy {
const topLevelAgents = resolveWorkflowAgents(workflow);
const subWorkflows: SubWorkflowGroupDescriptor[] = [];
const subWorkflowNodes = workflow.graph.nodes
.filter((node): node is WorkflowNode & { config: SubWorkflowConfig } => node.kind === 'sub-workflow')
.slice()
.sort((a, b) => {
const orderA = a.order ?? Number.MAX_SAFE_INTEGER;
const orderB = b.order ?? Number.MAX_SAFE_INTEGER;
if (orderA !== orderB) return orderA - orderB;
return a.label.localeCompare(b.label);
});
for (const node of subWorkflowNodes) {
const subWorkflowDef = resolveSubWorkflowDefinition(node, options);
if (!subWorkflowDef) continue;
const agents = resolveWorkflowAgents(subWorkflowDef);
const mode = inferWorkflowOrchestrationMode(subWorkflowDef, options);
subWorkflows.push({
nodeId: node.id,
nodeLabel: node.label,
workflowId: node.config.workflowId,
workflowName: subWorkflowDef.name || node.label,
orchestrationMode: mode,
agents,
});
}
return { topLevelAgents, subWorkflows };
}
function resolveSubWorkflowDefinition(
node: WorkflowNode & { config: SubWorkflowConfig },
options?: WorkflowResolutionOptions,
): WorkflowDefinition | undefined {
if (node.config.inlineWorkflow) return node.config.inlineWorkflow;
if (node.config.workflowId && options?.resolveWorkflow) {
return options.resolveWorkflow(node.config.workflowId);
}
return undefined;
}
function hasWorkflowExecutionFanEdges( function hasWorkflowExecutionFanEdges(
workflow: WorkflowDefinition, workflow: WorkflowDefinition,
options?: WorkflowResolutionOptions, options?: WorkflowResolutionOptions,
@@ -0,0 +1,255 @@
import { describe, expect, test } from 'bun:test';
import type { AgentActivityEvent } from '@shared/contracts/sidecar';
import type { SessionEventRecord } from '@shared/domain/event';
import { SCRATCHPAD_PROJECT_ID } from '@shared/domain/project';
import {
createSessionRunRecord,
type SessionRunRecord,
} from '@shared/domain/runTimeline';
import type { SessionRecord } from '@shared/domain/session';
import type { WorkflowDefinition } from '@shared/domain/workflow';
import { createWorkspaceSeed, type WorkspaceState } from '@shared/domain/workspace';
import { SessionTurnExecutor } from '@main/services/sessionTurnExecutor';
describe('SessionTurnExecutor agent activity', () => {
test('preserves subworkflow context on nested agent activity session events', async () => {
const { workspace, session, run } = createRunningContext();
const harness = createExecutor();
const { executor, emittedEvents, runUpdates } = harness;
const initialEventCount = run.events.length;
const activityEvent: AgentActivityEvent = {
type: 'agent-activity',
requestId: run.requestId,
sessionId: session.id,
activityType: 'thinking',
agentId: 'agent-1',
agentName: 'Primary',
subworkflowNodeId: 'nested-flow',
subworkflowName: 'Nested flow',
};
await (
executor as unknown as {
applyAgentActivity: (
workspace: WorkspaceState,
sessionId: string,
requestId: string,
event: AgentActivityEvent,
) => Promise<void>;
}
).applyAgentActivity(workspace, session.id, run.requestId, activityEvent);
expect(harness.saveCalls).toBe(1);
expect(runUpdates).toHaveLength(1);
expect(session.runs[0]?.events).toHaveLength(initialEventCount + 1);
expect(session.runs[0]?.events.at(-1)).toMatchObject({
kind: 'thinking',
agentId: 'agent-1',
agentName: 'Primary',
status: 'completed',
});
expect(emittedEvents).toHaveLength(1);
expect(emittedEvents[0]).toMatchObject({
sessionId: session.id,
kind: 'agent-activity',
activityType: 'thinking',
agentId: 'agent-1',
agentName: 'Primary',
subworkflowNodeId: 'nested-flow',
subworkflowName: 'Nested flow',
});
});
test('emits subworkflow lifecycle events without appending run timeline activity', async () => {
const { workspace, session, run } = createRunningContext();
const harness = createExecutor();
const { executor, emittedEvents, runUpdates } = harness;
const initialEventCount = run.events.length;
const activityEvent: AgentActivityEvent = {
type: 'agent-activity',
requestId: run.requestId,
sessionId: session.id,
activityType: 'subworkflow-started',
subworkflowNodeId: 'nested-flow',
subworkflowName: 'Nested flow',
};
await (
executor as unknown as {
applyAgentActivity: (
workspace: WorkspaceState,
sessionId: string,
requestId: string,
event: AgentActivityEvent,
) => Promise<void>;
}
).applyAgentActivity(workspace, session.id, run.requestId, activityEvent);
expect(harness.saveCalls).toBe(0);
expect(runUpdates).toHaveLength(0);
expect(session.runs[0]?.events).toHaveLength(initialEventCount);
expect(emittedEvents).toHaveLength(1);
expect(emittedEvents[0]).toMatchObject({
sessionId: session.id,
kind: 'agent-activity',
activityType: 'subworkflow-started',
subworkflowNodeId: 'nested-flow',
subworkflowName: 'Nested flow',
});
});
});
function createExecutor(): {
executor: SessionTurnExecutor;
emittedEvents: SessionEventRecord[];
runUpdates: SessionRunRecord[];
saveCalls: number;
} {
const emittedEvents: SessionEventRecord[] = [];
const runUpdates: SessionRunRecord[] = [];
let saveCalls = 0;
const executor = new SessionTurnExecutor({
saveWorkspace: async () => {
saveCalls += 1;
},
persistWorkspace: async (workspace) => workspace,
requireSession: (workspace, sessionId) => {
const session = workspace.sessions.find((candidate) => candidate.id === sessionId);
if (!session) {
throw new Error(`Missing session ${sessionId}`);
}
return session;
},
resolveSessionWorkflow: () => createWorkflow(),
updateSessionRun: (session, requestId, updater) => {
const runIndex = session.runs.findIndex((candidate) => candidate.requestId === requestId);
if (runIndex < 0) {
return undefined;
}
const nextRun = updater(session.runs[runIndex]!);
session.runs[runIndex] = nextRun;
return nextRun;
},
emitRunUpdated: (_sessionId, _occurredAt, run) => {
runUpdates.push(run);
},
emitSessionEvent: (event) => {
emittedEvents.push(event);
},
rejectPendingApprovals: () => [],
buildRunTurnToolingConfig: () => undefined,
runSidecarTurnWithCheckpointRecovery: async () => [],
handleApprovalRequested: async () => undefined,
handleUserInputRequested: async () => undefined,
handleMcpOAuthRequired: async () => undefined,
handleExitPlanModeRequested: async () => undefined,
handleTurnScopedEvent: async () => undefined,
sidecarResolveApproval: async () => undefined,
sidecarResolveUserInput: async () => undefined,
captureWorkingTreeSnapshot: async () => undefined,
captureWorkingTreeBaseline: async () => [],
refreshSessionRunGitSummary: async () => undefined,
cleanupWorkflowCheckpointRecovery: async () => undefined,
scheduleProjectGitRefresh: () => undefined,
loadAvailableModelCatalog: async () => [],
});
return {
executor,
emittedEvents,
runUpdates,
get saveCalls() {
return saveCalls;
},
};
}
function createRunningContext(): {
workspace: WorkspaceState;
session: SessionRecord;
run: SessionRunRecord;
} {
const workflow = createWorkflow();
const run = createSessionRunRecord({
requestId: 'turn-1',
project: {
id: SCRATCHPAD_PROJECT_ID,
path: 'C:\\scratchpad',
},
workingDirectory: 'C:\\scratchpad',
workspaceKind: 'scratchpad',
workflow,
triggerMessageId: 'msg-user-1',
startedAt: '2026-04-01T12:00:00.000Z',
});
const session: SessionRecord = {
id: 'session-1',
projectId: SCRATCHPAD_PROJECT_ID,
workflowId: workflow.id,
title: 'Activity session',
createdAt: '2026-04-01T12:00:00.000Z',
updatedAt: '2026-04-01T12:00:00.000Z',
status: 'running',
messages: [
{
id: 'msg-user-1',
role: 'user',
authorName: 'You',
content: 'Continue the workflow.',
createdAt: '2026-04-01T12:00:00.000Z',
},
],
runs: [run],
};
const workspace = createWorkspaceSeed();
workspace.sessions = [session];
workspace.workflows = [workflow];
return { workspace, session, run };
}
function createWorkflow(): WorkflowDefinition {
return {
id: 'workflow-handoff',
name: 'Activity flow',
description: '',
graph: {
nodes: [
{ id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
{
id: 'agent-1',
kind: 'agent',
label: 'Primary',
position: { x: 200, y: 0 },
order: 0,
config: {
kind: 'agent',
id: 'agent-1',
name: 'Primary',
description: '',
instructions: 'Help with the request.',
model: 'gpt-5.4',
},
},
{ id: 'end', kind: 'end', label: 'End', position: { x: 400, y: 0 }, config: { kind: 'end' } },
],
edges: [
{ id: 'edge-start-agent', source: 'start', target: 'agent-1', kind: 'direct' },
{ id: 'edge-agent-end', source: 'agent-1', target: 'end', kind: 'direct' },
],
},
settings: {
checkpointing: { enabled: true },
executionMode: 'off-thread',
orchestrationMode: 'handoff',
maxIterations: 4,
},
createdAt: '2026-04-01T00:00:00.000Z',
updatedAt: '2026-04-01T00:00:00.000Z',
};
}
+318
View File
@@ -736,4 +736,322 @@ describe('workflow diagnostic turn events', () => {
const entries = result['session-1']!; const entries = result['session-1']!;
expect(entries[0].label).toBe('Workflow warning'); expect(entries[0].label).toBe('Workflow warning');
}); });
test('formats subworkflow-started lifecycle event', () => {
const result = applyTurnEventLog({}, {
sessionId: 'session-1',
kind: 'agent-activity',
occurredAt: '2026-03-23T00:00:00.000Z',
activityType: 'subworkflow-started',
subworkflowNodeId: 'data-pipeline',
subworkflowName: 'Data Pipeline',
});
const entries = result['session-1']!;
expect(entries).toHaveLength(1);
expect(entries[0].label).toBe('Sub-workflow started: Data Pipeline');
expect(entries[0].phase).toBe('start');
});
test('formats subworkflow-completed lifecycle event', () => {
const result = applyTurnEventLog({}, {
sessionId: 'session-1',
kind: 'agent-activity',
occurredAt: '2026-03-23T00:00:00.000Z',
activityType: 'subworkflow-completed',
subworkflowNodeId: 'data-pipeline',
subworkflowName: 'Data Pipeline',
});
const entries = result['session-1']!;
expect(entries).toHaveLength(1);
expect(entries[0].label).toBe('Sub-workflow completed: Data Pipeline');
expect(entries[0].phase).toBe('end');
expect(entries[0].success).toBe(true);
});
});
/* ── Sub-workflow grouping tests ───────────────────────────── */
import {
buildGroupedActivityRows,
type SubWorkflowActivityGroup,
} from '@renderer/lib/sessionActivity';
import {
resolveWorkflowAgentHierarchy,
type SubWorkflowGroupDescriptor,
type WorkflowAgentHierarchy,
} from '@shared/domain/workflow';
describe('sub-workflow activity grouping', () => {
function makeWorkflow(overrides?: Partial<WorkflowDefinition>): WorkflowDefinition {
return {
id: 'wf-1',
name: 'Test Workflow',
description: 'A test workflow.',
createdAt: '2026-01-01T00:00:00Z',
updatedAt: '2026-01-01T00:00:00Z',
settings: { orchestrationMode: 'sequential', checkpointing: { enabled: false }, executionMode: 'off-thread' },
graph: {
nodes: [
{ id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
{ id: 'agent-a', kind: 'agent', label: 'Agent A', position: { x: 100, y: 0 }, order: 1, config: { kind: 'agent', id: 'agent-a', name: 'Agent A', description: '', instructions: '', model: 'gpt-5.4' } },
{ id: 'end', kind: 'end', label: 'End', position: { x: 200, y: 0 }, config: { kind: 'end' } },
],
edges: [
{ id: 'e1', source: 'start', target: 'agent-a', kind: 'direct' },
{ id: 'e2', source: 'agent-a', target: 'end', kind: 'direct' },
],
},
...overrides,
};
}
function makeSubWorkflow(): WorkflowDefinition {
return makeWorkflow({
id: 'sub-wf-1',
name: 'Sub Pipeline',
graph: {
nodes: [
{ id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
{ id: 'inner-agent', kind: 'agent', label: 'Inner Agent', position: { x: 100, y: 0 }, order: 1, config: { kind: 'agent', id: 'inner-agent', name: 'Inner Agent', description: '', instructions: '', model: 'gpt-5.4' } },
{ id: 'inner-agent-2', kind: 'agent', label: 'Inner Agent 2', position: { x: 200, y: 0 }, order: 2, config: { kind: 'agent', id: 'inner-agent-2', name: 'Inner Agent 2', description: '', instructions: '', model: 'gpt-5.4' } },
{ id: 'end', kind: 'end', label: 'End', position: { x: 300, y: 0 }, config: { kind: 'end' } },
],
edges: [
{ id: 'e1', source: 'start', target: 'inner-agent', kind: 'direct' },
{ id: 'e2', source: 'inner-agent', target: 'inner-agent-2', kind: 'direct' },
{ id: 'e3', source: 'inner-agent-2', target: 'end', kind: 'direct' },
],
},
});
}
test('flat workflow produces no sub-workflow groups', () => {
const workflow = makeWorkflow();
const hierarchy = resolveWorkflowAgentHierarchy(workflow);
const result = buildGroupedActivityRows(undefined, hierarchy);
expect(result.topLevelAgents).toHaveLength(1);
expect(result.topLevelAgents[0].agentName).toBe('Agent A');
expect(result.subWorkflows).toHaveLength(0);
});
test('workflow with inline sub-workflow produces grouped agents', () => {
const subWf = makeSubWorkflow();
const workflow = makeWorkflow({
graph: {
nodes: [
{ id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
{ id: 'agent-a', kind: 'agent', label: 'Agent A', position: { x: 100, y: 0 }, order: 1, config: { kind: 'agent', id: 'agent-a', name: 'Agent A', description: '', instructions: '', model: 'gpt-5.4' } },
{ id: 'sub-node', kind: 'sub-workflow', label: 'Sub Pipeline', position: { x: 200, y: 0 }, order: 2, config: { kind: 'sub-workflow', inlineWorkflow: subWf } },
{ id: 'end', kind: 'end', label: 'End', position: { x: 300, y: 0 }, config: { kind: 'end' } },
],
edges: [
{ id: 'e1', source: 'start', target: 'agent-a', kind: 'direct' },
{ id: 'e2', source: 'agent-a', target: 'sub-node', kind: 'direct' },
{ id: 'e3', source: 'sub-node', target: 'end', kind: 'direct' },
],
},
});
const hierarchy = resolveWorkflowAgentHierarchy(workflow);
expect(hierarchy.topLevelAgents).toHaveLength(1);
expect(hierarchy.subWorkflows).toHaveLength(1);
expect(hierarchy.subWorkflows[0].workflowName).toBe('Sub Pipeline');
expect(hierarchy.subWorkflows[0].agents).toHaveLength(2);
const result = buildGroupedActivityRows(undefined, hierarchy);
expect(result.topLevelAgents).toHaveLength(1);
expect(result.subWorkflows).toHaveLength(1);
expect(result.subWorkflows[0].agents).toHaveLength(2);
expect(result.subWorkflows[0].status).toBe('idle');
});
test('sub-workflow group status reflects agent activity', () => {
const subWf = makeSubWorkflow();
const workflow = makeWorkflow({
graph: {
nodes: [
{ id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
{ id: 'sub-node', kind: 'sub-workflow', label: 'Pipeline', position: { x: 100, y: 0 }, config: { kind: 'sub-workflow', inlineWorkflow: subWf } },
{ id: 'end', kind: 'end', label: 'End', position: { x: 200, y: 0 }, config: { kind: 'end' } },
],
edges: [
{ id: 'e1', source: 'start', target: 'sub-node', kind: 'direct' },
{ id: 'e2', source: 'sub-node', target: 'end', kind: 'direct' },
],
},
});
const hierarchy = resolveWorkflowAgentHierarchy(workflow);
// Running: when lifecycle event says 'subworkflow-started'
const runningActivity = {
'sub-node': { agentId: 'sub-node', agentName: 'Pipeline', activityType: 'subworkflow-started' as const },
'inner-agent': { agentId: 'inner-agent', agentName: 'Inner Agent', activityType: 'thinking' as const },
};
const running = buildGroupedActivityRows(runningActivity, hierarchy);
expect(running.subWorkflows[0].status).toBe('running');
// Completed: when lifecycle event says 'subworkflow-completed'
const completedActivity = {
'sub-node': { agentId: 'sub-node', agentName: 'Pipeline', activityType: 'subworkflow-completed' as const },
'inner-agent': { agentId: 'inner-agent', agentName: 'Inner Agent', activityType: 'completed' as const },
};
const completed = buildGroupedActivityRows(completedActivity, hierarchy);
expect(completed.subWorkflows[0].status).toBe('completed');
});
test('sub-workflow group derives running status from active agents', () => {
const subWf = makeSubWorkflow();
const workflow = makeWorkflow({
graph: {
nodes: [
{ id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
{ id: 'sub-node', kind: 'sub-workflow', label: 'Pipeline', position: { x: 100, y: 0 }, config: { kind: 'sub-workflow', inlineWorkflow: subWf } },
{ id: 'end', kind: 'end', label: 'End', position: { x: 200, y: 0 }, config: { kind: 'end' } },
],
edges: [
{ id: 'e1', source: 'start', target: 'sub-node', kind: 'direct' },
{ id: 'e2', source: 'sub-node', target: 'end', kind: 'direct' },
],
},
});
const hierarchy = resolveWorkflowAgentHierarchy(workflow);
// No lifecycle event, but agent is active — derive running status
const activity = {
'inner-agent': { agentId: 'inner-agent', agentName: 'Inner Agent', activityType: 'thinking' as const },
};
const result = buildGroupedActivityRows(activity, hierarchy);
expect(result.subWorkflows[0].status).toBe('running');
});
test('referenced sub-workflow resolves via options', () => {
const subWf = makeSubWorkflow();
const workflow = makeWorkflow({
graph: {
nodes: [
{ id: 'start', kind: 'start', label: 'Start', position: { x: 0, y: 0 }, config: { kind: 'start' } },
{ id: 'sub-node', kind: 'sub-workflow', label: 'Ref Pipeline', position: { x: 100, y: 0 }, config: { kind: 'sub-workflow', workflowId: 'sub-wf-1' } },
{ id: 'end', kind: 'end', label: 'End', position: { x: 200, y: 0 }, config: { kind: 'end' } },
],
edges: [
{ id: 'e1', source: 'start', target: 'sub-node', kind: 'direct' },
{ id: 'e2', source: 'sub-node', target: 'end', kind: 'direct' },
],
},
});
const hierarchy = resolveWorkflowAgentHierarchy(workflow, {
resolveWorkflow: (id) => (id === 'sub-wf-1' ? subWf : undefined),
});
expect(hierarchy.subWorkflows).toHaveLength(1);
expect(hierarchy.subWorkflows[0].agents).toHaveLength(2);
expect(hierarchy.subWorkflows[0].workflowId).toBe('sub-wf-1');
});
test('dynamic grouping picks up unresolved sub-workflow agents from activity', () => {
const workflow = makeWorkflow(); // flat workflow, no sub-workflow nodes
const hierarchy = resolveWorkflowAgentHierarchy(workflow);
// Activity events arrive with subworkflowNodeId for agents not in the hierarchy
const activity = {
'agent-a': { agentId: 'agent-a', agentName: 'Agent A', activityType: 'thinking' as const },
'dynamic-agent': {
agentId: 'dynamic-agent',
agentName: 'Dynamic Agent',
activityType: 'tool-calling' as const,
subworkflowNodeId: 'dynamic-sub',
subworkflowName: 'Dynamic Sub',
toolName: 'search',
},
};
const result = buildGroupedActivityRows(activity, hierarchy);
expect(result.topLevelAgents).toHaveLength(1);
expect(result.subWorkflows).toHaveLength(1);
expect(result.subWorkflows[0].nodeId).toBe('dynamic-sub');
expect(result.subWorkflows[0].name).toBe('Dynamic Sub');
expect(result.subWorkflows[0].agents).toHaveLength(1);
expect(result.subWorkflows[0].agents[0].agentName).toBe('Dynamic Agent');
expect(result.subWorkflows[0].status).toBe('running');
});
});
describe('sub-workflow activity event handling', () => {
test('stores subworkflow-started lifecycle event keyed by subworkflowNodeId', () => {
const event: SessionEventRecord = {
sessionId: 'session-1',
kind: 'agent-activity',
occurredAt: '2026-03-23T00:00:00.000Z',
activityType: 'subworkflow-started',
subworkflowNodeId: 'pipeline-node',
subworkflowName: 'Data Pipeline',
};
const result = applySessionEventActivity({}, event);
expect(result['session-1']).toBeDefined();
expect(result['session-1']!['pipeline-node']).toEqual({
agentId: 'pipeline-node',
agentName: 'Data Pipeline',
activityType: 'subworkflow-started',
subworkflowNodeId: 'pipeline-node',
subworkflowName: 'Data Pipeline',
});
});
test('stores subworkflow-completed lifecycle event replacing started', () => {
const startEvent: SessionEventRecord = {
sessionId: 'session-1',
kind: 'agent-activity',
occurredAt: '2026-03-23T00:00:00.000Z',
activityType: 'subworkflow-started',
subworkflowNodeId: 'pipeline-node',
subworkflowName: 'Data Pipeline',
};
const completeEvent: SessionEventRecord = {
sessionId: 'session-1',
kind: 'agent-activity',
occurredAt: '2026-03-23T00:00:01.000Z',
activityType: 'subworkflow-completed',
subworkflowNodeId: 'pipeline-node',
subworkflowName: 'Data Pipeline',
};
let state = applySessionEventActivity({}, startEvent);
state = applySessionEventActivity(state, completeEvent);
expect(state['session-1']!['pipeline-node']?.activityType).toBe('subworkflow-completed');
});
test('propagates subworkflow context on regular agent activity events', () => {
const event: SessionEventRecord = {
sessionId: 'session-1',
kind: 'agent-activity',
occurredAt: '2026-03-23T00:00:00.000Z',
activityType: 'thinking',
agentId: 'inner-agent',
agentName: 'Inner Agent',
subworkflowNodeId: 'pipeline-node',
subworkflowName: 'Data Pipeline',
};
const result = applySessionEventActivity({}, event);
const agentState = result['session-1']!['inner-agent'];
expect(agentState).toBeDefined();
expect(agentState.subworkflowNodeId).toBe('pipeline-node');
expect(agentState.subworkflowName).toBe('Data Pipeline');
});
test('drops lifecycle event without subworkflowNodeId', () => {
const event: SessionEventRecord = {
sessionId: 'session-1',
kind: 'agent-activity',
occurredAt: '2026-03-23T00:00:00.000Z',
activityType: 'subworkflow-started',
// No subworkflowNodeId
};
const result = applySessionEventActivity({}, event);
expect(result['session-1']).toBeUndefined();
});
}); });