Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
17 commits
Select commit Hold shift + click to select a range
a5ff6b2
feat(agent-stream): add agent-events thinking/tool streaming for chat…
Jul 14, 2026
a241ec1
fix(agent-stream): clear stuck streaming UI and format db snapshot
Jul 14, 2026
6f58d91
fix(agent-stream): satisfy biome format and import order
Jul 14, 2026
3e139eb
fix(agent-stream): keep drained answer on abort and update migration …
Jul 14, 2026
3b9e24d
fix(chat): keep Stop notice when server emits cancel error
Jul 14, 2026
6966850
improvement(chat): ChatGPT-style thinking shimmer and stick-to-bottom…
Jul 14, 2026
eac2a22
fix(agent-stream): stop pump on client disconnect; soft-complete agen…
Jul 14, 2026
2533f7e
fix(agent-stream): persist includeThinking across pause snapshots
Jul 14, 2026
8618407
fix(agent-stream): keep drained answer text when stream times out
Jul 14, 2026
db4e26e
improvement(chat): auto-collapse tools chrome when tool streaming ends
Jul 14, 2026
2664d92
fix(agent-stream): settle canvas stream chrome on failure paths
Jul 14, 2026
ce3737b
fix(lint): organize imports in terminal console store
Jul 14, 2026
dff275b
fix(agent-stream): mark open tools cancelled on HITL pause
Jul 14, 2026
8a5e6ca
chore(db): drop branch-local 0261 migration ahead of staging merge
icecrasher321 Jul 23, 2026
29fc6cf
Merge remote-tracking branch 'origin/staging' into agent-stream-events
icecrasher321 Jul 23, 2026
de3b056
chore(db): regenerate include_thinking migration as 0266 post staging…
icecrasher321 Jul 23, 2026
82e5206
fix(providers): resolve type errors in streaming tool loop call sites
icecrasher321 Jul 23, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
90 changes: 90 additions & 0 deletions apps/docs/content/docs/en/workflows/deployment/agent-events.mdx
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
---
title: Agent stream events
description: Protocol for streaming thinking and tool lifecycle from Agent blocks
---

import { Callout } from 'fumadocs-ui/components/callout'
import { Tab, Tabs } from 'fumadocs-ui/components/tabs'

Agent blocks can emit more than answer text while they run: **provider-exposed thinking** (or reasoning summaries) and a **tool-call lifecycle** (name + status). Sim delivers those as typed events on an opt-in stream protocol.

<Callout type="info">
Sim does **not** invent thinking for providers that do not stream it. Bedrock Converse, many OpenAI-compat models, and non-reasoning chat models stay text-only (plus tools when a live tool loop is wired).
</Callout>

## Dual gate (public chat / simple SSE)

Thinking and tool frames leave the public chat or workflow simple-SSE path only when **both** are true:

1. Deployment policy: chat `includeThinking` is enabled (default **off**).
2. Request opts in with header:

```http
X-Sim-Stream-Protocol: agent-events-v1
```

Legacy clients that omit the header keep today’s text-only SSE even if the deployment has thinking enabled. The hosted chat UI always sends the header for its own deployments.

## Simple SSE frame shapes

Answer text stays on `chunk`. Thinking and tools never reuse `chunk` (so older clients that append every `chunk` into the answer cannot leak thinking).

| Frame | Meaning |
|-------|---------|
| `{ "blockId", "chunk": "…" }` | Final-turn answer text only |
| `{ "blockId", "event": "thinking", "data": "…" }` | Thinking / reasoning summary delta |
| `{ "blockId", "event": "tool", "phase": "start"\|"end", "id", "name", "status?" }` | Tool lifecycle (no args / results) |
| `{ "event": "final", … }` | Terminal success envelope |
| `{ "event": "stream_error", … }` | Terminal failure (may omit `final`) |
| `data: [DONE]` | Stream closed (documented completion marker) |

