mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-09 13:18:46 +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:
@@ -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']!;
|
||||
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