mirror of
https://github.com/davidkaya/aryx.git
synced 2026-08-06 19:58:43 +02:00
refactor: remove pattern domain and migrate renderer to workflow-only
- Delete src/shared/domain/pattern.ts and all pattern-specific renderer components (PatternEditor, NewSessionModal, pattern-graph directory, patternGraph lib) and their tests - Migrate all renderer imports from @shared/domain/pattern to @shared/domain/workflow (ReasoningEffort, reasoningEffortOptions, WorkflowOrchestrationMode, AgentNodeConfig, WorkflowDefinition) - Update App.tsx: remove workflowToPattern bridge, createDraftPattern, NewSessionModal; sessions now created directly with default workflow - Update ChatPane, ActivityPanel, Sidebar to accept WorkflowDefinition instead of PatternDefinition and derive agents/mode from workflow - Update SettingsPanel: remove PatternsSection nav item, pattern editing state, and pattern-related props; update text references - Update RunTimeline and modeAccent/modeVisuals records to use WorkflowOrchestrationMode (drop magentic mode entry) - Update sessionActivity.ts to use generic agent interface Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -1,383 +0,0 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { addAgentToGraph, createBuiltinPatterns, resolvePatternGraph, type PatternDefinition } from '@shared/domain/pattern';
|
||||
import {
|
||||
addEdge,
|
||||
autoLayoutGraph,
|
||||
canMoveSequential,
|
||||
findAgentForNode,
|
||||
isConnectionAllowed,
|
||||
isEdgeDeletionAllowed,
|
||||
removeEdge,
|
||||
swapSequentialOrder,
|
||||
toCanvasEdges,
|
||||
toCanvasNodes,
|
||||
} from '@renderer/lib/patternGraph';
|
||||
|
||||
const BUILTIN_TIMESTAMP = '2026-03-22T00:00:00.000Z';
|
||||
const patterns = createBuiltinPatterns(BUILTIN_TIMESTAMP);
|
||||
|
||||
function findPattern(mode: string): PatternDefinition {
|
||||
return patterns.find((p) => p.mode === mode)!;
|
||||
}
|
||||
|
||||
describe('pattern graph view model', () => {
|
||||
test('projects sequential graph into canvas nodes with correct kinds and labels', () => {
|
||||
const pattern = findPattern('sequential');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const nodes = toCanvasNodes(graph, pattern.agents);
|
||||
|
||||
expect(nodes.length).toBe(pattern.agents.length + 2);
|
||||
|
||||
const userInput = nodes.find((n) => n.data.kind === 'user-input');
|
||||
const userOutput = nodes.find((n) => n.data.kind === 'user-output');
|
||||
expect(userInput).toBeDefined();
|
||||
expect(userOutput).toBeDefined();
|
||||
expect(userInput!.data.readOnly).toBe(true);
|
||||
expect(userOutput!.data.readOnly).toBe(true);
|
||||
expect(userInput!.type).toBe('userInputNode');
|
||||
|
||||
const agentNodes = nodes.filter((n) => n.data.kind === 'agent');
|
||||
expect(agentNodes.length).toBe(pattern.agents.length);
|
||||
expect(agentNodes[0]!.data.label).toBe(pattern.agents[0]!.name);
|
||||
expect(agentNodes[0]!.data.readOnly).toBe(false);
|
||||
expect(agentNodes[0]!.type).toBe('agentNode');
|
||||
});
|
||||
|
||||
test('projects concurrent graph with distributor and collector system nodes', () => {
|
||||
const pattern = findPattern('concurrent');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const nodes = toCanvasNodes(graph, pattern.agents);
|
||||
const edges = toCanvasEdges(graph, pattern.mode);
|
||||
|
||||
const distributor = nodes.find((n) => n.data.kind === 'distributor');
|
||||
const collector = nodes.find((n) => n.data.kind === 'collector');
|
||||
expect(distributor).toBeDefined();
|
||||
expect(collector).toBeDefined();
|
||||
expect(distributor!.data.readOnly).toBe(true);
|
||||
expect(collector!.data.readOnly).toBe(true);
|
||||
|
||||
expect(edges.length).toBeGreaterThan(0);
|
||||
expect(edges.every((e) => !e.animated)).toBe(true);
|
||||
});
|
||||
|
||||
test('handoff graph edges are animated', () => {
|
||||
const pattern = findPattern('handoff');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const edges = toCanvasEdges(graph, pattern.mode);
|
||||
|
||||
expect(edges.every((e) => e.animated)).toBe(true);
|
||||
});
|
||||
|
||||
test('group chat graph includes orchestrator system node', () => {
|
||||
const pattern = findPattern('group-chat');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const nodes = toCanvasNodes(graph, pattern.agents);
|
||||
|
||||
const orchestrator = nodes.find((n) => n.data.kind === 'orchestrator');
|
||||
expect(orchestrator).toBeDefined();
|
||||
expect(orchestrator!.data.readOnly).toBe(true);
|
||||
expect(orchestrator!.data.label).toBe('Orchestrator');
|
||||
});
|
||||
|
||||
test('findAgentForNode resolves agent from graph node id', () => {
|
||||
const pattern = findPattern('sequential');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
|
||||
const firstAgent = pattern.agents[0]!;
|
||||
const agent = findAgentForNode(`agent-node-${firstAgent.id}`, graph, pattern.agents);
|
||||
expect(agent).toBeDefined();
|
||||
expect(agent!.id).toBe(firstAgent.id);
|
||||
|
||||
const noAgent = findAgentForNode('system-user-input', graph, pattern.agents);
|
||||
expect(noAgent).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('pattern graph connection rules', () => {
|
||||
test('sequential mode disallows all new connections', () => {
|
||||
const pattern = findPattern('sequential');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const agentNodes = graph.nodes.filter((n) => n.kind === 'agent');
|
||||
|
||||
const allowed = isConnectionAllowed(
|
||||
{ source: agentNodes[0]!.id, target: agentNodes[1]!.id, sourceHandle: null, targetHandle: null },
|
||||
'sequential',
|
||||
graph,
|
||||
);
|
||||
expect(allowed).toBe(false);
|
||||
});
|
||||
|
||||
test('handoff mode allows agent-to-agent connections', () => {
|
||||
const pattern = findPattern('handoff');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const agentNodes = graph.nodes.filter((n) => n.kind === 'agent');
|
||||
|
||||
const allowed = isConnectionAllowed(
|
||||
{ source: agentNodes[0]!.id, target: agentNodes[1]!.id, sourceHandle: null, targetHandle: null },
|
||||
'handoff',
|
||||
graph,
|
||||
);
|
||||
expect(allowed).toBe(true);
|
||||
});
|
||||
|
||||
test('handoff mode blocks system-to-agent connections', () => {
|
||||
const pattern = findPattern('handoff');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const inputNode = graph.nodes.find((n) => n.kind === 'user-input')!;
|
||||
const agentNode = graph.nodes.find((n) => n.kind === 'agent')!;
|
||||
|
||||
const allowed = isConnectionAllowed(
|
||||
{ source: inputNode.id, target: agentNode.id, sourceHandle: null, targetHandle: null },
|
||||
'handoff',
|
||||
graph,
|
||||
);
|
||||
expect(allowed).toBe(false);
|
||||
});
|
||||
|
||||
test('concurrent mode disallows all new connections', () => {
|
||||
const pattern = findPattern('concurrent');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const agentNodes = graph.nodes.filter((n) => n.kind === 'agent');
|
||||
|
||||
const allowed = isConnectionAllowed(
|
||||
{ source: agentNodes[0]!.id, target: agentNodes[1]!.id, sourceHandle: null, targetHandle: null },
|
||||
'concurrent',
|
||||
graph,
|
||||
);
|
||||
expect(allowed).toBe(false);
|
||||
});
|
||||
|
||||
test('group-chat mode allows orchestrator-to-agent connections', () => {
|
||||
const pattern = findPattern('group-chat');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const orchestratorNode = graph.nodes.find((n) => n.kind === 'orchestrator')!;
|
||||
const agentNode = graph.nodes.find((n) => n.kind === 'agent')!;
|
||||
|
||||
const orcToAgent = isConnectionAllowed(
|
||||
{ source: orchestratorNode.id, target: agentNode.id, sourceHandle: null, targetHandle: null },
|
||||
'group-chat',
|
||||
graph,
|
||||
);
|
||||
expect(orcToAgent).toBe(true);
|
||||
|
||||
const agentToOrc = isConnectionAllowed(
|
||||
{ source: agentNode.id, target: orchestratorNode.id, sourceHandle: null, targetHandle: null },
|
||||
'group-chat',
|
||||
graph,
|
||||
);
|
||||
expect(agentToOrc).toBe(true);
|
||||
});
|
||||
|
||||
test('group-chat mode disallows agent-to-agent connections', () => {
|
||||
const pattern = findPattern('group-chat');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const agentNodes = graph.nodes.filter((n) => n.kind === 'agent');
|
||||
|
||||
const allowed = isConnectionAllowed(
|
||||
{ source: agentNodes[0]!.id, target: agentNodes[1]!.id, sourceHandle: null, targetHandle: null },
|
||||
'group-chat',
|
||||
graph,
|
||||
);
|
||||
expect(allowed).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('pattern graph mutation helpers', () => {
|
||||
test('addEdge adds a new edge between agent nodes', () => {
|
||||
const pattern = findPattern('handoff');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const agentNodes = graph.nodes.filter((n) => n.kind === 'agent');
|
||||
const initialEdgeCount = graph.edges.length;
|
||||
|
||||
const updated = addEdge(graph, agentNodes[1]!.id, agentNodes[2]!.id);
|
||||
expect(updated.edges.length).toBe(initialEdgeCount + 1);
|
||||
|
||||
const duplicated = addEdge(updated, agentNodes[1]!.id, agentNodes[2]!.id);
|
||||
expect(duplicated.edges.length).toBe(initialEdgeCount + 1);
|
||||
});
|
||||
|
||||
test('removeEdge removes an edge by id', () => {
|
||||
const pattern = findPattern('handoff');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const firstEdge = graph.edges[0]!;
|
||||
|
||||
const updated = removeEdge(graph, firstEdge.id);
|
||||
expect(updated.edges.length).toBe(graph.edges.length - 1);
|
||||
expect(updated.edges.find((e) => e.id === firstEdge.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('addAgentToGraph inserts a wired agent node for sequential mode', () => {
|
||||
const pattern = findPattern('sequential');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const newAgent = { id: 'new-1', name: 'New Agent', description: '', instructions: '', model: 'gpt-5.4' };
|
||||
|
||||
const updated = addAgentToGraph(graph, pattern.mode, newAgent);
|
||||
expect(updated.nodes.length).toBe(graph.nodes.length + 1);
|
||||
|
||||
const newNode = updated.nodes.find((n) => n.agentId === 'new-1');
|
||||
expect(newNode).toBeDefined();
|
||||
expect(newNode!.kind).toBe('agent');
|
||||
|
||||
// New agent is wired into the chain, so edge count stays at agents+1
|
||||
expect(updated.edges.length).toBe(graph.edges.length + 1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sequential reorder', () => {
|
||||
test('swapSequentialOrder swaps two adjacent agents and rebuilds edges', () => {
|
||||
const pattern = findPattern('sequential');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const agentNodes = graph.nodes.filter((n) => n.kind === 'agent').sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
|
||||
const firstId = agentNodes[0]!.id;
|
||||
const secondId = agentNodes[1]!.id;
|
||||
|
||||
const swapped = swapSequentialOrder(graph, firstId, 'down');
|
||||
const newFirst = swapped.nodes.find((n) => n.id === firstId)!;
|
||||
const newSecond = swapped.nodes.find((n) => n.id === secondId)!;
|
||||
|
||||
expect(newFirst.order).toBe(1);
|
||||
expect(newSecond.order).toBe(0);
|
||||
|
||||
// Verify linear edges still form a valid chain
|
||||
const sortedAgents = swapped.nodes.filter((n) => n.kind === 'agent').sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
expect(sortedAgents[0]!.id).toBe(secondId);
|
||||
expect(sortedAgents[1]!.id).toBe(firstId);
|
||||
});
|
||||
|
||||
test('canMoveSequential respects boundary conditions', () => {
|
||||
const pattern = findPattern('sequential');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const agentNodes = graph.nodes.filter((n) => n.kind === 'agent').sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
|
||||
expect(canMoveSequential(graph, agentNodes[0]!.id, 'up')).toBe(false);
|
||||
expect(canMoveSequential(graph, agentNodes[0]!.id, 'down')).toBe(true);
|
||||
expect(canMoveSequential(graph, agentNodes[agentNodes.length - 1]!.id, 'down')).toBe(false);
|
||||
expect(canMoveSequential(graph, agentNodes[agentNodes.length - 1]!.id, 'up')).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe('edge deletion rules', () => {
|
||||
test('handoff and group-chat modes allow edge deletion', () => {
|
||||
expect(isEdgeDeletionAllowed('handoff')).toBe(true);
|
||||
expect(isEdgeDeletionAllowed('group-chat')).toBe(true);
|
||||
expect(isEdgeDeletionAllowed('sequential')).toBe(false);
|
||||
expect(isEdgeDeletionAllowed('concurrent')).toBe(false);
|
||||
expect(isEdgeDeletionAllowed('single')).toBe(false);
|
||||
});
|
||||
|
||||
test('handoff canvas edges mark only agent-to-agent edges as deletable', () => {
|
||||
const pattern = findPattern('handoff');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const edges = toCanvasEdges(graph, 'handoff');
|
||||
|
||||
const deletableEdges = edges.filter((e) => e.deletable);
|
||||
const nonDeletableEdges = edges.filter((e) => !e.deletable);
|
||||
|
||||
// Agent-to-agent edges should be deletable
|
||||
expect(deletableEdges.length).toBeGreaterThan(0);
|
||||
// Structural edges (user-input → triage, agent → user-output) should not
|
||||
expect(nonDeletableEdges.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('group-chat marks orchestrator↔agent edges as deletable', () => {
|
||||
const pattern = findPattern('group-chat');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const edges = toCanvasEdges(graph, 'group-chat');
|
||||
|
||||
const deletableEdges = edges.filter((e) => e.deletable);
|
||||
const nonDeletableEdges = edges.filter((e) => !e.deletable);
|
||||
|
||||
// Orchestrator↔agent edges should be deletable
|
||||
expect(deletableEdges.length).toBeGreaterThan(0);
|
||||
// Structural edges (user-input → orchestrator, orchestrator → user-output)
|
||||
expect(nonDeletableEdges.length).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
test('user-input nodes use userInputNode type and user-output use userOutputNode type', () => {
|
||||
const pattern = findPattern('sequential');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const nodes = toCanvasNodes(graph, pattern.agents);
|
||||
|
||||
const inputNode = nodes.find((n) => n.data.kind === 'user-input');
|
||||
const outputNode = nodes.find((n) => n.data.kind === 'user-output');
|
||||
|
||||
expect(inputNode!.type).toBe('userInputNode');
|
||||
expect(outputNode!.type).toBe('userOutputNode');
|
||||
});
|
||||
|
||||
test('edges have directional arrow markers', () => {
|
||||
const pattern = findPattern('sequential');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const edges = toCanvasEdges(graph, pattern.mode);
|
||||
|
||||
expect(edges.length).toBeGreaterThan(0);
|
||||
for (const edge of edges) {
|
||||
expect(edge.markerEnd).toBeDefined();
|
||||
expect((edge.markerEnd as { type: string }).type).toBe('arrowclosed');
|
||||
}
|
||||
});
|
||||
|
||||
test('agent nodes include provider and model label when models catalog is provided', () => {
|
||||
const { modelCatalog } = require('@shared/domain/models');
|
||||
const pattern = findPattern('sequential');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const nodes = toCanvasNodes(graph, pattern.agents, modelCatalog);
|
||||
|
||||
const agentNodes = nodes.filter((n) => n.data.kind === 'agent');
|
||||
expect(agentNodes.length).toBeGreaterThan(0);
|
||||
|
||||
for (const node of agentNodes) {
|
||||
expect(node.data.provider).toBeDefined();
|
||||
expect(node.data.modelLabel).toBeDefined();
|
||||
expect(node.data.modelLabel!.length).toBeGreaterThan(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('agent nodes infer provider from model id without models catalog', () => {
|
||||
const pattern = findPattern('sequential');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const nodes = toCanvasNodes(graph, pattern.agents);
|
||||
|
||||
const agentNodes = nodes.filter((n) => n.data.kind === 'agent');
|
||||
for (const node of agentNodes) {
|
||||
// Provider should still be inferred from model id prefix
|
||||
expect(node.data.provider).toBeDefined();
|
||||
// Without catalog, modelLabel falls back to the raw model id
|
||||
expect(node.data.modelLabel).toBeDefined();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe('auto-layout', () => {
|
||||
test('autoLayoutGraph repositions nodes without changing edges', () => {
|
||||
const pattern = findPattern('handoff');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const layouted = autoLayoutGraph(graph);
|
||||
|
||||
expect(layouted.nodes.length).toBe(graph.nodes.length);
|
||||
expect(layouted.edges.length).toBe(graph.edges.length);
|
||||
expect(layouted.edges).toEqual(graph.edges);
|
||||
|
||||
const positionsChanged = layouted.nodes.some((n, i) => {
|
||||
const orig = graph.nodes[i]!;
|
||||
return n.position.x !== orig.position.x || n.position.y !== orig.position.y;
|
||||
});
|
||||
expect(positionsChanged).toBe(true);
|
||||
});
|
||||
|
||||
test('autoLayoutGraph produces finite positions for all modes', () => {
|
||||
for (const mode of ['sequential', 'concurrent', 'handoff', 'group-chat'] as const) {
|
||||
const pattern = findPattern(mode);
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const layouted = autoLayoutGraph(graph);
|
||||
|
||||
expect(layouted.nodes.length).toBe(graph.nodes.length);
|
||||
for (const node of layouted.nodes) {
|
||||
expect(Number.isFinite(node.position.x)).toBe(true);
|
||||
expect(Number.isFinite(node.position.y)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -1,538 +0,0 @@
|
||||
import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import {
|
||||
addAgentToGraph,
|
||||
createBuiltinPatterns,
|
||||
removeAgentFromGraph,
|
||||
resolvePatternGraph,
|
||||
syncPatternGraph,
|
||||
type PatternAgentDefinition,
|
||||
validatePatternDefinition,
|
||||
} from '@shared/domain/pattern';
|
||||
|
||||
const BUILTIN_TIMESTAMP = '2026-03-22T00:00:00.000Z';
|
||||
|
||||
function createAgent(id: string, name = `Agent ${id}`): PatternAgentDefinition {
|
||||
return {
|
||||
id,
|
||||
name,
|
||||
description: `${name} description`,
|
||||
instructions: `${name} instructions`,
|
||||
model: 'gpt-5.4',
|
||||
reasoningEffort: 'medium',
|
||||
};
|
||||
}
|
||||
|
||||
describe('pattern validation', () => {
|
||||
test('builtin patterns are valid except explicitly unavailable modes', () => {
|
||||
const patterns = createBuiltinPatterns(BUILTIN_TIMESTAMP);
|
||||
|
||||
const validPatterns = patterns.filter((pattern) => pattern.availability !== 'unavailable');
|
||||
|
||||
for (const pattern of validPatterns) {
|
||||
expect(validatePatternDefinition(pattern)).toEqual([]);
|
||||
}
|
||||
});
|
||||
|
||||
test('builtin patterns require tool-call approval by default', () => {
|
||||
const patterns = createBuiltinPatterns(BUILTIN_TIMESTAMP);
|
||||
|
||||
for (const pattern of patterns) {
|
||||
expect(pattern.approvalPolicy?.rules).toContainEqual({ kind: 'tool-call' });
|
||||
}
|
||||
});
|
||||
|
||||
test('magentic pattern is marked unavailable', () => {
|
||||
const magentic = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
|
||||
(pattern) => pattern.mode === 'magentic',
|
||||
);
|
||||
|
||||
expect(magentic).toBeDefined();
|
||||
expect(validatePatternDefinition(magentic!)[0]?.message).toContain('unsupported');
|
||||
});
|
||||
|
||||
test('single-agent mode reports agent count, warning, and model issues together', () => {
|
||||
const singlePattern = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
|
||||
(pattern) => pattern.mode === 'single',
|
||||
);
|
||||
|
||||
expect(singlePattern).toBeDefined();
|
||||
|
||||
const issues = validatePatternDefinition({
|
||||
...singlePattern!,
|
||||
agents: [
|
||||
{
|
||||
...singlePattern!.agents[0],
|
||||
instructions: ' ',
|
||||
},
|
||||
{
|
||||
...singlePattern!.agents[0],
|
||||
id: 'agent-reviewer',
|
||||
name: 'Reviewer',
|
||||
model: '',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
expect(issues.find((issue) => issue.field === 'agents')?.message).toBe(
|
||||
'Single-agent chat requires exactly one agent.',
|
||||
);
|
||||
expect(issues.find((issue) => issue.field === 'agents.instructions')?.level).toBe('warning');
|
||||
expect(issues.find((issue) => issue.field === 'agents.instructions')?.message).toBe(
|
||||
'Agent "Primary Agent" should have instructions.',
|
||||
);
|
||||
expect(issues.find((issue) => issue.field === 'agents.model')?.message).toBe(
|
||||
'Agent "Reviewer" requires a model identifier.',
|
||||
);
|
||||
});
|
||||
|
||||
test('multi-agent orchestration modes reject single-agent configurations', () => {
|
||||
const patterns = createBuiltinPatterns(BUILTIN_TIMESTAMP);
|
||||
const handoff = patterns.find((pattern) => pattern.mode === 'handoff');
|
||||
const groupChat = patterns.find((pattern) => pattern.mode === 'group-chat');
|
||||
|
||||
expect(handoff).toBeDefined();
|
||||
expect(groupChat).toBeDefined();
|
||||
|
||||
expect(
|
||||
validatePatternDefinition({
|
||||
...handoff!,
|
||||
agents: handoff!.agents.slice(0, 1),
|
||||
}).find((issue) => issue.field === 'agents')?.message,
|
||||
).toBe('Handoff orchestration requires at least two agents.');
|
||||
|
||||
expect(
|
||||
validatePatternDefinition({
|
||||
...groupChat!,
|
||||
agents: groupChat!.agents.slice(0, 1),
|
||||
}).find((issue) => issue.field === 'agents')?.message,
|
||||
).toBe('Group chat requires at least two agents.');
|
||||
});
|
||||
|
||||
test('handoff builtin instructions clearly separate triage and specialist ownership', () => {
|
||||
const handoff = createBuiltinPatterns(BUILTIN_TIMESTAMP).find((pattern) => pattern.mode === 'handoff');
|
||||
|
||||
expect(handoff).toBeDefined();
|
||||
expect(handoff?.agents[0].instructions).toContain('hand off before inspecting files');
|
||||
expect(handoff?.agents[0].instructions).toContain('Do not claim that you delegated');
|
||||
expect(handoff?.agents[1].instructions).toContain('own the substantive answer');
|
||||
expect(handoff?.agents[2].instructions).toContain('own the substantive answer');
|
||||
});
|
||||
|
||||
test('group chat builtin instructions frame iterative drafting and review', () => {
|
||||
const groupChat = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
|
||||
(pattern) => pattern.mode === 'group-chat',
|
||||
);
|
||||
|
||||
expect(groupChat).toBeDefined();
|
||||
expect(groupChat?.agents[0].instructions).toContain('refine your earlier draft');
|
||||
expect(groupChat?.agents[1].instructions).toContain('specific improvements');
|
||||
expect(groupChat?.agents[1].instructions).toContain('instead of restarting the conversation');
|
||||
});
|
||||
|
||||
test('approval policy rejects unknown agent references', () => {
|
||||
const singlePattern = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
|
||||
(pattern) => pattern.mode === 'single',
|
||||
);
|
||||
|
||||
expect(singlePattern).toBeDefined();
|
||||
|
||||
const issues = validatePatternDefinition({
|
||||
...singlePattern!,
|
||||
approvalPolicy: {
|
||||
rules: [
|
||||
{
|
||||
kind: 'tool-call',
|
||||
agentIds: ['agent-missing'],
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(issues.find((issue) => issue.field === 'approvalPolicy')?.message).toBe(
|
||||
'Approval checkpoint "tool-call" references unknown agent "agent-missing".',
|
||||
);
|
||||
});
|
||||
|
||||
test('approval policy rejects unknown auto-approved tool references when tool names are provided', () => {
|
||||
const singlePattern = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
|
||||
(pattern) => pattern.mode === 'single',
|
||||
);
|
||||
|
||||
expect(singlePattern).toBeDefined();
|
||||
|
||||
const issues = validatePatternDefinition({
|
||||
...singlePattern!,
|
||||
approvalPolicy: {
|
||||
rules: [{ kind: 'tool-call' }],
|
||||
autoApprovedToolNames: ['web_fetch', 'unknown.tool'],
|
||||
},
|
||||
}, ['web_fetch']);
|
||||
|
||||
expect(issues.find((issue) => issue.field === 'approvalPolicy')?.message).toBe(
|
||||
'Approval auto-approve references unknown tool "unknown.tool".',
|
||||
);
|
||||
});
|
||||
|
||||
test('builtin patterns seed graph topology for each orchestration mode', () => {
|
||||
const patterns = createBuiltinPatterns(BUILTIN_TIMESTAMP);
|
||||
const single = patterns.find((pattern) => pattern.mode === 'single');
|
||||
const concurrent = patterns.find((pattern) => pattern.mode === 'concurrent');
|
||||
const handoff = patterns.find((pattern) => pattern.mode === 'handoff');
|
||||
const groupChat = patterns.find((pattern) => pattern.mode === 'group-chat');
|
||||
|
||||
expect(single).toBeDefined();
|
||||
expect(concurrent).toBeDefined();
|
||||
expect(handoff).toBeDefined();
|
||||
expect(groupChat).toBeDefined();
|
||||
|
||||
expect(resolvePatternGraph(single!).nodes.map((node) => node.kind)).toEqual([
|
||||
'user-input',
|
||||
'agent',
|
||||
'user-output',
|
||||
]);
|
||||
|
||||
expect(resolvePatternGraph(concurrent!).nodes.map((node) => node.kind)).toEqual([
|
||||
'user-input',
|
||||
'distributor',
|
||||
'agent',
|
||||
'agent',
|
||||
'agent',
|
||||
'collector',
|
||||
'user-output',
|
||||
]);
|
||||
|
||||
expect(resolvePatternGraph(handoff!).edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'system-user-input',
|
||||
target: 'agent-node-agent-handoff-triage',
|
||||
}),
|
||||
);
|
||||
expect(resolvePatternGraph(handoff!).edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'agent-node-agent-handoff-triage',
|
||||
target: 'agent-node-agent-handoff-ux',
|
||||
}),
|
||||
);
|
||||
|
||||
expect(resolvePatternGraph(groupChat!).nodes.map((node) => node.kind)).toContain('orchestrator');
|
||||
});
|
||||
|
||||
test('syncPatternGraph rebuilds sequential topology from the current agent list', () => {
|
||||
const sequential = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
|
||||
(pattern) => pattern.mode === 'sequential',
|
||||
);
|
||||
|
||||
expect(sequential).toBeDefined();
|
||||
|
||||
const updated = syncPatternGraph({
|
||||
...sequential!,
|
||||
agents: [
|
||||
...sequential!.agents,
|
||||
{
|
||||
id: 'agent-sequential-final',
|
||||
name: 'Final Reviewer',
|
||||
description: 'Adds a final pass.',
|
||||
instructions: 'Do a last review.',
|
||||
model: 'gpt-5.4',
|
||||
reasoningEffort: 'medium',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const graph = resolvePatternGraph(updated);
|
||||
expect(graph.nodes.filter((node) => node.kind === 'agent')).toHaveLength(4);
|
||||
expect(graph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'agent-node-agent-sequential-reviewer',
|
||||
target: 'agent-node-agent-sequential-final',
|
||||
}),
|
||||
);
|
||||
expect(graph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'agent-node-agent-sequential-final',
|
||||
target: 'system-user-output',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('addAgentToGraph appends a new sequential agent before user output', () => {
|
||||
const sequential = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
|
||||
(pattern) => pattern.mode === 'sequential',
|
||||
);
|
||||
|
||||
expect(sequential).toBeDefined();
|
||||
|
||||
const updatedGraph = addAgentToGraph(
|
||||
resolvePatternGraph(sequential!),
|
||||
sequential!.mode,
|
||||
createAgent('agent-sequential-final', 'Final Reviewer'),
|
||||
);
|
||||
|
||||
expect(updatedGraph.nodes.filter((node) => node.kind === 'agent')).toHaveLength(4);
|
||||
expect(updatedGraph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'agent-node-agent-sequential-reviewer',
|
||||
target: 'agent-node-agent-sequential-final',
|
||||
}),
|
||||
);
|
||||
expect(updatedGraph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'agent-node-agent-sequential-final',
|
||||
target: 'system-user-output',
|
||||
}),
|
||||
);
|
||||
expect(updatedGraph.edges).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'agent-node-agent-sequential-reviewer',
|
||||
target: 'system-user-output',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('addAgentToGraph wires concurrent agents between the distributor and collector', () => {
|
||||
const concurrent = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
|
||||
(pattern) => pattern.mode === 'concurrent',
|
||||
);
|
||||
|
||||
expect(concurrent).toBeDefined();
|
||||
|
||||
const updatedGraph = addAgentToGraph(
|
||||
resolvePatternGraph(concurrent!),
|
||||
concurrent!.mode,
|
||||
createAgent('agent-concurrent-final', 'Final Implementer'),
|
||||
);
|
||||
|
||||
expect(updatedGraph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'system-distributor',
|
||||
target: 'agent-node-agent-concurrent-final',
|
||||
}),
|
||||
);
|
||||
expect(updatedGraph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'agent-node-agent-concurrent-final',
|
||||
target: 'system-collector',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('addAgentToGraph wires handoff specialists to the entry agent and output', () => {
|
||||
const handoff = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
|
||||
(pattern) => pattern.mode === 'handoff',
|
||||
);
|
||||
|
||||
expect(handoff).toBeDefined();
|
||||
|
||||
const updatedGraph = addAgentToGraph(
|
||||
resolvePatternGraph(handoff!),
|
||||
handoff!.mode,
|
||||
createAgent('agent-handoff-docs', 'Docs Specialist'),
|
||||
);
|
||||
|
||||
expect(updatedGraph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'agent-node-agent-handoff-triage',
|
||||
target: 'agent-node-agent-handoff-docs',
|
||||
}),
|
||||
);
|
||||
expect(updatedGraph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'agent-node-agent-handoff-docs',
|
||||
target: 'agent-node-agent-handoff-triage',
|
||||
}),
|
||||
);
|
||||
expect(updatedGraph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'agent-node-agent-handoff-docs',
|
||||
target: 'system-user-output',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('addAgentToGraph wires group-chat agents to the orchestrator', () => {
|
||||
const groupChat = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
|
||||
(pattern) => pattern.mode === 'group-chat',
|
||||
);
|
||||
|
||||
expect(groupChat).toBeDefined();
|
||||
|
||||
const updatedGraph = addAgentToGraph(
|
||||
resolvePatternGraph(groupChat!),
|
||||
groupChat!.mode,
|
||||
createAgent('agent-group-editor', 'Editor'),
|
||||
);
|
||||
|
||||
expect(updatedGraph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'system-orchestrator',
|
||||
target: 'agent-node-agent-group-editor',
|
||||
}),
|
||||
);
|
||||
expect(updatedGraph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'agent-node-agent-group-editor',
|
||||
target: 'system-orchestrator',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('addAgentToGraph rejects additions in single-agent mode', () => {
|
||||
const single = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
|
||||
(pattern) => pattern.mode === 'single',
|
||||
);
|
||||
|
||||
expect(single).toBeDefined();
|
||||
expect(() =>
|
||||
addAgentToGraph(resolvePatternGraph(single!), single!.mode, createAgent('agent-extra', 'Extra Agent')))
|
||||
.toThrow('Single-agent chat requires exactly one agent.');
|
||||
});
|
||||
|
||||
test('removeAgentFromGraph stitches linear gaps and re-numbers remaining agent orders', () => {
|
||||
const sequential = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
|
||||
(pattern) => pattern.mode === 'sequential',
|
||||
);
|
||||
|
||||
expect(sequential).toBeDefined();
|
||||
|
||||
const updatedGraph = removeAgentFromGraph(
|
||||
resolvePatternGraph(sequential!),
|
||||
sequential!.mode,
|
||||
'agent-sequential-builder',
|
||||
);
|
||||
|
||||
expect(updatedGraph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'agent-node-agent-sequential-analyst',
|
||||
target: 'agent-node-agent-sequential-reviewer',
|
||||
}),
|
||||
);
|
||||
expect(updatedGraph.edges).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'agent-node-agent-sequential-analyst',
|
||||
target: 'agent-node-agent-sequential-builder',
|
||||
}),
|
||||
);
|
||||
expect(updatedGraph.edges).not.toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'agent-node-agent-sequential-builder',
|
||||
target: 'agent-node-agent-sequential-reviewer',
|
||||
}),
|
||||
);
|
||||
expect(
|
||||
updatedGraph.nodes
|
||||
.filter((node) => node.kind === 'agent')
|
||||
.map((node) => node.order),
|
||||
).toEqual([0, 1]);
|
||||
});
|
||||
|
||||
test('removeAgentFromGraph cleans up concurrent fan-out and fan-in edges', () => {
|
||||
const concurrent = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
|
||||
(pattern) => pattern.mode === 'concurrent',
|
||||
);
|
||||
|
||||
expect(concurrent).toBeDefined();
|
||||
|
||||
const updatedGraph = removeAgentFromGraph(
|
||||
resolvePatternGraph(concurrent!),
|
||||
concurrent!.mode,
|
||||
'agent-concurrent-product',
|
||||
);
|
||||
|
||||
expect(updatedGraph.nodes.some((node) => node.agentId === 'agent-concurrent-product')).toBe(false);
|
||||
expect(updatedGraph.edges.some((edge) => edge.target === 'agent-node-agent-concurrent-product')).toBe(false);
|
||||
expect(updatedGraph.edges.some((edge) => edge.source === 'agent-node-agent-concurrent-product')).toBe(false);
|
||||
});
|
||||
|
||||
test('removeAgentFromGraph rewires a removed handoff entry agent to the next specialist', () => {
|
||||
const handoff = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
|
||||
(pattern) => pattern.mode === 'handoff',
|
||||
);
|
||||
|
||||
expect(handoff).toBeDefined();
|
||||
|
||||
const updatedGraph = removeAgentFromGraph(
|
||||
resolvePatternGraph(handoff!),
|
||||
handoff!.mode,
|
||||
'agent-handoff-triage',
|
||||
);
|
||||
|
||||
expect(updatedGraph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'system-user-input',
|
||||
target: 'agent-node-agent-handoff-ux',
|
||||
}),
|
||||
);
|
||||
expect(updatedGraph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'agent-node-agent-handoff-ux',
|
||||
target: 'system-user-output',
|
||||
}),
|
||||
);
|
||||
expect(updatedGraph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'agent-node-agent-handoff-ux',
|
||||
target: 'agent-node-agent-handoff-runtime',
|
||||
}),
|
||||
);
|
||||
expect(updatedGraph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'agent-node-agent-handoff-runtime',
|
||||
target: 'agent-node-agent-handoff-ux',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('removeAgentFromGraph preserves orchestrator routes for the remaining group-chat agents', () => {
|
||||
const groupChat = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
|
||||
(pattern) => pattern.mode === 'group-chat',
|
||||
);
|
||||
|
||||
expect(groupChat).toBeDefined();
|
||||
|
||||
const updatedGraph = removeAgentFromGraph(
|
||||
resolvePatternGraph(groupChat!),
|
||||
groupChat!.mode,
|
||||
'agent-group-reviewer',
|
||||
);
|
||||
|
||||
expect(updatedGraph.nodes.some((node) => node.agentId === 'agent-group-reviewer')).toBe(false);
|
||||
expect(updatedGraph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'system-orchestrator',
|
||||
target: 'agent-node-agent-group-writer',
|
||||
}),
|
||||
);
|
||||
expect(updatedGraph.edges).toContainEqual(
|
||||
expect.objectContaining({
|
||||
source: 'agent-node-agent-group-writer',
|
||||
target: 'system-orchestrator',
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('graph validation rejects branched sequential topology', () => {
|
||||
const sequential = createBuiltinPatterns(BUILTIN_TIMESTAMP).find(
|
||||
(pattern) => pattern.mode === 'sequential',
|
||||
);
|
||||
|
||||
expect(sequential).toBeDefined();
|
||||
|
||||
const issues = validatePatternDefinition({
|
||||
...sequential!,
|
||||
graph: {
|
||||
...resolvePatternGraph(sequential!),
|
||||
edges: [
|
||||
...resolvePatternGraph(sequential!).edges,
|
||||
{
|
||||
id: 'edge-system-user-input-to-agent-node-agent-sequential-builder-duplicate',
|
||||
source: 'system-user-input',
|
||||
target: 'agent-node-agent-sequential-builder',
|
||||
},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
expect(issues.find((issue) => issue.field === 'graph')?.message).toContain('single path');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user