Skip to content

Commit 7de9e3e

Browse files
fix/agentflow: Update default values and auto-add Start node for empty state (#6210)
- Implemented auto-add functionality for Start node in Agentflow when creating a new canvas. - Updated default values for number and options types to return empty strings instead of zero or first option name. - Adjusted MessagesInput and ArrayInput tests to reflect changes in default values.
1 parent faaaa11 commit 7de9e3e

9 files changed

Lines changed: 162 additions & 52 deletions

File tree

packages/agentflow/examples/src/demos/CustomUIExample.tsx

Lines changed: 2 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -12,26 +12,6 @@ import { Agentflow } from '@flowiseai/agentflow'
1212

1313
import { apiBaseUrl, token } from '../config'
1414

15-
const initialFlow: FlowData = {
16-
nodes: [
17-
{
18-
id: 'startAgentflow_0',
19-
type: 'agentflowNode',
20-
position: { x: 300, y: 200 },
21-
data: {
22-
id: 'startAgentflow_0',
23-
name: 'startAgentflow',
24-
label: 'Start',
25-
color: '#7EE787',
26-
hideInput: true,
27-
outputAnchors: [{ id: 'startAgentflow_0-output-0', name: 'start', label: 'Start', type: 'start' }]
28-
}
29-
}
30-
],
31-
edges: [],
32-
viewport: { x: 0, y: 0, zoom: 1 }
33-
}
34-
3515
// Custom header component
3616
function CustomHeader({ flowName, isDirty, onSave, onExport, onValidate }: HeaderRenderProps) {
3717
const [validationStatus, setValidationStatus] = useState<'valid' | 'invalid' | null>(null)
@@ -259,11 +239,11 @@ export function CustomUIExample() {
259239
ref={agentflowRef}
260240
apiBaseUrl={apiBaseUrl}
261241
token={token ?? undefined}
262-
initialFlow={initialFlow}
263242
renderHeader={(props: HeaderRenderProps) => <CustomHeader {...props} />}
264243
renderNodePalette={(props: PaletteRenderProps) => <CustomPalette {...props} />}
265244
showDefaultHeader={false}
266245
showDefaultPalette={false}
246+
enableGenerator={false}
267247
onSave={(flow: FlowData) => {
268248
console.log('Saving flow:', flow)
269249
alert('Flow saved! Check console.')
@@ -277,10 +257,10 @@ export function CustomUIExample() {
277257
export const CustomUIExampleProps = {
278258
apiBaseUrl: '{from environment variables}',
279259
token: '{from environment variables}',
280-
initialFlow: 'FlowData',
281260
renderHeader: '(props: HeaderRenderProps) => ReactNode',
282261
renderNodePalette: '(props: PaletteRenderProps) => ReactNode',
283262
showDefaultHeader: false,
284263
showDefaultPalette: false,
264+
enableGenerator: false,
285265
onSave: '(flow: FlowData) => void'
286266
}

packages/agentflow/src/Agentflow.test.tsx

Lines changed: 106 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,14 +6,17 @@
66
import { createRef } from 'react'
77

88
import { fireEvent, render, waitFor } from '@testing-library/react'
9+
import mockAxios from 'axios'
910

10-
import type { AgentFlowInstance, FlowData } from './core/types'
11+
import type { AgentFlowInstance, AgentflowProps, FlowData } from './core/types'
1112
import { Agentflow } from './Agentflow'
1213

1314
// Mock external dependencies - implementations in __mocks__/
1415
jest.mock('reactflow')
1516
jest.mock('axios')
1617

18+
const mockGet = mockAxios.get as jest.Mock
19+
1720
// Mock GenerateFlowDialog to expose callbacks for testing
1821
jest.mock('./features/generator', () => ({
1922
GenerateFlowDialog: ({
@@ -482,4 +485,106 @@ describe('Agentflow Component', () => {
482485
expect(document.getElementById('agentflow-css-variables')).not.toBeInTheDocument()
483486
})
484487
})
488+
489+
describe('Auto Start Node Initialization', () => {
490+
const startNodeSchema = {
491+
name: 'startAgentflow',
492+
label: 'Start',
493+
type: 'Start',
494+
category: 'Agent Flows',
495+
description: 'Start node',
496+
baseClasses: ['Start'],
497+
inputs: [],
498+
outputs: [],
499+
version: 1
500+
}
501+
502+
beforeEach(() => {
503+
// Mock API to return a startAgentflow node definition
504+
mockGet.mockImplementation((url: string) => {
505+
if (typeof url === 'string' && url.includes('/nodes')) {
506+
return Promise.resolve({ data: [startNodeSchema] })
507+
}
508+
return Promise.resolve({ data: [] })
509+
})
510+
})
511+
512+
afterEach(() => {
513+
// Restore default mock (empty array)
514+
mockGet.mockImplementation(() => Promise.resolve({ data: [] }))
515+
})
516+
517+
/** Helper: render without initialFlow and assert Start node via ref */
518+
const renderAndGetNodes = async (initialFlow?: FlowData | null) => {
519+
const ref = createRef<AgentFlowInstance>()
520+
const props: Record<string, unknown> = { apiBaseUrl: 'https://example.com', ref }
521+
if (initialFlow !== undefined) props.initialFlow = initialFlow
522+
523+
render(<Agentflow {...(props as unknown as AgentflowProps)} />)
524+
525+
await waitFor(() => {
526+
expect(ref.current).toBeDefined()
527+
const flow = ref.current!.getFlow()
528+
expect(flow.nodes.length).toBeGreaterThan(0)
529+
})
530+
return ref.current!.getFlow().nodes
531+
}
532+
533+
it('should auto-add Start node when initialFlow is undefined', async () => {
534+
const nodes = await renderAndGetNodes()
535+
expect(nodes).toHaveLength(1)
536+
expect(nodes[0].id).toBe('startAgentflow_0')
537+
expect(nodes[0].data.name).toBe('startAgentflow')
538+
expect(nodes[0].data.label).toBe('Start')
539+
})
540+
541+
it('should auto-add Start node when initialFlow is null', async () => {
542+
const nodes = await renderAndGetNodes(null)
543+
expect(nodes).toHaveLength(1)
544+
expect(nodes[0].data.name).toBe('startAgentflow')
545+
})
546+
547+
it('should auto-add Start node when initialFlow has empty nodes', async () => {
548+
const emptyFlow: FlowData = { nodes: [], edges: [], viewport: { x: 0, y: 0, zoom: 1 } }
549+
const nodes = await renderAndGetNodes(emptyFlow)
550+
expect(nodes).toHaveLength(1)
551+
expect(nodes[0].data.name).toBe('startAgentflow')
552+
})
553+
554+
it('should auto-add Start node when initialFlow is empty object', async () => {
555+
const nodes = await renderAndGetNodes({} as FlowData)
556+
expect(nodes).toHaveLength(1)
557+
expect(nodes[0].data.name).toBe('startAgentflow')
558+
})
559+
560+
it('should handle initialFlow with unrelated fields without errors', async () => {
561+
const illegalFlow = { foo: 'bar', baz: 123 } as unknown as FlowData
562+
const nodes = await renderAndGetNodes(illegalFlow)
563+
564+
expect(nodes).toHaveLength(1)
565+
expect(nodes[0].data.name).toBe('startAgentflow')
566+
})
567+
568+
it('should handle initialFlow with wrong types for nodes/edges without crashing', async () => {
569+
const illegalFlow = { nodes: 'not-an-array', edges: 42 } as unknown as FlowData
570+
const nodes = await renderAndGetNodes(illegalFlow)
571+
572+
// Non-array nodes/edges are safely ignored, auto-init adds Start node
573+
expect(nodes).toHaveLength(1)
574+
expect(nodes[0].data.name).toBe('startAgentflow')
575+
})
576+
577+
it('should not auto-add Start node when initialFlow has nodes', async () => {
578+
const ref = createRef<AgentFlowInstance>()
579+
render(<Agentflow {...defaultProps} initialFlow={mockFlow} ref={ref} />)
580+
581+
await waitFor(() => {
582+
expect(ref.current).toBeDefined()
583+
})
584+
const nodes = ref.current!.getFlow().nodes
585+
// Should only have the one node from mockFlow, no duplicate Start added
586+
const startNodes = nodes.filter((n) => n.data.name === 'startAgentflow')
587+
expect(startNodes).toHaveLength(1)
588+
})
589+
})
485590
})

packages/agentflow/src/Agentflow.tsx

Lines changed: 28 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { IconSparkles } from '@tabler/icons-react'
66

77
import { tokens } from './core/theme'
88
import type { AgentFlowInstance, AgentflowProps, FlowData, FlowDataCallback, FlowEdge, FlowNode } from './core/types'
9+
import { initNode, resolveNodeType } from './core/utils'
910
import { applyValidationErrorsToNodes, validateFlow } from './core/validation'
1011
import {
1112
AgentflowHeader,
@@ -80,8 +81,10 @@ function AgentflowCanvas({
8081
}
8182
}, [isDarkMode])
8283

83-
const [nodes, setLocalNodes, onNodesChange] = useNodesState(initialFlow?.nodes || [])
84-
const [edges, setLocalEdges, onEdgesChange] = useEdgesState(initialFlow?.edges || [])
84+
const safeInitialNodes = Array.isArray(initialFlow?.nodes) ? initialFlow.nodes : []
85+
const safeInitialEdges = Array.isArray(initialFlow?.edges) ? initialFlow.edges : []
86+
const [nodes, setLocalNodes, onNodesChange] = useNodesState(safeInitialNodes)
87+
const [edges, setLocalEdges, onEdgesChange] = useEdgesState(safeInitialEdges)
8588
const [showGenerateDialog, setShowGenerateDialog] = useState(false)
8689

8790
// Constraint violation snackbar state
@@ -98,6 +101,29 @@ function AgentflowCanvas({
98101
// Load available nodes
99102
const { availableNodes } = useFlowNodes()
100103

104+
// Auto-add Start node when creating a new (empty) canvas.
105+
// Only runs once: when availableNodes first loads and the canvas has no initial flow.
106+
const hasInitialFlow = safeInitialNodes.length > 0
107+
const startNodeInitialized = useRef(false)
108+
useEffect(() => {
109+
if (hasInitialFlow || startNodeInitialized.current) return
110+
if (availableNodes.length === 0) return
111+
112+
const startNodeDef = availableNodes.find((n) => n.name === 'startAgentflow')
113+
if (!startNodeDef) return
114+
115+
startNodeInitialized.current = true
116+
const startNodeId = 'startAgentflow_0'
117+
const startNodeData = initNode(startNodeDef, startNodeId, true)
118+
const startNode: FlowNode = {
119+
id: startNodeId,
120+
type: resolveNodeType(startNodeDef.type ?? ''),
121+
position: { x: 100, y: 100 },
122+
data: { ...startNodeData, label: 'Start' }
123+
}
124+
setLocalNodes([startNode])
125+
}, [hasInitialFlow, availableNodes, setLocalNodes])
126+
101127
// Register local state setters with context on mount
102128
useEffect(() => {
103129
registerLocalStateSetters(setLocalNodes, setLocalEdges)

packages/agentflow/src/atoms/ArrayInput.test.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -302,7 +302,7 @@ describe('ArrayInput', () => {
302302

303303
expect(mockOnDataChange).toHaveBeenCalledWith({
304304
inputParam: inputParamWithTypes,
305-
newValue: [{ str: '', num: 0, bool: false, arr: [] }]
305+
newValue: [{ str: '', num: '', bool: false, arr: [] }]
306306
})
307307
})
308308

packages/agentflow/src/atoms/MessagesInput.test.tsx

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -136,14 +136,14 @@ describe('MessagesInput', () => {
136136

137137
// --- Add ---
138138

139-
it('should add a new message with default role "user" and empty content', () => {
139+
it('should add a new message with empty role and empty content', () => {
140140
render(<MessagesInput inputParam={mockInputParam} data={mockNodeData} onDataChange={mockOnDataChange} />)
141141

142142
fireEvent.click(screen.getByRole('button', { name: /Add Messages/i }))
143143

144144
expect(mockOnDataChange).toHaveBeenCalledWith({
145145
inputParam: mockInputParam,
146-
newValue: [{ role: 'user', content: '' }]
146+
newValue: [{ role: '', content: '' }]
147147
})
148148
})
149149

@@ -163,7 +163,7 @@ describe('MessagesInput', () => {
163163
inputParam: mockInputParam,
164164
newValue: [
165165
{ role: 'system', content: 'Hello' },
166-
{ role: 'user', content: '' }
166+
{ role: '', content: '' }
167167
]
168168
})
169169
})

packages/agentflow/src/atoms/MessagesInput.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -22,7 +22,7 @@ const MESSAGE_ROLES = [
2222
type MessageRole = (typeof MESSAGE_ROLES)[number]['value']
2323

2424
export interface MessageEntry {
25-
role: MessageRole
25+
role: MessageRole | ''
2626
content: string
2727
}
2828

@@ -77,7 +77,7 @@ export function MessagesInput({ inputParam, data, disabled = false, variableItem
7777
)
7878

7979
const handleAddMessage = useCallback(() => {
80-
const newMessage: MessageEntry = { role: 'user', content: '' }
80+
const newMessage: MessageEntry = { role: '', content: '' }
8181
onDataChange?.({ inputParam, newValue: [...messages, newMessage] })
8282
}, [messages, inputParam, onDataChange])
8383

packages/agentflow/src/core/primitives/inputDefaults.test.ts

Lines changed: 15 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,8 @@ describe('getDefaultValueForType', () => {
1515
expect(getDefaultValueForType({ type: 'boolean' })).toBe(false)
1616
})
1717

18-
it('returns 0 for number', () => {
19-
expect(getDefaultValueForType({ type: 'number' })).toBe(0)
18+
it("returns '' for number without explicit default", () => {
19+
expect(getDefaultValueForType({ type: 'number' })).toBe('')
2020
})
2121

2222
it("returns '{}' for json", () => {
@@ -27,24 +27,28 @@ describe('getDefaultValueForType', () => {
2727
expect(getDefaultValueForType({ type: 'array' })).toEqual([])
2828
})
2929

30-
it('returns first option name for object options', () => {
30+
it("returns '' for options without explicit default", () => {
3131
expect(
3232
getDefaultValueForType({
3333
type: 'options',
3434
options: [{ name: 'first' }, { name: 'second' }]
3535
})
36-
).toBe('first')
37-
})
38-
39-
it('returns first option value for string options', () => {
40-
expect(getDefaultValueForType({ type: 'options', options: ['alpha', 'beta'] })).toBe('alpha')
41-
})
42-
43-
it("returns '' for options with no options", () => {
36+
).toBe('')
37+
expect(getDefaultValueForType({ type: 'options', options: ['alpha', 'beta'] })).toBe('')
4438
expect(getDefaultValueForType({ type: 'options' })).toBe('')
4539
expect(getDefaultValueForType({ type: 'options', options: [] })).toBe('')
4640
})
4741

42+
it('returns explicit default for options when provided', () => {
43+
expect(
44+
getDefaultValueForType({
45+
type: 'options',
46+
default: 'second',
47+
options: [{ name: 'first' }, { name: 'second' }]
48+
})
49+
).toBe('second')
50+
})
51+
4852
it("returns '' for string", () => {
4953
expect(getDefaultValueForType({ type: 'string' })).toBe('')
5054
})

packages/agentflow/src/core/primitives/inputDefaults.ts

Lines changed: 3 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,7 @@
88
*/
99
export function getDefaultValueForType({
1010
type,
11-
default: defaultValue,
12-
options
11+
default: defaultValue
1312
}: {
1413
type: string
1514
default?: unknown
@@ -21,16 +20,12 @@ export function getDefaultValueForType({
2120
case 'boolean':
2221
return false
2322
case 'number':
24-
return 0
23+
return ''
2524
case 'json':
2625
return '{}'
2726
case 'array':
2827
return []
29-
case 'options': {
30-
const first = options?.[0]
31-
if (!first) return ''
32-
return typeof first === 'string' ? first : first.name
33-
}
28+
case 'options':
3429
case 'string':
3530
case 'password':
3631
default:

packages/agentflow/src/infrastructure/store/AgentflowContext.tsx

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -99,8 +99,8 @@ interface AgentflowStateProviderProps {
9999
export function AgentflowStateProvider({ children, initialFlow }: AgentflowStateProviderProps) {
100100
const [state, dispatch] = useReducer(agentflowReducer, {
101101
...initialState,
102-
nodes: normalizeNodes(initialFlow?.nodes || []),
103-
edges: initialFlow?.edges || []
102+
nodes: normalizeNodes(Array.isArray(initialFlow?.nodes) ? initialFlow.nodes : []),
103+
edges: Array.isArray(initialFlow?.edges) ? initialFlow.edges : []
104104
})
105105

106106
// Store ReactFlow local state setters in refs which are populated by AgentflowCanvas

0 commit comments

Comments
 (0)