Skip to content

Commit c72b099

Browse files
committed
fix(chat): reconcile structured output delivery
1 parent 62d922f commit c72b099

4 files changed

Lines changed: 95 additions & 5 deletions

File tree

‎apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.test.tsx‎

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -144,6 +144,30 @@ describe('useChatStreaming thinking + abort', () => {
144144
expect(messages[0].isStreaming).toBe(false)
145145
})
146146

147+
it('projects file metadata to the fields used by the chat', async () => {
148+
mockReadSSEEvents.mockImplementation(async (_source, options) => {
149+
await options.onEvent({
150+
blockId: 'agent-1',
151+
event: 'output',
152+
data: {
153+
...imageFile,
154+
providerFileId: 'provider-file-1',
155+
providerFileUri: 'provider://file-1',
156+
remoteUrl: 'https://files.example.com/signed',
157+
internalMetadata: { source: 'provider' },
158+
},
159+
})
160+
await options.onEvent({ event: 'final', data: { success: true, output: {} } })
161+
})
162+
163+
await act(async () => {
164+
await handle.latest().handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn())
165+
})
166+
167+
expect(messages[0].files).toEqual([imageFile])
168+
expect(messages[0].content).toBe('')
169+
})
170+
147171
it.each([{ data: [] }, { data: null }, { data: { files: [] } }])(
148172
'keeps empty structured outputs invisible: $data',
149173
async ({ data }) => {

‎apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts‎

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,16 @@ const logger = createLogger('UseChatStreaming')
3737
function extractChatOutput(value: unknown, files: Map<string, ChatFile>): unknown {
3838
if (value === null || value === undefined) return value
3939
if (isUserFileWithMetadata(value)) {
40-
files.set(value.id, value)
40+
files.set(value.id, {
41+
id: value.id,
42+
name: value.name,
43+
url: value.url,
44+
key: value.key,
45+
size: value.size,
46+
type: value.type,
47+
context: value.context,
48+
base64: value.base64,
49+
})
4150
return undefined
4251
}
4352
if (Array.isArray(value)) {

‎apps/sim/lib/workflows/streaming/streaming.test.ts‎

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -152,6 +152,54 @@ describe('createStreamingResponse', () => {
152152
expect(events).toContainEqual({ blockId: 'agent', event: 'output', data: [file] })
153153
})
154154

155+
it('emits composite response-format selections once as structured outputs', async () => {
156+
const result = {
157+
content: 'Your image.',
158+
files: [
159+
{
160+
id: 'file-image',
161+
name: 'image.png',
162+
size: 3,
163+
type: 'image/png',
164+
key: 'execution/image.png',
165+
url: '/api/files/serve/execution%2Fimage.png',
166+
base64: 'YWJj',
167+
},
168+
],
169+
count: 1,
170+
}
171+
const stream = await createStreamingResponse({
172+
requestId: 'request-chat-composite',
173+
requestHeaders: new Headers({ [AGENT_STREAM_PROTOCOL_HEADER]: CHAT_OUTPUT_PROTOCOL_V1 }),
174+
streamConfig: {
175+
selectedOutputs: ['agent_result'],
176+
workflowTriggerType: 'chat',
177+
includeFileBase64: false,
178+
},
179+
executeFn: async ({ onStream, onBlockComplete }) => {
180+
await onStream({
181+
blockId: 'agent',
182+
clientStreamTransformed: true,
183+
stream: new ReadableStream({
184+
start(controller) {
185+
controller.enqueue(new TextEncoder().encode(JSON.stringify(result)))
186+
controller.close()
187+
},
188+
}),
189+
execution: { success: true, output: {} },
190+
})
191+
await onBlockComplete('agent', { result })
192+
return { success: true, output: {}, logs: [], metadata: { duration: 1 } }
193+
},
194+
})
195+
196+
const events = await collectSSEEvents(stream)
197+
expect(events.filter((event) => 'chunk' in event)).toEqual([])
198+
expect(events.filter((event) => event.event === 'output')).toEqual([
199+
{ blockId: 'agent', event: 'output', data: result },
200+
])
201+
})
202+
155203
it('enforces the aggregate inline byte limit for structured chat outputs', async () => {
156204
const value = { text: 'x'.repeat(9 * 1024 * 1024) }
157205
const stream = await createStreamingResponse({

‎apps/sim/lib/workflows/streaming/streaming.ts‎

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -130,6 +130,7 @@ interface StreamingState {
130130
processedOutputs: Set<string>
131131
streamCompletionTimes: Map<string, number>
132132
completedBlockIds: Set<string>
133+
deferredOutputBlocks: Set<string>
133134
selectedOutputBytes: number
134135
streamedSelectedOutputKeys: Set<string>
135136
selectedOutputError?: string
@@ -563,6 +564,7 @@ export async function createStreamingResponse(
563564
processedOutputs: new Set(),
564565
streamCompletionTimes: new Map(),
565566
completedBlockIds: new Set(),
567+
deferredOutputBlocks: new Set(),
566568
selectedOutputBytes: 0,
567569
streamedSelectedOutputKeys: new Set(),
568570
}
@@ -632,6 +634,11 @@ export async function createStreamingResponse(
632634
return
633635
}
634636

637+
/** Response-format streams contain complete selected values; send their typed outputs once. */
638+
const deferSelectedOutputs =
639+
emitStructuredOutputs && streamingExec.clientStreamTransformed === true
640+
if (deferSelectedOutputs) state.deferredOutputBlocks.add(blockId)
641+
635642
/**
636643
* Negotiated clients get answer text live from the sink (pending deltas
637644
* stream as the model generates; `chunk_reset` clears an intermediate
@@ -641,8 +648,8 @@ export async function createStreamingResponse(
641648
* only writes once the turn is classified — correct for a consumer that
642649
* cannot retract, at the cost of arriving in one piece.
643650
*
644-
* Response-format projections rewrite the bytes, so those blocks keep
645-
* the byte stream as the frame source either way.
651+
* Response-format projections rewrite the bytes. Typed-output clients
652+
* receive those selections from onBlockComplete instead.
646653
*/
647654
const sinkAnswerText =
648655
clientAcceptsProtocol &&
@@ -708,7 +715,7 @@ export async function createStreamingResponse(
708715
}
709716
state.streamedChunks.get(blockId)!.push(textChunk)
710717

711-
if (!sinkAnswerText) {
718+
if (!sinkAnswerText && !deferSelectedOutputs) {
712719
emitAnswerChunk(textChunk)
713720
}
714721
}
@@ -743,7 +750,9 @@ export async function createStreamingResponse(
743750
return
744751
}
745752

746-
const hasStreamedText = state.streamedChunks.has(selectedOutputBlockId)
753+
const hasStreamedText =
754+
state.streamedChunks.has(selectedOutputBlockId) &&
755+
!state.deferredOutputBlocks.has(selectedOutputBlockId)
747756
if (hasStreamedText && !emitStructuredOutputs) {
748757
return
749758
}

0 commit comments

Comments
 (0)