Merge remote-tracking branch 'origin/main' into feat/support-agent-sandbox

This commit is contained in:
yyh
2026-03-25 17:49:36 +08:00
93 changed files with 13582 additions and 2337 deletions

View File

@ -0,0 +1,127 @@
import type {
AgentLogItemWithChildren,
IterationDurationMap,
LoopDurationMap,
LoopVariableMap,
NodeTracing,
} from '@/types/workflow'
import { act, renderHook } from '@testing-library/react'
import { BlockEnum } from '../../types'
import { useLogs } from '../hooks'
const createNodeTracing = (id: string): NodeTracing => ({
id,
index: 0,
predecessor_node_id: '',
node_id: id,
node_type: BlockEnum.Tool,
title: id,
inputs: {},
inputs_truncated: false,
process_data: {},
process_data_truncated: false,
outputs_truncated: false,
status: 'succeeded',
elapsed_time: 1,
metadata: {
iterator_length: 0,
iterator_index: 0,
loop_length: 0,
loop_index: 0,
},
created_at: 0,
created_by: {
id: 'user-1',
name: 'User',
email: 'user@example.com',
},
finished_at: 1,
})
const createAgentLog = (id: string, children: AgentLogItemWithChildren[] = []): AgentLogItemWithChildren => ({
node_execution_id: `execution-${id}`,
node_id: `node-${id}`,
parent_id: undefined,
label: id,
status: 'success',
data: {},
metadata: {},
message_id: id,
children,
})
describe('useLogs', () => {
beforeEach(() => {
vi.clearAllMocks()
})
it('should manage retry, iteration, and loop detail panels', () => {
const { result } = renderHook(() => useLogs())
const retryDetail = [createNodeTracing('retry-node')]
const iterationDetail = [[createNodeTracing('iteration-node')]]
const loopDetail = [[createNodeTracing('loop-node')]]
const iterationDurationMap: IterationDurationMap = { 'iteration-node': 2 }
const loopDurationMap: LoopDurationMap = { 'loop-node': 3 }
const loopVariableMap: LoopVariableMap = { 'loop-node': { item: 'value' } }
expect(result.current.showSpecialResultPanel).toBe(false)
act(() => {
result.current.handleShowRetryResultList(retryDetail)
})
expect(result.current.showRetryDetail).toBe(true)
expect(result.current.retryResultList).toEqual(retryDetail)
expect(result.current.showSpecialResultPanel).toBe(true)
act(() => {
result.current.setShowRetryDetailFalse()
result.current.handleShowIterationResultList(iterationDetail, iterationDurationMap)
result.current.handleShowLoopResultList(loopDetail, loopDurationMap, loopVariableMap)
})
expect(result.current.showRetryDetail).toBe(false)
expect(result.current.showIteratingDetail).toBe(true)
expect(result.current.iterationResultList).toEqual(iterationDetail)
expect(result.current.iterationResultDurationMap).toEqual(iterationDurationMap)
expect(result.current.showLoopingDetail).toBe(true)
expect(result.current.loopResultList).toEqual(loopDetail)
expect(result.current.loopResultDurationMap).toEqual(loopDurationMap)
expect(result.current.loopResultVariableMap).toEqual(loopVariableMap)
})
it('should push, trim, and clear agent/tool log navigation state', () => {
const { result } = renderHook(() => useLogs())
const childLog = createAgentLog('child-log')
const rootLog = createAgentLog('root-log', [childLog])
const siblingLog = createAgentLog('sibling-log')
act(() => {
result.current.handleShowAgentOrToolLog(rootLog)
})
expect(result.current.agentOrToolLogItemStack).toEqual([rootLog])
expect(result.current.agentOrToolLogListMap).toEqual({
'root-log': [childLog],
})
expect(result.current.showSpecialResultPanel).toBe(true)
act(() => {
result.current.handleShowAgentOrToolLog(siblingLog)
})
expect(result.current.agentOrToolLogItemStack).toEqual([rootLog, siblingLog])
act(() => {
result.current.handleShowAgentOrToolLog(rootLog)
})
expect(result.current.agentOrToolLogItemStack).toEqual([rootLog])
act(() => {
result.current.handleShowAgentOrToolLog(undefined)
})
expect(result.current.agentOrToolLogItemStack).toEqual([])
})
})

