mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-29 06:07:11 +02:00
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:
+4
-2
@@ -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");
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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',
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user