From 373f2d639704328ee26345d2b1271efefaae8d29 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 31 Aug 2026 22:52:17 -0700 Subject: [PATCH 1/6] feat(streaming): support nested workflow outputs --- apps/sim/app/api/chat/[identifier]/route.ts | 5 +- .../app/api/workflows/[id]/execute/route.ts | 9 + .../output-select/output-select.test.tsx | 254 ++++++++++++++++ .../output-select/output-select.tsx | 284 +++++++++--------- apps/sim/executor/execution/types.ts | 2 + .../workflow/workflow-handler.test.ts | 67 ++++- .../handlers/workflow/workflow-handler.ts | 48 ++- apps/sim/hooks/queries/chats.ts | 21 +- .../webhooks/slack-execution-stream.test.ts | 62 +++- .../lib/webhooks/slack-execution-stream.ts | 25 +- .../lib/webhooks/slack-stream-config.test.ts | 4 +- apps/sim/lib/webhooks/slack-stream-config.ts | 14 +- .../lib/workflows/executor/execute-service.ts | 9 + .../workflows/executor/execute-workflow.ts | 17 +- .../streaming/nested-output-options.test.ts | 102 +++++++ .../streaming/nested-output-options.ts | 182 +++++++++++ .../streaming/output-selector.test.ts | 51 ++++ .../workflows/streaming/output-selector.ts | 118 ++++++++ .../lib/workflows/streaming/streaming.test.ts | 46 +++ apps/sim/lib/workflows/streaming/streaming.ts | 33 +- 20 files changed, 1143 insertions(+), 210 deletions(-) create mode 100644 apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.test.tsx create mode 100644 apps/sim/lib/workflows/streaming/nested-output-options.test.ts create mode 100644 apps/sim/lib/workflows/streaming/nested-output-options.ts create mode 100644 apps/sim/lib/workflows/streaming/output-selector.test.ts create mode 100644 apps/sim/lib/workflows/streaming/output-selector.ts diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts index 629fe2d9a75..ec2aa8e5f1c 100644 --- a/apps/sim/app/api/chat/[identifier]/route.ts +++ b/apps/sim/app/api/chat/[identifier]/route.ts @@ -14,6 +14,7 @@ import { withRouteHandler } from '@/lib/core/utils/with-route-handler' import { preprocessExecution } from '@/lib/execution/preprocessing' import { LoggingSession } from '@/lib/logs/execution/logging-session' import { ChatFiles } from '@/lib/uploads' +import { formatOutputSelector } from '@/lib/workflows/streaming/output-selector' import { setChatAuthCookie, validateChatAuth } from '@/app/api/chat/utils' import { createErrorResponse, createSuccessResponse } from '@/app/api/workflows/utils' @@ -213,9 +214,7 @@ export const POST = withRouteHandler( const selectedOutputs: string[] = [] if (deployment.outputConfigs && Array.isArray(deployment.outputConfigs)) { for (const config of deployment.outputConfigs) { - const outputId = config.path - ? `${config.blockId}_${config.path}` - : `${config.blockId}_content` + const outputId = formatOutputSelector(config.blockId, config.path || 'content') selectedOutputs.push(outputId) } } diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index fab9a869074..0e30e7c20a8 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -141,6 +141,10 @@ import { forwardAgentStreamToExecutionEvents, shouldForwardAnswerTextFromSink, } from '@/lib/workflows/streaming/forward-agent-stream-events' +import { + formatOutputSelector, + parseOutputSelector, +} from '@/lib/workflows/streaming/output-selector' import { agentStreamProtocolResponseHeaders, createStreamingResponse, @@ -307,6 +311,11 @@ function resolveOutputIds( } return selectedOutputs.map((outputId) => { + if (outputId.includes('/')) { + const parsed = parseOutputSelector(outputId) + return formatOutputSelector(parsed.blockId, parsed.path) + } + const underscoreIndex = outputId.indexOf('_') const dotIndex = outputId.indexOf('.') if (underscoreIndex > 0) { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.test.tsx new file mode 100644 index 00000000000..40d1d0df45a --- /dev/null +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.test.tsx @@ -0,0 +1,254 @@ +/** + * @vitest-environment jsdom + */ +import { act, type ReactNode } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/emcn', () => ({ + cn: (...values: unknown[]) => values.flat().filter(Boolean).join(' '), + Combobox: ({ + groups, + multiSelectValues = [], + onMultiSelectChange, + }: { + groups: Array<{ + section?: string + sectionElement?: ReactNode + items: Array<{ + label: string + value: string + iconElement?: ReactNode + suffixElement?: ReactNode + onSelect?: () => void + }> + }> + multiSelectValues?: string[] + onMultiSelectChange?: (values: string[]) => void + }) => ( +
+ {groups.map((group, groupIndex) => ( +
+ {group.sectionElement} + {group.section ? {group.section} : null} + {group.items.map((option) => ( + + ))} +
+ ))} +
+ ), + ChipCombobox: ({ + groups, + multiSelectValues, + onMultiSelectChange, + }: { + groups: Array<{ section?: string; items: Array<{ label: string; value: string }> }> + multiSelectValues?: string[] + onMultiSelectChange?: (values: string[]) => void + }) => ( +
+ {groups.flatMap((group) => + group.items.map((option) => ( + + )) + )} +
+ ), +})) + +vi.mock('zustand/react/shallow', () => ({ useShallow: (selector: unknown) => selector })) + +vi.mock('@/blocks/block-tile', () => ({ + BlockTile: ({ blockType }: { blockType: string }) => , +})) + +vi.mock('@/hooks/queries/workflows', () => ({ useWorkflowStates: () => new Map() })) + +vi.mock('@/stores/workflow-diff/store', () => ({ + useWorkflowDiffStore: (selector: (state: object) => unknown) => + selector({ + isShowingDiff: false, + isDiffReady: false, + hasActiveDiff: false, + baselineWorkflow: null, + }), +})) + +vi.mock('@/stores/workflows/subblock/store', () => ({ + useSubBlockStore: (selector: (state: object) => unknown) => + selector({ workflowValues: { root: {} } }), +})) + +vi.mock('@/stores/workflows/workflow/store', () => ({ + useWorkflowStore: (selector: (state: object) => unknown) => selector({ blocks: {}, edges: [] }), +})) + +vi.mock('@/lib/workflows/streaming/nested-output-options', () => { + const rootOutput = { + id: 'summary_content', + label: 'Summarizer.content', + blockId: 'summary', + blockName: 'Summarizer', + blockType: 'agent', + groupKey: 'summary', + groupLabel: 'Summarizer', + path: 'content', + menuPath: [], + } + const nestedOutput = { + id: 'workflow/agent_answer', + label: 'Research.Writer.answer', + blockId: 'workflow/agent', + blockName: 'Writer', + blockType: 'agent', + groupKey: 'workflow/agent', + groupLabel: 'Research / Writer', + path: 'answer', + menuPath: [], + } + + return { + collectReferencedWorkflowIds: () => [], + buildWorkflowOutputOptions: () => [rootOutput, nestedOutput], + buildWorkflowOutputMenu: () => [ + { + blockId: 'summary', + blockName: 'Summarizer', + blockType: 'agent', + outputs: [rootOutput], + children: [], + }, + { + blockId: 'workflow', + blockName: 'Research', + blockType: 'workflow_input', + outputs: [], + children: [ + { + blockId: 'workflow/agent', + blockName: 'Writer', + blockType: 'agent', + outputs: [nestedOutput], + children: [], + }, + ], + }, + ], + } +}) + +import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select' + +let root: Root | null = null +let container: HTMLDivElement | null = null + +function outputSelect( + workflowId: string, + selectedOutputs: string[], + onOutputSelect: (outputIds: string[]) => void +) { + return ( + + ) +} + +function renderOutputSelect(selectedOutputs: string[], onOutputSelect = vi.fn()) { + ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true + container = document.createElement('div') + document.body.appendChild(container) + root = createRoot(container) + act(() => { + root?.render(outputSelect('root', selectedOutputs, onOutputSelect)) + }) + return onOutputSelect +} + +function rerenderOutputSelect( + workflowId: string, + selectedOutputs: string[], + onOutputSelect: (outputIds: string[]) => void +) { + act(() => { + root?.render(outputSelect(workflowId, selectedOutputs, onOutputSelect)) + }) +} + +afterEach(() => { + if (root) act(() => root?.unmount()) + container?.remove() + root = null + container = null +}) + +describe('OutputSelect nested workflow menu', () => { + const clickOption = (label: string) => { + const option = [...document.querySelectorAll('button')].find( + (candidate) => candidate.textContent === label + ) + if (!(option instanceof HTMLButtonElement)) + throw new Error(`Output option did not render: ${label}`) + act(() => option.click()) + } + + it('keeps root outputs visible and drills into workflow block outputs', () => { + renderOutputSelect([]) + + expect(document.body.textContent).toContain('Summarizer') + expect(document.body.textContent).toContain('content') + expect(document.body.textContent).toContain('Research') + expect(document.body.textContent).not.toContain('Writer') + + clickOption('Outputs') + expect(document.body.textContent).toContain('Back') + expect(document.body.textContent).toContain('Writer') + expect(document.body.textContent).toContain('answer') + expect(document.body.textContent).not.toContain('Summarizer') + }) + + it('keeps invocation-scoped values when toggling nested outputs', () => { + const onOutputSelect = renderOutputSelect([]) + clickOption('Outputs') + clickOption('answer') + + expect(onOutputSelect).toHaveBeenCalledWith(['workflow/agent_answer']) + }) + + it('returns to the root menu when the owning workflow changes', () => { + const onOutputSelect = renderOutputSelect([]) + clickOption('Outputs') + + rerenderOutputSelect('replacement', [], onOutputSelect) + + expect(document.body.textContent).toContain('Summarizer') + expect(document.body.textContent).not.toContain('Back') + }) +}) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx index 27ff29ba633..6488386735b 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx @@ -1,14 +1,26 @@ 'use client' -import { useMemo } from 'react' -import { ChipCombobox, Combobox, type ComboboxOptionGroup, cn } from '@sim/emcn' +import { useMemo, useState } from 'react' +import { + ChipCombobox, + Combobox, + type ComboboxOption, + type ComboboxOptionGroup, + cn, +} from '@sim/emcn' +import { ArrowLeft, ChevronRight } from '@sim/emcn/icons' +import type { BlockState, WorkflowState } from '@sim/workflow-types/workflow' import { useShallow } from 'zustand/react/shallow' import { - type FlattenOutputsBlockInput, - flattenWorkflowOutputs, -} from '@/lib/workflows/blocks/flatten-outputs' + buildWorkflowOutputMenu, + buildWorkflowOutputOptions, + collectReferencedWorkflowIds, + type WorkflowOutputMenuNode, + type WorkflowOutputOption, +} from '@/lib/workflows/streaming/nested-output-options' import { BlockTile } from '@/blocks/block-tile' -import { normalizeName } from '@/executor/constants' +import { DEFAULTS } from '@/executor/constants' +import { useWorkflowStates } from '@/hooks/queries/workflows' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' import { useWorkflowStore } from '@/stores/workflows/workflow/store' @@ -45,6 +57,26 @@ interface OutputSelectProps { className?: string } +function getOutputValue(output: WorkflowOutputOption, valueMode: 'id' | 'label'): string { + return valueMode === 'label' && !output.blockId.includes('/') ? output.label : output.id +} + +function resolveOutputMenuNode( + roots: readonly WorkflowOutputMenuNode[], + menuPath: readonly string[] +): WorkflowOutputMenuNode | undefined { + let nodes = roots + let activeNode: WorkflowOutputMenuNode | undefined + for (const blockId of menuPath) { + activeNode = nodes.find((node) => node.blockId === blockId) + if (!activeNode) { + throw new Error(`Output menu path does not resolve: ${menuPath.join('/')}`) + } + nodes = activeNode.children + } + return activeNode +} + /** * OutputSelect component for selecting workflow block outputs * @@ -55,7 +87,11 @@ interface OutputSelectProps { * @param props - Component props * @returns The OutputSelect component */ -export function OutputSelect({ +export function OutputSelect(props: OutputSelectProps) { + return +} + +function OutputSelectContent({ workflowId, selectedOutputs = EMPTY_OUTPUTS, onOutputSelect, @@ -67,7 +103,9 @@ export function OutputSelect({ size = 'sm', className, }: OutputSelectProps) { + const [menuPath, setMenuPath] = useState([]) const blocks = useWorkflowStore((state) => state.blocks) + const edges = useWorkflowStore((state) => state.edges) const { isShowingDiff, isDiffReady, hasActiveDiff, baselineWorkflow } = useWorkflowDiffStore( useShallow((s) => ({ isShowingDiff: s.isShowingDiff, @@ -84,22 +122,16 @@ export function OutputSelect({ * Uses diff blocks when in diff mode, otherwise main blocks */ const shouldUseBaseline = hasActiveDiff && isDiffReady && !isShowingDiff && baselineWorkflow - const workflowBlocks = - shouldUseBaseline && baselineWorkflow ? baselineWorkflow.blocks : (blocks as any) + const workflowBlocks = shouldUseBaseline && baselineWorkflow ? baselineWorkflow.blocks : blocks + const workflowEdges = shouldUseBaseline && baselineWorkflow ? baselineWorkflow.edges : edges - /** - * Extracts all available workflow outputs for the dropdown - */ - const workflowOutputs = useMemo(() => { + const rootState = useMemo>(() => { if (!workflowId || !workflowBlocks || typeof workflowBlocks !== 'object') { - return [] + return { blocks: {}, edges: [] } } - const blockArray = Object.values(workflowBlocks) as any[] - if (blockArray.length === 0) return [] + const blockArray = Object.values(workflowBlocks) as BlockState[] - // Merge the editor's subblock store values into the blocks before flattening — - // the workflow store doesn't always have the latest subBlocks.value. - const mergedBlocks: FlattenOutputsBlockInput[] = blockArray.map((block) => { + const mergedBlocks = blockArray.map((block): BlockState => { const rawSubBlockValues = shouldUseBaseline && baselineWorkflow ? baselineWorkflow.blocks?.[block.id]?.subBlocks @@ -114,149 +146,109 @@ export function OutputSelect({ } } return { - id: block.id, - type: block.type, - name: block.name, - triggerMode: Boolean(block.triggerMode), + ...block, subBlocks, - } + } as BlockState }) - const flat = flattenWorkflowOutputs(mergedBlocks) - return flat.map((f) => { - const displayBlockName = - f.blockName && typeof f.blockName === 'string' - ? normalizeName(f.blockName) - : `block-${f.blockId}` - return { - id: `${f.blockId}_${f.path}`, - label: `${displayBlockName}.${f.path}`, - blockId: f.blockId, - blockName: f.blockName, - blockType: f.blockType, - path: f.path, - } - }) + return { + blocks: Object.fromEntries(mergedBlocks.map((block) => [block.id, block])), + edges: workflowEdges, + } }, [ workflowBlocks, + workflowEdges, workflowId, - isShowingDiff, - isDiffReady, baselineWorkflow, - blocks, subBlockValues, shouldUseBaseline, ]) - /** - * Gets display text for selected outputs - */ - const selectedDisplayText = useMemo(() => { - if (!selectedOutputs || selectedOutputs.length === 0) { - return placeholder - } - - const validOutputs = selectedOutputs.filter((val) => - workflowOutputs.some((o) => o.id === val || o.label === val) - ) - - if (validOutputs.length === 0) { - return placeholder - } - - if (validOutputs.length === 1) { - return '1 output' - } - - return `${validOutputs.length} outputs` - }, [selectedOutputs, workflowOutputs, placeholder]) - - /** - * Groups outputs by block and sorts by distance from starter block. - * Returns ComboboxOptionGroup[] for use with Combobox. - */ - const comboboxGroups = useMemo((): ComboboxOptionGroup[] => { - const groups: Record = {} - const blockDistances: Record = {} - const edges = useWorkflowStore.getState().edges - - const starterBlock = Object.values(blocks).find((block) => block.type === 'starter') - const starterBlockId = starterBlock?.id + const firstLevelWorkflowIds = useMemo( + () => collectReferencedWorkflowIds([rootState]), + [rootState] + ) + const firstLevelStates = useWorkflowStates(firstLevelWorkflowIds) + const secondLevelWorkflowIds = collectReferencedWorkflowIds(firstLevelStates.values()) + const secondLevelStates = useWorkflowStates(secondLevelWorkflowIds) + const thirdLevelWorkflowIds = collectReferencedWorkflowIds(secondLevelStates.values()) + const thirdLevelStates = useWorkflowStates(thirdLevelWorkflowIds) - if (starterBlockId) { - const adjList: Record = {} - edges.forEach((edge) => { - if (!adjList[edge.source]) adjList[edge.source] = [] - adjList[edge.source].push(edge.target) + const workflowStates = new Map([...firstLevelStates, ...secondLevelStates, ...thirdLevelStates]) + const workflowOutputs = workflowId + ? buildWorkflowOutputOptions({ + rootWorkflowId: workflowId, + rootState, + workflowStates, + maxChildDepth: DEFAULTS.MAX_SSE_CHILD_DEPTH, }) + : [] + const outputMenu = buildWorkflowOutputMenu(workflowOutputs) + const activeMenuNode = resolveOutputMenuNode(outputMenu, menuPath) - const visited = new Set() - const queue: Array<[string, number]> = [[starterBlockId, 0]] - - while (queue.length > 0) { - const [currentNodeId, distance] = queue.shift()! - if (visited.has(currentNodeId)) continue - - visited.add(currentNodeId) - blockDistances[currentNodeId] = distance + const validOutputCount = selectedOutputs.filter((val) => + workflowOutputs.some((output) => output.id === val || output.label === val) + ).length + let selectedDisplayText = placeholder + if (validOutputCount === 1) { + selectedDisplayText = '1 output' + } else if (validOutputCount > 1) { + selectedDisplayText = `${validOutputCount} outputs` + } - const outgoingNodeIds = adjList[currentNodeId] || [] - outgoingNodeIds.forEach((targetId) => { - queue.push([targetId, distance + 1]) - }) - } - } - - workflowOutputs.forEach((output) => { - if (!groups[output.blockName]) groups[output.blockName] = [] - groups[output.blockName].push(output) + const normalizedSelectedValues = selectedOutputs + .map((val) => { + const output = workflowOutputs.find((item) => item.id === val || item.label === val) + if (!output) return null + return getOutputValue(output, valueMode) }) + .filter((value): value is string => value !== null) - const sortedGroups = Object.entries(groups) - .map(([blockName, outputs]) => ({ - blockName, - outputs, - distance: blockDistances[outputs[0]?.blockId] || 0, - })) - .sort((a, b) => b.distance - a.distance) - - return sortedGroups.map(({ blockName, outputs }) => { - const firstOutput = outputs[0] - - return { - sectionElement: ( -
- - {blockName} -
- ), - items: outputs.map((output) => ({ - label: output.path, - value: valueMode === 'label' ? output.label : output.id, - })), - } - }) - }, [workflowOutputs, blocks, valueMode]) + const folderOption = (node: WorkflowOutputMenuNode): ComboboxOption => ({ + label: 'Outputs', + value: `folder:${node.blockId}`, + suffixElement: , + onSelect: () => setMenuPath((currentPath) => [...currentPath, node.blockId]), + keepOpen: true, + }) - /** - * Normalize selected values to match the valueMode - */ - const normalizedSelectedValues = useMemo(() => { - return selectedOutputs - .map((val) => { - // Find the output that matches either id or label - const output = workflowOutputs.find((o) => o.id === val || o.label === val) - if (!output) return null - // Return in the format matching valueMode - return valueMode === 'label' ? output.label : output.id - }) - .filter((v): v is string => v !== null) - }, [selectedOutputs, workflowOutputs, valueMode]) + const outputGroup = (node: WorkflowOutputMenuNode): ComboboxOptionGroup => ({ + sectionElement: ( +
+ + {node.blockName} +
+ ), + items: [ + ...node.outputs.map((output) => ({ + label: output.path, + value: getOutputValue(output, valueMode), + })), + ...(node.children.length > 0 ? [folderOption(node)] : []), + ], + }) + const comboboxGroups: ComboboxOptionGroup[] = activeMenuNode + ? [ + { + section: activeMenuNode.blockName, + items: [ + { + label: 'Back', + value: `back:${activeMenuNode.blockId}`, + iconElement: , + onSelect: () => setMenuPath((currentPath) => currentPath.slice(0, -1)), + keepOpen: true, + }, + ], + }, + ...activeMenuNode.children.map(outputGroup), + ] + : outputMenu.map(outputGroup) const Trigger = size === 'md' ? ChipCombobox : Combobox return ( @@ -268,6 +260,12 @@ export function OutputSelect({ multiSelect multiSelectValues={normalizedSelectedValues} onMultiSelectChange={onOutputSelect} + onOpenChange={(open) => { + if (!open) setMenuPath([]) + }} + onArrowLeft={ + activeMenuNode ? () => setMenuPath((currentPath) => currentPath.slice(0, -1)) : undefined + } placeholder={selectedDisplayText} overlayLabel={selectedDisplayText} overlayContent={selectedDisplayText} diff --git a/apps/sim/executor/execution/types.ts b/apps/sim/executor/execution/types.ts index ba7e7db6a8a..a2ac7a80ce8 100644 --- a/apps/sim/executor/execution/types.ts +++ b/apps/sim/executor/execution/types.ts @@ -197,6 +197,8 @@ export interface BlockCompletionCallbackData { endedAt: string /** Per-invocation unique ID linking this workflow block execution to its child block events. */ childWorkflowInstanceId?: string + /** Invocation-scoped block ID used only to match externally selected outputs. */ + outputBlockId?: string } export interface ExecutionCallbacks { diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts index e77cb455cae..8c314e7471b 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts @@ -2131,7 +2131,7 @@ describe('WorkflowBlockHandler', () => { expect(mockExecutorExecute).not.toHaveBeenCalled() }) - it('leaves regular workflow blocks entirely alone', async () => { + it('does not stream an unselected regular child workflow', async () => { const registry = new ResolvedSecretTraceRegistry() const ctx = { ...mockContext, @@ -2177,10 +2177,73 @@ describe('WorkflowBlockHandler', () => { }, }) ) - expect(extensions.onStream).toBe(ctx.onStream) + expect(extensions.stream).toBe(false) + expect(extensions.selectedOutputs).toEqual([]) + expect(extensions.onStream).toBeUndefined() expect(extensions.childWorkflowContext).toBeDefined() }) + it('scopes a selected regular child output through its workflow invocation', async () => { + const onStream = vi.fn() + const onBlockComplete = vi.fn() + const ctx = { + ...mockContext, + workspaceId: 'workspace-1', + stream: true, + selectedOutputs: ['workflow-block-1/agent-1_content'], + onStream, + onBlockComplete, + } as unknown as ExecutionContext + mockFetch.mockResolvedValue({ + ok: true, + json: () => + Promise.resolve({ + data: { + name: 'Child Workflow', + workspaceId: 'workspace-1', + state: { blocks: [], edges: [], loops: {}, parallels: {} }, + }, + }), + }) + + await handler.execute(ctx, mockBlock, { workflowId: 'child-workflow-id' }) + + const extensions = executorOptions[0].contextExtensions + expect(extensions.stream).toBe(true) + expect(extensions.selectedOutputs).toEqual(['agent-1_content']) + + const childStream = { + blockId: 'agent-1', + stream: new ReadableStream(), + execution: { success: true, output: {} }, + } + await extensions.onStream(childStream) + expect(onStream).toHaveBeenCalledWith({ + ...childStream, + blockId: 'workflow-block-1/agent-1', + }) + + const completion = { + output: { content: 'done' }, + executionTime: 1, + startedAt: '2026-01-01T00:00:00.000Z', + executionOrder: 1, + endedAt: '2026-01-01T00:00:00.001Z', + } + await extensions.onBlockComplete('agent-1', 'Agent', 'agent', completion) + expect(onBlockComplete).toHaveBeenCalledWith( + 'agent-1', + 'Agent', + 'agent', + { + ...completion, + outputBlockId: 'workflow-block-1/agent-1', + }, + undefined, + undefined + ) + }) + it('preserves the canonical parent origin through deeper regular children', async () => { const ctx = { ...mockContext, diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index c339a8667d1..8db8d471d8d 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -22,6 +22,10 @@ import { } from '@/lib/workflows/custom-blocks/child-execution' import { getCustomBlockAuthority } from '@/lib/workflows/custom-blocks/operations' import { extractInputFieldsFromBlocks } from '@/lib/workflows/input-format' +import { + scopeOutputBlockId, + selectChildOutputSelectors, +} from '@/lib/workflows/streaming/output-selector' import { parseWorkflowVariables } from '@/lib/workflows/variables/parse' import { type CustomBlockOutput, isCustomBlockType } from '@/blocks/custom/build-config' import type { BlockOutput } from '@/blocks/types' @@ -484,6 +488,24 @@ export class WorkflowBlockHandler implements BlockHandler { const shouldPropagateCallbacks = withinSseChildDepth && (!isCustomBlock || (traceChildRuns && Boolean(ctx.liveTraceViewerUserId))) + const effectiveBlockId = nodeMetadata + ? (nodeMetadata.originalBlockId ?? nodeMetadata.nodeId) + : block.id + const requestedChildOutputs = selectChildOutputSelectors( + effectiveBlockId, + ctx.selectedOutputs + ) + if (isCustomBlock && requestedChildOutputs.length > 0) { + throw new Error('Custom block child outputs cannot be selected for streaming') + } + if (!withinSseChildDepth && requestedChildOutputs.length > 0) { + throw new Error( + `Selected stream output exceeds the maximum child workflow depth of ${DEFAULTS.MAX_SSE_CHILD_DEPTH}` + ) + } + const childSelectedOutputs = isCustomBlock ? [] : requestedChildOutputs + const shouldStreamChild = + shouldPropagateCallbacks && Boolean(ctx.stream) && childSelectedOutputs.length > 0 if (!withinSseChildDepth && !isCustomBlock) { logger.info('Dropping SSE callbacks beyond max child depth', { @@ -494,9 +516,6 @@ export class WorkflowBlockHandler implements BlockHandler { } if (shouldPropagateCallbacks) { - const effectiveBlockId = nodeMetadata - ? (nodeMetadata.originalBlockId ?? nodeMetadata.nodeId) - : block.id const iterationContext = nodeMetadata ? getIterationContext(ctx, nodeMetadata) : undefined await ctx.onChildWorkflowInstanceReady?.( effectiveBlockId, @@ -777,11 +796,15 @@ export class WorkflowBlockHandler implements BlockHandler { } } if (shouldPropagateCallbacks) { + const childOutputBlockId = output.outputBlockId ?? blockId await parentStreamSink.onBlockComplete?.( blockId, blockName, blockType, - output, + { + ...output, + outputBlockId: scopeOutputBlockId(effectiveBlockId, childOutputBlockId), + }, iterationContext, childWorkflowContext ) @@ -789,7 +812,20 @@ export class WorkflowBlockHandler implements BlockHandler { } } if (shouldPropagateCallbacks) { - childCallbacks.onStream = ctx.onStream + if (shouldStreamChild) { + childCallbacks.onStream = async (streamingExecution) => { + if (!streamingExecution.blockId) { + throw new Error('Child workflow stream is missing its block ID') + } + if (!ctx.onStream) { + throw new Error('Child workflow stream has no parent stream callback') + } + await ctx.onStream({ + ...streamingExecution, + blockId: scopeOutputBlockId(effectiveBlockId, streamingExecution.blockId), + }) + } + } childCallbacks.onChildWorkflowInstanceReady = ctx.onChildWorkflowInstanceReady childCallbacks.childWorkflowContext = { parentBlockId: instanceId, @@ -830,6 +866,8 @@ export class WorkflowBlockHandler implements BlockHandler { // child still carries the trusted identity chain to deeper children. startRunMetadata: childStartRunMetadata ?? inherited, abortSignal: childCancellation?.signal ?? ctx.abortSignal, + stream: shouldStreamChild, + selectedOutputs: childSelectedOutputs, // Propagate in-flight block-output redaction into child workflows so // nested blocks mask outputs too (recurses: each child forwards it). piiBlockOutputRedaction: ctx.piiBlockOutputRedaction, diff --git a/apps/sim/hooks/queries/chats.ts b/apps/sim/hooks/queries/chats.ts index 5ba504b53ce..1974398bea8 100644 --- a/apps/sim/hooks/queries/chats.ts +++ b/apps/sim/hooks/queries/chats.ts @@ -19,6 +19,7 @@ import { updateChatContract, verifyChatEmailOtpContract, } from '@/lib/api/contracts/chats' +import { parseOutputSelector } from '@/lib/workflows/streaming/output-selector' import type { OutputConfig } from '@/stores/chat/types' import { deploymentKeys, invalidateDeploymentQueries } from './deployments' @@ -226,19 +227,13 @@ function throwUserFriendlyIdentifierError(error: unknown): never { * Parses output block selections into structured output configs */ function parseOutputConfigs(selectedOutputBlocks: string[]): OutputConfig[] { - return selectedOutputBlocks - .map((outputId) => { - const firstUnderscoreIndex = outputId.indexOf('_') - if (firstUnderscoreIndex !== -1) { - const blockId = outputId.substring(0, firstUnderscoreIndex) - const path = outputId.substring(firstUnderscoreIndex + 1) - if (blockId && path) { - return { blockId, path } - } - } - return null - }) - .filter((config): config is OutputConfig => config !== null) + return selectedOutputBlocks.map((outputId) => { + const parsed = parseOutputSelector(outputId) + if (!parsed.path) { + throw new Error(`Chat output selector must include a path: ${outputId}`) + } + return parsed + }) } /** diff --git a/apps/sim/lib/webhooks/slack-execution-stream.test.ts b/apps/sim/lib/webhooks/slack-execution-stream.test.ts index 41eeb048d0a..51bcd203f54 100644 --- a/apps/sim/lib/webhooks/slack-execution-stream.test.ts +++ b/apps/sim/lib/webhooks/slack-execution-stream.test.ts @@ -309,10 +309,67 @@ describe('SlackExecutionStreamController', () => { await streaming }) - it('sends selected non-streaming outputs after block completion', async () => { + it('streams transformed answer text with tool and thinking events from the event sink', async () => { + const { controller } = await createController() + const events: AgentStreamEvent[] = [ + { type: 'thinking_delta', text: 'Checking Gmail' }, + { type: 'tool_call_start', id: 'tool-1', name: 'gmail_send_email' }, + { + type: 'tool_call_end', + id: 'tool-1', + name: 'gmail_send_email', + status: 'success', + }, + { type: 'text_delta', text: 'Unselected structured response', turn: 'pending' }, + { type: 'turn_end', turn: 'final' }, + ] + + const subscribe = vi.fn(({ onEvent }) => { + for (const event of events) void onEvent(event) + return vi.fn() + }) + + await controller.callbacks.onStream?.({ + blockId: 'agent', + executionOrder: 6, + stream: createByteStream('Selected answer'), + streamFormat: 'text', + clientStreamTransformed: true, + subscribe, + }) + + expect(subscribe).toHaveBeenCalledOnce() + const appendedChunks = mockAppendSlackAgentStream.mock.calls.flatMap((call) => call[3]) + expect(appendedChunks).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'task_update', + title: 'Thinking', + status: 'complete', + }), + expect.objectContaining({ + type: 'task_update', + title: 'Gmail Send Email', + status: 'in_progress', + }), + expect.objectContaining({ + type: 'task_update', + title: 'Gmail Send Email', + status: 'complete', + }), + { type: 'markdown_text', text: 'Selected answer' }, + ]) + ) + expect(appendedChunks).not.toContainEqual({ + type: 'markdown_text', + text: 'Unselected structured response', + }) + }) + + it('sends a selected nested non-streaming output after block completion', async () => { const config: SlackStreamResponseConfig = { ...BASE_CONFIG, - outputConfigs: [{ blockId: 'lookup', path: 'result.name' }], + outputConfigs: [{ blockId: 'workflow-block/lookup', path: 'result.name' }], } const { controller } = await createController(config, { event: { channel: 'D123', timestamp: '1700000000.000001', user: 'U123' }, @@ -324,6 +381,7 @@ describe('SlackExecutionStreamController', () => { startedAt: '2026-08-31T00:00:00.000Z', executionOrder: 7, endedAt: '2026-08-31T00:00:00.010Z', + outputBlockId: 'workflow-block/lookup', }) expect(mockStartSlackAgentStream).toHaveBeenCalledWith( diff --git a/apps/sim/lib/webhooks/slack-execution-stream.ts b/apps/sim/lib/webhooks/slack-execution-stream.ts index 4cd0af4c8bb..110bef48824 100644 --- a/apps/sim/lib/webhooks/slack-execution-stream.ts +++ b/apps/sim/lib/webhooks/slack-execution-stream.ts @@ -22,6 +22,7 @@ import { type SlackStreamSessionTarget, unregisterSlackStreamSession, } from '@/lib/webhooks/slack-stream-sessions' +import { formatOutputSelector } from '@/lib/workflows/streaming/output-selector' import type { BlockCompletionCallbackData, ExecutionCallbacks } from '@/executor/execution/types' import type { ExecutionResult, StreamingExecution } from '@/executor/types' import type { AgentStreamEvent } from '@/providers/stream-events' @@ -294,8 +295,8 @@ export class SlackExecutionStreamController { ) { this.token = token this.target = target - this.selectedOutputs = options.config.outputConfigs.map( - (output) => `${output.blockId}_${output.path}` + this.selectedOutputs = options.config.outputConfigs.map((output) => + formatOutputSelector(output.blockId, output.path) ) this.callbacks = { onStream: (stream) => this.onStream(stream), @@ -391,21 +392,24 @@ export class SlackExecutionStreamController { ) this.invocations.set(key, invocation) - const forwardFromSink = Boolean(stream.subscribe) && !stream.clientStreamTransformed - const unsubscribe = forwardFromSink - ? stream.subscribe?.({ onEvent: (event) => invocation.onEvent(event) }) - : undefined + const answerFromEventSink = Boolean(stream.subscribe) && !stream.clientStreamTransformed + const unsubscribe = stream.subscribe?.({ + onEvent: async (event) => { + if (!answerFromEventSink && event.type === 'text_delta') return + await invocation.onEvent(event) + }, + }) const reader = stream.stream.getReader() const decoder = new TextDecoder() try { while (true) { const { done, value } = await reader.read() if (done) break - if (!forwardFromSink) { + if (!answerFromEventSink) { await invocation.appendProjectedBytes(decoder.decode(value, { stream: true })) } } - if (!forwardFromSink) { + if (!answerFromEventSink) { const remainder = decoder.decode() if (remainder) await invocation.appendProjectedBytes(remainder) } @@ -420,9 +424,10 @@ export class SlackExecutionStreamController { private async onBlockComplete(blockId: string, data: BlockCompletionCallbackData): Promise { try { - const selected = this.selectedForBlock(blockId) + const selectedOutputBlockId = data.outputBlockId ?? blockId + const selected = this.selectedForBlock(selectedOutputBlockId) if (selected.length === 0) return - const key = this.invocationKey(blockId, data.executionOrder) + const key = this.invocationKey(selectedOutputBlockId, data.executionOrder) if (this.invocations.has(key)) return const display = await this.options.loggingSession.projectDisplayContent( diff --git a/apps/sim/lib/webhooks/slack-stream-config.test.ts b/apps/sim/lib/webhooks/slack-stream-config.test.ts index 6ca4c522d26..5204c371405 100644 --- a/apps/sim/lib/webhooks/slack-stream-config.test.ts +++ b/apps/sim/lib/webhooks/slack-stream-config.test.ts @@ -13,7 +13,7 @@ describe('Slack stream response config', () => { const providerConfig: Record = { eventType: 'app_mention', streamResponse: true, - streamOutputs: ['block-1_content', 'block-2_result.value'], + streamOutputs: ['block-1_content', 'workflow-block/block-2_result.value'], streamIncludeThinking: true, streamIncludeToolCalls: false, streamTaskTitle: ' Working ', @@ -26,7 +26,7 @@ describe('Slack stream response config', () => { enabled: true, outputConfigs: [ { blockId: 'block-1', path: 'content' }, - { blockId: 'block-2', path: 'result.value' }, + { blockId: 'workflow-block/block-2', path: 'result.value' }, ], includeThinking: true, includeToolCalls: false, diff --git a/apps/sim/lib/webhooks/slack-stream-config.ts b/apps/sim/lib/webhooks/slack-stream-config.ts index 464af4d9b14..91946458bc0 100644 --- a/apps/sim/lib/webhooks/slack-stream-config.ts +++ b/apps/sim/lib/webhooks/slack-stream-config.ts @@ -1,4 +1,5 @@ import { isRecordLike } from '@sim/utils/object' +import { parseOutputSelector } from '@/lib/workflows/streaming/output-selector' export const SLACK_STREAM_RESPONSE_EVENTS = [ 'message', @@ -22,15 +23,12 @@ export interface SlackStreamResponseConfig { const SLACK_TASK_TITLE_LIMIT = 256 -function parseOutputSelector(selector: string): SlackStreamOutputConfig { - const separatorIndex = selector.indexOf('_') - if (separatorIndex <= 0 || separatorIndex === selector.length - 1) { +function parseSlackOutputSelector(selector: string): SlackStreamOutputConfig { + const parsed = parseOutputSelector(selector) + if (!parsed.path) { throw new Error(`Invalid Slack stream output selector: ${selector}`) } - return { - blockId: selector.slice(0, separatorIndex), - path: selector.slice(separatorIndex + 1), - } + return parsed } /** Converts trigger authoring fields into the durable Slack streaming contract. */ @@ -73,7 +71,7 @@ export function normalizeSlackStreamResponseConfig( return { enabled: true, - outputConfigs: selectors.map(parseOutputSelector), + outputConfigs: selectors.map(parseSlackOutputSelector), includeThinking: providerConfig.streamIncludeThinking === true, includeToolCalls: providerConfig.streamIncludeToolCalls !== false, taskTitle, diff --git a/apps/sim/lib/workflows/executor/execute-service.ts b/apps/sim/lib/workflows/executor/execute-service.ts index 1bfbf058b89..1d3666147c9 100644 --- a/apps/sim/lib/workflows/executor/execute-service.ts +++ b/apps/sim/lib/workflows/executor/execute-service.ts @@ -33,6 +33,10 @@ import { loadWorkflowFromNormalizedTables, } from '@/lib/workflows/persistence/utils' import { shouldEmitAgentStreamEvents } from '@/lib/workflows/streaming/agent-stream-protocol' +import { + formatOutputSelector, + parseOutputSelector, +} from '@/lib/workflows/streaming/output-selector' import { agentStreamProtocolResponseHeaders, createStreamingResponse, @@ -842,6 +846,11 @@ export function resolveOutputIds( } return selectedOutputs.map((outputId) => { + if (outputId.includes('/')) { + const parsed = parseOutputSelector(outputId) + return formatOutputSelector(parsed.blockId, parsed.path) + } + const underscoreIndex = outputId.indexOf('_') const dotIndex = outputId.indexOf('.') if (underscoreIndex > 0) { diff --git a/apps/sim/lib/workflows/executor/execute-workflow.ts b/apps/sim/lib/workflows/executor/execute-workflow.ts index 39cdad0c07a..b68b9acd4dc 100644 --- a/apps/sim/lib/workflows/executor/execute-workflow.ts +++ b/apps/sim/lib/workflows/executor/execute-workflow.ts @@ -12,7 +12,11 @@ import { captureServerEvent } from '@/lib/posthog/server' import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core' import { handlePostExecutionPauseState } from '@/lib/workflows/executor/pause-persistence' import { ExecutionSnapshot } from '@/executor/execution/snapshot' -import type { ExecutionMetadata, SerializableExecutionState } from '@/executor/execution/types' +import type { + BlockCompletionCallbackData, + ExecutionMetadata, + SerializableExecutionState, +} from '@/executor/execution/types' import type { ExecutionResult, StreamingExecution } from '@/executor/types' import { attachExecutionResult, hasExecutionResult } from '@/executor/utils/errors' import type { ResolvedSecretTraceProvenanceV1 } from '@/executor/utils/resolved-secret-trace-registry' @@ -40,7 +44,7 @@ export interface ExecuteWorkflowOptions { blockType: string, executionOrder: number ) => Promise - onBlockComplete?: (blockId: string, output: unknown) => Promise + onBlockComplete?: (blockId: string, output: unknown, outputBlockId?: string) => Promise /** Transfers post-execution logging ownership to the streaming caller after execution succeeds. */ skipLoggingComplete?: boolean includeFileBase64?: boolean @@ -195,8 +199,13 @@ export async function executeWorkflow( } : undefined, onBlockComplete: streamConfig?.onBlockComplete - ? async (blockId: string, _blockName: string, _blockType: string, output: unknown) => { - await streamConfig.onBlockComplete!(blockId, output) + ? async ( + blockId: string, + _blockName: string, + _blockType: string, + data: BlockCompletionCallbackData + ) => { + await streamConfig.onBlockComplete!(blockId, data.output, data.outputBlockId) } : undefined, }, diff --git a/apps/sim/lib/workflows/streaming/nested-output-options.test.ts b/apps/sim/lib/workflows/streaming/nested-output-options.test.ts new file mode 100644 index 00000000000..b8d8c9e42e6 --- /dev/null +++ b/apps/sim/lib/workflows/streaming/nested-output-options.test.ts @@ -0,0 +1,102 @@ +import { describe, expect, it, vi } from 'vitest' + +vi.mock('@/lib/workflows/blocks/flatten-outputs', () => ({ + flattenWorkflowOutputs: (blocks: Iterable<{ id: string; name: string; type: string }>) => + [...blocks] + .filter((candidate) => candidate.type === 'agent') + .map((candidate) => ({ + blockId: candidate.id, + blockName: candidate.name, + blockType: candidate.type, + path: 'content', + })), +})) + +import { + buildWorkflowOutputMenu, + buildWorkflowOutputOptions, + collectReferencedWorkflowIds, + getWorkflowInvocationTarget, +} from '@/lib/workflows/streaming/nested-output-options' + +function block(id: string, type: string, name: string, subBlocks = {}, data = {}) { + return { + id, + type, + name, + subBlocks, + data, + position: { x: 0, y: 0 }, + outputs: {}, + enabled: true, + } +} + +describe('nested workflow output options', () => { + it('uses the active canonical workflow ID', () => { + const workflowBlock = block( + 'invoke', + 'workflow_input', + 'Research', + { + workflowId: { value: 'basic-workflow' }, + manualWorkflowId: { value: 'advanced-workflow' }, + }, + { canonicalModes: { workflowId: 'advanced' } } + ) + + expect(getWorkflowInvocationTarget(workflowBlock)).toBe('advanced-workflow') + }) + + it('builds invocation-scoped selectors and stops cycles', () => { + const root = { + blocks: { + invoke: block('invoke', 'workflow_input', 'Research', { + workflowId: { value: 'child-workflow' }, + }), + }, + edges: [], + } + const child = { + blocks: { + agent: block('agent', 'agent', 'Writer'), + cycle: block('cycle', 'workflow_input', 'Back to root', { + workflowId: { value: 'root-workflow' }, + }), + }, + edges: [], + } + + expect(collectReferencedWorkflowIds([root])).toEqual(['child-workflow']) + const options = buildWorkflowOutputOptions({ + rootWorkflowId: 'root-workflow', + rootState: root, + workflowStates: new Map([ + ['child-workflow', child], + ['root-workflow', root], + ]), + maxChildDepth: 3, + }) + + expect(options.some((option) => option.id === 'invoke/agent_content')).toBe(true) + expect(options.some((option) => option.id.startsWith('invoke/cycle/invoke/'))).toBe(false) + + expect(buildWorkflowOutputMenu(options)).toMatchObject([ + { + blockId: 'invoke', + blockName: 'Research', + blockType: 'workflow_input', + outputs: [], + children: [ + { + blockId: 'invoke/agent', + blockName: 'Writer', + blockType: 'agent', + outputs: [{ id: 'invoke/agent_content', path: 'content' }], + children: [], + }, + ], + }, + ]) + }) +}) diff --git a/apps/sim/lib/workflows/streaming/nested-output-options.ts b/apps/sim/lib/workflows/streaming/nested-output-options.ts new file mode 100644 index 00000000000..381cdca220c --- /dev/null +++ b/apps/sim/lib/workflows/streaming/nested-output-options.ts @@ -0,0 +1,182 @@ +import type { BlockState, WorkflowState } from '@sim/workflow-types/workflow' +import { flattenWorkflowOutputs } from '@/lib/workflows/blocks/flatten-outputs' +import { scopeOutputBlockId } from '@/lib/workflows/streaming/output-selector' +import { normalizeName } from '@/executor/constants' + +const WORKFLOW_BLOCK_TYPES = new Set(['workflow', 'workflow_input']) + +export interface WorkflowOutputOption { + id: string + label: string + blockId: string + blockName: string + blockType: string + groupKey: string + groupLabel: string + path: string + menuPath: WorkflowOutputMenuSegment[] +} + +export interface WorkflowOutputMenuSegment { + blockId: string + blockName: string + blockType: string +} + +export interface WorkflowOutputMenuNode extends WorkflowOutputMenuSegment { + outputs: WorkflowOutputOption[] + children: WorkflowOutputMenuNode[] +} + +type OutputWorkflowState = Pick + +function unwrapSubBlockValue(value: unknown): unknown { + return value && typeof value === 'object' && 'value' in value + ? (value as { value: unknown }).value + : value +} + +/** Resolves the active literal child workflow selected by a regular Workflow block. */ +export function getWorkflowInvocationTarget(block: BlockState): string | undefined { + if (!WORKFLOW_BLOCK_TYPES.has(block.type)) return undefined + + const basicValue = unwrapSubBlockValue(block.subBlocks.workflowId) + const advancedValue = unwrapSubBlockValue(block.subBlocks.manualWorkflowId) + const mode = block.data?.canonicalModes?.workflowId + const selected = + mode === 'advanced' + ? advancedValue + : mode === 'basic' + ? basicValue + : typeof basicValue === 'string' && basicValue + ? basicValue + : advancedValue + + return typeof selected === 'string' && selected.trim() ? selected.trim() : undefined +} + +export function collectReferencedWorkflowIds( + states: Iterable +): string[] { + const workflowIds = new Set() + for (const state of states) { + if (!state) continue + for (const block of Object.values(state.blocks)) { + const workflowId = getWorkflowInvocationTarget(block) + if (workflowId) workflowIds.add(workflowId) + } + } + return [...workflowIds] +} + +interface BuildWorkflowOutputOptionsInput { + rootWorkflowId: string + rootState: OutputWorkflowState + workflowStates: ReadonlyMap + maxChildDepth: number +} + +/** Builds selectable outputs across regular child-workflow invocation paths. */ +export function buildWorkflowOutputOptions({ + rootWorkflowId, + rootState, + workflowStates, + maxChildDepth, +}: BuildWorkflowOutputOptionsInput): WorkflowOutputOption[] { + const options: WorkflowOutputOption[] = [] + + const visit = ( + workflowId: string, + state: OutputWorkflowState, + invocationPath: WorkflowOutputMenuSegment[], + childDepth: number, + callChain: ReadonlySet + ): void => { + const flattened = flattenWorkflowOutputs(Object.values(state.blocks), state.edges) + for (const output of flattened) { + const parentBlockId = invocationPath.at(-1)?.blockId + const blockId = parentBlockId + ? scopeOutputBlockId(parentBlockId, output.blockId) + : output.blockId + const displayBlockName = normalizeName(output.blockName || `block-${output.blockId}`) + const invocationNames = invocationPath.map((segment) => segment.blockName) + const groupLabel = + invocationNames.length > 0 + ? `${invocationNames.join(' / ')} / ${output.blockName}` + : output.blockName + options.push({ + id: `${blockId}_${output.path}`, + label: `${[...invocationNames.map(normalizeName), displayBlockName, output.path].join('.')}`, + blockId, + blockName: output.blockName, + blockType: output.blockType, + groupKey: blockId, + groupLabel, + path: output.path, + menuPath: [ + ...invocationPath, + { + blockId, + blockName: output.blockName, + blockType: output.blockType, + }, + ], + }) + } + + if (childDepth >= maxChildDepth) return + + for (const block of Object.values(state.blocks)) { + const childWorkflowId = getWorkflowInvocationTarget(block) + if (!childWorkflowId || callChain.has(childWorkflowId)) continue + const childState = workflowStates.get(childWorkflowId) + if (!childState) continue + const parentBlockId = invocationPath.at(-1)?.blockId + const blockId = parentBlockId ? scopeOutputBlockId(parentBlockId, block.id) : block.id + visit( + childWorkflowId, + childState, + [ + ...invocationPath, + { + blockId, + blockName: block.name, + blockType: block.type, + }, + ], + childDepth + 1, + new Set([...callChain, childWorkflowId]) + ) + } + } + + visit(rootWorkflowId, rootState, [], 0, new Set([rootWorkflowId])) + return options +} + +export function buildWorkflowOutputMenu( + options: readonly WorkflowOutputOption[] +): WorkflowOutputMenuNode[] { + const roots: WorkflowOutputMenuNode[] = [] + + for (const option of options) { + let siblings = roots + let node: WorkflowOutputMenuNode | undefined + + for (const segment of option.menuPath) { + node = siblings.find((candidate) => candidate.blockId === segment.blockId) + if (!node) { + node = { ...segment, outputs: [], children: [] } + siblings.push(node) + } + siblings = node.children + } + + if (!node) { + throw new Error(`Workflow output is missing its menu path: ${option.id}`) + } + node.outputs.push(option) + } + + return roots +} diff --git a/apps/sim/lib/workflows/streaming/output-selector.test.ts b/apps/sim/lib/workflows/streaming/output-selector.test.ts new file mode 100644 index 00000000000..2750ab431f5 --- /dev/null +++ b/apps/sim/lib/workflows/streaming/output-selector.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest' +import { + formatOutputSelector, + parseOutputSelector, + scopeOutputBlockId, + selectChildOutputSelectors, +} from '@/lib/workflows/streaming/output-selector' + +describe('output selector scoping', () => { + it('preserves root selectors and parses nested selectors', () => { + expect(parseOutputSelector('agent_content')).toEqual({ + blockId: 'agent', + path: 'content', + }) + expect(parseOutputSelector('workflow/agent_content.text')).toEqual({ + blockId: 'workflow/agent', + path: 'content.text', + }) + expect(parseOutputSelector('agent')).toEqual({ + blockId: 'agent', + path: '', + }) + }) + + it('scopes block IDs through multiple workflow invocations', () => { + expect(scopeOutputBlockId('outer-workflow', 'inner-workflow/agent')).toBe( + 'outer-workflow/inner-workflow/agent' + ) + expect(formatOutputSelector('outer-workflow/agent', 'content')).toBe( + 'outer-workflow/agent_content' + ) + }) + + it('selects and strips only outputs addressed to the child invocation', () => { + expect( + selectChildOutputSelectors('workflow-a', [ + 'root_content', + 'workflow-b/agent_content', + 'workflow-a/agent_content', + 'workflow-a/nested-workflow/agent_content.text', + ]) + ).toEqual(['agent_content', 'nested-workflow/agent_content.text']) + }) + + it.each(['', ' workflow/agent_content', '/agent_content', 'workflow//agent_content'])( + 'fails fast for malformed selector %j', + (selector) => { + expect(() => parseOutputSelector(selector)).toThrow('Invalid') + } + ) +}) diff --git a/apps/sim/lib/workflows/streaming/output-selector.ts b/apps/sim/lib/workflows/streaming/output-selector.ts new file mode 100644 index 00000000000..68e1eaef835 --- /dev/null +++ b/apps/sim/lib/workflows/streaming/output-selector.ts @@ -0,0 +1,118 @@ +export const OUTPUT_SCOPE_SEPARATOR = '/' +const INTERNAL_OUTPUT_PATH_SEPARATOR = '_' +const PUBLIC_OUTPUT_PATH_SEPARATOR = '.' + +export interface ParsedOutputSelector { + /** Invocation-scoped block ID, such as `workflow-block/agent-block`. */ + blockId: string + /** Dot path within the selected block output. Empty selects the whole block. */ + path: string +} + +function assertValidScopedBlockId(blockId: string): void { + if (!blockId || blockId.trim() !== blockId) { + throw new Error(`Invalid output selector block ID: ${blockId}`) + } + const segments = blockId.split(OUTPUT_SCOPE_SEPARATOR) + if (segments.some((segment) => !segment || segment.trim() !== segment)) { + throw new Error(`Invalid scoped output selector block ID: ${blockId}`) + } +} + +function parseOutputSelectorWithSeparator( + selector: string, + separator: typeof INTERNAL_OUTPUT_PATH_SEPARATOR | typeof PUBLIC_OUTPUT_PATH_SEPARATOR +): ParsedOutputSelector { + if (!selector || selector.trim() !== selector) { + throw new Error(`Invalid output selector: ${selector}`) + } + + const separatorIndex = selector.indexOf(separator) + const blockId = separatorIndex > 0 ? selector.slice(0, separatorIndex) : selector + const path = separatorIndex > 0 ? selector.slice(separatorIndex + 1) : '' + + assertValidScopedBlockId(blockId) + if (separatorIndex === 0 || (separatorIndex > 0 && !path)) { + throw new Error(`Invalid output selector: ${selector}`) + } + + return { blockId, path } +} + +/** Parses the caller-facing `blockId.path` selector form. */ +export function parsePublicOutputSelector(selector: string): ParsedOutputSelector { + return parseOutputSelectorWithSeparator(selector, PUBLIC_OUTPUT_PATH_SEPARATOR) +} + +/** Parses the executor-internal `blockId_path` selector form. */ +export function parseInternalOutputSelector(selector: string): ParsedOutputSelector { + return parseOutputSelectorWithSeparator(selector, INTERNAL_OUTPUT_PATH_SEPARATOR) +} + +/** + * Parses selectors persisted by the output picker before and after dot-form + * authoring became canonical. + */ +export function parseStoredOutputSelector(selector: string): ParsedOutputSelector { + const underscoreIndex = selector.indexOf(INTERNAL_OUTPUT_PATH_SEPARATOR) + const dotIndex = selector.indexOf(PUBLIC_OUTPUT_PATH_SEPARATOR) + return underscoreIndex > 0 && (dotIndex < 0 || underscoreIndex < dotIndex) + ? parseInternalOutputSelector(selector) + : parsePublicOutputSelector(selector) +} + +function formatOutputSelectorWithSeparator( + blockId: string, + path: string, + separator: typeof INTERNAL_OUTPUT_PATH_SEPARATOR | typeof PUBLIC_OUTPUT_PATH_SEPARATOR +): string { + assertValidScopedBlockId(blockId) + if (path.trim() !== path || path.startsWith('.') || path.endsWith('.')) { + throw new Error(`Invalid output selector path: ${path}`) + } + return path ? `${blockId}${separator}${path}` : blockId +} + +/** Formats the caller-facing selector stored in authoring state and sent over APIs. */ +export function formatPublicOutputSelector(blockId: string, path = ''): string { + return formatOutputSelectorWithSeparator(blockId, path, PUBLIC_OUTPUT_PATH_SEPARATOR) +} + +/** Formats the canonical internal selector consumed by the executor. */ +export function formatInternalOutputSelector(blockId: string, path = ''): string { + return formatOutputSelectorWithSeparator(blockId, path, INTERNAL_OUTPUT_PATH_SEPARATOR) +} + +/** Parses persisted authoring selectors in both the legacy internal and public forms. */ +export const parseOutputSelector = parseStoredOutputSelector + +/** Formats selectors for the executor's legacy internal contract. */ +export const formatOutputSelector = formatInternalOutputSelector + +export function scopeOutputBlockId(parentBlockId: string, childBlockId: string): string { + assertValidScopedBlockId(parentBlockId) + assertValidScopedBlockId(childBlockId) + return `${parentBlockId}${OUTPUT_SCOPE_SEPARATOR}${childBlockId}` +} + +/** + * Returns only selections addressed to a workflow-block invocation and removes + * that invocation segment before handing them to its child executor. + */ +export function selectChildOutputSelectors( + parentBlockId: string, + selectedOutputs: readonly string[] | undefined +): string[] { + assertValidScopedBlockId(parentBlockId) + const prefix = `${parentBlockId}${OUTPUT_SCOPE_SEPARATOR}` + const childSelectors: string[] = [] + + for (const selector of selectedOutputs ?? []) { + const parsed = parseInternalOutputSelector(selector) + if (!parsed.blockId.startsWith(prefix)) continue + const childBlockId = parsed.blockId.slice(prefix.length) + childSelectors.push(formatInternalOutputSelector(childBlockId, parsed.path)) + } + + return childSelectors +} diff --git a/apps/sim/lib/workflows/streaming/streaming.test.ts b/apps/sim/lib/workflows/streaming/streaming.test.ts index 5e5391358cc..1851d7b5209 100644 --- a/apps/sim/lib/workflows/streaming/streaming.test.ts +++ b/apps/sim/lib/workflows/streaming/streaming.test.ts @@ -180,6 +180,7 @@ describe('createStreamingResponse', () => { streamConfig: {}, executeFn: async ({ onStream }) => { await onStream({ + blockId: 'agent-1', stream: new ReadableStream({ start(controller) { controller.error(rawError) @@ -217,6 +218,46 @@ describe('createStreamingResponse', () => { expect(rawError.message).toBe(message) }) + it('emits invocation-scoped block IDs for a nested agent stream', async () => { + const stream = await createStreamingResponse({ + requestId: 'request-nested-agent', + executionId: 'execution-1', + streamConfig: { + selectedOutputs: ['workflow-block/agent-1_content'], + includeFileBase64: false, + }, + executeFn: async ({ onStream }) => { + await onStream({ + blockId: 'workflow-block/agent-1', + stream: new ReadableStream({ + start(controller) { + controller.enqueue(new TextEncoder().encode('Nested answer')) + controller.close() + }, + }), + execution: { + success: true, + output: { content: 'Nested answer' }, + logs: [], + metadata: {}, + }, + }) + return { + success: true, + output: {}, + logs: [], + metadata: { duration: 1 }, + } + }, + }) + + const events = await collectSSEEvents(stream) + expect(events).toContainEqual({ + blockId: 'workflow-block/agent-1', + chunk: 'Nested answer', + }) + }) + it('extracts block-level selected outputs from JSON content payloads', async () => { const output = { content: JSON.stringify({ answer: 'ok' }) } const stream = await createStreamingResponse({ @@ -944,6 +985,7 @@ describe('createStreamingResponse agent-events-v1', () => { }) const onStreamPromise = onStream({ + blockId: 'agent-1', stream: textStream, streamFormat: 'text', subscribe: (nextSink: { onEvent: (event: unknown) => void | Promise }) => { @@ -1173,6 +1215,7 @@ describe('createStreamingResponse agent-events-v1', () => { }) const onStreamPromise = onStream({ + blockId: 'agent-1', stream: textStream, streamFormat: 'text', subscribe: (nextSink: { onEvent: (event: unknown) => void | Promise }) => { @@ -1262,6 +1305,7 @@ describe('createStreamingResponse agent-events-v1', () => { }) const onStreamPromise = onStream({ + blockId: 'agent-1', stream: textStream, streamFormat: 'text', subscribe: (nextSink: { onEvent: (event: unknown) => void | Promise }) => { @@ -1342,6 +1386,7 @@ describe('createStreamingResponse agent-events-v1', () => { }) const onStreamPromise = onStream({ + blockId: 'agent-1', stream: textStream, streamFormat: 'text', subscribe: (nextSink: { onEvent: (event: unknown) => void | Promise }) => { @@ -1506,6 +1551,7 @@ describe('createStreamingResponse agent-events-v1', () => { }) const onStreamPromise = onStream({ + blockId: 'agent-1', stream: textStream, streamFormat: 'text', subscribe: (nextSink: any) => { diff --git a/apps/sim/lib/workflows/streaming/streaming.ts b/apps/sim/lib/workflows/streaming/streaming.ts index c330388dd0b..dda38e49dab 100644 --- a/apps/sim/lib/workflows/streaming/streaming.ts +++ b/apps/sim/lib/workflows/streaming/streaming.ts @@ -43,14 +43,6 @@ import { navigatePathAsync } from '@/executor/variables/resolvers/reference-asyn import type { ToolCallEndStatus } from '@/providers/stream-events' import { DEFAULT_MAX_THINKING_CHARS } from '@/providers/stream-pump' -/** - * Extended streaming execution type that includes blockId on the execution. - * The runtime passes blockId but the base StreamingExecution type doesn't declare it. - */ -interface StreamingExecutionWithBlockId extends Omit { - execution?: StreamingExecution['execution'] & { blockId?: string } -} - const logger = createLogger('WorkflowStreaming') const DANGEROUS_KEYS = ['__proto__', 'constructor', 'prototype'] @@ -88,7 +80,7 @@ interface StreamingConfig { export type StreamingExecutorFn = (callbacks: { onStream: (streamingExec: StreamingExecution) => Promise - onBlockComplete: (blockId: string, output: unknown) => Promise + onBlockComplete: (blockId: string, output: unknown, outputBlockId?: string) => Promise abortSignal: AbortSignal }) => Promise @@ -602,8 +594,8 @@ export async function createStreamingResponse( * Subscribe synchronously before the first await so the executor pump * can attach sinks before pulling provider chunks. */ - const onStreamCallback = async (streamingExec: StreamingExecutionWithBlockId) => { - const blockId = streamingExec.execution?.blockId + const onStreamCallback = async (streamingExec: StreamingExecution) => { + const blockId = streamingExec.blockId if (!blockId) { logger.warn(`[${requestId}] Streaming execution missing blockId`) return @@ -708,19 +700,24 @@ export async function createStreamingResponse( const includeFileBase64 = streamConfig.includeFileBase64 ?? true const base64MaxBytes = streamConfig.base64MaxBytes - const onBlockCompleteCallback = async (blockId: string, output: unknown) => { - state.completedBlockIds.add(blockId) + const onBlockCompleteCallback = async ( + blockId: string, + output: unknown, + outputBlockId?: string + ) => { + const selectedOutputBlockId = outputBlockId ?? blockId + state.completedBlockIds.add(selectedOutputBlockId) if (!streamConfig.selectedOutputs?.length) { return } - if (state.streamedChunks.has(blockId)) { + if (state.streamedChunks.has(selectedOutputBlockId)) { return } const matchingOutputs = getSelectedOutputDescriptors(streamConfig.selectedOutputs).filter( - (descriptor) => descriptor.blockId === blockId + (descriptor) => descriptor.blockId === selectedOutputBlockId ) /** @@ -792,14 +789,14 @@ export async function createStreamingResponse( getInlineJsonByteLength(hydratedOutput) ?? 0, Buffer.byteLength(formattedOutput, 'utf8') ) - sendChunk(blockId, formattedOutput, { + sendChunk(selectedOutputBlockId, formattedOutput, { selectedOutputKey: descriptor.key, selectedOutputBytes, }) } } catch (error) { logger.warn(`[${requestId}] Failed to materialize selected output`, { - blockId, + blockId: selectedOutputBlockId, outputId: descriptor.outputId, ...projectResolvedSecretDiagnosticError(error, undefined), }) @@ -807,7 +804,7 @@ export async function createStreamingResponse( state.selectedOutputError ??= errorMessage const frame: ChatStreamErrorFrame = { event: 'error', - blockId, + blockId: selectedOutputBlockId, error: errorMessage, } controller.enqueue(encodeSSE(frame)) From 6b9e6ee6053a2fa5bb7456d66e1c69cebb7f0c3c Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 31 Aug 2026 23:22:43 -0700 Subject: [PATCH 2/6] fix(streaming): reset stale output drilldowns --- .../output-select/output-select.test.tsx | 67 +++++++++++++------ .../output-select/output-select.tsx | 54 ++++++++++++++- .../lib/workflows/streaming/streaming.test.ts | 13 ++-- 3 files changed, 105 insertions(+), 29 deletions(-) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.test.tsx index 40d1d0df45a..db426d0ff3f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.test.tsx @@ -3,7 +3,11 @@ */ import { act, type ReactNode } from 'react' import { createRoot, type Root } from 'react-dom/client' -import { afterEach, describe, expect, it, vi } from 'vitest' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +const { outputMenuState } = vi.hoisted(() => ({ + outputMenuState: { includeNestedWorkflow: true }, +})) vi.mock('@sim/emcn', () => ({ cn: (...values: unknown[]) => values.flat().filter(Boolean).join(' '), @@ -134,31 +138,37 @@ vi.mock('@/lib/workflows/streaming/nested-output-options', () => { return { collectReferencedWorkflowIds: () => [], - buildWorkflowOutputOptions: () => [rootOutput, nestedOutput], - buildWorkflowOutputMenu: () => [ - { + buildWorkflowOutputOptions: () => + outputMenuState.includeNestedWorkflow ? [rootOutput, nestedOutput] : [rootOutput], + buildWorkflowOutputMenu: () => { + const rootNode = { blockId: 'summary', blockName: 'Summarizer', blockType: 'agent', outputs: [rootOutput], children: [], - }, - { - blockId: 'workflow', - blockName: 'Research', - blockType: 'workflow_input', - outputs: [], - children: [ - { - blockId: 'workflow/agent', - blockName: 'Writer', - blockType: 'agent', - outputs: [nestedOutput], - children: [], - }, - ], - }, - ], + } + return outputMenuState.includeNestedWorkflow + ? [ + rootNode, + { + blockId: 'workflow', + blockName: 'Research', + blockType: 'workflow_input', + outputs: [], + children: [ + { + blockId: 'workflow/agent', + blockName: 'Writer', + blockType: 'agent', + outputs: [nestedOutput], + children: [], + }, + ], + }, + ] + : [rootNode] + }, } }) @@ -167,6 +177,10 @@ import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/compo let root: Root | null = null let container: HTMLDivElement | null = null +beforeEach(() => { + outputMenuState.includeNestedWorkflow = true +}) + function outputSelect( workflowId: string, selectedOutputs: string[], @@ -251,4 +265,15 @@ describe('OutputSelect nested workflow menu', () => { expect(document.body.textContent).toContain('Summarizer') expect(document.body.textContent).not.toContain('Back') }) + + it('returns to the root menu when a workflow edit invalidates the active path', () => { + const onOutputSelect = renderOutputSelect([]) + clickOption('Outputs') + + outputMenuState.includeNestedWorkflow = false + rerenderOutputSelect('root', [], onOutputSelect) + + expect(document.body.textContent).toContain('Summarizer') + expect(document.body.textContent).not.toContain('Back') + }) }) diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx index 6488386735b..e16fdc73d49 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx @@ -57,6 +57,20 @@ interface OutputSelectProps { className?: string } +interface OutputSelectMenuProps { + outputMenu: readonly WorkflowOutputMenuNode[] + workflowOutputs: readonly WorkflowOutputOption[] + selectedOutputs: string[] + onOutputSelect: (outputIds: string[]) => void + disabled: boolean + placeholder: string + valueMode: 'id' | 'label' + align: 'start' | 'end' | 'center' + maxHeight: number + size: 'sm' | 'md' + className?: string +} + function getOutputValue(output: WorkflowOutputOption, valueMode: 'id' | 'label'): string { return valueMode === 'label' && !output.blockId.includes('/') ? output.label : output.id } @@ -88,7 +102,7 @@ function resolveOutputMenuNode( * @returns The OutputSelect component */ export function OutputSelect(props: OutputSelectProps) { - return + return } function OutputSelectContent({ @@ -103,7 +117,6 @@ function OutputSelectContent({ size = 'sm', className, }: OutputSelectProps) { - const [menuPath, setMenuPath] = useState([]) const blocks = useWorkflowStore((state) => state.blocks) const edges = useWorkflowStore((state) => state.edges) const { isShowingDiff, isDiffReady, hasActiveDiff, baselineWorkflow } = useWorkflowDiffStore( @@ -184,6 +197,43 @@ function OutputSelectContent({ }) : [] const outputMenu = buildWorkflowOutputMenu(workflowOutputs) + const outputMenuRevision = JSON.stringify([ + workflowId, + ...workflowOutputs.map((output) => output.id), + ]) + + return ( + + ) +} + +function OutputSelectMenu({ + outputMenu, + workflowOutputs, + selectedOutputs, + onOutputSelect, + disabled, + placeholder, + valueMode, + align, + maxHeight, + size, + className, +}: OutputSelectMenuProps) { + const [menuPath, setMenuPath] = useState([]) const activeMenuNode = resolveOutputMenuNode(outputMenu, menuPath) const validOutputCount = selectedOutputs.filter((val) => diff --git a/apps/sim/lib/workflows/streaming/streaming.test.ts b/apps/sim/lib/workflows/streaming/streaming.test.ts index 1851d7b5209..efae7687624 100644 --- a/apps/sim/lib/workflows/streaming/streaming.test.ts +++ b/apps/sim/lib/workflows/streaming/streaming.test.ts @@ -9,6 +9,7 @@ import { agentStreamProtocolResponseHeaders, createStreamingResponse, } from '@/lib/workflows/streaming/streaming' +import type { AgentStreamSink } from '@/providers/stream-events' const workflowStreamingLoggerCallIndex = loggerMock.createLogger.mock.calls.findIndex( ([name]) => name === 'WorkflowStreaming' @@ -1207,7 +1208,7 @@ describe('createStreamingResponse agent-events-v1', () => { }, executeFn: async ({ onStream }) => { let textController!: ReadableStreamDefaultController - let sink: { onEvent: (event: unknown) => void | Promise } | undefined + let sink: AgentStreamSink | undefined const textStream = new ReadableStream({ start(controller) { textController = controller @@ -1218,7 +1219,7 @@ describe('createStreamingResponse agent-events-v1', () => { blockId: 'agent-1', stream: textStream, streamFormat: 'text', - subscribe: (nextSink: { onEvent: (event: unknown) => void | Promise }) => { + subscribe: (nextSink: AgentStreamSink) => { sink = nextSink return () => {} }, @@ -1297,7 +1298,7 @@ describe('createStreamingResponse agent-events-v1', () => { }, executeFn: async ({ onStream }) => { let textController!: ReadableStreamDefaultController - let sink: { onEvent: (event: unknown) => void | Promise } | undefined + let sink: AgentStreamSink | undefined const textStream = new ReadableStream({ start(controller) { textController = controller @@ -1308,7 +1309,7 @@ describe('createStreamingResponse agent-events-v1', () => { blockId: 'agent-1', stream: textStream, streamFormat: 'text', - subscribe: (nextSink: { onEvent: (event: unknown) => void | Promise }) => { + subscribe: (nextSink: AgentStreamSink) => { sink = nextSink return () => {} }, @@ -1543,7 +1544,7 @@ describe('createStreamingResponse agent-events-v1', () => { }, executeFn: async ({ onStream }) => { let textController!: ReadableStreamDefaultController - let sink: { onEvent: (event: unknown) => void | Promise } | undefined + let sink: AgentStreamSink | undefined const textStream = new ReadableStream({ start(controller) { textController = controller @@ -1554,7 +1555,7 @@ describe('createStreamingResponse agent-events-v1', () => { blockId: 'agent-1', stream: textStream, streamFormat: 'text', - subscribe: (nextSink: any) => { + subscribe: (nextSink: AgentStreamSink) => { sink = nextSink return () => { sink = undefined From 4245489a66ca2eca54904c19433baae769baf52d Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Mon, 31 Aug 2026 23:46:01 -0700 Subject: [PATCH 3/6] fix(streaming): validate nested output selectors --- .../[workflowId]/execute/route.test.ts | 26 +++++++++++ .../app/api/workflows/[id]/execute/route.ts | 4 +- .../executor/execution/block-executor.test.ts | 27 +++++++++++ apps/sim/executor/execution/block-executor.ts | 2 +- apps/sim/hooks/queries/chats.test.tsx | 21 +++++++++ apps/sim/hooks/queries/chats.ts | 10 ++-- .../api/contracts/chat-output-config.test.ts | 31 +++++++++++++ apps/sim/lib/api/contracts/chats.ts | 18 ++++++-- .../lib/api/contracts/v2/chat-deployments.ts | 9 ++++ apps/sim/lib/webhooks/slack-stream-config.ts | 4 +- .../lib/workflows/executor/execute-service.ts | 16 +++++-- .../streaming/output-selector.test.ts | 46 +++++++++++++++---- .../workflows/streaming/output-selector.ts | 33 +++++++++---- 13 files changed, 210 insertions(+), 37 deletions(-) create mode 100644 apps/sim/lib/api/contracts/chat-output-config.test.ts diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts index d15821b6e27..f053e8b28e4 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts @@ -579,6 +579,32 @@ describe('POST /api/v2/workflows/[workflowId]/execute', () => { ) }) + it('maps malformed nested output selectors to an input failure', async () => { + const result = await executeWorkflowService({ + workflowId: 'workflow-1', + principal: { kind: 'personal_api_key', userId: 'actor-1', keyId: 'key-1' }, + userId: 'actor-1', + input: {}, + triggerType: 'api', + requestId: 'request-1', + workflowRecord, + selectedOutputs: ['workflow//agent.content'], + mode: 'stream', + requestHeaders: new Headers(), + }) + + expect(result).toEqual({ + ok: false, + failure: { + kind: 'input', + message: + 'Invalid selectedOutputs: Invalid scoped output selector block ID: workflow//agent', + statusCode: 400, + }, + }) + expect(mockReleaseExecutionSlot).toHaveBeenCalledWith('execution-123') + }) + it('rejects async manual execution and conflicting mock input before dispatch', async () => { authenticatePersonalKey() diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 0e30e7c20a8..939025fbde5 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -143,7 +143,7 @@ import { } from '@/lib/workflows/streaming/forward-agent-stream-events' import { formatOutputSelector, - parseOutputSelector, + parseStoredOutputSelector, } from '@/lib/workflows/streaming/output-selector' import { agentStreamProtocolResponseHeaders, @@ -312,7 +312,7 @@ function resolveOutputIds( return selectedOutputs.map((outputId) => { if (outputId.includes('/')) { - const parsed = parseOutputSelector(outputId) + const parsed = parseStoredOutputSelector(outputId) return formatOutputSelector(parsed.blockId, parsed.path) } diff --git a/apps/sim/executor/execution/block-executor.test.ts b/apps/sim/executor/execution/block-executor.test.ts index c0758935e81..250542ee7be 100644 --- a/apps/sim/executor/execution/block-executor.test.ts +++ b/apps/sim/executor/execution/block-executor.test.ts @@ -1352,6 +1352,33 @@ describe('BlockExecutor streaming pump', () => { ) }) + it('forwards the stable block ID for streams from expanded branch nodes', async () => { + const handler = createAgentEventsStreamingHandler({ + events: [{ type: 'text_delta', text: 'branch answer', turn: 'final' }], + }) + const { executor, block, state } = createExecutor(handler) + const ctx = createContext(state) + const node = createNode(block) + node.id = `${block.id}₍0₎` + node.metadata = { + isParallelBranch: true, + subflowId: 'parallel-1', + subflowType: 'parallel', + originalBlockId: block.id, + branchIndex: 0, + } + let streamedBlockId: string | undefined + + ctx.onStream = async (streamingExec) => { + streamedBlockId = streamingExec.blockId + await new Response(streamingExec.stream).text() + } + + await executor.execute(ctx, node, block) + + expect(streamedBlockId).toBe(block.id) + }) + it('drains without onStream and still persists answer content', async () => { const handler = createAgentEventsStreamingHandler({ events: [{ type: 'text_delta', text: 'offline answer', turn: 'final' }], diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index ee3a9928f2a..313989475aa 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -1154,7 +1154,7 @@ export class BlockExecutor { selectedOutputs: string[], executionOrder?: number ): Promise { - const blockId = node.id + const blockId = node.metadata?.originalBlockId ?? node.id const piiEnabled = Boolean(ctx.piiBlockOutputRedaction?.enabled) // Live-forward only when a client stream exists and PII redaction is off. const forwardToClient = Boolean(ctx.onStream) && !piiEnabled diff --git a/apps/sim/hooks/queries/chats.test.tsx b/apps/sim/hooks/queries/chats.test.tsx index c485fc1aa6f..ab257103e3f 100644 --- a/apps/sim/hooks/queries/chats.test.tsx +++ b/apps/sim/hooks/queries/chats.test.tsx @@ -118,4 +118,25 @@ describe('chat mutations invalidate the deployment boundary', () => { expect(mockInvalidateDeploymentQueries).toHaveBeenCalledWith(expect.anything(), 'wf-1') }) + + it('normalizes a legacy empty output path to the deployed chat fallback', async () => { + const { getResult } = renderHookWithClient(() => useUpdateChat()) + + await act(async () => { + await getResult().mutateAsync({ + chatId: 'chat-1', + workflowId: 'wf-1', + formData: { ...FORM_DATA, selectedOutputBlocks: ['agent-block_'] }, + }) + }) + + expect(mockRequestJson).toHaveBeenCalledWith( + expect.anything(), + expect.objectContaining({ + body: expect.objectContaining({ + outputConfigs: [{ blockId: 'agent-block', path: 'content' }], + }), + }) + ) + }) }) diff --git a/apps/sim/hooks/queries/chats.ts b/apps/sim/hooks/queries/chats.ts index 1974398bea8..dd1401b5d95 100644 --- a/apps/sim/hooks/queries/chats.ts +++ b/apps/sim/hooks/queries/chats.ts @@ -19,7 +19,7 @@ import { updateChatContract, verifyChatEmailOtpContract, } from '@/lib/api/contracts/chats' -import { parseOutputSelector } from '@/lib/workflows/streaming/output-selector' +import { parseInternalOutputSelector } from '@/lib/workflows/streaming/output-selector' import type { OutputConfig } from '@/stores/chat/types' import { deploymentKeys, invalidateDeploymentQueries } from './deployments' @@ -228,11 +228,9 @@ function throwUserFriendlyIdentifierError(error: unknown): never { */ function parseOutputConfigs(selectedOutputBlocks: string[]): OutputConfig[] { return selectedOutputBlocks.map((outputId) => { - const parsed = parseOutputSelector(outputId) - if (!parsed.path) { - throw new Error(`Chat output selector must include a path: ${outputId}`) - } - return parsed + const normalizedOutputId = outputId.endsWith('_') ? outputId.slice(0, -1) : outputId + const parsed = parseInternalOutputSelector(normalizedOutputId) + return { ...parsed, path: parsed.path || 'content' } }) } diff --git a/apps/sim/lib/api/contracts/chat-output-config.test.ts b/apps/sim/lib/api/contracts/chat-output-config.test.ts new file mode 100644 index 00000000000..5d4b80f61f8 --- /dev/null +++ b/apps/sim/lib/api/contracts/chat-output-config.test.ts @@ -0,0 +1,31 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { chatOutputConfigSchema } from '@/lib/api/contracts/chats' +import { v2ChatDeploymentOutputConfigSchema } from '@/lib/api/contracts/v2/chat-deployments' + +const OUTPUT_CONFIG_SCHEMAS = [chatOutputConfigSchema, v2ChatDeploymentOutputConfigSchema] + +describe('chat output config contracts', () => { + it.each(OUTPUT_CONFIG_SCHEMAS)('accepts invocation-scoped output selectors', (schema) => { + expect( + schema.safeParse({ blockId: 'workflow-block/agent-block', path: 'content.text' }).success + ).toBe(true) + }) + + it.each(OUTPUT_CONFIG_SCHEMAS)( + 'rejects output selectors the executor cannot format', + (schema) => { + for (const config of [ + { blockId: '/agent-block', path: 'content' }, + { blockId: 'workflow-block/', path: 'content' }, + { blockId: 'agent-block', path: '.content' }, + { blockId: 'agent-block', path: 'content.' }, + { blockId: 'agent-block', path: 'content..text' }, + ]) { + expect(schema.safeParse(config).success).toBe(false) + } + } + ) +}) diff --git a/apps/sim/lib/api/contracts/chats.ts b/apps/sim/lib/api/contracts/chats.ts index 082d3f434b5..f72e0e5383e 100644 --- a/apps/sim/lib/api/contracts/chats.ts +++ b/apps/sim/lib/api/contracts/chats.ts @@ -1,5 +1,7 @@ +import { getErrorMessage } from '@sim/utils/errors' import { z } from 'zod' import { defineRouteContract } from '@/lib/api/contracts/types' +import { formatInternalOutputSelector } from '@/lib/workflows/streaming/output-selector' export const chatAuthTypeSchema = z.enum(['public', 'password', 'email', 'sso']) export type ChatAuthType = z.output @@ -38,10 +40,18 @@ export const chatIdentifierParamsSchema = z.object({ identifier: z.string().min(1), }) -export const chatOutputConfigSchema = z.object({ - blockId: z.string().min(1), - path: z.string().min(1), -}) +export const chatOutputConfigSchema = z + .object({ + blockId: z.string().min(1), + path: z.string().min(1), + }) + .superRefine((config, ctx) => { + try { + formatInternalOutputSelector(config.blockId, config.path) + } catch (error) { + ctx.addIssue({ code: 'custom', message: getErrorMessage(error, 'Invalid output config') }) + } + }) export const deployedChatOutputConfigSchema = z.object({ blockId: z.string(), diff --git a/apps/sim/lib/api/contracts/v2/chat-deployments.ts b/apps/sim/lib/api/contracts/v2/chat-deployments.ts index 223e7256fef..9766e80309a 100644 --- a/apps/sim/lib/api/contracts/v2/chat-deployments.ts +++ b/apps/sim/lib/api/contracts/v2/chat-deployments.ts @@ -1,3 +1,4 @@ +import { getErrorMessage } from '@sim/utils/errors' import { z } from 'zod' import { chatAuthTypeSchema, chatDeploymentPasswordSchema } from '@/lib/api/contracts/chats' import { @@ -14,6 +15,7 @@ import { v2SortFields, } from '@/lib/api/contracts/v2/shared' import { v2WorkflowIdParamsSchema } from '@/lib/api/contracts/v2/workflows' +import { formatInternalOutputSelector } from '@/lib/workflows/streaming/output-selector' /** * v2 chat-deployment contracts. @@ -120,6 +122,13 @@ export const v2ChatDeploymentOutputConfigSchema = z .describe('Path within that block output.'), }) .strict() + .superRefine((config, ctx) => { + try { + formatInternalOutputSelector(config.blockId, config.path) + } catch (error) { + ctx.addIssue({ code: 'custom', message: getErrorMessage(error, 'Invalid output config') }) + } + }) .meta({ id: 'ChatDeploymentOutputConfig', title: 'Chat deployment output config', diff --git a/apps/sim/lib/webhooks/slack-stream-config.ts b/apps/sim/lib/webhooks/slack-stream-config.ts index 91946458bc0..e54d92884f5 100644 --- a/apps/sim/lib/webhooks/slack-stream-config.ts +++ b/apps/sim/lib/webhooks/slack-stream-config.ts @@ -1,5 +1,5 @@ import { isRecordLike } from '@sim/utils/object' -import { parseOutputSelector } from '@/lib/workflows/streaming/output-selector' +import { parseInternalOutputSelector } from '@/lib/workflows/streaming/output-selector' export const SLACK_STREAM_RESPONSE_EVENTS = [ 'message', @@ -24,7 +24,7 @@ export interface SlackStreamResponseConfig { const SLACK_TASK_TITLE_LIMIT = 256 function parseSlackOutputSelector(selector: string): SlackStreamOutputConfig { - const parsed = parseOutputSelector(selector) + const parsed = parseInternalOutputSelector(selector) if (!parsed.path) { throw new Error(`Invalid Slack stream output selector: ${selector}`) } diff --git a/apps/sim/lib/workflows/executor/execute-service.ts b/apps/sim/lib/workflows/executor/execute-service.ts index 1d3666147c9..f1878eabc68 100644 --- a/apps/sim/lib/workflows/executor/execute-service.ts +++ b/apps/sim/lib/workflows/executor/execute-service.ts @@ -35,7 +35,7 @@ import { import { shouldEmitAgentStreamEvents } from '@/lib/workflows/streaming/agent-stream-protocol' import { formatOutputSelector, - parseOutputSelector, + parsePublicOutputSelector, } from '@/lib/workflows/streaming/output-selector' import { agentStreamProtocolResponseHeaders, @@ -476,7 +476,17 @@ export async function executeWorkflowService( } if (mode === 'stream') { - const resolvedSelectedOutputs = resolveOutputIds(selectedOutputs, workflowBlocks) + let resolvedSelectedOutputs: string[] | undefined + try { + resolvedSelectedOutputs = resolveOutputIds(selectedOutputs, workflowBlocks) + } catch (error) { + await releaseExecutionSlot(executionId) + return failure({ + kind: 'input', + message: `Invalid selectedOutputs: ${getErrorMessage(error)}`, + statusCode: 400, + }) + } const streamWorkflow = { id: workflow.id, /** @@ -847,7 +857,7 @@ export function resolveOutputIds( return selectedOutputs.map((outputId) => { if (outputId.includes('/')) { - const parsed = parseOutputSelector(outputId) + const parsed = parsePublicOutputSelector(outputId) return formatOutputSelector(parsed.blockId, parsed.path) } diff --git a/apps/sim/lib/workflows/streaming/output-selector.test.ts b/apps/sim/lib/workflows/streaming/output-selector.test.ts index 2750ab431f5..60ace3cb2d2 100644 --- a/apps/sim/lib/workflows/streaming/output-selector.test.ts +++ b/apps/sim/lib/workflows/streaming/output-selector.test.ts @@ -1,27 +1,48 @@ import { describe, expect, it } from 'vitest' import { formatOutputSelector, - parseOutputSelector, + parseInternalOutputSelector, + parsePublicOutputSelector, + parseStoredOutputSelector, scopeOutputBlockId, selectChildOutputSelectors, } from '@/lib/workflows/streaming/output-selector' describe('output selector scoping', () => { it('preserves root selectors and parses nested selectors', () => { - expect(parseOutputSelector('agent_content')).toEqual({ + expect(parseInternalOutputSelector('agent_content')).toEqual({ blockId: 'agent', path: 'content', }) - expect(parseOutputSelector('workflow/agent_content.text')).toEqual({ + expect(parseInternalOutputSelector('workflow/agent_content.text')).toEqual({ blockId: 'workflow/agent', path: 'content.text', }) - expect(parseOutputSelector('agent')).toEqual({ + expect(parseInternalOutputSelector('agent')).toEqual({ blockId: 'agent', path: '', }) }) + it('preserves underscores in caller-facing block IDs', () => { + for (const parse of [parsePublicOutputSelector, parseStoredOutputSelector]) { + expect(parse('workflow_block/agent_name.content')).toEqual({ + blockId: 'workflow_block/agent_name', + path: 'content', + }) + } + }) + + it('recognizes canonical stored internal selectors with dotted paths', () => { + const workflowBlockId = '11111111-1111-4111-8111-111111111111' + const agentBlockId = '22222222-2222-4222-8222-222222222222' + + expect(parseStoredOutputSelector(`${workflowBlockId}/${agentBlockId}_content.text`)).toEqual({ + blockId: `${workflowBlockId}/${agentBlockId}`, + path: 'content.text', + }) + }) + it('scopes block IDs through multiple workflow invocations', () => { expect(scopeOutputBlockId('outer-workflow', 'inner-workflow/agent')).toBe( 'outer-workflow/inner-workflow/agent' @@ -42,10 +63,15 @@ describe('output selector scoping', () => { ).toEqual(['agent_content', 'nested-workflow/agent_content.text']) }) - it.each(['', ' workflow/agent_content', '/agent_content', 'workflow//agent_content'])( - 'fails fast for malformed selector %j', - (selector) => { - expect(() => parseOutputSelector(selector)).toThrow('Invalid') - } - ) + it.each([ + '', + ' workflow/agent_content', + '/agent_content', + 'workflow//agent_content', + 'agent_.content', + 'agent_content.', + 'agent_content..text', + ])('fails fast for malformed selector %j', (selector) => { + expect(() => parseInternalOutputSelector(selector)).toThrow('Invalid') + }) }) diff --git a/apps/sim/lib/workflows/streaming/output-selector.ts b/apps/sim/lib/workflows/streaming/output-selector.ts index 68e1eaef835..e215d4d3347 100644 --- a/apps/sim/lib/workflows/streaming/output-selector.ts +++ b/apps/sim/lib/workflows/streaming/output-selector.ts @@ -1,3 +1,5 @@ +import { isValidUuid } from '@sim/utils/id' + export const OUTPUT_SCOPE_SEPARATOR = '/' const INTERNAL_OUTPUT_PATH_SEPARATOR = '_' const PUBLIC_OUTPUT_PATH_SEPARATOR = '.' @@ -19,6 +21,17 @@ function assertValidScopedBlockId(blockId: string): void { } } +function assertValidOutputPath(path: string): void { + if ( + path.trim() !== path || + path + .split(PUBLIC_OUTPUT_PATH_SEPARATOR) + .some((segment) => !segment || segment.trim() !== segment) + ) { + throw new Error(`Invalid output selector path: ${path}`) + } +} + function parseOutputSelectorWithSeparator( selector: string, separator: typeof INTERNAL_OUTPUT_PATH_SEPARATOR | typeof PUBLIC_OUTPUT_PATH_SEPARATOR @@ -35,6 +48,7 @@ function parseOutputSelectorWithSeparator( if (separatorIndex === 0 || (separatorIndex > 0 && !path)) { throw new Error(`Invalid output selector: ${selector}`) } + if (path) assertValidOutputPath(path) return { blockId, path } } @@ -56,9 +70,15 @@ export function parseInternalOutputSelector(selector: string): ParsedOutputSelec export function parseStoredOutputSelector(selector: string): ParsedOutputSelector { const underscoreIndex = selector.indexOf(INTERNAL_OUTPUT_PATH_SEPARATOR) const dotIndex = selector.indexOf(PUBLIC_OUTPUT_PATH_SEPARATOR) - return underscoreIndex > 0 && (dotIndex < 0 || underscoreIndex < dotIndex) - ? parseInternalOutputSelector(selector) - : parsePublicOutputSelector(selector) + const internalBlockId = underscoreIndex > 0 ? selector.slice(0, underscoreIndex) : '' + const hasCanonicalInternalBlockId = + internalBlockId.length > 0 && + internalBlockId.split(OUTPUT_SCOPE_SEPARATOR).every((segment) => isValidUuid(segment)) + + if (hasCanonicalInternalBlockId || (underscoreIndex > 0 && dotIndex < 0)) { + return parseInternalOutputSelector(selector) + } + return parsePublicOutputSelector(selector) } function formatOutputSelectorWithSeparator( @@ -67,9 +87,7 @@ function formatOutputSelectorWithSeparator( separator: typeof INTERNAL_OUTPUT_PATH_SEPARATOR | typeof PUBLIC_OUTPUT_PATH_SEPARATOR ): string { assertValidScopedBlockId(blockId) - if (path.trim() !== path || path.startsWith('.') || path.endsWith('.')) { - throw new Error(`Invalid output selector path: ${path}`) - } + if (path) assertValidOutputPath(path) return path ? `${blockId}${separator}${path}` : blockId } @@ -83,9 +101,6 @@ export function formatInternalOutputSelector(blockId: string, path = ''): string return formatOutputSelectorWithSeparator(blockId, path, INTERNAL_OUTPUT_PATH_SEPARATOR) } -/** Parses persisted authoring selectors in both the legacy internal and public forms. */ -export const parseOutputSelector = parseStoredOutputSelector - /** Formats selectors for the executor's legacy internal contract. */ export const formatOutputSelector = formatInternalOutputSelector From 628f800258569d937f3f1cfe0cceda2ae04a3a00 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 1 Sep 2026 00:44:33 -0700 Subject: [PATCH 4/6] fix(slack): use public output selectors --- apps/docs/content/docs/integrations/slack.mdx | 2 +- .../output-select/output-select.test.tsx | 23 ++++++++++++++++--- .../output-select/output-select.tsx | 15 ++++++++---- .../workflow-output-selector.tsx | 1 + .../lib/webhooks/slack-stream-config.test.ts | 10 ++++---- apps/sim/lib/webhooks/slack-stream-config.ts | 4 ++-- apps/sim/triggers/slack/oauth.ts | 2 +- 7 files changed, 41 insertions(+), 16 deletions(-) diff --git a/apps/docs/content/docs/integrations/slack.mdx b/apps/docs/content/docs/integrations/slack.mdx index d237bca88fd..7cfd3cc5318 100644 --- a/apps/docs/content/docs/integrations/slack.mdx +++ b/apps/docs/content/docs/integrations/slack.mdx @@ -1971,7 +1971,7 @@ Trigger from Slack events, interactions, and slash commands | `manualChannelFilter` | string | No | Comma-separated channel IDs to restrict to. Set IDs directly here. | | `threads` | string | No | Include thread replies, exclude them \(top-level only\), or fire only on thread replies. | | `streamResponse` | boolean | No | Create a Slack agent session and stream selected workflow outputs into the conversation that started this run. Custom bots only. | -| `streamOutputs` | workflow-output-selector | No | Each selected block invocation creates its own Slack response. Agent outputs stream live; other outputs are sent when the block completes. | +| `streamOutputs` | workflow-output-selector | No | Output selectors use the same blockId.path form as the streaming API. Each selected block invocation creates its own Slack response. Agent outputs stream live; other outputs are sent when the block completes. | | `streamTaskTitle` | string | No | Optional status Slack shows while each selected response is being produced. Leave empty to use Running. | | `streamTaskDisplayMode` | string | No | Choose how Slack displays thinking and tool progress. | | `streamIncludeThinking` | boolean | No | Show agent thinking as Slack task updates while the response is generated. | diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.test.tsx index db426d0ff3f..973c2d774d0 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.test.tsx @@ -184,24 +184,30 @@ beforeEach(() => { function outputSelect( workflowId: string, selectedOutputs: string[], - onOutputSelect: (outputIds: string[]) => void + onOutputSelect: (outputIds: string[]) => void, + valueMode: 'id' | 'label' | 'public' = 'id' ) { return ( ) } -function renderOutputSelect(selectedOutputs: string[], onOutputSelect = vi.fn()) { +function renderOutputSelect( + selectedOutputs: string[], + onOutputSelect = vi.fn(), + valueMode: 'id' | 'label' | 'public' = 'id' +) { ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true container = document.createElement('div') document.body.appendChild(container) root = createRoot(container) act(() => { - root?.render(outputSelect('root', selectedOutputs, onOutputSelect)) + root?.render(outputSelect('root', selectedOutputs, onOutputSelect, valueMode)) }) return onOutputSelect } @@ -256,6 +262,17 @@ describe('OutputSelect nested workflow menu', () => { expect(onOutputSelect).toHaveBeenCalledWith(['workflow/agent_answer']) }) + it('emits public dot selectors for trigger authoring', () => { + const onOutputSelect = renderOutputSelect([], vi.fn(), 'public') + + clickOption('content') + expect(onOutputSelect).toHaveBeenCalledWith(['summary.content']) + + clickOption('Outputs') + clickOption('answer') + expect(onOutputSelect).toHaveBeenCalledWith(['workflow/agent.answer']) + }) + it('returns to the root menu when the owning workflow changes', () => { const onOutputSelect = renderOutputSelect([]) clickOption('Outputs') diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx index e16fdc73d49..86b93206408 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx @@ -18,6 +18,7 @@ import { type WorkflowOutputMenuNode, type WorkflowOutputOption, } from '@/lib/workflows/streaming/nested-output-options' +import { formatPublicOutputSelector } from '@/lib/workflows/streaming/output-selector' import { BlockTile } from '@/blocks/block-tile' import { DEFAULTS } from '@/executor/constants' import { useWorkflowStates } from '@/hooks/queries/workflows' @@ -41,8 +42,8 @@ interface OutputSelectProps { disabled?: boolean /** Placeholder text when no outputs are selected */ placeholder?: string - /** Whether to emit output IDs or labels in onOutputSelect callback */ - valueMode?: 'id' | 'label' + /** Whether to emit internal IDs, display labels, or public dot selectors */ + valueMode?: 'id' | 'label' | 'public' /** Alignment of the dropdown relative to the trigger */ align?: 'start' | 'end' | 'center' /** Maximum height of the dropdown content in pixels */ @@ -64,14 +65,20 @@ interface OutputSelectMenuProps { onOutputSelect: (outputIds: string[]) => void disabled: boolean placeholder: string - valueMode: 'id' | 'label' + valueMode: 'id' | 'label' | 'public' align: 'start' | 'end' | 'center' maxHeight: number size: 'sm' | 'md' className?: string } -function getOutputValue(output: WorkflowOutputOption, valueMode: 'id' | 'label'): string { +function getOutputValue( + output: WorkflowOutputOption, + valueMode: 'id' | 'label' | 'public' +): string { + if (valueMode === 'public') { + return formatPublicOutputSelector(output.blockId, output.path) + } return valueMode === 'label' && !output.blockId.includes('/') ? output.label : output.id } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-output-selector/workflow-output-selector.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-output-selector/workflow-output-selector.tsx index bab97784f0f..bbe921224b8 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-output-selector/workflow-output-selector.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/editor/components/sub-block/components/workflow-output-selector/workflow-output-selector.tsx @@ -34,6 +34,7 @@ export function WorkflowOutputSelector({ onOutputSelect={setStoredValue} disabled={disabled || isPreview} placeholder={placeholder} + valueMode='public' size='md' className='w-full' /> diff --git a/apps/sim/lib/webhooks/slack-stream-config.test.ts b/apps/sim/lib/webhooks/slack-stream-config.test.ts index 5204c371405..7e4b207867e 100644 --- a/apps/sim/lib/webhooks/slack-stream-config.test.ts +++ b/apps/sim/lib/webhooks/slack-stream-config.test.ts @@ -13,7 +13,7 @@ describe('Slack stream response config', () => { const providerConfig: Record = { eventType: 'app_mention', streamResponse: true, - streamOutputs: ['block-1_content', 'workflow-block/block-2_result.value'], + streamOutputs: ['block-1.content', 'workflow-block/block-2.result.value'], streamIncludeThinking: true, streamIncludeToolCalls: false, streamTaskTitle: ' Working ', @@ -44,14 +44,14 @@ describe('Slack stream response config', () => { normalizeSlackStreamResponseConfig({ eventType: 'message', streamResponse: true, - streamOutputs: ['block_content'], + streamOutputs: ['block.content'], })?.taskTitle ).toBe('Running') expect( normalizeSlackStreamResponseConfig({ eventType: 'message', streamResponse: true, - streamOutputs: ['block_content'], + streamOutputs: ['block.content'], streamTaskTitle: ' ', })?.taskTitle ).toBe('Running') @@ -88,14 +88,14 @@ describe('Slack stream response config', () => { normalizeSlackStreamResponseConfig({ eventType: 'reaction_added', streamResponse: true, - streamOutputs: ['block_content'], + streamOutputs: ['block.content'], }) ).toThrow('reply-capable') expect(() => normalizeSlackStreamResponseConfig({ eventType: 'message', streamResponse: true, - streamOutputs: ['content'], + streamOutputs: ['block_content'], }) ).toThrow('Invalid Slack stream output selector') }) diff --git a/apps/sim/lib/webhooks/slack-stream-config.ts b/apps/sim/lib/webhooks/slack-stream-config.ts index e54d92884f5..52909cab7be 100644 --- a/apps/sim/lib/webhooks/slack-stream-config.ts +++ b/apps/sim/lib/webhooks/slack-stream-config.ts @@ -1,5 +1,5 @@ import { isRecordLike } from '@sim/utils/object' -import { parseInternalOutputSelector } from '@/lib/workflows/streaming/output-selector' +import { parsePublicOutputSelector } from '@/lib/workflows/streaming/output-selector' export const SLACK_STREAM_RESPONSE_EVENTS = [ 'message', @@ -24,7 +24,7 @@ export interface SlackStreamResponseConfig { const SLACK_TASK_TITLE_LIMIT = 256 function parseSlackOutputSelector(selector: string): SlackStreamOutputConfig { - const parsed = parseInternalOutputSelector(selector) + const parsed = parsePublicOutputSelector(selector) if (!parsed.path) { throw new Error(`Invalid Slack stream output selector: ${selector}`) } diff --git a/apps/sim/triggers/slack/oauth.ts b/apps/sim/triggers/slack/oauth.ts index 768d51a457e..a95328166d8 100644 --- a/apps/sim/triggers/slack/oauth.ts +++ b/apps/sim/triggers/slack/oauth.ts @@ -190,7 +190,7 @@ export const slackOAuthTrigger: TriggerConfig = { type: 'workflow-output-selector', placeholder: 'Select workflow outputs', description: - 'Each selected block invocation creates its own Slack response. Agent outputs stream live; other outputs are sent when the block completes.', + 'Output selectors use the same blockId.path form as the streaming API. Each selected block invocation creates its own Slack response. Agent outputs stream live; other outputs are sent when the block completes.', required: { field: 'streamResponse', value: true, From d7e8b6f1cced4ce45a150f6fd38c023816972e38 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 1 Sep 2026 02:08:45 -0700 Subject: [PATCH 5/6] fix(streaming): scope nested outputs by workflow --- apps/docs/content/docs/cli/reference.mdx | 2 +- apps/docs/content/docs/cli/workflows.mdx | 2 +- apps/docs/content/docs/integrations/slack.mdx | 2 +- apps/docs/openapi-v2-workflows.json | 11 +- .../chat/hooks/use-chat-streaming.ts | 8 +- apps/sim/app/api/chat/[identifier]/route.ts | 6 +- apps/sim/app/api/v2/chat-deployments/utils.ts | 8 +- .../[workflowId]/execute/route.test.ts | 3 +- .../app/api/workflows/[id]/execute/route.ts | 86 ++---- .../output-select/output-select.test.tsx | 15 +- .../output-select/output-select.tsx | 15 +- .../deploy-modal/components/chat/chat.tsx | 4 +- apps/sim/blocks/blocks/slack.ts | 2 + apps/sim/executor/execution/types.ts | 2 +- .../workflow/workflow-handler.test.ts | 30 +- .../handlers/workflow/workflow-handler.ts | 22 +- apps/sim/executor/types.ts | 4 +- .../api/contracts/chat-output-config.test.ts | 10 +- apps/sim/lib/api/contracts/chats.ts | 4 +- .../lib/api/contracts/v2/chat-deployments.ts | 11 +- apps/sim/lib/api/contracts/v2/workflows.ts | 2 +- apps/sim/lib/webhooks/deploy.test.ts | 3 + apps/sim/lib/webhooks/deploy.ts | 5 +- .../webhooks/slack-execution-stream.test.ts | 28 +- .../lib/webhooks/slack-execution-stream.ts | 39 ++- .../lib/webhooks/slack-stream-config.test.ts | 66 +++-- apps/sim/lib/webhooks/slack-stream-config.ts | 50 +++- .../workflows/application/chat-deployments.ts | 13 +- .../lib/workflows/executor/execute-service.ts | 69 +---- .../streaming/nested-output-options.test.ts | 14 +- .../streaming/nested-output-options.ts | 26 +- .../streaming/output-selector.test.ts | 116 +++++--- .../workflows/streaming/output-selector.ts | 265 +++++++++++++----- .../resolve-output-selectors.test.ts | 64 +++++ .../streaming/resolve-output-selectors.ts | 38 +++ .../lib/workflows/streaming/streaming.test.ts | 8 +- apps/sim/stores/chat/types.ts | 1 + apps/sim/triggers/slack/oauth.ts | 2 +- packages/sim-cli/src/contract/commands.ts | 2 +- packages/sim-cli/src/generated/v2-api.ts | 6 +- 40 files changed, 726 insertions(+), 338 deletions(-) create mode 100644 apps/sim/lib/workflows/streaming/resolve-output-selectors.test.ts create mode 100644 apps/sim/lib/workflows/streaming/resolve-output-selectors.ts diff --git a/apps/docs/content/docs/cli/reference.mdx b/apps/docs/content/docs/cli/reference.mdx index 9bb1d4cf8af..c73997ce9e3 100644 --- a/apps/docs/content/docs/cli/reference.mdx +++ b/apps/docs/content/docs/cli/reference.mdx @@ -5157,7 +5157,7 @@ sim workflows run [options] | `--input ` | No | Trigger input as JSON (JSON, or @path / @- to read a file or stdin). | | `--async` | No | Queue the run and return immediately. | | `--execution-timeout-seconds ` | No | Requested server-side timeout for an asynchronous run, in seconds. An upper bound, not the effective timeout: the run uses the smaller of this value and the plan's execution timeout, so requesting more than the plan allows silently yields the plan timeout. Rejected with `400` unless `async` is true. | -| `--select-output ` | No | Return blockName.field values from the streamed result (e.g. agent_1.content), requires --follow; missing fields are omitted (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--select-output ` | No | Return streamed outputs as blockName.path or childWorkflowId.blockName.path; selecting a child workflow applies to every invocation, requires --follow (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--include-file-base64` | No | Inline eligible output files as base64 content. Rejected when `async` is true. | | `--no-include-file-base64` | No | Send --include-file-base64 as false. | | `--base64-max-bytes ` | No | Maximum total bytes of file content to inline as base64, lowering but never raising the server limit of 16 MiB. Rejected when `async` is true. | diff --git a/apps/docs/content/docs/cli/workflows.mdx b/apps/docs/content/docs/cli/workflows.mdx index 18c783a1c31..7169b5b2d58 100644 --- a/apps/docs/content/docs/cli/workflows.mdx +++ b/apps/docs/content/docs/cli/workflows.mdx @@ -532,7 +532,7 @@ sim workflows run [options] | `--input ` | No | Trigger input as JSON (JSON, or @path / @- to read a file or stdin). | | `--async` | No | Queue the run and return immediately. | | `--execution-timeout-seconds ` | No | Requested server-side timeout for an asynchronous run, in seconds. An upper bound, not the effective timeout: the run uses the smaller of this value and the plan's execution timeout, so requesting more than the plan allows silently yields the plan timeout. Rejected with `400` unless `async` is true. | -| `--select-output ` | No | Return blockName.field values from the streamed result (e.g. agent_1.content), requires --follow; missing fields are omitted (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | +| `--select-output ` | No | Return streamed outputs as blockName.path or childWorkflowId.blockName.path; selecting a child workflow applies to every invocation, requires --follow (space-separated, or @path / @- with one value per line; @@value for a literal leading @). | | `--include-file-base64` | No | Inline eligible output files as base64 content. Rejected when `async` is true. | | `--no-include-file-base64` | No | Send --include-file-base64 as false. | | `--base64-max-bytes ` | No | Maximum total bytes of file content to inline as base64, lowering but never raising the server limit of 16 MiB. Rejected when `async` is true. | diff --git a/apps/docs/content/docs/integrations/slack.mdx b/apps/docs/content/docs/integrations/slack.mdx index 7cfd3cc5318..df7fe6f0288 100644 --- a/apps/docs/content/docs/integrations/slack.mdx +++ b/apps/docs/content/docs/integrations/slack.mdx @@ -1971,7 +1971,7 @@ Trigger from Slack events, interactions, and slash commands | `manualChannelFilter` | string | No | Comma-separated channel IDs to restrict to. Set IDs directly here. | | `threads` | string | No | Include thread replies, exclude them \(top-level only\), or fire only on thread replies. | | `streamResponse` | boolean | No | Create a Slack agent session and stream selected workflow outputs into the conversation that started this run. Custom bots only. | -| `streamOutputs` | workflow-output-selector | No | Output selectors use the same blockId.path form as the streaming API. Each selected block invocation creates its own Slack response. Agent outputs stream live; other outputs are sent when the block completes. | +| `streamOutputs` | workflow-output-selector | No | Use `<blockName>.<outputPath>` for this workflow or `<childWorkflowId>.<blockName>.<outputPath>` for a child workflow. Selecting a child workflow applies to every invocation of it. Agent outputs stream live; other outputs are sent when the block completes. | | `streamTaskTitle` | string | No | Optional status Slack shows while each selected response is being produced. Leave empty to use Running. | | `streamTaskDisplayMode` | string | No | Choose how Slack displays thinking and tool progress. | | `streamIncludeThinking` | boolean | No | Show agent thinking as Slack task updates while the response is generated. | diff --git a/apps/docs/openapi-v2-workflows.json b/apps/docs/openapi-v2-workflows.json index e61e1f407a5..d2fab40d5d6 100644 --- a/apps/docs/openapi-v2-workflows.json +++ b/apps/docs/openapi-v2-workflows.json @@ -8593,6 +8593,10 @@ "StoredChatDeploymentOutputConfig": { "type": "object", "properties": { + "workflowId": { + "description": "Child workflow containing the selected block. Omitted for the deployed workflow.", + "type": "string" + }, "blockId": { "type": "string", "description": "Block whose output the chat streams." @@ -8902,6 +8906,11 @@ "ChatDeploymentOutputConfig": { "type": "object", "properties": { + "workflowId": { + "description": "Child workflow containing the selected block. Omit for the deployed workflow.", + "type": "string", + "minLength": 1 + }, "blockId": { "type": "string", "minLength": 1, @@ -9305,7 +9314,7 @@ "type": "boolean" }, "selectedOutputs": { - "description": "Block output references to include in a streamed response, as `blockId`, `blockId.path`, or `BlockName.path` (resolved against the live workflow). Requires `stream: true` — it shapes the streamed envelope only, so it is rejected on a sync request and when `async` is true. To narrow a finished run, pass `selectedOutputs` to the run resource instead.", + "description": "Block output references to include in a streamed response. Use `.` for the executed workflow or `..` for a child workflow; block names are normalized workflow reference names. Selecting a child workflow applies to every invocation of it. Requires `stream: true` — it shapes the streamed envelope only, so it is rejected on a sync request and when `async` is true. To narrow a finished run, pass `selectedOutputs` to the run resource instead.", "maxItems": 100, "type": "array", "items": { diff --git a/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts b/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts index d4e9b8d1f09..8dbac2525a3 100644 --- a/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts +++ b/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts @@ -21,6 +21,7 @@ import { isChatThinkingFrame, isChatToolFrame, } from '@/lib/workflows/streaming/agent-stream-protocol' +import { scopeOutputBlockId } from '@/lib/workflows/streaming/output-selector' import type { ChatFile, ChatMessage, @@ -70,7 +71,7 @@ function extractFilesFromData( } export interface StreamingOptions { - outputConfigs?: Array<{ blockId: string; path?: string }> + outputConfigs?: Array<{ workflowId?: string; blockId: string; path?: string }> /** * Shared AbortController for fetch + SSE body reads. When provided (preferred), * Stop aborts the in-flight request server-side as well as the reader. @@ -430,7 +431,10 @@ export function useChatStreaming() { if (outputConfigs?.length && finalData.output) { for (const config of outputConfigs) { - const blockOutputs = finalData.output[config.blockId] + const outputBlockId = config.workflowId + ? scopeOutputBlockId(config.workflowId, config.blockId) + : config.blockId + const blockOutputs = finalData.output[outputBlockId] if (!blockOutputs) continue const value = getOutputValue(blockOutputs, config.path) diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts index ec2aa8e5f1c..72e6d7fd3af 100644 --- a/apps/sim/app/api/chat/[identifier]/route.ts +++ b/apps/sim/app/api/chat/[identifier]/route.ts @@ -214,7 +214,11 @@ export const POST = withRouteHandler( const selectedOutputs: string[] = [] if (deployment.outputConfigs && Array.isArray(deployment.outputConfigs)) { for (const config of deployment.outputConfigs) { - const outputId = formatOutputSelector(config.blockId, config.path || 'content') + const outputId = formatOutputSelector( + config.blockId, + config.path || 'content', + config.workflowId + ) selectedOutputs.push(outputId) } } diff --git a/apps/sim/app/api/v2/chat-deployments/utils.ts b/apps/sim/app/api/v2/chat-deployments/utils.ts index 85226313231..a293f470956 100644 --- a/apps/sim/app/api/v2/chat-deployments/utils.ts +++ b/apps/sim/app/api/v2/chat-deployments/utils.ts @@ -51,9 +51,13 @@ function normalizeStoredOutputConfigs(raw: unknown): V2ChatDeploymentOutputConfi const configs: V2ChatDeploymentOutputConfig[] = [] for (const entry of raw) { if (!entry || typeof entry !== 'object' || Array.isArray(entry)) continue - const { blockId, path } = entry as Record + const { workflowId, blockId, path } = entry as Record if (typeof blockId !== 'string' || blockId.length === 0) continue - configs.push({ blockId, path: typeof path === 'string' ? path : '' }) + configs.push({ + ...(typeof workflowId === 'string' && workflowId.length > 0 ? { workflowId } : {}), + blockId, + path: typeof path === 'string' ? path : '', + }) } return configs } diff --git a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts index f053e8b28e4..6449e170f57 100644 --- a/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts +++ b/apps/sim/app/api/v2/workflows/[workflowId]/execute/route.test.ts @@ -597,8 +597,7 @@ describe('POST /api/v2/workflows/[workflowId]/execute', () => { ok: false, failure: { kind: 'input', - message: - 'Invalid selectedOutputs: Invalid scoped output selector block ID: workflow//agent', + message: 'Invalid selectedOutputs: Invalid output selector: workflow//agent.content', statusCode: 400, }, }) diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 939025fbde5..53e07eb7e1d 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -4,7 +4,8 @@ import { workflow as workflowTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { authorizeWorkflowByWorkspacePermission } from '@sim/platform-authz/workflow' import { getErrorMessage, toError } from '@sim/utils/errors' -import { generateId, isValidUuid } from '@sim/utils/id' +import { generateId } from '@sim/utils/id' +import type { BlockState } from '@sim/workflow-types/workflow' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { @@ -141,10 +142,7 @@ import { forwardAgentStreamToExecutionEvents, shouldForwardAnswerTextFromSink, } from '@/lib/workflows/streaming/forward-agent-stream-events' -import { - formatOutputSelector, - parseStoredOutputSelector, -} from '@/lib/workflows/streaming/output-selector' +import { resolveOutputSelectors } from '@/lib/workflows/streaming/resolve-output-selectors' import { agentStreamProtocolResponseHeaders, createStreamingResponse, @@ -156,7 +154,6 @@ import { PublicApiNotAllowedError, validatePublicApiAllowed, } from '@/ee/access-control/utils/permission-check' -import { normalizeName } from '@/executor/constants' import { ExecutionSnapshot } from '@/executor/execution/snapshot' import type { BlockCompletionCallbackData, @@ -302,61 +299,13 @@ function payloadTooLargeResponse(message = 'Workflow execution response exceeds ) } -function resolveOutputIds( +async function resolveOutputIds( selectedOutputs: string[] | undefined, - blocks: Record -): string[] | undefined { - if (!selectedOutputs || selectedOutputs.length === 0) { - return selectedOutputs - } - - return selectedOutputs.map((outputId) => { - if (outputId.includes('/')) { - const parsed = parseStoredOutputSelector(outputId) - return formatOutputSelector(parsed.blockId, parsed.path) - } - - const underscoreIndex = outputId.indexOf('_') - const dotIndex = outputId.indexOf('.') - if (underscoreIndex > 0) { - const maybeUuid = outputId.substring(0, underscoreIndex) - if (isValidUuid(maybeUuid)) { - return outputId - } - } - - if (dotIndex > 0) { - const maybeUuid = outputId.substring(0, dotIndex) - if (isValidUuid(maybeUuid)) { - return `${outputId.substring(0, dotIndex)}_${outputId.substring(dotIndex + 1)}` - } - } - - if (isValidUuid(outputId)) { - return outputId - } - - if (dotIndex === -1) { - logger.warn(`Invalid output ID format (missing dot): ${outputId}`) - return outputId - } - - const blockName = outputId.substring(0, dotIndex) - const path = outputId.substring(dotIndex + 1) - - const normalizedBlockName = normalizeName(blockName) - const block = Object.values(blocks).find((b: any) => { - return normalizeName(b.name || '') === normalizedBlockName - }) - - if (!block) { - logger.warn(`Block not found for name: ${blockName} (from output ID: ${outputId})`) - return outputId - } - - const resolvedId = `${block.id}_${path}` - logger.debug(`Resolved output ID: ${outputId} -> ${resolvedId}`) - return resolvedId + blocks: Record +): Promise { + return resolveOutputSelectors({ + selectedOutputs, + currentBlocks: blocks, }) } @@ -1680,10 +1629,19 @@ async function handleExecutePost( } else { reqLogger.info('Using streaming API response') - const resolvedSelectedOutputs = resolveOutputIds( - selectedOutputs, - cachedWorkflowData?.blocks || {} - ) + let resolvedSelectedOutputs: string[] | undefined + try { + resolvedSelectedOutputs = await resolveOutputIds( + selectedOutputs, + cachedWorkflowData?.blocks || {} + ) + } catch (error) { + await releaseExecutionSlot(executionId) + return NextResponse.json( + { error: `Invalid selectedOutputs: ${getErrorMessage(error)}` }, + { status: 400 } + ) + } const streamVariables = cachedWorkflowData?.variables ?? (workflow as any).variables const streamWorkflow = { id: workflow.id, diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.test.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.test.tsx index 973c2d774d0..296da0f8df2 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.test.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.test.tsx @@ -125,9 +125,10 @@ vi.mock('@/lib/workflows/streaming/nested-output-options', () => { menuPath: [], } const nestedOutput = { - id: 'workflow/agent_answer', - label: 'Research.Writer.answer', - blockId: 'workflow/agent', + id: 'child-workflow.agent_answer', + label: 'child-workflow.writer.answer', + workflowId: 'child-workflow', + blockId: 'agent', blockName: 'Writer', blockType: 'agent', groupKey: 'workflow/agent', @@ -254,23 +255,23 @@ describe('OutputSelect nested workflow menu', () => { expect(document.body.textContent).not.toContain('Summarizer') }) - it('keeps invocation-scoped values when toggling nested outputs', () => { + it('keeps workflow-scoped values when toggling nested outputs', () => { const onOutputSelect = renderOutputSelect([]) clickOption('Outputs') clickOption('answer') - expect(onOutputSelect).toHaveBeenCalledWith(['workflow/agent_answer']) + expect(onOutputSelect).toHaveBeenCalledWith(['child-workflow.agent_answer']) }) it('emits public dot selectors for trigger authoring', () => { const onOutputSelect = renderOutputSelect([], vi.fn(), 'public') clickOption('content') - expect(onOutputSelect).toHaveBeenCalledWith(['summary.content']) + expect(onOutputSelect).toHaveBeenCalledWith(['summarizer.content']) clickOption('Outputs') clickOption('answer') - expect(onOutputSelect).toHaveBeenCalledWith(['workflow/agent.answer']) + expect(onOutputSelect).toHaveBeenCalledWith(['child-workflow.writer.answer']) }) it('returns to the root menu when the owning workflow changes', () => { diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx index 86b93206408..0f6bbe63d09 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select.tsx @@ -20,7 +20,7 @@ import { } from '@/lib/workflows/streaming/nested-output-options' import { formatPublicOutputSelector } from '@/lib/workflows/streaming/output-selector' import { BlockTile } from '@/blocks/block-tile' -import { DEFAULTS } from '@/executor/constants' +import { DEFAULTS, normalizeName } from '@/executor/constants' import { useWorkflowStates } from '@/hooks/queries/workflows' import { useWorkflowDiffStore } from '@/stores/workflow-diff/store' import { useSubBlockStore } from '@/stores/workflows/subblock/store' @@ -77,9 +77,13 @@ function getOutputValue( valueMode: 'id' | 'label' | 'public' ): string { if (valueMode === 'public') { - return formatPublicOutputSelector(output.blockId, output.path) + return formatPublicOutputSelector( + normalizeName(output.blockName), + output.path, + output.workflowId + ) } - return valueMode === 'label' && !output.blockId.includes('/') ? output.label : output.id + return valueMode === 'label' ? output.label : output.id } function resolveOutputMenuNode( @@ -206,7 +210,10 @@ function OutputSelectContent({ const outputMenu = buildWorkflowOutputMenu(workflowOutputs) const outputMenuRevision = JSON.stringify([ workflowId, - ...workflowOutputs.map((output) => output.id), + ...workflowOutputs.map((output) => [ + output.id, + ...output.menuPath.map((segment) => segment.blockId), + ]), ]) return ( diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx index 510e32667d5..c8458633c42 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx @@ -23,6 +23,7 @@ import { GeneratedPasswordInput } from '@/components/ui' import { isSsoEnabled } from '@/lib/core/config/env-flags' import { getBaseUrl, getEmailDomain } from '@/lib/core/utils/urls' import { validateAllowlistEntry } from '@/lib/messaging/email/validation' +import { formatInternalOutputSelector } from '@/lib/workflows/streaming/output-selector' import { OutputSelect } from '@/app/workspace/[workspaceId]/w/[workflowId]/components/chat/components/output-select/output-select' import { type AuthType, @@ -201,7 +202,8 @@ export function ChatDeploy({ existingChat.customizations?.welcomeMessage || 'Hi there! How can I help you today?', selectedOutputBlocks: Array.isArray(existingChat.outputConfigs) ? existingChat.outputConfigs.map( - (config: { blockId: string; path: string }) => `${config.blockId}_${config.path}` + (config: { workflowId?: string; blockId: string; path: string }) => + formatInternalOutputSelector(config.blockId, config.path, config.workflowId) ) : [], includeThinking: existingChat.includeThinking ?? false, diff --git a/apps/sim/blocks/blocks/slack.ts b/apps/sim/blocks/blocks/slack.ts index 4fe5904b7a8..1edceee03cb 100644 --- a/apps/sim/blocks/blocks/slack.ts +++ b/apps/sim/blocks/blocks/slack.ts @@ -50,6 +50,8 @@ export const SlackBlock: BlockConfig = { authMode: AuthMode.OAuth, longDescription: 'Integrate Slack into the workflow. Can send, update, and delete messages, send ephemeral messages visible only to a specific user, open/update/push modal views, publish Home tab views, create canvases, read messages, and add or remove reactions. Requires Bot Token instead of OAuth in advanced mode. Can be used in trigger mode to trigger a workflow when a message is sent to a channel.', + bestPractices: + 'For Slack trigger response streaming, select current-workflow outputs as `.` and child-workflow outputs as `..`. Use the normalized block reference name shown by the workflow catalog. Selecting a child workflow applies to every invocation of that workflow in the run.', docsLink: 'https://docs.sim.ai/integrations/slack', category: 'tools', integrationType: IntegrationType.Communication, diff --git a/apps/sim/executor/execution/types.ts b/apps/sim/executor/execution/types.ts index a2ac7a80ce8..94ddca9b54e 100644 --- a/apps/sim/executor/execution/types.ts +++ b/apps/sim/executor/execution/types.ts @@ -197,7 +197,7 @@ export interface BlockCompletionCallbackData { endedAt: string /** Per-invocation unique ID linking this workflow block execution to its child block events. */ childWorkflowInstanceId?: string - /** Invocation-scoped block ID used only to match externally selected outputs. */ + /** Root or child-workflow-scoped block identity used to match externally selected outputs. */ outputBlockId?: string } diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts index 8c314e7471b..2cd95e2c907 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.test.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.test.ts @@ -2183,14 +2183,14 @@ describe('WorkflowBlockHandler', () => { expect(extensions.childWorkflowContext).toBeDefined() }) - it('scopes a selected regular child output through its workflow invocation', async () => { + it('scopes a selected regular child output through its child workflow', async () => { const onStream = vi.fn() const onBlockComplete = vi.fn() const ctx = { ...mockContext, workspaceId: 'workspace-1', stream: true, - selectedOutputs: ['workflow-block-1/agent-1_content'], + selectedOutputs: ['child-workflow-id.agent-1_content'], onStream, onBlockComplete, } as unknown as ExecutionContext @@ -2201,7 +2201,25 @@ describe('WorkflowBlockHandler', () => { data: { name: 'Child Workflow', workspaceId: 'workspace-1', - state: { blocks: [], edges: [], loops: {}, parallels: {} }, + state: { + blocks: [ + { + id: 'agent-1', + type: 'agent', + name: 'Agent', + metadata: { id: 'agent', name: 'Agent' }, + position: { x: 0, y: 0 }, + config: { tool: 'agent', params: {} }, + inputs: {}, + outputs: {}, + subBlocks: {}, + enabled: true, + }, + ], + edges: [], + loops: {}, + parallels: {}, + }, }, }), }) @@ -2220,7 +2238,8 @@ describe('WorkflowBlockHandler', () => { await extensions.onStream(childStream) expect(onStream).toHaveBeenCalledWith({ ...childStream, - blockId: 'workflow-block-1/agent-1', + blockId: 'child-workflow-id.agent-1', + childWorkflowInstanceId: expect.any(String), }) const completion = { @@ -2237,7 +2256,8 @@ describe('WorkflowBlockHandler', () => { 'agent', { ...completion, - outputBlockId: 'workflow-block-1/agent-1', + outputBlockId: 'child-workflow-id.agent-1', + childWorkflowInstanceId: expect.any(String), }, undefined, undefined diff --git a/apps/sim/executor/handlers/workflow/workflow-handler.ts b/apps/sim/executor/handlers/workflow/workflow-handler.ts index 8db8d471d8d..8e44c7f25fb 100644 --- a/apps/sim/executor/handlers/workflow/workflow-handler.ts +++ b/apps/sim/executor/handlers/workflow/workflow-handler.ts @@ -491,19 +491,20 @@ export class WorkflowBlockHandler implements BlockHandler { const effectiveBlockId = nodeMetadata ? (nodeMetadata.originalBlockId ?? nodeMetadata.nodeId) : block.id - const requestedChildOutputs = selectChildOutputSelectors( - effectiveBlockId, + const childOutputSelection = selectChildOutputSelectors( + workflowId, + childWorkflow.rawBlocks || {}, ctx.selectedOutputs ) - if (isCustomBlock && requestedChildOutputs.length > 0) { + if (isCustomBlock && childOutputSelection.targetsChildWorkflow) { throw new Error('Custom block child outputs cannot be selected for streaming') } - if (!withinSseChildDepth && requestedChildOutputs.length > 0) { + if (!withinSseChildDepth && childOutputSelection.targetsChildWorkflow) { throw new Error( `Selected stream output exceeds the maximum child workflow depth of ${DEFAULTS.MAX_SSE_CHILD_DEPTH}` ) } - const childSelectedOutputs = isCustomBlock ? [] : requestedChildOutputs + const childSelectedOutputs = isCustomBlock ? [] : childOutputSelection.selectedOutputs const shouldStreamChild = shouldPropagateCallbacks && Boolean(ctx.stream) && childSelectedOutputs.length > 0 @@ -797,13 +798,16 @@ export class WorkflowBlockHandler implements BlockHandler { } if (shouldPropagateCallbacks) { const childOutputBlockId = output.outputBlockId ?? blockId + const selectedBlockRef = + childOutputSelection.selectedBlockRefs.get(childOutputBlockId) ?? childOutputBlockId await parentStreamSink.onBlockComplete?.( blockId, blockName, blockType, { ...output, - outputBlockId: scopeOutputBlockId(effectiveBlockId, childOutputBlockId), + outputBlockId: scopeOutputBlockId(workflowId, selectedBlockRef), + childWorkflowInstanceId: output.childWorkflowInstanceId ?? instanceId, }, iterationContext, childWorkflowContext @@ -820,9 +824,13 @@ export class WorkflowBlockHandler implements BlockHandler { if (!ctx.onStream) { throw new Error('Child workflow stream has no parent stream callback') } + const selectedBlockRef = + childOutputSelection.selectedBlockRefs.get(streamingExecution.blockId) ?? + streamingExecution.blockId await ctx.onStream({ ...streamingExecution, - blockId: scopeOutputBlockId(effectiveBlockId, streamingExecution.blockId), + blockId: scopeOutputBlockId(workflowId, selectedBlockRef), + childWorkflowInstanceId: streamingExecution.childWorkflowInstanceId ?? instanceId, }) } } diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index 255fa4dab98..b3e98b2c497 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -638,8 +638,10 @@ export interface ExecutionResult { } export interface StreamingExecution { - /** Workflow block invocation that owns this stream. */ + /** Selected block identity: a root block ID or `childWorkflowId.blockRef`. */ blockId?: string + /** Internal identity that disambiguates repeated invocations of one child workflow. */ + childWorkflowInstanceId?: string /** Per-run invocation order, unique across loop and parallel executions. */ executionOrder?: number /** diff --git a/apps/sim/lib/api/contracts/chat-output-config.test.ts b/apps/sim/lib/api/contracts/chat-output-config.test.ts index 5d4b80f61f8..8cdb781be93 100644 --- a/apps/sim/lib/api/contracts/chat-output-config.test.ts +++ b/apps/sim/lib/api/contracts/chat-output-config.test.ts @@ -8,9 +8,13 @@ import { v2ChatDeploymentOutputConfigSchema } from '@/lib/api/contracts/v2/chat- const OUTPUT_CONFIG_SCHEMAS = [chatOutputConfigSchema, v2ChatDeploymentOutputConfigSchema] describe('chat output config contracts', () => { - it.each(OUTPUT_CONFIG_SCHEMAS)('accepts invocation-scoped output selectors', (schema) => { + it.each(OUTPUT_CONFIG_SCHEMAS)('accepts child-workflow output selectors', (schema) => { expect( - schema.safeParse({ blockId: 'workflow-block/agent-block', path: 'content.text' }).success + schema.safeParse({ + workflowId: 'child-workflow', + blockId: 'agent-block', + path: 'content.text', + }).success ).toBe(true) }) @@ -19,7 +23,7 @@ describe('chat output config contracts', () => { (schema) => { for (const config of [ { blockId: '/agent-block', path: 'content' }, - { blockId: 'workflow-block/', path: 'content' }, + { workflowId: 'workflow/child', blockId: 'agent-block', path: 'content' }, { blockId: 'agent-block', path: '.content' }, { blockId: 'agent-block', path: 'content.' }, { blockId: 'agent-block', path: 'content..text' }, diff --git a/apps/sim/lib/api/contracts/chats.ts b/apps/sim/lib/api/contracts/chats.ts index f72e0e5383e..bf33643c11f 100644 --- a/apps/sim/lib/api/contracts/chats.ts +++ b/apps/sim/lib/api/contracts/chats.ts @@ -42,18 +42,20 @@ export const chatIdentifierParamsSchema = z.object({ export const chatOutputConfigSchema = z .object({ + workflowId: z.string().min(1).optional(), blockId: z.string().min(1), path: z.string().min(1), }) .superRefine((config, ctx) => { try { - formatInternalOutputSelector(config.blockId, config.path) + formatInternalOutputSelector(config.blockId, config.path, config.workflowId) } catch (error) { ctx.addIssue({ code: 'custom', message: getErrorMessage(error, 'Invalid output config') }) } }) export const deployedChatOutputConfigSchema = z.object({ + workflowId: z.string().optional(), blockId: z.string(), path: z.string().optional(), }) diff --git a/apps/sim/lib/api/contracts/v2/chat-deployments.ts b/apps/sim/lib/api/contracts/v2/chat-deployments.ts index 9766e80309a..8551b02ef9b 100644 --- a/apps/sim/lib/api/contracts/v2/chat-deployments.ts +++ b/apps/sim/lib/api/contracts/v2/chat-deployments.ts @@ -112,6 +112,11 @@ export const v2StoredChatDeploymentCustomizationsSchema = z export const v2ChatDeploymentOutputConfigSchema = z .object({ + workflowId: z + .string() + .min(1, 'outputConfigs[].workflowId cannot be empty') + .optional() + .describe('Child workflow containing the selected block. Omit for the deployed workflow.'), blockId: z .string() .min(1, 'outputConfigs[].blockId cannot be empty') @@ -124,7 +129,7 @@ export const v2ChatDeploymentOutputConfigSchema = z .strict() .superRefine((config, ctx) => { try { - formatInternalOutputSelector(config.blockId, config.path) + formatInternalOutputSelector(config.blockId, config.path, config.workflowId) } catch (error) { ctx.addIssue({ code: 'custom', message: getErrorMessage(error, 'Invalid output config') }) } @@ -145,6 +150,10 @@ export const v2ChatDeploymentOutputConfigSchema = z */ export const v2StoredChatDeploymentOutputConfigSchema = z .object({ + workflowId: z + .string() + .optional() + .describe('Child workflow containing the selected block. Omitted for the deployed workflow.'), blockId: z.string().describe('Block whose output the chat streams.'), path: z.string().describe('Path within that block output. Empty means the whole output.'), }) diff --git a/apps/sim/lib/api/contracts/v2/workflows.ts b/apps/sim/lib/api/contracts/v2/workflows.ts index 05fa6837a73..6a78ce0f8bf 100644 --- a/apps/sim/lib/api/contracts/v2/workflows.ts +++ b/apps/sim/lib/api/contracts/v2/workflows.ts @@ -1293,7 +1293,7 @@ export const v2ExecuteWorkflowBodySchema = z .max(100) .optional() .describe( - 'Block output references to include in a streamed response, as `blockId`, `blockId.path`, or `BlockName.path` (resolved against the live workflow). Requires `stream: true` — it shapes the streamed envelope only, so it is rejected on a sync request and when `async` is true. To narrow a finished run, pass `selectedOutputs` to the run resource instead.' + 'Block output references to include in a streamed response. Use `.` for the executed workflow or `..` for a child workflow; block names are normalized workflow reference names. Selecting a child workflow applies to every invocation of it. Requires `stream: true` — it shapes the streamed envelope only, so it is rejected on a sync request and when `async` is true. To narrow a finished run, pass `selectedOutputs` to the run resource instead.' ), includeThinking: z .boolean() diff --git a/apps/sim/lib/webhooks/deploy.test.ts b/apps/sim/lib/webhooks/deploy.test.ts index 4b4fb4b8c8b..9732e9d0308 100644 --- a/apps/sim/lib/webhooks/deploy.test.ts +++ b/apps/sim/lib/webhooks/deploy.test.ts @@ -267,6 +267,7 @@ describe('resolveWebhookConfigForBlock — slack_oauth routing', () => { ;(getTrigger as unknown as Mock).mockReturnValue(slackTriggerDef) return resolveWebhookConfigForBlock({ block: makeBlock('slack_oauth', values), + blocks: {}, workflow, userId: 'deployer-1', requestId: 'req-1', @@ -528,6 +529,7 @@ describe('resolveWebhookConfigForBlock — migrated slack_webhook routing', () = ;(getTrigger as unknown as Mock).mockReturnValue(legacySlackTriggerDef) return resolveWebhookConfigForBlock({ block: makeBlock('slack_webhook', values), + blocks: {}, workflow: { workspaceId: 'ws-1' }, userId: 'deployer-1', requestId: 'req-1', @@ -592,6 +594,7 @@ describe('resolveWebhookConfigForBlock — TikTok routing', () => { ;(getTrigger as unknown as Mock).mockReturnValue(tiktokTriggerDef) return resolveWebhookConfigForBlock({ block: makeBlock('tiktok', { triggerCredentials: credentialReference }), + blocks: {}, workflow, userId: 'deployer-1', requestId: 'req-1', diff --git a/apps/sim/lib/webhooks/deploy.ts b/apps/sim/lib/webhooks/deploy.ts index c7d33b4fc7b..136720edcc1 100644 --- a/apps/sim/lib/webhooks/deploy.ts +++ b/apps/sim/lib/webhooks/deploy.ts @@ -340,6 +340,7 @@ export async function resolveTriggerCredentialId( */ export async function resolveWebhookConfigForBlock(input: { block: BlockState + blocks: Record workflow: Record userId: string requestId: string @@ -448,7 +449,7 @@ export async function resolveWebhookConfigForBlock(input: { try { replaceSlackStreamAuthoringConfig( providerConfig, - normalizeSlackStreamResponseConfig(providerConfig) + normalizeSlackStreamResponseConfig(providerConfig, input.blocks) ) } catch (error) { return { @@ -749,6 +750,7 @@ export async function prepareStableTriggerWebhooksForDeploy({ signal?.throwIfAborted() const resolved = await resolveWebhookConfigForBlock({ block, + blocks, workflow, userId, requestId, @@ -858,6 +860,7 @@ export async function saveTriggerWebhooksForDeploy({ for (const block of triggerBlocks) { const resolved = await resolveWebhookConfigForBlock({ block, + blocks, workflow, userId, requestId, diff --git a/apps/sim/lib/webhooks/slack-execution-stream.test.ts b/apps/sim/lib/webhooks/slack-execution-stream.test.ts index 51bcd203f54..8f0c5cc812f 100644 --- a/apps/sim/lib/webhooks/slack-execution-stream.test.ts +++ b/apps/sim/lib/webhooks/slack-execution-stream.test.ts @@ -369,7 +369,7 @@ describe('SlackExecutionStreamController', () => { it('sends a selected nested non-streaming output after block completion', async () => { const config: SlackStreamResponseConfig = { ...BASE_CONFIG, - outputConfigs: [{ blockId: 'workflow-block/lookup', path: 'result.name' }], + outputConfigs: [{ workflowId: 'child-workflow', blockId: 'lookup', path: 'result.name' }], } const { controller } = await createController(config, { event: { channel: 'D123', timestamp: '1700000000.000001', user: 'U123' }, @@ -381,7 +381,8 @@ describe('SlackExecutionStreamController', () => { startedAt: '2026-08-31T00:00:00.000Z', executionOrder: 7, endedAt: '2026-08-31T00:00:00.010Z', - outputBlockId: 'workflow-block/lookup', + outputBlockId: 'child-workflow.lookup', + childWorkflowInstanceId: 'child-instance-1', }) expect(mockStartSlackAgentStream).toHaveBeenCalledWith( @@ -404,6 +405,29 @@ describe('SlackExecutionStreamController', () => { ) }) + it('keeps repeated invocations of the same child workflow distinct', async () => { + const config: SlackStreamResponseConfig = { + ...BASE_CONFIG, + outputConfigs: [{ workflowId: 'child-workflow', blockId: 'agent', path: 'content' }], + } + const { controller } = await createController(config, { + event: { channel: 'D123', timestamp: '1700000000.000001', user: 'U123' }, + }) + + for (const childWorkflowInstanceId of ['child-instance-1', 'child-instance-2']) { + await controller.callbacks.onStream?.({ + blockId: 'child-workflow.agent', + childWorkflowInstanceId, + executionOrder: 1, + stream: createByteStream(childWorkflowInstanceId), + execution: { success: true, output: {} }, + }) + } + + expect(mockStartSlackAgentStream).toHaveBeenCalledTimes(2) + expect(() => controller.assertSucceeded()).not.toThrow() + }) + it('rejects credentials that do not belong to the workflow workspace', async () => { mockGetSlackBotCredential.mockResolvedValue({ botToken: 'xoxb-token', diff --git a/apps/sim/lib/webhooks/slack-execution-stream.ts b/apps/sim/lib/webhooks/slack-execution-stream.ts index 110bef48824..3a9f43b3dbe 100644 --- a/apps/sim/lib/webhooks/slack-execution-stream.ts +++ b/apps/sim/lib/webhooks/slack-execution-stream.ts @@ -22,7 +22,7 @@ import { type SlackStreamSessionTarget, unregisterSlackStreamSession, } from '@/lib/webhooks/slack-stream-sessions' -import { formatOutputSelector } from '@/lib/workflows/streaming/output-selector' +import { formatOutputSelector, scopeOutputBlockId } from '@/lib/workflows/streaming/output-selector' import type { BlockCompletionCallbackData, ExecutionCallbacks } from '@/executor/execution/types' import type { ExecutionResult, StreamingExecution } from '@/executor/types' import type { AgentStreamEvent } from '@/providers/stream-events' @@ -296,7 +296,7 @@ export class SlackExecutionStreamController { this.token = token this.target = target this.selectedOutputs = options.config.outputConfigs.map((output) => - formatOutputSelector(output.blockId, output.path) + formatOutputSelector(output.blockId, output.path, output.workflowId) ) this.callbacks = { onStream: (stream) => this.onStream(stream), @@ -335,11 +335,24 @@ export class SlackExecutionStreamController { } private selectedForBlock(blockId: string): SlackStreamOutputConfig[] { - return this.options.config.outputConfigs.filter((output) => output.blockId === blockId) + return this.options.config.outputConfigs.filter((output) => { + const selectedBlockId = output.workflowId + ? scopeOutputBlockId(output.workflowId, output.blockId) + : output.blockId + return selectedBlockId === blockId + }) + } + + private invocationKey( + blockId: string, + executionOrder: number, + childWorkflowInstanceId?: string + ): string { + return `${blockId}:${childWorkflowInstanceId ?? executionOrder}` } - private invocationKey(blockId: string, executionOrder: number): string { - return `${blockId}:${executionOrder}` + private taskId(executionOrder: number, childWorkflowInstanceId?: string): string { + return `sim-${this.options.executionId}-${childWorkflowInstanceId ?? executionOrder}` } private recordFailure(error: unknown): Error { @@ -376,7 +389,11 @@ export class SlackExecutionStreamController { if (this.selectedForBlock(stream.blockId).length === 0) { throw new Error(`Slack streaming received an unselected block: ${stream.blockId}`) } - const key = this.invocationKey(stream.blockId, stream.executionOrder) + const key = this.invocationKey( + stream.blockId, + stream.executionOrder, + stream.childWorkflowInstanceId + ) if (this.invocations.has(key)) { throw new Error(`Duplicate Slack stream invocation: ${key}`) } @@ -384,7 +401,7 @@ export class SlackExecutionStreamController { this.token, this.target, this.options.config, - `sim-${this.options.executionId}-${stream.executionOrder}`, + this.taskId(stream.executionOrder, stream.childWorkflowInstanceId), this.options.config.taskTitle, (text) => this.projectLiveText(text, stream.displayResolvedSecretTraceProvenance), (text) => this.projectFinalText(text, stream.displayResolvedSecretTraceProvenance), @@ -427,7 +444,11 @@ export class SlackExecutionStreamController { const selectedOutputBlockId = data.outputBlockId ?? blockId const selected = this.selectedForBlock(selectedOutputBlockId) if (selected.length === 0) return - const key = this.invocationKey(selectedOutputBlockId, data.executionOrder) + const key = this.invocationKey( + selectedOutputBlockId, + data.executionOrder, + data.childWorkflowInstanceId + ) if (this.invocations.has(key)) return const display = await this.options.loggingSession.projectDisplayContent( @@ -448,7 +469,7 @@ export class SlackExecutionStreamController { this.token, this.target, this.options.config, - `sim-${this.options.executionId}-${data.executionOrder}`, + this.taskId(data.executionOrder, data.childWorkflowInstanceId), this.options.config.taskTitle, async (value) => value, async (value) => value, diff --git a/apps/sim/lib/webhooks/slack-stream-config.test.ts b/apps/sim/lib/webhooks/slack-stream-config.test.ts index 7e4b207867e..5a8a0a8e441 100644 --- a/apps/sim/lib/webhooks/slack-stream-config.test.ts +++ b/apps/sim/lib/webhooks/slack-stream-config.test.ts @@ -8,25 +8,29 @@ import { replaceSlackStreamAuthoringConfig, } from '@/lib/webhooks/slack-stream-config' +const CHILD_WORKFLOW_ID = '11111111-1111-4111-8111-111111111111' + describe('Slack stream response config', () => { it('normalizes selected outputs and replaces authoring fields', () => { const providerConfig: Record = { eventType: 'app_mention', streamResponse: true, - streamOutputs: ['block-1.content', 'workflow-block/block-2.result.value'], + streamOutputs: ['rootagent.content', `${CHILD_WORKFLOW_ID}.writer.result.value`], streamIncludeThinking: true, streamIncludeToolCalls: false, streamTaskTitle: ' Working ', streamTaskDisplayMode: 'plan', } - const normalized = normalizeSlackStreamResponseConfig(providerConfig) + const normalized = normalizeSlackStreamResponseConfig(providerConfig, { + 'block-1': { id: 'block-1', name: 'Root Agent' }, + }) replaceSlackStreamAuthoringConfig(providerConfig, normalized) expect(normalized).toEqual({ enabled: true, outputConfigs: [ { blockId: 'block-1', path: 'content' }, - { blockId: 'workflow-block/block-2', path: 'result.value' }, + { workflowId: CHILD_WORKFLOW_ID, blockId: 'writer', path: 'result.value' }, ], includeThinking: true, includeToolCalls: false, @@ -41,19 +45,25 @@ describe('Slack stream response config', () => { it('defaults omitted or blank response status labels to Running', () => { expect( - normalizeSlackStreamResponseConfig({ - eventType: 'message', - streamResponse: true, - streamOutputs: ['block.content'], - })?.taskTitle + normalizeSlackStreamResponseConfig( + { + eventType: 'message', + streamResponse: true, + streamOutputs: ['block.content'], + }, + { block: { id: 'block', name: 'Block' } } + )?.taskTitle ).toBe('Running') expect( - normalizeSlackStreamResponseConfig({ - eventType: 'message', - streamResponse: true, - streamOutputs: ['block.content'], - streamTaskTitle: ' ', - })?.taskTitle + normalizeSlackStreamResponseConfig( + { + eventType: 'message', + streamResponse: true, + streamOutputs: ['block.content'], + streamTaskTitle: ' ', + }, + { block: { id: 'block', name: 'Block' } } + )?.taskTitle ).toBe('Running') }) @@ -85,18 +95,24 @@ describe('Slack stream response config', () => { it('rejects non-reply events and malformed output selectors', () => { expect(() => - normalizeSlackStreamResponseConfig({ - eventType: 'reaction_added', - streamResponse: true, - streamOutputs: ['block.content'], - }) + normalizeSlackStreamResponseConfig( + { + eventType: 'reaction_added', + streamResponse: true, + streamOutputs: ['block.content'], + }, + {} + ) ).toThrow('reply-capable') expect(() => - normalizeSlackStreamResponseConfig({ - eventType: 'message', - streamResponse: true, - streamOutputs: ['block_content'], - }) + normalizeSlackStreamResponseConfig( + { + eventType: 'message', + streamResponse: true, + streamOutputs: ['block_content'], + }, + {} + ) ).toThrow('Invalid Slack stream output selector') }) @@ -107,7 +123,7 @@ describe('Slack stream response config', () => { } replaceSlackStreamAuthoringConfig( providerConfig, - normalizeSlackStreamResponseConfig(providerConfig) + normalizeSlackStreamResponseConfig(providerConfig, {}) ) expect(providerConfig.streamResponseConfig).toBeUndefined() }) diff --git a/apps/sim/lib/webhooks/slack-stream-config.ts b/apps/sim/lib/webhooks/slack-stream-config.ts index 52909cab7be..dbeb71bf70b 100644 --- a/apps/sim/lib/webhooks/slack-stream-config.ts +++ b/apps/sim/lib/webhooks/slack-stream-config.ts @@ -1,5 +1,10 @@ import { isRecordLike } from '@sim/utils/object' -import { parsePublicOutputSelector } from '@/lib/workflows/streaming/output-selector' +import { + formatInternalOutputSelector, + parsePublicOutputSelector, + resolveOutputBlockRef, +} from '@/lib/workflows/streaming/output-selector' +import { normalizeName } from '@/executor/constants' export const SLACK_STREAM_RESPONSE_EVENTS = [ 'message', @@ -8,6 +13,7 @@ export const SLACK_STREAM_RESPONSE_EVENTS = [ ] as const export interface SlackStreamOutputConfig { + workflowId?: string blockId: string path: string } @@ -23,17 +29,25 @@ export interface SlackStreamResponseConfig { const SLACK_TASK_TITLE_LIMIT = 256 -function parseSlackOutputSelector(selector: string): SlackStreamOutputConfig { - const parsed = parsePublicOutputSelector(selector) +function parseSlackOutputSelector( + selector: string, + currentBlocks: Record, + currentBlockRefs: ReadonlySet +): SlackStreamOutputConfig { + const parsed = parsePublicOutputSelector(selector, { currentBlockRefs }) if (!parsed.path) { throw new Error(`Invalid Slack stream output selector: ${selector}`) } - return parsed + const blockId = parsed.workflowId + ? parsed.blockId + : resolveOutputBlockRef(parsed.blockId, currentBlocks) + return { ...parsed, blockId } } /** Converts trigger authoring fields into the durable Slack streaming contract. */ export function normalizeSlackStreamResponseConfig( - providerConfig: Record + providerConfig: Record, + blocks: Record ): SlackStreamResponseConfig | null { if (providerConfig.streamResponse !== true) return null @@ -68,10 +82,17 @@ export function normalizeSlackStreamResponseConfig( `Slack stream response status label must be ${SLACK_TASK_TITLE_LIMIT} characters or fewer` ) } + const currentBlockRefs = new Set() + for (const block of Object.values(blocks)) { + currentBlockRefs.add(block.id) + if (block.name) currentBlockRefs.add(normalizeName(block.name)) + } return { enabled: true, - outputConfigs: selectors.map(parseSlackOutputSelector), + outputConfigs: selectors.map((selector) => + parseSlackOutputSelector(selector, blocks, currentBlockRefs) + ), includeThinking: providerConfig.streamIncludeThinking === true, includeToolCalls: providerConfig.streamIncludeToolCalls !== false, taskTitle, @@ -98,7 +119,22 @@ export function readSlackStreamResponseConfig( if (typeof output.path !== 'string' || !output.path) { throw new Error('Persisted Slack stream output is missing an output path') } - return { blockId: output.blockId, path: output.path } + if ( + output.workflowId !== undefined && + (typeof output.workflowId !== 'string' || !output.workflowId) + ) { + throw new Error('Persisted Slack stream output has an invalid workflow ID') + } + formatInternalOutputSelector( + output.blockId, + output.path, + typeof output.workflowId === 'string' ? output.workflowId : undefined + ) + return { + ...(typeof output.workflowId === 'string' ? { workflowId: output.workflowId } : {}), + blockId: output.blockId, + path: output.path, + } }) if (typeof value.includeThinking !== 'boolean' || typeof value.includeToolCalls !== 'boolean') { throw new Error('Persisted Slack stream visibility settings are invalid') diff --git a/apps/sim/lib/workflows/application/chat-deployments.ts b/apps/sim/lib/workflows/application/chat-deployments.ts index fa125332cfe..eb6dc4f1a70 100644 --- a/apps/sim/lib/workflows/application/chat-deployments.ts +++ b/apps/sim/lib/workflows/application/chat-deployments.ts @@ -21,13 +21,14 @@ import { resolveActiveWorkflowApplicationContext } from '@/lib/workflows/applica import { workflowOperations } from '@/lib/workflows/application/operations' import { assertedWorkflowWorkspaceId } from '@/lib/workflows/application/principal-scope' import { performChatDeploy, performChatUndeploy } from '@/lib/workflows/orchestration' +import { formatInternalOutputSelector } from '@/lib/workflows/streaming/output-selector' import { ChatDeployAuthNotAllowedError, validateChatDeployAuth, } from '@/ee/access-control/utils/permission-check' type ChatAuthType = 'public' | 'password' | 'email' | 'sso' -type ChatOutputConfig = { blockId: string; path: string } +type ChatOutputConfig = { workflowId?: string; blockId: string; path: string } type ChatCustomizations = { primaryColor?: string welcomeMessage?: string @@ -73,12 +74,22 @@ function parseChatOutputConfigs(value: unknown[] | undefined): ChatOutputConfig[ 'blockId' in entry && typeof entry.blockId === 'string' && entry.blockId.length > 0 && + (!('workflowId' in entry) || + entry.workflowId === undefined || + (typeof entry.workflowId === 'string' && entry.workflowId.length > 0)) && 'path' in entry && typeof entry.path === 'string' ) ) { throw new OrchestrationError('validation', 'Invalid chat output configuration') } + try { + for (const config of value) { + formatInternalOutputSelector(config.blockId, config.path, config.workflowId) + } + } catch { + throw new OrchestrationError('validation', 'Invalid chat output configuration') + } return value } diff --git a/apps/sim/lib/workflows/executor/execute-service.ts b/apps/sim/lib/workflows/executor/execute-service.ts index f1878eabc68..3ebb700717f 100644 --- a/apps/sim/lib/workflows/executor/execute-service.ts +++ b/apps/sim/lib/workflows/executor/execute-service.ts @@ -2,7 +2,8 @@ import type { WorkflowExecutionPrincipal } from '@sim/auth/principal' import type { workflow as workflowTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' -import { generateId, isValidUuid } from '@sim/utils/id' +import { generateId } from '@sim/utils/id' +import type { BlockState } from '@sim/workflow-types/workflow' import { releaseExecutionSlot } from '@/lib/billing/calculations/usage-reservation' import type { BillingAttributionSnapshot } from '@/lib/billing/core/billing-attribution' import { createTimeoutAbortController, getTimeoutErrorMessage } from '@/lib/core/execution-limits' @@ -33,17 +34,13 @@ import { loadWorkflowFromNormalizedTables, } from '@/lib/workflows/persistence/utils' import { shouldEmitAgentStreamEvents } from '@/lib/workflows/streaming/agent-stream-protocol' -import { - formatOutputSelector, - parsePublicOutputSelector, -} from '@/lib/workflows/streaming/output-selector' +import { resolveOutputSelectors } from '@/lib/workflows/streaming/resolve-output-selectors' import { agentStreamProtocolResponseHeaders, createStreamingResponse, } from '@/lib/workflows/streaming/streaming' import { workflowHasResponseBlock } from '@/lib/workflows/utils' import { withCustomBlockOverlay } from '@/blocks/custom/server-overlay' -import { normalizeName } from '@/executor/constants' import { ExecutionSnapshot } from '@/executor/execution/snapshot' import type { ExecutionMetadata, SerializableExecutionState } from '@/executor/execution/types' import type { NormalizedBlockOutput } from '@/executor/types' @@ -478,7 +475,7 @@ export async function executeWorkflowService( if (mode === 'stream') { let resolvedSelectedOutputs: string[] | undefined try { - resolvedSelectedOutputs = resolveOutputIds(selectedOutputs, workflowBlocks) + resolvedSelectedOutputs = await resolveOutputIds(selectedOutputs, workflowBlocks) } catch (error) { await releaseExecutionSlot(executionId) return failure({ @@ -847,60 +844,12 @@ export async function executeWorkflowService( * `.path`) to internal `_` ids — same normalization the * v1 streaming path applies. */ -export function resolveOutputIds( +export async function resolveOutputIds( selectedOutputs: string[] | undefined, blocks: Record -): string[] | undefined { - if (!selectedOutputs || selectedOutputs.length === 0) { - return selectedOutputs - } - - return selectedOutputs.map((outputId) => { - if (outputId.includes('/')) { - const parsed = parsePublicOutputSelector(outputId) - return formatOutputSelector(parsed.blockId, parsed.path) - } - - const underscoreIndex = outputId.indexOf('_') - const dotIndex = outputId.indexOf('.') - if (underscoreIndex > 0) { - const maybeUuid = outputId.substring(0, underscoreIndex) - if (isValidUuid(maybeUuid)) { - return outputId - } - } - - if (dotIndex > 0) { - const maybeUuid = outputId.substring(0, dotIndex) - if (isValidUuid(maybeUuid)) { - return `${outputId.substring(0, dotIndex)}_${outputId.substring(dotIndex + 1)}` - } - } - - if (isValidUuid(outputId)) { - return outputId - } - - if (dotIndex === -1) { - logger.warn(`Invalid output ID format (missing dot): ${outputId}`) - return outputId - } - - const blockName = outputId.substring(0, dotIndex) - const path = outputId.substring(dotIndex + 1) - - const normalizedBlockName = normalizeName(blockName) - const block = Object.values(blocks).find((candidate) => { - const record = candidate as { name?: string } - return normalizeName(record.name || '') === normalizedBlockName - }) - - if (!block) { - logger.warn(`Block not found for name: ${blockName} (from output ID: ${outputId})`) - return outputId - } - - const resolvedId = `${(block as { id: string }).id}_${path}` - return resolvedId +): Promise { + return resolveOutputSelectors({ + selectedOutputs, + currentBlocks: blocks as Record, }) } diff --git a/apps/sim/lib/workflows/streaming/nested-output-options.test.ts b/apps/sim/lib/workflows/streaming/nested-output-options.test.ts index b8d8c9e42e6..8010fc1547f 100644 --- a/apps/sim/lib/workflows/streaming/nested-output-options.test.ts +++ b/apps/sim/lib/workflows/streaming/nested-output-options.test.ts @@ -48,7 +48,7 @@ describe('nested workflow output options', () => { expect(getWorkflowInvocationTarget(workflowBlock)).toBe('advanced-workflow') }) - it('builds invocation-scoped selectors and stops cycles', () => { + it('builds workflow-scoped selectors and stops cycles', () => { const root = { blocks: { invoke: block('invoke', 'workflow_input', 'Research', { @@ -78,8 +78,14 @@ describe('nested workflow output options', () => { maxChildDepth: 3, }) - expect(options.some((option) => option.id === 'invoke/agent_content')).toBe(true) - expect(options.some((option) => option.id.startsWith('invoke/cycle/invoke/'))).toBe(false) + expect( + options.some( + (option) => + option.id === 'child-workflow.agent_content' && + option.label === 'child-workflow.writer.content' + ) + ).toBe(true) + expect(options.some((option) => option.menuPath.length > 2)).toBe(false) expect(buildWorkflowOutputMenu(options)).toMatchObject([ { @@ -92,7 +98,7 @@ describe('nested workflow output options', () => { blockId: 'invoke/agent', blockName: 'Writer', blockType: 'agent', - outputs: [{ id: 'invoke/agent_content', path: 'content' }], + outputs: [{ id: 'child-workflow.agent_content', path: 'content' }], children: [], }, ], diff --git a/apps/sim/lib/workflows/streaming/nested-output-options.ts b/apps/sim/lib/workflows/streaming/nested-output-options.ts index 381cdca220c..1ec3ef71908 100644 --- a/apps/sim/lib/workflows/streaming/nested-output-options.ts +++ b/apps/sim/lib/workflows/streaming/nested-output-options.ts @@ -1,6 +1,9 @@ import type { BlockState, WorkflowState } from '@sim/workflow-types/workflow' import { flattenWorkflowOutputs } from '@/lib/workflows/blocks/flatten-outputs' -import { scopeOutputBlockId } from '@/lib/workflows/streaming/output-selector' +import { + formatInternalOutputSelector, + formatPublicOutputSelector, +} from '@/lib/workflows/streaming/output-selector' import { normalizeName } from '@/executor/constants' const WORKFLOW_BLOCK_TYPES = new Set(['workflow', 'workflow_input']) @@ -8,6 +11,7 @@ const WORKFLOW_BLOCK_TYPES = new Set(['workflow', 'workflow_input']) export interface WorkflowOutputOption { id: string label: string + workflowId?: string blockId: string blockName: string blockType: string @@ -94,29 +98,29 @@ export function buildWorkflowOutputOptions({ ): void => { const flattened = flattenWorkflowOutputs(Object.values(state.blocks), state.edges) for (const output of flattened) { - const parentBlockId = invocationPath.at(-1)?.blockId - const blockId = parentBlockId - ? scopeOutputBlockId(parentBlockId, output.blockId) - : output.blockId + const selectedWorkflowId = workflowId === rootWorkflowId ? undefined : workflowId const displayBlockName = normalizeName(output.blockName || `block-${output.blockId}`) const invocationNames = invocationPath.map((segment) => segment.blockName) + const menuParentId = invocationPath.at(-1)?.blockId + const menuBlockId = menuParentId ? `${menuParentId}/${output.blockId}` : output.blockId const groupLabel = invocationNames.length > 0 ? `${invocationNames.join(' / ')} / ${output.blockName}` : output.blockName options.push({ - id: `${blockId}_${output.path}`, - label: `${[...invocationNames.map(normalizeName), displayBlockName, output.path].join('.')}`, - blockId, + id: formatInternalOutputSelector(output.blockId, output.path, selectedWorkflowId), + label: formatPublicOutputSelector(displayBlockName, output.path, selectedWorkflowId), + workflowId: selectedWorkflowId, + blockId: output.blockId, blockName: output.blockName, blockType: output.blockType, - groupKey: blockId, + groupKey: menuBlockId, groupLabel, path: output.path, menuPath: [ ...invocationPath, { - blockId, + blockId: menuBlockId, blockName: output.blockName, blockType: output.blockType, }, @@ -132,7 +136,7 @@ export function buildWorkflowOutputOptions({ const childState = workflowStates.get(childWorkflowId) if (!childState) continue const parentBlockId = invocationPath.at(-1)?.blockId - const blockId = parentBlockId ? scopeOutputBlockId(parentBlockId, block.id) : block.id + const blockId = parentBlockId ? `${parentBlockId}/${block.id}` : block.id visit( childWorkflowId, childState, diff --git a/apps/sim/lib/workflows/streaming/output-selector.test.ts b/apps/sim/lib/workflows/streaming/output-selector.test.ts index 60ace3cb2d2..b4e07d4916f 100644 --- a/apps/sim/lib/workflows/streaming/output-selector.test.ts +++ b/apps/sim/lib/workflows/streaming/output-selector.test.ts @@ -1,6 +1,7 @@ import { describe, expect, it } from 'vitest' import { - formatOutputSelector, + formatInternalOutputSelector, + formatPublicOutputSelector, parseInternalOutputSelector, parsePublicOutputSelector, parseStoredOutputSelector, @@ -8,67 +9,108 @@ import { selectChildOutputSelectors, } from '@/lib/workflows/streaming/output-selector' +const CHILD_WORKFLOW_ID = '11111111-1111-4111-8111-111111111111' +const CHILD_BLOCK_ID = '22222222-2222-4222-8222-222222222222' + +function block(id: string, name: string) { + return { + id, + type: 'agent', + name, + subBlocks: {}, + position: { x: 0, y: 0 }, + outputs: {}, + enabled: true, + } +} + describe('output selector scoping', () => { - it('preserves root selectors and parses nested selectors', () => { + it('parses root and workflow-scoped internal selectors', () => { expect(parseInternalOutputSelector('agent_content')).toEqual({ blockId: 'agent', path: 'content', }) - expect(parseInternalOutputSelector('workflow/agent_content.text')).toEqual({ - blockId: 'workflow/agent', + expect( + parseInternalOutputSelector(`${CHILD_WORKFLOW_ID}.${CHILD_BLOCK_ID}_content.text`) + ).toEqual({ + workflowId: CHILD_WORKFLOW_ID, + blockId: CHILD_BLOCK_ID, path: 'content.text', }) - expect(parseInternalOutputSelector('agent')).toEqual({ - blockId: 'agent', - path: '', - }) }) - it('preserves underscores in caller-facing block IDs', () => { - for (const parse of [parsePublicOutputSelector, parseStoredOutputSelector]) { - expect(parse('workflow_block/agent_name.content')).toEqual({ - blockId: 'workflow_block/agent_name', - path: 'content', + it('uses current workflow block refs to distinguish nested selectors from dotted paths', () => { + const currentBlockRefs = new Set(['rootagent']) + + expect(parsePublicOutputSelector('rootagent.result.text', { currentBlockRefs })).toEqual({ + blockId: 'rootagent', + path: 'result.text', + }) + expect( + parsePublicOutputSelector(`${CHILD_WORKFLOW_ID}.writer.result.text`, { + currentBlockRefs, }) - } + ).toEqual({ + workflowId: CHILD_WORKFLOW_ID, + blockId: 'writer', + path: 'result.text', + }) }) - it('recognizes canonical stored internal selectors with dotted paths', () => { - const workflowBlockId = '11111111-1111-4111-8111-111111111111' - const agentBlockId = '22222222-2222-4222-8222-222222222222' + it('recognizes stable stored internal selectors without confusing public underscores', () => { + const currentBlockRefs = new Set([CHILD_BLOCK_ID]) - expect(parseStoredOutputSelector(`${workflowBlockId}/${agentBlockId}_content.text`)).toEqual({ - blockId: `${workflowBlockId}/${agentBlockId}`, + expect( + parseStoredOutputSelector(`${CHILD_BLOCK_ID}_content.text`, { currentBlockRefs }) + ).toEqual({ + blockId: CHILD_BLOCK_ID, path: 'content.text', }) + expect(parseStoredOutputSelector('my_agent.content', { currentBlockRefs })).toEqual({ + blockId: 'my_agent', + path: 'content', + }) }) - it('scopes block IDs through multiple workflow invocations', () => { - expect(scopeOutputBlockId('outer-workflow', 'inner-workflow/agent')).toBe( - 'outer-workflow/inner-workflow/agent' + it('formats workflow-scoped selectors without invocation paths', () => { + expect(formatPublicOutputSelector('writer', 'content', CHILD_WORKFLOW_ID)).toBe( + `${CHILD_WORKFLOW_ID}.writer.content` ) - expect(formatOutputSelector('outer-workflow/agent', 'content')).toBe( - 'outer-workflow/agent_content' + expect(formatInternalOutputSelector(CHILD_BLOCK_ID, 'content', CHILD_WORKFLOW_ID)).toBe( + `${CHILD_WORKFLOW_ID}.${CHILD_BLOCK_ID}_content` + ) + expect(scopeOutputBlockId(CHILD_WORKFLOW_ID, CHILD_BLOCK_ID)).toBe( + `${CHILD_WORKFLOW_ID}.${CHILD_BLOCK_ID}` ) }) - it('selects and strips only outputs addressed to the child invocation', () => { - expect( - selectChildOutputSelectors('workflow-a', [ - 'root_content', - 'workflow-b/agent_content', - 'workflow-a/agent_content', - 'workflow-a/nested-workflow/agent_content.text', - ]) - ).toEqual(['agent_content', 'nested-workflow/agent_content.text']) + it('routes direct selections locally and forwards descendant workflow selections', () => { + const descendantWorkflowId = '33333333-3333-4333-8333-333333333333' + const directSelector = formatInternalOutputSelector('writer', 'content', CHILD_WORKFLOW_ID) + const descendantSelector = formatInternalOutputSelector( + 'reviewer', + 'result.text', + descendantWorkflowId + ) + + const selection = selectChildOutputSelectors( + CHILD_WORKFLOW_ID, + { [CHILD_BLOCK_ID]: block(CHILD_BLOCK_ID, 'Writer') }, + ['root_content', directSelector, descendantSelector] + ) + + expect(selection.selectedOutputs).toEqual([`${CHILD_BLOCK_ID}_content`, descendantSelector]) + expect(selection.selectedBlockRefs.get(CHILD_BLOCK_ID)).toBe('writer') + expect(selection.targetsChildWorkflow).toBe(true) }) it.each([ '', - ' workflow/agent_content', - '/agent_content', - 'workflow//agent_content', - 'agent_.content', + ' workflow.agent_content', + 'workflow/agent_content', + '.agent_content', + 'workflow..agent_content', + 'agent_', 'agent_content.', 'agent_content..text', ])('fails fast for malformed selector %j', (selector) => { diff --git a/apps/sim/lib/workflows/streaming/output-selector.ts b/apps/sim/lib/workflows/streaming/output-selector.ts index e215d4d3347..ac74dd9bf9b 100644 --- a/apps/sim/lib/workflows/streaming/output-selector.ts +++ b/apps/sim/lib/workflows/streaming/output-selector.ts @@ -1,23 +1,36 @@ import { isValidUuid } from '@sim/utils/id' +import type { BlockState } from '@sim/workflow-types/workflow' +import { normalizeName } from '@/executor/constants' -export const OUTPUT_SCOPE_SEPARATOR = '/' const INTERNAL_OUTPUT_PATH_SEPARATOR = '_' const PUBLIC_OUTPUT_PATH_SEPARATOR = '.' export interface ParsedOutputSelector { - /** Invocation-scoped block ID, such as `workflow-block/agent-block`. */ + /** Child workflow containing the selected block. Omitted for the current workflow. */ + workflowId?: string + /** Stable block ID internally, or normalized block name at a public boundary. */ blockId: string /** Dot path within the selected block output. Empty selects the whole block. */ path: string } -function assertValidScopedBlockId(blockId: string): void { - if (!blockId || blockId.trim() !== blockId) { - throw new Error(`Invalid output selector block ID: ${blockId}`) - } - const segments = blockId.split(OUTPUT_SCOPE_SEPARATOR) - if (segments.some((segment) => !segment || segment.trim() !== segment)) { - throw new Error(`Invalid scoped output selector block ID: ${blockId}`) +export interface PublicOutputSelectorContext { + /** IDs and normalized names belonging to the workflow being executed. */ + currentBlockRefs: ReadonlySet + /** Known reachable child workflows. UUID workflow IDs are also recognized without preloading. */ + childWorkflowIds?: ReadonlySet +} + +export interface ChildOutputSelection { + selectedOutputs: string[] + /** Actual child block ID to the caller-supplied ref used in the scoped selector. */ + selectedBlockRefs: ReadonlyMap + targetsChildWorkflow: boolean +} + +function assertValidSelectorPart(value: string, label: string): void { + if (!value || value.trim() !== value || value.includes('/') || value.includes('.')) { + throw new Error(`Invalid output selector ${label}: ${value}`) } } @@ -32,102 +45,220 @@ function assertValidOutputPath(path: string): void { } } -function parseOutputSelectorWithSeparator( +function assertSelector(selector: string): void { + if (!selector || selector.trim() !== selector || selector.includes('/')) { + throw new Error(`Invalid output selector: ${selector}`) + } +} + +function decodeInternalSelectorPart(value: string): string { + try { + return decodeURIComponent(value) + } catch { + throw new Error(`Invalid encoded output selector part: ${value}`) + } +} + +function encodeInternalSelectorPart(value: string): string { + assertValidSelectorPart(value, 'part') + return encodeURIComponent(value).replaceAll(INTERNAL_OUTPUT_PATH_SEPARATOR, '%5F') +} + +function parseScopedBlockRef( + value: string, + decodeInternal = false +): Pick { + const segments = value.split(PUBLIC_OUTPUT_PATH_SEPARATOR) + if (segments.length > 2) { + throw new Error(`Invalid output selector block reference: ${value}`) + } + const [rawFirst, rawSecond] = segments + const first = decodeInternal ? decodeInternalSelectorPart(rawFirst) : rawFirst + const second = rawSecond + ? decodeInternal + ? decodeInternalSelectorPart(rawSecond) + : rawSecond + : undefined + if (!first || (segments.length === 2 && !second)) { + throw new Error(`Invalid output selector block reference: ${value}`) + } + if (second) { + assertValidSelectorPart(first, 'workflow ID') + assertValidSelectorPart(second, 'block reference') + return { workflowId: first, blockId: second } + } + assertValidSelectorPart(first, 'block reference') + return { blockId: first } +} + +/** Parses caller-facing selectors using the current workflow to disambiguate dot paths. */ +export function parsePublicOutputSelector( selector: string, - separator: typeof INTERNAL_OUTPUT_PATH_SEPARATOR | typeof PUBLIC_OUTPUT_PATH_SEPARATOR + context?: PublicOutputSelectorContext ): ParsedOutputSelector { - if (!selector || selector.trim() !== selector) { + assertSelector(selector) + const segments = selector.split(PUBLIC_OUTPUT_PATH_SEPARATOR) + if (segments.some((segment) => !segment || segment.trim() !== segment)) { throw new Error(`Invalid output selector: ${selector}`) } - const separatorIndex = selector.indexOf(separator) - const blockId = separatorIndex > 0 ? selector.slice(0, separatorIndex) : selector - const path = separatorIndex > 0 ? selector.slice(separatorIndex + 1) : '' + const [first, second, ...pathSegments] = segments + assertValidSelectorPart(first, 'block reference') + if (!second) return { blockId: first, path: '' } - assertValidScopedBlockId(blockId) + if (context?.currentBlockRefs.has(first)) { + const path = [second, ...pathSegments].join(PUBLIC_OUTPUT_PATH_SEPARATOR) + assertValidOutputPath(path) + return { blockId: first, path } + } + + const selectsChildWorkflow = context?.childWorkflowIds?.has(first) === true || isValidUuid(first) + if (context && selectsChildWorkflow && pathSegments.length > 0) { + assertValidSelectorPart(second, 'block reference') + const path = pathSegments.join(PUBLIC_OUTPUT_PATH_SEPARATOR) + assertValidOutputPath(path) + return { workflowId: first, blockId: second, path } + } + + const path = [second, ...pathSegments].join(PUBLIC_OUTPUT_PATH_SEPARATOR) + assertValidOutputPath(path) + return { blockId: first, path } +} + +/** Parses the executor-internal `blockId_path` or `workflowId.blockId_path` form. */ +export function parseInternalOutputSelector(selector: string): ParsedOutputSelector { + assertSelector(selector) + const separatorIndex = selector.indexOf(INTERNAL_OUTPUT_PATH_SEPARATOR) + const scopedBlockRef = separatorIndex > 0 ? selector.slice(0, separatorIndex) : selector + const path = separatorIndex > 0 ? selector.slice(separatorIndex + 1) : '' if (separatorIndex === 0 || (separatorIndex > 0 && !path)) { throw new Error(`Invalid output selector: ${selector}`) } + const parsed = parseScopedBlockRef(scopedBlockRef, true) + if (parsed.workflowId && !path) { + throw new Error(`Nested output selector is missing its output path: ${selector}`) + } if (path) assertValidOutputPath(path) - - return { blockId, path } + return { ...parsed, path } } -/** Parses the caller-facing `blockId.path` selector form. */ -export function parsePublicOutputSelector(selector: string): ParsedOutputSelector { - return parseOutputSelectorWithSeparator(selector, PUBLIC_OUTPUT_PATH_SEPARATOR) +/** Parses output-picker state, whose canonical form is the internal selector form. */ +export function parseStoredOutputSelector( + selector: string, + context?: PublicOutputSelectorContext +): ParsedOutputSelector { + const separatorIndex = selector.indexOf(INTERNAL_OUTPUT_PATH_SEPARATOR) + if (separatorIndex > 0) { + const scopedBlockRef = selector.slice(0, separatorIndex) + const scopedSegments = scopedBlockRef.split(PUBLIC_OUTPUT_PATH_SEPARATOR) + const isCurrentStableBlock = context?.currentBlockRefs.has(scopedBlockRef) === true + const isChildStableBlock = + scopedSegments.length === 2 && + isValidUuid(scopedSegments[0]) && + isValidUuid(scopedSegments[1]) + const isEncodedInternalBlock = scopedBlockRef.includes('%') + if (isCurrentStableBlock || isChildStableBlock || isEncodedInternalBlock || !context) { + return parseInternalOutputSelector(selector) + } + } + return parsePublicOutputSelector(selector, context) } -/** Parses the executor-internal `blockId_path` selector form. */ -export function parseInternalOutputSelector(selector: string): ParsedOutputSelector { - return parseOutputSelectorWithSeparator(selector, INTERNAL_OUTPUT_PATH_SEPARATOR) +function formatPublicScopedBlockRef(blockId: string, workflowId?: string): string { + assertValidSelectorPart(blockId, 'block reference') + if (!workflowId) return blockId + assertValidSelectorPart(workflowId, 'workflow ID') + return `${workflowId}${PUBLIC_OUTPUT_PATH_SEPARATOR}${blockId}` } -/** - * Parses selectors persisted by the output picker before and after dot-form - * authoring became canonical. - */ -export function parseStoredOutputSelector(selector: string): ParsedOutputSelector { - const underscoreIndex = selector.indexOf(INTERNAL_OUTPUT_PATH_SEPARATOR) - const dotIndex = selector.indexOf(PUBLIC_OUTPUT_PATH_SEPARATOR) - const internalBlockId = underscoreIndex > 0 ? selector.slice(0, underscoreIndex) : '' - const hasCanonicalInternalBlockId = - internalBlockId.length > 0 && - internalBlockId.split(OUTPUT_SCOPE_SEPARATOR).every((segment) => isValidUuid(segment)) +function formatInternalScopedBlockRef(blockId: string, workflowId?: string): string { + const encodedBlockId = encodeInternalSelectorPart(blockId) + if (!workflowId) return encodedBlockId + return `${encodeInternalSelectorPart(workflowId)}${PUBLIC_OUTPUT_PATH_SEPARATOR}${encodedBlockId}` +} - if (hasCanonicalInternalBlockId || (underscoreIndex > 0 && dotIndex < 0)) { - return parseInternalOutputSelector(selector) +/** Formats the caller-facing `block.path` or `workflow.block.path` selector. */ +export function formatPublicOutputSelector( + blockId: string, + path = '', + workflowId?: string +): string { + if (workflowId && !path) { + throw new Error('Nested output selectors require an output path') } - return parsePublicOutputSelector(selector) + const scopedBlockRef = formatPublicScopedBlockRef(blockId, workflowId) + if (path) assertValidOutputPath(path) + return path ? `${scopedBlockRef}${PUBLIC_OUTPUT_PATH_SEPARATOR}${path}` : scopedBlockRef } -function formatOutputSelectorWithSeparator( +/** Formats the canonical `block_path` or `workflow.block_path` executor selector. */ +export function formatInternalOutputSelector( blockId: string, - path: string, - separator: typeof INTERNAL_OUTPUT_PATH_SEPARATOR | typeof PUBLIC_OUTPUT_PATH_SEPARATOR + path = '', + workflowId?: string ): string { - assertValidScopedBlockId(blockId) + if (workflowId && !path) { + throw new Error('Nested output selectors require an output path') + } + const scopedBlockRef = formatInternalScopedBlockRef(blockId, workflowId) if (path) assertValidOutputPath(path) - return path ? `${blockId}${separator}${path}` : blockId + return path ? `${scopedBlockRef}${INTERNAL_OUTPUT_PATH_SEPARATOR}${path}` : scopedBlockRef } -/** Formats the caller-facing selector stored in authoring state and sent over APIs. */ -export function formatPublicOutputSelector(blockId: string, path = ''): string { - return formatOutputSelectorWithSeparator(blockId, path, PUBLIC_OUTPUT_PATH_SEPARATOR) -} +export const formatOutputSelector = formatInternalOutputSelector -/** Formats the canonical internal selector consumed by the executor. */ -export function formatInternalOutputSelector(blockId: string, path = ''): string { - return formatOutputSelectorWithSeparator(blockId, path, INTERNAL_OUTPUT_PATH_SEPARATOR) +/** Creates the external block identity emitted by a selected child workflow. */ +export function scopeOutputBlockId(workflowId: string, childBlockId: string): string { + if (childBlockId.includes(PUBLIC_OUTPUT_PATH_SEPARATOR)) { + parseScopedBlockRef(childBlockId, true) + return childBlockId + } + return formatInternalScopedBlockRef(childBlockId, workflowId) } -/** Formats selectors for the executor's legacy internal contract. */ -export const formatOutputSelector = formatInternalOutputSelector +export function resolveOutputBlockRef( + blockRef: string, + blocks: Record +): string { + const exact = blocks[blockRef] + if (exact) return exact.id -export function scopeOutputBlockId(parentBlockId: string, childBlockId: string): string { - assertValidScopedBlockId(parentBlockId) - assertValidScopedBlockId(childBlockId) - return `${parentBlockId}${OUTPUT_SCOPE_SEPARATOR}${childBlockId}` + const blockValues = Object.values(blocks) + const idMatches = blockValues.filter((block) => block.id === blockRef) + if (idMatches.length === 1) return idMatches[0].id + + const normalizedRef = normalizeName(blockRef) + const matches = blockValues.filter((block) => normalizeName(block.name || '') === normalizedRef) + if (matches.length !== 1) { + throw new Error(`Selected output block does not resolve: ${blockRef}`) + } + return matches[0].id } -/** - * Returns only selections addressed to a workflow-block invocation and removes - * that invocation segment before handing them to its child executor. - */ +/** Routes workflow-scoped selections into a child executor. */ export function selectChildOutputSelectors( - parentBlockId: string, + childWorkflowId: string, + childBlocks: Record, selectedOutputs: readonly string[] | undefined -): string[] { - assertValidScopedBlockId(parentBlockId) - const prefix = `${parentBlockId}${OUTPUT_SCOPE_SEPARATOR}` +): ChildOutputSelection { + assertValidSelectorPart(childWorkflowId, 'workflow ID') const childSelectors: string[] = [] + const selectedBlockRefs = new Map() + let targetsChildWorkflow = false for (const selector of selectedOutputs ?? []) { const parsed = parseInternalOutputSelector(selector) - if (!parsed.blockId.startsWith(prefix)) continue - const childBlockId = parsed.blockId.slice(prefix.length) + if (!parsed.workflowId) continue + if (parsed.workflowId !== childWorkflowId) { + childSelectors.push(selector) + continue + } + + targetsChildWorkflow = true + const childBlockId = resolveOutputBlockRef(parsed.blockId, childBlocks) + selectedBlockRefs.set(childBlockId, parsed.blockId) childSelectors.push(formatInternalOutputSelector(childBlockId, parsed.path)) } - return childSelectors + return { selectedOutputs: childSelectors, selectedBlockRefs, targetsChildWorkflow } } diff --git a/apps/sim/lib/workflows/streaming/resolve-output-selectors.test.ts b/apps/sim/lib/workflows/streaming/resolve-output-selectors.test.ts new file mode 100644 index 00000000000..5e52d36c095 --- /dev/null +++ b/apps/sim/lib/workflows/streaming/resolve-output-selectors.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it } from 'vitest' +import { resolveOutputSelectors } from '@/lib/workflows/streaming/resolve-output-selectors' + +const ROOT_BLOCK_ID = '11111111-1111-4111-8111-111111111111' +const CHILD_WORKFLOW_ID = '22222222-2222-4222-8222-222222222222' + +function block(id: string, name: string) { + return { + id, + type: 'agent', + name, + subBlocks: {}, + position: { x: 0, y: 0 }, + outputs: {}, + enabled: true, + } +} + +describe('resolveOutputSelectors', () => { + it('resolves current names and defers child names to the authorized child loader', () => { + expect( + resolveOutputSelectors({ + selectedOutputs: [ + 'rootagent.result.text', + `${CHILD_WORKFLOW_ID}.answer_writer.result.text`, + ], + currentBlocks: { [ROOT_BLOCK_ID]: block(ROOT_BLOCK_ID, 'Root Agent') }, + }) + ).toEqual([`${ROOT_BLOCK_ID}_result.text`, `${CHILD_WORKFLOW_ID}.answer%5Fwriter_result.text`]) + }) + + it('rejects invocation-scoped slash selectors', () => { + expect(() => + resolveOutputSelectors({ + selectedOutputs: ['workflow-block/agent.content'], + currentBlocks: { [ROOT_BLOCK_ID]: block(ROOT_BLOCK_ID, 'Root Agent') }, + }) + ).toThrow('Invalid output selector') + }) + + it('uses referenced workflow IDs even when the ID is not UUID-shaped', () => { + const invocation = { + ...block('invoke', 'Research'), + type: 'workflow_input', + subBlocks: { workflowId: { value: 'child-workflow' } }, + } + + expect( + resolveOutputSelectors({ + selectedOutputs: ['child-workflow.writer.content'], + currentBlocks: { invoke: invocation }, + }) + ).toEqual(['child-workflow.writer_content']) + }) + + it('does not reinterpret an unknown root block name as a child workflow', () => { + expect(() => + resolveOutputSelectors({ + selectedOutputs: ['missing.result.text'], + currentBlocks: { [ROOT_BLOCK_ID]: block(ROOT_BLOCK_ID, 'Root Agent') }, + }) + ).toThrow('Selected output block does not resolve: missing') + }) +}) diff --git a/apps/sim/lib/workflows/streaming/resolve-output-selectors.ts b/apps/sim/lib/workflows/streaming/resolve-output-selectors.ts new file mode 100644 index 00000000000..bb83bd40600 --- /dev/null +++ b/apps/sim/lib/workflows/streaming/resolve-output-selectors.ts @@ -0,0 +1,38 @@ +import type { BlockState } from '@sim/workflow-types/workflow' +import { getWorkflowInvocationTarget } from '@/lib/workflows/streaming/nested-output-options' +import { + formatInternalOutputSelector, + parseStoredOutputSelector, + resolveOutputBlockRef, +} from '@/lib/workflows/streaming/output-selector' +import { normalizeName } from '@/executor/constants' + +interface ResolveOutputSelectorsOptions { + selectedOutputs: readonly string[] | undefined + currentBlocks: Record +} + +/** Resolves current-workflow names and leaves child names for its authorized loader. */ +export function resolveOutputSelectors({ + selectedOutputs, + currentBlocks, +}: ResolveOutputSelectorsOptions): string[] | undefined { + if (!selectedOutputs || selectedOutputs.length === 0) return selectedOutputs?.slice() + + const currentBlockRefs = new Set() + const childWorkflowIds = new Set() + for (const block of Object.values(currentBlocks)) { + currentBlockRefs.add(block.id) + currentBlockRefs.add(normalizeName(block.name || '')) + const childWorkflowId = getWorkflowInvocationTarget(block) + if (childWorkflowId) childWorkflowIds.add(childWorkflowId) + } + + return selectedOutputs.map((selector) => { + const parsed = parseStoredOutputSelector(selector, { currentBlockRefs, childWorkflowIds }) + const blockId = parsed.workflowId + ? parsed.blockId + : resolveOutputBlockRef(parsed.blockId, currentBlocks) + return formatInternalOutputSelector(blockId, parsed.path, parsed.workflowId) + }) +} diff --git a/apps/sim/lib/workflows/streaming/streaming.test.ts b/apps/sim/lib/workflows/streaming/streaming.test.ts index efae7687624..1400be73a1a 100644 --- a/apps/sim/lib/workflows/streaming/streaming.test.ts +++ b/apps/sim/lib/workflows/streaming/streaming.test.ts @@ -219,17 +219,17 @@ describe('createStreamingResponse', () => { expect(rawError.message).toBe(message) }) - it('emits invocation-scoped block IDs for a nested agent stream', async () => { + it('emits workflow-scoped block IDs for a nested agent stream', async () => { const stream = await createStreamingResponse({ requestId: 'request-nested-agent', executionId: 'execution-1', streamConfig: { - selectedOutputs: ['workflow-block/agent-1_content'], + selectedOutputs: ['child-workflow.agent-1_content'], includeFileBase64: false, }, executeFn: async ({ onStream }) => { await onStream({ - blockId: 'workflow-block/agent-1', + blockId: 'child-workflow.agent-1', stream: new ReadableStream({ start(controller) { controller.enqueue(new TextEncoder().encode('Nested answer')) @@ -254,7 +254,7 @@ describe('createStreamingResponse', () => { const events = await collectSSEEvents(stream) expect(events).toContainEqual({ - blockId: 'workflow-block/agent-1', + blockId: 'child-workflow.agent-1', chunk: 'Nested answer', }) }) diff --git a/apps/sim/stores/chat/types.ts b/apps/sim/stores/chat/types.ts index 14bc6a70c7f..f40abed4d71 100644 --- a/apps/sim/stores/chat/types.ts +++ b/apps/sim/stores/chat/types.ts @@ -26,6 +26,7 @@ export interface ChatMessage { * Output configuration for chat deployments */ export interface OutputConfig { + workflowId?: string blockId: string path: string } diff --git a/apps/sim/triggers/slack/oauth.ts b/apps/sim/triggers/slack/oauth.ts index a95328166d8..379548ef7f8 100644 --- a/apps/sim/triggers/slack/oauth.ts +++ b/apps/sim/triggers/slack/oauth.ts @@ -190,7 +190,7 @@ export const slackOAuthTrigger: TriggerConfig = { type: 'workflow-output-selector', placeholder: 'Select workflow outputs', description: - 'Output selectors use the same blockId.path form as the streaming API. Each selected block invocation creates its own Slack response. Agent outputs stream live; other outputs are sent when the block completes.', + 'Use `.` for this workflow or `..` for a child workflow. Selecting a child workflow applies to every invocation of it. Agent outputs stream live; other outputs are sent when the block completes.', required: { field: 'streamResponse', value: true, diff --git a/packages/sim-cli/src/contract/commands.ts b/packages/sim-cli/src/contract/commands.ts index c12b9ed0b87..4e01a9dea4c 100644 --- a/packages/sim-cli/src/contract/commands.ts +++ b/packages/sim-cli/src/contract/commands.ts @@ -1435,7 +1435,7 @@ export const CLI_CONTRACT: CliContract = { name: 'select-output', list: true, describe: - 'Return blockName.field values from the streamed result (e.g. agent_1.content), requires --follow; missing fields are omitted', + 'Return streamed outputs as blockName.path or childWorkflowId.blockName.path; selecting a child workflow applies to every invocation, requires --follow', }, // SSE, not JSON — the generic client cannot consume it, so the response // encoding is chosen by `--follow`, which `workflow-run-follow.ts` adds to diff --git a/packages/sim-cli/src/generated/v2-api.ts b/packages/sim-cli/src/generated/v2-api.ts index 4ae1de25892..82a31c11c9f 100644 --- a/packages/sim-cli/src/generated/v2-api.ts +++ b/packages/sim-cli/src/generated/v2-api.ts @@ -4608,6 +4608,7 @@ type GetWorkflowChatDeploymentResponseRef0 = { } type GetWorkflowChatDeploymentResponseRef1 = { + workflowId?: string blockId: string path: string } @@ -5137,6 +5138,7 @@ type ListChatDeploymentsResponseRef0 = { } type ListChatDeploymentsResponseRef1 = { + workflowId?: string blockId: string path: string } @@ -6875,6 +6877,7 @@ type ReplaceWorkflowChatDeploymentBodyRef0 = { } type ReplaceWorkflowChatDeploymentBodyRef1 = { + workflowId?: string blockId: string path: string } @@ -6899,6 +6902,7 @@ type ReplaceWorkflowChatDeploymentResponseRef0 = { } type ReplaceWorkflowChatDeploymentResponseRef1 = { + workflowId?: string blockId: string path: string } @@ -10743,7 +10747,7 @@ export const V2_OPERATIONS = { selectedOutputs: { kind: 'array', describe: - 'Block output references to include in a streamed response, as `blockId`, `blockId.path`, or `BlockName.path` (resolved against the live workflow). Requires `stream: true` — it shapes the streamed envelope only, so it is rejected on a sync request and when `async` is true. To narrow a finished run, pass `selectedOutputs` to the run resource instead.', + 'Block output references to include in a streamed response. Use `.` for the executed workflow or `..` for a child workflow; block names are normalized workflow reference names. Selecting a child workflow applies to every invocation of it. Requires `stream: true` — it shapes the streamed envelope only, so it is rejected on a sync request and when `async` is true. To narrow a finished run, pass `selectedOutputs` to the run resource instead.', }, includeThinking: { kind: 'boolean', From 9ca35f979898c57f38775ae674b213e4ef898e06 Mon Sep 17 00:00:00 2001 From: Theodore Li Date: Tue, 1 Sep 2026 02:14:03 -0700 Subject: [PATCH 6/6] fix(cli): align streaming selector help test --- packages/sim-cli/src/runtime/build.test.ts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/packages/sim-cli/src/runtime/build.test.ts b/packages/sim-cli/src/runtime/build.test.ts index 59468426151..c6f800ed248 100644 --- a/packages/sim-cli/src/runtime/build.test.ts +++ b/packages/sim-cli/src/runtime/build.test.ts @@ -772,8 +772,8 @@ describe('commands parsed through commander', () => { it('runs a workflow without input and keeps output selection distinct from rendering', async () => { const help = commandAt('workflows', 'run').helpInformation() expect(help).toContain('--select-output ') - expect(help).toContain('blockName.field') - expect(help).toContain('agent_1.content') + expect(help).toContain('blockName.path') + expect(help).toContain('childWorkflowId.blockName.path') expect(help).not.toContain('--output ') const [, withoutInput] = await run(