mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-07 12:18:44 +02:00
feat: add backend orchestration mode support
Add first-class backend support for workflow orchestration modes, including mode settings, graph scaffolding, mode-aware validation, and real Agent Framework handoff/group-chat execution paths. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -2,8 +2,15 @@ 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';
|
||||
@@ -371,4 +378,267 @@ describe('workflow validation', () => {
|
||||
|
||||
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');
|
||||
expect(loopEdges).toHaveLength(1);
|
||||
expect(loopEdges[0]).toMatchObject({
|
||||
source: 'agent-handoff-specialist-1',
|
||||
target: 'agent-handoff-triage',
|
||||
maxIterations: 4,
|
||||
});
|
||||
});
|
||||
|
||||
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'));
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user