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
@@ -115,19 +115,86 @@ public sealed class AgentIdentityResolverTests
Assert.Equal("UX Specialist", agent.AgentName);
}
[Fact]
public void TryResolveKnownAgentIdentity_ResolvesReferencedSubworkflowAgentWithContext()
{
WorkflowDefinitionDto nestedWorkflow = CreateWorkflow(
"nested-review-workflow",
[
CreateAgent("agent-reviewer", "Reviewer"),
],
orchestrationMode: "single");
WorkflowDefinitionDto workflow = CreateWorkflow(
"parent-workflow",
[
CreateSubworkflow("subworkflow-review", "Review Lane", workflowId: nestedWorkflow.Id),
],
orchestrationMode: "single");
bool resolved = AgentIdentityResolver.TryResolveKnownAgentIdentity(
workflow,
[nestedWorkflow],
"Reviewer_agent_reviewer",
out AgentIdentity agent);
Assert.True(resolved);
Assert.Equal("agent-reviewer", agent.AgentId);
Assert.Equal("Reviewer", agent.AgentName);
Assert.Equal("subworkflow-review", agent.Subworkflow?.SubworkflowNodeId);
Assert.Equal("Review Lane", agent.Subworkflow?.SubworkflowName);
}
[Fact]
public void BuildAgentSubworkflowIndex_UsesImmediateNestedSubworkflowContext()
{
WorkflowDefinitionDto innerWorkflow = CreateWorkflow(
"inner-workflow",
[
CreateAgent("agent-inner-reviewer", "Inner Reviewer"),
],
orchestrationMode: "single");
WorkflowDefinitionDto outerWorkflow = CreateWorkflow(
"outer-workflow",
[
CreateSubworkflow("subworkflow-inner", "Inner Review", inlineWorkflow: innerWorkflow),
],
orchestrationMode: "single");
WorkflowDefinitionDto workflow = CreateWorkflow(
"parent-workflow",
[
CreateSubworkflow("subworkflow-outer", "Outer Review", inlineWorkflow: outerWorkflow),
],
orchestrationMode: "single");
IReadOnlyDictionary<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(
IReadOnlyList<WorkflowNodeDto> agents,
IReadOnlyList<WorkflowNodeDto> nodes,
string orchestrationMode = "concurrent")
{
return CreateWorkflow($"{orchestrationMode}-workflow", nodes, orchestrationMode);
}
private static WorkflowDefinitionDto CreateWorkflow(
string id,
IReadOnlyList<WorkflowNodeDto> nodes,
string orchestrationMode = "concurrent")
{
return new WorkflowDefinitionDto
{
Id = $"{orchestrationMode}-workflow",
Id = id,
Name = "Workflow",
Graph = new WorkflowGraphDto
{
Nodes =
[
.. agents,
.. nodes,
],
},
Settings = new WorkflowSettingsDto
@@ -154,4 +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);
}
[Fact]
public void ObserveSessionEvent_AssistantMessageDelta_ForNestedAgent_IncludesSubworkflowContext()
{
RunTurnCommandDto command = CreateCommandWithReferencedSubworkflow();
CopilotTurnExecutionState state = new(command);
WorkflowDefinitionDto nestedWorkflow = Assert.Single(command.WorkflowLibrary!);
WorkflowNodeDto nestedAgent = Assert.Single(nestedWorkflow.GetAgentNodes());
state.ObserveSessionEvent(
nestedAgent,
SessionEvent.FromJson(
"""
{
"type": "assistant.message_delta",
"data": {
"messageId": "msg-nested-1",
"deltaContent": "Reviewing"
},
"id": "7ef95d90-7ee7-45e2-ac38-cf749caf4f69",
"timestamp": "2026-03-27T00:00:00Z"
}
"""));
AgentActivityEventDto activity = Assert.Single(state.DrainPendingEvents().OfType<AgentActivityEventDto>());
Assert.Equal("thinking", activity.ActivityType);
Assert.Equal("agent-reviewer", activity.AgentId);
Assert.Equal("Reviewer", activity.AgentName);
Assert.Equal("subworkflow-review", activity.SubworkflowNodeId);
Assert.Equal("Review Lane", activity.SubworkflowName);
Assert.True(state.ActiveAgent.HasValue);
Assert.Equal("subworkflow-review", state.ActiveAgent.Value.Subworkflow?.SubworkflowNodeId);
Assert.Equal("Review Lane", state.ActiveAgent.Value.Subworkflow?.SubworkflowName);
}
[Fact]
public void ObserveSessionEvent_ToolExecutionStart_TracksToolNameByCallIdAndQueuesToolActivity()
{
@@ -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);
}
[Fact]
public async Task HandleWorkflowEventAsync_EmitsSubworkflowStartedActivityForSubworkflowExecutor()
{
RunTurnCommandDto command = CreateReferencedSubworkflowCommand();
CopilotTurnExecutionState state = new(command);
List<AgentActivityEventDto> activities = [];
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
"HandleWorkflowEventAsync",
BindingFlags.NonPublic | BindingFlags.Static)!;
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
null,
[
command,
new ExecutorInvokedEvent("subworkflow-review", null!),
Array.Empty<ChatMessage>(),
state,
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
(Func<SidecarEventDto, Task>)(sidecarEvent =>
{
activities.Add(Assert.IsType<AgentActivityEventDto>(sidecarEvent));
return Task.CompletedTask;
}),
])!;
bool shouldEndTurn = await handleTask;
Assert.False(shouldEndTurn);
AgentActivityEventDto activity = Assert.Single(activities);
Assert.Equal("subworkflow-started", activity.ActivityType);
Assert.Null(activity.AgentId);
Assert.Null(activity.AgentName);
Assert.Equal("subworkflow-review", activity.SubworkflowNodeId);
Assert.Equal("Review Lane", activity.SubworkflowName);
}
[Fact]
public async Task HandleWorkflowEventAsync_EmitsSubworkflowCompletedActivityForSubworkflowExecutor()
{
RunTurnCommandDto command = CreateReferencedSubworkflowCommand();
CopilotTurnExecutionState state = new(command);
List<AgentActivityEventDto> activities = [];
MethodInfo handleWorkflowEvent = typeof(CopilotWorkflowRunner).GetMethod(
"HandleWorkflowEventAsync",
BindingFlags.NonPublic | BindingFlags.Static)!;
Task<bool> handleTask = (Task<bool>)handleWorkflowEvent.Invoke(
null,
[
command,
new ExecutorCompletedEvent("subworkflow-review", null),
Array.Empty<ChatMessage>(),
state,
(Func<TurnDeltaEventDto, Task>)(_ => Task.CompletedTask),
(Func<SidecarEventDto, Task>)(sidecarEvent =>
{
activities.Add(Assert.IsType<AgentActivityEventDto>(sidecarEvent));
return Task.CompletedTask;
}),
])!;
bool shouldEndTurn = await handleTask;
Assert.False(shouldEndTurn);
AgentActivityEventDto activity = Assert.Single(activities);
Assert.Equal("subworkflow-completed", activity.ActivityType);
Assert.Null(activity.AgentId);
Assert.Null(activity.AgentName);
Assert.Equal("subworkflow-review", activity.SubworkflowNodeId);
Assert.Equal("Review Lane", activity.SubworkflowName);
}
[Fact]
public async Task HandleWorkflowEventAsync_EmitsWorkflowWarningDiagnostic()
{
@@ -2243,11 +2315,37 @@ public sealed class CopilotWorkflowRunnerTests
};
}
private static WorkflowNodeDto CreateSubworkflow(
string id,
string label,
string? workflowId = null,
WorkflowDefinitionDto? inlineWorkflow = null)
{
return new WorkflowNodeDto
{
Id = id,
Kind = "sub-workflow",
Label = label,
Config = new WorkflowNodeConfigDto
{
Kind = "sub-workflow",
WorkflowId = workflowId,
InlineWorkflow = inlineWorkflow,
},
};
}
private static RunTurnCommandDto CreateCommand(
string orchestrationMode,
params WorkflowNodeDto[] agents)
{
return CreateCommand(orchestrationMode, modeSettings: null, workflowName: null, workflowDescription: null, agents);
return CreateCommand(
orchestrationMode,
modeSettings: null,
workflowName: null,
workflowDescription: null,
workflowLibrary: null,
agents: agents);
}
private static RunTurnCommandDto CreateCommand(
@@ -2255,12 +2353,14 @@ public sealed class CopilotWorkflowRunnerTests
OrchestrationModeSettingsDto? modeSettings = null,
string? workflowName = null,
string? workflowDescription = null,
IReadOnlyList<WorkflowDefinitionDto>? workflowLibrary = null,
params WorkflowNodeDto[] agents)
{
return new RunTurnCommandDto
{
RequestId = "turn-1",
SessionId = "session-1",
WorkflowLibrary = workflowLibrary ?? [],
Workflow = new WorkflowDefinitionDto
{
Id = $"workflow-{orchestrationMode}",
@@ -2336,6 +2436,35 @@ public sealed class CopilotWorkflowRunnerTests
};
}
private static RunTurnCommandDto CreateReferencedSubworkflowCommand()
{
WorkflowDefinitionDto nestedWorkflow = new()
{
Id = "nested-review-workflow",
Name = "Nested Review Workflow",
Graph = new WorkflowGraphDto
{
Nodes =
[
CreateAgent("agent-reviewer", "Reviewer"),
],
},
Settings = new WorkflowSettingsDto
{
OrchestrationMode = "single",
},
};
return CreateCommand(
"single",
workflowName: "Parent Workflow",
workflowLibrary: [nestedWorkflow],
agents:
[
CreateSubworkflow("subworkflow-review", "Review Lane", workflowId: nestedWorkflow.Id),
]);
}
private static RunTurnCommandDto CreateHandoffCommand()
{
return CreateCommand(
@@ -246,6 +246,54 @@ public sealed class WorkflowRequestInfoInterpreterTests
Assert.Empty(tracking.ToolCallHasArgumentsById);
}
[Fact]
public void TryCreateActivityFromRequest_IncludesSubworkflowContextForToolCallingAgent()
{
var tracking = CreateToolTracking();
RequestInfoEvent requestInfo = CreateRequestInfoEvent(
new FunctionCallContent("call-1", "view", new Dictionary<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]
public void RequiresUserInputTurnBoundary_ReturnsTrueForUnhandledHandoffRequests()
{
@@ -341,24 +389,56 @@ public sealed class WorkflowRequestInfoInterpreterTests
CreateAgent("agent-handoff-ux", "UX Specialist"),
]);
private static RunTurnCommandDto CreateHandoffCommandWithReferencedSubworkflow()
{
WorkflowDefinitionDto nestedWorkflow = new()
{
Id = "nested-review-workflow",
Name = "Nested Review Workflow",
Graph = new WorkflowGraphDto
{
Nodes =
[
CreateAgent("agent-handoff-ux", "UX Specialist"),
],
},
Settings = new WorkflowSettingsDto
{
OrchestrationMode = "single",
},
};
return CreateCommand(
"handoff",
[
CreateAgent("agent-handoff-triage", "Triage"),
CreateSubworkflow("subworkflow-review", "Review Lane", workflowId: nestedWorkflow.Id),
],
workflowLibrary: [nestedWorkflow]);
}
private static (
ConcurrentDictionary<string, string> ToolNamesByCallId,
ConcurrentDictionary<string, bool> ToolCallHasArgumentsById) CreateToolTracking()
=> (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
{
RequestId = "turn-1",
SessionId = "session-1",
WorkflowLibrary = workflowLibrary ?? [],
Workflow = new WorkflowDefinitionDto
{
Id = $"{orchestrationMode}-workflow",
Name = "Workflow",
Graph = new WorkflowGraphDto
{
Nodes = [.. agents],
Nodes = [.. nodes],
},
Settings = new WorkflowSettingsDto
{
@@ -386,6 +466,26 @@ public sealed class WorkflowRequestInfoInterpreterTests
};
}
private static WorkflowNodeDto CreateSubworkflow(
string id,
string label,
string? workflowId = null,
WorkflowDefinitionDto? inlineWorkflow = null)
{
return new WorkflowNodeDto
{
Id = id,
Kind = "sub-workflow",
Label = label,
Config = new WorkflowNodeConfigDto
{
Kind = "sub-workflow",
WorkflowId = workflowId,
InlineWorkflow = inlineWorkflow,
},
};
}
private static RequestInfoEvent CreateRequestInfoEvent(object payload)
{
RequestPort port = RequestPort.Create<object, object>("test-port");