Skip to content

Commit 199dc56

Browse files
feat(streaming): support nested workflow outputs
1 parent 48f72a2 commit 199dc56

20 files changed

Lines changed: 1143 additions & 210 deletions

File tree

apps/sim/app/api/chat/[identifier]/route.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
1414
import { preprocessExecution } from '@/lib/execution/preprocessing'
1515
import { LoggingSession } from '@/lib/logs/execution/logging-session'
1616
import { ChatFiles } from '@/lib/uploads'
17+
import { formatOutputSelector } from '@/lib/workflows/streaming/output-selector'
1718
import { setChatAuthCookie, validateChatAuth } from '@/app/api/chat/utils'
1819
import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils'
1920

@@ -213,9 +214,7 @@ export const POST = withRouteHandler(
213214
const selectedOutputs: string[] = []
214215
if (deployment.outputConfigs && Array.isArray(deployment.outputConfigs)) {
215216
for (const config of deployment.outputConfigs) {
216-
const outputId = config.path
217-
? `${config.blockId}_${config.path}`
218-
: `${config.blockId}_content`
217+
const outputId = formatOutputSelector(config.blockId, config.path || 'content')
219218
selectedOutputs.push(outputId)
220219
}
221220
}

apps/sim/app/api/workflows/[id]/execute/route.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,6 +141,10 @@ import {
141141
forwardAgentStreamToExecutionEvents,
142142
shouldForwardAnswerTextFromSink,
143143
} from '@/lib/workflows/streaming/forward-agent-stream-events'
144+
import {
145+
formatOutputSelector,
146+
parseOutputSelector,
147+
} from '@/lib/workflows/streaming/output-selector'
144148
import {
145149
agentStreamProtocolResponseHeaders,
146150
createStreamingResponse,
@@ -307,6 +311,11 @@ function resolveOutputIds(
307311
}
308312

309313
return selectedOutputs.map((outputId) => {
314+
if (outputId.includes('/')) {
315+
const parsed = parseOutputSelector(outputId)
316+
return formatOutputSelector(parsed.blockId, parsed.path)
317+
}
318+
310319
const underscoreIndex = outputId.indexOf('_')
311320
const dotIndex = outputId.indexOf('.')
312321
if (underscoreIndex > 0) {
Lines changed: 254 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,254 @@
1+
/**
2+
* @vitest-environment jsdom
3+
*/
4+
import { act, type ReactNode } from 'react'
5+
import { createRoot, type Root } from 'react-dom/client'
6+
import { afterEach, describe, expect, it, vi } from 'vitest'
7+
8+
vi.mock('@sim/emcn', () => ({
9+
cn: (...values: unknown[]) => values.flat().filter(Boolean).join(' '),
10+
Combobox: ({
11+
groups,
12+
multiSelectValues = [],
13+
onMultiSelectChange,
14+
}: {
15+
groups: Array<{
16+
section?: string
17+
sectionElement?: ReactNode
18+
items: Array<{
19+
label: string
20+
value: string
21+
iconElement?: ReactNode
22+
suffixElement?: ReactNode
23+
onSelect?: () => void
24+
}>
25+
}>
26+
multiSelectValues?: string[]
27+
onMultiSelectChange?: (values: string[]) => void
28+
}) => (
29+
<div>
30+
{groups.map((group, groupIndex) => (
31+
<div key={group.section ?? groupIndex}>
32+
{group.sectionElement}
33+
{group.section ? <span data-section>{group.section}</span> : null}
34+
{group.items.map((option) => (
35+
<button
36+
key={option.value}
37+
type='button'
38+
onClick={() => {
39+
if (option.onSelect) {
40+
option.onSelect()
41+
return
42+
}
43+
onMultiSelectChange?.(
44+
multiSelectValues.includes(option.value)
45+
? multiSelectValues.filter((value) => value !== option.value)
46+
: [...multiSelectValues, option.value]
47+
)
48+
}}
49+
>
50+
{option.iconElement}
51+
{option.label}
52+
{option.suffixElement}
53+
</button>
54+
))}
55+
</div>
56+
))}
57+
</div>
58+
),
59+
ChipCombobox: ({
60+
groups,
61+
multiSelectValues,
62+
onMultiSelectChange,
63+
}: {
64+
groups: Array<{ section?: string; items: Array<{ label: string; value: string }> }>
65+
multiSelectValues?: string[]
66+
onMultiSelectChange?: (values: string[]) => void
67+
}) => (
68+
<div data-chip-combobox>
69+
{groups.flatMap((group) =>
70+
group.items.map((option) => (
71+
<button
72+
key={option.value}
73+
type='button'
74+
onClick={() => onMultiSelectChange?.([...(multiSelectValues ?? []), option.value])}
75+
>
76+
{option.label}
77+
</button>
78+
))
79+
)}
80+
</div>
81+
),
82+
}))
83+
84+
vi.mock('zustand/react/shallow', () => ({ useShallow: (selector: unknown) => selector }))
85+
86+
vi.mock('@/blocks/block-tile', () => ({
87+
BlockTile: ({ blockType }: { blockType: string }) => <span data-block-type={blockType} />,
88+
}))
89+
90+
vi.mock('@/hooks/queries/workflows', () => ({ useWorkflowStates: () => new Map() }))
91+
92+
vi.mock('@/stores/workflow-diff/store', () => ({
93+
useWorkflowDiffStore: (selector: (state: object) => unknown) =>
94+
selector({
95+
isShowingDiff: false,
96+
isDiffReady: false,
97+
hasActiveDiff: false,
98+
baselineWorkflow: null,
99+
}),
100+
}))
101+
102+
vi.mock('@/stores/workflows/subblock/store', () => ({
103+
useSubBlockStore: (selector: (state: object) => unknown) =>
104+
selector({ workflowValues: { root: {} } }),
105+
}))
106+
107+
vi.mock('@/stores/workflows/workflow/store', () => ({
108+
useWorkflowStore: (selector: (state: object) => unknown) => selector({ blocks: {}, edges: [] }),
109+
}))
110+
111+
vi.mock('@/lib/workflows/streaming/nested-output-options', () => {
112+
const rootOutput = {
113+
id: 'summary_content',
114+
label: 'Summarizer.content',
115+
blockId: 'summary',
116+
blockName: 'Summarizer',
117+
blockType: 'agent',
118+
groupKey: 'summary',
119+
groupLabel: 'Summarizer',
120+
path: 'content',
121+
menuPath: [],
122+
}
123+
const nestedOutput = {
124+
id: 'workflow/agent_answer',
125+
label: 'Research.Writer.answer',
126+
blockId: 'workflow/agent',
127+
blockName: 'Writer',
128+
blockType: 'agent',
129+
groupKey: 'workflow/agent',
130+
groupLabel: 'Research / Writer',
131+
path: 'answer',
132+
menuPath: [],
133+
}
134+
135+
return {
136+
collectReferencedWorkflowIds: () => [],
137+
buildWorkflowOutputOptions: () => [rootOutput, nestedOutput],
138+
buildWorkflowOutputMenu: () => [
139+
{
140+
blockId: 'summary',
141+
blockName: 'Summarizer',
142+
blockType: 'agent',
143+
outputs: [rootOutput],
144+
children: [],
145+
},
146+
{
147+
blockId: 'workflow',
148+
blockName: 'Research',
149+
blockType: 'workflow_input',
150+
outputs: [],
151+
children: [
152+
{
153+
blockId: 'workflow/agent',
154+
blockName: 'Writer',
155+
blockType: 'agent',
156+
outputs: [nestedOutput],
157+
children: [],
158+
},
159+
],
160+
},
161+
],
162+
}
163+
})
164+
165+
import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select'
166+
167+
let root: Root | null = null
168+
let container: HTMLDivElement | null = null
169+
170+
function outputSelect(
171+
workflowId: string,
172+
selectedOutputs: string[],
173+
onOutputSelect: (outputIds: string[]) => void
174+
) {
175+
return (
176+
<OutputSelect
177+
workflowId={workflowId}
178+
selectedOutputs={selectedOutputs}
179+
onOutputSelect={onOutputSelect}
180+
/>
181+
)
182+
}
183+
184+
function renderOutputSelect(selectedOutputs: string[], onOutputSelect = vi.fn()) {
185+
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
186+
container = document.createElement('div')
187+
document.body.appendChild(container)
188+
root = createRoot(container)
189+
act(() => {
190+
root?.render(outputSelect('root', selectedOutputs, onOutputSelect))
191+
})
192+
return onOutputSelect
193+
}
194+
195+
function rerenderOutputSelect(
196+
workflowId: string,
197+
selectedOutputs: string[],
198+
onOutputSelect: (outputIds: string[]) => void
199+
) {
200+
act(() => {
201+
root?.render(outputSelect(workflowId, selectedOutputs, onOutputSelect))
202+
})
203+
}
204+
205+
afterEach(() => {
206+
if (root) act(() => root?.unmount())
207+
container?.remove()
208+
root = null
209+
container = null
210+
})
211+
212+
describe('OutputSelect nested workflow menu', () => {
213+
const clickOption = (label: string) => {
214+
const option = [...document.querySelectorAll('button')].find(
215+
(candidate) => candidate.textContent === label
216+
)
217+
if (!(option instanceof HTMLButtonElement))
218+
throw new Error(`Output option did not render: ${label}`)
219+
act(() => option.click())
220+
}
221+
222+
it('keeps root outputs visible and drills into workflow block outputs', () => {
223+
renderOutputSelect([])
224+
225+
expect(document.body.textContent).toContain('Summarizer')
226+
expect(document.body.textContent).toContain('content')
227+
expect(document.body.textContent).toContain('Research')
228+
expect(document.body.textContent).not.toContain('Writer')
229+
230+
clickOption('Outputs')
231+
expect(document.body.textContent).toContain('Back')
232+
expect(document.body.textContent).toContain('Writer')
233+
expect(document.body.textContent).toContain('answer')
234+
expect(document.body.textContent).not.toContain('Summarizer')
235+
})
236+
237+
it('keeps invocation-scoped values when toggling nested outputs', () => {
238+
const onOutputSelect = renderOutputSelect([])
239+
clickOption('Outputs')
240+
clickOption('answer')
241+
242+
expect(onOutputSelect).toHaveBeenCalledWith(['workflow/agent_answer'])
243+
})
244+
245+
it('returns to the root menu when the owning workflow changes', () => {
246+
const onOutputSelect = renderOutputSelect([])
247+
clickOption('Outputs')
248+
249+
rerenderOutputSelect('replacement', [], onOutputSelect)
250+
251+
expect(document.body.textContent).toContain('Summarizer')
252+
expect(document.body.textContent).not.toContain('Back')
253+
})
254+
})

0 commit comments

Comments
 (0)