Skip to content

Commit 06521ce

Browse files
fix(chat-deploy): surface child workflow outputs
1 parent f3c4d67 commit 06521ce

4 files changed

Lines changed: 177 additions & 98 deletions

File tree

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.dom.test.tsx‎

Lines changed: 88 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -5,13 +5,80 @@ import { act, useState } from 'react'
55
import { createRoot, type Root } from 'react-dom/client'
66
import { afterEach, describe, expect, it, vi } from 'vitest'
77

8+
const { workflowFixture } = vi.hoisted(() => ({
9+
workflowFixture: {
10+
rootBlocks: {
11+
invoke: {
12+
id: 'invoke',
13+
type: 'workflow_input',
14+
name: 'invokeChild',
15+
position: { x: 0, y: 0 },
16+
subBlocks: {
17+
workflowId: {
18+
id: 'workflowId',
19+
type: 'workflow-selector',
20+
value: 'child-workflow',
21+
},
22+
},
23+
outputs: {},
24+
enabled: true,
25+
data: { canonicalModes: { workflowId: 'basic' } },
26+
},
27+
},
28+
childState: {
29+
blocks: {
30+
agent: {
31+
id: 'agent',
32+
type: 'agent',
33+
name: 'researchAgent',
34+
position: { x: 0, y: 0 },
35+
subBlocks: {},
36+
outputs: {},
37+
enabled: true,
38+
},
39+
},
40+
edges: [],
41+
},
42+
},
43+
}))
44+
845
vi.mock('zustand/react/shallow', () => ({ useShallow: (selector: unknown) => selector }))
946

1047
vi.mock('@/blocks/block-tile', () => ({
1148
BlockTile: ({ blockType }: { blockType: string }) => <span data-block-type={blockType} />,
1249
}))
1350

14-
vi.mock('@/hooks/queries/workflows', () => ({ useWorkflowStates: () => new Map() }))
51+
vi.mock('@/lib/workflows/blocks/flatten-outputs', () => ({
52+
flattenWorkflowOutputs: (blocks: Iterable<{ id: string; name: string; type: string }>) =>
53+
[...blocks].flatMap((block) => {
54+
if (block.type === 'workflow_input') {
55+
return ['success', 'childWorkflowName', 'childWorkflowId', 'result', 'error'].map(
56+
(path) => ({
57+
blockId: block.id,
58+
blockName: block.name,
59+
blockType: block.type,
60+
path,
61+
})
62+
)
63+
}
64+
if (block.type === 'agent') {
65+
return [
66+
{ blockId: block.id, blockName: block.name, blockType: block.type, path: 'content' },
67+
]
68+
}
69+
return []
70+
}),
71+
}))
72+
73+
vi.mock('@/hooks/queries/workflows', () => ({
74+
useWorkflowStates: (workflowIds: string[]) =>
75+
new Map(
76+
workflowIds.map((workflowId) => [
77+
workflowId,
78+
workflowId === 'child-workflow' ? workflowFixture.childState : null,
79+
])
80+
),
81+
}))
1582