View File

@ -0,0 +1,356 @@
import type { ReactNode } from 'react'
import type { AgentLogItemWithChildren, NodeTracing } from '@/types/workflow'
import { fireEvent, render, screen } from '@testing-library/react'
import { BlockEnum, NodeRunningStatus } from '../../types'
import ResultPanel from '../result-panel'
const mockUseTranslation = vi.hoisted(() => vi.fn())
const mockCodeEditor = vi.hoisted(() => vi.fn())
const mockLargeDataAlert = vi.hoisted(() => vi.fn())
const mockStatusPanel = vi.hoisted(() => vi.fn())
const mockMetaData = vi.hoisted(() => vi.fn())
const mockErrorHandleTip = vi.hoisted(() => vi.fn())
const mockIterationLogTrigger = vi.hoisted(() => vi.fn())
const mockLoopLogTrigger = vi.hoisted(() => vi.fn())
const mockRetryLogTrigger = vi.hoisted(() => vi.fn())
const mockAgentLogTrigger = vi.hoisted(() => vi.fn())
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/app/components/workflow/nodes/_base/components/editor/code-editor', () => ({
__esModule: true,
default: (props: {
title: ReactNode
value: unknown
footer?: ReactNode
tip?: ReactNode
}) => {
mockCodeEditor(props)
return (
<section data-testid="code-editor">
<div>{props.title}</div>
<div>{typeof props.value === 'string' ? props.value : JSON.stringify(props.value)}</div>
{props.tip}
{props.footer}
</section>
)
},
}))
vi.mock('@/app/components/workflow/nodes/_base/components/error-handle/error-handle-tip', () => ({
__esModule: true,
default: ({ type }: { type?: string }) => {
mockErrorHandleTip(type)
return <div data-testid="error-handle-tip">{type}</div>
},
}))
vi.mock('@/app/components/workflow/run/iteration-log', () => ({
IterationLogTrigger: (props: {
onShowIterationResultList: (detail: unknown, durationMap: unknown) => void
nodeInfo: { details?: unknown, iterDurationMap?: unknown }
}) => {
mockIterationLogTrigger(props)
return (
<button
type="button"
onClick={() => props.onShowIterationResultList(props.nodeInfo.details, props.nodeInfo.iterDurationMap)}
>
iteration-trigger
</button>
)
},
}))
vi.mock('@/app/components/workflow/run/loop-log', () => ({
LoopLogTrigger: (props: {
onShowLoopResultList: (detail: unknown, durationMap: unknown) => void
nodeInfo: { details?: unknown, loopDurationMap?: unknown }
}) => {
mockLoopLogTrigger(props)
return (
<button
type="button"
onClick={() => props.onShowLoopResultList(props.nodeInfo.details, props.nodeInfo.loopDurationMap)}
>
loop-trigger
</button>
)
},
}))
vi.mock('@/app/components/workflow/run/retry-log', () => ({
RetryLogTrigger: (props: {
onShowRetryResultList: (detail: unknown) => void
nodeInfo: { retryDetail?: unknown }
}) => {
mockRetryLogTrigger(props)
return (
<button
type="button"
onClick={() => props.onShowRetryResultList(props.nodeInfo.retryDetail)}
>
retry-trigger
</button>
)
},
}))
vi.mock('@/app/components/workflow/run/agent-log', () => ({
AgentLogTrigger: (props: {
onShowAgentOrToolLog: (detail: unknown) => void
nodeInfo: { agentLog?: unknown }
}) => {
mockAgentLogTrigger(props)
return (
<button
type="button"
onClick={() => props.onShowAgentOrToolLog(props.nodeInfo.agentLog)}
>
agent-trigger
</button>
)
},
}))
vi.mock('@/app/components/workflow/variable-inspect/large-data-alert', () => ({
__esModule: true,
default: (props: { downloadUrl?: string }) => {
mockLargeDataAlert(props)
return <div data-testid="large-data-alert">{props.downloadUrl ?? 'no-download'}</div>
},
}))
vi.mock('@/app/components/workflow/run/meta', () => ({
__esModule: true,
default: (props: Record<string, unknown>) => {
mockMetaData(props)
return <div data-testid="meta-data">{JSON.stringify(props)}</div>
},
}))
vi.mock('@/app/components/workflow/run/status', () => ({
__esModule: true,
default: (props: Record<string, unknown>) => {
mockStatusPanel(props)
return <div data-testid="status-panel">{JSON.stringify(props)}</div>
},
}))
const createNodeInfo = (overrides: Partial<NodeTracing> = {}): NodeTracing => ({
id: 'trace-node-1',
index: 0,
predecessor_node_id: '',
node_id: 'node-1',
node_type: BlockEnum.Code,
title: 'Code',
inputs: {},
inputs_truncated: false,
process_data: {},
process_data_truncated: false,
outputs_truncated: false,
status: NodeRunningStatus.Succeeded,
elapsed_time: 0,
metadata: {
iterator_length: 0,
iterator_index: 0,
loop_length: 0,
loop_index: 0,
},
created_at: 0,
created_by: {
id: 'user-1',
name: 'User',
email: 'user@example.com',
},
finished_at: 1,
details: undefined,
retryDetail: undefined,
agentLog: undefined,
iterDurationMap: undefined,
loopDurationMap: undefined,
...overrides,
})
const createLogDetail = (id: string): NodeTracing => createNodeInfo({
id: `trace-${id}`,
node_id: id,
title: id,
})
const createAgentLog = (label: string): AgentLogItemWithChildren => ({
node_execution_id: `execution-${label}`,
message_id: `message-${label}`,
node_id: `node-${label}`,
parent_id: undefined,
label,
status: 'success',
data: {},
metadata: {},
children: [],
})
describe('ResultPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
})
it('should render status, editors, alerts, error strategy tip, and metadata', () => {
render(
<ResultPanel
nodeInfo={createNodeInfo()}
inputs={JSON.stringify({ topic: 'AI' })}
inputs_truncated
process_data={JSON.stringify({ step: 1 })}
process_data_truncated
outputs={{ answer: 'done' }}
outputs_truncated
outputs_full_content={{ download_url: 'https://example.com/output.json' }}
status={NodeRunningStatus.Succeeded}
error="boom"
elapsed_time={2.5}
total_tokens={42}
created_at={1710000000}
created_by="Alice"
steps={3}
showSteps
exceptionCounts={1}
execution_metadata={{ error_strategy: 'continue-on-error' }}
isListening
workflowRunId="run-1"
/>,
)
expect(screen.getByTestId('status-panel')).toBeInTheDocument()
expect(screen.getByText('COMMON.INPUT')).toBeInTheDocument()
expect(screen.getByText('COMMON.PROCESSDATA')).toBeInTheDocument()
expect(screen.getByText('COMMON.OUTPUT')).toBeInTheDocument()
expect(screen.getAllByTestId('code-editor')).toHaveLength(3)
expect(screen.getAllByTestId('large-data-alert')).toHaveLength(3)
expect(screen.getByTestId('error-handle-tip')).toHaveTextContent('continue-on-error')
expect(screen.getByTestId('meta-data')).toBeInTheDocument()
expect(mockStatusPanel).toHaveBeenCalledWith(expect.objectContaining({
status: NodeRunningStatus.Succeeded,
time: 2.5,
tokens: 42,
error: 'boom',
exceptionCounts: 1,
isListening: true,
workflowRunId: 'run-1',
}))
expect(mockMetaData).toHaveBeenCalledWith(expect.objectContaining({
status: NodeRunningStatus.Succeeded,
executor: 'Alice',
startTime: 1710000000,
time: 2.5,
tokens: 42,
steps: 3,
showSteps: true,
}))
expect(mockLargeDataAlert).toHaveBeenLastCalledWith(expect.objectContaining({
downloadUrl: 'https://example.com/output.json',
}))
})
it('should render and invoke iteration and loop triggers only when their handlers are provided', () => {
const handleShowIterationResultList = vi.fn()
const handleShowLoopResultList = vi.fn()
const details = [[createLogDetail('iter-1')]]
const { rerender } = render(
<ResultPanel
nodeInfo={createNodeInfo({
node_type: BlockEnum.Iteration,
details,
iterDurationMap: { 0: 3 },
})}
status={NodeRunningStatus.Running}
handleShowIterationResultList={handleShowIterationResultList}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'iteration-trigger' }))
expect(handleShowIterationResultList).toHaveBeenCalledWith(details, { 0: 3 })
rerender(
<ResultPanel
nodeInfo={createNodeInfo({
node_type: BlockEnum.Loop,
details,
loopDurationMap: { 0: 5 },
})}
status={NodeRunningStatus.Running}
handleShowLoopResultList={handleShowLoopResultList}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'loop-trigger' }))
expect(handleShowLoopResultList).toHaveBeenCalledWith(details, { 0: 5 })
})
it('should render retry and agent/tool triggers when the node shape supports them', () => {
const onShowRetryDetail = vi.fn()
const handleShowAgentOrToolLog = vi.fn()
const retryDetail = [createLogDetail('retry-1')]
const agentLog = [createAgentLog('tool-call')]
const { rerender } = render(
<ResultPanel
nodeInfo={createNodeInfo({
node_type: BlockEnum.Code,
retryDetail,
})}
status={NodeRunningStatus.Succeeded}
onShowRetryDetail={onShowRetryDetail}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'retry-trigger' }))
expect(onShowRetryDetail).toHaveBeenCalledWith(retryDetail)
rerender(
<ResultPanel
nodeInfo={createNodeInfo({
node_type: BlockEnum.Agent,
agentLog,
})}
status={NodeRunningStatus.Succeeded}
handleShowAgentOrToolLog={handleShowAgentOrToolLog}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'agent-trigger' }))
expect(handleShowAgentOrToolLog).toHaveBeenCalledWith(agentLog)
rerender(
<ResultPanel
nodeInfo={createNodeInfo({
node_type: BlockEnum.Tool,
agentLog,
})}
status={NodeRunningStatus.Succeeded}
handleShowAgentOrToolLog={handleShowAgentOrToolLog}
/>,
)
fireEvent.click(screen.getByRole('button', { name: 'agent-trigger' }))
expect(handleShowAgentOrToolLog).toHaveBeenLastCalledWith(agentLog)
})
it('should still render the output editor while the node is running even without outputs', () => {
render(
<ResultPanel
nodeInfo={createNodeInfo()}
inputs="{}"
status={NodeRunningStatus.Running}
/>,
)
expect(screen.getByText('COMMON.OUTPUT')).toBeInTheDocument()
})
})

