Skip to content

Commit 2628f0b

Browse files
fix(streaming): project structured output live
1 parent 358af42 commit 2628f0b

12 files changed

Lines changed: 512 additions & 29 deletions

apps/sim/executor/execution/block-executor.test.ts

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1352,6 +1352,47 @@ describe('BlockExecutor streaming pump', () => {
13521352
)
13531353
})
13541354

1355+
it('projects a selected structured string into live sink deltas', async () => {
1356+
const handler = createAgentEventsStreamingHandler({
1357+
events: [
1358+
{ type: 'text_delta', text: '{"answer":"Hello ', turn: 'pending' },
1359+
{ type: 'text_delta', text: 'world","score":1}', turn: 'pending' },
1360+
{ type: 'turn_end', turn: 'final' },
1361+
],
1362+
})
1363+
const { executor, block, state } = createExecutor(handler)
1364+
block.config.params = {
1365+
responseFormat: {
1366+
schema: {
1367+
type: 'object',
1368+
properties: { answer: { type: 'string' }, score: { type: 'number' } },
1369+
},
1370+
},
1371+
}
1372+
const ctx = createContext(state)
1373+
ctx.stream = true
1374+
ctx.selectedOutputs = [`${block.id}_answer`]
1375+
const sinkText: string[] = []
1376+
let forwardedText = ''
1377+
1378+
ctx.onStream = async (streamingExec) => {
1379+
expect(streamingExec.clientStreamTransformed).toBe(true)
1380+
expect(streamingExec.clientSinkTransformed).toBe(true)
1381+
streamingExec.subscribe?.({
1382+
onEvent: (event) => {
1383+
if (event.type === 'text_delta') sinkText.push(event.text)
1384+
},
1385+
})
1386+
forwardedText = await new Response(streamingExec.stream).text()
1387+
}
1388+
1389+
await executor.execute(ctx, createNode(block), block)
1390+
1391+
expect(sinkText).toEqual(['Hello ', 'world'])
1392+
expect(forwardedText).toBe('Hello world')
1393+
expect(state.getBlockOutput(block.id)?.answer).toBe('Hello world')
1394+
})
1395+
13551396
it('forwards the stable block ID for streams from expanded branch nodes', async () => {
13561397
const handler = createAgentEventsStreamingHandler({
13571398
events: [{ type: 'text_delta', text: 'branch answer', turn: 'final' }],

apps/sim/executor/execution/block-executor.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1197,6 +1197,15 @@ export class BlockExecutor {
11971197
selectedOutputs,
11981198
responseFormat
11991199
)
1200+
const clientStreamTransformed = processedClientStream !== pump.textStream
1201+
const projectedSubscribe = clientStreamTransformed
1202+
? streamingResponseFormatProcessor.processEventSubscription(
1203+
pump.subscribe,
1204+
blockId,
1205+
selectedOutputs,
1206+
responseFormat
1207+
)
1208+
: undefined
12001209

12011210
// Start onStream without awaiting so a sync `subscribe(sink)` can run before
12021211
// the first provider pull, then read the projected text stream concurrently
@@ -1208,10 +1217,11 @@ export class BlockExecutor {
12081217
...(executionOrder !== undefined ? { executionOrder } : {}),
12091218
stream: processedClientStream,
12101219
streamFormat: 'text',
1211-
subscribe: pump.subscribe,
1220+
subscribe: projectedSubscribe ?? pump.subscribe,
12121221
// processStream returns the input stream identity when no
12131222
// response-format extraction applies.
1214-
clientStreamTransformed: processedClientStream !== pump.textStream,
1223+
clientStreamTransformed,
1224+
clientSinkTransformed: Boolean(projectedSubscribe),
12151225
displayResolvedSecretTraceProvenance:
12161226
ctx.resolvedSecretTraceRegistry?.exportCommittedProvenanceForValue(resolvedInputs),
12171227
})

apps/sim/executor/handlers/workflow/workflow-handler.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2233,6 +2233,9 @@ describe('WorkflowBlockHandler', () => {
22332233
const childStream = {
22342234
blockId: 'agent-1',
22352235
stream: new ReadableStream(),
2236+
subscribe: vi.fn(),
2237+
clientStreamTransformed: true,
2238+
clientSinkTransformed: true,
22362239
execution: { success: true, output: {} },
22372240
}
22382241
await extensions.onStream(childStream)

apps/sim/executor/types.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -666,10 +666,11 @@ export interface StreamingExecution {
666666
/**
667667
* True when {@link stream} is a response-format projection (selected JSON
668668
* fields extracted from structured output) rather than raw answer text. Sink
669-
* `text_delta` events then do NOT match the byte stream, so consumers must
670-
* keep sourcing answer text from {@link stream} instead of the sink.
669+
* `text_delta` events match it only when {@link clientSinkTransformed} is true.
671670
*/
672671
clientStreamTransformed?: boolean
672+
/** True when sink text deltas are projected to match a transformed client stream. */
673+
clientSinkTransformed?: boolean
673674
/** Internal provenance for the exact block input that initiated this live stream. */
674675
displayResolvedSecretTraceProvenance?: ResolvedSecretTraceProvenanceV1
675676
/** Internal source registry retained only for sanitizing failures while the stream drains. */

apps/sim/executor/utils.test.ts

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import {
33
StreamingResponseFormatProcessor,
44
streamingResponseFormatProcessor,
55
} from '@/executor/utils'
6+
import type { AgentStreamEvent, AgentStreamSink } from '@/providers/stream-events'
67

78
describe('StreamingResponseFormatProcessor', () => {
89
let processor: StreamingResponseFormatProcessor
@@ -170,6 +171,91 @@ describe('StreamingResponseFormatProcessor', () => {
170171
expect(result).toBe('charlie')
171172
})
172173

174+
it('projects a selected string field from live event deltas', async () => {
175+
let sourceSink: AgentStreamSink | undefined
176+
const projectedEvents: AgentStreamEvent[] = []
177+
const subscribe = processor.processEventSubscription(
178+
(sink) => {
179+
sourceSink = sink
180+
return () => {}
181+
},
182+
'block-1',
183+
['block-1_answer'],
184+
JSON.stringify({
185+
type: 'object',
186+
properties: {
187+
meta: { type: 'object' },
188+
answer: { type: 'string' },
189+
score: { type: 'number' },
190+
},
191+
})
192+
)
193+
194+
expect(subscribe).toBeDefined()
195+
subscribe?.({
196+
onEvent: async (event) => {
197+
projectedEvents.push(event)
198+
},
199+
})
200+
201+
await sourceSink?.onEvent({
202+
type: 'text_delta',
203+
text: '{"meta":{"source":"test"},"answer":"Hello ',
204+
turn: 'pending',
205+
})
206+
expect(projectedEvents).toEqual([{ type: 'text_delta', text: 'Hello ', turn: 'pending' }])
207+
208+
await sourceSink?.onEvent({
209+
type: 'text_delta',
210+
text: 'world","score":1}',
211+
turn: 'pending',
212+
})
213+
await sourceSink?.onEvent({ type: 'thinking_delta', text: 'done' })
214+
await sourceSink?.onEvent({ type: 'turn_end', turn: 'final' })
215+
216+
expect(projectedEvents).toEqual([
217+
{ type: 'text_delta', text: 'Hello ', turn: 'pending' },
218+
{ type: 'text_delta', text: 'world', turn: 'pending' },
219+
{ type: 'thinking_delta', text: 'done' },
220+
{ type: 'turn_end', turn: 'final' },
221+
])
222+
})
223+
224+
it('holds incomplete JSON escapes until they can be decoded', async () => {
225+
let sourceSink: AgentStreamSink | undefined
226+
const projectedText: string[] = []
227+
const subscribe = processor.processEventSubscription(
228+
(sink) => {
229+
sourceSink = sink
230+
return () => {}
231+
},
232+
'block-1',
233+
['block-1_answer'],
234+
{ schema: { properties: { answer: { type: 'string' } } } }
235+
)
236+
237+
subscribe?.({
238+
onEvent: (event) => {
239+
if (event.type === 'text_delta') projectedText.push(event.text)
240+
},
241+
})
242+
await sourceSink?.onEvent({ type: 'text_delta', text: '{"answer":"line\\', turn: 'final' })
243+
await sourceSink?.onEvent({ type: 'text_delta', text: 'nnext"}', turn: 'final' })
244+
245+
expect(projectedText).toEqual(['line', '\nnext'])
246+
})
247+
248+
it('does not claim live sink projection for non-string fields', () => {
249+
const subscribe = processor.processEventSubscription(
250+
() => () => {},
251+
'block-1',
252+
['block-1_score'],
253+
{ schema: { properties: { score: { type: 'number' } } } }
254+
)
255+
256+
expect(subscribe).toBeUndefined()
257+
})
258+
173259
it.concurrent('should handle missing fields gracefully', async () => {
174260
const mockStream = new ReadableStream({
175261
start(controller) {

0 commit comments

Comments
 (0)