mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-24 13:38:43 +02:00
For builder-based orchestration modes (group-chat, handoff), the loop edge maxIterations, workflow settings.maxIterations, and mode-specific settings (e.g. groupChat.maxRounds) were independently editable but only the mode settings controlled runtime behavior. This caused confusion when changing one did not update the others. Changes: - Add syncBuilderModeEdgeIterations() to keep loop edge maxIterations in sync with the authoritative mode settings for builder-based modes - Wire sync into normalizeWorkflowDefinition so edges are consistent on load/normalization - Update scaffoldGraphForMode to accept optional settings instead of hardcoded iteration values (4 for handoff, 5 for group-chat) - Sync settings.maxIterations <-> groupChat.maxRounds bidirectionally when either changes in the UI - Make loop edge controls read-only in the graph inspector for builder-based modes with explanatory text - Add 8 consistency tests validating built-in workflows, scaffold behavior, and normalization sync Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
750 lines
27 KiB
TypeScript
750 lines
27 KiB
TypeScript
import { describe, expect, test } from 'bun:test';
|
|
|
|
import {
|
|
buildWorkflowExecutionDefinition,
|
|
createBuiltinWorkflows,
|
|
createDefaultModeSettings,
|
|
isBuilderBasedMode,
|
|
isGraphBasedMode,
|
|
normalizeWorkflowDefinition,
|
|
scaffoldGraphForMode,
|
|
validateWorkflowDefinition,
|
|
type WorkflowDefinition,
|
|
type WorkflowNode,
|
|
} from '@shared/domain/workflow';
|
|
|
|
const TIMESTAMP = '2026-04-05T00:00:00.000Z';
|
|
|
|
function createWorkflow(): WorkflowDefinition {
|
|
return {
|
|
id: 'workflow-1',
|
|
name: 'Agent Workflow',
|
|
description: 'A simple workflow.',
|
|
createdAt: TIMESTAMP,
|
|
updatedAt: TIMESTAMP,
|
|
graph: {
|
|
nodes: [
|
|
{
|
|
id: 'start',
|
|
kind: 'start',
|
|
label: 'Start',
|
|
position: { x: 0, y: 0 },
|
|
config: { kind: 'start' },
|
|
},
|
|
{
|
|
id: 'agent-primary',
|
|
kind: 'agent',
|
|
label: 'Primary Agent',
|
|
position: { x: 200, y: 0 },
|
|
order: 0,
|
|
config: {
|
|
kind: 'agent',
|
|
id: 'agent-primary',
|
|
name: 'Primary Agent',
|
|
description: 'Handles the task.',
|
|
instructions: 'Do the work.',
|
|
model: 'gpt-5.4',
|
|
reasoningEffort: 'medium',
|
|
},
|
|
},
|
|
{
|
|
id: 'end',
|
|
kind: 'end',
|
|
label: 'End',
|
|
position: { x: 400, y: 0 },
|
|
config: { kind: 'end' },
|
|
},
|
|
],
|
|
edges: [
|
|
{ id: 'edge-start-agent', source: 'start', target: 'agent-primary', kind: 'direct' },
|
|
{ id: 'edge-agent-end', source: 'agent-primary', target: 'end', kind: 'direct' },
|
|
],
|
|
},
|
|
settings: {
|
|
checkpointing: { enabled: false },
|
|
executionMode: 'off-thread',
|
|
},
|
|
};
|
|
}
|
|
|
|
function createReferencedSubWorkflow(): WorkflowDefinition {
|
|
return {
|
|
id: 'child-workflow',
|
|
name: 'Child Workflow',
|
|
description: 'Nested child workflow.',
|
|
createdAt: TIMESTAMP,
|
|
updatedAt: TIMESTAMP,
|
|
graph: {
|
|
nodes: [
|
|
{
|
|
id: 'start',
|
|
kind: 'start',
|
|
label: 'Start',
|
|
position: { x: 0, y: 0 },
|
|
config: { kind: 'start' },
|
|
},
|
|
{
|
|
id: 'agent-reviewer',
|
|
kind: 'agent',
|
|
label: 'Reviewer Agent',
|
|
position: { x: 200, y: 0 },
|
|
order: 0,
|
|
config: {
|
|
kind: 'agent',
|
|
id: 'agent-reviewer',
|
|
name: 'Reviewer Agent',
|
|
description: 'Reviews the task.',
|
|
instructions: 'Review the task.',
|
|
model: 'gpt-5.4',
|
|
},
|
|
},
|
|
{
|
|
id: 'end',
|
|
kind: 'end',
|
|
label: 'End',
|
|
position: { x: 400, y: 0 },
|
|
config: { kind: 'end' },
|
|
},
|
|
],
|
|
edges: [
|
|
{ id: 'edge-start-reviewer', source: 'start', target: 'agent-reviewer', kind: 'direct' },
|
|
{ id: 'edge-reviewer-end', source: 'agent-reviewer', target: 'end', kind: 'direct' },
|
|
],
|
|
},
|
|
settings: {
|
|
checkpointing: { enabled: false },
|
|
executionMode: 'off-thread',
|
|
},
|
|
};
|
|
}
|
|
|
|
describe('workflow validation', () => {
|
|
test('accepts a simple start-agent-end workflow', () => {
|
|
expect(validateWorkflowDefinition(createWorkflow())).toEqual([]);
|
|
});
|
|
|
|
test('accepts phase 4 executor node kinds as executable work', () => {
|
|
const workflow = createWorkflow();
|
|
workflow.graph.nodes[1] = {
|
|
id: 'invoke-function',
|
|
kind: 'invoke-function',
|
|
label: 'Function Tool',
|
|
position: { x: 100, y: 0 },
|
|
config: {
|
|
kind: 'invoke-function',
|
|
functionName: 'GetUserData',
|
|
},
|
|
};
|
|
workflow.graph.edges[0] = { id: 'edge-start-fn', source: 'start', target: 'invoke-function', kind: 'direct' };
|
|
workflow.graph.edges[1] = { id: 'edge-fn-end', source: 'invoke-function', target: 'end', kind: 'direct' };
|
|
|
|
expect(validateWorkflowDefinition(workflow)).toEqual([]);
|
|
});
|
|
|
|
test('counts function and request port nodes as executable work', () => {
|
|
const functionWorkflow = createWorkflow();
|
|
functionWorkflow.graph.nodes[1] = {
|
|
id: 'invoke-function',
|
|
kind: 'invoke-function',
|
|
label: 'Function Tool',
|
|
position: { x: 200, y: 0 },
|
|
config: {
|
|
kind: 'invoke-function',
|
|
functionName: 'identity',
|
|
},
|
|
};
|
|
functionWorkflow.graph.edges[0] = { id: 'edge-start-function', source: 'start', target: 'invoke-function', kind: 'direct' };
|
|
functionWorkflow.graph.edges[1] = { id: 'edge-function-end', source: 'invoke-function', target: 'end', kind: 'direct' };
|
|
|
|
const requestPortWorkflow = createWorkflow();
|
|
requestPortWorkflow.graph.nodes[1] = {
|
|
id: 'request-port',
|
|
kind: 'request-port',
|
|
label: 'Approval',
|
|
position: { x: 200, y: 0 },
|
|
config: {
|
|
kind: 'request-port',
|
|
portId: 'approval',
|
|
requestType: 'Question',
|
|
responseType: 'string',
|
|
},
|
|
};
|
|
requestPortWorkflow.graph.edges[0] = { id: 'edge-start-port', source: 'start', target: 'request-port', kind: 'direct' };
|
|
requestPortWorkflow.graph.edges[1] = { id: 'edge-port-end', source: 'request-port', target: 'end', kind: 'direct' };
|
|
|
|
expect(validateWorkflowDefinition(functionWorkflow)).toEqual([]);
|
|
expect(validateWorkflowDefinition(requestPortWorkflow)).toEqual([]);
|
|
});
|
|
|
|
test('rejects invalid phase 4 executor configs', () => {
|
|
const functionWorkflow = createWorkflow();
|
|
functionWorkflow.graph.nodes[1] = {
|
|
id: 'invoke-function',
|
|
kind: 'invoke-function',
|
|
label: 'Function Tool',
|
|
position: { x: 200, y: 0 },
|
|
config: {
|
|
kind: 'invoke-function',
|
|
functionName: ' ',
|
|
},
|
|
};
|
|
functionWorkflow.graph.edges[0] = { id: 'edge-start-function', source: 'start', target: 'invoke-function', kind: 'direct' };
|
|
functionWorkflow.graph.edges[1] = { id: 'edge-function-end', source: 'invoke-function', target: 'end', kind: 'direct' };
|
|
|
|
const requestPortWorkflow = createWorkflow();
|
|
requestPortWorkflow.graph.nodes[1] = {
|
|
id: 'request-port',
|
|
kind: 'request-port',
|
|
label: 'Approval',
|
|
position: { x: 200, y: 0 },
|
|
config: {
|
|
kind: 'request-port',
|
|
portId: ' ',
|
|
requestType: '',
|
|
responseType: ' ',
|
|
},
|
|
};
|
|
requestPortWorkflow.graph.edges[0] = { id: 'edge-start-port', source: 'start', target: 'request-port', kind: 'direct' };
|
|
requestPortWorkflow.graph.edges[1] = { id: 'edge-port-end', source: 'request-port', target: 'end', kind: 'direct' };
|
|
|
|
expect(validateWorkflowDefinition(functionWorkflow).some((issue) => issue.field === 'graph.nodes.config.functionName')).toBe(true);
|
|
|
|
const requestPortIssues = validateWorkflowDefinition(requestPortWorkflow);
|
|
expect(requestPortIssues.some((issue) => issue.field === 'graph.nodes.config.portId')).toBe(true);
|
|
expect(requestPortIssues.some((issue) => issue.field === 'graph.nodes.config.requestType')).toBe(true);
|
|
expect(requestPortIssues.some((issue) => issue.field === 'graph.nodes.config.responseType')).toBe(true);
|
|
});
|
|
|
|
test('accepts sub-workflow nodes with inline workflows', () => {
|
|
const inlineWorkflow = createReferencedSubWorkflow();
|
|
const workflow = createWorkflow();
|
|
workflow.graph.nodes[1] = {
|
|
id: 'sub-workflow',
|
|
kind: 'sub-workflow',
|
|
label: 'Nested Workflow',
|
|
position: { x: 200, y: 0 },
|
|
config: {
|
|
kind: 'sub-workflow',
|
|
inlineWorkflow,
|
|
},
|
|
};
|
|
workflow.graph.edges[0] = { id: 'edge-start-sub', source: 'start', target: 'sub-workflow', kind: 'direct' };
|
|
workflow.graph.edges[1] = { id: 'edge-sub-end', source: 'sub-workflow', target: 'end', kind: 'direct' };
|
|
|
|
expect(validateWorkflowDefinition(workflow)).toEqual([]);
|
|
});
|
|
|
|
test('rejects sub-workflow nodes without exactly one workflow source', () => {
|
|
const workflow = createWorkflow();
|
|
workflow.graph.nodes[1] = {
|
|
id: 'sub-workflow',
|
|
kind: 'sub-workflow',
|
|
label: 'Nested Workflow',
|
|
position: { x: 200, y: 0 },
|
|
config: {
|
|
kind: 'sub-workflow',
|
|
},
|
|
};
|
|
workflow.graph.edges[0] = { id: 'edge-start-sub', source: 'start', target: 'sub-workflow', kind: 'direct' };
|
|
workflow.graph.edges[1] = { id: 'edge-sub-end', source: 'sub-workflow', target: 'end', kind: 'direct' };
|
|
|
|
const issues = validateWorkflowDefinition(workflow);
|
|
expect(issues.some((issue) => issue.message.includes('exactly one of workflowId or inlineWorkflow'))).toBe(true);
|
|
});
|
|
|
|
test('builds an execution definition from workflow agents', () => {
|
|
const execution = buildWorkflowExecutionDefinition(createWorkflow());
|
|
|
|
expect(execution.id).toBe('workflow-1');
|
|
expect(execution.agents).toHaveLength(1);
|
|
expect(execution.agents[0]?.name).toBe('Primary Agent');
|
|
expect(execution.orchestrationMode).toBe('single');
|
|
});
|
|
|
|
test('includes referenced sub-workflow agents when building execution definitions', () => {
|
|
const childWorkflow = createReferencedSubWorkflow();
|
|
const workflow = createWorkflow();
|
|
workflow.graph.nodes[1] = {
|
|
id: 'sub-workflow',
|
|
kind: 'sub-workflow',
|
|
label: 'Nested Workflow',
|
|
position: { x: 200, y: 0 },
|
|
config: {
|
|
kind: 'sub-workflow',
|
|
workflowId: childWorkflow.id,
|
|
},
|
|
};
|
|
workflow.graph.edges[0] = { id: 'edge-start-sub', source: 'start', target: 'sub-workflow', kind: 'direct' };
|
|
workflow.graph.edges[1] = { id: 'edge-sub-end', source: 'sub-workflow', target: 'end', kind: 'direct' };
|
|
|
|
const execution = buildWorkflowExecutionDefinition(workflow, {
|
|
resolveWorkflow: (workflowId: string) => workflowId === childWorkflow.id ? childWorkflow : undefined,
|
|
});
|
|
|
|
expect(execution.orchestrationMode).toBe('single');
|
|
expect(execution.agents.map((agent) => agent.id)).toEqual(['agent-reviewer']);
|
|
});
|
|
|
|
test('accepts simple property and expression conditions', () => {
|
|
const workflow = createWorkflow();
|
|
workflow.graph.edges[0] = {
|
|
...workflow.graph.edges[0]!,
|
|
condition: {
|
|
type: 'property',
|
|
combinator: 'and',
|
|
rules: [
|
|
{
|
|
propertyPath: 'role',
|
|
operator: 'equals',
|
|
value: 'user',
|
|
},
|
|
],
|
|
},
|
|
};
|
|
workflow.graph.edges[1] = {
|
|
...workflow.graph.edges[1]!,
|
|
condition: {
|
|
type: 'expression',
|
|
expression: 'role == "assistant" || role == "user"',
|
|
},
|
|
};
|
|
|
|
expect(validateWorkflowDefinition(workflow)).toEqual([]);
|
|
});
|
|
|
|
test('rejects invalid condition operators and expressions', () => {
|
|
const workflow = createWorkflow();
|
|
workflow.graph.edges[0] = {
|
|
...workflow.graph.edges[0]!,
|
|
condition: {
|
|
type: 'property',
|
|
rules: [
|
|
{
|
|
propertyPath: 'role',
|
|
operator: 'bad-op' as 'equals',
|
|
value: 'user',
|
|
},
|
|
],
|
|
},
|
|
};
|
|
workflow.graph.edges[1] = {
|
|
...workflow.graph.edges[1]!,
|
|
condition: {
|
|
type: 'expression',
|
|
expression: 'role ~= "user"',
|
|
},
|
|
};
|
|
|
|
const issues = validateWorkflowDefinition(workflow);
|
|
expect(issues.some((issue) => issue.field === 'graph.edges.condition.rules.operator')).toBe(true);
|
|
expect(issues.some((issue) => issue.field === 'graph.edges.condition.expression')).toBe(true);
|
|
});
|
|
|
|
test('rejects unmarked loop edges and requires termination metadata', () => {
|
|
const workflow = createWorkflow();
|
|
workflow.graph.edges.push({
|
|
id: 'edge-agent-loop',
|
|
source: 'agent-primary',
|
|
target: 'agent-primary',
|
|
kind: 'direct',
|
|
});
|
|
|
|
const issues = validateWorkflowDefinition(workflow);
|
|
expect(issues.some((issue) => issue.field === 'graph.edges.isLoop')).toBe(true);
|
|
expect(issues.some((issue) => issue.field === 'graph.edges.condition')).toBe(true);
|
|
expect(issues.some((issue) => issue.field === 'graph.edges.maxIterations')).toBe(true);
|
|
});
|
|
|
|
test('accepts loop edges with condition, cap, and exit path', () => {
|
|
const workflow = createWorkflow();
|
|
workflow.graph.edges.push({
|
|
id: 'edge-agent-loop',
|
|
source: 'agent-primary',
|
|
target: 'agent-primary',
|
|
kind: 'direct',
|
|
isLoop: true,
|
|
maxIterations: 3,
|
|
condition: {
|
|
type: 'property',
|
|
rules: [
|
|
{
|
|
propertyPath: 'iteration',
|
|
operator: 'lt',
|
|
value: '3',
|
|
},
|
|
],
|
|
},
|
|
});
|
|
|
|
expect(validateWorkflowDefinition(workflow)).toEqual([]);
|
|
});
|
|
|
|
test('creates default mode settings for builder-based modes', () => {
|
|
expect(createDefaultModeSettings()).toBeUndefined();
|
|
expect(createDefaultModeSettings('single')).toBeUndefined();
|
|
expect(createDefaultModeSettings('handoff')).toEqual({
|
|
handoff: {
|
|
toolCallFiltering: 'handoff-only',
|
|
returnToPrevious: false,
|
|
},
|
|
});
|
|
expect(createDefaultModeSettings('group-chat')).toEqual({
|
|
groupChat: {
|
|
selectionStrategy: 'round-robin',
|
|
maxRounds: 5,
|
|
},
|
|
});
|
|
});
|
|
|
|
test('identifies graph-based and builder-based orchestration modes', () => {
|
|
expect(isGraphBasedMode('single')).toBe(true);
|
|
expect(isGraphBasedMode('sequential')).toBe(true);
|
|
expect(isGraphBasedMode('concurrent')).toBe(true);
|
|
expect(isGraphBasedMode('handoff')).toBe(false);
|
|
expect(isBuilderBasedMode('handoff')).toBe(true);
|
|
expect(isBuilderBasedMode('group-chat')).toBe(true);
|
|
expect(isBuilderBasedMode('single')).toBe(false);
|
|
});
|
|
|
|
test('scaffolds single mode while preserving the provided agent node', () => {
|
|
const agentNode = createWorkflow().graph.nodes[1] as WorkflowNode;
|
|
const graph = scaffoldGraphForMode('single', [agentNode]);
|
|
|
|
expect(graph.nodes.map((node) => node.id)).toEqual(['start', 'agent-primary', 'end']);
|
|
expect(graph.edges.map((edge) => edge.kind)).toEqual(['direct', 'direct']);
|
|
expect(graph.nodes[1]?.config.kind).toBe('agent');
|
|
expect(graph.nodes[1]?.config.kind === 'agent' ? graph.nodes[1].config.name : '').toBe('Primary Agent');
|
|
});
|
|
|
|
test('scaffolds concurrent mode with fan-out and fan-in edges', () => {
|
|
const graph = scaffoldGraphForMode('concurrent');
|
|
|
|
expect(graph.nodes[0]?.id).toBe('start');
|
|
expect(graph.nodes.at(-1)?.id).toBe('end');
|
|
expect(graph.edges.filter((edge) => edge.kind === 'fan-out')).toHaveLength(2);
|
|
expect(graph.edges.filter((edge) => edge.kind === 'fan-in')).toHaveLength(2);
|
|
});
|
|
|
|
test('scaffolds handoff mode with triage and specialist return loops', () => {
|
|
const graph = scaffoldGraphForMode('handoff');
|
|
const loopEdges = graph.edges.filter((edge) => edge.isLoop);
|
|
|
|
expect(graph.nodes.map((node) => node.id)).toContain('agent-handoff-triage');
|
|
// Both forward (triage→specialist) and return (specialist→triage) edges are loops
|
|
expect(loopEdges).toHaveLength(2);
|
|
expect(loopEdges.every((edge) => edge.maxIterations === 4)).toBe(true);
|
|
expect(loopEdges.every((edge) => edge.condition?.type === 'always')).toBe(true);
|
|
});
|
|
|
|
test('scaffolds group-chat mode with loop edges between agent nodes', () => {
|
|
const graph = scaffoldGraphForMode('group-chat');
|
|
const loopEdges = graph.edges.filter((edge) => edge.isLoop);
|
|
|
|
expect(loopEdges.length).toBeGreaterThanOrEqual(2);
|
|
expect(loopEdges.every((edge) => edge.condition?.type === 'always')).toBe(true);
|
|
});
|
|
|
|
test('normalizes mode settings for builder-based workflows', () => {
|
|
const workflow = createWorkflow();
|
|
workflow.settings.orchestrationMode = 'handoff';
|
|
workflow.settings.modeSettings = {
|
|
handoff: {
|
|
toolCallFiltering: 'all',
|
|
returnToPrevious: true,
|
|
handoffInstructions: ' Delegate carefully. ',
|
|
triageAgentNodeId: ' agent-primary ',
|
|
},
|
|
};
|
|
|
|
const normalized = normalizeWorkflowDefinition(workflow);
|
|
|
|
expect(normalized.settings.modeSettings).toEqual({
|
|
handoff: {
|
|
toolCallFiltering: 'all',
|
|
returnToPrevious: true,
|
|
handoffInstructions: 'Delegate carefully.',
|
|
triageAgentNodeId: 'agent-primary',
|
|
},
|
|
});
|
|
});
|
|
|
|
test('rejects single mode graphs that use multiple agents or fan edges', () => {
|
|
const workflow = createWorkflow();
|
|
workflow.settings.orchestrationMode = 'single';
|
|
workflow.graph.nodes.splice(2, 0, {
|
|
id: 'agent-secondary',
|
|
kind: 'agent',
|
|
label: 'Secondary Agent',
|
|
position: { x: 320, y: 120 },
|
|
order: 1,
|
|
config: {
|
|
kind: 'agent',
|
|
id: 'agent-secondary',
|
|
name: 'Secondary Agent',
|
|
description: 'Handles backup work.',
|
|
instructions: 'Assist the primary agent.',
|
|
model: 'gpt-5.4',
|
|
},
|
|
});
|
|
workflow.graph.edges = [
|
|
{ id: 'edge-start-primary', source: 'start', target: 'agent-primary', kind: 'fan-out', fanOutConfig: { strategy: 'broadcast' } },
|
|
{ id: 'edge-start-secondary', source: 'start', target: 'agent-secondary', kind: 'fan-out', fanOutConfig: { strategy: 'broadcast' } },
|
|
{ id: 'edge-primary-end', source: 'agent-primary', target: 'end', kind: 'fan-in' },
|
|
{ id: 'edge-secondary-end', source: 'agent-secondary', target: 'end', kind: 'fan-in' },
|
|
];
|
|
|
|
const issues = validateWorkflowDefinition(workflow);
|
|
expect(issues.some((issue) => issue.message.includes('Single mode requires exactly one agent node'))).toBe(true);
|
|
expect(issues.some((issue) => issue.message.includes('Single mode does not support fan-out or fan-in edges'))).toBe(true);
|
|
});
|
|
|
|
test('warns when sequential mode is not shaped like a linear graph', () => {
|
|
const workflow = createWorkflow();
|
|
workflow.settings.orchestrationMode = 'sequential';
|
|
workflow.graph.nodes.splice(2, 0, {
|
|
id: 'agent-secondary',
|
|
kind: 'agent',
|
|
label: 'Secondary Agent',
|
|
position: { x: 220, y: 120 },
|
|
order: 1,
|
|
config: {
|
|
kind: 'agent',
|
|
id: 'agent-secondary',
|
|
name: 'Secondary Agent',
|
|
description: 'Handles follow-up work.',
|
|
instructions: 'Follow up.',
|
|
model: 'gpt-5.4',
|
|
},
|
|
});
|
|
workflow.graph.edges = [
|
|
{ id: 'edge-start-primary', source: 'start', target: 'agent-primary', kind: 'direct' },
|
|
{ id: 'edge-start-secondary', source: 'start', target: 'agent-secondary', kind: 'direct' },
|
|
{ id: 'edge-primary-end', source: 'agent-primary', target: 'end', kind: 'direct' },
|
|
{ id: 'edge-secondary-end', source: 'agent-secondary', target: 'end', kind: 'direct' },
|
|
];
|
|
|
|
const issues = validateWorkflowDefinition(workflow);
|
|
expect(issues.some((issue) => issue.level === 'warning' && issue.message.includes('Sequential mode works best with a linear'))).toBe(true);
|
|
});
|
|
|
|
test('requires concurrent mode fan edges and warns when branches do not rejoin', () => {
|
|
const workflow = createWorkflow();
|
|
workflow.settings.orchestrationMode = 'concurrent';
|
|
workflow.graph.nodes.splice(2, 0, {
|
|
id: 'agent-secondary',
|
|
kind: 'agent',
|
|
label: 'Secondary Agent',
|
|
position: { x: 220, y: 120 },
|
|
order: 1,
|
|
config: {
|
|
kind: 'agent',
|
|
id: 'agent-secondary',
|
|
name: 'Secondary Agent',
|
|
description: 'Handles alternate work.',
|
|
instructions: 'Assist in parallel.',
|
|
model: 'gpt-5.4',
|
|
},
|
|
});
|
|
workflow.graph.edges = [
|
|
{ id: 'edge-start-primary', source: 'start', target: 'agent-primary', kind: 'fan-out', fanOutConfig: { strategy: 'broadcast' } },
|
|
{ id: 'edge-start-secondary', source: 'start', target: 'agent-secondary', kind: 'fan-out', fanOutConfig: { strategy: 'broadcast' } },
|
|
{ id: 'edge-primary-end', source: 'agent-primary', target: 'end', kind: 'fan-in' },
|
|
{ id: 'edge-secondary-primary', source: 'agent-secondary', target: 'agent-primary', kind: 'direct' },
|
|
];
|
|
|
|
const issues = validateWorkflowDefinition(workflow);
|
|
expect(issues.some((issue) => issue.level === 'warning' && issue.message.includes('rejoins through a matching fan-in edge'))).toBe(true);
|
|
});
|
|
|
|
test('validates handoff triage settings and specialist return paths', () => {
|
|
const workflow = createWorkflow();
|
|
workflow.settings.orchestrationMode = 'handoff';
|
|
workflow.settings.modeSettings = {
|
|
handoff: {
|
|
toolCallFiltering: 'handoff-only',
|
|
returnToPrevious: false,
|
|
triageAgentNodeId: 'missing-triage',
|
|
},
|
|
};
|
|
workflow.graph.nodes.splice(2, 0, {
|
|
id: 'agent-specialist',
|
|
kind: 'agent',
|
|
label: 'Specialist',
|
|
position: { x: 420, y: 120 },
|
|
order: 1,
|
|
config: {
|
|
kind: 'agent',
|
|
id: 'agent-specialist',
|
|
name: 'Specialist',
|
|
description: 'Handles specialist work.',
|
|
instructions: 'Own the specialist response.',
|
|
model: 'gpt-5.4',
|
|
},
|
|
});
|
|
workflow.graph.edges = [
|
|
{ id: 'edge-start-triage', source: 'start', target: 'agent-primary', kind: 'direct' },
|
|
{ id: 'edge-specialist-end', source: 'agent-specialist', target: 'end', kind: 'direct' },
|
|
{ id: 'edge-triage-end', source: 'agent-primary', target: 'end', kind: 'direct' },
|
|
];
|
|
|
|
const invalidTriageIssues = validateWorkflowDefinition(workflow);
|
|
expect(invalidTriageIssues.some((issue) => issue.field === 'settings.modeSettings.handoff.triageAgentNodeId')).toBe(true);
|
|
|
|
workflow.settings.modeSettings = {
|
|
handoff: {
|
|
toolCallFiltering: 'handoff-only',
|
|
returnToPrevious: false,
|
|
triageAgentNodeId: 'agent-primary',
|
|
},
|
|
};
|
|
|
|
const issues = validateWorkflowDefinition(workflow);
|
|
expect(issues.some((issue) => issue.level === 'warning' && issue.message.includes('triage agent has outgoing edges to specialist agents'))).toBe(true);
|
|
expect(issues.some((issue) => issue.level === 'warning' && issue.message.includes('should have a return path back to the triage agent'))).toBe(true);
|
|
});
|
|
|
|
test('warns when group-chat mode does not create a loop among agents', () => {
|
|
const workflow = createWorkflow();
|
|
workflow.settings.orchestrationMode = 'group-chat';
|
|
workflow.graph.nodes.splice(2, 0, {
|
|
id: 'agent-reviewer',
|
|
kind: 'agent',
|
|
label: 'Reviewer',
|
|
position: { x: 420, y: 0 },
|
|
order: 1,
|
|
config: {
|
|
kind: 'agent',
|
|
id: 'agent-reviewer',
|
|
name: 'Reviewer',
|
|
description: 'Reviews drafts.',
|
|
instructions: 'Review and refine.',
|
|
model: 'gpt-5.4',
|
|
},
|
|
});
|
|
workflow.graph.edges = [
|
|
{ id: 'edge-start-writer', source: 'start', target: 'agent-primary', kind: 'direct' },
|
|
{ id: 'edge-writer-reviewer', source: 'agent-primary', target: 'agent-reviewer', kind: 'direct' },
|
|
{ id: 'edge-reviewer-end', source: 'agent-reviewer', target: 'end', kind: 'direct' },
|
|
];
|
|
|
|
const issues = validateWorkflowDefinition(workflow);
|
|
expect(issues.some((issue) => issue.level === 'warning' && issue.message.includes('form a loop for repeated turns'))).toBe(true);
|
|
});
|
|
|
|
test('builtin handoff and group-chat workflows include mode settings defaults', () => {
|
|
const builtinWorkflows = createBuiltinWorkflows(TIMESTAMP);
|
|
const handoffWorkflow = builtinWorkflows.find((workflow) => workflow.settings.orchestrationMode === 'handoff');
|
|
const groupChatWorkflow = builtinWorkflows.find((workflow) => workflow.settings.orchestrationMode === 'group-chat');
|
|
|
|
expect(handoffWorkflow?.settings.modeSettings).toEqual(createDefaultModeSettings('handoff'));
|
|
expect(groupChatWorkflow?.settings.modeSettings).toEqual(createDefaultModeSettings('group-chat'));
|
|
});
|
|
});
|
|
|
|
describe('built-in workflow consistency', () => {
|
|
const builtinWorkflows = createBuiltinWorkflows(TIMESTAMP);
|
|
|
|
test('all built-in workflows pass validation without errors', () => {
|
|
for (const workflow of builtinWorkflows) {
|
|
const issues = validateWorkflowDefinition(workflow);
|
|
const errors = issues.filter((i) => i.level === 'error');
|
|
expect(errors).toEqual([]);
|
|
}
|
|
});
|
|
|
|
test('all built-in workflows are stable under normalization', () => {
|
|
for (const workflow of builtinWorkflows) {
|
|
const normalized = normalizeWorkflowDefinition(workflow);
|
|
expect(normalized.graph.edges).toEqual(workflow.graph.edges);
|
|
expect(normalized.settings).toEqual(workflow.settings);
|
|
}
|
|
});
|
|
|
|
test('group-chat workflow has consistent maxIterations across settings and edges', () => {
|
|
const workflow = builtinWorkflows.find((w) => w.settings.orchestrationMode === 'group-chat')!;
|
|
const maxRounds = workflow.settings.modeSettings!.groupChat!.maxRounds;
|
|
const maxIterations = workflow.settings.maxIterations;
|
|
|
|
expect(maxIterations).toBeDefined();
|
|
expect(maxRounds).toBe(maxIterations!);
|
|
|
|
const loopEdges = workflow.graph.edges.filter((e) => e.isLoop);
|
|
expect(loopEdges.length).toBeGreaterThan(0);
|
|
for (const edge of loopEdges) {
|
|
expect(edge.maxIterations).toBe(maxRounds);
|
|
}
|
|
});
|
|
|
|
test('handoff workflow has consistent maxIterations across settings and edges', () => {
|
|
const workflow = builtinWorkflows.find((w) => w.settings.orchestrationMode === 'handoff')!;
|
|
const maxIterations = workflow.settings.maxIterations;
|
|
|
|
const loopEdges = workflow.graph.edges.filter((e) => e.isLoop);
|
|
expect(loopEdges.length).toBeGreaterThan(0);
|
|
for (const edge of loopEdges) {
|
|
expect(edge.maxIterations).toBe(maxIterations);
|
|
}
|
|
});
|
|
|
|
test('scaffoldGraphForMode respects provided settings for group-chat', () => {
|
|
const graph = scaffoldGraphForMode('group-chat', {
|
|
settings: {
|
|
checkpointing: { enabled: false },
|
|
executionMode: 'off-thread',
|
|
orchestrationMode: 'group-chat',
|
|
modeSettings: { groupChat: { selectionStrategy: 'round-robin', maxRounds: 12 } },
|
|
maxIterations: 12,
|
|
},
|
|
});
|
|
|
|
const loopEdges = graph.edges.filter((e) => e.isLoop);
|
|
expect(loopEdges.length).toBeGreaterThan(0);
|
|
for (const edge of loopEdges) {
|
|
expect(edge.maxIterations).toBe(12);
|
|
}
|
|
});
|
|
|
|
test('scaffoldGraphForMode respects provided settings for handoff', () => {
|
|
const graph = scaffoldGraphForMode('handoff', {
|
|
settings: {
|
|
checkpointing: { enabled: false },
|
|
executionMode: 'off-thread',
|
|
orchestrationMode: 'handoff',
|
|
maxIterations: 8,
|
|
},
|
|
});
|
|
|
|
const loopEdges = graph.edges.filter((e) => e.isLoop);
|
|
expect(loopEdges.length).toBeGreaterThan(0);
|
|
for (const edge of loopEdges) {
|
|
expect(edge.maxIterations).toBe(8);
|
|
}
|
|
});
|
|
|
|
test('normalizeWorkflowDefinition syncs loop edge maxIterations for group-chat', () => {
|
|
const workflow = createBuiltinWorkflows(TIMESTAMP).find((w) => w.settings.orchestrationMode === 'group-chat')!;
|
|
const modified: WorkflowDefinition = {
|
|
...workflow,
|
|
settings: {
|
|
...workflow.settings,
|
|
maxIterations: 10,
|
|
modeSettings: {
|
|
groupChat: { selectionStrategy: 'round-robin', maxRounds: 10 },
|
|
},
|
|
},
|
|
};
|
|
|
|
const normalized = normalizeWorkflowDefinition(modified);
|
|
const loopEdges = normalized.graph.edges.filter((e) => e.isLoop);
|
|
for (const edge of loopEdges) {
|
|
expect(edge.maxIterations).toBe(10);
|
|
}
|
|
});
|
|
|
|
test('normalizeWorkflowDefinition does not modify loop edges for graph-based modes', () => {
|
|
const workflow = createBuiltinWorkflows(TIMESTAMP).find((w) => w.settings.orchestrationMode === 'sequential')!;
|
|
const normalized = normalizeWorkflowDefinition(workflow);
|
|
expect(normalized.graph.edges).toEqual(workflow.graph.edges);
|
|
});
|
|
});
|