View File

@ -0,0 +1,199 @@
import { fireEvent, render, screen } from '@testing-library/react'
import { getHoveredParallelId } from '../get-hovered-parallel-id'
import TracingPanel from '../tracing-panel'
const mockUseTranslation = vi.hoisted(() => vi.fn())
const mockFormatNodeList = vi.hoisted(() => vi.fn())
const mockUseLogs = vi.hoisted(() => vi.fn())
const mockNodePanel = vi.hoisted(() => vi.fn())
const mockSpecialResultPanel = vi.hoisted(() => vi.fn())
vi.mock('react-i18next', () => ({
useTranslation: () => mockUseTranslation(),
}))
vi.mock('@/app/components/workflow/run/utils/format-log', () => ({
__esModule: true,
default: (...args: unknown[]) => mockFormatNodeList(...args),
}))
vi.mock('../hooks', () => ({
useLogs: () => mockUseLogs(),
}))
vi.mock('../node', () => ({
__esModule: true,
default: (props: {
nodeInfo: { id: string }
}) => {
mockNodePanel(props)
return <div data-testid={`node-${props.nodeInfo.id}`}>{props.nodeInfo.id}</div>
},
}))
vi.mock('../special-result-panel', () => ({
__esModule: true,
default: (props: Record<string, unknown>) => {
mockSpecialResultPanel(props)
return <div data-testid="special-result-panel">special</div>
},
}))
describe('TracingPanel', () => {
beforeEach(() => {
vi.clearAllMocks()
mockUseTranslation.mockReturnValue({
t: (key: string) => key,
})
mockUseLogs.mockReturnValue({
showSpecialResultPanel: false,
showRetryDetail: false,
setShowRetryDetailFalse: vi.fn(),
retryResultList: [],
handleShowRetryResultList: vi.fn(),
showIteratingDetail: false,
setShowIteratingDetailFalse: vi.fn(),
iterationResultList: [],
iterationResultDurationMap: {},
handleShowIterationResultList: vi.fn(),
showLoopingDetail: false,
setShowLoopingDetailFalse: vi.fn(),
loopResultList: [],
loopResultDurationMap: {},
loopResultVariableMap: {},
handleShowLoopResultList: vi.fn(),
agentOrToolLogItemStack: [],
agentOrToolLogListMap: {},
handleShowAgentOrToolLog: vi.fn(),
})
})
it('should render formatted nodes, preserve branch labels, and collapse parallel groups', () => {
mockFormatNodeList.mockReturnValue([
{
id: 'parallel-1',
parallelDetail: {
isParallelStartNode: true,
parallelTitle: 'Parallel Group',
children: [{
id: 'child-1',
title: 'Child Node',
parallelDetail: {
branchTitle: 'Branch A',
},
}],
},
},
{
id: 'node-2',
title: 'Standalone Node',
parallelDetail: {
branchTitle: 'Branch B',
},
},
])
const parentClick = vi.fn()
const { container } = render(
<div onClick={parentClick}>
<TracingPanel
list={[{ id: 'raw-node' } as never]}
className="custom-class"
hideNodeInfo
hideNodeProcessDetail
/>
</div>,
)
expect(screen.getByText('Parallel Group')).toBeInTheDocument()
expect(screen.getByText('Branch A')).toBeInTheDocument()
expect(screen.getByText('Branch B')).toBeInTheDocument()
expect(screen.getByTestId('node-child-1')).toBeInTheDocument()
expect(screen.getByTestId('node-node-2')).toBeInTheDocument()
fireEvent.click(container.querySelector('.py-2') as HTMLElement)
expect(parentClick).not.toHaveBeenCalled()
const hoverTarget = screen.getByText('Parallel Group').closest('[data-parallel-id="parallel-1"]') as HTMLElement
const nestedParallelTarget = document.createElement('div')
nestedParallelTarget.setAttribute('data-parallel-id', 'parallel-1')
const unrelatedTarget = document.createElement('div')
document.body.appendChild(nestedParallelTarget)
document.body.appendChild(unrelatedTarget)
fireEvent.mouseEnter(hoverTarget)
const sameParallelOut = new MouseEvent('mouseout', { bubbles: true })
Object.defineProperty(sameParallelOut, 'relatedTarget', { value: nestedParallelTarget })
hoverTarget.dispatchEvent(sameParallelOut)
const differentTargetOut = new MouseEvent('mouseout', { bubbles: true })
Object.defineProperty(differentTargetOut, 'relatedTarget', { value: unrelatedTarget })
hoverTarget.dispatchEvent(differentTargetOut)
fireEvent.mouseLeave(hoverTarget)
fireEvent.click(screen.getAllByRole('button')[0])
expect(container.querySelector('[data-parallel-id="parallel-1"] > div:last-child')).toHaveClass('hidden')
fireEvent.click(screen.getAllByRole('button')[0])
expect(container.querySelector('[data-parallel-id="parallel-1"] > div:last-child')).not.toHaveClass('hidden')
expect(mockNodePanel).toHaveBeenCalledWith(expect.objectContaining({
hideInfo: true,
hideProcessDetail: true,
}))
nestedParallelTarget.remove()
unrelatedTarget.remove()
})
it('should switch to the special result panel when the log state requests it', () => {
mockUseLogs.mockReturnValue({
showSpecialResultPanel: true,
showRetryDetail: true,
setShowRetryDetailFalse: vi.fn(),
retryResultList: [{ id: 'retry-1' }],
handleShowRetryResultList: vi.fn(),
showIteratingDetail: true,
setShowIteratingDetailFalse: vi.fn(),
iterationResultList: [[{ id: 'iter-1' }]],
iterationResultDurationMap: { 0: 1 },
handleShowIterationResultList: vi.fn(),
showLoopingDetail: true,
setShowLoopingDetailFalse: vi.fn(),
loopResultList: [[{ id: 'loop-1' }]],
loopResultDurationMap: { 0: 2 },
loopResultVariableMap: { 0: {} },
handleShowLoopResultList: vi.fn(),
agentOrToolLogItemStack: [{ id: 'agent-1' }],
agentOrToolLogListMap: { agent: [] },
handleShowAgentOrToolLog: vi.fn(),
})
render(<TracingPanel list={[]} />)
expect(screen.getByTestId('special-result-panel')).toBeInTheDocument()
expect(mockSpecialResultPanel).toHaveBeenCalledWith(expect.objectContaining({
showRetryDetail: true,
retryResultList: [{ id: 'retry-1' }],
showIteratingDetail: true,
showLoopingDetail: true,
agentOrToolLogItemStack: [{ id: 'agent-1' }],
}))
})
it('should resolve hovered parallel ids from related targets', () => {
const sameParallelTarget = document.createElement('div')
sameParallelTarget.setAttribute('data-parallel-id', 'parallel-1')
document.body.appendChild(sameParallelTarget)
const nestedChild = document.createElement('span')
sameParallelTarget.appendChild(nestedChild)
const unrelatedTarget = document.createElement('div')
expect(getHoveredParallelId(nestedChild)).toBe('parallel-1')
expect(getHoveredParallelId(unrelatedTarget)).toBeNull()
expect(getHoveredParallelId(null)).toBeNull()
sameParallelTarget.remove()
})
})

