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:
David Kaya
2026-03-24 23:09:21 +01:00
co-authored by Copilot
parent 7d078faada
commit c6c8e79e9f
7 changed files with 392 additions and 55 deletions
+13 -14
View File
@@ -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>
);
+130 -6
View File
@@ -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(
+19 -16
View File
@@ -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],
+91 -1
View File
@@ -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');
});
});