Skip to content

Commit 854a6db

Browse files
committed
feat(streaming): stream answer text live during tool loops via turn_end protocol
The live tool loops buffered all answer text per model turn (classification of intermediate vs final is only known at turn end), so gated surfaces saw thinking stream, then dead air with the thinking chrome stuck open, then the whole answer at once. Loops now emit text deltas live as `turn: 'pending'` plus a `turn_end` event per turn. The pump buffers pending text and projects it to the byte path (answerText/logs/memory/legacy clients) only on a final turn_end, so all settled semantics are unchanged. Gated surfaces render the pending text as it streams and reconcile with a reset when a turn resolves to tools: - public chat: live `chunk` frames from the sink + dual-gated `chunk_reset`; byte-path frame emission is suppressed to avoid duplicates (kept for response-format transformed streams via clientStreamTransformed) - canvas: forwarder emits live `stream:chunk` + `stream:chunk_reset`; the execute route and HITL resume readers stop re-emitting byte chunks; panel chat tracks per-block segments and replaces content on flush - chat client: per-block text segments, chunk_reset handling, and thinking chrome now settles on tool start as well as first answer chunk
1 parent 807ae80 commit 854a6db

33 files changed

Lines changed: 905 additions & 119 deletions

apps/docs/content/docs/en/workflows/deployment/agent-events.mdx

