From c6c8e79e9ff3b2c8240a224be3486c32c8e966ec Mon Sep 17 00:00:00 2001 From: David Kaya Date: Tue, 24 Mar 2026 23:09:21 +0100 Subject: [PATCH] fix: address graph editor UX feedback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 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> --- src/renderer/components/PatternEditor.tsx | 27 ++-- .../components/pattern-graph/GraphNodes.tsx | 41 +++++- .../pattern-graph/PatternGraphCanvas.tsx | 43 +++++- .../pattern-graph/PatternGraphInspector.tsx | 73 ++++++++-- src/renderer/lib/patternGraph.ts | 136 +++++++++++++++++- src/shared/domain/pattern.ts | 35 ++--- tests/renderer/patternGraph.test.ts | 92 +++++++++++- 7 files changed, 392 insertions(+), 55 deletions(-) diff --git a/src/renderer/components/PatternEditor.tsx b/src/renderer/components/PatternEditor.tsx index a091f06..1c7380c 100644 --- a/src/renderer/components/PatternEditor.tsx +++ b/src/renderer/components/PatternEditor.tsx @@ -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) { @@ -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} /> diff --git a/src/renderer/components/pattern-graph/GraphNodes.tsx b/src/renderer/components/pattern-graph/GraphNodes.tsx index edba39e..605a659 100644 --- a/src/renderer/components/pattern-graph/GraphNodes.tsx +++ b/src/renderer/components/pattern-graph/GraphNodes.tsx @@ -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 ( + <> + + + + + ); +}); + +export const UserOutputNode = memo(function UserOutputNode({ data, selected }: NodeProps) { + const nodeData = data as unknown as GraphNodeData; + return ( + <> + + + + + ); +}); + export const SystemNode = memo(function SystemNode({ data, selected }: NodeProps) { const nodeData = data as unknown as GraphNodeData; return ( <> - + - + ); }); @@ -67,14 +98,16 @@ export const AgentNode = memo(function AgentNode({ data, selected }: NodeProps) const nodeData = data as unknown as GraphNodeData; return ( <> - + - + ); }); export const graphNodeTypes = { + userInputNode: UserInputNode, + userOutputNode: UserOutputNode, systemNode: SystemNode, agentNode: AgentNode, }; diff --git a/src/renderer/components/pattern-graph/PatternGraphCanvas.tsx b/src/renderer/components/pattern-graph/PatternGraphCanvas.tsx index efc103c..3ffcb55 100644 --- a/src/renderer/components/pattern-graph/PatternGraphCanvas.tsx +++ b/src/renderer/components/pattern-graph/PatternGraphCanvas.tsx @@ -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" > diff --git a/src/renderer/components/pattern-graph/PatternGraphInspector.tsx b/src/renderer/components/pattern-graph/PatternGraphInspector.tsx index d9194d1..c2ad344 100644 --- a/src/renderer/components/pattern-graph/PatternGraphInspector.tsx +++ b/src/renderer/components/pattern-graph/PatternGraphInspector.tsx @@ -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; agents: PatternAgentDefinition[]; graph: PatternGraph; + mode: OrchestrationMode; selectedNodeId: string | null; onAgentChange: (agentId: string, patch: Partial) => 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; + mode: OrchestrationMode; + graph: PatternGraph; + nodeId: string; onAgentChange: (agentId: string, patch: Partial) => 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 (
@@ -123,14 +145,37 @@ function AgentNodeInspector({
{agent.name || 'Unnamed'}
- +
+ {showReorder && ( + <> + + + + )} + +
); diff --git a/src/renderer/lib/patternGraph.ts b/src/renderer/lib/patternGraph.ts index f562446..33174c0 100644 --- a/src/renderer/lib/patternGraph.ts +++ b/src/renderer/lib/patternGraph.ts @@ -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[] { @@ -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( diff --git a/src/shared/domain/pattern.ts b/src/shared/domain/pattern.ts index 21d7d36..03e25ba 100644 --- a/src/shared/domain/pattern.ts +++ b/src/shared/domain/pattern.ts @@ -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], diff --git a/tests/renderer/patternGraph.test.ts b/tests/renderer/patternGraph.test.ts index 2757c7a..4643871 100644 --- a/tests/renderer/patternGraph.test.ts +++ b/tests/renderer/patternGraph.test.ts @@ -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'); + }); });