View File

@ -0,0 +1,10 @@
export const getHoveredParallelId = (relatedTarget: EventTarget | null) => {
const element = relatedTarget as Element | null
if (element && 'closest' in element) {
const closestParallel = element.closest('[data-parallel-id]')
if (closestParallel)
return closestParallel.getAttribute('data-parallel-id')
}
return null
}

View File

@ -1,10 +1,6 @@
'use client'
import type { FC } from 'react'
import type { NodeTracing } from '@/types/workflow'
import {
RiArrowDownSLine,
RiMenu4Line,
} from '@remixicon/react'
import * as React from 'react'
import {
useCallback,
@ -14,6 +10,7 @@ import {
import { useTranslation } from 'react-i18next'
import formatNodeList from '@/app/components/workflow/run/utils/format-log'
import { cn } from '@/utils/classnames'
import { getHoveredParallelId } from './get-hovered-parallel-id'
import { useLogs } from './hooks'
import NodePanel from './node'
import SpecialResultPanel from './special-result-panel'
@ -54,18 +51,7 @@ const TracingPanel: FC<TracingPanelProps> = ({
}, [])
const handleParallelMouseLeave = useCallback((e: React.MouseEvent) => {
const relatedTarget = e.relatedTarget as Element | null
if (relatedTarget && 'closest' in relatedTarget) {
const closestParallel = relatedTarget.closest('[data-parallel-id]')
if (closestParallel)
setHoveredParallel(closestParallel.getAttribute('data-parallel-id'))
else
setHoveredParallel(null)
}
else {
setHoveredParallel(null)
}
setHoveredParallel(getHoveredParallelId(e.relatedTarget))
}, [])
const {
@ -130,7 +116,9 @@ const TracingPanel: FC<TracingPanelProps> = ({
isHovered ? 'rounded border-components-button-primary-border bg-components-button-primary-bg text-text-primary-on-surface' : 'text-text-secondary hover:text-text-primary',
)}
>
{isHovered ? <RiArrowDownSLine className="h-3 w-3" /> : <RiMenu4Line className="h-3 w-3 text-text-tertiary" />}
{isHovered
? <span aria-hidden className="i-ri-arrow-down-s-line h-3 w-3" />
: <span aria-hidden className="i-ri-menu-4-line h-3 w-3 text-text-tertiary" />}
</button>
<div className="flex items-center text-text-secondary system-xs-semibold-uppercase">
<span>{parallelDetail.parallelTitle}</span>

View File

@ -0,0 +1,199 @@
import type { NodeTracing } from '@/types/workflow'
import { BlockEnum } from '@/app/components/workflow/types'
import formatToTracingNodeList from '../index'
const mockFormatAgentNode = vi.hoisted(() => vi.fn())
const mockFormatHumanInputNode = vi.hoisted(() => vi.fn())
const mockFormatRetryNode = vi.hoisted(() => vi.fn())
const mockAddChildrenToLoopNode = vi.hoisted(() => vi.fn())
const mockAddChildrenToIterationNode = vi.hoisted(() => vi.fn())
const mockFormatParallelNode = vi.hoisted(() => vi.fn())
vi.mock('../agent', () => ({
__esModule: true,
default: (...args: unknown[]) => mockFormatAgentNode(...args),
}))
vi.mock('../human-input', () => ({
__esModule: true,
default: (...args: unknown[]) => mockFormatHumanInputNode(...args),
}))
vi.mock('../retry', () => ({
__esModule: true,
default: (...args: unknown[]) => mockFormatRetryNode(...args),
}))
vi.mock('../loop', () => ({
addChildrenToLoopNode: (...args: unknown[]) => mockAddChildrenToLoopNode(...args),
}))
vi.mock('../iteration', () => ({
addChildrenToIterationNode: (...args: unknown[]) => mockAddChildrenToIterationNode(...args),
}))
vi.mock('../parallel', () => ({
__esModule: true,
default: (...args: unknown[]) => mockFormatParallelNode(...args),
}))
const createTrace = (overrides: Partial<NodeTracing> = {}): NodeTracing => ({
id: overrides.id ?? overrides.node_id ?? 'node-1',
index: overrides.index ?? 0,
predecessor_node_id: '',
node_id: overrides.node_id ?? 'node-1',
node_type: overrides.node_type ?? BlockEnum.Tool,
title: overrides.title ?? 'Node',
inputs: {},
inputs_truncated: false,
process_data: {},
process_data_truncated: false,
outputs_truncated: false,
status: overrides.status ?? 'succeeded',
error: overrides.error,
elapsed_time: 1,
execution_metadata: overrides.execution_metadata ?? {
total_tokens: 0,
total_price: 0,
currency: 'USD',
},
metadata: {
iterator_length: 0,
iterator_index: 0,
loop_length: 0,
loop_index: 0,
},
created_at: 0,
created_by: {
id: 'user-1',
name: 'User',
email: 'user@example.com',
},
finished_at: 1,
})
const createExecutionMetadata = (overrides: Partial<NonNullable<NodeTracing['execution_metadata']>> = {}): NonNullable<NodeTracing['execution_metadata']> => ({
total_tokens: 0,
total_price: 0,
currency: 'USD',
...overrides,
})
describe('formatToTracingNodeList', () => {
beforeEach(() => {
vi.clearAllMocks()
mockFormatAgentNode.mockImplementation((list: NodeTracing[]) => list)
mockFormatHumanInputNode.mockImplementation((list: NodeTracing[]) => list)
mockFormatRetryNode.mockImplementation((list: NodeTracing[]) => list)
mockAddChildrenToLoopNode.mockImplementation((item: NodeTracing, children: NodeTracing[]) => ({
...item,
loopChildren: children.map(child => child.node_id),
details: [[{ id: 'loop-detail-row' }]],
}))
mockAddChildrenToIterationNode.mockImplementation((item: NodeTracing, children: NodeTracing[]) => ({
...item,
iterationChildren: children.map(child => child.node_id),
details: [[{ id: 'iteration-detail-row' }]],
}))
mockFormatParallelNode.mockImplementation((list: unknown[]) =>
list.map(item => ({
...(item as Record<string, unknown>),
parallelFormatted: true,
})))
})
it('should sort the input by index and run the formatter pipeline in order', () => {
const t = vi.fn((key: string) => key)
const traces = [
createTrace({ id: 'b', node_id: 'b', title: 'B', index: 2 }),
createTrace({ id: 'a', node_id: 'a', title: 'A', index: 0 }),
createTrace({ id: 'c', node_id: 'c', title: 'C', index: 1 }),
]
const result = formatToTracingNodeList(traces, t)
expect(mockFormatAgentNode).toHaveBeenCalledWith([
expect.objectContaining({ node_id: 'a' }),
expect.objectContaining({ node_id: 'c' }),
expect.objectContaining({ node_id: 'b' }),
])
expect(mockFormatHumanInputNode).toHaveBeenCalledWith(mockFormatAgentNode.mock.results[0].value)
expect(mockFormatRetryNode).toHaveBeenCalledWith(mockFormatHumanInputNode.mock.results[0].value)
expect(mockFormatParallelNode).toHaveBeenLastCalledWith(expect.any(Array), t)
expect(result).toEqual([
expect.objectContaining({ node_id: 'a', parallelFormatted: true }),
expect.objectContaining({ node_id: 'c', parallelFormatted: true }),
expect.objectContaining({ node_id: 'b', parallelFormatted: true }),
])
})
it('should collapse loop and iteration children into parent nodes and propagate child failures', () => {
const t = vi.fn((key: string) => key)
const loopParent = createTrace({
id: 'loop-parent',
node_id: 'loop-parent',
node_type: BlockEnum.Loop,
index: 0,
})
const loopChild = createTrace({
id: 'loop-child',
node_id: 'loop-child',
index: 1,
status: 'failed',
error: 'loop child failed',
execution_metadata: createExecutionMetadata({ loop_id: 'loop-parent' }),
})
const iterationParent = createTrace({
id: 'iteration-parent',
node_id: 'iteration-parent',
node_type: BlockEnum.Iteration,
index: 2,
})
const iterationChild = createTrace({
id: 'iteration-child',
node_id: 'iteration-child',
index: 3,
status: 'failed',
error: 'iteration child failed',
execution_metadata: createExecutionMetadata({ iteration_id: 'iteration-parent' }),
})
const result = formatToTracingNodeList([
loopParent,
loopChild,
iterationParent,
iterationChild,
], t)
expect(mockAddChildrenToLoopNode).toHaveBeenCalledWith(
expect.objectContaining({
node_id: 'loop-parent',
status: 'failed',
error: 'loop child failed',
}),
[expect.objectContaining({ node_id: 'loop-child' })],
)
expect(mockAddChildrenToIterationNode).toHaveBeenCalledWith(
expect.objectContaining({
node_id: 'iteration-parent',
status: 'failed',
error: 'iteration child failed',
}),
[expect.objectContaining({ node_id: 'iteration-child' })],
)
expect(mockFormatParallelNode).toHaveBeenCalledTimes(3)
expect(result).toEqual([
expect.objectContaining({
node_id: 'loop-parent',
loopChildren: ['loop-child'],
parallelFormatted: true,
}),
expect.objectContaining({
node_id: 'iteration-parent',
iterationChildren: ['iteration-child'],
parallelFormatted: true,
}),
])
})
})