### Intermediate-turn text

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`.

### Abort

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

### Reconnect

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.

## Canvas (draft Run)

When you click **Run** in the builder, the execution-events SSE path forwards the same sink:

- `stream:thinking` — `{ blockId, data }`
- `stream:tool` — `{ blockId, phase, id, name, status? }`
- `stream:chunk` — answer text (unchanged)

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).

## Chat deployment toggle

In **Deploy → Chat**, enable **Include thinking** so public chat can expose thinking/tool frames (still requires the protocol header). Redeploy / update the chat after changing Agent models or tools.

## Capability honesty (high level)

| Family | Thinking | Live tools |
|--------|----------|------------|
| Anthropic / Azure Anthropic | Yes (incl. redacted blocks in traces) | Yes |
| Gemini / Vertex | Yes when `includeThoughts` requested | Yes |
| OpenAI Responses | Reasoning **summaries** when streamed | Silent tool loop today (answer streams; chips may be absent) |
| OpenAI-compat (Groq, DeepSeek, …) | Only if vendor streams `reasoning` / `reasoning_content` | Live loop where wired (e.g. Groq, DeepSeek) |
| Bedrock | Not invented | Yes when streaming tool loop is used |

## Example (public chat)

<Tabs items={['cURL']}>
<Tab value="cURL">
```bash
curl -N -X POST 'http://localhost:3000/api/chat/your-slug' \
-H 'Content-Type: application/json' \
-H 'X-Sim-Stream-Protocol: agent-events-v1' \
-d '{"input":"Think briefly, then say hi"}'
```
</Tab>
</Tabs>

See also [Chat deployment](/docs/workflows/deployment/chat) for access control and the Include thinking setting.
1 change: 1 addition & 0 deletions apps/docs/content/docs/en/workflows/deployment/chat.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ Configure the following fields, then click **Launch Chat**:
| **Output** | Output fields from your workflow blocks returned as the chat response. At least one must be selected. |
| **Welcome Message** | Greeting shown before the user sends their first message. Defaults to `"Hi there! How can I help you today?"`. |
| **Access Control** | Controls who can access the chat. See [Access Control](#access-control) below. |
| **Include thinking** | When enabled, the hosted chat can show provider-exposed thinking and tool lifecycle (name + status). Requires the chat client to send `X-Sim-Stream-Protocol: agent-events-v1` (the hosted UI always does). Default is off. See [Agent stream events](/docs/workflows/deployment/agent-events). |

### Output Selection

Expand Down
2 changes: 1 addition & 1 deletion apps/docs/content/docs/en/workflows/deployment/meta.json
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
{
"title": "Deployment",
"pages": ["index", "api", "chat", "mcp"]
"pages": ["index", "api", "chat", "agent-events", "mcp"]
}
72 changes: 43 additions & 29 deletions apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,10 @@ import { type RefObject, useCallback, useEffect, useMemo, useRef, useState } fro
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { noop } from '@/lib/core/utils/request'
import {
AGENT_STREAM_PROTOCOL_HEADER,
AGENT_STREAM_PROTOCOL_V1,
} from '@/lib/workflows/streaming/agent-stream-protocol'
import {
ChatErrorState,
ChatHeader,
Expand Down Expand Up @@ -95,8 +99,9 @@ export default function ChatClient({ identifier }: { identifier: string }) {
const [conversationId] = useState(() => generateId())

const [showScrollButton, setShowScrollButton] = useState(false)
const [userHasScrolled, setUserHasScrolled] = useState(false)
const isUserScrollingRef = useRef(false)
/** ChatGPT-style: follow new tokens only while the viewport is near the bottom. */
const stickToBottomRef = useRef(true)
const ignoreScrollRef = useRef(false)

const [isVoiceFirstMode, setIsVoiceFirstMode] = useState(false)

Expand Down Expand Up @@ -131,10 +136,31 @@ export default function ChatClient({ identifier }: { identifier: string }) {
const audioContextRef = useRef<AudioContext | null>(null)
const { isPlayingAudio, streamTextToAudio, stopAudio } = useAudioStreaming(audioContextRef)

const scrollToBottom = useCallback(() => {
if (messagesEndRef.current) {
messagesEndRef.current.scrollIntoView({ behavior: 'smooth' })
const NEAR_BOTTOM_THRESHOLD_PX = 100

/**
* ChatGPT-style scroll. Without `force`, no-ops when the user has scrolled away.
* With `force` (jump button), re-pins to bottom.
*/
const scrollToBottom = useCallback((options?: { behavior?: ScrollBehavior; force?: boolean }) => {
const behavior = options?.behavior ?? 'smooth'
const force = options?.force === true
if (!force && !stickToBottomRef.current) return
if (!messagesEndRef.current) return

if (force) {
stickToBottomRef.current = true
setShowScrollButton(false)
}

ignoreScrollRef.current = true
messagesEndRef.current.scrollIntoView({ behavior })
window.setTimeout(
() => {
ignoreScrollRef.current = false
},
behavior === 'smooth' ? 400 : 50
)
}, [])

const scrollToMessage = useCallback(
Expand Down Expand Up @@ -165,39 +191,23 @@ export default function ChatClient({ identifier }: { identifier: string }) {
[messagesContainerRef]
)

const isStreamingResponseRef = useRef(isStreamingResponse)
isStreamingResponseRef.current = isStreamingResponse

useEffect(() => {
const container = messagesContainerRef.current
if (!container) return

const handleScroll = () => {
if (ignoreScrollRef.current) return
const { scrollTop, scrollHeight, clientHeight } = container
const distanceFromBottom = scrollHeight - scrollTop - clientHeight
setShowScrollButton(distanceFromBottom > 100)

if (isStreamingResponseRef.current && !isUserScrollingRef.current) {
setUserHasScrolled(true)
}
const nearBottom = distanceFromBottom <= NEAR_BOTTOM_THRESHOLD_PX
stickToBottomRef.current = nearBottom
setShowScrollButton(!nearBottom)
}

container.addEventListener('scroll', handleScroll, { passive: true })
return () => container.removeEventListener('scroll', handleScroll)
}, [chatConfig, isVoiceFirstMode, authRequired])

useEffect(() => {
if (isStreamingResponse) {
setUserHasScrolled(false)

isUserScrollingRef.current = true
const timeoutId = setTimeout(() => {
isUserScrollingRef.current = false
}, 1000)
return () => clearTimeout(timeoutId)
}
}, [isStreamingResponse])

const handleSendMessage = async (
messageParam?: string,
isVoiceInput = false,
Expand All @@ -220,7 +230,8 @@ export default function ChatClient({ identifier }: { identifier: string }) {
filesCount: files?.length,
})

setUserHasScrolled(false)
stickToBottomRef.current = true
setShowScrollButton(false)

const userMessage: ChatMessage = {
id: generateId(),
Expand All @@ -244,7 +255,9 @@ export default function ChatClient({ identifier }: { identifier: string }) {
scrollToMessage(userMessage.id, true)
}, 100)

// One AbortController for fetch + SSE body reads so Stop cancels server work too.
const abortController = new AbortController()
abortControllerRef.current = abortController
const timeoutId = setTimeout(() => {
abortController.abort()
}, CHAT_REQUEST_TIMEOUT_MS)
Expand Down Expand Up @@ -282,6 +295,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
headers: {
'Content-Type': 'application/json',
'X-Requested-With': 'XMLHttpRequest',
[AGENT_STREAM_PROTOCOL_HEADER]: AGENT_STREAM_PROTOCOL_V1,
},
body: JSON.stringify(payload),
credentials: 'same-origin',
Expand Down Expand Up @@ -316,8 +330,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
response,
setMessages,
setIsLoading,
scrollToBottom,
userHasScrolled,
() => scrollToBottom({ behavior: 'auto' }),
{
voiceSettings: {
isVoiceEnabled: shouldPlayAudio,
Expand All @@ -326,6 +339,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
},
audioStreamHandler: audioHandler,
outputConfigs: chatConfig?.outputConfigs,
abortController,
}
)
} catch (error) {
Expand Down Expand Up @@ -437,7 +451,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
showScrollButton={showScrollButton}
messagesContainerRef={messagesContainerRef as RefObject<HTMLDivElement>}
messagesEndRef={messagesEndRef as RefObject<HTMLDivElement>}
scrollToBottom={scrollToBottom}
scrollToBottom={() => scrollToBottom({ behavior: 'smooth', force: true })}
scrollToMessage={scrollToMessage}
chatConfig={chatConfig}
/>
Expand Down
Loading
Loading