mirror of
https://github.com/davidkaya/aryx.git
synced 2026-07-27 23:18:46 +02:00
fix: address graph editor UX feedback
- Hide input handle on User Input nodes and output handle on User Output nodes (separate userInputNode/userOutputNode node types) - Enable edge deletion in handoff mode only (Delete key or React Flow UI); only agent-to-agent edges are deletable, structural edges are protected - Block all edge mutations (add/delete) in concurrent and group chat modes - Add agent as disconnected node without auto-rebuilding the graph - Add sequential reorder controls (↑↓) in the inspector panel with position/order swap and automatic edge rebuilding - Replace circular group chat layout with vertical column to prevent bidirectional edge crossings - Tighten handoff layout spacing so edges between triage and specialists don't overlap nodes - Use smoothstep edge type for cleaner edge routing across all modes - Add 6 new tests covering reorder, disconnected add, edge deletion rules, and node type assertions Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
This commit is contained in:
@@ -34,6 +34,7 @@ import {
|
||||
type WorkspaceToolingSettings,
|
||||
} from '@shared/domain/tooling';
|
||||
|
||||
import { addAgentNodeToGraph } from '@renderer/lib/patternGraph';
|
||||
import { PatternGraphCanvas } from './pattern-graph/PatternGraphCanvas';
|
||||
import { PatternGraphInspector } from './pattern-graph/PatternGraphInspector';
|
||||
|
||||
@@ -149,20 +150,16 @@ export function PatternEditor({
|
||||
}
|
||||
|
||||
function addAgent() {
|
||||
emitChange({
|
||||
...pattern,
|
||||
agents: [
|
||||
...pattern.agents,
|
||||
{
|
||||
id: `agent-${crypto.randomUUID()}`,
|
||||
name: `Agent ${pattern.agents.length + 1}`,
|
||||
description: '',
|
||||
instructions: '',
|
||||
model: 'gpt-5.4',
|
||||
reasoningEffort: 'high',
|
||||
},
|
||||
],
|
||||
});
|
||||
const newAgent: PatternAgentDefinition = {
|
||||
id: `agent-${crypto.randomUUID()}`,
|
||||
name: `Agent ${pattern.agents.length + 1}`,
|
||||
description: '',
|
||||
instructions: '',
|
||||
model: 'gpt-5.4',
|
||||
reasoningEffort: 'high',
|
||||
};
|
||||
const updatedGraph = addAgentNodeToGraph(graph, newAgent);
|
||||
onChange({ ...pattern, agents: [...pattern.agents, newAgent], graph: updatedGraph });
|
||||
}
|
||||
|
||||
function updateAgent(agentId: string, patch: Partial<PatternAgentDefinition>) {
|
||||
@@ -474,9 +471,11 @@ export function PatternEditor({
|
||||
availableModels={availableModels}
|
||||
agents={pattern.agents}
|
||||
graph={graph}
|
||||
mode={pattern.mode}
|
||||
selectedNodeId={selectedNodeId}
|
||||
onAgentChange={updateAgent}
|
||||
onAgentRemove={removeAgent}
|
||||
onGraphChange={emitGraphChange}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -52,13 +52,44 @@ function GraphNodeContent({ data, selected }: { data: GraphNodeData; selected: b
|
||||
);
|
||||
}
|
||||
|
||||
const handleStyles = {
|
||||
system: '!size-2 !border-zinc-600 !bg-zinc-400',
|
||||
agent: '!size-2 !border-indigo-400 !bg-indigo-500',
|
||||
hidden: '!size-0 !border-0 !bg-transparent !min-w-0 !min-h-0',
|
||||
};
|
||||
|
||||
/* user-input: source only (no incoming handle)
|
||||
user-output: target only (no outgoing handle) */
|
||||
|
||||
export const UserInputNode = memo(function UserInputNode({ data, selected }: NodeProps) {
|
||||
const nodeData = data as unknown as GraphNodeData;
|
||||
return (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className={handleStyles.hidden} />
|
||||
<GraphNodeContent data={nodeData} selected={selected ?? false} />
|
||||
<Handle type="source" position={Position.Right} className={handleStyles.system} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const UserOutputNode = memo(function UserOutputNode({ data, selected }: NodeProps) {
|
||||
const nodeData = data as unknown as GraphNodeData;
|
||||
return (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className={handleStyles.system} />
|
||||
<GraphNodeContent data={nodeData} selected={selected ?? false} />
|
||||
<Handle type="source" position={Position.Right} className={handleStyles.hidden} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const SystemNode = memo(function SystemNode({ data, selected }: NodeProps) {
|
||||
const nodeData = data as unknown as GraphNodeData;
|
||||
return (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className="!size-2 !border-zinc-600 !bg-zinc-400" />
|
||||
<Handle type="target" position={Position.Left} className={handleStyles.system} />
|
||||
<GraphNodeContent data={nodeData} selected={selected ?? false} />
|
||||
<Handle type="source" position={Position.Right} className="!size-2 !border-zinc-600 !bg-zinc-400" />
|
||||
<Handle type="source" position={Position.Right} className={handleStyles.system} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -67,14 +98,16 @@ export const AgentNode = memo(function AgentNode({ data, selected }: NodeProps)
|
||||
const nodeData = data as unknown as GraphNodeData;
|
||||
return (
|
||||
<>
|
||||
<Handle type="target" position={Position.Left} className="!size-2 !border-indigo-400 !bg-indigo-500" />
|
||||
<Handle type="target" position={Position.Left} className={handleStyles.agent} />
|
||||
<GraphNodeContent data={nodeData} selected={selected ?? false} />
|
||||
<Handle type="source" position={Position.Right} className="!size-2 !border-indigo-400 !bg-indigo-500" />
|
||||
<Handle type="source" position={Position.Right} className={handleStyles.agent} />
|
||||
</>
|
||||
);
|
||||
});
|
||||
|
||||
export const graphNodeTypes = {
|
||||
userInputNode: UserInputNode,
|
||||
userOutputNode: UserOutputNode,
|
||||
systemNode: SystemNode,
|
||||
agentNode: AgentNode,
|
||||
};
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { useCallback, useEffect, useMemo, useRef } from 'react';
|
||||
import {
|
||||
ReactFlow,
|
||||
Background,
|
||||
@@ -6,17 +6,21 @@ import {
|
||||
useNodesState,
|
||||
useEdgesState,
|
||||
type Node,
|
||||
type Edge,
|
||||
type OnConnect,
|
||||
type OnEdgesChange,
|
||||
type OnNodesChange,
|
||||
} from '@xyflow/react';
|
||||
import '@xyflow/react/dist/style.css';
|
||||
|
||||
import type { PatternDefinition, PatternGraph } from '@shared/domain/pattern';
|
||||
import type { OrchestrationMode, PatternDefinition, PatternGraph } from '@shared/domain/pattern';
|
||||
import { resolvePatternGraph } from '@shared/domain/pattern';
|
||||
import {
|
||||
addHandoffEdge,
|
||||
fromCanvasPositions,
|
||||
isConnectionAllowed,
|
||||
isEdgeDeletionAllowed,
|
||||
removeEdge,
|
||||
toCanvasEdges,
|
||||
toCanvasNodes,
|
||||
type GraphNodeData,
|
||||
@@ -80,6 +84,37 @@ export function PatternGraphCanvas({
|
||||
[onNodesChange, pattern, onGraphChange, setNodes],
|
||||
);
|
||||
|
||||
const handleEdgesChange: OnEdgesChange = useCallback(
|
||||
(changes) => {
|
||||
// Block all edge deletions in modes that don't support it
|
||||
if (!isEdgeDeletionAllowed(pattern.mode)) {
|
||||
const filtered = changes.filter((c) => c.type !== 'remove');
|
||||
if (filtered.length > 0) {
|
||||
onEdgesChange(filtered);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// For handoff mode, apply removals to the authoritative graph
|
||||
const removals = changes.filter((c) => c.type === 'remove');
|
||||
if (removals.length > 0) {
|
||||
let updatedGraph = graph;
|
||||
for (const removal of removals) {
|
||||
if (removal.type === 'remove') {
|
||||
updatedGraph = removeEdge(updatedGraph, removal.id);
|
||||
}
|
||||
}
|
||||
onGraphChange(updatedGraph);
|
||||
}
|
||||
|
||||
const nonRemovals = changes.filter((c) => c.type !== 'remove');
|
||||
if (nonRemovals.length > 0) {
|
||||
onEdgesChange(nonRemovals);
|
||||
}
|
||||
},
|
||||
[onEdgesChange, pattern.mode, graph, onGraphChange],
|
||||
);
|
||||
|
||||
const handleConnect: OnConnect = useCallback(
|
||||
(connection) => {
|
||||
if (!isConnectionAllowed(connection, pattern.mode, graph)) {
|
||||
@@ -114,7 +149,7 @@ export function PatternGraphCanvas({
|
||||
}))}
|
||||
edges={edges}
|
||||
onNodesChange={handleNodesChange}
|
||||
onEdgesChange={onEdgesChange}
|
||||
onEdgesChange={handleEdgesChange}
|
||||
onConnect={handleConnect}
|
||||
onNodeClick={handleNodeClick}
|
||||
onPaneClick={handlePaneClick}
|
||||
@@ -125,9 +160,11 @@ export function PatternGraphCanvas({
|
||||
maxZoom={2}
|
||||
proOptions={{ hideAttribution: true }}
|
||||
defaultEdgeOptions={{
|
||||
type: 'smoothstep',
|
||||
style: { stroke: '#52525b', strokeWidth: 1.5 },
|
||||
}}
|
||||
connectionLineStyle={{ stroke: '#6366f1', strokeWidth: 1.5 }}
|
||||
deleteKeyCode="Delete"
|
||||
>
|
||||
<Background variant={BackgroundVariant.Dots} gap={20} size={1} color="#27272a" />
|
||||
</ReactFlow>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Bot, CircleUser, Layers, Plus, Radio, Shuffle, Trash2 } from 'lucide-react';
|
||||
import { Bot, ChevronDown, ChevronUp, CircleUser, Layers, Radio, Shuffle, Trash2 } from 'lucide-react';
|
||||
|
||||
import {
|
||||
findModel,
|
||||
@@ -6,17 +6,28 @@ import {
|
||||
resolveReasoningEffort,
|
||||
type ModelDefinition,
|
||||
} from '@shared/domain/models';
|
||||
import type { PatternAgentDefinition, PatternGraph, PatternGraphNodeKind } from '@shared/domain/pattern';
|
||||
import { findAgentForNode } from '@renderer/lib/patternGraph';
|
||||
import type {
|
||||
OrchestrationMode,
|
||||
PatternAgentDefinition,
|
||||
PatternGraph,
|
||||
PatternGraphNodeKind,
|
||||
} from '@shared/domain/pattern';
|
||||
import {
|
||||
canMoveSequential,
|
||||
findAgentForNode,
|
||||
swapSequentialOrder,
|
||||
} from '@renderer/lib/patternGraph';
|
||||
import { ModelSelect, ReasoningEffortSelect } from '../AgentConfigFields';
|
||||
|
||||
interface PatternGraphInspectorProps {
|
||||
availableModels: ReadonlyArray<ModelDefinition>;
|
||||
agents: PatternAgentDefinition[];
|
||||
graph: PatternGraph;
|
||||
mode: OrchestrationMode;
|
||||
selectedNodeId: string | null;
|
||||
onAgentChange: (agentId: string, patch: Partial<PatternAgentDefinition>) => void;
|
||||
onAgentRemove: (agentId: string) => void;
|
||||
onGraphChange: (graph: PatternGraph) => void;
|
||||
}
|
||||
|
||||
function InputField({
|
||||
@@ -104,15 +115,26 @@ function SystemNodeInspector({ kind }: { kind: PatternGraphNodeKind }) {
|
||||
function AgentNodeInspector({
|
||||
agent,
|
||||
availableModels,
|
||||
mode,
|
||||
graph,
|
||||
nodeId,
|
||||
onAgentChange,
|
||||
onAgentRemove,
|
||||
onGraphChange,
|
||||
}: {
|
||||
agent: PatternAgentDefinition;
|
||||
availableModels: ReadonlyArray<ModelDefinition>;
|
||||
mode: OrchestrationMode;
|
||||
graph: PatternGraph;
|
||||
nodeId: string;
|
||||
onAgentChange: (agentId: string, patch: Partial<PatternAgentDefinition>) => void;
|
||||
onAgentRemove: (agentId: string) => void;
|
||||
onGraphChange: (graph: PatternGraph) => void;
|
||||
}) {
|
||||
const model = findModel(agent.model, availableModels);
|
||||
const showReorder = mode === 'sequential' || mode === 'single' || mode === 'magentic';
|
||||
const canUp = showReorder && canMoveSequential(graph, nodeId, 'up');
|
||||
const canDown = showReorder && canMoveSequential(graph, nodeId, 'down');
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
@@ -123,14 +145,37 @@ function AgentNodeInspector({
|
||||
</div>
|
||||
<div className="text-[13px] font-semibold text-zinc-200">{agent.name || 'Unnamed'}</div>
|
||||
</div>
|
||||
<button
|
||||
className="flex items-center gap-1 text-[12px] text-zinc-600 transition hover:text-red-400"
|
||||
onClick={() => onAgentRemove(agent.id)}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
Remove
|
||||
</button>
|
||||
<div className="flex items-center gap-1">
|
||||
{showReorder && (
|
||||
<>
|
||||
<button
|
||||
className="flex size-6 items-center justify-center rounded text-zinc-500 transition hover:bg-zinc-800 hover:text-zinc-300 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-zinc-500"
|
||||
disabled={!canUp}
|
||||
onClick={() => onGraphChange(swapSequentialOrder(graph, nodeId, 'up'))}
|
||||
title="Move earlier in sequence"
|
||||
type="button"
|
||||
>
|
||||
<ChevronUp className="size-3.5" />
|
||||
</button>
|
||||
<button
|
||||
className="flex size-6 items-center justify-center rounded text-zinc-500 transition hover:bg-zinc-800 hover:text-zinc-300 disabled:opacity-30 disabled:hover:bg-transparent disabled:hover:text-zinc-500"
|
||||
disabled={!canDown}
|
||||
onClick={() => onGraphChange(swapSequentialOrder(graph, nodeId, 'down'))}
|
||||
title="Move later in sequence"
|
||||
type="button"
|
||||
>
|
||||
<ChevronDown className="size-3.5" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<button
|
||||
className="flex items-center gap-1 text-[12px] text-zinc-600 transition hover:text-red-400"
|
||||
onClick={() => onAgentRemove(agent.id)}
|
||||
type="button"
|
||||
>
|
||||
<Trash2 className="size-3" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<InputField
|
||||
@@ -181,9 +226,11 @@ export function PatternGraphInspector({
|
||||
availableModels,
|
||||
agents,
|
||||
graph,
|
||||
mode,
|
||||
selectedNodeId,
|
||||
onAgentChange,
|
||||
onAgentRemove,
|
||||
onGraphChange,
|
||||
}: PatternGraphInspectorProps) {
|
||||
if (!selectedNodeId) {
|
||||
return (
|
||||
@@ -218,8 +265,12 @@ export function PatternGraphInspector({
|
||||
<AgentNodeInspector
|
||||
agent={agent}
|
||||
availableModels={availableModels}
|
||||
mode={mode}
|
||||
graph={graph}
|
||||
nodeId={selectedNodeId}
|
||||
onAgentChange={onAgentChange}
|
||||
onAgentRemove={onAgentRemove}
|
||||
onGraphChange={onGraphChange}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -45,11 +45,16 @@ function resolveNodeLabel(node: PatternGraphNode, agents: PatternAgentDefinition
|
||||
}
|
||||
|
||||
function resolveNodeType(kind: PatternGraphNodeKind): string {
|
||||
if (kind === 'agent') {
|
||||
return 'agentNode';
|
||||
switch (kind) {
|
||||
case 'user-input':
|
||||
return 'userInputNode';
|
||||
case 'user-output':
|
||||
return 'userOutputNode';
|
||||
case 'agent':
|
||||
return 'agentNode';
|
||||
default:
|
||||
return 'systemNode';
|
||||
}
|
||||
|
||||
return 'systemNode';
|
||||
}
|
||||
|
||||
export function toCanvasNodes(graph: PatternGraph, agents: PatternAgentDefinition[]): Node<GraphNodeData>[] {
|
||||
@@ -70,14 +75,28 @@ export function toCanvasNodes(graph: PatternGraph, agents: PatternAgentDefinitio
|
||||
}));
|
||||
}
|
||||
|
||||
/** Determines whether user-created edges can be deleted in this mode. */
|
||||
function isEdgeDeletable(edge: PatternGraphEdge, mode: OrchestrationMode, graph: PatternGraph): boolean {
|
||||
if (mode !== 'handoff') {
|
||||
return false;
|
||||
}
|
||||
|
||||
const sourceNode = graph.nodes.find((n) => n.id === edge.source);
|
||||
const targetNode = graph.nodes.find((n) => n.id === edge.target);
|
||||
|
||||
// Only agent↔agent edges in handoff mode are user-deletable;
|
||||
// system edges (user-input → triage, agent → user-output) are structural.
|
||||
return sourceNode?.kind === 'agent' && targetNode?.kind === 'agent';
|
||||
}
|
||||
|
||||
export function toCanvasEdges(graph: PatternGraph, mode: OrchestrationMode): Edge[] {
|
||||
return graph.edges.map((edge) => ({
|
||||
id: edge.id,
|
||||
source: edge.source,
|
||||
target: edge.target,
|
||||
type: 'default',
|
||||
type: 'smoothstep',
|
||||
animated: mode === 'handoff',
|
||||
deletable: false,
|
||||
deletable: isEdgeDeletable(edge, mode, graph),
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -134,6 +153,11 @@ export function isConnectionAllowed(
|
||||
}
|
||||
}
|
||||
|
||||
/** Whether edges can be deleted by the user in this mode. */
|
||||
export function isEdgeDeletionAllowed(mode: OrchestrationMode): boolean {
|
||||
return mode === 'handoff';
|
||||
}
|
||||
|
||||
/* ── Graph mutation helpers ─────────────────────────────────── */
|
||||
|
||||
function edgeId(source: string, target: string): string {
|
||||
@@ -158,6 +182,106 @@ export function removeEdge(graph: PatternGraph, removeEdgeId: string): PatternGr
|
||||
return { ...graph, edges: graph.edges.filter((e) => e.id !== removeEdgeId) };
|
||||
}
|
||||
|
||||
/* ── Add disconnected agent node ───────────────────────────── */
|
||||
|
||||
export function addAgentNodeToGraph(
|
||||
graph: PatternGraph,
|
||||
agent: PatternAgentDefinition,
|
||||
): PatternGraph {
|
||||
const existingAgentNodes = graph.nodes.filter((n) => n.kind === 'agent');
|
||||
const nextOrder = existingAgentNodes.length;
|
||||
|
||||
// Place new node below existing agent nodes
|
||||
const maxY = existingAgentNodes.reduce((max, n) => Math.max(max, n.position.y), 0);
|
||||
const avgX = existingAgentNodes.length > 0
|
||||
? Math.round(existingAgentNodes.reduce((sum, n) => sum + n.position.x, 0) / existingAgentNodes.length)
|
||||
: 400;
|
||||
|
||||
const newNode: PatternGraphNode = {
|
||||
id: `agent-node-${agent.id}`,
|
||||
kind: 'agent',
|
||||
agentId: agent.id,
|
||||
order: nextOrder,
|
||||
position: { x: avgX, y: maxY + 120 },
|
||||
};
|
||||
|
||||
return { ...graph, nodes: [...graph.nodes, newNode] };
|
||||
}
|
||||
|
||||
/* ── Sequential reorder ────────────────────────────────────── */
|
||||
|
||||
export function swapSequentialOrder(
|
||||
graph: PatternGraph,
|
||||
agentNodeId: string,
|
||||
direction: 'up' | 'down',
|
||||
): PatternGraph {
|
||||
const agentNodes = graph.nodes
|
||||
.filter((n) => n.kind === 'agent')
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
|
||||
const currentIndex = agentNodes.findIndex((n) => n.id === agentNodeId);
|
||||
if (currentIndex < 0) {
|
||||
return graph;
|
||||
}
|
||||
|
||||
const swapIndex = direction === 'up' ? currentIndex - 1 : currentIndex + 1;
|
||||
if (swapIndex < 0 || swapIndex >= agentNodes.length) {
|
||||
return graph;
|
||||
}
|
||||
|
||||
const currentNode = agentNodes[currentIndex]!;
|
||||
const swapNode = agentNodes[swapIndex]!;
|
||||
|
||||
// Swap order values
|
||||
const updatedNodes = graph.nodes.map((n) => {
|
||||
if (n.id === currentNode.id) {
|
||||
return { ...n, order: swapNode.order, position: { ...swapNode.position } };
|
||||
}
|
||||
if (n.id === swapNode.id) {
|
||||
return { ...n, order: currentNode.order, position: { ...currentNode.position } };
|
||||
}
|
||||
return n;
|
||||
});
|
||||
|
||||
// Rebuild linear edges: input → agent₁ → agent₂ → ... → output
|
||||
const inputNode = graph.nodes.find((n) => n.kind === 'user-input');
|
||||
const outputNode = graph.nodes.find((n) => n.kind === 'user-output');
|
||||
const sortedAgents = updatedNodes
|
||||
.filter((n) => n.kind === 'agent')
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
|
||||
const path = [
|
||||
...(inputNode ? [inputNode.id] : []),
|
||||
...sortedAgents.map((n) => n.id),
|
||||
...(outputNode ? [outputNode.id] : []),
|
||||
];
|
||||
|
||||
const newEdges: PatternGraphEdge[] = [];
|
||||
for (let i = 0; i < path.length - 1; i += 1) {
|
||||
newEdges.push({ id: edgeId(path[i]!, path[i + 1]!), source: path[i]!, target: path[i + 1]! });
|
||||
}
|
||||
|
||||
return { nodes: updatedNodes, edges: newEdges };
|
||||
}
|
||||
|
||||
/** Check if an agent node can be moved up or down in sequential order. */
|
||||
export function canMoveSequential(
|
||||
graph: PatternGraph,
|
||||
agentNodeId: string,
|
||||
direction: 'up' | 'down',
|
||||
): boolean {
|
||||
const agentNodes = graph.nodes
|
||||
.filter((n) => n.kind === 'agent')
|
||||
.sort((a, b) => (a.order ?? 0) - (b.order ?? 0));
|
||||
|
||||
const index = agentNodes.findIndex((n) => n.id === agentNodeId);
|
||||
if (index < 0) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return direction === 'up' ? index > 0 : index < agentNodes.length - 1;
|
||||
}
|
||||
|
||||
/* ── Agent node for selected inspector ─────────────────────── */
|
||||
|
||||
export function findAgentForNode(
|
||||
|
||||
@@ -210,14 +210,20 @@ function createHandoffGraph(agents: PatternAgentDefinition[]): PatternGraph {
|
||||
const outputNode: PatternGraphNode = {
|
||||
id: SYSTEM_NODE_IDS.userOutput,
|
||||
kind: 'user-output',
|
||||
position: { x: 860, y: 0 },
|
||||
position: { x: 700, y: 0 },
|
||||
};
|
||||
const entryAgent = agents[0];
|
||||
const specialistCount = Math.max(agents.length - 1, 1);
|
||||
const entryNode = entryAgent
|
||||
? createAgentNode(entryAgent, 0, { x: 220, y: 0 })
|
||||
? createAgentNode(entryAgent, 0, { x: 200, y: 0 })
|
||||
: undefined;
|
||||
// Place specialists in a vertical column with enough spacing so
|
||||
// bidirectional edges to/from the triage agent don't overlap.
|
||||
const specialistNodes = agents.slice(1).map((agent, index) =>
|
||||
createAgentNode(agent, index + 1, { x: 540, y: spreadY(index, Math.max(agents.length - 1, 1), 220) }),
|
||||
createAgentNode(agent, index + 1, {
|
||||
x: 460,
|
||||
y: spreadY(index, specialistCount, 150),
|
||||
}),
|
||||
);
|
||||
const nodes = [inputNode, ...(entryNode ? [entryNode] : []), ...specialistNodes, outputNode];
|
||||
const edges: PatternGraphEdge[] = [];
|
||||
@@ -245,24 +251,21 @@ function createGroupChatGraph(agents: PatternAgentDefinition[]): PatternGraph {
|
||||
const orchestratorNode: PatternGraphNode = {
|
||||
id: SYSTEM_NODE_IDS.orchestrator,
|
||||
kind: 'orchestrator',
|
||||
position: { x: 250, y: 0 },
|
||||
position: { x: 200, y: 0 },
|
||||
};
|
||||
const outputNode: PatternGraphNode = {
|
||||
id: SYSTEM_NODE_IDS.userOutput,
|
||||
kind: 'user-output',
|
||||
position: { x: 900, y: 0 },
|
||||
position: { x: 660, y: 0 },
|
||||
};
|
||||
const centerX = 560;
|
||||
const centerY = 0;
|
||||
const radiusX = 190;
|
||||
const radiusY = 170;
|
||||
const agentNodes = agents.map((agent, index) => {
|
||||
const angle = agents.length <= 1 ? 0 : (Math.PI * 2 * index) / agents.length - Math.PI / 2;
|
||||
return createAgentNode(agent, index, {
|
||||
x: Math.round(centerX + Math.cos(angle) * radiusX),
|
||||
y: Math.round(centerY + Math.sin(angle) * radiusY),
|
||||
});
|
||||
});
|
||||
// Place agents in a vertical column to the right of the orchestrator.
|
||||
// This avoids crossing edges from the bidirectional orchestrator↔agent links.
|
||||
const agentNodes = agents.map((agent, index) =>
|
||||
createAgentNode(agent, index, {
|
||||
x: 440,
|
||||
y: spreadY(index, Math.max(agents.length, 1), 130),
|
||||
}),
|
||||
);
|
||||
|
||||
return {
|
||||
nodes: [inputNode, orchestratorNode, ...agentNodes, outputNode],
|
||||
|
||||
@@ -2,10 +2,14 @@ import { describe, expect, test } from 'bun:test';
|
||||
|
||||
import { createBuiltinPatterns, resolvePatternGraph, type PatternDefinition } from '@shared/domain/pattern';
|
||||
import {
|
||||
addAgentNodeToGraph,
|
||||
addHandoffEdge,
|
||||
canMoveSequential,
|
||||
findAgentForNode,
|
||||
isConnectionAllowed,
|
||||
isEdgeDeletionAllowed,
|
||||
removeEdge,
|
||||
swapSequentialOrder,
|
||||
toCanvasEdges,
|
||||
toCanvasNodes,
|
||||
} from '@renderer/lib/patternGraph';
|
||||
@@ -31,7 +35,7 @@ describe('pattern graph view model', () => {
|
||||
expect(userOutput).toBeDefined();
|
||||
expect(userInput!.data.readOnly).toBe(true);
|
||||
expect(userOutput!.data.readOnly).toBe(true);
|
||||
expect(userInput!.type).toBe('systemNode');
|
||||
expect(userInput!.type).toBe('userInputNode');
|
||||
|
||||
const agentNodes = nodes.filter((n) => n.data.kind === 'agent');
|
||||
expect(agentNodes.length).toBe(pattern.agents.length);
|
||||
@@ -168,4 +172,90 @@ describe('pattern graph mutation helpers', () => {
|
||||
expect(updated.edges.length).toBe(graph.edges.length - 1);
|
||||
expect(updated.edges.find((e) => e.id === firstEdge.id)).toBeUndefined();
|
||||
});
|
||||
|
||||
test('addAgentNodeToGraph places a disconnected agent node on the canvas', () => {
|
||||
const pattern = findPattern('sequential');
|
||||
const graph = resolvePatternGraph(pattern);
|
||||
const newAgent = { id: 'new-1', name: 'New Agent', description: '', instructions: '', model: 'gpt-5.4' };
|
||||
|
||||
const updated = addAgentNodeToGraph(graph, 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');
|
||||
|
||||
// No new edges added — node is disconnected
|
||||
expect(updated.edges.length).toBe(graph.edges.length);
|
||||
});
|
||||
});
|
||||
|
||||
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('only handoff mode allows edge deletion', () => {
|
||||
expect(isEdgeDeletionAllowed('handoff')).toBe(true);
|
||||
expect(isEdgeDeletionAllowed('sequential')).toBe(false);
|
||||
expect(isEdgeDeletionAllowed('concurrent')).toBe(false);
|
||||
expect(isEdgeDeletionAllowed('group-chat')).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('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');
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user