Fix/agentflow Integration and verification test fixes (#6175)

* Fix JSON inout button not appearing

* Fix to display icons for vector embeddings and tools in Agent node

* Add variable syntax support to condition node

* Clear out existing values when operation is changed in Condition node

* Loop node  to show ancestral nodes

* Fix position and styling of "See Example"

* Refactor AsyncInput to remove duplicate logic

* Fix gemini comments

* Addressed review comments
This commit is contained in:
j-sanaa
2026-04-09 15:49:00 -07:00
committed by GitHub
parent e765367fdc
commit 6ad18cc6b4
10 changed files with 787 additions and 111 deletions
@@ -3,6 +3,7 @@ import { fireEvent, render, screen } from '@testing-library/react'
import type { InputParam, NodeData } from '@/core/types'
import { ConditionBuilder } from './ConditionBuilder'
import type { VariableItem } from './VariablePicker'
// --- Mocks ---
const mockOnDataChange = jest.fn()
@@ -10,13 +11,15 @@ const mockOnDataChange = jest.fn()
jest.mock('./NodeInputHandler', () => ({
NodeInputHandler: ({
inputParam,
onDataChange
onDataChange,
variableItems
}: {
inputParam: InputParam
data: NodeData
onDataChange: (args: { inputParam: InputParam; newValue: unknown }) => void
variableItems?: unknown[]
}) => (
<div data-testid={`input-handler-${inputParam.name}`}>
<div data-testid={`input-handler-${inputParam.name}`} data-has-variables={variableItems !== undefined ? 'true' : 'false'}>
<label>{inputParam.label}</label>
<input data-testid={`input-${inputParam.name}`} onChange={(e) => onDataChange({ inputParam, newValue: e.target.value })} />
</div>
@@ -146,6 +149,25 @@ describe('ConditionBuilder', () => {
})
})
it('should clear value1 and value2 when operation changes', () => {
const data: NodeData = {
...mockNodeData,
inputs: {
conditions: [{ type: 'string', value1: 'hello', operation: 'equal', value2: 'world' }]
}
} as NodeData
render(<ConditionBuilder inputParam={conditionInputParam} data={data} onDataChange={mockOnDataChange} />)
const operationInput = screen.getByTestId('input-operation')
fireEvent.change(operationInput, { target: { value: 'contains' } })
expect(mockOnDataChange).toHaveBeenCalledWith({
inputParam: conditionInputParam,
newValue: [{ type: 'string', value1: '', operation: 'contains', value2: '' }]
})
})
it('should disable buttons when disabled prop is true', () => {
const data: NodeData = {
...mockNodeData,
@@ -207,6 +229,35 @@ describe('ConditionBuilder', () => {
expect(screen.queryByTestId('input-handler-value2')).not.toBeInTheDocument()
})
it('should forward variableItems to NodeInputHandler only for params with acceptVariable', () => {
const inputParamWithAcceptVariable: InputParam = {
...conditionInputParam,
array: [
{ id: 'type', name: 'type', label: 'Type', type: 'options', default: 'string' } as InputParam,
{ id: 'value1', name: 'value1', label: 'Value 1', type: 'string', default: '', acceptVariable: true } as InputParam,
{ id: 'value2', name: 'value2', label: 'Value 2', type: 'string', default: '', acceptVariable: true } as InputParam
]
}
const data: NodeData = {
...mockNodeData,
inputs: { conditions: [{ type: 'string', value1: '', value2: '' }] }
} as NodeData
const variableItems: VariableItem[] = [{ label: 'question', value: '{{question}}', category: 'Chat Context' }]
render(
<ConditionBuilder
inputParam={inputParamWithAcceptVariable}
data={data}
onDataChange={mockOnDataChange}
variableItems={variableItems}
/>
)
expect(screen.getByTestId('input-handler-type')).toHaveAttribute('data-has-variables', 'false')
expect(screen.getByTestId('input-handler-value1')).toHaveAttribute('data-has-variables', 'true')
expect(screen.getByTestId('input-handler-value2')).toHaveAttribute('data-has-variables', 'true')
})
it('should render fields for each condition item', () => {
const data: NodeData = {
...mockNodeData,
@@ -9,6 +9,7 @@ import type { InputParam, NodeData } from '@/core/types'
import { NodeInputHandler } from './NodeInputHandler'
import { useStableKeys } from './useStableKeys'
import type { VariableItem } from './VariablePicker'
export interface ConditionBuilderProps {
inputParam: InputParam
@@ -16,6 +17,7 @@ export interface ConditionBuilderProps {
disabled?: boolean
onDataChange?: (params: { inputParam: InputParam; newValue: unknown }) => void
itemParameters?: InputParam[][]
variableItems?: VariableItem[]
}
/**
@@ -28,7 +30,8 @@ export function ConditionBuilder({
data,
disabled = false,
onDataChange,
itemParameters: itemParametersProp
itemParameters: itemParametersProp,
variableItems
}: ConditionBuilderProps) {
const theme = useTheme()
@@ -49,6 +52,10 @@ export function ConditionBuilder({
const updatedArrayItems = [...arrayItems]
const updatedItem = { ...updatedArrayItems[itemIndex] }
updatedItem[changedParam.name] = newValue
if (changedParam.name === 'operation') {
updatedItem.value1 = ''
updatedItem.value2 = ''
}
updatedArrayItems[itemIndex] = updatedItem
onDataChange?.({ inputParam, newValue: updatedArrayItems })
},
@@ -137,6 +144,7 @@ export function ConditionBuilder({
isAdditionalParams={true}
disablePadding={false}
onDataChange={itemHandlers[index]}
variableItems={param.acceptVariable ? variableItems : undefined}
/>
))}
</Box>
@@ -379,17 +379,18 @@ describe('NodeInputHandler – json type', () => {
expect(screen.queryByTestId('json-input')).toBeNull()
})
it('renders inline JsonInput for json with acceptVariable but no variableItems', () => {
it('renders a button for json with acceptVariable even when no variableItems are provided', () => {
render(
<NodeInputHandler
inputParam={makeParam({ type: 'json', acceptVariable: true })}
inputParam={makeParam({ type: 'json', acceptVariable: true, label: 'My Field' })}
data={baseNodeData}
isAdditionalParams
onDataChange={mockOnDataChange}
/>
)
expect(screen.getByTestId('json-input')).toBeTruthy()
expect(screen.getByRole('button', { name: 'My Field' })).toBeTruthy()
expect(screen.queryByTestId('json-input')).toBeNull()
})
})
@@ -48,6 +48,7 @@ export interface AsyncInputProps {
disabled: boolean
onChange: (newValue: string) => void
nodeName?: string
nodeId?: string
inputValues?: Record<string, unknown>
}
@@ -303,7 +304,7 @@ export function NodeInputHandler({
case 'json': {
const jsonStr = typeof value === 'string' ? value : JSON.stringify(value || {})
if (inputParam.acceptVariable && variableItems && variableItems.length > 0) {
if (inputParam.acceptVariable) {
// acceptVariable: show a button that opens a dialog with JsonInput + variable support
return (
<Button
@@ -323,22 +324,22 @@ export function NodeInputHandler({
case 'code':
return (
<>
{inputParam.codeExample && !disabled && (
<Button
size='small'
variant='outlined'
sx={{ mb: 1, textTransform: 'none' }}
onClick={() => handleDataChange(inputParam.codeExample)}
>
See Example
</Button>
)}
<CodeInput
value={typeof value === 'string' ? value : ''}
onChange={(code) => handleDataChange(code)}
language={inputParam.codeLanguage}
disabled={disabled}
/>
{inputParam.codeExample && !disabled && (
<Button
size='small'
variant='text'
sx={{ mt: 0.5, textTransform: 'none' }}
onClick={() => handleDataChange(inputParam.codeExample)}
>
See Example
</Button>
)}
</>
)
@@ -378,6 +379,7 @@ export function NodeInputHandler({
disabled={disabled}
onChange={(v) => handleDataChange(v)}
nodeName={data.name}
nodeId={data.id}
inputValues={data.inputs as Record<string, unknown> | undefined}
/>
{inputParam.loadConfig && ConfigInputComponent && value && onConfigChange && (
@@ -403,6 +405,7 @@ export function NodeInputHandler({
disabled={disabled}
onChange={(v) => handleDataChange(v)}
nodeName={data.name}
nodeId={data.id}
inputValues={data.inputs as Record<string, unknown> | undefined}
/>
)
@@ -541,7 +544,7 @@ export function NodeInputHandler({
</Popover>
)}
{inputParam?.type === 'json' && inputParam.acceptVariable && variableItems && variableItems.length > 0 && (
{inputParam?.type === 'json' && inputParam.acceptVariable && (
<Dialog open={jsonDialogOpen} onClose={() => setJsonDialogOpen(false)} fullWidth maxWidth='sm'>
<DialogTitle>{inputParam.label}</DialogTitle>
<DialogContent>
@@ -0,0 +1,298 @@
import { darken } from '@mui/material/styles'
import { render, screen } from '@testing-library/react'
import { Mock } from 'jest-mock'
import { useConfigContext } from '@/infrastructure/store'
import { NodeToolIcons } from './NodeToolIcons'
// --- Mocks ---
jest.mock('@/infrastructure/store', () => ({
useApiContext: () => ({ apiBaseUrl: 'http://localhost:3000' }),
useConfigContext: jest.fn(() => ({ isDarkMode: false }))
}))
jest.mock('@mui/material/styles', () => ({
...jest.requireActual('@mui/material/styles'),
darken: jest.fn((color: string, coefficient: number) => `darken(${color},${coefficient})`)
}))
jest.mock('../nodeIcons', () => ({
getBuiltInOpenAIToolIcon: (toolName: string) => {
const icons: Record<string, React.ReactElement> = {
web_search_preview: <span data-testid='openai-web-search' />,
code_interpreter: <span data-testid='openai-code-interpreter' />,
image_generation: <span data-testid='openai-image-generation' />
}
return icons[toolName] ?? null
},
getBuiltInGeminiToolIcon: (toolName: string) => {
const icons: Record<string, React.ReactElement> = {
urlContext: <span data-testid='gemini-url-context' />,
googleSearch: <span data-testid='gemini-google-search' />,
codeExecution: <span data-testid='gemini-code-execution' />
}
return icons[toolName] ?? null
},
getBuiltInAnthropicToolIcon: (toolName: string) => {
const icons: Record<string, React.ReactElement> = {
web_search_20250305: <span data-testid='anthropic-web-search' />,
web_fetch_20250910: <span data-testid='anthropic-web-fetch' />
}
return icons[toolName] ?? null
}
}))
// --- Helpers ---
function renderToolIcons(inputs: Record<string, unknown>, nodeColor = '#4A90D9') {
return render(<NodeToolIcons inputs={inputs} nodeColor={nodeColor} />)
}
// --- Tests ---
describe('NodeToolIcons', () => {
describe('renders nothing', () => {
it('returns null when inputs is undefined', () => {
const { container } = render(<NodeToolIcons />)
expect(container.firstChild).toBeNull()
})
it('returns null when inputs has no tool fields', () => {
const { container } = renderToolIcons({ llmModel: 'gpt-4', someOtherField: 'value' })
expect(container.firstChild).toBeNull()
})
it('returns null when tool arrays are empty', () => {
const { container } = renderToolIcons({ llmTools: [], agentTools: [] })
expect(container.firstChild).toBeNull()
})
it('returns null when llmTools is a non-array (JSON string guard)', () => {
// Non-array values should be ignored, not crash
const { container } = renderToolIcons({ llmTools: '[{"llmSelectedTool":"customTool"}]' })
expect(container.firstChild).toBeNull()
})
it('returns null when agentKnowledgeVSEmbeddings is a non-array', () => {
const { container } = renderToolIcons({ agentKnowledgeVSEmbeddings: 'not-an-array' })
expect(container.firstChild).toBeNull()
})
})
describe('llmTools', () => {
it('renders icon for each tool in llmTools', () => {
renderToolIcons({
llmTools: [{ llmSelectedTool: 'customTool1' }, { llmSelectedTool: 'customTool2' }]
})
const imgs = screen.getAllByRole('img')
expect(imgs).toHaveLength(2)
expect(imgs[0]).toHaveAttribute('src', 'http://localhost:3000/api/v1/node-icon/customTool1')
expect(imgs[1]).toHaveAttribute('src', 'http://localhost:3000/api/v1/node-icon/customTool2')
})
it('skips entries without llmSelectedTool property', () => {
renderToolIcons({
llmTools: [{ llmSelectedTool: 'toolA' }, { someOtherProp: 'value' }]
})
const imgs = screen.getAllByRole('img')
expect(imgs).toHaveLength(1)
expect(imgs[0]).toHaveAttribute('src', 'http://localhost:3000/api/v1/node-icon/toolA')
})
})
describe('agentTools', () => {
it('renders icon for each tool in agentTools', () => {
renderToolIcons({
agentTools: [{ agentSelectedTool: 'agentToolX' }]
})
const imgs = screen.getAllByRole('img')
expect(imgs).toHaveLength(1)
expect(imgs[0]).toHaveAttribute('src', 'http://localhost:3000/api/v1/node-icon/agentToolX')
})
})
describe('selectedTool / toolAgentflowSelectedTool', () => {
it('renders icon for selectedTool', () => {
renderToolIcons({ selectedTool: 'myTool' })
const img = screen.getByRole('img')
expect(img).toHaveAttribute('src', 'http://localhost:3000/api/v1/node-icon/myTool')
})
it('falls back to toolAgentflowSelectedTool when selectedTool is absent', () => {
renderToolIcons({ toolAgentflowSelectedTool: 'fallbackTool' })
const img = screen.getByRole('img')
expect(img).toHaveAttribute('src', 'http://localhost:3000/api/v1/node-icon/fallbackTool')
})
it('prefers selectedTool over toolAgentflowSelectedTool', () => {
renderToolIcons({ selectedTool: 'primary', toolAgentflowSelectedTool: 'secondary' })
const imgs = screen.getAllByRole('img')
// Only one icon should render (the resolved single tool)
expect(imgs).toHaveLength(1)
expect(imgs[0]).toHaveAttribute('src', 'http://localhost:3000/api/v1/node-icon/primary')
})
it('renders nothing when both selectedTool and toolAgentflowSelectedTool are absent', () => {
const { container } = renderToolIcons({ llmModel: 'gpt-4' })
expect(container.firstChild).toBeNull()
})
})
describe('agentKnowledgeVSEmbeddings', () => {
it('renders vectorStore and embeddingModel icons per entry', () => {
renderToolIcons({
agentKnowledgeVSEmbeddings: [{ vectorStore: 'pinecone', embeddingModel: 'openaiEmbeddings' }]
})
const imgs = screen.getAllByRole('img')
expect(imgs).toHaveLength(2)
expect(imgs[0]).toHaveAttribute('src', 'http://localhost:3000/api/v1/node-icon/pinecone')
expect(imgs[1]).toHaveAttribute('src', 'http://localhost:3000/api/v1/node-icon/openaiEmbeddings')
})
it('renders only vectorStore icon when embeddingModel is absent', () => {
renderToolIcons({
agentKnowledgeVSEmbeddings: [{ vectorStore: 'pinecone' }]
})
const imgs = screen.getAllByRole('img')
expect(imgs).toHaveLength(1)
expect(imgs[0]).toHaveAttribute('src', 'http://localhost:3000/api/v1/node-icon/pinecone')
})
it('renders icons for multiple knowledge entries', () => {
renderToolIcons({
agentKnowledgeVSEmbeddings: [
{ vectorStore: 'pinecone', embeddingModel: 'openaiEmbeddings' },
{ vectorStore: 'weaviate', embeddingModel: 'cohereEmbeddings' }
]
})
const imgs = screen.getAllByRole('img')
expect(imgs).toHaveLength(4)
})
})
describe('built-in OpenAI tools', () => {
it('renders icon for web_search_preview as array', () => {
renderToolIcons({ agentToolsBuiltInOpenAI: ['web_search_preview'] })
expect(screen.getByTestId('openai-web-search')).toBeInTheDocument()
})
it('renders icon for code_interpreter', () => {
renderToolIcons({ agentToolsBuiltInOpenAI: ['code_interpreter'] })
expect(screen.getByTestId('openai-code-interpreter')).toBeInTheDocument()
})
it('renders icon for image_generation', () => {
renderToolIcons({ agentToolsBuiltInOpenAI: ['image_generation'] })
expect(screen.getByTestId('openai-image-generation')).toBeInTheDocument()
})
it('accepts JSON string input', () => {
renderToolIcons({ agentToolsBuiltInOpenAI: '["web_search_preview","code_interpreter"]' })
expect(screen.getByTestId('openai-web-search')).toBeInTheDocument()
expect(screen.getByTestId('openai-code-interpreter')).toBeInTheDocument()
})
it('renders no icons for unknown built-in tool names', () => {
renderToolIcons({ agentToolsBuiltInOpenAI: ['unknown_tool'] })
expect(screen.queryByRole('img')).not.toBeInTheDocument()
expect(screen.queryByTestId(/^openai-/)).not.toBeInTheDocument()
})
})
describe('built-in Gemini tools', () => {
it('renders icon for urlContext', () => {
renderToolIcons({ agentToolsBuiltInGemini: ['urlContext'] })
expect(screen.getByTestId('gemini-url-context')).toBeInTheDocument()
})
it('renders icon for googleSearch', () => {
renderToolIcons({ agentToolsBuiltInGemini: ['googleSearch'] })
expect(screen.getByTestId('gemini-google-search')).toBeInTheDocument()
})
it('renders icon for codeExecution', () => {
renderToolIcons({ agentToolsBuiltInGemini: ['codeExecution'] })
expect(screen.getByTestId('gemini-code-execution')).toBeInTheDocument()
})
it('accepts JSON string input', () => {
renderToolIcons({ agentToolsBuiltInGemini: '["googleSearch"]' })
expect(screen.getByTestId('gemini-google-search')).toBeInTheDocument()
})
})
describe('built-in Anthropic tools', () => {
it('renders icon for web_search_20250305', () => {
renderToolIcons({ agentToolsBuiltInAnthropic: ['web_search_20250305'] })
expect(screen.getByTestId('anthropic-web-search')).toBeInTheDocument()
})
it('renders icon for web_fetch_20250910', () => {
renderToolIcons({ agentToolsBuiltInAnthropic: ['web_fetch_20250910'] })
expect(screen.getByTestId('anthropic-web-fetch')).toBeInTheDocument()
})
it('accepts JSON string input', () => {
renderToolIcons({ agentToolsBuiltInAnthropic: '["web_search_20250305","web_fetch_20250910"]' })
expect(screen.getByTestId('anthropic-web-search')).toBeInTheDocument()
expect(screen.getByTestId('anthropic-web-fetch')).toBeInTheDocument()
})
it('renders no icons for unknown built-in tool names', () => {
renderToolIcons({ agentToolsBuiltInAnthropic: ['unknown_anthropic_tool'] })
expect(screen.queryByRole('img')).not.toBeInTheDocument()
expect(screen.queryByTestId(/^anthropic-/)).not.toBeInTheDocument()
})
})
describe('multiple tool groups', () => {
it('renders icons from multiple tool groups simultaneously', () => {
renderToolIcons({
llmTools: [{ llmSelectedTool: 'customTool' }],
agentToolsBuiltInOpenAI: ['web_search_preview'],
agentToolsBuiltInGemini: ['googleSearch']
})
expect(screen.getByRole('img')).toHaveAttribute('src', 'http://localhost:3000/api/v1/node-icon/customTool')
expect(screen.getByTestId('openai-web-search')).toBeInTheDocument()
expect(screen.getByTestId('gemini-google-search')).toBeInTheDocument()
})
})
describe('dark mode', () => {
afterEach(() => {
;(useConfigContext as Mock).mockReturnValue({ isDarkMode: false })
})
it('renders built-in tool icons in dark mode without crashing', () => {
;(useConfigContext as Mock).mockReturnValue({ isDarkMode: true })
expect(() => renderToolIcons({ agentToolsBuiltInOpenAI: ['web_search_preview'] })).not.toThrow()
})
it('uses a higher darken coefficient in dark mode than in light mode', () => {
const nodeColor = '#4A90D9'
;(useConfigContext as Mock).mockReturnValue({ isDarkMode: true })
renderToolIcons({ agentToolsBuiltInOpenAI: ['web_search_preview'] }, nodeColor)
expect(darken).toHaveBeenCalledWith(nodeColor, 0.5)
;(useConfigContext as Mock).mockReturnValue({ isDarkMode: false })
renderToolIcons({ agentToolsBuiltInOpenAI: ['web_search_preview'] }, nodeColor)
expect(darken).toHaveBeenCalledWith(nodeColor, 0.2)
})
})
describe('edge cases', () => {
it('handles invalid JSON string for built-in tools gracefully', () => {
const { container } = renderToolIcons({ agentToolsBuiltInOpenAI: 'not-valid-json' })
expect(container.firstChild).toBeNull()
})
it('handles empty JSON array string', () => {
const { container } = renderToolIcons({ agentToolsBuiltInOpenAI: '[]' })
expect(container.firstChild).toBeNull()
})
it('does not crash when nodeColor is undefined', () => {
expect(() => render(<NodeToolIcons inputs={{ agentToolsBuiltInOpenAI: ['web_search_preview'] }} />)).not.toThrow()
})
})
})
@@ -0,0 +1,165 @@
import { memo } from 'react'
import { Box } from '@mui/material'
import { darken } from '@mui/material/styles'
import { useApiContext, useConfigContext } from '@/infrastructure/store'
import { getBuiltInAnthropicToolIcon, getBuiltInGeminiToolIcon, getBuiltInOpenAIToolIcon } from '../nodeIcons'
interface ToolEntry {
[key: string]: unknown
}
type BuiltInProvider = 'openai' | 'gemini' | 'anthropic'
interface ToolConfig {
tools: ToolEntry[]
toolProperty: string | string[]
builtInProvider?: BuiltInProvider
}
const builtInIconGetters: Record<BuiltInProvider, (_name: string) => React.ReactElement | null> = {
openai: getBuiltInOpenAIToolIcon,
gemini: getBuiltInGeminiToolIcon,
anthropic: getBuiltInAnthropicToolIcon
}
export interface NodeToolIconsProps {
inputs?: Record<string, unknown>
nodeColor?: string
}
function parseToolArray(value: unknown): ToolEntry[] {
if (!value) return []
if (typeof value === 'string') {
try {
const parsed = JSON.parse(value)
return Array.isArray(parsed) ? parsed.map((tool: unknown) => ({ builtInTool: tool })) : []
} catch {
return []
}
}
if (Array.isArray(value)) return value.map((tool) => ({ builtInTool: tool }))
return []
}
function NodeToolIconsComponent({ inputs, nodeColor = '#4A90D9' }: NodeToolIconsProps) {
const { apiBaseUrl } = useApiContext()
const { isDarkMode } = useConfigContext()
if (!inputs) return null
const singleTool = inputs.selectedTool ?? inputs.toolAgentflowSelectedTool
const toolConfigs: ToolConfig[] = [
{ tools: Array.isArray(inputs.llmTools) ? (inputs.llmTools as ToolEntry[]) : [], toolProperty: 'llmSelectedTool' },
{ tools: Array.isArray(inputs.agentTools) ? (inputs.agentTools as ToolEntry[]) : [], toolProperty: 'agentSelectedTool' },
{
tools: singleTool ? [{ selectedTool: singleTool }] : [],
toolProperty: ['selectedTool', 'toolAgentflowSelectedTool']
},
{
tools: Array.isArray(inputs.agentKnowledgeVSEmbeddings) ? (inputs.agentKnowledgeVSEmbeddings as ToolEntry[]) : [],
toolProperty: ['vectorStore', 'embeddingModel']
},
{
tools: parseToolArray(inputs.agentToolsBuiltInOpenAI),
toolProperty: 'builtInTool',
builtInProvider: 'openai' as const
},
{
tools: parseToolArray(inputs.agentToolsBuiltInGemini),
toolProperty: 'builtInTool',
builtInProvider: 'gemini' as const
},
{
tools: parseToolArray(inputs.agentToolsBuiltInAnthropic),
toolProperty: 'builtInTool',
builtInProvider: 'anthropic' as const
}
]
const validConfigs = toolConfigs.filter((config) => config.tools.length > 0)
if (validConfigs.length === 0) return null
const builtInBg = isDarkMode ? darken(nodeColor, 0.5) : darken(nodeColor, 0.2)
return (
<>
{validConfigs.map((config, configIndex) => (
<Box key={`tools-${configIndex}`} sx={{ display: 'flex', gap: 1, mt: 1 }}>
{config.tools.flatMap((tool, toolIndex) => {
if (Array.isArray(config.toolProperty)) {
return config.toolProperty
.filter((prop) => tool[prop])
.map((prop, propIndex) => {
const toolName = tool[prop] as string
return (
<Box
key={`tool-${configIndex}-${toolIndex}-${propIndex}`}
component='img'
src={`${apiBaseUrl}/api/v1/node-icon/${encodeURIComponent(toolName)}`}
alt={toolName}
sx={{
width: 20,
height: 20,
borderRadius: '50%',
backgroundColor: 'rgba(255, 255, 255, 0.2)',
padding: 0.3,
objectFit: 'contain'
}}
/>
)
})
}
const toolName = tool[config.toolProperty as string] as string
if (!toolName) return []
if (config.builtInProvider) {
const icon = builtInIconGetters[config.builtInProvider](toolName)
if (!icon) return []
return [
<Box
key={`tool-${configIndex}-${toolIndex}`}
sx={{
width: 20,
height: 20,
borderRadius: '50%',
backgroundColor: builtInBg,
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
padding: 0.2
}}
>
{icon}
</Box>
]
}
return [
<Box
key={`tool-${configIndex}-${toolIndex}`}
component='img'
src={`${apiBaseUrl}/api/v1/node-icon/${encodeURIComponent(toolName)}`}
alt={toolName}
sx={{
width: 20,
height: 20,
borderRadius: '50%',
backgroundColor: 'rgba(255, 255, 255, 0.2)',
padding: 0.3,
objectFit: 'contain'
}}
/>
]
})}
</Box>
))}
</>
)
}
export const NodeToolIcons = memo(NodeToolIconsComponent)
@@ -12,6 +12,7 @@ import { NodeModelConfigs } from '../components/NodeModelConfigs'
import { getMinimumNodeHeight, NodeOutputHandles } from '../components/NodeOutputHandles'
import { NodeStatusIndicator, NodeWarningIndicator } from '../components/NodeStatusIndicator'
import { NodeToolbarActions } from '../components/NodeToolbarActions'
import { NodeToolIcons } from '../components/NodeToolIcons'
import { useOpenNodeEditor } from '../hooks'
import { useNodeColors } from '../hooks/useNodeColors'
import { CardWrapper } from '../styled'
@@ -106,6 +107,7 @@ function AgentFlowNodeComponent({ data }: AgentFlowNodeProps) {
{data.label}
</Typography>
<NodeModelConfigs inputs={data.inputs} />
<NodeToolIcons inputs={data.inputs} nodeColor={data.color} />
</Box>
</Box>
@@ -63,10 +63,11 @@ jest.mock('@/infrastructure/api/hooks', () => ({
}))
let mockNodes: Array<{ id: string; type: string; position: { x: number; y: number }; data: Record<string, unknown> }> = []
let mockEdges: Array<{ id: string; source: string; target: string; targetHandle?: string; type: string }> = []
jest.mock('@/infrastructure/store', () => ({
useAgentflowContext: () => ({
state: { nodes: mockNodes, edges: [] }
state: { nodes: mockNodes, edges: mockEdges }
})
}))
@@ -96,6 +97,7 @@ const idleResult = (): MockAsyncResult => ({ options: [], loading: false, error:
beforeEach(() => {
jest.clearAllMocks()
mockNodes = []
mockEdges = []
mockUseAsyncOptions.mockReturnValue({ options: [], loading: true, error: null, refetch: mockRefetch })
})
@@ -731,6 +733,101 @@ describe('AsyncInput', () => {
})
})
describe('AsyncInput – listPreviousNodes', () => {
const makeFlowNode = (id: string, name: string, label: string) => ({
id,
type: 'agentflowNode',
position: { x: 0, y: 0 },
data: { name, label, inputs: {} }
})
const makeEdge = (source: string, target: string) => ({
id: `${source}-${target}`,
source,
target,
targetHandle: target,
type: 'agentflowEdge'
})
it('does not call useAsyncOptions — options come from flow state only', () => {
mockNodes = [makeFlowNode('loop_0', 'loopAgentflow', 'Loop 0')]
render(
<AsyncInput
inputParam={makeParam({ type: 'asyncOptions', loadMethod: 'listPreviousNodes' })}
value=''
disabled={false}
onChange={jest.fn()}
nodeId='loop_0'
/>
)
expect(mockUseAsyncOptions).not.toHaveBeenCalled()
})
it('renders ancestor nodes from flow state as dropdown options', async () => {
mockNodes = [
makeFlowNode('start_0', 'startAgentflow', 'Start'),
makeFlowNode('agent_0', 'agentAgentflow', 'Agent 0'),
makeFlowNode('loop_0', 'loopAgentflow', 'Loop 0')
]
mockEdges = [makeEdge('start_0', 'agent_0'), makeEdge('agent_0', 'loop_0')]
render(
<AsyncInput
inputParam={makeParam({ type: 'asyncOptions', loadMethod: 'listPreviousNodes' })}
value=''
disabled={false}
onChange={jest.fn()}
nodeId='loop_0'
/>
)
fireEvent.mouseDown(screen.getByRole('combobox'))
await waitFor(() => {
expect(screen.getByText('Start')).toBeTruthy()
expect(screen.getByText('Agent 0')).toBeTruthy()
})
})
it('shows no options when the loop node has no ancestors', async () => {
mockNodes = [makeFlowNode('loop_0', 'loopAgentflow', 'Loop 0')]
mockEdges = []
render(
<AsyncInput
inputParam={makeParam({ type: 'asyncOptions', loadMethod: 'listPreviousNodes' })}
value=''
disabled={false}
onChange={jest.fn()}
nodeId='loop_0'
/>
)
fireEvent.mouseDown(screen.getByRole('combobox'))
await waitFor(() => expect(screen.getByText('No options')).toBeTruthy())
})
it('clears stored value when the selected ancestor node is removed from the flow', async () => {
const mockChange = jest.fn()
mockNodes = [makeFlowNode('loop_0', 'loopAgentflow', 'Loop 0')]
mockEdges = []
// Value points to a node that is no longer in the flow / no longer an ancestor
render(
<AsyncInput
inputParam={makeParam({ type: 'asyncOptions', loadMethod: 'listPreviousNodes' })}
value='agent_0-Agent 0'
disabled={false}
onChange={mockChange}
nodeId='loop_0'
/>
)
await waitFor(() => expect(mockChange).toHaveBeenCalledWith(''))
})
})
describe('AsyncInput – Refresh button', () => {
it('does NOT show refresh button when inputParam.refresh is falsy', () => {
mockUseAsyncOptions.mockReturnValue({
@@ -1,12 +1,13 @@
import { Fragment, useCallback, useEffect, useState } from 'react'
import { Fragment, useCallback, useEffect, useRef, useState } from 'react'
import { Box, CircularProgress, IconButton, TextField, Typography } from '@mui/material'
import Autocomplete from '@mui/material/Autocomplete'
import { IconEdit, IconRefresh } from '@tabler/icons-react'
import type { AsyncInputProps } from '@/atoms'
import { Dropdown } from '@/atoms'
import type { FlowNode, NodeOption } from '@/core/types'
import { getDefinedStateKeys } from '@/core/utils'
import { getDefinedStateKeys, getUpstreamNodes } from '@/core/utils'
import { useAsyncOptions } from '@/infrastructure/api/hooks'
import { useAgentflowContext } from '@/infrastructure/store'
@@ -36,6 +37,21 @@ function buildAsyncParams(
}
}
/**
* Returns all ancestor nodes for the given node ID as dropdown options.
* Used directly by AsyncOptionsDropdown when loadMethod === 'listPreviousNodes',
* bypassing useAsyncOptions entirely — no server call needed.
*/
function useFlowAncestorNodeOptions(nodeId?: string): NodeOption[] {
const { state } = useAgentflowContext()
if (!nodeId) return []
return getUpstreamNodes(nodeId, state.nodes as FlowNode[], state.edges, /* includeStart */ true).map((n) => ({
label: n.data.label,
name: `${n.id}-${n.data.label}`,
description: n.id
}))
}
/**
* Extract state keys from all nodes except the given node.
* Excludes the current node to avoid a circular reference where a node's own
@@ -47,7 +63,87 @@ function useFlowStateKeys(excludeNodeName?: string): string[] {
return getDefinedStateKeys(nodes as FlowNode[])
}
function AsyncOptionsInput({ inputParam, value, disabled, onChange, nodeName, inputValues }: AsyncInputProps) {
/** Wraps the three-step async option fetching setup into a single hook. */
function useAsyncOptionData(
inputParam: AsyncInputProps['inputParam'],
nodeName: string | undefined,
inputValues: Record<string, unknown> | undefined
) {
const stateKeys = useFlowStateKeys(nodeName)
const params = buildAsyncParams(inputParam.loadMethod, nodeName, inputValues, stateKeys)
return useAsyncOptions({
loadMethod: inputParam.loadMethod,
credentialNames: inputParam.credentialNames,
params
})
}
/** Error state with a retry button. Shared between single- and multi-select dropdowns. */
function AsyncFetchError({ error, onRetry }: { error: string; onRetry: () => void }) {
return (
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant='caption' color='error' sx={{ flexGrow: 1 }}>
{error}
</Typography>
<IconButton size='small' onClick={onRetry} title='Retry' aria-label='retry'>
<IconRefresh size={16} />
</IconButton>
</Box>
)
}
/** Renders the image + label + description block for a single option. */
function OptionContent({ option }: { option: NodeOption }) {
return (
<>
{option.imageSrc && (
<Box
component='img'
src={option.imageSrc}
alt={option.label}
sx={{ width: 30, height: 30, padding: '1px', borderRadius: '50%', flexShrink: 0 }}
/>
)}
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant='h5'>{option.label}</Typography>
{option.description && <Typography variant='caption'>{option.description}</Typography>}
</Box>
</>
)
}
/** Loading spinner end adornment. Shared between single- and multi-select inputs. */
function LoadingEndAdornment({ loading, original }: { loading: boolean; original: React.ReactNode }) {
return (
<Fragment>
{loading ? <CircularProgress color='inherit' size={20} /> : null}
{original}
</Fragment>
)
}
/** Dropdown for listPreviousNodes — reads ancestor nodes from flow state, no server call. */
function PreviousNodesDropdown({ value, disabled, onChange, nodeId }: Pick<AsyncInputProps, 'value' | 'disabled' | 'onChange' | 'nodeId'>) {
const options = useFlowAncestorNodeOptions(nodeId)
const stringValue = typeof value === 'string' ? value : ''
const onChangeRef = useRef(onChange)
useEffect(() => {
onChangeRef.current = onChange
})
// Clear stored value if the selected node no longer exists in the flow.
// onChange is accessed via ref so an unstable parent callback can't retrigger this effect.
useEffect(() => {
if (stringValue && !options.some((o) => o.name === stringValue)) {
onChangeRef.current('')
}
}, [stringValue, options])
return <Dropdown value={stringValue} options={options} onSelect={onChange} disabled={disabled} />
}
function AsyncOptionsInput({ inputParam, value, disabled, onChange, nodeName, nodeId, inputValues }: AsyncInputProps) {
const isCredential = !!inputParam.credentialNames?.length
const [createDialogOpen, setCreateDialogOpen] = useState(false)
const [editDialogOpen, setEditDialogOpen] = useState(false)
@@ -83,20 +179,32 @@ function AsyncOptionsInput({ inputParam, value, disabled, onChange, nodeName, in
return (
<>
<Box sx={{ display: 'flex', flexDirection: 'row', alignItems: 'center', mt: 1 }}>
<AsyncOptionsDropdown
key={reloadKey}
inputParam={inputParam}
value={pendingValue ?? value}
disabled={disabled}
onChange={(v) => {
setPendingValue(null)
onChange(v)
}}
nodeName={nodeName}
inputValues={inputValues}
isCredential={isCredential}
onCreateNew={() => setCreateDialogOpen(true)}
/>
{inputParam.loadMethod === 'listPreviousNodes' ? (
<PreviousNodesDropdown
value={pendingValue ?? value}
disabled={disabled}
onChange={(v) => {
setPendingValue(null)
onChange(v)
}}
nodeId={nodeId}
/>
) : (
<AsyncOptionsDropdown
key={reloadKey}
inputParam={inputParam}
value={pendingValue ?? value}
disabled={disabled}
onChange={(v) => {
setPendingValue(null)
onChange(v)
}}
nodeName={nodeName}
inputValues={inputValues}
isCredential={isCredential}
onCreateNew={() => setCreateDialogOpen(true)}
/>
)}
{selectedCredentialId && (
<IconButton title='Edit Credential' color='primary' size='small' onClick={() => setEditDialogOpen(true)}>
<IconEdit size={18} />
@@ -146,13 +254,7 @@ function AsyncOptionsDropdown({
isCredential,
onCreateNew
}: AsyncInputProps & { isCredential: boolean; onCreateNew: () => void }) {
const stateKeys = useFlowStateKeys(nodeName)
const params = buildAsyncParams(inputParam.loadMethod, nodeName, inputValues, stateKeys)
const { options, loading, error, refetch } = useAsyncOptions({
loadMethod: inputParam.loadMethod,
credentialNames: inputParam.credentialNames,
params
})
const { options, loading, error, refetch } = useAsyncOptionData(inputParam, nodeName, inputValues)
// Append "- Create New -" sentinel for credential dropdowns
const displayOptions = isCredential ? [...options, { label: '- Create New -', name: CREATE_NEW_SENTINEL }] : options
@@ -165,24 +267,21 @@ function AsyncOptionsDropdown({
setInputText(matchedValue?.label ?? '')
}, [matchedValue?.label])
// Clear the stored value if it no longer matches any available option
const onChangeRef = useRef(onChange)
useEffect(() => {
onChangeRef.current = onChange
})
// Clear the stored value if it no longer matches any available option.
// onChange is accessed via ref so an unstable parent callback can't retrigger this effect.
useEffect(() => {
if (!loading && value && typeof value === 'string' && !options.some((o) => o.name === value)) {
onChange('')
onChangeRef.current('')
}
}, [loading, value, options, onChange])
}, [loading, value, options])
if (error) {
return (
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant='caption' color='error' sx={{ flexGrow: 1 }}>
{error}
</Typography>
<IconButton size='small' onClick={refetch} title='Retry' aria-label='retry'>
<IconRefresh size={16} />
</IconButton>
</Box>
)
return <AsyncFetchError error={error} onRetry={refetch} />
}
return (
@@ -217,20 +316,7 @@ function AsyncOptionsDropdown({
{option.label}
</Typography>
) : (
<>
{option.imageSrc && (
<Box
component='img'
src={option.imageSrc}
alt={option.label}
sx={{ width: 30, height: 30, padding: '1px', borderRadius: '50%', flexShrink: 0 }}
/>
)}
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant='h5'>{option.label}</Typography>
{option.description && <Typography variant='caption'>{option.description}</Typography>}
</Box>
</>
<OptionContent option={option} />
)}
</Box>
)}
@@ -252,12 +338,7 @@ function AsyncOptionsDropdown({
{params.InputProps.startAdornment}
</>
),
endAdornment: (
<Fragment>
{loading ? <CircularProgress color='inherit' size={20} /> : null}
{params.InputProps.endAdornment}
</Fragment>
)
endAdornment: <LoadingEndAdornment loading={loading} original={params.InputProps.endAdornment} />
}}
/>
)}
@@ -290,25 +371,10 @@ function AsyncMultiOptionsInput({ inputParam, value, disabled, onChange, nodeNam
/** Inner multi-select component. Remounted via key to force a fresh fetch. */
function AsyncMultiOptionsDropdown({ inputParam, value, disabled, onChange, nodeName, inputValues }: AsyncInputProps) {
const stateKeys = useFlowStateKeys(nodeName)
const params = buildAsyncParams(inputParam.loadMethod, nodeName, inputValues, stateKeys)
const { options, loading, error, refetch } = useAsyncOptions({
loadMethod: inputParam.loadMethod,
credentialNames: inputParam.credentialNames,
params
})
const { options, loading, error, refetch } = useAsyncOptionData(inputParam, nodeName, inputValues)
if (error) {
return (
<Box sx={{ mt: 1, display: 'flex', alignItems: 'center', gap: 1 }}>
<Typography variant='caption' color='error' sx={{ flexGrow: 1 }}>
{error}
</Typography>
<IconButton size='small' onClick={refetch} title='Retry' aria-label='retry'>
<IconRefresh size={16} />
</IconButton>
</Box>
)
return <AsyncFetchError error={error} onRetry={refetch} />
}
// Stored as JSON-serialized array of names, e.g. '["option1","option2"]'
@@ -347,18 +413,7 @@ function AsyncMultiOptionsDropdown({ inputParam, value, disabled, onChange, node
sx={{ flexGrow: 1 }}
renderOption={(props, option) => (
<Box component='li' {...props} sx={{ display: 'flex', alignItems: 'center', gap: 1 }}>
{option.imageSrc && (
<Box
component='img'
src={option.imageSrc}
alt={option.label}
sx={{ width: 30, height: 30, padding: '1px', borderRadius: '50%', flexShrink: 0 }}
/>
)}
<Box sx={{ display: 'flex', flexDirection: 'column' }}>
<Typography variant='h5'>{option.label}</Typography>
{option.description && <Typography variant='caption'>{option.description}</Typography>}
</Box>
<OptionContent option={option} />
</Box>
)}
renderInput={(params) => (
@@ -366,12 +421,7 @@ function AsyncMultiOptionsDropdown({ inputParam, value, disabled, onChange, node
{...params}
InputProps={{
...params.InputProps,
endAdornment: (
<Fragment>
{loading ? <CircularProgress color='inherit' size={20} /> : null}
{params.InputProps.endAdornment}
</Fragment>
)
endAdornment: <LoadingEndAdornment loading={loading} original={params.InputProps.endAdornment} />
}}
/>
)}
@@ -342,6 +342,7 @@ function EditNodeDialogComponent({ show, dialogProps, onCancel }: EditNodeDialog
disabled={dialogProps.disabled}
onDataChange={onCustomDataChange}
itemParameters={arrayItemParameters[inputParam.name]}
variableItems={variableItems}
/>
)
}