1683
vi.mock('@/stores/workflow-diff/store', () => ({
1784
useWorkflowDiffStore: (selector: (state: object) => unknown) =>
@@ -25,41 +92,14 @@ vi.mock('@/stores/workflow-diff/store', () => ({
2592

2693
vi.mock('@/stores/workflows/subblock/store', () => ({
2794
useSubBlockStore: (selector: (state: object) => unknown) =>
28-
selector({ workflowValues: { root: {} } }),
95+
selector({ workflowValues: { root: { invoke: { workflowId: 'child-workflow' } } } }),
2996
}))
3097

3198
vi.mock('@/stores/workflows/workflow/store', () => ({
32-
useWorkflowStore: (selector: (state: object) => unknown) => selector({ blocks: {}, edges: [] }),
99+
useWorkflowStore: (selector: (state: object) => unknown) =>
100+
selector({ blocks: workflowFixture.rootBlocks, edges: [] }),
33101
}))
34102

35-
vi.mock('@/lib/workflows/streaming/nested-output-options', () => {
36-
const rootOutput = {
37-
id: 'summary_content',
38-
label: 'Summarizer.content',
39-
blockId: 'summary',
40-
blockName: 'Summarizer',
41-
blockType: 'agent',
42-
groupKey: 'summary',
43-
groupLabel: 'Summarizer',
44-
path: 'content',
45-
menuPath: [],
46-
}
47-
48-
return {
49-
collectReferencedWorkflowIds: () => [],
50-
buildWorkflowOutputOptions: () => [rootOutput],
51-
buildWorkflowOutputMenu: () => [
52-
{
53-
blockId: 'summary',
54-
blockName: 'Summarizer',
55-
blockType: 'agent',
56-
outputs: [rootOutput],
57-
children: [],
58-
},
59-
],
60-
}
61-
})
62-
63103
import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select'
64104

65105
let root: Root | null = null
@@ -94,25 +134,36 @@ afterEach(() => {
94134
})
95135

96136
describe('OutputSelect DOM interaction', () => {
97-
it('selects an output through the real medium combobox', () => {
137+
it('drills into and selects a child workflow output through the real medium combobox', () => {
98138
renderPicker()
99139

100140
const trigger = document.querySelector<HTMLElement>('[role="combobox"]')
101141
if (!trigger) throw new Error('Output picker trigger did not render')
102142
act(() => trigger.click())
103143

104-
const option = [...document.querySelectorAll<HTMLElement>('[role="option"]')].find(
105-
(candidate) => candidate.textContent === 'content'
144+
expect(document.body.textContent).toContain('invokeChild')
145+
const rootOptions = [...document.querySelectorAll<HTMLElement>('[role="option"]')]
146+
const folderOption = rootOptions.find((candidate) => candidate.textContent === 'Outputs')
147+
if (!folderOption) throw new Error('Child workflow folder did not render')
148+
expect(rootOptions.indexOf(folderOption)).toBeLessThan(
149+
rootOptions.findIndex((candidate) => candidate.textContent === 'result')
106150
)
107-
if (!option) throw new Error('Output option did not render')
108-
const floatingSurface = option.closest<HTMLElement>('[data-native-surface-overlay]')
151+
const floatingSurface = folderOption.closest<HTMLElement>('[data-native-surface-overlay]')
109152
if (!floatingSurface) throw new Error('Output picker floating surface did not render')
110153

111154
expect(floatingSurface.className).toContain('pointer-events-auto')
155+
act(() => folderOption.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })))
156+
157+
expect(document.body.textContent).toContain('researchAgent')
158+
const outputOption = [...document.querySelectorAll<HTMLElement>('[role="option"]')].find(
159+
(candidate) => candidate.textContent === 'content'
160+
)
161+
if (!outputOption) throw new Error('Child workflow output did not render')
112162

113-
act(() => option.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })))
163+
act(() => outputOption.dispatchEvent(new MouseEvent('mousedown', { bubbles: true })))
114164

115165
expect(trigger.textContent).toContain('1 output')
116166
expect(document.body.textContent).toContain('Selected')
167+
expect(document.body.textContent).toContain('invokeChild / researchAgent / content')
117168
})
118169
})

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.test.tsx‎

Lines changed: 48 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,14 @@ import { createRoot, type Root } from 'react-dom/client'
66
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
77

88
const { outputMenuState } = vi.hoisted(() => ({
9-
outputMenuState: { includeNestedWorkflow: true },
9+
outputMenuState: {
10+
includeNestedWorkflow: true,
11+
workflowBlocks: {} as Record<string, object>,
12+
workflowValues: { root: {} } as Record<string, Record<string, Record<string, unknown>>>,
13+
capturedRootState: null as {
14+
blocks: Record<string, { subBlocks: Record<string, { value?: unknown }> }>
15+
} | null,
16+
},
1017
}))
1118

1219
vi.mock('@sim/emcn', () => {
@@ -93,11 +100,12 @@ vi.mock('@/stores/workflow-diff/store', () => ({
93100

94101
vi.mock('@/stores/workflows/subblock/store', () => ({
95102
useSubBlockStore: (selector: (state: object) => unknown) =>
96-
selector({ workflowValues: { root: {} } }),
103+
selector({ workflowValues: outputMenuState.workflowValues }),
97104
}))
98105

99106
vi.mock('@/stores/workflows/workflow/store', () => ({
100-
useWorkflowStore: (selector: (state: object) => unknown) => selector({ blocks: {}, edges: [] }),
107+
useWorkflowStore: (selector: (state: object) => unknown) =>
108+
selector({ blocks: outputMenuState.workflowBlocks, edges: [] }),
101109
}))
102110

103111
vi.mock('@/lib/workflows/streaming/nested-output-options', () => {
@@ -127,8 +135,14 @@ vi.mock('@/lib/workflows/streaming/nested-output-options', () => {
127135

128136
return {
129137
collectReferencedWorkflowIds: () => [],
130-
buildWorkflowOutputOptions: () =>
131-
outputMenuState.includeNestedWorkflow ? [rootOutput, nestedOutput] : [rootOutput],
138+
buildWorkflowOutputOptions: (input: {
139+
rootState: {
140+
blocks: Record<string, { subBlocks: Record<string, { value?: unknown }> }>
141+
}
142+
}) => {
143+
outputMenuState.capturedRootState = input.rootState
144+
return outputMenuState.includeNestedWorkflow ? [rootOutput, nestedOutput] : [rootOutput]
145+
},
132146
buildWorkflowOutputMenu: () => {
133147
const rootNode = {
134148
blockId: 'summary',
@@ -168,6 +182,9 @@ let container: HTMLDivElement | null = null
168182

169183
beforeEach(() => {
170184
outputMenuState.includeNestedWorkflow = true
185+
outputMenuState.workflowBlocks = {}
186+
outputMenuState.workflowValues = { root: {} }
187+
outputMenuState.capturedRootState = null
171188
})
172189

173190
function outputSelect(
@@ -246,6 +263,32 @@ describe('OutputSelect nested workflow menu', () => {
246263
expect(document.body.textContent).not.toContain('Summarizer')
247264
})
248265

266+
it('preserves a persisted workflow target when the editor value map is sparse', () => {
267+
outputMenuState.workflowBlocks = {
268+
invoke: {
269+
id: 'invoke',
270+
type: 'workflow_input',
271+
name: 'Research',
272+
position: { x: 0, y: 0 },
273+
subBlocks: {
274+
workflowId: {
275+
id: 'workflowId',
276+
type: 'workflow-selector',
277+
value: 'child-workflow',
278+
},
279+
},
280+
outputs: {},
281+
enabled: true,
282+
},
283+
}
284+
285+
renderOutputSelect([])
286+
287+
expect(outputMenuState.capturedRootState?.blocks.invoke.subBlocks.workflowId.value).toBe(
288+
'child-workflow'
289+
)
290+
})
291+
249292
it('keeps workflow-scoped values when toggling nested outputs', () => {
250293
const onOutputSelect = renderOutputSelect([])
251294
clickOption('Outputs')

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx‎

Lines changed: 8 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,7 @@ import {
99
cn,
1010
} from '@sim/emcn'
1111
import { ArrowLeft, ChevronRight } from '@sim/emcn/icons'
12+
import { mergeSubblockStateWithValues } from '@sim/workflow-persistence/subblocks'
1213
import type { BlockState, WorkflowState } from '@sim/workflow-types/workflow'
1314
import { useShallow } from 'zustand/react/shallow'
1415
import {
@@ -153,30 +154,14 @@ function OutputSelectContent({
153154
if (!workflowId || !workflowBlocks || typeof workflowBlocks !== 'object') {
154155
return { blocks: {}, edges: [] }
155156
}
156-
const blockArray = Object.values(workflowBlocks) as BlockState[]
157-
158-
const mergedBlocks = blockArray.map((block): BlockState => {
159-
const rawSubBlockValues =
160-
shouldUseBaseline && baselineWorkflow
161-
? baselineWorkflow.blocks?.[block.id]?.subBlocks
162-
: subBlockValues?.[block.id]
163-
const subBlocks: Record<string, unknown> = {}
164-
if (rawSubBlockValues && typeof rawSubBlockValues === 'object') {
165-
for (const [key, val] of Object.entries(rawSubBlockValues)) {
166-
subBlocks[key] =
167-
val && typeof val === 'object' && 'value' in (val as object)
168-
? (val as { value: unknown })
169-
: { value: val }
170-
}
171-
}
172-
return {
173-
...block,
174-
subBlocks,
175-
} as BlockState
176-
})
157+
const blockMap = workflowBlocks as Record<string, BlockState>
158+
const mergedBlocks = mergeSubblockStateWithValues(
159+
blockMap,
160+
shouldUseBaseline ? {} : (subBlockValues ?? {})
161+
)
177162

178163
return {
179-
blocks: Object.fromEntries(mergedBlocks.map((block) => [block.id, block])),
164+
blocks: mergedBlocks,
180165
edges: workflowEdges,
181166
}
182167
}, [
@@ -309,10 +294,10 @@ function OutputSelectMenu({
309294
</div>
310295
),
311296
items: [
297+
...(node.children.length > 0 ? [folderOption(node)] : []),
312298
...node.outputs
313299
.filter((output) => !selectedValueSet.has(getOutputValue(output, valueMode)))
314300
.map((output) => outputOption(output)),
315-
...(node.children.length > 0 ? [folderOption(node)] : []),
316301
],
317302
})
318303

‎apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/tool-input/tool-input.tsx‎

Lines changed: 33 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1257,39 +1257,6 @@ export const ToolInput = memo(function ToolInput({
12571257
groups.push({ items: actionItems })
12581258
}
12591259

1260-
if (availableWorkflows.length > 0) {
1261-
groups.push({
1262-
section: 'Workflows',
1263-
items: availableWorkflows.map((workflow) => {
1264-
const alreadySelected = isWorkflowAlreadySelected(selectedTools, workflow.id)
1265-
return {
1266-
label: workflow.name,
1267-
value: `workflow-${workflow.id}`,
1268-
iconElement: createToolIcon('#6366F1', WorkflowIcon),
1269-
onSelect: () => {
1270-
if (alreadySelected) return
1271-
const newTool: StoredTool = {
1272-
type: 'workflow_input',
1273-
title: 'Workflow',
1274-
toolId: 'workflow_executor',
1275-
params: {
1276-
workflowId: workflow.id,
1277-
},
1278-
isExpanded: true,
1279-
usageControl: 'auto',
1280-
}
1281-
setStoreValue([
1282-
...selectedTools.map((tool) => ({ ...tool, isExpanded: false })),
1283-
newTool,
1284-
])
1285-
setOpen(false)
1286-
},
1287-
disabled: isPreview || disabled || alreadySelected,
1288-
}
1289-
}),
1290-
})
1291-
}
1292-
12931260
if (!permissionConfig.disableCustomTools && !customUnsupported && customTools.length > 0) {
12941261
groups.push({
12951262
section: 'Custom Tools',
@@ -1388,6 +1355,39 @@ export const ToolInput = memo(function ToolInput({
13881355
})
13891356
}
13901357

1358+
if (availableWorkflows.length > 0) {
1359+
groups.push({
1360+
section: 'Workflows',
1361+
items: availableWorkflows.map((workflow) => {
1362+
const alreadySelected = isWorkflowAlreadySelected(selectedTools, workflow.id)
1363+
return {
1364+
label: workflow.name,
1365+
value: `workflow-${workflow.id}`,
1366+
iconElement: createToolIcon('#6366F1', WorkflowIcon),
1367+
onSelect: () => {
1368+
if (alreadySelected) return
1369+
const newTool: StoredTool = {
1370+
type: 'workflow_input',
1371+
title: 'Workflow',
1372+
toolId: 'workflow_executor',
1373+
params: {
1374+
workflowId: workflow.id,
1375+
},
1376+
isExpanded: true,
1377+
usageControl: 'auto',
1378+
}
1379+
setStoreValue([
1380+
...selectedTools.map((tool) => ({ ...tool, isExpanded: false })),
1381+
newTool,
1382+
])
1383+
setOpen(false)
1384+
},
1385+
disabled: isPreview || disabled || alreadySelected,
1386+
}
1387+
}),
1388+
})
1389+
}
1390+
13911391
if (!permissionConfig.disableMcpTools && supportsAdvancedMcpServer) {
13921392
groups.push({
13931393
section: 'Advanced',

0 commit comments

Comments
 (0)