Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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"]
}
8 changes: 8 additions & 0 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 @@ -244,7 +248,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 +288,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 @@ -326,6 +333,7 @@ export default function ChatClient({ identifier }: { identifier: string }) {
},
audioStreamHandler: audioHandler,
outputConfigs: chatConfig?.outputConfigs,
abortController,
}
)
} catch (error) {
Expand Down
167 changes: 162 additions & 5 deletions apps/sim/app/(interfaces)/chat/components/message/message.test.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,24 @@
/**
* @vitest-environment node
* @vitest-environment jsdom
*/
import { describe, expect, it, vi } from 'vitest'
import { act } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, describe, expect, it, vi } from 'vitest'

vi.mock('@sim/emcn', () => ({
Button: ({ children, ...props }: { children?: React.ReactNode; [key: string]: unknown }) => (
<button type='button' {...props}>
{children}
</button>
),
Duplicate: () => null,
Tooltip: {},
Tooltip: {
Provider: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
Root: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
Trigger: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
Content: ({ children }: { children?: React.ReactNode }) => <>{children}</>,
},
cn: (...args: unknown[]) => args.filter(Boolean).join(' '),
}))

vi.mock('@/app/(interfaces)/chat/components/message/components/file-download', () => ({
Expand All @@ -14,10 +27,14 @@ vi.mock('@/app/(interfaces)/chat/components/message/components/file-download', (
}))

vi.mock('@/app/(interfaces)/chat/components/message/components/markdown-renderer', () => ({
default: () => null,
default: ({ content }: { content: string }) => <div data-testid='answer'>{content}</div>,
}))

import { escapeHtml } from '@/app/(interfaces)/chat/components/message/message'
import {
type ChatMessage,
ClientChatMessage,
escapeHtml,
} from '@/app/(interfaces)/chat/components/message/message'

describe('escapeHtml', () => {
it('escapes all five HTML-significant characters', () => {
Expand All @@ -41,3 +58,143 @@ describe('escapeHtml', () => {
expect(escapeHtml('')).toBe('')
})
})

function renderMessage(message: ChatMessage): { container: HTMLDivElement; unmount: () => void } {
;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
const container = document.createElement('div')
document.body.appendChild(container)
const root: Root = createRoot(container)
act(() => {
root.render(<ClientChatMessage message={message} />)
})
return {
container,
unmount: () => {
act(() => {
root.unmount()
})
container.remove()
},
}
}

describe('ClientChatMessage thinking chrome (Step 6)', () => {
const mounts: Array<() => void> = []

afterEach(() => {
while (mounts.length) {
mounts.pop()?.()
}
})

it('does not show thinking chrome when thinking is absent or empty', () => {
const without = renderMessage({
id: '1',
type: 'assistant',
content: 'Hello',
timestamp: new Date(),
})
mounts.push(without.unmount)
expect(without.container.textContent).not.toContain('Thinking')

const empty = renderMessage({
id: '2',
type: 'assistant',
content: 'Hello',
thinking: '',
timestamp: new Date(),
})
mounts.push(empty.unmount)
expect(empty.container.textContent).not.toContain('Thinking')
})

it('shows collapsible thinking chrome above the answer after first thinking', () => {
const { container, unmount } = renderMessage({
id: '3',
type: 'assistant',
content: 'Answer text',
thinking: 'Internal reasoning',
isThinkingStreaming: true,
timestamp: new Date(),
})
mounts.push(unmount)

expect(container.textContent).toContain('Thinking…')
expect(container.textContent).toContain('Internal reasoning')
expect(container.textContent).toContain('Answer text')
})

it('labels completed thinking as Thought for a moment', () => {
const { container, unmount } = renderMessage({
id: '4',
type: 'assistant',
content: 'Answer text',
thinking: 'Internal reasoning',
isThinkingStreaming: false,
timestamp: new Date(),
})
mounts.push(unmount)

expect(container.textContent).toContain('Thought for a moment')
expect(container.textContent).toContain('Answer text')
})
})

describe('ClientChatMessage tool chrome (Step 8)', () => {
const mounts: Array<() => void> = []

afterEach(() => {
while (mounts.length) {
mounts.pop()?.()
}
})

it('does not show tool chrome when toolCalls are absent or empty', () => {
const without = renderMessage({
id: '1',
type: 'assistant',
content: 'Hello',
timestamp: new Date(),
})
mounts.push(without.unmount)
expect(without.container.textContent).not.toContain('Tools')
expect(without.container.textContent).not.toContain('Using tools')

const empty = renderMessage({
id: '2',
type: 'assistant',
content: 'Hello',
toolCalls: [],
timestamp: new Date(),
})
mounts.push(empty.unmount)
expect(empty.container.textContent).not.toContain('Tools')
})

it('shows humanized tool names only (no args) while tools are running', () => {
const { container, unmount } = renderMessage({
id: '3',
type: 'assistant',
content: 'Answer',
isToolStreaming: true,
toolCalls: [
{
key: 'agent-1:toolu_1',
blockId: 'agent-1',
id: 'toolu_1',
name: 'http_request',
displayName: 'Http Request',
status: 'running',
},
],
timestamp: new Date(),
})
mounts.push(unmount)

expect(container.textContent).toContain('Using tools…')
expect(container.textContent).toContain('Http Request')
expect(container.textContent).not.toContain('toolu_1')
expect(container.textContent).not.toContain('args')
expect(container.textContent).toContain('Answer')
})
})
Loading
Loading