Lines changed: 12 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -31,33 +31,40 @@ Answer text stays on `chunk`. Thinking and tools never reuse `chunk` (so older c
3131

3232
| Frame | Meaning |
3333
|-------|---------|
34-
| `{ "blockId", "chunk": "…" }` | Final-turn answer text only |
34+
| `{ "blockId", "chunk": "…" }` | Answer text. Legacy clients receive settled final-turn text; opted-in clients receive it live as the model generates (see below) |
35+
| `{ "blockId", "event": "chunk_reset" }` | Opted-in only: discard the block’s streamed answer text — it belonged to a turn that resolved to tool calls |
3536
| `{ "blockId", "event": "thinking", "data": "…" }` | Thinking / reasoning summary delta |
3637
| `{ "blockId", "event": "tool", "phase": "start"\|"end", "id", "name", "status?" }` | Tool lifecycle (no args / results) |
3738
| `{ "event": "final", "data": … }` | Terminal success envelope |
3839
| `{ "event": "error", "error": "…" }` | Terminal failure — followed by `[DONE]`, never by `final` |
3940
| `{ "event": "stream_error", "blockId?", "error" }` | Non-terminal mid-block read issue; the stream keeps going |
4041
| `data: [DONE]` | Stream closed (always follows the terminal `final` or `error` frame) |
4142

42-
### Intermediate-turn text
43+
### Live answer text and intermediate turns
4344

44-
During a live tool loop, the model may emit preamble text before calling a tool. That text is tagged **intermediate** internally and is **not** projected into the user-facing answer channel. Only the **final** turn’s text appears as `chunk`.
45+
During a live tool loop, the model can’t be classified mid-turn: text it emits may turn out to be the final answer or preamble before a tool call (the stop reason arrives only at turn end).
46+
47+
- **Opted-in clients** (protocol header + `includeThinking`) receive answer text as `chunk` frames **live**, token by token. If the turn then resolves to tool calls, a `chunk_reset` frame tells the client to discard that block’s streamed text — the final turn re-streams live after tools settle. Append `chunk`, honor `chunk_reset`, and the displayed answer always converges to the block’s final content.
48+
- **Legacy clients** (no header) never see provisional text: only settled final-turn text is emitted as `chunk`, delivered when the turn completes.
49+
50+
Logs, memory, and the block’s `content` output always contain final-turn text only — intermediate preamble is never persisted.
4551

4652
### Abort
4753

4854
Client disconnect or Stop aborts the provider stream. In-flight tools settle as `cancelled`. Cancel is distinct from execution timeout.
4955

5056
### Reconnect
5157

52-
Canvas execution-events `stream:chunk`, `stream:thinking`, and `stream:tool` are **live-only** (not buffered for reconnect replay), same as answer chunks. Guaranteed `seq` replay is out of scope.
58+
Canvas execution-events `stream:chunk`, `stream:chunk_reset`, `stream:thinking`, and `stream:tool` are **live-only** (not buffered for reconnect replay), same as answer chunks. Guaranteed `seq` replay is out of scope.
5359

5460
## Canvas (draft Run)
5561

5662
When you click **Run** in the builder, the execution-events SSE path forwards the same sink. The canvas is always opted in — it does not send (or need) the `X-Sim-Stream-Protocol` header; the dual gate applies only to the public chat / simple SSE surface:
5763

5864
- `stream:thinking``{ blockId, text }`
5965
- `stream:tool``{ blockId, phase, id, name, status? }`
60-
- `stream:chunk` — answer text (unchanged)
66+
- `stream:chunk` — answer text, live on agent-events runs
67+
- `stream:chunk_reset``{ blockId }`; discard the block’s streamed text (intermediate turn)
6168

6269
The terminal output panel shows Thinking / Tools chrome above the block output when those events arrive. PII redaction on block output still disables live forwarding (executor rule).
6370

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

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -147,6 +147,82 @@ describe('useChatStreaming thinking + abort', () => {
147147
expect(assistant?.isThinkingStreaming).toBe(false)
148148
})
149149

150+
it('clears a block’s live text on chunk_reset and keeps the re-streamed final turn', async () => {
151+
mockReadSSEEvents.mockImplementation(async (_source, options) => {
152+
// Turn 1: live preamble, then tools follow → reset.
153+
await options.onEvent({ blockId: 'agent-1', chunk: 'Let me check the weather…' })
154+
await options.onEvent({
155+
blockId: 'agent-1',
156+
event: 'tool',
157+
phase: 'start',
158+
id: 't1',
159+
name: 'get_weather',
160+
})
161+
await options.onEvent({ blockId: 'agent-1', event: 'chunk_reset' })
162+
await options.onEvent({
163+
blockId: 'agent-1',
164+
event: 'tool',
165+
phase: 'end',
166+
id: 't1',
167+
name: 'get_weather',
168+
status: 'success',
169+
})
170+
// Turn 2: final answer streams live.
171+
await options.onEvent({ blockId: 'agent-1', chunk: 'It is ' })
172+
await options.onEvent({ blockId: 'agent-1', chunk: '68°F.' })
173+
await options.onEvent({
174+
event: 'final',
175+
data: { success: true, output: {} },
176+
})
177+
})
178+
179+
await act(async () => {
180+
await handle.latest().handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn())
181+
})
182+
await flushUiBatch()
183+
184+
const assistant = messages.find((m) => m.id === 'msg-assistant-1')
185+
expect(assistant?.content).toBe('It is 68°F.')
186+
expect(assistant?.content).not.toContain('Let me check')
187+
})
188+
189+
it('settles thinking chrome when a tool starts', async () => {
190+
let midStreamThinking: boolean | undefined
191+
mockReadSSEEvents.mockImplementation(async (_source, options) => {
192+
await options.onEvent({ blockId: 'agent-1', event: 'thinking', data: 'planning…' })
193+
await options.onEvent({
194+
blockId: 'agent-1',
195+
event: 'tool',
196+
phase: 'start',
197+
id: 't1',
198+
name: 'get_weather',
199+
})
200+
// UI flush is synchronous in tests (rAF mocked) — capture mid-stream state.
201+
midStreamThinking = messages.find((m) => m.type === 'assistant')?.isThinkingStreaming
202+
await options.onEvent({
203+
blockId: 'agent-1',
204+
event: 'tool',
205+
phase: 'end',
206+
id: 't1',
207+
name: 'get_weather',
208+
status: 'success',
209+
})
210+
await options.onEvent({
211+
event: 'final',
212+
data: { success: true, output: {} },
213+
})
214+
})
215+
216+
await act(async () => {
217+
await handle.latest().handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn())
218+
})
219+
await flushUiBatch()
220+
221+
expect(midStreamThinking).toBe(false)
222+
const assistant = messages.find((m) => m.id === 'msg-assistant-1')
223+
expect(assistant?.thinking).toBe('planning…')
224+
})
225+
150226
it('ignores non-terminal stream_error frames and keeps streaming', async () => {
151227
mockReadSSEEvents.mockImplementation(async (_source, options) => {
152228
await options.onEvent({

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

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import { readSSEEvents } from '@/lib/core/utils/sse'
1414
import { isUserFileWithMetadata } from '@/lib/core/utils/user-file'
1515
import {
1616
isChatChunkFrame,
17+
isChatChunkResetFrame,
1718
isChatErrorFrame,
1819
isChatFinalFrame,
1920
isChatStreamErrorFrame,
@@ -182,7 +183,19 @@ export function useChatStreaming() {
182183
return
183184
}
184185

186+
/**
187+
* Answer text tracked per block so a `chunk_reset` (dual-gated streams:
188+
* a live-streamed turn resolved to tool calls) can clear one block's
189+
* contribution. `accumulatedText` is re-derived on every mutation —
190+
* cross-block separators arrive baked into the chunks.
191+
*/
192+
const blockTextOrder: string[] = []
193+
const blockTextSegments = new Map<string, string>()
185194
let accumulatedText = ''
195+
const recomputeAccumulatedText = () => {
196+
accumulatedText = blockTextOrder.map((id) => blockTextSegments.get(id) ?? '').join('')
197+
accumulatedTextRef.current = accumulatedText
198+
}
186199
let accumulatedThinking = ''
187200
let isThinkingStreaming = false
188201
let lastAudioPosition = 0
@@ -356,6 +369,11 @@ export function useChatStreaming() {
356369
if (!messageIdMap.has(blockId)) {
357370
messageIdMap.set(blockId, messageId)
358371
}
372+
// Tools starting means the turn's thinking phase is over — settle
373+
// the thinking chrome (it re-opens if more thinking streams later).
374+
if (json.phase === 'start' && isThinkingStreaming) {
375+
isThinkingStreaming = false
376+
}
359377
applyToolCallPhase(
360378
toolCallsMap,
361379
toolCallOrder,
@@ -531,6 +549,21 @@ export function useChatStreaming() {
531549
return true
532550
}
533551

552+
if (isChatChunkResetFrame(json)) {
553+
// The block's live-streamed text belonged to an intermediate turn
554+
// (tool calls follow); drop it — the final turn re-streams after.
555+
const { blockId } = json
556+
if (blockTextSegments.has(blockId)) {
557+
blockTextSegments.set(blockId, '')
558+
recomputeAccumulatedText()
559+
// Spoken audio cannot be unplayed; clamp so slicing stays valid.
560+
lastAudioPosition = Math.min(lastAudioPosition, accumulatedText.length)
561+
uiDirty = true
562+
scheduleUIFlush()
563+
}
564+
return false
565+
}
566+
534567
// Answer text only — never append thinking/tool/unknown chunk frames blindly.
535568
if (isChatChunkFrame(json)) {
536569
const { blockId, chunk: contentChunk } = json
@@ -543,8 +576,12 @@ export function useChatStreaming() {
543576
isThinkingStreaming = false
544577
}
545578

546-
accumulatedText += contentChunk
547-
accumulatedTextRef.current = accumulatedText
579+
if (!blockTextSegments.has(blockId)) {
580+
blockTextOrder.push(blockId)
581+
blockTextSegments.set(blockId, '')
582+
}
583+
blockTextSegments.set(blockId, blockTextSegments.get(blockId)! + contentChunk)
584+
recomputeAccumulatedText()
548585
logger.debug('[useChatStreaming] Received chunk', {
549586
blockId,
550587
chunkLength: contentChunk.length,

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

Lines changed: 13 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -88,7 +88,10 @@ import {
8888
loadWorkflowDeploymentVersionState,
8989
loadWorkflowFromNormalizedTables,
9090
} from '@/lib/workflows/persistence/utils'
91-
import { forwardAgentStreamToExecutionEvents } from '@/lib/workflows/streaming/forward-agent-stream-events'
91+
import {
92+
forwardAgentStreamToExecutionEvents,
93+
shouldForwardAnswerTextFromSink,
94+
} from '@/lib/workflows/streaming/forward-agent-stream-events'
9295
import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
9396
import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils'
9497
import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils'
@@ -1725,12 +1728,19 @@ async function handleExecutePost(
17251728
const onStream = async (streamingExec: StreamingExecution) => {
17261729
const blockId = (streamingExec.execution as any).blockId
17271730

1731+
// Live answer text rides the sink when available (pending deltas
1732+
// stream as the model generates; chunk_reset clears intermediate
1733+
// turns). The byte stream is then drained without re-emitting
1734+
// chunks — its text is the same final-turn content.
1735+
const answerTextFromSink = shouldForwardAnswerTextFromSink(streamingExec)
1736+
17281737
// Sync window: attach sink before first await so pump delivers thinking/tools.
17291738
const unsubscribe = forwardAgentStreamToExecutionEvents(streamingExec, {
17301739
blockId,
17311740
executionId,
17321741
workflowId,
17331742
sendEvent,
1743+
forwardAnswerText: answerTextFromSink,
17341744
})
17351745

17361746
const reader = streamingExec.stream.getReader()
@@ -1749,6 +1759,8 @@ async function handleExecutePost(
17491759
if (timeoutController.signal.aborted || isStreamClosed) break
17501760
if (done) break
17511761

1762+
if (answerTextFromSink) continue
1763+
17521764
const chunk = decoder.decode(value, { stream: true })
17531765
await sendEvent({
17541766
type: 'stream:chunk',

apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/chat/chat.tsx

Lines changed: 50 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@ export function Chat() {
238238
selectedWorkflowOutputs,
239239
setSelectedWorkflowOutput,
240240
appendMessageContent,
241+
setMessageContent,
241242
finalizeMessageStream,
242243
getConversationId,
243244
clearChat,
@@ -256,6 +257,7 @@ export function Chat() {
256257
selectedWorkflowOutputs: s.selectedWorkflowOutputs,
257258
setSelectedWorkflowOutput: s.setSelectedWorkflowOutput,
258259
appendMessageContent: s.appendMessageContent,
260+
setMessageContent: s.setMessageContent,
259261
finalizeMessageStream: s.finalizeMessageStream,
260262
getConversationId: s.getConversationId,
261263
clearChat: s.clearChat,
@@ -495,10 +497,21 @@ export function Chat() {
495497
async (stream: ReadableStream<Uint8Array>, responseMessageId: string) => {
496498
const reader = stream.getReader()
497499
streamReaderRef.current = reader
500+
501+
/**
502+
* Answer text tracked per block so a `chunk_reset` frame (a live-streamed
503+
* turn resolved to tool calls) can drop one block's contribution. Each
504+
* flush replaces the message content with the joined segments.
505+
*/
506+
const blockOrder: string[] = []
507+
const blockSegments = new Map<string, string>()
498508
let accumulatedContent = ''
509+
const recomputeContent = () => {
510+
accumulatedContent = blockOrder.map((id) => blockSegments.get(id) ?? '').join('')
511+
}
499512

500513
const BATCH_MAX_MS = 50
501-
let pendingChunks = ''
514+
let contentDirty = false
502515
let batchRAF: number | null = null
503516
let batchTimer: ReturnType<typeof setTimeout> | null = null
504517
let lastFlush = 0
@@ -512,9 +525,9 @@ export function Chat() {
512525
clearTimeout(batchTimer)
513526
batchTimer = null
514527
}
515-
if (pendingChunks) {
516-
appendMessageContent(responseMessageId, pendingChunks)
517-
pendingChunks = ''
528+
if (contentDirty) {
529+
setMessageContent(responseMessageId, accumulatedContent)
530+
contentDirty = false
518531
}
519532
lastFlush = performance.now()
520533
}
@@ -534,12 +547,17 @@ export function Chat() {
534547

535548
let finalError: string | null = null
536549
try {
537-
await readSSEEvents<{ event?: string; data?: ExecutionResult; chunk?: string }>(reader, {
550+
await readSSEEvents<{
551+
event?: string
552+
data?: ExecutionResult
553+
chunk?: string
554+
blockId?: string
555+
}>(reader, {
538556
onParseError: (_data, e) => {
539557
logger.error('Error parsing stream data:', e)
540558
},
541559
onEvent: (json) => {
542-
const { event, data: eventData, chunk: contentChunk } = json
560+
const { event, data: eventData, chunk: contentChunk, blockId } = json
543561

544562
if (event === 'final' && eventData) {
545563
if ('success' in eventData && !eventData.success) {
@@ -548,9 +566,25 @@ export function Chat() {
548566
return true
549567
}
550568

569+
if (event === 'chunk_reset' && blockId) {
570+
if (blockSegments.has(blockId)) {
571+
blockSegments.set(blockId, '')
572+
recomputeContent()
573+
contentDirty = true
574+
scheduleFlush()
575+
}
576+
return
577+
}
578+
551579
if (contentChunk) {
552-
accumulatedContent += contentChunk
553-
pendingChunks += contentChunk
580+
const segmentKey = blockId ?? ''
581+
if (!blockSegments.has(segmentKey)) {
582+
blockOrder.push(segmentKey)
583+
blockSegments.set(segmentKey, '')
584+
}
585+
blockSegments.set(segmentKey, blockSegments.get(segmentKey)! + contentChunk)
586+
recomputeContent()
587+
contentDirty = true
554588
scheduleFlush()
555589
}
556590
},
@@ -578,7 +612,14 @@ export function Chat() {
578612
focusInput(100)
579613
}
580614
},
581-
[appendMessageContent, finalizeMessageStream, focusInput, selectedOutputs, activeWorkflowId]
615+
[
616+
appendMessageContent,
617+
setMessageContent,
618+
finalizeMessageStream,
619+
focusInput,
620+
selectedOutputs,
621+
activeWorkflowId,
622+
]
582623
)
583624

584625
/**

0 commit comments

Comments
 (0)