From af2d2f62d655a2580e4873ef90e23d9d0501778e Mon Sep 17 00:00:00 2001 From: christopherholland-workday Date: Fri, 27 Feb 2026 08:37:19 -0800 Subject: [PATCH 1/3] Fix polynomial regular expressions used on user controlled data (#5857) * Fix polynomial regular expressions used on user controlled data * Fix polynomial regular expressions used on user controlled data * Fix polynomial regular expressions used on user controlled data --------- Co-authored-by: christopherholland-workday --- packages/components/src/utils.ts | 41 ++++- packages/components/test/utils.test.ts | 231 +++++++++++++++++++++++++ 2 files changed, 267 insertions(+), 5 deletions(-) create mode 100644 packages/components/test/utils.test.ts diff --git a/packages/components/src/utils.ts b/packages/components/src/utils.ts index 7b8845a0134..91bf5d4f1cb 100644 --- a/packages/components/src/utils.ts +++ b/packages/components/src/utils.ts @@ -1235,8 +1235,35 @@ export const isAllowedUploadMimeType = (mime: string): boolean => { } // remove invalid markdown image pattern: ![]() +// Uses indexOf instead of a global regex to avoid O(n²) backtracking when the +// input contains many '![' sequences (polynomial ReDoS with the g flag). export const removeInvalidImageMarkdown = (output: string): string => { - return typeof output === 'string' ? output.replace(/!\[.*?\]\((?!https?:\/\/).*?\)/g, '') : output + if (typeof output !== 'string') return output + let result = '' + let pos = 0 + while (pos < output.length) { + const start = output.indexOf('![', pos) + if (start === -1) { + result += output.slice(pos) + break + } + result += output.slice(pos, start) + const closeBracket = output.indexOf(']', start + 2) + if (closeBracket === -1 || output[closeBracket + 1] !== '(') { + result += '![' + pos = start + 2 + continue + } + const closeParen = output.indexOf(')', closeBracket + 2) + if (closeParen === -1) { + result += output.slice(start) + break + } + const url = output.slice(closeBracket + 2, closeParen) + if (/^https?:\/\//.test(url)) result += output.slice(start, closeParen + 1) + pos = closeParen + 1 + } + return result } /** @@ -1442,8 +1469,12 @@ export const stripHTMLFromToolInput = (input: string) => { return cleanedInput } +// Regex constants exported for testability +export const COMMONJS_REQUIRE_REGEX = /^(const|let|var)\s+\S[^=]*=\s*require\s*\(/ +export const IMPORT_EXTRACTION_REGEX = /(?:import\s+\S[^\n]*?\s+from\s+['"]([^'"]+)['"]|require\s*\(\s*['"]([^'"]+)['"]\s*\))/ + // Helper function to convert require statements to ESM imports -const convertRequireToImport = (requireLine: string): string | null => { +export const convertRequireToImport = (requireLine: string): string | null => { // Remove leading/trailing whitespace and get the indentation const indent = requireLine.match(/^(\s*)/)?.[1] || '' const trimmed = requireLine.trim() @@ -1456,7 +1487,7 @@ const convertRequireToImport = (requireLine: string): string | null => { } // Match patterns like: const { name1, name2 } = require('module') - const destructureMatch = trimmed.match(/^(const|let|var)\s+\{\s*([^}]+)\s*\}\s*=\s*require\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/) + const destructureMatch = trimmed.match(/^(const|let|var)\s+\{([^}]+)\}\s*=\s*require\s*\(\s*['"`]([^'"`]+)['"`]\s*\)/) if (destructureMatch) { const [, , destructuredVars, moduleName] = destructureMatch return `${indent}import { ${destructuredVars.trim()} } from '${moduleName}';` @@ -1576,7 +1607,7 @@ export const executeJavaScriptCode = async ( importLines.push(line) } // Check for CommonJS require statements and convert them to ESM imports - else if (/^(const|let|var)\s+.*=\s*require\s*\(/.test(trimmedLine)) { + else if (COMMONJS_REQUIRE_REGEX.test(trimmedLine)) { const convertedImport = convertRequireToImport(trimmedLine) if (convertedImport) { importLines.push(convertedImport) @@ -1593,7 +1624,7 @@ export const executeJavaScriptCode = async ( // Auto-detect required libraries from code // Extract required modules from import/require statements - const importRegex = /(?:import\s+.*?\s+from\s+['"]([^'"]+)['"]|require\s*\(\s*['"]([^'"]+)['"]\s*\))/g + const importRegex = new RegExp(IMPORT_EXTRACTION_REGEX.source, 'g') let match while ((match = importRegex.exec(code)) !== null) { const moduleName = match[1] || match[2] diff --git a/packages/components/test/utils.test.ts b/packages/components/test/utils.test.ts new file mode 100644 index 00000000000..dd0f1c2c44d --- /dev/null +++ b/packages/components/test/utils.test.ts @@ -0,0 +1,231 @@ +import { removeInvalidImageMarkdown, convertRequireToImport, COMMONJS_REQUIRE_REGEX, IMPORT_EXTRACTION_REGEX } from '../src/utils' + +describe('removeInvalidImageMarkdown', () => { + describe('strips non-http/https image markdown', () => { + it('removes a relative-path image', () => { + expect(removeInvalidImageMarkdown('![alt](./image.png)')).toBe('') + }) + + it('removes an image with no URL scheme', () => { + expect(removeInvalidImageMarkdown('![alt](image.png)')).toBe('') + }) + + it('removes a data-URI image', () => { + expect(removeInvalidImageMarkdown('![alt](data:image/png;base64,abc123)')).toBe('') + }) + + it('removes an image with an absolute local path', () => { + expect(removeInvalidImageMarkdown('![alt](/some/local/path.png)')).toBe('') + }) + }) + + describe('preserves http and https image markdown', () => { + it('keeps an https image', () => { + const input = '![alt](https://example.com/img.png)' + expect(removeInvalidImageMarkdown(input)).toBe(input) + }) + + it('keeps an http image', () => { + const input = '![alt](http://example.com/img.png)' + expect(removeInvalidImageMarkdown(input)).toBe(input) + }) + }) + + describe('non-string inputs pass through unchanged', () => { + it('returns null as-is', () => { + expect(removeInvalidImageMarkdown(null as any)).toBeNull() + }) + + it('returns a number as-is', () => { + expect(removeInvalidImageMarkdown(42 as any)).toBe(42) + }) + + it('returns an object as-is', () => { + const obj = { a: 1 } + expect(removeInvalidImageMarkdown(obj as any)).toBe(obj) + }) + + it('returns undefined as-is', () => { + expect(removeInvalidImageMarkdown(undefined as any)).toBeUndefined() + }) + }) + + describe('mixed content in the same string', () => { + it('strips relative image but keeps https image', () => { + const input = 'See ![a](./a.png) and ![b](https://example.com/b.png)' + expect(removeInvalidImageMarkdown(input)).toBe('See and ![b](https://example.com/b.png)') + }) + + it('strips multiple non-http images', () => { + const input = '![x](x.png) text ![y](y.png)' + expect(removeInvalidImageMarkdown(input)).toBe(' text ') + }) + + it('preserves surrounding text', () => { + expect(removeInvalidImageMarkdown('before ![alt](./img.png) after')).toBe('before after') + }) + }) + + describe('edge cases', () => { + it('handles empty string', () => { + expect(removeInvalidImageMarkdown('')).toBe('') + }) + + it('handles string with no images', () => { + expect(removeInvalidImageMarkdown('just text')).toBe('just text') + }) + + it('does not remove a link that is not an image (missing !)', () => { + expect(removeInvalidImageMarkdown('[alt](./image.png)')).toBe('[alt](./image.png)') + }) + }) +}) + +// --------------------------------------------------------------------------- +// convertRequireToImport (line 1459) +// --------------------------------------------------------------------------- + +describe('convertRequireToImport', () => { + describe('default require → default import', () => { + it('converts const default require', () => { + expect(convertRequireToImport("const foo = require('bar')")).toBe("import foo from 'bar';") + }) + + it('converts let default require', () => { + expect(convertRequireToImport("let foo = require('bar')")).toBe("import foo from 'bar';") + }) + + it('converts var default require', () => { + expect(convertRequireToImport("var foo = require('bar')")).toBe("import foo from 'bar';") + }) + + it('handles scoped package names', () => { + expect(convertRequireToImport("const pkg = require('@scope/pkg')")).toBe("import pkg from '@scope/pkg';") + }) + }) + + describe('destructured require → named import', () => { + it('converts single destructured require', () => { + expect(convertRequireToImport("const { a } = require('bar')")).toBe("import { a } from 'bar';") + }) + + it('converts multiple destructured require', () => { + expect(convertRequireToImport("const { a, b } = require('bar')")).toBe("import { a, b } from 'bar';") + }) + + it('trims outer whitespace from destructured vars', () => { + // Leading/trailing spaces around the var list are trimmed; internal spacing preserved + expect(convertRequireToImport("const { a, b } = require('bar')")).toBe("import { a, b } from 'bar';") + }) + }) + + describe('property-access require', () => { + it('matches as a default import (default pattern takes precedence; .property is not captured)', () => { + // The default-require pattern at line 1452 has no end-of-string anchor, so it matches + // `require('bar')` as a prefix of `require('bar').baz`. The property branch is never reached. + expect(convertRequireToImport("const foo = require('bar').baz")).toBe("import foo from 'bar';") + }) + }) + + describe('indentation preservation', () => { + it('preserves leading spaces', () => { + expect(convertRequireToImport(" const foo = require('bar')")).toBe(" import foo from 'bar';") + }) + + it('preserves leading tab', () => { + expect(convertRequireToImport("\tconst foo = require('bar')")).toBe("\timport foo from 'bar';") + }) + }) + + describe('unrecognised input returns null', () => { + it('returns null for a console.log call', () => { + expect(convertRequireToImport("console.log('hello')")).toBeNull() + }) + + it('returns null when require is not called', () => { + expect(convertRequireToImport("var x = someOtherFunction('y')")).toBeNull() + }) + + it('returns null for an empty string', () => { + expect(convertRequireToImport('')).toBeNull() + }) + }) +}) + +// --------------------------------------------------------------------------- +// CommonJS detection regex (inline at utils.ts line 1579) +// Tests the pattern used to identify require() lines in executeJavaScriptCode +// --------------------------------------------------------------------------- + +describe('CommonJS detection regex (utils.ts line 1579 pattern)', () => { + const commonJsDetectionRegex = COMMONJS_REQUIRE_REGEX + + it('matches a const default require', () => { + expect(commonJsDetectionRegex.test("const foo = require('x')")).toBe(true) + }) + + it('matches a let default require', () => { + expect(commonJsDetectionRegex.test("let foo = require('x')")).toBe(true) + }) + + it('matches a destructured require', () => { + expect(commonJsDetectionRegex.test("const { a } = require('x')")).toBe(true) + }) + + it('does not match a non-require assignment', () => { + expect(commonJsDetectionRegex.test("const foo = someOtherFn('x')")).toBe(false) + }) + + it('does not match an import statement', () => { + expect(commonJsDetectionRegex.test("import foo from 'x'")).toBe(false) + }) + + it('does not match a bare require call (no assignment)', () => { + expect(commonJsDetectionRegex.test("require('x')")).toBe(false) + }) +}) + +describe('Import extraction regex (utils.ts line 1596 pattern)', () => { + const extractModules = (code: string): string[] => { + const results: string[] = [] + const re = new RegExp(IMPORT_EXTRACTION_REGEX.source, 'g') + let m: RegExpExecArray | null + while ((m = re.exec(code)) !== null) { + results.push(m[1] ?? m[2]) + } + return results + } + + it('extracts module from a default import', () => { + expect(extractModules("import foo from 'lodash'")).toEqual(['lodash']) + }) + + it('extracts module from a named import', () => { + expect(extractModules("import { a, b } from 'react'")).toEqual(['react']) + }) + + it('extracts module from a namespace import', () => { + expect(extractModules("import * as x from 'mod'")).toEqual(['mod']) + }) + + it('extracts module from a require call', () => { + expect(extractModules("require('fs')")).toEqual(['fs']) + }) + + it('extracts module from a require call inside an assignment', () => { + expect(extractModules("const foo = require('path')")).toEqual(['path']) + }) + + it('extracts a scoped package name', () => { + expect(extractModules("import x from '@scope/pkg'")).toEqual(['@scope/pkg']) + }) + + it('extracts multiple modules from a multi-line code block', () => { + const code = ["import foo from 'lodash'", "const bar = require('fs')"].join('\n') + expect(extractModules(code)).toEqual(['lodash', 'fs']) + }) + + it('returns empty array when there are no imports', () => { + expect(extractModules('console.log("hello")')).toEqual([]) + }) +}) From c24fbe5386718cc78b676fbda52293344e1e813d Mon Sep 17 00:00:00 2001 From: tianwei-liu Date: Fri, 27 Feb 2026 09:25:34 -0800 Subject: [PATCH 2/3] fix(agentflow): correct streaming field default of chat model configs in LLM node (#5856) --- packages/components/nodes/agentflow/Agent/Agent.ts | 7 ++++++- packages/components/nodes/agentflow/LLM/LLM.ts | 7 ++++++- 2 files changed, 12 insertions(+), 2 deletions(-) diff --git a/packages/components/nodes/agentflow/Agent/Agent.ts b/packages/components/nodes/agentflow/Agent/Agent.ts index dd536d4fb0a..bb578500c2c 100644 --- a/packages/components/nodes/agentflow/Agent/Agent.ts +++ b/packages/components/nodes/agentflow/Agent/Agent.ts @@ -1074,7 +1074,12 @@ class Agent_Agentflow implements INode { // Initialize response and determine if streaming is possible let response: AIMessageChunk = new AIMessageChunk('') const isLastNode = options.isLastNode as boolean - const isStreamable = isLastNode && options.sseStreamer !== undefined && modelConfig?.streaming !== false && !isStructuredOutput + const streamingConfig = modelConfig?.streaming + const useDefault = streamingConfig == null || streamingConfig === '' + const effectiveStreaming = useDefault + ? newLLMNodeInstance.inputs?.find((i: INodeParams) => i.name === 'streaming')?.default ?? true + : streamingConfig + const isStreamable = isLastNode && options.sseStreamer !== undefined && effectiveStreaming !== false && !isStructuredOutput // Start analytics if (analyticHandlers && options.parentTraceIds) { diff --git a/packages/components/nodes/agentflow/LLM/LLM.ts b/packages/components/nodes/agentflow/LLM/LLM.ts index 3f33560ad20..8ff1b08fc0b 100644 --- a/packages/components/nodes/agentflow/LLM/LLM.ts +++ b/packages/components/nodes/agentflow/LLM/LLM.ts @@ -466,7 +466,12 @@ class LLM_Agentflow implements INode { // Initialize response and determine if streaming is possible let response: AIMessageChunk = new AIMessageChunk('') const isLastNode = options.isLastNode as boolean - const isStreamable = isLastNode && options.sseStreamer !== undefined && modelConfig?.streaming !== false && !isStructuredOutput + const streamingConfig = modelConfig?.streaming + const useDefault = streamingConfig == null || streamingConfig === '' + const effectiveStreaming = useDefault + ? newLLMNodeInstance.inputs?.find((i: INodeParams) => i.name === 'streaming')?.default ?? true + : streamingConfig + const isStreamable = isLastNode && options.sseStreamer !== undefined && effectiveStreaming !== false && !isStructuredOutput // Start analytics if (analyticHandlers && options.parentTraceIds) { From 86ac9f62202bcc393205ff045435e64af27f6ea4 Mon Sep 17 00:00:00 2001 From: j-sanaa Date: Fri, 27 Feb 2026 09:38:39 -0800 Subject: [PATCH 3/3] Fix(agentflow): fix duplicate and drag bug and make behavior similar to v2 (#5850) * Fix duplicate and drag bug * Fix position of duplicate ndoe to same as v2 * enhance duplicateNode functionality with unique ID generation and position adjustments similar to v2 * enhance deleteNode functionality to clean up connected inputs and remove descendants * Revert changes to deleteNode * Modify tests to have a nested structure rather than a flat one --- .../store/AgentflowContext.test.tsx | 1923 ++++++++++------- .../infrastructure/store/AgentflowContext.tsx | 93 +- 2 files changed, 1171 insertions(+), 845 deletions(-) diff --git a/packages/agentflow/src/infrastructure/store/AgentflowContext.test.tsx b/packages/agentflow/src/infrastructure/store/AgentflowContext.test.tsx index bb90d9bb979..92644a10f4e 100644 --- a/packages/agentflow/src/infrastructure/store/AgentflowContext.test.tsx +++ b/packages/agentflow/src/infrastructure/store/AgentflowContext.test.tsx @@ -18,937 +18,1097 @@ const createWrapper = (initialFlow?: FlowData) => { return Wrapper } -describe('AgentflowContext - deleteNode', () => { - it('should remove node from the nodes array', () => { - const initialFlow: FlowData = { - nodes: [makeNode('node-1'), makeNode('node-2'), makeNode('node-3')], - edges: [] - } - - const { result } = renderHook(() => useAgentflowContext(), { - wrapper: createWrapper(initialFlow) - }) +describe('AgentflowContext', () => { + describe('deleteNode', () => { + it('should remove node from the nodes array', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1'), makeNode('node-2'), makeNode('node-3')], + edges: [] + } - // Initial state should have 3 nodes - expect(result.current.state.nodes).toHaveLength(3) - expect(result.current.state.nodes.map((n) => n.id)).toEqual(['node-1', 'node-2', 'node-3']) + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + // Initial state should have 3 nodes + expect(result.current.state.nodes).toHaveLength(3) + expect(result.current.state.nodes.map((n) => n.id)).toEqual(['node-1', 'node-2', 'node-3']) + + // Delete node-2 + act(() => { + result.current.deleteNode('node-2') + }) + + // Should have 2 nodes remaining + expect(result.current.state.nodes).toHaveLength(2) + expect(result.current.state.nodes.map((n) => n.id)).toEqual(['node-1', 'node-3']) + expect(result.current.state.nodes.find((n) => n.id === 'node-2')).toBeUndefined() + }) + + it('should remove connected edges when node is deleted', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1'), makeNode('node-2'), makeNode('node-3'), makeNode('node-4')], + edges: [ + makeEdge('node-1', 'node-2', { id: 'edge-1-2' }), + makeEdge('node-2', 'node-3', { id: 'edge-2-3' }), + makeEdge('node-3', 'node-4', { id: 'edge-3-4' }) + ] + } - // Delete node-2 - act(() => { - result.current.deleteNode('node-2') - }) + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) - // Should have 2 nodes remaining - expect(result.current.state.nodes).toHaveLength(2) - expect(result.current.state.nodes.map((n) => n.id)).toEqual(['node-1', 'node-3']) - expect(result.current.state.nodes.find((n) => n.id === 'node-2')).toBeUndefined() - }) + // Initial state should have 3 edges + expect(result.current.state.edges).toHaveLength(3) - it('should remove connected edges when node is deleted', () => { - const initialFlow: FlowData = { - nodes: [makeNode('node-1'), makeNode('node-2'), makeNode('node-3'), makeNode('node-4')], - edges: [ - makeEdge('node-1', 'node-2', { id: 'edge-1-2' }), - makeEdge('node-2', 'node-3', { id: 'edge-2-3' }), - makeEdge('node-3', 'node-4', { id: 'edge-3-4' }) - ] - } + // Delete node-2 (which is connected to node-1 and node-3) + act(() => { + result.current.deleteNode('node-2') + }) - const { result } = renderHook(() => useAgentflowContext(), { - wrapper: createWrapper(initialFlow) + // Should remove edges where node-2 is source or target + expect(result.current.state.edges).toHaveLength(1) + expect(result.current.state.edges[0].id).toBe('edge-3-4') + expect(result.current.state.edges.find((e) => e.id === 'edge-1-2')).toBeUndefined() + expect(result.current.state.edges.find((e) => e.id === 'edge-2-3')).toBeUndefined() }) - // Initial state should have 3 edges - expect(result.current.state.edges).toHaveLength(3) + it('should remove edges where deleted node is the source', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1'), makeNode('node-2'), makeNode('node-3')], + edges: [makeEdge('node-1', 'node-2', { id: 'edge-1-2' }), makeEdge('node-1', 'node-3', { id: 'edge-1-3' })] + } - // Delete node-2 (which is connected to node-1 and node-3) - act(() => { - result.current.deleteNode('node-2') - }) + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) - // Should remove edges where node-2 is source or target - expect(result.current.state.edges).toHaveLength(1) - expect(result.current.state.edges[0].id).toBe('edge-3-4') - expect(result.current.state.edges.find((e) => e.id === 'edge-1-2')).toBeUndefined() - expect(result.current.state.edges.find((e) => e.id === 'edge-2-3')).toBeUndefined() - }) + expect(result.current.state.edges).toHaveLength(2) - it('should remove edges where deleted node is the source', () => { - const initialFlow: FlowData = { - nodes: [makeNode('node-1'), makeNode('node-2'), makeNode('node-3')], - edges: [makeEdge('node-1', 'node-2', { id: 'edge-1-2' }), makeEdge('node-1', 'node-3', { id: 'edge-1-3' })] - } + // Delete node-1 (which is the source for both edges) + act(() => { + result.current.deleteNode('node-1') + }) - const { result } = renderHook(() => useAgentflowContext(), { - wrapper: createWrapper(initialFlow) + // All edges from node-1 should be removed + expect(result.current.state.edges).toHaveLength(0) }) - expect(result.current.state.edges).toHaveLength(2) + it('should remove edges where deleted node is the target', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1'), makeNode('node-2'), makeNode('node-3')], + edges: [makeEdge('node-1', 'node-3', { id: 'edge-1-3' }), makeEdge('node-2', 'node-3', { id: 'edge-2-3' })] + } - // Delete node-1 (which is the source for both edges) - act(() => { - result.current.deleteNode('node-1') - }) + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) - // All edges from node-1 should be removed - expect(result.current.state.edges).toHaveLength(0) - }) + expect(result.current.state.edges).toHaveLength(2) - it('should remove edges where deleted node is the target', () => { - const initialFlow: FlowData = { - nodes: [makeNode('node-1'), makeNode('node-2'), makeNode('node-3')], - edges: [makeEdge('node-1', 'node-3', { id: 'edge-1-3' }), makeEdge('node-2', 'node-3', { id: 'edge-2-3' })] - } + // Delete node-3 (which is the target for both edges) + act(() => { + result.current.deleteNode('node-3') + }) - const { result } = renderHook(() => useAgentflowContext(), { - wrapper: createWrapper(initialFlow) + // All edges to node-3 should be removed + expect(result.current.state.edges).toHaveLength(0) }) - expect(result.current.state.edges).toHaveLength(2) + it('should mark state as dirty when node is deleted', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1'), makeNode('node-2')], + edges: [] + } - // Delete node-3 (which is the target for both edges) - act(() => { - result.current.deleteNode('node-3') - }) + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) - // All edges to node-3 should be removed - expect(result.current.state.edges).toHaveLength(0) - }) + // Initial state should not be dirty + expect(result.current.state.isDirty).toBe(false) - it('should mark state as dirty when node is deleted', () => { - const initialFlow: FlowData = { - nodes: [makeNode('node-1'), makeNode('node-2')], - edges: [] - } + // Delete a node + act(() => { + result.current.deleteNode('node-1') + }) - const { result } = renderHook(() => useAgentflowContext(), { - wrapper: createWrapper(initialFlow) + // State should be marked as dirty + expect(result.current.state.isDirty).toBe(true) }) - // Initial state should not be dirty - expect(result.current.state.isDirty).toBe(false) + it('should call local state setters when registered', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1'), makeNode('node-2')], + edges: [makeEdge('node-1', 'node-2', { id: 'edge-1-2' })] + } - // Delete a node - act(() => { - result.current.deleteNode('node-1') - }) + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) - // State should be marked as dirty - expect(result.current.state.isDirty).toBe(true) - }) + // Create mock local state setters + const mockSetLocalNodes = jest.fn() + const mockSetLocalEdges = jest.fn() - it('should call local state setters when registered', () => { - const initialFlow: FlowData = { - nodes: [makeNode('node-1'), makeNode('node-2')], - edges: [makeEdge('node-1', 'node-2', { id: 'edge-1-2' })] - } + // Register the local state setters + act(() => { + result.current.registerLocalStateSetters(mockSetLocalNodes, mockSetLocalEdges) + }) - const { result } = renderHook(() => useAgentflowContext(), { - wrapper: createWrapper(initialFlow) - }) + // Delete a node + act(() => { + result.current.deleteNode('node-1') + }) - // Create mock local state setters - const mockSetLocalNodes = jest.fn() - const mockSetLocalEdges = jest.fn() + // Local state setters should be called with updated nodes and edges + expect(mockSetLocalNodes).toHaveBeenCalledTimes(1) + expect(mockSetLocalNodes).toHaveBeenCalledWith(expect.arrayContaining([expect.objectContaining({ id: 'node-2' })])) + expect(mockSetLocalNodes).toHaveBeenCalledWith(expect.not.arrayContaining([expect.objectContaining({ id: 'node-1' })])) - // Register the local state setters - act(() => { - result.current.registerLocalStateSetters(mockSetLocalNodes, mockSetLocalEdges) + expect(mockSetLocalEdges).toHaveBeenCalledTimes(1) + expect(mockSetLocalEdges).toHaveBeenCalledWith([]) }) - // Delete a node - act(() => { - result.current.deleteNode('node-1') - }) + it('should handle deleting multiple nodes sequentially', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1'), makeNode('node-2'), makeNode('node-3')], + edges: [makeEdge('node-1', 'node-2', { id: 'edge-1-2' }), makeEdge('node-2', 'node-3', { id: 'edge-2-3' })] + } - // Local state setters should be called with updated nodes and edges - expect(mockSetLocalNodes).toHaveBeenCalledTimes(1) - expect(mockSetLocalNodes).toHaveBeenCalledWith(expect.arrayContaining([expect.objectContaining({ id: 'node-2' })])) - expect(mockSetLocalNodes).toHaveBeenCalledWith(expect.not.arrayContaining([expect.objectContaining({ id: 'node-1' })])) + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) - expect(mockSetLocalEdges).toHaveBeenCalledTimes(1) - expect(mockSetLocalEdges).toHaveBeenCalledWith([]) - }) + // Delete first node + act(() => { + result.current.deleteNode('node-1') + }) - it('should handle deleting multiple nodes sequentially', () => { - const initialFlow: FlowData = { - nodes: [makeNode('node-1'), makeNode('node-2'), makeNode('node-3')], - edges: [makeEdge('node-1', 'node-2', { id: 'edge-1-2' }), makeEdge('node-2', 'node-3', { id: 'edge-2-3' })] - } + expect(result.current.state.nodes).toHaveLength(2) + expect(result.current.state.edges).toHaveLength(1) + expect(result.current.state.edges[0].id).toBe('edge-2-3') - const { result } = renderHook(() => useAgentflowContext(), { - wrapper: createWrapper(initialFlow) - }) + // Delete second node + act(() => { + result.current.deleteNode('node-2') + }) - // Delete first node - act(() => { - result.current.deleteNode('node-1') + expect(result.current.state.nodes).toHaveLength(1) + expect(result.current.state.nodes[0].id).toBe('node-3') + expect(result.current.state.edges).toHaveLength(0) }) - expect(result.current.state.nodes).toHaveLength(2) - expect(result.current.state.edges).toHaveLength(1) - expect(result.current.state.edges[0].id).toBe('edge-2-3') - - // Delete second node - act(() => { - result.current.deleteNode('node-2') - }) + it('should preserve other nodes and edges when deleting a node', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1'), makeNode('node-2'), makeNode('node-3'), makeNode('node-4')], + edges: [makeEdge('node-1', 'node-2', { id: 'edge-1-2' }), makeEdge('node-3', 'node-4', { id: 'edge-3-4' })] + } - expect(result.current.state.nodes).toHaveLength(1) - expect(result.current.state.nodes[0].id).toBe('node-3') - expect(result.current.state.edges).toHaveLength(0) - }) + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) - it('should preserve other nodes and edges when deleting a node', () => { - const initialFlow: FlowData = { - nodes: [makeNode('node-1'), makeNode('node-2'), makeNode('node-3'), makeNode('node-4')], - edges: [makeEdge('node-1', 'node-2', { id: 'edge-1-2' }), makeEdge('node-3', 'node-4', { id: 'edge-3-4' })] - } + // Delete node-2 (connected to node-1) + act(() => { + result.current.deleteNode('node-2') + }) - const { result } = renderHook(() => useAgentflowContext(), { - wrapper: createWrapper(initialFlow) - }) + // Should preserve node-3, node-4 and their edge + expect(result.current.state.nodes).toHaveLength(3) + expect(result.current.state.nodes.map((n) => n.id)).toEqual(['node-1', 'node-3', 'node-4']) - // Delete node-2 (connected to node-1) - act(() => { - result.current.deleteNode('node-2') + expect(result.current.state.edges).toHaveLength(1) + expect(result.current.state.edges[0].id).toBe('edge-3-4') }) - - // Should preserve node-3, node-4 and their edge - expect(result.current.state.nodes).toHaveLength(3) - expect(result.current.state.nodes.map((n) => n.id)).toEqual(['node-1', 'node-3', 'node-4']) - - expect(result.current.state.edges).toHaveLength(1) - expect(result.current.state.edges[0].id).toBe('edge-3-4') }) -}) - -describe('AgentflowContext - duplicateNode', () => { - it('should create a duplicate node with unique ID', () => { - const initialFlow: FlowData = { - nodes: [makeNode('node-1'), makeNode('node-2')], - edges: [] - } - const { result } = renderHook(() => useAgentflowContext(), { - wrapper: createWrapper(initialFlow) - }) - - // Initial state should have 2 nodes - expect(result.current.state.nodes).toHaveLength(2) - - // Duplicate node-1 - act(() => { - result.current.duplicateNode('node-1') - }) - - // Should have 3 nodes - expect(result.current.state.nodes).toHaveLength(3) + describe('duplicateNode', () => { + it('should create a duplicate node with unique ID using getUniqueNodeId', () => { + const initialFlow: FlowData = { + nodes: [ + makeFlowNode('agentflow_0', { + data: { id: 'agentflow_0', name: 'agentflow', label: 'Agent 1', outputAnchors: [] } + }) + ], + edges: [] + } - // Find the duplicated node - const duplicatedNode = result.current.state.nodes.find((n) => n.id.startsWith('node-1_copy_')) + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) - expect(duplicatedNode).toBeDefined() - expect(duplicatedNode?.id).toBe('node-1_copy_1') - }) + // Initial state should have 1 node + expect(result.current.state.nodes).toHaveLength(1) - it('should position duplicate with +50 offset', () => { - const initialFlow: FlowData = { - nodes: [makeNode('node-1')], - edges: [] - } + // Duplicate the node + act(() => { + result.current.duplicateNode('agentflow_0') + }) - const originalNode = initialFlow.nodes[0] - originalNode.position = { x: 100, y: 200 } + // Should have 2 nodes + expect(result.current.state.nodes).toHaveLength(2) - const { result } = renderHook(() => useAgentflowContext(), { - wrapper: createWrapper(initialFlow) - }) + // Find the duplicated node - should use getUniqueNodeId format (agentflow_1, not agentflow_0_copy_1) + const duplicatedNode = result.current.state.nodes.find((n) => n.id === 'agentflow_1') - // Duplicate the node - act(() => { - result.current.duplicateNode('node-1') + expect(duplicatedNode).toBeDefined() + expect(duplicatedNode?.id).toBe('agentflow_1') + expect(duplicatedNode?.data.id).toBe('agentflow_1') }) - // Find the duplicated node - const duplicatedNode = result.current.state.nodes.find((n) => n.id.startsWith('node-1_copy_')) + it('should position duplicate using width + distance formula', () => { + const initialFlow: FlowData = { + nodes: [ + makeFlowNode('agentflow_0', { + position: { x: 100, y: 200 }, + width: 300, + data: { id: 'agentflow_0', name: 'agentflow', label: 'Agent 1', outputAnchors: [] } + }) + ], + edges: [] + } - expect(duplicatedNode?.position.x).toBe(150) - expect(duplicatedNode?.position.y).toBe(250) - }) + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) - it('should preserve all node data properties', () => { - const initialFlow: FlowData = { - nodes: [ - makeFlowNode('node-1', { - type: 'customType', - data: { - id: 'node-1', - name: 'testNode', - label: 'Test Node', - color: '#FF0000', - category: 'test', - description: 'Test description' - } - }) - ], - edges: [] - } + // Duplicate the node with default distance (50) + act(() => { + result.current.duplicateNode('agentflow_0') + }) - const { result } = renderHook(() => useAgentflowContext(), { - wrapper: createWrapper(initialFlow) - }) + // Find the duplicated node + const duplicatedNode = result.current.state.nodes.find((n) => n.id === 'agentflow_1') - // Duplicate the node - act(() => { - result.current.duplicateNode('node-1') + // Position should be: original.x + width + distance = 100 + 300 + 50 = 450 + expect(duplicatedNode?.position.x).toBe(450) + expect(duplicatedNode?.position.y).toBe(200) // Y unchanged }) - // Find the duplicated node - const duplicatedNode = result.current.state.nodes.find((n) => n.id.startsWith('node-1_copy_')) + it('should support custom distance parameter', () => { + const initialFlow: FlowData = { + nodes: [ + makeFlowNode('agentflow_0', { + position: { x: 100, y: 200 }, + width: 300, + data: { id: 'agentflow_0', name: 'agentflow', label: 'Agent 1', outputAnchors: [] } + }) + ], + edges: [] + } - // Should preserve data properties (except id) - expect(duplicatedNode?.data.name).toBe('testNode') - expect(duplicatedNode?.data.label).toBe('Test Node') - expect(duplicatedNode?.data.color).toBe('#FF0000') - expect(duplicatedNode?.data.category).toBe('test') - expect(duplicatedNode?.data.description).toBe('Test description') - }) + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) - it('should preserve node type', () => { - const initialFlow: FlowData = { - nodes: [makeNode('node-1', 'stickyNote')], - edges: [] - } + // Duplicate with custom distance of 100 + act(() => { + result.current.duplicateNode('agentflow_0', 100) + }) - const { result } = renderHook(() => useAgentflowContext(), { - wrapper: createWrapper(initialFlow) - }) + const duplicatedNode = result.current.state.nodes.find((n) => n.id === 'agentflow_1') - // Duplicate the node - act(() => { - result.current.duplicateNode('node-1') + // Position: 100 + 300 + 100 = 500 + expect(duplicatedNode?.position.x).toBe(500) }) - // Find the duplicated node - const duplicatedNode = result.current.state.nodes.find((n) => n.id.startsWith('node-1_copy_')) + it('should set label with number suffix format', () => { + const initialFlow: FlowData = { + nodes: [ + makeFlowNode('agentflow_0', { + data: { id: 'agentflow_0', name: 'agentflow', label: 'Agent 1', outputAnchors: [] } + }) + ], + edges: [] + } - expect(duplicatedNode?.type).toBe('stickyNote') - }) + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + act(() => { + result.current.duplicateNode('agentflow_0') + }) + + const duplicatedNode = result.current.state.nodes.find((n) => n.id === 'agentflow_1') + + // Label should be "Agent 1 (1)" - extracted from agentflow_1 + expect(duplicatedNode?.data.label).toBe('Agent 1 (1)') + }) + + it('should preserve all node data properties', () => { + const initialFlow: FlowData = { + nodes: [ + makeFlowNode('node-1', { + type: 'customType', + data: { + id: 'node-1', + name: 'testNode', + label: 'Test Node', + color: '#FF0000', + category: 'test', + description: 'Test description', + outputAnchors: [] + } + }) + ], + edges: [] + } - it('should mark state as dirty after duplication', () => { - const initialFlow: FlowData = { - nodes: [makeNode('node-1')], - edges: [] - } + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) - const { result } = renderHook(() => useAgentflowContext(), { - wrapper: createWrapper(initialFlow) - }) + // Duplicate the node + act(() => { + result.current.duplicateNode('node-1') + }) - // Initial state should not be dirty - expect(result.current.state.isDirty).toBe(false) + // Find the duplicated node + const duplicatedNode = result.current.state.nodes.find((n) => n.id === 'testNode_0') - // Duplicate a node - act(() => { - result.current.duplicateNode('node-1') + // Should preserve data properties + expect(duplicatedNode?.data.name).toBe('testNode') + expect(duplicatedNode?.data.label).toBe('Test Node (0)') // Label gets suffix + expect(duplicatedNode?.data.color).toBe('#FF0000') + expect(duplicatedNode?.data.category).toBe('test') + expect(duplicatedNode?.data.description).toBe('Test description') }) - // State should be marked as dirty - expect(result.current.state.isDirty).toBe(true) - }) + it('should preserve node type', () => { + const initialFlow: FlowData = { + nodes: [ + makeFlowNode('stickyNote_0', { + type: 'stickyNote', + data: { id: 'stickyNote_0', name: 'stickyNote', label: 'Note', outputAnchors: [] } + }) + ], + edges: [] + } - it('should preserve original node unchanged', () => { - const initialFlow: FlowData = { - nodes: [makeNode('node-1')], - edges: [] - } + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) - const originalNode = initialFlow.nodes[0] - originalNode.position = { x: 100, y: 200 } - originalNode.data.label = 'Original Label' + // Duplicate the node + act(() => { + result.current.duplicateNode('stickyNote_0') + }) - const { result } = renderHook(() => useAgentflowContext(), { - wrapper: createWrapper(initialFlow) - }) + // Find the duplicated node + const duplicatedNode = result.current.state.nodes.find((n) => n.id === 'stickyNote_1') - // Duplicate the node - act(() => { - result.current.duplicateNode('node-1') + expect(duplicatedNode?.type).toBe('stickyNote') }) - // Find the original node - const originalNodeAfter = result.current.state.nodes.find((n) => n.id === 'node-1') - - // Original should be unchanged - expect(originalNodeAfter?.position.x).toBe(100) - expect(originalNodeAfter?.position.y).toBe(200) - expect(originalNodeAfter?.data.label).toBe('Original Label') - expect(originalNodeAfter?.data.id).toBe('node-1') - }) - - it('should handle multiple sequential duplications with unique IDs', () => { - const initialFlow: FlowData = { - nodes: [makeNode('node-1'), makeNode('node-2')], - edges: [] - } - - const { result } = renderHook(() => useAgentflowContext(), { - wrapper: createWrapper(initialFlow) - }) + it('should mark state as dirty after duplication', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1')], + edges: [] + } - act(() => { - result.current.duplicateNode('node-1') - }) + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + // Initial state should not be dirty + expect(result.current.state.isDirty).toBe(false) + + // Duplicate a node + act(() => { + result.current.duplicateNode('node-1') + }) + + // State should be marked as dirty + expect(result.current.state.isDirty).toBe(true) + }) + + it('should preserve original node unchanged', () => { + const initialFlow: FlowData = { + nodes: [ + makeFlowNode('agentflow_0', { + position: { x: 100, y: 200 }, + data: { + id: 'agentflow_0', + name: 'agentflow', + label: 'Original Label', + inputAnchors: [{ id: 'agentflow_0-input-model-LLM', name: 'model', label: 'Model', type: 'LLM' }], + outputAnchors: [] + } + }) + ], + edges: [] + } - act(() => { - result.current.duplicateNode('node-2') - }) + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + // Duplicate the node + act(() => { + result.current.duplicateNode('agentflow_0') + }) + + // Find the original node + const originalNodeAfter = result.current.state.nodes.find((n) => n.id === 'agentflow_0') + + // Original should be completely unchanged + expect(originalNodeAfter?.position.x).toBe(100) + expect(originalNodeAfter?.position.y).toBe(200) + expect(originalNodeAfter?.data.label).toBe('Original Label') + expect(originalNodeAfter?.data.id).toBe('agentflow_0') + // Verify nested objects aren't mutated + expect(originalNodeAfter?.data.inputAnchors?.[0]?.id).toBe('agentflow_0-input-model-LLM') + }) + + it('should handle multiple sequential duplications with unique IDs', () => { + const initialFlow: FlowData = { + nodes: [ + makeFlowNode('agentflow_0', { + data: { id: 'agentflow_0', name: 'agentflow', label: 'Agent 1', outputAnchors: [] } + }), + makeFlowNode('tool_0', { + data: { id: 'tool_0', name: 'tool', label: 'Tool 1', outputAnchors: [] } + }) + ], + edges: [] + } - // Should have 4 nodes (2 originals + 2 duplicates) - expect(result.current.state.nodes).toHaveLength(4) + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + act(() => { + result.current.duplicateNode('agentflow_0') + }) + + act(() => { + result.current.duplicateNode('tool_0') + }) + + // Should have 4 nodes (2 originals + 2 duplicates) + expect(result.current.state.nodes).toHaveLength(4) + + // All IDs should be unique + const ids = result.current.state.nodes.map((n) => n.id) + const uniqueIds = new Set(ids) + expect(uniqueIds.size).toBe(4) + + // Should have the correct IDs + expect(result.current.state.nodes.find((n) => n.id === 'agentflow_0')).toBeDefined() + expect(result.current.state.nodes.find((n) => n.id === 'agentflow_1')).toBeDefined() + expect(result.current.state.nodes.find((n) => n.id === 'tool_0')).toBeDefined() + expect(result.current.state.nodes.find((n) => n.id === 'tool_1')).toBeDefined() + + // Each node should have matching node.id and data.id + result.current.state.nodes.forEach((node) => { + expect(node.id).toBe(node.data.id) + }) + }) + + it('should update anchor IDs to match new node ID', () => { + const initialFlow: FlowData = { + nodes: [ + makeFlowNode('agentflow_0', { + data: { + id: 'agentflow_0', + name: 'agentflow', + label: 'Agent 1', + inputs: [{ id: 'agentflow_0-input-model-string', name: 'model', label: 'Model', type: 'string' }], + inputAnchors: [{ id: 'agentflow_0-input-llm-LLM', name: 'llm', label: 'LLM', type: 'LLM' }], + outputAnchors: [{ id: 'agentflow_0-output-0', name: 'output', label: 'Output', type: 'string' }] + } + }) + ], + edges: [] + } - // All IDs should be unique - const ids = result.current.state.nodes.map((n) => n.id) - const uniqueIds = new Set(ids) - expect(uniqueIds.size).toBe(4) + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + act(() => { + result.current.duplicateNode('agentflow_0') + }) + + const original = result.current.state.nodes.find((n) => n.id === 'agentflow_0') + const duplicate = result.current.state.nodes.find((n) => n.id === 'agentflow_1') + + // Original node IDs should be unchanged + expect(original?.data.inputs?.[0]?.id).toBe('agentflow_0-input-model-string') + expect(original?.data.inputAnchors?.[0]?.id).toBe('agentflow_0-input-llm-LLM') + expect(original?.data.outputAnchors?.[0]?.id).toBe('agentflow_0-output-0') + + // Duplicate node IDs should be updated to use new node ID + expect(duplicate?.data.inputs?.[0]?.id).toBe('agentflow_1-input-model-string') + expect(duplicate?.data.inputAnchors?.[0]?.id).toBe('agentflow_1-input-llm-LLM') + expect(duplicate?.data.outputAnchors?.[0]?.id).toBe('agentflow_1-output-0') + }) + + it('should clear connected input values (string connections)', () => { + const initialFlow: FlowData = { + nodes: [ + makeFlowNode('agentflow_0', { + data: { + id: 'agentflow_0', + name: 'agentflow', + label: 'Agent 1', + inputs: [{ id: 'agentflow_0-input-model-string', name: 'model', label: 'Model', type: 'string' }], + inputValues: { + model: '{{agent_upstream.data.instance}}', // Connection reference + temperature: '0.7', // Regular value + apiKey: 'sk-1234' // Regular value + }, + outputAnchors: [] + } + }) + ], + edges: [] + } - // Should have one duplicate of each original - const node1Duplicates = result.current.state.nodes.filter((n) => n.id.startsWith('node-1_copy_')) - const node2Duplicates = result.current.state.nodes.filter((n) => n.id.startsWith('node-2_copy_')) + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + act(() => { + result.current.duplicateNode('agentflow_0') + }) + + const original = result.current.state.nodes.find((n) => n.id === 'agentflow_0') + const duplicate = result.current.state.nodes.find((n) => n.id === 'agentflow_1') + + // Original should still have the connection + expect(original?.data.inputValues?.model).toBe('{{agent_upstream.data.instance}}') + expect(original?.data.inputValues?.temperature).toBe('0.7') + expect(original?.data.inputValues?.apiKey).toBe('sk-1234') + + // Duplicate should have connection cleared but regular values preserved + expect(duplicate?.data.inputValues?.model).toBe('') // Cleared (no default) + expect(duplicate?.data.inputValues?.temperature).toBe('0.7') // Preserved + expect(duplicate?.data.inputValues?.apiKey).toBe('sk-1234') // Preserved + }) + + it('should reset connected input values to parameter defaults', () => { + const initialFlow: FlowData = { + nodes: [ + makeFlowNode('agentflow_0', { + data: { + id: 'agentflow_0', + name: 'agentflow', + label: 'Agent 1', + inputs: [ + { id: 'agentflow_0-input-model-string', name: 'model', label: 'Model', type: 'string', default: 'gpt-4' }, + { id: 'agentflow_0-input-temp-number', name: 'temperature', label: 'Temp', type: 'number', default: 0.7 } + ], + inputValues: { + model: '{{agent_upstream.data.instance}}', // Connection + temperature: '{{agent_upstream.data.temperature}}' // Connection + }, + outputAnchors: [] + } + }) + ], + edges: [] + } - expect(node1Duplicates).toHaveLength(1) - expect(node2Duplicates).toHaveLength(1) + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + act(() => { + result.current.duplicateNode('agentflow_0') + }) + + const duplicate = result.current.state.nodes.find((n) => n.id === 'agentflow_1') + + // Should reset to parameter defaults + expect(duplicate?.data.inputValues?.model).toBe('gpt-4') + expect(duplicate?.data.inputValues?.temperature).toBe(0.7) + }) + + it('should filter connection strings from array input values', () => { + const initialFlow: FlowData = { + nodes: [ + makeFlowNode('agentflow_0', { + data: { + id: 'agentflow_0', + name: 'agentflow', + label: 'Agent 1', + inputValues: { + tools: ['{{agent_tool1.data.instance}}', '{{agent_tool2.data.instance}}', 'regularValue'], + models: ['gpt-4', 'gpt-3.5'] // No connections + }, + outputAnchors: [] + } + }) + ], + edges: [] + } - // Each duplicate should have matching node.id and data.id - expect(node1Duplicates[0].id).toBe(node1Duplicates[0].data.id) - expect(node2Duplicates[0].id).toBe(node2Duplicates[0].data.id) - }) + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) - it('should NOT duplicate connected edges', () => { - const initialFlow: FlowData = { - nodes: [makeNode('node-1'), makeNode('node-2'), makeNode('node-3')], - edges: [makeEdge('node-1', 'node-2', { id: 'edge-1-2' }), makeEdge('node-2', 'node-3', { id: 'edge-2-3' })] - } + act(() => { + result.current.duplicateNode('agentflow_0') + }) - const { result } = renderHook(() => useAgentflowContext(), { - wrapper: createWrapper(initialFlow) - }) + const original = result.current.state.nodes.find((n) => n.id === 'agentflow_0') + const duplicate = result.current.state.nodes.find((n) => n.id === 'agentflow_1') - // Initial state should have 2 edges - expect(result.current.state.edges).toHaveLength(2) + // Original should be unchanged + expect(original?.data.inputValues?.tools).toEqual([ + '{{agent_tool1.data.instance}}', + '{{agent_tool2.data.instance}}', + 'regularValue' + ]) + expect(original?.data.inputValues?.models).toEqual(['gpt-4', 'gpt-3.5']) - // Duplicate node-2 (which has incoming and outgoing edges) - act(() => { - result.current.duplicateNode('node-2') + // Duplicate should filter out connection strings but keep regular values + expect(duplicate?.data.inputValues?.tools).toEqual(['regularValue']) + expect(duplicate?.data.inputValues?.models).toEqual(['gpt-4', 'gpt-3.5']) }) - // Should still have only 2 edges (edges not duplicated) - expect(result.current.state.edges).toHaveLength(2) - expect(result.current.state.edges[0].id).toBe('edge-1-2') - expect(result.current.state.edges[1].id).toBe('edge-2-3') - }) - - it('should generate sequential unique IDs for duplicates', () => { - const initialFlow: FlowData = { - nodes: [makeNode('node-1')], - edges: [] - } + it('should NOT duplicate connected edges', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1'), makeNode('node-2'), makeNode('node-3')], + edges: [makeEdge('node-1', 'node-2', { id: 'edge-1-2' }), makeEdge('node-2', 'node-3', { id: 'edge-2-3' })] + } - const { result } = renderHook(() => useAgentflowContext(), { - wrapper: createWrapper(initialFlow) - }) + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) - // Duplicate the node once - act(() => { - result.current.duplicateNode('node-1') - }) + // Initial state should have 2 edges + expect(result.current.state.edges).toHaveLength(2) - // Find the first duplicated node - const firstDuplicate = result.current.state.nodes.find((n) => n.id === 'node-1_copy_1') - expect(firstDuplicate).toBeDefined() + // Duplicate node-2 (which has incoming and outgoing edges) + act(() => { + result.current.duplicateNode('node-2') + }) - // Duplicate the original node again - act(() => { - result.current.duplicateNode('node-1') + // Should still have only 2 edges (edges not duplicated) + expect(result.current.state.edges).toHaveLength(2) + expect(result.current.state.edges[0].id).toBe('edge-1-2') + expect(result.current.state.edges[1].id).toBe('edge-2-3') }) - // Find the second duplicated node - const secondDuplicate = result.current.state.nodes.find((n) => n.id === 'node-1_copy_2') - expect(secondDuplicate).toBeDefined() - - // Should have 3 nodes total (original + 2 duplicates) - expect(result.current.state.nodes).toHaveLength(3) - }) -}) - -describe('AgentflowContext - openEditDialog & closeEditDialog', () => { - it('should open edit dialog with node data and input params', () => { - const initialFlow: FlowData = { - nodes: [makeNode('node-1')], - edges: [] - } - - const { result } = renderHook(() => useAgentflowContext(), { - wrapper: createWrapper(initialFlow) - }) + it('should generate sequential unique IDs for duplicates', () => { + const initialFlow: FlowData = { + nodes: [ + makeFlowNode('agentflow_0', { + data: { id: 'agentflow_0', name: 'agentflow', label: 'Agent 1', outputAnchors: [] } + }) + ], + edges: [] + } - // Initial state should have no editing node - expect(result.current.state.editingNodeId).toBeNull() - expect(result.current.state.editDialogProps).toBeNull() - - const nodeData = { - id: 'node-1', - name: 'testNode', - label: 'Test Node', - outputAnchors: [] - } - - const inputParams = [ - { - id: 'param-1', - name: 'param1', - label: 'Parameter 1', - type: 'string' + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + // Duplicate the node once + act(() => { + result.current.duplicateNode('agentflow_0') + }) + + // Find the first duplicated node + const firstDuplicate = result.current.state.nodes.find((n) => n.id === 'agentflow_1') + expect(firstDuplicate).toBeDefined() + expect(firstDuplicate?.data.label).toBe('Agent 1 (1)') + + // Duplicate the original node again + act(() => { + result.current.duplicateNode('agentflow_0') + }) + + // Find the second duplicated node + const secondDuplicate = result.current.state.nodes.find((n) => n.id === 'agentflow_2') + expect(secondDuplicate).toBeDefined() + expect(secondDuplicate?.data.label).toBe('Agent 1 (2)') + + // Should have 3 nodes total (original + 2 duplicates) + expect(result.current.state.nodes).toHaveLength(3) + }) + + it('should deep clone to avoid mutating original node', () => { + const initialFlow: FlowData = { + nodes: [ + makeFlowNode('agentflow_0', { + data: { + id: 'agentflow_0', + name: 'agentflow', + label: 'Agent 1', + inputAnchors: [{ id: 'agentflow_0-input-model-LLM', name: 'model', label: 'Model', type: 'LLM' }], + outputAnchors: [{ id: 'agentflow_0-output-0', name: 'output', label: 'Output', type: 'string' }] + } + }) + ], + edges: [] } - ] - // Open edit dialog - act(() => { - result.current.openEditDialog('node-1', nodeData, inputParams) - }) + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) - // Should set editingNodeId - expect(result.current.state.editingNodeId).toBe('node-1') + act(() => { + result.current.duplicateNode('agentflow_0') + }) - // Should set editDialogProps - expect(result.current.state.editDialogProps).toEqual({ - inputParams: inputParams, - data: nodeData, - disabled: false - }) - }) + const original = result.current.state.nodes.find((n) => n.id === 'agentflow_0') - it('should close edit dialog and clear state', () => { - const initialFlow: FlowData = { - nodes: [makeNode('node-1')], - edges: [] - } + // Original node's nested objects should NOT be mutated + expect(original?.data.inputAnchors?.[0]?.id).toBe('agentflow_0-input-model-LLM') + expect(original?.data.outputAnchors?.[0]?.id).toBe('agentflow_0-output-0') + expect(original?.data.label).toBe('Agent 1') - const { result } = renderHook(() => useAgentflowContext(), { - wrapper: createWrapper(initialFlow) + // Verify the duplicate has different IDs (proves deep clone worked) + const duplicate = result.current.state.nodes.find((n) => n.id === 'agentflow_1') + expect(duplicate?.data.inputAnchors?.[0]?.id).toBe('agentflow_1-input-model-LLM') + expect(duplicate?.data.outputAnchors?.[0]?.id).toBe('agentflow_1-output-0') }) + }) - const nodeData = { - id: 'node-1', - name: 'testNode', - label: 'Test Node', - outputAnchors: [] - } - - const inputParams = [ - { - id: 'param-1', - name: 'param1', - label: 'Parameter 1', - type: 'string' + describe('openEditDialog & closeEditDialog', () => { + it('should open edit dialog with node data and input params', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1')], + edges: [] } - ] - // First open the dialog - act(() => { - result.current.openEditDialog('node-1', nodeData, inputParams) - }) + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) - // Verify dialog is open - expect(result.current.state.editingNodeId).toBe('node-1') - expect(result.current.state.editDialogProps).not.toBeNull() + // Initial state should have no editing node + expect(result.current.state.editingNodeId).toBeNull() + expect(result.current.state.editDialogProps).toBeNull() - // Close the dialog - act(() => { - result.current.closeEditDialog() - }) + const nodeData = { + id: 'node-1', + name: 'testNode', + label: 'Test Node', + outputAnchors: [] + } - // Should clear editingNodeId - expect(result.current.state.editingNodeId).toBeNull() + const inputParams = [ + { + id: 'param-1', + name: 'param1', + label: 'Parameter 1', + type: 'string' + } + ] - // Should clear editDialogProps - expect(result.current.state.editDialogProps).toBeNull() - }) + // Open edit dialog + act(() => { + result.current.openEditDialog('node-1', nodeData, inputParams) + }) - it('should handle opening dialog for different nodes', () => { - const initialFlow: FlowData = { - nodes: [makeNode('node-1'), makeNode('node-2')], - edges: [] - } + // Should set editingNodeId + expect(result.current.state.editingNodeId).toBe('node-1') - const { result } = renderHook(() => useAgentflowContext(), { - wrapper: createWrapper(initialFlow) + // Should set editDialogProps + expect(result.current.state.editDialogProps).toEqual({ + inputParams: inputParams, + data: nodeData, + disabled: false + }) }) - const nodeData1 = { - id: 'node-1', - name: 'testNode1', - label: 'Test Node 1', - outputAnchors: [] - } - - const nodeData2 = { - id: 'node-2', - name: 'testNode2', - label: 'Test Node 2', - outputAnchors: [] - } - - const inputParams = [ - { - id: 'param-1', - name: 'param1', - label: 'Parameter 1', - type: 'string' + it('should close edit dialog and clear state', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1')], + edges: [] } - ] - // Open dialog for node-1 - act(() => { - result.current.openEditDialog('node-1', nodeData1, inputParams) - }) + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) - expect(result.current.state.editingNodeId).toBe('node-1') - expect(result.current.state.editDialogProps).not.toBeNull() - expect(result.current.state.editDialogProps!.data).toBeDefined() - expect(result.current.state.editDialogProps!.data!.label).toBe('Test Node 1') + const nodeData = { + id: 'node-1', + name: 'testNode', + label: 'Test Node', + outputAnchors: [] + } - // Open dialog for node-2 (should replace node-1) - act(() => { - result.current.openEditDialog('node-2', nodeData2, inputParams) - }) + const inputParams = [ + { + id: 'param-1', + name: 'param1', + label: 'Parameter 1', + type: 'string' + } + ] - expect(result.current.state.editingNodeId).toBe('node-2') - expect(result.current.state.editDialogProps).not.toBeNull() - expect(result.current.state.editDialogProps!.data).toBeDefined() - expect(result.current.state.editDialogProps!.data!.label).toBe('Test Node 2') - }) + // First open the dialog + act(() => { + result.current.openEditDialog('node-1', nodeData, inputParams) + }) - it('should set disabled to false in dialog props', () => { - const initialFlow: FlowData = { - nodes: [makeNode('node-1')], - edges: [] - } + // Verify dialog is open + expect(result.current.state.editingNodeId).toBe('node-1') + expect(result.current.state.editDialogProps).not.toBeNull() - const { result } = renderHook(() => useAgentflowContext(), { - wrapper: createWrapper(initialFlow) - }) + // Close the dialog + act(() => { + result.current.closeEditDialog() + }) - const nodeData = { - id: 'node-1', - name: 'testNode', - label: 'Test Node', - outputAnchors: [] - } + // Should clear editingNodeId + expect(result.current.state.editingNodeId).toBeNull() - act(() => { - result.current.openEditDialog('node-1', nodeData, []) + // Should clear editDialogProps + expect(result.current.state.editDialogProps).toBeNull() }) - // disabled should always be false - expect(result.current.state.editDialogProps?.disabled).toBe(false) - }) + it('should handle opening dialog for different nodes', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1'), makeNode('node-2')], + edges: [] + } - it('should preserve inputParams in dialog props', () => { - const initialFlow: FlowData = { - nodes: [makeNode('node-1')], - edges: [] - } + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) - const { result } = renderHook(() => useAgentflowContext(), { - wrapper: createWrapper(initialFlow) - }) + const nodeData1 = { + id: 'node-1', + name: 'testNode1', + label: 'Test Node 1', + outputAnchors: [] + } - const nodeData = { - id: 'node-1', - name: 'testNode', - label: 'Test Node', - outputAnchors: [] - } - - const inputParams = [ - { - id: 'param-1', - name: 'param1', - label: 'Parameter 1', - type: 'string', - optional: true - }, - { - id: 'param-2', - name: 'param2', - label: 'Parameter 2', - type: 'number', - default: 42 + const nodeData2 = { + id: 'node-2', + name: 'testNode2', + label: 'Test Node 2', + outputAnchors: [] } - ] - act(() => { - result.current.openEditDialog('node-1', nodeData, inputParams) - }) + const inputParams = [ + { + id: 'param-1', + name: 'param1', + label: 'Parameter 1', + type: 'string' + } + ] - // Should preserve all input params with their properties - expect(result.current.state.editDialogProps).not.toBeNull() - expect(result.current.state.editDialogProps!.inputParams).toEqual(inputParams) - expect(result.current.state.editDialogProps!.inputParams).toHaveLength(2) + // Open dialog for node-1 + act(() => { + result.current.openEditDialog('node-1', nodeData1, inputParams) + }) - const params = result.current.state.editDialogProps!.inputParams! - expect(params[0]).toBeDefined() - expect(params[0]!.optional).toBe(true) - expect(params[1]).toBeDefined() - expect(params[1]!.default).toBe(42) - }) -}) + expect(result.current.state.editingNodeId).toBe('node-1') + expect(result.current.state.editDialogProps).not.toBeNull() + expect(result.current.state.editDialogProps!.data).toBeDefined() + expect(result.current.state.editDialogProps!.data!.label).toBe('Test Node 1') -describe('AgentflowContext - state synchronization', () => { - it('should call local state setters for setNodes', () => { - const initialFlow: FlowData = { - nodes: [ - { - id: 'node-1', - type: 'agentflowNode', - position: { x: 100, y: 100 }, - data: { - id: 'node-1', - name: 'Node 1', - label: 'Node 1', - outputAnchors: [] - } - } - ], - edges: [] - } + // Open dialog for node-2 (should replace node-1) + act(() => { + result.current.openEditDialog('node-2', nodeData2, inputParams) + }) - const { result } = renderHook(() => useAgentflowContext(), { - wrapper: createWrapper(initialFlow) + expect(result.current.state.editingNodeId).toBe('node-2') + expect(result.current.state.editDialogProps).not.toBeNull() + expect(result.current.state.editDialogProps!.data).toBeDefined() + expect(result.current.state.editDialogProps!.data!.label).toBe('Test Node 2') }) - const mockSetLocalNodes = jest.fn() - const mockSetLocalEdges = jest.fn() + it('should set disabled to false in dialog props', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1')], + edges: [] + } - act(() => { - result.current.registerLocalStateSetters(mockSetLocalNodes, mockSetLocalEdges) - }) + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) - const newNodes: FlowNode[] = [ - { - id: 'node-2', - type: 'agentflowNode', - position: { x: 200, y: 200 }, - data: { - id: 'node-2', - name: 'Node 2', - label: 'Node 2', - outputAnchors: [] - } - }, - { - id: 'node-3', - type: 'agentflowNode', - position: { x: 300, y: 300 }, - data: { - id: 'node-3', - name: 'Node 3', - label: 'Node 3', - outputAnchors: [] - } + const nodeData = { + id: 'node-1', + name: 'testNode', + label: 'Test Node', + outputAnchors: [] } - ] - act(() => { - result.current.setNodes(newNodes) + act(() => { + result.current.openEditDialog('node-1', nodeData, []) + }) + + // disabled should always be false + expect(result.current.state.editDialogProps?.disabled).toBe(false) }) - // Verify local state setter was called - expect(mockSetLocalNodes).toHaveBeenCalledTimes(1) - expect(mockSetLocalNodes).toHaveBeenCalledWith( - expect.arrayContaining([expect.objectContaining({ id: 'node-2' }), expect.objectContaining({ id: 'node-3' })]) - ) + it('should preserve inputParams in dialog props', () => { + const initialFlow: FlowData = { + nodes: [makeNode('node-1')], + edges: [] + } - // Verify context state was updated - expect(result.current.state.nodes).toHaveLength(2) - expect(result.current.state.nodes[0].id).toBe('node-2') - expect(result.current.state.nodes[1].id).toBe('node-3') - }) + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + const nodeData = { + id: 'node-1', + name: 'testNode', + label: 'Test Node', + outputAnchors: [] + } - it('should call local state setters for setEdges', () => { - const initialFlow: FlowData = { - nodes: [ + const inputParams = [ { - id: 'node-1', - type: 'agentflowNode', - position: { x: 100, y: 100 }, - data: { - id: 'node-1', - name: 'Node 1', - label: 'Node 1', - outputAnchors: [] - } + id: 'param-1', + name: 'param1', + label: 'Parameter 1', + type: 'string', + optional: true }, { - id: 'node-2', - type: 'agentflowNode', - position: { x: 200, y: 200 }, - data: { - id: 'node-2', - name: 'Node 2', - label: 'Node 2', - outputAnchors: [] - } + id: 'param-2', + name: 'param2', + label: 'Parameter 2', + type: 'number', + default: 42 } - ], - edges: [] - } + ] - const { result } = renderHook(() => useAgentflowContext(), { - wrapper: createWrapper(initialFlow) - }) + act(() => { + result.current.openEditDialog('node-1', nodeData, inputParams) + }) - const mockSetLocalNodes = jest.fn() - const mockSetLocalEdges = jest.fn() + // Should preserve all input params with their properties + expect(result.current.state.editDialogProps).not.toBeNull() + expect(result.current.state.editDialogProps!.inputParams).toEqual(inputParams) + expect(result.current.state.editDialogProps!.inputParams).toHaveLength(2) - act(() => { - result.current.registerLocalStateSetters(mockSetLocalNodes, mockSetLocalEdges) + const params = result.current.state.editDialogProps!.inputParams! + expect(params[0]).toBeDefined() + expect(params[0]!.optional).toBe(true) + expect(params[1]).toBeDefined() + expect(params[1]!.default).toBe(42) }) + }) - const newEdges: FlowEdge[] = [ - { - id: 'edge-1', - source: 'node-1', - target: 'node-2', - type: 'agentflowEdge' - }, - { - id: 'edge-2', - source: 'node-2', - target: 'node-1', - type: 'agentflowEdge' - } - ] + describe('state synchronization', () => { + let mockSetLocalNodes: jest.Mock + let mockSetLocalEdges: jest.Mock - act(() => { - result.current.setEdges(newEdges) + beforeEach(() => { + mockSetLocalNodes = jest.fn() + mockSetLocalEdges = jest.fn() }) - // Verify local state setter was called - expect(mockSetLocalEdges).toHaveBeenCalledTimes(1) - expect(mockSetLocalEdges).toHaveBeenCalledWith( - expect.arrayContaining([expect.objectContaining({ id: 'edge-1' }), expect.objectContaining({ id: 'edge-2' })]) - ) + it('should call local state setters for setNodes', () => { + const initialFlow: FlowData = { + nodes: [ + { + id: 'node-1', + type: 'agentflowNode', + position: { x: 100, y: 100 }, + data: { + id: 'node-1', + name: 'Node 1', + label: 'Node 1', + outputAnchors: [] + } + } + ], + edges: [] + } + + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) - // Verify context state was updated - expect(result.current.state.edges).toHaveLength(2) - expect(result.current.state.edges[0].id).toBe('edge-1') - expect(result.current.state.edges[1].id).toBe('edge-2') - }) + act(() => { + result.current.registerLocalStateSetters(mockSetLocalNodes, mockSetLocalEdges) + }) - it('should call local state setters for updateNodeData', () => { - const initialFlow: FlowData = { - nodes: [ + const newNodes: FlowNode[] = [ { - id: 'node-1', + id: 'node-2', type: 'agentflowNode', - position: { x: 100, y: 100 }, + position: { x: 200, y: 200 }, data: { - id: 'node-1', - name: 'Node 1', - label: 'Node 1', + id: 'node-2', + name: 'Node 2', + label: 'Node 2', outputAnchors: [] } }, { - id: 'node-2', + id: 'node-3', type: 'agentflowNode', - position: { x: 200, y: 200 }, + position: { x: 300, y: 300 }, data: { - id: 'node-2', - name: 'Node 2', - label: 'Node 2', + id: 'node-3', + name: 'Node 3', + label: 'Node 3', outputAnchors: [] } } - ], - edges: [] - } - - const { result } = renderHook(() => useAgentflowContext(), { - wrapper: createWrapper(initialFlow) - }) - - const mockSetLocalNodes = jest.fn() - const mockSetLocalEdges = jest.fn() + ] - act(() => { - result.current.registerLocalStateSetters(mockSetLocalNodes, mockSetLocalEdges) - }) + act(() => { + result.current.setNodes(newNodes) + }) - const updatedData = { - label: 'Updated Node 1', - name: 'updated-node-1' - } + // Verify local state setter was called + expect(mockSetLocalNodes).toHaveBeenCalledTimes(1) + expect(mockSetLocalNodes).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ id: 'node-2' }), expect.objectContaining({ id: 'node-3' })]) + ) - act(() => { - result.current.updateNodeData('node-1', updatedData) + // Verify context state was updated + expect(result.current.state.nodes).toHaveLength(2) + expect(result.current.state.nodes[0].id).toBe('node-2') + expect(result.current.state.nodes[1].id).toBe('node-3') }) - // Verify local state setter was called - expect(mockSetLocalNodes).toHaveBeenCalledTimes(1) - expect(mockSetLocalNodes).toHaveBeenCalledWith( - expect.arrayContaining([ - expect.objectContaining({ - id: 'node-1', - data: expect.objectContaining({ - label: 'Updated Node 1', - name: 'updated-node-1' - }) - }), - expect.objectContaining({ id: 'node-2' }) - ]) - ) - - // Verify context state was updated - const updatedNode = result.current.state.nodes.find((n) => n.id === 'node-1') - expect(updatedNode?.data.label).toBe('Updated Node 1') - expect(updatedNode?.data.name).toBe('updated-node-1') - - // Verify other node was not affected - const otherNode = result.current.state.nodes.find((n) => n.id === 'node-2') - expect(otherNode?.data.label).toBe('Node 2') - }) - - it('should call local state setters for deleteEdge', () => { - const initialFlow: FlowData = { - nodes: [ - { - id: 'node-1', - type: 'agentflowNode', - position: { x: 100, y: 100 }, - data: { + it('should call local state setters for setEdges', () => { + const initialFlow: FlowData = { + nodes: [ + { id: 'node-1', - name: 'Node 1', - label: 'Node 1', - outputAnchors: [] - } - }, - { - id: 'node-2', - type: 'agentflowNode', - position: { x: 200, y: 200 }, - data: { + type: 'agentflowNode', + position: { x: 100, y: 100 }, + data: { + id: 'node-1', + name: 'Node 1', + label: 'Node 1', + outputAnchors: [] + } + }, + { id: 'node-2', - name: 'Node 2', - label: 'Node 2', - outputAnchors: [] + type: 'agentflowNode', + position: { x: 200, y: 200 }, + data: { + id: 'node-2', + name: 'Node 2', + label: 'Node 2', + outputAnchors: [] + } } - } - ], - edges: [ + ], + edges: [] + } + + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + act(() => { + result.current.registerLocalStateSetters(mockSetLocalNodes, mockSetLocalEdges) + }) + + const newEdges: FlowEdge[] = [ { id: 'edge-1', source: 'node-1', @@ -962,99 +1122,222 @@ describe('AgentflowContext - state synchronization', () => { type: 'agentflowEdge' } ] - } - const { result } = renderHook(() => useAgentflowContext(), { - wrapper: createWrapper(initialFlow) - }) + act(() => { + result.current.setEdges(newEdges) + }) - const mockSetLocalNodes = jest.fn() - const mockSetLocalEdges = jest.fn() + // Verify local state setter was called + expect(mockSetLocalEdges).toHaveBeenCalledTimes(1) + expect(mockSetLocalEdges).toHaveBeenCalledWith( + expect.arrayContaining([expect.objectContaining({ id: 'edge-1' }), expect.objectContaining({ id: 'edge-2' })]) + ) - act(() => { - result.current.registerLocalStateSetters(mockSetLocalNodes, mockSetLocalEdges) + // Verify context state was updated + expect(result.current.state.edges).toHaveLength(2) + expect(result.current.state.edges[0].id).toBe('edge-1') + expect(result.current.state.edges[1].id).toBe('edge-2') }) - act(() => { - result.current.deleteEdge('edge-1') - }) + it('should call local state setters for updateNodeData', () => { + const initialFlow: FlowData = { + nodes: [ + { + id: 'node-1', + type: 'agentflowNode', + position: { x: 100, y: 100 }, + data: { + id: 'node-1', + name: 'Node 1', + label: 'Node 1', + outputAnchors: [] + } + }, + { + id: 'node-2', + type: 'agentflowNode', + position: { x: 200, y: 200 }, + data: { + id: 'node-2', + name: 'Node 2', + label: 'Node 2', + outputAnchors: [] + } + } + ], + edges: [] + } - // Verify local state setter was called - expect(mockSetLocalEdges).toHaveBeenCalledTimes(1) - expect(mockSetLocalEdges).toHaveBeenCalledWith(expect.arrayContaining([expect.objectContaining({ id: 'edge-2' })])) + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) - // Verify context state was updated - expect(result.current.state.edges).toHaveLength(1) - expect(result.current.state.edges[0].id).toBe('edge-2') - }) + act(() => { + result.current.registerLocalStateSetters(mockSetLocalNodes, mockSetLocalEdges) + }) - it('should synchronize state for combined operations', () => { - const initialFlow: FlowData = { - nodes: [ - { - id: 'node-1', - type: 'agentflowNode', - position: { x: 100, y: 100 }, - data: { + const updatedData = { + label: 'Updated Node 1', + name: 'updated-node-1' + } + + act(() => { + result.current.updateNodeData('node-1', updatedData) + }) + + // Verify local state setter was called + expect(mockSetLocalNodes).toHaveBeenCalledTimes(1) + expect(mockSetLocalNodes).toHaveBeenCalledWith( + expect.arrayContaining([ + expect.objectContaining({ id: 'node-1', - name: 'Node 1', - label: 'Node 1', - outputAnchors: [] + data: expect.objectContaining({ + label: 'Updated Node 1', + name: 'updated-node-1' + }) + }), + expect.objectContaining({ id: 'node-2' }) + ]) + ) + + // Verify context state was updated + const updatedNode = result.current.state.nodes.find((n) => n.id === 'node-1') + expect(updatedNode?.data.label).toBe('Updated Node 1') + expect(updatedNode?.data.name).toBe('updated-node-1') + + // Verify other node was not affected + const otherNode = result.current.state.nodes.find((n) => n.id === 'node-2') + expect(otherNode?.data.label).toBe('Node 2') + }) + + it('should call local state setters for deleteEdge', () => { + const initialFlow: FlowData = { + nodes: [ + { + id: 'node-1', + type: 'agentflowNode', + position: { x: 100, y: 100 }, + data: { + id: 'node-1', + name: 'Node 1', + label: 'Node 1', + outputAnchors: [] + } + }, + { + id: 'node-2', + type: 'agentflowNode', + position: { x: 200, y: 200 }, + data: { + id: 'node-2', + name: 'Node 2', + label: 'Node 2', + outputAnchors: [] + } } - } - ], - edges: [] - } + ], + edges: [ + { + id: 'edge-1', + source: 'node-1', + target: 'node-2', + type: 'agentflowEdge' + }, + { + id: 'edge-2', + source: 'node-2', + target: 'node-1', + type: 'agentflowEdge' + } + ] + } - const { result } = renderHook(() => useAgentflowContext(), { - wrapper: createWrapper(initialFlow) - }) + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + act(() => { + result.current.registerLocalStateSetters(mockSetLocalNodes, mockSetLocalEdges) + }) - const mockSetLocalNodes = jest.fn() - const mockSetLocalEdges = jest.fn() + act(() => { + result.current.deleteEdge('edge-1') + }) - act(() => { - result.current.registerLocalStateSetters(mockSetLocalNodes, mockSetLocalEdges) + // Verify local state setter was called + expect(mockSetLocalEdges).toHaveBeenCalledTimes(1) + expect(mockSetLocalEdges).toHaveBeenCalledWith(expect.arrayContaining([expect.objectContaining({ id: 'edge-2' })])) + + // Verify context state was updated + expect(result.current.state.edges).toHaveLength(1) + expect(result.current.state.edges[0].id).toBe('edge-2') }) - // 1. Add a new node via setNodes - act(() => { - result.current.setNodes([ - ...result.current.state.nodes, - { - id: 'node-2', - type: 'agentflowNode', - position: { x: 200, y: 200 }, - data: { + it('should synchronize state for combined operations', () => { + const initialFlow: FlowData = { + nodes: [ + { + id: 'node-1', + type: 'agentflowNode', + position: { x: 100, y: 100 }, + data: { + id: 'node-1', + name: 'Node 1', + label: 'Node 1', + outputAnchors: [] + } + } + ], + edges: [] + } + + const { result } = renderHook(() => useAgentflowContext(), { + wrapper: createWrapper(initialFlow) + }) + + act(() => { + result.current.registerLocalStateSetters(mockSetLocalNodes, mockSetLocalEdges) + }) + + // 1. Add a new node via setNodes + act(() => { + result.current.setNodes([ + ...result.current.state.nodes, + { id: 'node-2', - name: 'Node 2', - label: 'Node 2', - outputAnchors: [] + type: 'agentflowNode', + position: { x: 200, y: 200 }, + data: { + id: 'node-2', + name: 'Node 2', + label: 'Node 2', + outputAnchors: [] + } } - } - ]) - }) + ]) + }) - expect(mockSetLocalNodes).toHaveBeenCalledTimes(1) + expect(mockSetLocalNodes).toHaveBeenCalledTimes(1) - // 2. Duplicate node-1 - act(() => { - result.current.duplicateNode('node-1') - }) + // 2. Duplicate node-1 + act(() => { + result.current.duplicateNode('node-1') + }) - expect(mockSetLocalNodes).toHaveBeenCalledTimes(2) + expect(mockSetLocalNodes).toHaveBeenCalledTimes(2) - // 3. Update node-2 data - act(() => { - result.current.updateNodeData('node-2', { label: 'Updated Node 2' }) - }) + // 3. Update node-2 data + act(() => { + result.current.updateNodeData('node-2', { label: 'Updated Node 2' }) + }) - expect(mockSetLocalNodes).toHaveBeenCalledTimes(3) + expect(mockSetLocalNodes).toHaveBeenCalledTimes(3) - // Verify final state - expect(result.current.state.nodes).toHaveLength(3) - expect(result.current.state.nodes.find((n) => n.id === 'node-1')).toBeDefined() - expect(result.current.state.nodes.find((n) => n.id === 'node-2')?.data.label).toBe('Updated Node 2') - expect(result.current.state.nodes.find((n) => n.id === 'node-1_copy_1')).toBeDefined() + // Verify final state + expect(result.current.state.nodes).toHaveLength(3) + expect(result.current.state.nodes.find((n) => n.id === 'node-1')).toBeDefined() + expect(result.current.state.nodes.find((n) => n.id === 'node-2')?.data.label).toBe('Updated Node 2') + expect(result.current.state.nodes.find((n) => n.id === 'Node 1_0')).toBeDefined() + }) }) }) diff --git a/packages/agentflow/src/infrastructure/store/AgentflowContext.tsx b/packages/agentflow/src/infrastructure/store/AgentflowContext.tsx index 6742492c7ac..e4c2e7bd0c2 100644 --- a/packages/agentflow/src/infrastructure/store/AgentflowContext.tsx +++ b/packages/agentflow/src/infrastructure/store/AgentflowContext.tsx @@ -1,10 +1,40 @@ import { createContext, Dispatch, ReactNode, useCallback, useContext, useReducer, useRef } from 'react' import type { ReactFlowInstance } from 'reactflow' +import { cloneDeep } from 'lodash' + import type { AgentflowAction, AgentflowState, FlowConfig, FlowData, FlowEdge, FlowNode, InputParam, NodeData } from '@/core/types' +import { getUniqueNodeId } from '@/core/utils' import { agentflowReducer, initialState, normalizeNodes } from './agentflowReducer' +// ======================================== +// Helper Functions +// ======================================== + +/** + * Check if a value is a connection string (e.g., "{{nodeId.data.instance}}") + */ +function isConnectionString(value: unknown): boolean { + return typeof value === 'string' && value.startsWith('{{') && value.endsWith('}}') +} + +/** + * Update IDs in anchor arrays to match a new node ID + */ +function updateAnchorIds(items: unknown, oldId: string, newId: string): void { + if (!Array.isArray(items)) return + for (const item of items) { + if (item?.id) { + item.id = item.id.replace(oldId, newId) + } + } +} + +// ======================================== +// Types +// ======================================== + // Local state setter types type NodesSetter = (nodes: FlowNode[]) => void type EdgesSetter = (edges: FlowEdge[]) => void @@ -25,7 +55,7 @@ export interface AgentflowContextValue { // Node operations deleteNode: (nodeId: string) => void - duplicateNode: (nodeId: string) => void + duplicateNode: (nodeId: string, distance?: number) => void updateNodeData: (nodeId: string, data: Partial) => void // Edge operations @@ -66,19 +96,6 @@ export function AgentflowStateProvider({ children, initialFlow }: AgentflowState localEdgesSetterRef.current = setLocalEdges }, []) - // Helper function to generate unique copy IDs - const getUniqueCopyId = useCallback((baseId: string, nodes: FlowNode[]): string => { - const existingIds = new Set(nodes.map((node) => node.id)) - for (let i = 1; i < Number.MAX_SAFE_INTEGER; i++) { - const newId = `${baseId}_copy_${i}` - if (!existingIds.has(newId)) { - return newId - } - } - //Fallback - return `${baseId}_copy_${Date.now()}` - }, []) - // Helper function to synchronize state updates between context and ReactFlow const syncStateUpdate = useCallback(({ nodes, edges }: { nodes?: FlowNode[]; edges?: FlowEdge[] }) => { if (nodes !== undefined) { @@ -135,35 +152,61 @@ export function AgentflowStateProvider({ children, initialFlow }: AgentflowState (nodeId: string) => { const newNodes = state.nodes.filter((node) => node.id !== nodeId) const newEdges = state.edges.filter((edge) => edge.source !== nodeId && edge.target !== nodeId) - syncStateUpdate({ nodes: newNodes, edges: newEdges }) }, [state.nodes, state.edges, syncStateUpdate] ) const duplicateNode = useCallback( - (nodeId: string) => { + (nodeId: string, distance = 50) => { const nodeToDuplicate = state.nodes.find((node) => node.id === nodeId) if (!nodeToDuplicate) return - const newNodeId = getUniqueCopyId(nodeToDuplicate.id, state.nodes) + const newNodeId = getUniqueNodeId(nodeToDuplicate.data, state.nodes) + const nodeWidth = nodeToDuplicate.width ?? 300 + + // Deep clone to avoid mutating the original node's nested objects + const clonedNode = cloneDeep(nodeToDuplicate) + const newNode: FlowNode = { - ...nodeToDuplicate, + ...clonedNode, id: newNodeId, position: { - x: nodeToDuplicate.position.x + 50, - y: nodeToDuplicate.position.y + 50 + x: clonedNode.position.x + nodeWidth + distance, + y: clonedNode.position.y }, data: { - ...nodeToDuplicate.data, - id: newNodeId + ...clonedNode.data, + id: newNodeId, + label: clonedNode.data.label + ` (${newNodeId.split('_').pop()})` + }, + selected: false + } + + // Update IDs in all anchor arrays to match new node ID + updateAnchorIds(newNode.data.inputs, nodeId, newNodeId) + updateAnchorIds(newNode.data.inputAnchors, nodeId, newNodeId) + updateAnchorIds(newNode.data.outputAnchors, nodeId, newNodeId) + + // Clear connected input values by resetting to defaults + if (newNode.data.inputValues) { + for (const inputName in newNode.data.inputValues) { + const value = newNode.data.inputValues[inputName] + + if (isConnectionString(value)) { + // Reset string connections to parameter default + const inputParam = newNode.data.inputs?.find((p) => p.name === inputName) + newNode.data.inputValues[inputName] = inputParam?.default ?? '' + } else if (Array.isArray(value)) { + // Filter out connection strings from arrays + newNode.data.inputValues[inputName] = value.filter((item) => !isConnectionString(item)) + } } } - const newNodes = [...state.nodes, newNode] - syncStateUpdate({ nodes: newNodes }) + syncStateUpdate({ nodes: [...state.nodes, newNode] }) }, - [state.nodes, syncStateUpdate, getUniqueCopyId] + [state.nodes, syncStateUpdate] ) const updateNodeData = useCallback(