Skip to content

Commit 03faf26

Browse files
Bill LeoutsakosBill Leoutsakos
authored andcommitted
fix(chat): suppress mixed invocation outputs
1 parent 20dc04e commit 03faf26

2 files changed

Lines changed: 71 additions & 7 deletions

File tree

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.test.tsx

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

99
const {
10+
chatStoreState,
1011
executionStoreState,
1112
mockCancel,
1213
mockExecute,
@@ -22,6 +23,9 @@ const {
2223
workflowBlocks,
2324
workflowStoreState,
2425
} = vi.hoisted(() => {
26+
const chatStoreState = {
27+
selectedWorkflowOutputs: [] as string[],
28+
}
2529
const workflowBlocks = {
2630
start: {
2731
id: 'start',
@@ -82,6 +86,7 @@ const {
8286
}
8387

8488
return {
89+
chatStoreState,
8590
executionStoreState,
8691
mockCancel: vi.fn(),
8792
mockExecute: vi.fn(),
@@ -167,9 +172,18 @@ vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-current-workflow
167172

168173
vi.mock('@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils', () => ({
169174
addHttpErrorConsoleEntry: vi.fn(),
170-
createBlockEventHandlers: () => ({
175+
createBlockEventHandlers: (config: {
176+
onBlockCompleteCallback?: (
177+
blockId: string,
178+
output: unknown,
179+
blockExecutionId?: string
180+
) => Promise<void>
181+
}) => ({
171182
onBlockStarted: vi.fn(),
172-
onBlockCompleted: vi.fn(),
183+
onBlockCompleted: vi.fn(
184+
(data: { blockId: string; output: unknown; blockExecutionId?: string }) =>
185+
config.onBlockCompleteCallback?.(data.blockId, data.output, data.blockExecutionId)
186+
),
173187
onBlockError: vi.fn(),
174188
onBlockChildWorkflowStarted: vi.fn(),
175189
}),
@@ -221,7 +235,7 @@ vi.mock('@/serializer', () => ({
221235
vi.mock('@/stores/chat/store', () => ({
222236
useChatStore: {
223237
getState: () => ({
224-
getSelectedWorkflowOutput: () => [],
238+
getSelectedWorkflowOutput: () => chatStoreState.selectedWorkflowOutputs,
225239
}),
226240
},
227241
}))
@@ -343,6 +357,7 @@ async function drainStream(value: unknown): Promise<void> {
343357
describe('useWorkflowExecution cancellation', () => {
344358
beforeEach(() => {
345359
vi.clearAllMocks()
360+
chatStoreState.selectedWorkflowOutputs = []
346361
executionStoreState.getCurrentExecutionId.mockReturnValue('execution-1')
347362
mockRequestJson.mockResolvedValue({ success: true })
348363
})
@@ -402,6 +417,7 @@ describe('useWorkflowExecution cancellation', () => {
402417
describe('useWorkflowExecution attachment uploads', () => {
403418
beforeEach(() => {
404419
vi.clearAllMocks()
420+
chatStoreState.selectedWorkflowOutputs = []
405421
executionStoreState.getCurrentExecutionId.mockReturnValue(null)
406422
mockResolveStartCandidates.mockReturnValue([])
407423
mockSelectBestTrigger.mockReturnValue([])
@@ -584,6 +600,52 @@ describe('useWorkflowExecution attachment uploads', () => {
584600
unmount()
585601
})
586602

603+
it('does not append a later sibling output after the block has streamed', async () => {
604+
chatStoreState.selectedWorkflowOutputs = ['agent-1_content']
605+
mockExecute.mockImplementationOnce(async (options) => {
606+
options.onExecutionId?.('execution-1')
607+
await options.callbacks?.onStreamChunk?.({
608+
blockId: 'agent-1',
609+
blockExecutionId: 'invoke-streamed',
610+
chunk: 'streamed answer',
611+
})
612+
await new Promise((resolve) => setTimeout(resolve, 0))
613+
await options.callbacks?.onBlockCompleted?.({
614+
blockId: 'agent-1',
615+
blockExecutionId: 'invoke-streamed',
616+
output: { content: 'streamed answer' },
617+
})
618+
await options.callbacks?.onBlockCompleted?.({
619+
blockId: 'agent-1',
620+
blockExecutionId: 'invoke-later',
621+
output: { content: 'later answer' },
622+
})
623+
})
624+
625+
const { result, unmount } = renderWorkflowExecutionHook()
626+
const decoder = new TextDecoder()
627+
let streamedText = ''
628+
629+
await act(async () => {
630+
const runResult = await result().handleRunWorkflow({ input: 'chat input' })
631+
if (!isChatWorkflowRunResult(runResult)) {
632+
throw new Error('Expected a chat workflow run result')
633+
}
634+
const reader = runResult.stream.getReader()
635+
while (true) {
636+
const { done, value } = await reader.read()
637+
if (done) break
638+
streamedText += decoder.decode(value, { stream: true })
639+
}
640+
streamedText += decoder.decode()
641+
})
642+
643+
expect(streamedText).toContain('streamed answer')
644+
expect(streamedText).not.toContain('later answer')
645+
646+
unmount()
647+
})
648+
587649
it('preserves legacy live thinking when no display projection field is sent', async () => {
588650
mockExecute.mockImplementationOnce(async (options) => {
589651
options.onExecutionId?.('execution-1')

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -784,6 +784,7 @@ export function useWorkflowExecution() {
784784
async start(controller) {
785785
const { encodeSSE } = await import('@/lib/core/utils/sse')
786786
const streamedChunks = new Map<string, string[]>()
787+
const streamedBlockIds = new Set<string>()
787788
const streamReadingPromises: Promise<void>[] = []
788789

789790
const safeEnqueue = (data: Uint8Array) => {
@@ -821,6 +822,7 @@ export function useWorkflowExecution() {
821822
}
822823
const chunk = new TextDecoder().decode(value)
823824
if (streamKey) {
825+
if (blockId) streamedBlockIds.add(blockId)
824826
streamedChunks.get(streamKey)!.push(chunk)
825827
}
826828

@@ -844,9 +846,9 @@ export function useWorkflowExecution() {
844846

845847
/**
846848
* Intermediate-turn reconciliation: drop the block's streamed text
847-
* (chunk_reset frame) and remove its bookkeeping entirely so
848-
* separator counting ignores it and the final turn (or, if none
849-
* re-streams, onBlockComplete's output fallback) starts clean.
849+
* (chunk_reset frame) and remove its per-invocation chunks so
850+
* separator counting ignores them. The block-level streamed marker
851+
* remains to prevent a sibling invocation from appending stale output.
850852
*/
851853
const onStreamReset = (blockId: string, blockExecutionId?: string) => {
852854
const streamKey = blockExecutionId ?? blockId
@@ -864,7 +866,7 @@ export function useWorkflowExecution() {
864866
) => {
865867
const streamKey = blockExecutionId ?? blockId
866868
// Skip if this block already had streaming content (avoid duplicates)
867-
if (streamedChunks.has(streamKey)) {
869+
if (streamedChunks.has(streamKey) || streamedBlockIds.has(blockId)) {
868870
logger.debug('[handleRunWorkflow] Skipping onBlockComplete for streaming block', {
869871
blockId,
870872
})

0 commit comments

Comments
 (0)