mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-25 22:18:40 +02:00
Replace the flat agent list and static flow diagrams in PatternEditor with an interactive React Flow canvas backed by the authoritative PatternGraph model. Users can now see and manipulate orchestration topology visually. Changes: - Add @xyflow/react (React Flow) as a canvas library - Create patternGraph view-model layer for graph-to-canvas projection - Create GraphNodes with custom system/agent node components - Create PatternGraphCanvas with drag, connect, and selection support - Create PatternGraphInspector for selected node/agent details - Rewrite PatternEditor with split layout: canvas + inspector sidebar - Add dark theme CSS overrides for React Flow - Add 11 tests covering view-model projection, connection rules, and graph mutation helpers - Update ARCHITECTURE.md to reflect the new renderer graph boundary The canvas projects PatternGraph into React Flow nodes/edges. Handoff mode supports drawing new agent-to-agent edges interactively. Node positions persist to the graph on drag completion. Agent editing moves from inline forms to the inspector panel on node selection. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
172 lines
6.3 KiB
TypeScript
172 lines
6.3 KiB
TypeScript
import { describe, expect, test } from 'bun:test';
|
|
|
|
import { createBuiltinPatterns, resolvePatternGraph, type PatternDefinition } from '@shared/domain/pattern';
|
|
import {
|
|
addHandoffEdge,
|
|
findAgentForNode,
|
|
isConnectionAllowed,
|
|
removeEdge,
|
|
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('systemNode');
|
|
|
|
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);
|
|
});
|
|
});
|
|
|
|
describe('pattern graph mutation helpers', () => {
|
|
test('addHandoffEdge 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 = addHandoffEdge(graph, agentNodes[1]!.id, agentNodes[2]!.id);
|
|
expect(updated.edges.length).toBe(initialEdgeCount + 1);
|
|
|
|
const duplicated = addHandoffEdge(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();
|
|
});
|
|
});
|