From a5ff6b20ae3f5c04768bc28a9830808cfeaa7344 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Mon, 13 Jul 2026 20:32:07 -0700 Subject: [PATCH 01/13] feat(agent-stream): add agent-events thinking/tool streaming for chat and canvas Ship the agent-events-v1 protocol with provider tool loops, dual-gated chat thinking, DeepSeek/Groq/OpenAI reasoning wiring, and ChatGPT-like thinking chrome. Co-authored-by: Cursor --- .../en/workflows/deployment/agent-events.mdx | 90 + .../docs/en/workflows/deployment/chat.mdx | 1 + .../docs/en/workflows/deployment/meta.json | 2 +- .../(interfaces)/chat/[identifier]/chat.tsx | 8 + .../chat/components/message/message.test.tsx | 167 +- .../chat/components/message/message.tsx | 53 +- .../chat/hooks/use-chat-streaming.test.tsx | 530 + .../chat/hooks/use-chat-streaming.ts | 287 +- .../app/api/chat/[identifier]/otp/route.ts | 2 + .../app/api/chat/[identifier]/route.test.ts | 6 + apps/sim/app/api/chat/[identifier]/route.ts | 21 +- apps/sim/app/api/chat/manage/[id]/route.ts | 6 + apps/sim/app/api/chat/route.ts | 2 + apps/sim/app/api/providers/route.ts | 6 +- .../[executionId]/[contextId]/route.ts | 3 + .../workflows/[id]/chat/status/route.test.ts | 2 + .../api/workflows/[id]/chat/status/route.ts | 2 + .../app/api/workflows/[id]/execute/route.ts | 53 +- .../deploy-modal/components/chat/chat.tsx | 20 + .../components/output-panel/output-panel.tsx | 29 + .../hooks/use-workflow-execution.ts | 92 + .../agent-stream/agent-stream-chrome.test.tsx | 145 + .../agent-stream/agent-stream-chrome.tsx | 203 + .../executor/execution/block-executor.test.ts | 289 + apps/sim/executor/execution/block-executor.ts | 243 +- apps/sim/executor/execution/types.ts | 5 + .../handlers/agent/agent-handler.test.ts | 34 + .../executor/handlers/agent/agent-handler.ts | 17 +- apps/sim/executor/types.ts | 22 + apps/sim/hooks/queries/chats.ts | 3 + apps/sim/hooks/use-execution-stream.test.ts | 55 + apps/sim/hooks/use-execution-stream.ts | 10 + .../contracts/chats.include-thinking.test.ts | 76 + apps/sim/lib/api/contracts/chats.ts | 11 +- apps/sim/lib/api/contracts/deployments.ts | 1 + .../tools/handlers/deployment/deploy.ts | 7 + .../tools/handlers/deployment/manage.ts | 2 + .../lib/copilot/tools/handlers/param-types.ts | 1 + .../lib/core/execution-limits/types.test.ts | 23 + apps/sim/lib/core/execution-limits/types.ts | 6 +- .../workflows/executor/execute-workflow.ts | 3 + .../workflows/executor/execution-events.ts | 38 + .../executor/human-in-the-loop-manager.ts | 39 +- .../workflows/orchestration/chat-deploy.ts | 5 + .../streaming/agent-stream-protocol.test.ts | 83 + .../streaming/agent-stream-protocol.ts | 60 + .../attach-agent-stream-sink.test.ts | 63 + .../streaming/attach-agent-stream-sink.ts | 47 + .../lib/workflows/streaming/streaming.test.ts | 370 + apps/sim/lib/workflows/streaming/streaming.ts | 152 +- .../__fixtures__/anthropic/fixtures.test.ts | 188 + .../providers/__fixtures__/anthropic/index.ts | 16 + .../anthropic/redacted-thinking-signature.ts | 99 + .../anthropic/thinking-text-tool.ts | 119 + .../__fixtures__/openai-compat/index.ts | 60 + apps/sim/providers/anthropic/core.ts | 89 +- .../anthropic/streaming-tool-loop.test.ts | 335 + .../anthropic/streaming-tool-loop.ts | 563 + apps/sim/providers/anthropic/utils.test.ts | 106 + apps/sim/providers/anthropic/utils.ts | 94 +- apps/sim/providers/azure-openai/index.ts | 2 + apps/sim/providers/azure-openai/utils.ts | 19 +- apps/sim/providers/baseten/index.ts | 2 + apps/sim/providers/baseten/utils.ts | 19 +- apps/sim/providers/bedrock/index.ts | 59 +- .../bedrock/streaming-tool-loop.test.ts | 141 + .../providers/bedrock/streaming-tool-loop.ts | 541 + .../providers/bedrock/utils.stream.test.ts | 51 + apps/sim/providers/bedrock/utils.ts | 20 +- apps/sim/providers/cerebras/index.ts | 2 + apps/sim/providers/cerebras/utils.ts | 18 +- apps/sim/providers/deepseek/index.test.ts | 99 + apps/sim/providers/deepseek/index.ts | 179 +- apps/sim/providers/deepseek/utils.ts | 18 +- apps/sim/providers/fireworks/index.ts | 2 + apps/sim/providers/fireworks/utils.ts | 19 +- apps/sim/providers/gemini/core.ts | 87 +- .../gemini/streaming-tool-loop.test.ts | 166 + .../providers/gemini/streaming-tool-loop.ts | 542 + .../sim/providers/google/utils.stream.test.ts | 74 + apps/sim/providers/google/utils.ts | 30 +- apps/sim/providers/groq/index.test.ts | 121 + apps/sim/providers/groq/index.ts | 177 +- apps/sim/providers/groq/utils.ts | 18 +- apps/sim/providers/litellm/index.ts | 2 + apps/sim/providers/litellm/utils.ts | 18 +- apps/sim/providers/meta/index.ts | 2 + apps/sim/providers/meta/utils.ts | 18 +- apps/sim/providers/mistral/index.ts | 2 + apps/sim/providers/mistral/utils.ts | 18 +- apps/sim/providers/models.ts | 46 +- apps/sim/providers/nvidia/index.ts | 2 + apps/sim/providers/nvidia/utils.ts | 18 +- apps/sim/providers/ollama-cloud/utils.ts | 18 +- apps/sim/providers/ollama/core.ts | 7 +- apps/sim/providers/ollama/utils.ts | 18 +- .../openai-compat/stream-events.test.ts | 110 + .../providers/openai-compat/stream-events.ts | 196 + .../openai-compat/streaming-tool-loop.test.ts | 297 + .../openai-compat/streaming-tool-loop.ts | 451 + .../providers/openai/core.reasoning.test.ts | 107 + apps/sim/providers/openai/core.ts | 36 +- .../sim/providers/openai/utils.stream.test.ts | 75 + apps/sim/providers/openai/utils.ts | 42 +- apps/sim/providers/openrouter/index.ts | 2 + apps/sim/providers/openrouter/utils.ts | 15 +- apps/sim/providers/sakana/index.ts | 2 + apps/sim/providers/sakana/utils.ts | 18 +- apps/sim/providers/step-10-smokes.test.ts | 67 + apps/sim/providers/stream-events.test.ts | 110 + apps/sim/providers/stream-events.ts | 142 + apps/sim/providers/stream-pump.test.ts | 494 + apps/sim/providers/stream-pump.ts | 424 + .../sim/providers/streaming-execution.test.ts | 39 + apps/sim/providers/streaming-execution.ts | 13 +- apps/sim/providers/together/index.ts | 2 + apps/sim/providers/together/utils.ts | 19 +- apps/sim/providers/tool-call-id.ts | 27 + apps/sim/providers/utils.test.ts | 11 + apps/sim/providers/vllm/index.ts | 2 + apps/sim/providers/vllm/utils.ts | 19 +- apps/sim/providers/xai/index.ts | 2 + apps/sim/providers/xai/utils.ts | 19 +- apps/sim/providers/zai/index.ts | 2 + apps/sim/providers/zai/utils.ts | 18 +- apps/sim/stores/terminal/console/store.ts | 12 + apps/sim/stores/terminal/console/types.ts | 10 + .../migrations/0261_chat_include_thinking.sql | 1 + .../db/migrations/meta/0261_snapshot.json | 17701 ++++++++++++++++ packages/db/migrations/meta/_journal.json | 7 + packages/db/schema.ts | 7 + packages/testing/src/mocks/schema.mock.ts | 1 + 132 files changed, 27747 insertions(+), 392 deletions(-) create mode 100644 apps/docs/content/docs/en/workflows/deployment/agent-events.mdx create mode 100644 apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.test.tsx create mode 100644 apps/sim/components/agent-stream/agent-stream-chrome.test.tsx create mode 100644 apps/sim/components/agent-stream/agent-stream-chrome.tsx create mode 100644 apps/sim/lib/api/contracts/chats.include-thinking.test.ts create mode 100644 apps/sim/lib/workflows/streaming/agent-stream-protocol.test.ts create mode 100644 apps/sim/lib/workflows/streaming/agent-stream-protocol.ts create mode 100644 apps/sim/lib/workflows/streaming/attach-agent-stream-sink.test.ts create mode 100644 apps/sim/lib/workflows/streaming/attach-agent-stream-sink.ts create mode 100644 apps/sim/providers/__fixtures__/anthropic/fixtures.test.ts create mode 100644 apps/sim/providers/__fixtures__/anthropic/index.ts create mode 100644 apps/sim/providers/__fixtures__/anthropic/redacted-thinking-signature.ts create mode 100644 apps/sim/providers/__fixtures__/anthropic/thinking-text-tool.ts create mode 100644 apps/sim/providers/__fixtures__/openai-compat/index.ts create mode 100644 apps/sim/providers/anthropic/streaming-tool-loop.test.ts create mode 100644 apps/sim/providers/anthropic/streaming-tool-loop.ts create mode 100644 apps/sim/providers/anthropic/utils.test.ts create mode 100644 apps/sim/providers/bedrock/streaming-tool-loop.test.ts create mode 100644 apps/sim/providers/bedrock/streaming-tool-loop.ts create mode 100644 apps/sim/providers/bedrock/utils.stream.test.ts create mode 100644 apps/sim/providers/deepseek/index.test.ts create mode 100644 apps/sim/providers/gemini/streaming-tool-loop.test.ts create mode 100644 apps/sim/providers/gemini/streaming-tool-loop.ts create mode 100644 apps/sim/providers/google/utils.stream.test.ts create mode 100644 apps/sim/providers/groq/index.test.ts create mode 100644 apps/sim/providers/openai-compat/stream-events.test.ts create mode 100644 apps/sim/providers/openai-compat/stream-events.ts create mode 100644 apps/sim/providers/openai-compat/streaming-tool-loop.test.ts create mode 100644 apps/sim/providers/openai-compat/streaming-tool-loop.ts create mode 100644 apps/sim/providers/openai/core.reasoning.test.ts create mode 100644 apps/sim/providers/openai/utils.stream.test.ts create mode 100644 apps/sim/providers/step-10-smokes.test.ts create mode 100644 apps/sim/providers/stream-events.test.ts create mode 100644 apps/sim/providers/stream-events.ts create mode 100644 apps/sim/providers/stream-pump.test.ts create mode 100644 apps/sim/providers/stream-pump.ts create mode 100644 apps/sim/providers/tool-call-id.ts create mode 100644 packages/db/migrations/0261_chat_include_thinking.sql create mode 100644 packages/db/migrations/meta/0261_snapshot.json diff --git a/apps/docs/content/docs/en/workflows/deployment/agent-events.mdx b/apps/docs/content/docs/en/workflows/deployment/agent-events.mdx new file mode 100644 index 00000000000..17ec06c0c66 --- /dev/null +++ b/apps/docs/content/docs/en/workflows/deployment/agent-events.mdx @@ -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. + + +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). + + +## 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) + + + +```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"}' +``` + + + +See also [Chat deployment](/docs/workflows/deployment/chat) for access control and the Include thinking setting. diff --git a/apps/docs/content/docs/en/workflows/deployment/chat.mdx b/apps/docs/content/docs/en/workflows/deployment/chat.mdx index e08a32cd01e..ffe2ac2c661 100644 --- a/apps/docs/content/docs/en/workflows/deployment/chat.mdx +++ b/apps/docs/content/docs/en/workflows/deployment/chat.mdx @@ -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 diff --git a/apps/docs/content/docs/en/workflows/deployment/meta.json b/apps/docs/content/docs/en/workflows/deployment/meta.json index 9d84bce6cff..037c70ba33a 100644 --- a/apps/docs/content/docs/en/workflows/deployment/meta.json +++ b/apps/docs/content/docs/en/workflows/deployment/meta.json @@ -1,4 +1,4 @@ { "title": "Deployment", - "pages": ["index", "api", "chat", "mcp"] + "pages": ["index", "api", "chat", "agent-events", "mcp"] } diff --git a/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx b/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx index 49f71d79272..80369fd8935 100644 --- a/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx +++ b/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx @@ -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, @@ -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) @@ -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', @@ -326,6 +333,7 @@ export default function ChatClient({ identifier }: { identifier: string }) { }, audioStreamHandler: audioHandler, outputConfigs: chatConfig?.outputConfigs, + abortController, } ) } catch (error) { diff --git a/apps/sim/app/(interfaces)/chat/components/message/message.test.tsx b/apps/sim/app/(interfaces)/chat/components/message/message.test.tsx index a48517a75f1..e0e48731e65 100644 --- a/apps/sim/app/(interfaces)/chat/components/message/message.test.tsx +++ b/apps/sim/app/(interfaces)/chat/components/message/message.test.tsx @@ -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 }) => ( + + ), 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', () => ({ @@ -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 }) =>
{content}
, })) -import { escapeHtml } from '@/app/(interfaces)/chat/components/message/message' +import { + ClientChatMessage, + escapeHtml, + type ChatMessage, +} from '@/app/(interfaces)/chat/components/message/message' describe('escapeHtml', () => { it('escapes all five HTML-significant characters', () => { @@ -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() + }) + 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') + }) +}) diff --git a/apps/sim/app/(interfaces)/chat/components/message/message.tsx b/apps/sim/app/(interfaces)/chat/components/message/message.tsx index f5ec5b623c8..951cfd4d3eb 100644 --- a/apps/sim/app/(interfaces)/chat/components/message/message.tsx +++ b/apps/sim/app/(interfaces)/chat/components/message/message.tsx @@ -3,6 +3,10 @@ import { memo, useState } from 'react' import { Button, cn, Duplicate, Tooltip } from '@sim/emcn' import { Check, File as FileIcon, FileText, Image as ImageIcon } from 'lucide-react' +import { + AgentStreamThinkingChrome, + AgentStreamToolCallsChrome, +} from '@/components/agent-stream/agent-stream-chrome' import { ChatFileDownload, ChatFileDownloadAll, @@ -27,6 +31,19 @@ export interface ChatFile { context?: string } +/** Lifecycle status for a tool chip (agent-events-v1). No args/results. */ +export type ChatToolCallStatus = 'running' | 'success' | 'error' | 'cancelled' + +export interface ChatToolCall { + /** Stable UI key: `${blockId}:${id}`. */ + key: string + blockId: string + id: string + name: string + displayName: string + status: ChatToolCallStatus +} + export interface ChatMessage { id: string content: string | Record @@ -34,6 +51,14 @@ export interface ChatMessage { timestamp: Date isInitialMessage?: boolean isStreaming?: boolean + /** Model thinking text (agent-events-v1). Chrome only when non-empty. */ + thinking?: string + /** True while thinking deltas are still arriving (before first answer chunk / final). */ + isThinkingStreaming?: boolean + /** Tool lifecycle chips (name + status only). Chrome only when non-empty. */ + toolCalls?: ChatToolCall[] + /** True while any tool chip is still `running`. */ + isToolStreaming?: boolean attachments?: ChatAttachment[] files?: ChatFile[] } @@ -81,15 +106,21 @@ function openAttachmentPreview(name: string, dataUrl: string): void { setTimeout(() => URL.revokeObjectURL(blobUrl), 60_000) } +function toolCallsFingerprint(toolCalls: ChatToolCall[] | undefined): string { + if (!toolCalls?.length) return '' + return toolCalls.map((t) => `${t.key}:${t.status}`).join('|') +} + export const ClientChatMessage = memo( function ClientChatMessage({ message }: { message: ChatMessage }) { const [isCopied, setIsCopied] = useState(false) const isJsonObject = typeof message.content === 'object' && message.content !== null - // Since tool calls are now handled via SSE events and stored in message.toolCalls, - // we can use the content directly without parsing + // Answer text is streamed separately from thinking / tool lifecycle events. const cleanTextContent = message.content + const hasThinking = typeof message.thinking === 'string' && message.thinking.length > 0 + const hasToolCalls = Array.isArray(message.toolCalls) && message.toolCalls.length > 0 const content = message.type === 'user' ? ( @@ -207,8 +238,19 @@ export const ClientChatMessage = memo(
- {/* Direct content rendering - tool calls are now handled via SSE events */}
+ {hasThinking && ( + + )} + {hasToolCalls && ( + + )}
{isJsonObject ? (
@@ -274,7 +316,12 @@ export const ClientChatMessage = memo(
     return (
       prevProps.message.id === nextProps.message.id &&
       prevProps.message.content === nextProps.message.content &&
+      prevProps.message.thinking === nextProps.message.thinking &&
       prevProps.message.isStreaming === nextProps.message.isStreaming &&
+      prevProps.message.isThinkingStreaming === nextProps.message.isThinkingStreaming &&
+      prevProps.message.isToolStreaming === nextProps.message.isToolStreaming &&
+      toolCallsFingerprint(prevProps.message.toolCalls) ===
+        toolCallsFingerprint(nextProps.message.toolCalls) &&
       prevProps.message.isInitialMessage === nextProps.message.isInitialMessage &&
       prevProps.message.files?.length === nextProps.message.files?.length
     )
diff --git a/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.test.tsx b/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.test.tsx
new file mode 100644
index 00000000000..997ed22b654
--- /dev/null
+++ b/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.test.tsx
@@ -0,0 +1,530 @@
+/**
+ * @vitest-environment jsdom
+ */
+import { act } from 'react'
+import { createRoot, type Root } from 'react-dom/client'
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
+
+const { mockReadSSEEvents } = vi.hoisted(() => ({
+  mockReadSSEEvents: vi.fn(),
+}))
+
+vi.mock('@/lib/core/utils/sse', () => ({
+  readSSEEvents: mockReadSSEEvents,
+}))
+
+vi.mock('@sim/utils/id', () => ({
+  generateId: () => 'msg-assistant-1',
+}))
+
+import type { ChatMessage } from '@/app/(interfaces)/chat/components/message/message'
+import {
+  isAnswerChunkFrame,
+  useChatStreaming,
+} from '@/app/(interfaces)/chat/hooks/use-chat-streaming'
+
+describe('isAnswerChunkFrame', () => {
+  it('accepts plain answer chunks without an event type', () => {
+    expect(isAnswerChunkFrame({ blockId: 'a1', chunk: 'hello' })).toBe(true)
+  })
+
+  it('rejects thinking / stream_error / tool frames even if chunk is present', () => {
+    expect(
+      isAnswerChunkFrame({ blockId: 'a1', chunk: 'leak', event: 'thinking', data: 'thought' })
+    ).toBe(false)
+    expect(isAnswerChunkFrame({ blockId: 'a1', chunk: 'x', event: 'stream_error' })).toBe(false)
+    expect(isAnswerChunkFrame({ blockId: 'a1', chunk: 'x', event: 'tool' })).toBe(false)
+    expect(isAnswerChunkFrame({ blockId: 'a1', chunk: 'x', event: 'tool_call_start' })).toBe(false)
+    expect(isAnswerChunkFrame({ blockId: 'a1', chunk: 'x', event: 'final' })).toBe(false)
+  })
+
+  it('rejects frames missing blockId or empty chunk', () => {
+    expect(isAnswerChunkFrame({ chunk: 'hello' })).toBe(false)
+    expect(isAnswerChunkFrame({ blockId: 'a1', chunk: '' })).toBe(false)
+  })
+})
+
+interface HookHandle {
+  latest: () => ReturnType
+  unmount: () => void
+}
+
+function renderStreamingHook(): HookHandle {
+  ;(globalThis as { IS_REACT_ACT_ENVIRONMENT?: boolean }).IS_REACT_ACT_ENVIRONMENT = true
+  const container = document.createElement('div')
+  const root: Root = createRoot(container)
+  let latest!: ReturnType
+
+  function Probe() {
+    latest = useChatStreaming()
+    return null
+  }
+
+  act(() => {
+    root.render()
+  })
+
+  return {
+    latest: () => latest,
+    unmount: () => {
+      act(() => {
+        root.unmount()
+      })
+    },
+  }
+}
+
+function makeSseResponse(): Response {
+  return {
+    body: new ReadableStream(),
+  } as Response
+}
+
+async function flushUiBatch() {
+  await act(async () => {
+    await new Promise((resolve) => {
+      requestAnimationFrame(() => resolve())
+    })
+    await new Promise((resolve) => {
+      setTimeout(resolve, 60)
+    })
+  })
+}
+
+describe('useChatStreaming thinking + abort (Step 6)', () => {
+  let handle: HookHandle
+  let messages: ChatMessage[]
+  let setMessages: React.Dispatch>
+
+  beforeEach(() => {
+    vi.clearAllMocks()
+    messages = []
+    setMessages = ((updater: React.SetStateAction) => {
+      messages = typeof updater === 'function' ? updater(messages) : updater
+    }) as React.Dispatch>
+    handle = renderStreamingHook()
+
+    // Run rAF immediately so UI batching is deterministic in tests.
+    vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
+      cb(performance.now())
+      return 1
+    })
+  })
+
+  afterEach(() => {
+    handle.unmount()
+    vi.restoreAllMocks()
+  })
+
+  it('routes thinking to message.thinking and answer chunks to content only', async () => {
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'thinking',
+        data: 'Let me reason. ',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'thinking',
+        data: 'More thought.',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        chunk: 'Final answer.',
+      })
+      await options.onEvent({
+        event: 'final',
+        data: { success: true, output: {} },
+      })
+    })
+
+    await act(async () => {
+      await handle.latest().handleStreamedResponse(
+        makeSseResponse(),
+        setMessages,
+        vi.fn(),
+        vi.fn(),
+        false
+      )
+    })
+    await flushUiBatch()
+
+    const assistant = messages.find((m) => m.id === 'msg-assistant-1')
+    expect(assistant?.thinking).toBe('Let me reason. More thought.')
+    expect(assistant?.content).toBe('Final answer.')
+    expect(assistant?.isStreaming).toBe(false)
+    expect(assistant?.isThinkingStreaming).toBe(false)
+  })
+
+  it('surfaces stream_error text in thinking chrome without terminating', async () => {
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'thinking',
+        data: 'Working…',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'stream_error',
+        error: 'partial provider glitch',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        chunk: 'Recovered answer.',
+      })
+      await options.onEvent({
+        event: 'final',
+        data: { success: true, output: {} },
+      })
+    })
+
+    await act(async () => {
+      await handle.latest().handleStreamedResponse(
+        makeSseResponse(),
+        setMessages,
+        vi.fn(),
+        vi.fn(),
+        false
+      )
+    })
+    await flushUiBatch()
+
+    const assistant = messages.find((m) => m.id === 'msg-assistant-1')
+    expect(assistant?.thinking).toContain('Working…')
+    expect(assistant?.thinking).toContain('[Stream error] partial provider glitch')
+    expect(assistant?.content).toBe('Recovered answer.')
+    expect(assistant?.isStreaming).toBe(false)
+  })
+
+  it('does not append thinking payload into answer when mislabeled as chunk', async () => {
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'thinking',
+        chunk: 'SHOULD_NOT_APPEND',
+        data: 'real thought',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        chunk: 'ok',
+      })
+      await options.onEvent({
+        event: 'final',
+        data: { success: true, output: {} },
+      })
+    })
+
+    await act(async () => {
+      await handle.latest().handleStreamedResponse(
+        makeSseResponse(),
+        setMessages,
+        vi.fn(),
+        vi.fn(),
+        false
+      )
+    })
+    await flushUiBatch()
+
+    const assistant = messages.find((m) => m.id === 'msg-assistant-1')
+    expect(assistant?.content).toBe('ok')
+    expect(assistant?.thinking).toBe('real thought')
+  })
+
+  it('TTS audioStreamHandler receives answer text only', async () => {
+    const audioStreamHandler = vi.fn().mockResolvedValue(undefined)
+
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'thinking',
+        data: 'secret internal monologue that must not be spoken.',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        chunk: 'Hello world.',
+      })
+      await options.onEvent({
+        event: 'final',
+        data: { success: true, output: {} },
+      })
+    })
+
+    await act(async () => {
+      await handle.latest().handleStreamedResponse(
+        makeSseResponse(),
+        setMessages,
+        vi.fn(),
+        vi.fn(),
+        false,
+        {
+          voiceSettings: {
+            isVoiceEnabled: true,
+            voiceId: 'voice-1',
+            autoPlayResponses: true,
+          },
+          audioStreamHandler,
+        }
+      )
+    })
+    await flushUiBatch()
+
+    expect(audioStreamHandler).toHaveBeenCalled()
+    for (const call of audioStreamHandler.mock.calls) {
+      expect(String(call[0])).not.toContain('secret')
+      expect(String(call[0])).not.toContain('monologue')
+    }
+    expect(audioStreamHandler.mock.calls.some((c) => String(c[0]).includes('Hello'))).toBe(true)
+  })
+
+  it('stopStreaming preserves thinking and aborts the shared controller', async () => {
+    const abortController = new AbortController()
+    let resolveStream!: () => void
+    const streamDone = new Promise((resolve) => {
+      resolveStream = resolve
+    })
+
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'thinking',
+        data: 'partial thought',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        chunk: 'partial answer',
+      })
+      // Hold the stream open until Stop aborts.
+      await new Promise((resolve) => {
+        options.signal?.addEventListener('abort', () => resolve(), { once: true })
+        // Also allow test cleanup if abort never fires.
+        streamDone.then(() => resolve())
+      })
+    })
+
+    const streamPromise = act(async () => {
+      await handle.latest().handleStreamedResponse(
+        makeSseResponse(),
+        setMessages,
+        vi.fn(),
+        vi.fn(),
+        false,
+        { abortController }
+      )
+    })
+
+    await flushUiBatch()
+    expect(messages.find((m) => m.id === 'msg-assistant-1')?.thinking).toBe('partial thought')
+
+    act(() => {
+      handle.latest().stopStreaming(setMessages)
+    })
+    resolveStream()
+    await streamPromise
+
+    const assistant = messages.find((m) => m.id === 'msg-assistant-1')
+    expect(abortController.signal.aborted).toBe(true)
+    expect(assistant?.thinking).toBe('partial thought')
+    expect(String(assistant?.content)).toContain('partial answer')
+    expect(String(assistant?.content)).toContain('Response stopped by user')
+    expect(assistant?.isStreaming).toBe(false)
+    expect(assistant?.isThinkingStreaming).toBe(false)
+  })
+
+  it('leaves thinking undefined when no thinking events arrive', async () => {
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      await options.onEvent({
+        blockId: 'agent-1',
+        chunk: 'just text',
+      })
+      await options.onEvent({
+        event: 'final',
+        data: { success: true, output: {} },
+      })
+    })
+
+    await act(async () => {
+      await handle.latest().handleStreamedResponse(
+        makeSseResponse(),
+        setMessages,
+        vi.fn(),
+        vi.fn(),
+        false
+      )
+    })
+    await flushUiBatch()
+
+    const assistant = messages.find((m) => m.id === 'msg-assistant-1')
+    expect(assistant?.thinking).toBeUndefined()
+    expect(assistant?.content).toBe('just text')
+  })
+})
+
+describe('useChatStreaming tool lifecycle (Step 8)', () => {
+  let handle: HookHandle
+  let messages: ChatMessage[]
+  let setMessages: React.Dispatch>
+
+  beforeEach(() => {
+    vi.clearAllMocks()
+    messages = []
+    setMessages = ((updater: React.SetStateAction) => {
+      messages = typeof updater === 'function' ? updater(messages) : updater
+    }) as React.Dispatch>
+    handle = renderStreamingHook()
+    vi.spyOn(window, 'requestAnimationFrame').mockImplementation((cb) => {
+      cb(performance.now())
+      return 1
+    })
+  })
+
+  afterEach(() => {
+    handle.unmount()
+    vi.restoreAllMocks()
+  })
+
+  it('maps tool start/end into keyed chips without touching answer content', async () => {
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'tool',
+        phase: 'start',
+        id: 'toolu_1',
+        name: 'http_request',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'tool',
+        phase: 'end',
+        id: 'toolu_1',
+        name: 'http_request',
+        status: 'success',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        chunk: 'https://httpbin.org/get',
+      })
+      await options.onEvent({
+        event: 'final',
+        data: { success: true, output: {} },
+      })
+    })
+
+    await act(async () => {
+      await handle.latest().handleStreamedResponse(
+        makeSseResponse(),
+        setMessages,
+        vi.fn(),
+        vi.fn(),
+        false
+      )
+    })
+    await flushUiBatch()
+
+    const assistant = messages.find((m) => m.id === 'msg-assistant-1')
+    expect(assistant?.content).toBe('https://httpbin.org/get')
+    expect(assistant?.toolCalls).toEqual([
+      {
+        key: 'agent-1:toolu_1',
+        blockId: 'agent-1',
+        id: 'toolu_1',
+        name: 'http_request',
+        displayName: 'Http Request',
+        status: 'success',
+      },
+    ])
+    expect(assistant?.toolCalls?.[0]).not.toHaveProperty('args')
+    expect(assistant?.toolCalls?.[0]).not.toHaveProperty('result')
+    expect(assistant?.isToolStreaming).toBe(false)
+  })
+
+  it('tracks parallel tools and cancels running chips on Stop', async () => {
+    const abortController = new AbortController()
+    let resolveStream!: () => void
+    const streamDone = new Promise((resolve) => {
+      resolveStream = resolve
+    })
+
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'tool',
+        phase: 'start',
+        id: 'toolu_1',
+        name: 'http_request',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'tool',
+        phase: 'start',
+        id: 'toolu_2',
+        name: 'function_execute',
+      })
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'tool',
+        phase: 'end',
+        id: 'toolu_2',
+        name: 'function_execute',
+        status: 'success',
+      })
+      await new Promise((resolve) => {
+        options.signal?.addEventListener('abort', () => resolve(), { once: true })
+        streamDone.then(() => resolve())
+      })
+    })
+
+    const streamPromise = act(async () => {
+      await handle.latest().handleStreamedResponse(
+        makeSseResponse(),
+        setMessages,
+        vi.fn(),
+        vi.fn(),
+        false,
+        { abortController }
+      )
+    })
+
+    await flushUiBatch()
+    expect(messages.find((m) => m.id === 'msg-assistant-1')?.toolCalls).toHaveLength(2)
+
+    act(() => {
+      handle.latest().stopStreaming(setMessages)
+    })
+    resolveStream()
+    await streamPromise
+
+    const tools = messages.find((m) => m.id === 'msg-assistant-1')?.toolCalls
+    expect(tools?.find((t) => t.id === 'toolu_1')?.status).toBe('cancelled')
+    expect(tools?.find((t) => t.id === 'toolu_2')?.status).toBe('success')
+    expect(messages.find((m) => m.id === 'msg-assistant-1')?.isToolStreaming).toBe(false)
+  })
+
+  it('settles straggler running tools to success on final', async () => {
+    mockReadSSEEvents.mockImplementation(async (_source, options) => {
+      await options.onEvent({
+        blockId: 'agent-1',
+        event: 'tool',
+        phase: 'start',
+        id: 'toolu_open',
+        name: 'http_request',
+      })
+      await options.onEvent({
+        event: 'final',
+        data: { success: true, output: {} },
+      })
+    })
+
+    await act(async () => {
+      await handle.latest().handleStreamedResponse(
+        makeSseResponse(),
+        setMessages,
+        vi.fn(),
+        vi.fn(),
+        false
+      )
+    })
+    await flushUiBatch()
+
+    expect(messages.find((m) => m.id === 'msg-assistant-1')?.toolCalls?.[0]?.status).toBe('success')
+  })
+})
diff --git a/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts b/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts
index be4b0b2e1ae..312586be90a 100644
--- a/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts
+++ b/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts
@@ -5,7 +5,13 @@ import { createLogger } from '@sim/logger'
 import { generateId } from '@sim/utils/id'
 import { readSSEEvents } from '@/lib/core/utils/sse'
 import { isUserFileWithMetadata } from '@/lib/core/utils/user-file'
-import type { ChatFile, ChatMessage } from '@/app/(interfaces)/chat/components/message/message'
+import { humanizeToolName } from '@/lib/copilot/tools/tool-display'
+import type {
+  ChatFile,
+  ChatMessage,
+  ChatToolCall,
+  ChatToolCallStatus,
+} from '@/app/(interfaces)/chat/components/message/message'
 import { CHAT_ERROR_MESSAGES } from '@/app/(interfaces)/chat/constants'
 
 const logger = createLogger('UseChatStreaming')
@@ -64,23 +70,99 @@ export interface StreamingOptions {
   onAudioEnd?: () => void
   audioStreamHandler?: (text: string) => Promise
   outputConfigs?: Array<{ blockId: string; path?: string }>
+  /**
+   * Shared AbortController for fetch + SSE body reads. When provided (preferred),
+   * Stop aborts the in-flight request server-side as well as the reader.
+   */
+  abortController?: AbortController
+}
+
+type ChatSseEvent = {
+  blockId?: string
+  chunk?: string
+  event?: string
+  error?: string
+  phase?: 'start' | 'end'
+  id?: string
+  name?: string
+  status?: 'success' | 'error' | 'cancelled'
+  data?:
+    | string
+    | {
+        success: boolean
+        error?: string | { message?: string }
+        output?: Record>
+      }
+}
+
+function toolCallKey(blockId: string, id: string): string {
+  return `${blockId}:${id}`
+}
+
+function settleInFlightTools(
+  map: Map,
+  terminal: Exclude
+): void {
+  for (const [key, tool] of map) {
+    if (tool.status === 'running') {
+      map.set(key, { ...tool, status: terminal })
+    }
+  }
+}
+
+function snapshotToolCalls(
+  order: string[],
+  map: Map
+): ChatToolCall[] | undefined {
+  if (order.length === 0) return undefined
+  return order.map((key) => map.get(key)).filter((t): t is ChatToolCall => Boolean(t))
+}
+
+function anyToolRunning(map: Map): boolean {
+  for (const tool of map.values()) {
+    if (tool.status === 'running') return true
+  }
+  return false
+}
+
+/** Answer text frames never carry an event type (or use a reserved non-answer event). */
+function isAnswerChunkFrame(json: ChatSseEvent): boolean {
+  if (!json.blockId || typeof json.chunk !== 'string' || !json.chunk) return false
+  // Thinking / tools / errors must never use `chunk` for answer accumulation.
+  if (json.event === 'thinking' || json.event === 'stream_error' || json.event === 'error') {
+    return false
+  }
+  if (
+    json.event === 'final' ||
+    json.event === 'tool' ||
+    json.event === 'tool_call_start' ||
+    json.event === 'tool_call_end'
+  ) {
+    return false
+  }
+  return json.event === undefined || json.event === ''
 }
 
 export function useChatStreaming() {
   const [isStreamingResponse, setIsStreamingResponse] = useState(false)
   const abortControllerRef = useRef(null)
   const accumulatedTextRef = useRef('')
+  const accumulatedThinkingRef = useRef('')
+  const accumulatedToolCallsRef = useRef([])
   const lastStreamedPositionRef = useRef(0)
   const audioStreamingActiveRef = useRef(false)
-  const lastDisplayedPositionRef = useRef(0) // Track displayed text in synced mode
+  const lastDisplayedPositionRef = useRef(0)
 
   const stopStreaming = (setMessages: React.Dispatch>) => {
     if (abortControllerRef.current) {
-      // Abort the fetch request
       abortControllerRef.current.abort()
       abortControllerRef.current = null
 
       const latestContent = accumulatedTextRef.current
+      const latestThinking = accumulatedThinkingRef.current
+      const latestTools = accumulatedToolCallsRef.current.map((tool) =>
+        tool.status === 'running' ? { ...tool, status: 'cancelled' as const } : tool
+      )
 
       setMessages((prev) => {
         const lastMessage = prev[prev.length - 1]
@@ -92,7 +174,16 @@ export function useChatStreaming() {
 
           return [
             ...prev.slice(0, -1),
-            { ...lastMessage, content: updatedContent, isStreaming: false },
+            {
+              ...lastMessage,
+              content: updatedContent,
+              // Preserve any thinking / tools received before Stop.
+              thinking: latestThinking || lastMessage.thinking,
+              toolCalls: latestTools.length > 0 ? latestTools : lastMessage.toolCalls,
+              isStreaming: false,
+              isThinkingStreaming: false,
+              isToolStreaming: false,
+            },
           ]
         }
 
@@ -101,6 +192,8 @@ export function useChatStreaming() {
 
       setIsStreamingResponse(false)
       accumulatedTextRef.current = ''
+      accumulatedThinkingRef.current = ''
+      accumulatedToolCallsRef.current = []
       lastStreamedPositionRef.current = 0
       lastDisplayedPositionRef.current = 0
       audioStreamingActiveRef.current = false
@@ -116,11 +209,15 @@ export function useChatStreaming() {
     streamingOptions?: StreamingOptions
   ) => {
     logger.info('[useChatStreaming] handleStreamedResponse called')
-    // Set streaming state
     setIsStreamingResponse(true)
-    abortControllerRef.current = new AbortController()
 
-    // Check if we should stream audio
+    // Prefer a shared controller from the caller (fetch + reader). Otherwise create one.
+    if (streamingOptions?.abortController) {
+      abortControllerRef.current = streamingOptions.abortController
+    } else if (!abortControllerRef.current) {
+      abortControllerRef.current = new AbortController()
+    }
+
     const shouldPlayAudio =
       streamingOptions?.voiceSettings?.isVoiceEnabled &&
       streamingOptions?.voiceSettings?.autoPlayResponses &&
@@ -133,7 +230,15 @@ export function useChatStreaming() {
     }
 
     let accumulatedText = ''
+    let accumulatedThinking = ''
+    let isThinkingStreaming = false
     let lastAudioPosition = 0
+    const toolCallsMap = new Map()
+    const toolCallOrder: string[] = []
+
+    const syncToolCallsRef = () => {
+      accumulatedToolCallsRef.current = snapshotToolCalls(toolCallOrder, toolCallsMap) ?? []
+    }
 
     const messageIdMap = new Map()
     const messageId = generateId()
@@ -156,12 +261,23 @@ export function useChatStreaming() {
       if (!uiDirty) return
       uiDirty = false
       lastUIFlush = performance.now()
-      const snapshot = accumulatedText
+      const contentSnapshot = accumulatedText
+      const thinkingSnapshot = accumulatedThinking
+      const thinkingStreamingSnapshot = isThinkingStreaming
+      const toolCallsSnapshot = snapshotToolCalls(toolCallOrder, toolCallsMap)
+      const toolStreamingSnapshot = anyToolRunning(toolCallsMap)
       setMessages((prev) =>
         prev.map((msg) => {
           if (msg.id !== messageId) return msg
           if (!msg.isStreaming) return msg
-          return { ...msg, content: snapshot }
+          return {
+            ...msg,
+            content: contentSnapshot,
+            thinking: thinkingSnapshot || undefined,
+            isThinkingStreaming: thinkingStreamingSnapshot,
+            toolCalls: toolCallsSnapshot,
+            isToolStreaming: toolStreamingSnapshot,
+          }
         })
       )
     }
@@ -194,18 +310,8 @@ export function useChatStreaming() {
     let terminated = false
 
     try {
-      await readSSEEvents<{
-        blockId?: string
-        chunk?: string
-        event?: string
-        error?: string
-        data?: {
-          success: boolean
-          error?: string | { message?: string }
-          output?: Record>
-        }
-      }>(response.body, {
-        signal: abortControllerRef.current.signal,
+      await readSSEEvents(response.body, {
+        signal: abortControllerRef.current!.signal,
         onParseError: (_data, parseError) => {
           logger.error('Error parsing stream data:', parseError)
         },
@@ -214,13 +320,20 @@ export function useChatStreaming() {
 
           if (eventType === 'error' || json.event === 'error') {
             const errorMessage = json.error || CHAT_ERROR_MESSAGES.GENERIC_ERROR
+            settleInFlightTools(toolCallsMap, 'error')
+            syncToolCallsRef()
+            const toolsSnapshot = snapshotToolCalls(toolCallOrder, toolCallsMap)
             setMessages((prev) =>
               prev.map((msg) =>
                 msg.id === messageId
                   ? {
                       ...msg,
                       content: errorMessage,
+                      thinking: accumulatedThinking || msg.thinking,
+                      toolCalls: toolsSnapshot ?? msg.toolCalls,
                       isStreaming: false,
+                      isThinkingStreaming: false,
+                      isToolStreaming: false,
                       type: 'assistant' as const,
                     }
                   : msg
@@ -231,9 +344,92 @@ export function useChatStreaming() {
             return true
           }
 
-          if (eventType === 'final' && json.data) {
+          if (eventType === 'stream_error') {
+            const errText =
+              typeof json.error === 'string' && json.error
+                ? json.error
+                : 'A streaming error occurred'
+            logger.warn('[useChatStreaming] Non-terminal stream_error', {
+              blockId,
+              error: errText,
+            })
+            // Non-terminal: keep streaming; surface in thinking chrome so it is visible.
+            accumulatedThinking +=
+              (accumulatedThinking ? '\n\n' : '') + `[Stream error] ${errText}`
+            accumulatedThinkingRef.current = accumulatedThinking
+            isThinkingStreaming = true
+            uiDirty = true
+            scheduleUIFlush()
+            return false
+          }
+
+          if (eventType === 'thinking' && blockId && typeof json.data === 'string') {
+            if (!messageIdMap.has(blockId)) {
+              messageIdMap.set(blockId, messageId)
+            }
+            accumulatedThinking += json.data
+            accumulatedThinkingRef.current = accumulatedThinking
+            isThinkingStreaming = true
+            uiDirty = true
+            scheduleUIFlush()
+            return false
+          }
+
+          if (
+            eventType === 'tool' &&
+            blockId &&
+            typeof json.id === 'string' &&
+            json.id &&
+            typeof json.name === 'string' &&
+            json.name
+          ) {
+            if (!messageIdMap.has(blockId)) {
+              messageIdMap.set(blockId, messageId)
+            }
+            const key = toolCallKey(blockId, json.id)
+            if (json.phase === 'start') {
+              if (!toolCallsMap.has(key)) {
+                toolCallOrder.push(key)
+              }
+              toolCallsMap.set(key, {
+                key,
+                blockId,
+                id: json.id,
+                name: json.name,
+                displayName: humanizeToolName(json.name),
+                status: 'running',
+              })
+            } else if (json.phase === 'end') {
+              const endStatus: ChatToolCallStatus =
+                json.status === 'error' || json.status === 'cancelled' ? json.status : 'success'
+              const existing = toolCallsMap.get(key)
+              if (!existing) {
+                toolCallOrder.push(key)
+                toolCallsMap.set(key, {
+                  key,
+                  blockId,
+                  id: json.id,
+                  name: json.name,
+                  displayName: humanizeToolName(json.name),
+                  status: endStatus,
+                })
+              } else {
+                toolCallsMap.set(key, { ...existing, status: endStatus })
+              }
+            }
+            syncToolCallsRef()
+            uiDirty = true
+            scheduleUIFlush()
+            return false
+          }
+
+          if (eventType === 'final' && json.data && typeof json.data === 'object') {
             flushUI()
             const finalData = json.data
+            isThinkingStreaming = false
+            settleInFlightTools(toolCallsMap, 'success')
+            syncToolCallsRef()
+            const toolsSnapshot = snapshotToolCalls(toolCallOrder, toolCallsMap)
 
             const outputConfigs = streamingOptions?.outputConfigs
             const formattedOutputs: string[] = []
@@ -358,7 +554,11 @@ export function useChatStreaming() {
                   ? {
                       ...msg,
                       isStreaming: false,
+                      isThinkingStreaming: false,
+                      isToolStreaming: false,
                       content: finalContent ?? msg.content,
+                      thinking: accumulatedThinking || msg.thinking,
+                      toolCalls: toolsSnapshot ?? msg.toolCalls,
                       files: extractedFiles.length > 0 ? extractedFiles : undefined,
                     }
                   : msg
@@ -366,6 +566,8 @@ export function useChatStreaming() {
             )
 
             accumulatedTextRef.current = ''
+            accumulatedThinkingRef.current = ''
+            accumulatedToolCallsRef.current = []
             lastStreamedPositionRef.current = 0
             lastDisplayedPositionRef.current = 0
             audioStreamingActiveRef.current = false
@@ -374,11 +576,17 @@ export function useChatStreaming() {
             return true
           }
 
-          if (blockId && contentChunk) {
-            if (!messageIdMap.has(blockId)) {
+          // Answer text only — never append thinking/tool/unknown chunk frames blindly.
+          if (isAnswerChunkFrame(json) && contentChunk) {
+            if (blockId && !messageIdMap.has(blockId)) {
               messageIdMap.set(blockId, messageId)
             }
 
+            // First answer chunk settles thinking chrome (still visible, no longer “live”).
+            if (isThinkingStreaming) {
+              isThinkingStreaming = false
+            }
+
             accumulatedText += contentChunk
             accumulatedTextRef.current = accumulatedText
             logger.debug('[useChatStreaming] Received chunk', {
@@ -416,10 +624,6 @@ export function useChatStreaming() {
                 }
               }
             }
-          } else if (blockId && eventType === 'end') {
-            setMessages((prev) =>
-              prev.map((msg) => (msg.id === messageId ? { ...msg, isStreaming: false } : msg))
-            )
           }
         },
       })
@@ -442,10 +646,31 @@ export function useChatStreaming() {
         }
       }
     } catch (error) {
-      logger.error('Error processing stream:', error)
+      // Stop / timeout abort the shared fetch controller; body read then throws AbortError.
+      // Match chat.tsx + use-audio-streaming: expected cancel, not a hard failure.
+      if (error instanceof Error && error.name === 'AbortError') {
+        logger.info('Stream aborted by user or timeout')
+        settleInFlightTools(toolCallsMap, 'cancelled')
+      } else {
+        logger.error('Error processing stream:', error)
+        settleInFlightTools(toolCallsMap, 'error')
+      }
+      syncToolCallsRef()
       flushUI()
+      const toolsSnapshot = snapshotToolCalls(toolCallOrder, toolCallsMap)
       setMessages((prev) =>
-        prev.map((msg) => (msg.id === messageId ? { ...msg, isStreaming: false } : msg))
+        prev.map((msg) =>
+          msg.id === messageId
+            ? {
+                ...msg,
+                isStreaming: false,
+                isThinkingStreaming: false,
+                isToolStreaming: false,
+                thinking: accumulatedThinking || msg.thinking,
+                toolCalls: toolsSnapshot ?? msg.toolCalls,
+              }
+            : msg
+        )
       )
     } finally {
       if (uiRAF !== null) cancelAnimationFrame(uiRAF)
@@ -473,3 +698,5 @@ export function useChatStreaming() {
     handleStreamedResponse,
   }
 }
+
+export { isAnswerChunkFrame }
diff --git a/apps/sim/app/api/chat/[identifier]/otp/route.ts b/apps/sim/app/api/chat/[identifier]/otp/route.ts
index 248fcfa84ab..99dfa6069c2 100644
--- a/apps/sim/app/api/chat/[identifier]/otp/route.ts
+++ b/apps/sim/app/api/chat/[identifier]/otp/route.ts
@@ -160,6 +160,7 @@ export const PUT = withRouteHandler(
           authType: chat.authType,
           password: chat.password,
           outputConfigs: chat.outputConfigs,
+          includeThinking: chat.includeThinking,
         })
         .from(chat)
         .where(
@@ -209,6 +210,7 @@ export const PUT = withRouteHandler(
         customizations: deployment.customizations,
         authType: deployment.authType,
         outputConfigs: deployment.outputConfigs,
+        includeThinking: deployment.includeThinking ?? false,
       })
       setChatAuthCookie(response, deployment.id, deployment.authType, deployment.password)
 
diff --git a/apps/sim/app/api/chat/[identifier]/route.test.ts b/apps/sim/app/api/chat/[identifier]/route.test.ts
index 9eb104547ff..ea7dfe48b28 100644
--- a/apps/sim/app/api/chat/[identifier]/route.test.ts
+++ b/apps/sim/app/api/chat/[identifier]/route.test.ts
@@ -37,6 +37,7 @@ function createMockNextRequest(
     method,
     headers: headersObj,
     nextUrl: parsedUrl,
+    signal: AbortSignal.timeout(60_000),
     cookies: {
       get: vi.fn().mockReturnValue(undefined),
     },
@@ -114,6 +115,7 @@ vi.mock('@/lib/uploads', () => ({
 
 vi.mock('@/lib/workflows/streaming/streaming', () => ({
   createStreamingResponse: vi.fn().mockImplementation(async () => createMockStream()),
+  agentStreamProtocolResponseHeaders: vi.fn().mockReturnValue({}),
 }))
 
 vi.mock('@/lib/workflows/executor/execute-workflow', () => ({
@@ -150,6 +152,7 @@ describe('Chat Identifier API Route', () => {
         primaryColor: '#000000',
       },
       outputConfigs: [{ blockId: 'block-1', path: 'output' }],
+      includeThinking: false,
     },
   ]
 
@@ -443,9 +446,12 @@ describe('Chat Identifier API Route', () => {
       expect(createStreamingResponse).toHaveBeenCalledWith(
         expect.objectContaining({
           executeFn: expect.any(Function),
+          requestSignal: expect.any(AbortSignal),
+          requestHeaders: expect.anything(),
           streamConfig: expect.objectContaining({
             isSecureMode: true,
             workflowTriggerType: 'chat',
+            includeThinking: false,
           }),
         })
       )
diff --git a/apps/sim/app/api/chat/[identifier]/route.ts b/apps/sim/app/api/chat/[identifier]/route.ts
index 819c732db5b..1da492d81ff 100644
--- a/apps/sim/app/api/chat/[identifier]/route.ts
+++ b/apps/sim/app/api/chat/[identifier]/route.ts
@@ -27,6 +27,7 @@ interface ChatConfigSource {
   customizations: unknown
   authType: string | null
   outputConfigs: unknown
+  includeThinking?: boolean | null
 }
 
 function toChatConfigResponse(deployment: ChatConfigSource) {
@@ -37,6 +38,7 @@ function toChatConfigResponse(deployment: ChatConfigSource) {
     customizations: deployment.customizations,
     authType: deployment.authType,
     outputConfigs: deployment.outputConfigs,
+    includeThinking: deployment.includeThinking ?? false,
   }
 }
 
@@ -80,6 +82,7 @@ export const POST = withRouteHandler(
           password: chat.password,
           allowedEmails: chat.allowedEmails,
           outputConfigs: chat.outputConfigs,
+          includeThinking: chat.includeThinking,
         })
         .from(chat)
         .where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt)))
@@ -216,7 +219,9 @@ export const POST = withRouteHandler(
           }
         }
 
-        const { createStreamingResponse } = await import('@/lib/workflows/streaming/streaming')
+        const { createStreamingResponse, agentStreamProtocolResponseHeaders } = await import(
+          '@/lib/workflows/streaming/streaming'
+        )
         const { executeWorkflow } = await import('@/lib/workflows/executor/execute-workflow')
         const { SSE_HEADERS } = await import('@/lib/core/utils/sse')
 
@@ -272,17 +277,21 @@ export const POST = withRouteHandler(
           variables: (workflowRecord?.variables as Record) ?? undefined,
         }
 
+        const includeThinking = deployment.includeThinking ?? false
         const stream = await createStreamingResponse({
           requestId,
           streamConfig: {
             selectedOutputs,
             isSecureMode: true,
             workflowTriggerType: 'chat',
+            includeThinking,
           },
           executionId,
           workspaceId,
           workflowId: deployment.workflowId,
           userId: resolvedActorUserId,
+          requestSignal: request.signal,
+          requestHeaders: request.headers,
           executeFn: async ({ onStream, onBlockComplete, abortSignal }) =>
             executeWorkflow(
               workflowForExecution,
@@ -300,6 +309,7 @@ export const POST = withRouteHandler(
                 abortSignal,
                 executionMode: 'stream',
                 billingAttribution,
+                includeThinking,
               },
               executionId
             ),
@@ -307,7 +317,13 @@ export const POST = withRouteHandler(
 
         const streamResponse = new NextResponse(stream, {
           status: 200,
-          headers: SSE_HEADERS,
+          headers: {
+            ...SSE_HEADERS,
+            ...agentStreamProtocolResponseHeaders({
+              includeThinking,
+              requestHeaders: request.headers,
+            }),
+          },
         })
         return streamResponse
       } catch (error: any) {
@@ -344,6 +360,7 @@ export const GET = withRouteHandler(
           password: chat.password,
           allowedEmails: chat.allowedEmails,
           outputConfigs: chat.outputConfigs,
+          includeThinking: chat.includeThinking,
         })
         .from(chat)
         .where(and(eq(chat.identifier, identifier), isNull(chat.archivedAt)))
diff --git a/apps/sim/app/api/chat/manage/[id]/route.ts b/apps/sim/app/api/chat/manage/[id]/route.ts
index 707051d7e9f..44ebc5f4b60 100644
--- a/apps/sim/app/api/chat/manage/[id]/route.ts
+++ b/apps/sim/app/api/chat/manage/[id]/route.ts
@@ -105,6 +105,7 @@ export const PATCH = withRouteHandler(
         password,
         allowedEmails,
         outputConfigs,
+        includeThinking,
       } = validatedData
 
       if (workflowId && workflowId !== existingChat[0].workflowId) {
@@ -191,6 +192,10 @@ export const PATCH = withRouteHandler(
         updateData.outputConfigs = outputConfigs
       }
 
+      if (includeThinking !== undefined) {
+        updateData.includeThinking = includeThinking
+      }
+
       const emailCount = Array.isArray(updateData.allowedEmails)
         ? updateData.allowedEmails.length
         : undefined
@@ -204,6 +209,7 @@ export const PATCH = withRouteHandler(
         hasPassword: updateData.password !== undefined,
         emailCount,
         outputConfigsCount,
+        includeThinking: updateData.includeThinking,
       })
 
       await db.update(chat).set(updateData).where(eq(chat.id, chatId))
diff --git a/apps/sim/app/api/chat/route.ts b/apps/sim/app/api/chat/route.ts
index ca1f8019fe7..523efc84c5e 100644
--- a/apps/sim/app/api/chat/route.ts
+++ b/apps/sim/app/api/chat/route.ts
@@ -64,6 +64,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
       password,
       allowedEmails = [],
       outputConfigs = [],
+      includeThinking = false,
     } = parsed.data.body
 
     if (authType === 'password' && !password) {
@@ -112,6 +113,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
       password,
       allowedEmails,
       outputConfigs,
+      includeThinking,
       workspaceId: workflowRecord.workspaceId,
     })
 
diff --git a/apps/sim/app/api/providers/route.ts b/apps/sim/app/api/providers/route.ts
index 49b90074943..bd7fb52b69a 100644
--- a/apps/sim/app/api/providers/route.ts
+++ b/apps/sim/app/api/providers/route.ts
@@ -24,6 +24,7 @@ import {
 } from '@/ee/access-control/utils/permission-check'
 import type { StreamingExecution } from '@/executor/types'
 import { executeProviderRequest } from '@/providers'
+import { projectStreamingExecutionToByteStream } from '@/providers/stream-pump'
 
 const logger = createLogger('ProvidersAPI')
 
@@ -238,8 +239,9 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
       logger.info(`[${requestId}] Received StreamingExecution from provider`)
 
       // Extract the stream and execution data
-      const stream = streamingExec.stream
       const executionData = streamingExec.execution
+      // agent-events-v1 is an object stream — project final-turn answer bytes for HTTP.
+      const byteStream = projectStreamingExecutionToByteStream(streamingExec)
 
       // Attach the execution data as a custom header
       // We need to safely serialize the execution data to avoid circular references
@@ -288,7 +290,7 @@ export const POST = withRouteHandler(async (request: NextRequest) => {
       }
 
       // Return the stream with execution data in a header
-      return new Response(stream, {
+      return new Response(byteStream, {
         headers: {
           'Content-Type': 'text/event-stream',
           'Cache-Control': 'no-cache',
diff --git a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts
index 6ea631652f6..102a2f0045a 100644
--- a/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts
+++ b/apps/sim/app/api/resume/[workflowId]/[executionId]/[contextId]/route.ts
@@ -257,12 +257,15 @@ export const POST = withRouteHandler(
           streamConfig: {
             selectedOutputs: persistedSnapshot.selectedOutputs,
             timeoutMs: preprocessResult.executionTimeout?.sync,
+            includeThinking: persistedSnapshot.metadata.includeThinking === true,
           },
           executionId: enqueueResult.resumeExecutionId,
           workspaceId: workflow.workspaceId || undefined,
           workflowId,
           userId: enqueueResult.userId,
           allowLargeValueWorkflowScope: true,
+          requestSignal: request.signal,
+          requestHeaders: request.headers,
           executeFn: async ({ onStream, onBlockComplete, abortSignal }) =>
             PauseResumeManager.startResumeExecution({
               ...resumeArgs,
diff --git a/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts b/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts
index 188a5a47580..6e6cb33da23 100644
--- a/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts
+++ b/apps/sim/app/api/workflows/[id]/chat/status/route.test.ts
@@ -82,6 +82,7 @@ describe('Workflow Chat Status Route', () => {
         authType: 'public',
         allowedEmails: [],
         outputConfigs: [{ blockId: 'agent-1', path: 'content' }],
+        includeThinking: false,
         password: 'secret',
         isActive: true,
       },
@@ -96,5 +97,6 @@ describe('Workflow Chat Status Route', () => {
     expect(data.deployment.id).toBe('chat-1')
     expect(data.deployment.hasPassword).toBe(true)
     expect(data.deployment.outputConfigs).toEqual([{ blockId: 'agent-1', path: 'content' }])
+    expect(data.deployment.includeThinking).toBe(false)
   })
 })
diff --git a/apps/sim/app/api/workflows/[id]/chat/status/route.ts b/apps/sim/app/api/workflows/[id]/chat/status/route.ts
index 49d555b813d..764f052f8c6 100644
--- a/apps/sim/app/api/workflows/[id]/chat/status/route.ts
+++ b/apps/sim/app/api/workflows/[id]/chat/status/route.ts
@@ -52,6 +52,7 @@ export const GET = withRouteHandler(
           authType: chat.authType,
           allowedEmails: chat.allowedEmails,
           outputConfigs: chat.outputConfigs,
+          includeThinking: chat.includeThinking,
           password: chat.password,
           isActive: chat.isActive,
         })
@@ -71,6 +72,7 @@ export const GET = withRouteHandler(
               authType: deploymentResults[0].authType,
               allowedEmails: deploymentResults[0].allowedEmails,
               outputConfigs: deploymentResults[0].outputConfigs,
+              includeThinking: deploymentResults[0].includeThinking ?? false,
               hasPassword: Boolean(deploymentResults[0].password),
             }
           : null
diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts
index a0ca81c0464..309e16c43c2 100644
--- a/apps/sim/app/api/workflows/[id]/execute/route.ts
+++ b/apps/sim/app/api/workflows/[id]/execute/route.ts
@@ -87,7 +87,8 @@ import {
   loadDeployedWorkflowState,
   loadWorkflowFromNormalizedTables,
 } from '@/lib/workflows/persistence/utils'
-import { createStreamingResponse } from '@/lib/workflows/streaming/streaming'
+import { createStreamingResponse, agentStreamProtocolResponseHeaders } from '@/lib/workflows/streaming/streaming'
+import { attachAgentStreamSink } from '@/lib/workflows/streaming/attach-agent-stream-sink'
 import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils'
 import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils'
 import { executeWorkflowJob, type WorkflowExecutionPayload } from '@/background/workflow-execution'
@@ -1448,6 +1449,8 @@ async function handleExecutePost(
           includeFileBase64,
           base64MaxBytes,
           timeoutMs: preprocessResult.executionTimeout?.sync,
+          // Workflow API has no chat includeThinking policy — thinking frames stay off.
+          includeThinking: false,
         },
         executionId,
         largeValueExecutionIds,
@@ -1457,6 +1460,8 @@ async function handleExecutePost(
         workflowId,
         userId: actorUserId,
         allowLargeValueWorkflowScope,
+        requestSignal: req.signal,
+        requestHeaders: req.headers,
         executeFn: async ({ onStream, onBlockComplete, abortSignal }) =>
           executeWorkflow(
             streamWorkflow,
@@ -1488,7 +1493,13 @@ async function handleExecutePost(
       executionIdClaimCommitted = true
       return new NextResponse(stream, {
         status: 200,
-        headers: SSE_HEADERS,
+        headers: {
+          ...SSE_HEADERS,
+          ...agentStreamProtocolResponseHeaders({
+            includeThinking: false,
+            requestHeaders: req.headers,
+          }),
+        },
       })
     }
 
@@ -1529,7 +1540,11 @@ async function handleExecutePost(
           event: ExecutionEvent,
           terminalStatus?: TerminalExecutionStreamStatus
         ) => {
-          const isBuffered = event.type !== 'stream:chunk' && event.type !== 'stream:done'
+          const isBuffered =
+            event.type !== 'stream:chunk' &&
+            event.type !== 'stream:done' &&
+            event.type !== 'stream:thinking' &&
+            event.type !== 'stream:tool'
           let eventToSend = event
           if (isBuffered) {
             try {
@@ -1727,6 +1742,37 @@ async function handleExecutePost(
           const onStream = async (streamingExec: StreamingExecution) => {
             const blockId = (streamingExec.execution as any).blockId
 
+            // Sync window: attach sink before first await so pump delivers thinking/tools.
+            const unsubscribe = attachAgentStreamSink(streamingExec, {
+              onThinkingDelta: async (text) => {
+                await sendEvent({
+                  type: 'stream:thinking',
+                  timestamp: new Date().toISOString(),
+                  executionId,
+                  workflowId,
+                  data: { blockId, data: text },
+                })
+              },
+              onToolCallStart: async (id, name) => {
+                await sendEvent({
+                  type: 'stream:tool',
+                  timestamp: new Date().toISOString(),
+                  executionId,
+                  workflowId,
+                  data: { blockId, phase: 'start', id, name },
+                })
+              },
+              onToolCallEnd: async (id, name, status) => {
+                await sendEvent({
+                  type: 'stream:tool',
+                  timestamp: new Date().toISOString(),
+                  executionId,
+                  workflowId,
+                  data: { blockId, phase: 'end', id, name, status },
+                })
+              },
+            })
+
             const reader = streamingExec.stream.getReader()
             const decoder = new TextDecoder()
             const cancelReader = () => {
@@ -1767,6 +1813,7 @@ async function handleExecutePost(
                 reqLogger.error('Error streaming block content:', error)
               }
             } finally {
+              unsubscribe()
               timeoutController.signal.removeEventListener('abort', cancelReader)
               try {
                 await reader.cancel().catch(() => {})
diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx
index 43b1ea42d11..9ec3718d571 100644
--- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/components/deploy/components/deploy-modal/components/chat/chat.tsx
@@ -11,6 +11,7 @@ import {
   Label,
   Loader,
   Skeleton,
+  Switch,
   TagInput,
   type TagItem,
   Textarea,
@@ -83,6 +84,7 @@ const initialFormData: ChatFormData = {
   emails: [],
   welcomeMessage: 'Hi there! How can I help you today?',
   selectedOutputBlocks: [],
+  includeThinking: false,
 }
 
 export function ChatDeploy({
@@ -193,6 +195,7 @@ export function ChatDeploy({
               (config: { blockId: string; path: string }) => `${config.blockId}_${config.path}`
             )
           : [],
+        includeThinking: existingChat.includeThinking ?? false,
       })
 
       if (existingChat.customizations?.imageUrl) {
@@ -368,6 +371,23 @@ export function ChatDeploy({
             )}
           
+
+
+ +

+ Allow this chat to stream model thinking when the client opts in. Off by default. +

+
+ updateField('includeThinking', checked)} + aria-label='Include thinking' + /> +
+ + {!showInput && + (selectedEntry.agentStreamThinking || + (selectedEntry.agentStreamToolCalls && + selectedEntry.agentStreamToolCalls.length > 0)) && ( +
+ {selectedEntry.agentStreamThinking ? ( + + ) : null} + {selectedEntry.agentStreamToolCalls && + selectedEntry.agentStreamToolCalls.length > 0 ? ( + t.status === 'running') + )} + /> + ) : null} +
+ )} {shouldShowCodeDisplay ? ( () const activeBlockRefCounts = new Map() const streamedChunks = new Map() + const agentStreamThinking = new Map() + const agentStreamToolCalls = new Map< + string, + Map + >() + const agentStreamToolOrder = new Map() const accumulatedBlockLogs: BlockLog[] = [] const accumulatedBlockStates = new Map() const executedBlockIds = new Set() @@ -1167,8 +1174,93 @@ export function useWorkflowExecution() { } }, + onStreamThinking: (data) => { + const prev = agentStreamThinking.get(data.blockId) ?? '' + const next = prev + data.data + agentStreamThinking.set(data.blockId, next) + updateConsole( + data.blockId, + { + agentStreamThinking: next, + agentStreamActive: true, + }, + executionIdRef.current + ) + }, + + onStreamTool: (data) => { + const key = `${data.blockId}:${data.id}` + if (!agentStreamToolCalls.has(data.blockId)) { + agentStreamToolCalls.set(data.blockId, new Map()) + agentStreamToolOrder.set(data.blockId, []) + } + const map = agentStreamToolCalls.get(data.blockId)! + const order = agentStreamToolOrder.get(data.blockId)! + + if (data.phase === 'start') { + if (!map.has(key)) { + order.push(key) + } + map.set(key, { + key, + id: data.id, + name: data.name, + displayName: humanizeToolName(data.name), + status: 'running', + }) + } else { + const existing = map.get(key) + map.set(key, { + key, + id: data.id, + name: data.name, + displayName: existing?.displayName ?? humanizeToolName(data.name), + status: data.status ?? 'success', + }) + if (!existing) { + order.push(key) + } + } + + updateConsole( + data.blockId, + { + agentStreamToolCalls: order + .map((k) => map.get(k)) + .filter((t): t is NonNullable => Boolean(t)), + agentStreamActive: true, + }, + executionIdRef.current + ) + }, + onStreamDone: (data) => { logger.info('Stream done for block:', data.blockId) + const map = agentStreamToolCalls.get(data.blockId) + const order = agentStreamToolOrder.get(data.blockId) + if (map && order) { + for (const [key, tool] of map) { + if (tool.status === 'running') { + map.set(key, { ...tool, status: 'success' }) + } + } + updateConsole( + data.blockId, + { + agentStreamActive: false, + agentStreamToolCalls: order + .map((k) => map.get(k)) + .filter((t): t is NonNullable => Boolean(t)), + }, + executionIdRef.current + ) + } else { + updateConsole( + data.blockId, + { agentStreamActive: false }, + executionIdRef.current + ) + } }, onExecutionCompleted: (data) => { diff --git a/apps/sim/components/agent-stream/agent-stream-chrome.test.tsx b/apps/sim/components/agent-stream/agent-stream-chrome.test.tsx new file mode 100644 index 00000000000..cdb0e7e6ef6 --- /dev/null +++ b/apps/sim/components/agent-stream/agent-stream-chrome.test.tsx @@ -0,0 +1,145 @@ +/** + * @vitest-environment jsdom + */ +import { act } from 'react' +import { createRoot, type Root } from 'react-dom/client' +import { afterEach, describe, expect, it, vi } from 'vitest' + +vi.mock('@sim/emcn', () => ({ + cn: (...args: unknown[]) => args.filter(Boolean).join(' '), +})) + +vi.mock('@/lib/copilot/tools/tool-display', () => ({ + humanizeToolName: (name: string) => name, +})) + +import { AgentStreamThinkingChrome } from '@/components/agent-stream/agent-stream-chrome' + +function renderChrome(props: { + thinking: string + isStreaming?: boolean +}): { + container: HTMLDivElement + rerender: (next: { thinking: string; isStreaming?: boolean }) => void + 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) + + const mount = (p: { thinking: string; isStreaming?: boolean }) => { + act(() => { + root.render( + + ) + }) + } + + mount(props) + + return { + container, + rerender: (next) => mount(next), + unmount: () => { + act(() => { + root.unmount() + }) + container.remove() + }, + } +} + +describe('AgentStreamThinkingChrome', () => { + const mounts: Array<() => void> = [] + + afterEach(() => { + while (mounts.length) { + mounts.pop()?.() + } + }) + + it('opens while streaming with Thinking… label and scrollable body', () => { + const { container, unmount } = renderChrome({ + thinking: 'step one', + isStreaming: true, + }) + mounts.push(unmount) + + const toggle = container.querySelector( + '[data-testid="agent-stream-thinking-toggle"]' + ) as HTMLButtonElement + const body = container.querySelector( + '[data-testid="agent-stream-thinking-body"]' + ) as HTMLDivElement + + expect(toggle.getAttribute('aria-expanded')).toBe('true') + expect(toggle.textContent).toContain('Thinking…') + expect(body.className).toContain('max-h-40') + expect(body.className).toContain('overflow-y-auto') + expect(body.textContent).toContain('step one') + }) + + it('auto-collapses when streaming ends and shows Thought for a moment', () => { + const { container, rerender, unmount } = renderChrome({ + thinking: 'long internal chain', + isStreaming: true, + }) + mounts.push(unmount) + + rerender({ thinking: 'long internal chain', isStreaming: false }) + + const toggle = container.querySelector( + '[data-testid="agent-stream-thinking-toggle"]' + ) as HTMLButtonElement + + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(toggle.textContent).toContain('Thought for a moment') + }) + + it('stays open after manual reopen once collapsed', () => { + const { container, rerender, unmount } = renderChrome({ + thinking: 'reason', + isStreaming: true, + }) + mounts.push(unmount) + + rerender({ thinking: 'reason', isStreaming: false }) + + const toggle = container.querySelector( + '[data-testid="agent-stream-thinking-toggle"]' + ) as HTMLButtonElement + expect(toggle.getAttribute('aria-expanded')).toBe('false') + + act(() => { + toggle.click() + }) + + expect(toggle.getAttribute('aria-expanded')).toBe('true') + expect( + container.querySelector('[data-testid="agent-stream-thinking-body"]')?.textContent + ).toContain('reason') + + // Re-render with same done state should not force-close a user pin. + rerender({ thinking: 'reason', isStreaming: false }) + expect(toggle.getAttribute('aria-expanded')).toBe('true') + }) + + it('re-opens when a new streaming phase starts', () => { + const { container, rerender, unmount } = renderChrome({ + thinking: 'first', + isStreaming: false, + }) + mounts.push(unmount) + + const toggle = container.querySelector( + '[data-testid="agent-stream-thinking-toggle"]' + ) as HTMLButtonElement + // Initial non-streaming starts closed. + expect(toggle.getAttribute('aria-expanded')).toBe('false') + + rerender({ thinking: 'first then more', isStreaming: true }) + expect(toggle.getAttribute('aria-expanded')).toBe('true') + expect(toggle.textContent).toContain('Thinking…') + }) +}) diff --git a/apps/sim/components/agent-stream/agent-stream-chrome.tsx b/apps/sim/components/agent-stream/agent-stream-chrome.tsx new file mode 100644 index 00000000000..24dd39f1476 --- /dev/null +++ b/apps/sim/components/agent-stream/agent-stream-chrome.tsx @@ -0,0 +1,203 @@ +'use client' + +import { useEffect, useLayoutEffect, useRef, useState } from 'react' +import { Check, ChevronDown, Circle, Square, X } from 'lucide-react' +import { cn } from '@sim/emcn' +import { humanizeToolName } from '@/lib/copilot/tools/tool-display' + +export type AgentStreamToolStatus = 'running' | 'success' | 'error' | 'cancelled' + +export interface AgentStreamToolCall { + key: string + id: string + name: string + displayName?: string + status: AgentStreamToolStatus +} + +/** Distance from bottom (px) within which we keep following new thinking text. */ +const STICK_TO_BOTTOM_THRESHOLD_PX = 24 + +export function AgentStreamThinkingChrome({ + thinking, + isStreaming = false, +}: { + thinking: string + isStreaming?: boolean +}) { + const [open, setOpen] = useState(!!isStreaming) + /** After auto-collapse, a manual open pins the panel until the user closes it. */ + const [userPinnedOpen, setUserPinnedOpen] = useState(false) + const [stickToBottom, setStickToBottom] = useState(true) + const [overflowing, setOverflowing] = useState(false) + const scrollRef = useRef(null) + const wasStreamingRef = useRef(!!isStreaming) + + useEffect(() => { + const wasStreaming = wasStreamingRef.current + wasStreamingRef.current = !!isStreaming + + if (isStreaming) { + setOpen(true) + setUserPinnedOpen(false) + setStickToBottom(true) + return + } + + // Auto-collapse when live thinking ends (answer started), unless user pinned open. + if (wasStreaming && !isStreaming && !userPinnedOpen) { + setOpen(false) + } + }, [isStreaming, userPinnedOpen]) + + useLayoutEffect(() => { + const el = scrollRef.current + if (!el || !open) return + + setOverflowing(el.scrollHeight > el.clientHeight + 1) + + if (isStreaming && stickToBottom) { + el.scrollTop = el.scrollHeight + } + }, [thinking, open, isStreaming, stickToBottom]) + + const handleToggle = () => { + setOpen((prev) => { + const next = !prev + if (!isStreaming) { + setUserPinnedOpen(next) + } else { + setUserPinnedOpen(false) + } + if (next) { + setStickToBottom(true) + } + return next + }) + } + + const handleScroll = () => { + const el = scrollRef.current + if (!el) return + const distanceFromBottom = el.scrollHeight - el.scrollTop - el.clientHeight + const nearBottom = distanceFromBottom <= STICK_TO_BOTTOM_THRESHOLD_PX + setStickToBottom(nearBottom) + setOverflowing(el.scrollHeight > el.clientHeight + 1) + } + + const label = isStreaming ? 'Thinking…' : 'Thought for a moment' + + return ( +
+ + +
+
+
+
+ {thinking} +
+ {open && isStreaming && overflowing && ( +
+ )} +
+
+
+
+ ) +} + +function ToolStatusIcon({ status }: { status: AgentStreamToolStatus }) { + if (status === 'success') { + return + } + if (status === 'error') { + return + } + if (status === 'cancelled') { + return + } + return +} + +export function AgentStreamToolCallsChrome({ + toolCalls, + isStreaming, +}: { + toolCalls: AgentStreamToolCall[] + isStreaming?: boolean +}) { + const [open, setOpen] = useState(!!isStreaming) + + useEffect(() => { + if (isStreaming) { + setOpen(true) + } + }, [isStreaming]) + + return ( +
+ + {open && ( +
    + {toolCalls.map((tool) => ( +
  • + + + {tool.displayName || humanizeToolName(tool.name)} + {tool.status === 'running' ? '…' : ''} + +
  • + ))} +
+ )} +
+ ) +} + +export function resolveAgentStreamToolDisplayName(name: string): string { + return humanizeToolName(name) +} diff --git a/apps/sim/executor/execution/block-executor.test.ts b/apps/sim/executor/execution/block-executor.test.ts index 94d291a18c5..5c71be6c9a6 100644 --- a/apps/sim/executor/execution/block-executor.test.ts +++ b/apps/sim/executor/execution/block-executor.test.ts @@ -26,6 +26,14 @@ vi.mock('@/lib/uploads', () => ({ }, })) +vi.mock('@/lib/logs/execution/pii-redaction', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + redactObjectStrings: vi.fn(actual.redactObjectStrings), + } +}) + function createBlock(): SerializedBlock { return { id: 'function-block-1', @@ -380,3 +388,284 @@ describe('BlockExecutor', () => { expect(state.getBlockOutput(block.id)).toEqual(output) }) }) + +describe('BlockExecutor streaming pump (Step 3)', () => { + function createAgentBlock(): SerializedBlock { + return { + id: 'agent-block-1', + metadata: { id: BlockType.AGENT, name: 'Agent' }, + position: { x: 0, y: 0 }, + config: { tool: BlockType.AGENT, params: {} }, + inputs: {}, + outputs: {}, + enabled: true, + } + } + + function createExecutor(handler: BlockHandler) { + const block = createAgentBlock() + const workflow: SerializedWorkflow = { + version: '1', + blocks: [block], + connections: [], + loops: {}, + parallels: {}, + } + const state = new ExecutionState() + const resolver = new VariableResolver(workflow, {}, state) + const executor = new BlockExecutor( + [handler], + resolver, + { + workspaceId: 'workspace-1', + executionId: 'execution-1', + userId: 'user-1', + metadata: { + requestId: 'request-1', + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + userId: 'user-1', + triggerType: 'manual', + useDraftState: false, + startTime: new Date().toISOString(), + }, + }, + state + ) + return { executor, block, state } + } + + function createAgentEventsStreamingHandler(options: { + events: Array> + attachThinkingOnDrain?: string + failAfterText?: string + onFullContent?: (content: string) => void | Promise + }): BlockHandler { + return { + canHandle: () => true, + execute: async () => { + const timeSegment: Record = { + type: 'model', + name: 'claude-test', + startTime: Date.now(), + endTime: Date.now(), + duration: 1, + } + const output = { + content: '', + model: 'claude-test', + tokens: { input: 1, output: 2, total: 3 }, + providerTiming: { + startTime: new Date().toISOString(), + endTime: new Date().toISOString(), + duration: 1, + timeSegments: [timeSegment], + }, + cost: { input: 0, output: 0, total: 0 }, + } + + const stream = new ReadableStream({ + start(controller) { + if (options.failAfterText) { + controller.enqueue({ + type: 'text_delta', + text: options.failAfterText, + turn: 'final', + }) + controller.error(new Error('provider reset')) + return + } + for (const event of options.events) { + controller.enqueue(event) + } + if (options.attachThinkingOnDrain) { + timeSegment.thinkingContent = options.attachThinkingOnDrain + } + controller.close() + }, + }) + + return { + stream, + streamFormat: 'agent-events-v1' as const, + execution: { + success: true, + output, + logs: [], + metadata: { + startTime: new Date().toISOString(), + endTime: new Date().toISOString(), + duration: 1, + }, + }, + onFullContent: options.onFullContent, + } + }, + } + } + + it('projects answer text to onStream and content; sink gets full timeline', async () => { + const onFullContent = vi.fn() + const handler = createAgentEventsStreamingHandler({ + events: [ + { type: 'thinking_delta', text: 'hmm ' }, + { type: 'thinking_delta', text: 'yes' }, + { type: 'text_delta', text: 'Hello ', turn: 'final' }, + { type: 'text_delta', text: 'world', turn: 'final' }, + ], + attachThinkingOnDrain: 'hmm yes', + onFullContent, + }) + const { executor, block, state } = createExecutor(handler) + const ctx = createContext(state) + const forwarded: string[] = [] + const sinkEvents: Array> = [] + + ctx.onStream = async (streamingExec) => { + expect(streamingExec.streamFormat).toBe('text') + streamingExec.subscribe?.({ + onEvent: async (event) => { + sinkEvents.push(event as Record) + }, + }) + const reader = streamingExec.stream.getReader() + const decoder = new TextDecoder() + while (true) { + const { done, value } = await reader.read() + if (done) break + forwarded.push(decoder.decode(value, { stream: true })) + } + } + + await executor.execute(ctx, createNode(block), block) + + expect(forwarded.join('')).toBe('Hello world') + expect(state.getBlockOutput(block.id)?.content).toBe('Hello world') + expect(onFullContent).toHaveBeenCalledWith('Hello world') + expect(sinkEvents).toEqual([ + { type: 'thinking_delta', text: 'hmm ' }, + { type: 'thinking_delta', text: 'yes' }, + { type: 'text_delta', text: 'Hello ', turn: 'final' }, + { type: 'text_delta', text: 'world', turn: 'final' }, + ]) + expect( + state.getBlockOutput(block.id)?.providerTiming?.timeSegments?.[0]?.thinkingContent + ).toBe('hmm yes') + }) + + it('drains without onStream and still persists answer content', async () => { + const handler = createAgentEventsStreamingHandler({ + events: [{ type: 'text_delta', text: 'offline answer', turn: 'final' }], + }) + const { executor, block, state } = createExecutor(handler) + const ctx = createContext(state) + + await executor.execute(ctx, createNode(block), block) + + expect(state.getBlockOutput(block.id)?.content).toBe('offline answer') + }) + + it('throws on mid-stream provider error (no truncated success)', async () => { + const handler = createAgentEventsStreamingHandler({ + failAfterText: 'partial', + }) + const { executor, block, state } = createExecutor(handler) + const ctx = createContext(state) + ctx.onStream = async (streamingExec) => { + const reader = streamingExec.stream.getReader() + try { + while (true) { + const { done } = await reader.read() + if (done) break + } + } catch { + // consumer may see the error; block must still fail + } + } + + await expect(executor.execute(ctx, createNode(block), block)).rejects.toThrow('provider reset') + expect(state.getBlockOutput(block.id)?.content).not.toBe('partial') + }) + + it('soft-completes on user abort with drained answer text (no failed block)', async () => { + const abortController = new AbortController() + const handler = createAgentEventsStreamingHandler({ + events: [ + { type: 'text_delta', text: 'partial answer', turn: 'final' }, + { type: 'thinking_delta', text: 'more' }, + ], + }) + // Override execute to abort mid-stream after first text chunk is projected. + const originalExecute = handler.execute.bind(handler) + handler.execute = async (ctx, block, inputs) => { + const result = (await originalExecute(ctx, block, inputs)) as { + stream: ReadableStream + streamFormat: 'agent-events-v1' + execution: { success: boolean; output: Record; logs: unknown[]; metadata: Record } + } + // Abort after a tick so the pump can project the first text_delta. + queueMicrotask(() => abortController.abort('user')) + return result + } + + const { executor, block, state } = createExecutor(handler) + const ctx = createContext(state) + ctx.abortSignal = abortController.signal + ctx.onStream = async (streamingExec) => { + streamingExec.subscribe?.({ onEvent: async () => {} }) + const reader = streamingExec.stream.getReader() + try { + while (true) { + const { done } = await reader.read() + if (done) break + } + } catch { + // abort may cancel the text stream + } + } + + await executor.execute(ctx, createNode(block), block) + + const output = state.getBlockOutput(block.id) + expect(output?.error).toBeUndefined() + // May or may not have content depending on race; must not be a failed error output. + expect(output).not.toMatchObject({ error: expect.any(String) }) + }) + + it('with PII redaction: no live forward and strips thinking from traces', async () => { + const { redactObjectStrings } = await import('@/lib/logs/execution/pii-redaction') + vi.mocked(redactObjectStrings).mockImplementation(async (value) => { + if (typeof value === 'string') { + return `[masked]${value}` as never + } + // Object walk is exercised elsewhere; keep streaming-stage string mask as-is. + return value as never + }) + + const handler = createAgentEventsStreamingHandler({ + events: [ + { type: 'thinking_delta', text: 'secret thought' }, + { type: 'text_delta', text: 'alice@example.com said hi', turn: 'final' }, + ], + attachThinkingOnDrain: 'secret thought', + }) + const { executor, block, state } = createExecutor(handler) + const ctx = createContext(state) + const onStream = vi.fn() + ctx.onStream = onStream + ctx.piiBlockOutputRedaction = { + enabled: true, + entityTypes: ['EMAIL_ADDRESS'], + language: 'en', + } + + await executor.execute(ctx, createNode(block), block) + + expect(onStream).not.toHaveBeenCalled() + expect(state.getBlockOutput(block.id)?.content).toBe('[masked]alice@example.com said hi') + expect( + state.getBlockOutput(block.id)?.providerTiming?.timeSegments?.[0]?.thinkingContent + ).toBeUndefined() + }) +}) diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index 29aef72d49e..1dd2466557e 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -48,6 +48,7 @@ import { getIterationContext, } from '@/executor/utils/iteration-context' import { isJSONString } from '@/executor/utils/json' +import { createAgentStreamPump } from '@/providers/stream-pump' import { filterOutputForLog } from '@/executor/utils/output-filter' import { buildBranchNodeId, @@ -188,22 +189,18 @@ export class BlockExecutor { if (isStreamingExecution) { const streamingExec = output as StreamingExecution - // The stream must still be drained to populate `execution.output`, but - // forwarding raw chunks to the client (or persisting them to memory) - // before redaction would leak PII. When block-output redaction is on we - // drain in buffer-only mode (no `onStream`, content masked before it's - // stored); the masked final output reaches the client via block-complete. - if (ctx.onStream) { - await this.handleStreamingExecution( - ctx, - node, - block, - streamingExec, - resolvedInputs, - normalizeStringArray(ctx.selectedOutputs), - !ctx.piiBlockOutputRedaction?.enabled - ) - } + // Always drain via the agent stream pump (tokens/cost/timing callbacks), + // even with no `onStream`. When block-output redaction is on we do not + // live-forward chunks; content is masked before persist and the masked + // final output reaches the client via block-complete. + await this.handleStreamingExecution( + ctx, + node, + block, + streamingExec, + resolvedInputs, + normalizeStringArray(ctx.selectedOutputs) + ) normalizedOutput = this.normalizeOutput( streamingExec.execution.output ?? streamingExec.execution @@ -382,6 +379,51 @@ export class BlockExecutor { ? inputsForLog : ((block.config?.params as Record | undefined) ?? {}) + // Routine user Stop: don't paint a failed agent block (workflow is already cancelled). + // Timeouts abort with reason `'timeout'` (see createTimeoutAbortController). + const isAbort = + (error instanceof DOMException && error.name === 'AbortError') || + (error instanceof Error && error.name === 'AbortError') + const isTimeout = ctx.abortSignal?.reason === 'timeout' + if (isAbort && !isTimeout && ctx.abortSignal?.aborted) { + const softOutput: NormalizedBlockOutput = { + content: '', + } + + this.setNodeOutput(node, softOutput, duration) + + if (blockLog) { + blockLog.endedAt = endedAt + blockLog.durationMs = duration + blockLog.success = true + blockLog.error = undefined + blockLog.input = this.sanitizeInputsForLog(input, block.metadata?.id) + blockLog.output = filterOutputForLog(block.metadata?.id || '', softOutput, { block }) + } + + this.execLogger.info('Block stream aborted by client; soft-completing', { + blockId: node.id, + blockType: block.metadata?.id, + }) + + if (!isSentinel && blockLog) { + this.fireBlockCompleteCallback( + blockStartPromise, + ctx, + node, + block, + this.sanitizeInputsForLog(input, block.metadata?.id), + filterOutputForLog(block.metadata?.id || '', softOutput, { block }), + duration, + blockLog.startedAt, + blockLog.executionOrder, + blockLog.endedAt + ) + } + + return softOutput + } + const errorOutput: NormalizedBlockOutput = { error: errorMessage, } @@ -773,119 +815,113 @@ export class BlockExecutor { block: SerializedBlock, streamingExec: StreamingExecution, resolvedInputs: Record, - selectedOutputs: string[], - forwardToClient = true + selectedOutputs: string[] ): Promise { const blockId = node.id + const piiEnabled = Boolean(ctx.piiBlockOutputRedaction?.enabled) + // Live-forward only when a client stream exists and PII redaction is off. + const forwardToClient = Boolean(ctx.onStream) && !piiEnabled const responseFormat = resolvedInputs?.responseFormat ?? (block.config?.params as Record | undefined)?.responseFormat ?? (block.config as Record | undefined)?.responseFormat - const sourceReader = streamingExec.stream.getReader() - const decoder = new TextDecoder() - const accumulated: string[] = [] - let drainError: unknown - let sourceFullyDrained = false - - if (forwardToClient) { - const clientSource = new ReadableStream({ - async pull(controller) { - try { - const { done, value } = await sourceReader.read() - if (done) { - const tail = decoder.decode() - if (tail) accumulated.push(tail) - sourceFullyDrained = true - controller.close() - return - } - accumulated.push(decoder.decode(value, { stream: true })) - controller.enqueue(value) - } catch (error) { - drainError = error - controller.error(error) - } - }, - async cancel(reason) { - try { - await sourceReader.cancel(reason) - } catch {} - }, - }) + const streamFormat = streamingExec.streamFormat ?? 'text' + const pump = createAgentStreamPump({ + source: streamingExec.stream, + streamFormat, + // No live consumer → sink-mode so we never buffer into an unread text stream. + sinkMode: !forwardToClient, + abortSignal: ctx.abortSignal, + }) - const processedClientStream = streamingResponseFormatProcessor.processStream( - clientSource, + let onStreamPromise: Promise | undefined + let processedClientStream: ReadableStream | undefined + + if (forwardToClient && ctx.onStream && pump.textStream) { + processedClientStream = streamingResponseFormatProcessor.processStream( + pump.textStream, blockId, selectedOutputs, responseFormat ) - try { - await ctx.onStream?.({ + // Start onStream without awaiting so a sync `subscribe(sink)` can run before + // the first provider pull, then read the projected text stream concurrently + // with `pump.run()`. + onStreamPromise = ctx + .onStream({ + ...streamingExec, stream: processedClientStream, - execution: streamingExec.execution, + streamFormat: 'text', + subscribe: pump.subscribe, }) - } catch (error) { - this.execLogger.error('Error in onStream callback', { blockId, error }) - await processedClientStream.cancel().catch(() => {}) - } finally { - try { - sourceReader.releaseLock() - } catch {} - } - } else { - // Buffer-only drain: consume the source so `execution.output` is complete, - // but never forward raw chunks to the client (block-output redaction is on). - try { - while (true) { - const { done, value } = await sourceReader.read() - if (done) { - const tail = decoder.decode() - if (tail) accumulated.push(tail) - sourceFullyDrained = true - break - } - accumulated.push(decoder.decode(value, { stream: true })) - } - } catch (error) { - drainError = error - } finally { - try { - sourceReader.releaseLock() - } catch {} + .catch(async (error) => { + this.execLogger.error('Error in onStream callback', { blockId, error }) + await processedClientStream?.cancel().catch(() => {}) + }) + } + + let pumpResult + try { + pumpResult = await pump.run() + } catch (error) { + this.execLogger.error('Error reading stream for block', { blockId, error }) + if (onStreamPromise) { + await onStreamPromise.catch(() => {}) } + throw error instanceof Error ? error : new Error(String(error)) + } + + if (onStreamPromise) { + await onStreamPromise + } + + // Timeout still fails the block. User Stop soft-completes with any drained + // answer text so logs don't show a scary red agent block for a routine cancel + // (workflow status remains `cancelled` via the engine abort flag). + if (pumpResult.cancelled && pumpResult.cancelReason === 'timeout') { + throw new DOMException('Provider request timed out', 'AbortError') } - if (drainError) { - this.execLogger.error('Error reading stream for block', { blockId, error: drainError }) + // Provider onComplete may have attached thinking to timing segments during drain. + // Under PII redaction, never retain raw thinking in traces. + if (piiEnabled) { + stripThinkingContentFromOutput(streamingExec.execution?.output) + } + + // User/unknown cancel: persist truncated answer when present, then return. + if (pumpResult.cancelled) { + const truncated = pumpResult.answerText + if (truncated && streamingExec.execution?.output) { + streamingExec.execution.output.content = truncated + } + this.execLogger.info('Stream cancelled by client; soft-completing agent block', { + blockId, + cancelReason: pumpResult.cancelReason, + hasContent: Boolean(truncated), + }) return } - // If the onStream consumer exited before the source drained (e.g. it caught - // an internal error and returned normally), `accumulated` holds a truncated - // response. Persisting that to memory or setting it as the block output - // would corrupt downstream state — skip and log instead. - if (!sourceFullyDrained) { + // If the pump did not fully drain (should be rare when not cancelled), skip + // persistence of potentially truncated answer text. + if (!pumpResult.fullyDrained) { this.execLogger.warn( 'Stream consumer exited before source drained; skipping content persistence', - { - blockId, - } + { blockId } ) return } - let fullContent = accumulated.join('') + let fullContent = pumpResult.answerText if (!fullContent) { return } - if (!forwardToClient && ctx.piiBlockOutputRedaction?.enabled) { - // Mask before the content is written to `execution.output` or persisted to - // memory via `onFullContent`, so the streamed agent response can't leak PII - // through either path. The block-output redaction below is then idempotent. + if (piiEnabled && ctx.piiBlockOutputRedaction) { + // Mask before writing to `execution.output` or `onFullContent`. fullContent = await redactObjectStrings(fullContent, { entityTypes: ctx.piiBlockOutputRedaction.entityTypes, language: ctx.piiBlockOutputRedaction.language, @@ -929,3 +965,16 @@ export class BlockExecutor { } } } + +/** Removes retained thinking from provider timing segments (PII safe default). */ +function stripThinkingContentFromOutput(output: unknown): void { + if (!output || typeof output !== 'object') return + const providerTiming = (output as { providerTiming?: { timeSegments?: unknown } }).providerTiming + const segments = providerTiming?.timeSegments + if (!Array.isArray(segments)) return + for (const segment of segments) { + if (segment && typeof segment === 'object' && 'thinkingContent' in segment) { + delete (segment as { thinkingContent?: string }).thinkingContent + } + } +} diff --git a/apps/sim/executor/execution/types.ts b/apps/sim/executor/execution/types.ts index b93ad107c5c..9ebda7cd0ca 100644 --- a/apps/sim/executor/execution/types.ts +++ b/apps/sim/executor/execution/types.ts @@ -45,6 +45,11 @@ export interface ExecutionMetadata { callChain?: string[] correlation?: AsyncExecutionCorrelation executionMode?: 'sync' | 'stream' | 'async' + /** + * Deployed-chat dual gate for thinking SSE. Persisted so HITL resume can + * re-enable thinking frames without hardcoding false. + */ + includeThinking?: boolean } export interface SerializableExecutionState { diff --git a/apps/sim/executor/handlers/agent/agent-handler.test.ts b/apps/sim/executor/handlers/agent/agent-handler.test.ts index 6990a719bf2..5ee3e2dad31 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.test.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.test.ts @@ -2243,4 +2243,38 @@ describe('AgentBlockHandler', () => { }) }) }) + + describe('wrapStreamForMemoryPersistence envelope (Step 3)', () => { + it('preserves streamFormat and subscribe via object spread', () => { + const handler = new AgentBlockHandler() + const subscribe = vi.fn() + const streamingExec: StreamingExecution = { + stream: new ReadableStream(), + streamFormat: 'agent-events-v1', + subscribe, + execution: { + success: true, + output: { content: '' }, + logs: [], + metadata: { startTime: '', endTime: '', duration: 0 }, + }, + } + + const wrapped = ( + handler as unknown as { + wrapStreamForMemoryPersistence: ( + ctx: ExecutionContext, + inputs: Record, + exec: StreamingExecution + ) => StreamingExecution + } + ).wrapStreamForMemoryPersistence({} as ExecutionContext, {}, streamingExec) + + expect(wrapped.streamFormat).toBe('agent-events-v1') + expect(wrapped.subscribe).toBe(subscribe) + expect(wrapped.stream).toBe(streamingExec.stream) + expect(wrapped.execution).toBe(streamingExec.execution) + expect(typeof wrapped.onFullContent).toBe('function') + }) + }) }) diff --git a/apps/sim/executor/handlers/agent/agent-handler.ts b/apps/sim/executor/handlers/agent/agent-handler.ts index 2373b8e6527..533dd336fdb 100644 --- a/apps/sim/executor/handlers/agent/agent-handler.ts +++ b/apps/sim/executor/handlers/agent/agent-handler.ts @@ -952,6 +952,19 @@ export class AgentBlockHandler implements BlockHandler { verbosity: inputs.verbosity, thinkingLevel: inputs.thinkingLevel, previousInteractionId: inputs.previousInteractionId, + // Live tool lifecycle on agent-events stream. Only providers with a real + // streaming tool loop (Step 7/9) — others still get agent-events on text + // streams without streamToolCalls. + streamToolCalls: + streaming && + formattedTools.length > 0 && + (providerId === 'anthropic' || + providerId === 'azure-anthropic' || + providerId === 'groq' || + providerId === 'deepseek' || + providerId === 'google' || + providerId === 'vertex' || + providerId === 'bedrock'), } } @@ -1025,6 +1038,7 @@ export class AgentBlockHandler implements BlockHandler { verbosity: providerRequest.verbosity, thinkingLevel: providerRequest.thinkingLevel, previousInteractionId: providerRequest.previousInteractionId, + streamToolCalls: providerRequest.streamToolCalls, abortSignal: ctx.abortSignal, }) @@ -1084,8 +1098,7 @@ export class AgentBlockHandler implements BlockHandler { streamingExec: StreamingExecution ): StreamingExecution { return { - stream: streamingExec.stream, - execution: streamingExec.execution, + ...streamingExec, onFullContent: async (content: string) => { if (!content.trim()) return try { diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index babb2a4e6c6..8f0c9dcf0cd 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -10,6 +10,10 @@ import type { SerializableExecutionState, } from '@/executor/execution/types' import type { RunFromBlockContext } from '@/executor/utils/run-from-block' +import type { + AgentStreamSink, + UnsubscribeAgentStreamSink, +} from '@/providers/stream-events' import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' import type { SubflowType } from '@/stores/workflows/workflow/types' @@ -497,7 +501,25 @@ export interface ExecutionResult { } export interface StreamingExecution { + /** + * Provider stream payload. Format is declared by {@link streamFormat}: + * - `'text'` (default): UTF-8 answer bytes (`ReadableStream`) + * - `'agent-events-v1'`: in-process `ReadableStream` of `AgentStreamEvent` objects + * + * Never sniff the payload; always read {@link streamFormat}. + * After the executor pump, {@link stream} is always projected UTF-8 answer text. + */ stream: ReadableStream + /** + * Discriminator for {@link stream}. Defaults to `'text'` when omitted so + * existing providers remain byte-stream consumers without changes. + */ + streamFormat?: 'text' | 'agent-events-v1' + /** + * Optional sink subscription installed synchronously during `onStream` before + * the executor pump starts draining. Late subscribers receive future events only. + */ + subscribe?: (sink: AgentStreamSink) => UnsubscribeAgentStreamSink execution: ExecutionResult & { isStreaming?: boolean } /** * Invoked with the assembled response text after the stream drains. Lets agent diff --git a/apps/sim/hooks/queries/chats.ts b/apps/sim/hooks/queries/chats.ts index 1c1da29faa1..046bfcc9dbb 100644 --- a/apps/sim/hooks/queries/chats.ts +++ b/apps/sim/hooks/queries/chats.ts @@ -175,6 +175,8 @@ export interface ChatFormData { emails: string[] welcomeMessage: string selectedOutputBlocks: string[] + /** When true, thinking may be streamed to clients that opt into agent-events-v1. Default false. */ + includeThinking: boolean } /** @@ -263,6 +265,7 @@ function buildChatPayload( allowedEmails: formData.authType === 'email' || formData.authType === 'sso' ? formData.emails : [], outputConfigs, + includeThinking: formData.includeThinking, } } diff --git a/apps/sim/hooks/use-execution-stream.test.ts b/apps/sim/hooks/use-execution-stream.test.ts index f38f028c805..eae38ac3028 100644 --- a/apps/sim/hooks/use-execution-stream.test.ts +++ b/apps/sim/hooks/use-execution-stream.test.ts @@ -53,6 +53,61 @@ describe('processSSEStream', () => { expect(order).toEqual(['handler:start', 'handler:end', 'event-id']) }) + it('routes stream:thinking and stream:tool without requiring event ids', async () => { + const onStreamThinking = vi.fn() + const onStreamTool = vi.fn() + const onStreamChunk = vi.fn() + const onEventId = vi.fn() + + const events: ExecutionEvent[] = [ + { + type: 'stream:thinking', + timestamp: new Date().toISOString(), + executionId: 'exec-1', + workflowId: 'wf-1', + data: { blockId: 'agent-1', data: 'reasoning ' }, + }, + { + type: 'stream:tool', + timestamp: new Date().toISOString(), + executionId: 'exec-1', + workflowId: 'wf-1', + data: { + blockId: 'agent-1', + phase: 'start', + id: 'tool_1', + name: 'http_request', + }, + }, + { + type: 'stream:chunk', + timestamp: new Date().toISOString(), + executionId: 'exec-1', + workflowId: 'wf-1', + data: { blockId: 'agent-1', chunk: 'answer' }, + }, + ] + + await processSSEStream( + streamEvents(events).getReader(), + { onStreamThinking, onStreamTool, onStreamChunk, onEventId }, + 'test' + ) + + expect(onStreamThinking).toHaveBeenCalledWith({ + blockId: 'agent-1', + data: 'reasoning ', + }) + expect(onStreamTool).toHaveBeenCalledWith({ + blockId: 'agent-1', + phase: 'start', + id: 'tool_1', + name: 'http_request', + }) + expect(onStreamChunk).toHaveBeenCalledWith({ blockId: 'agent-1', chunk: 'answer' }) + expect(onEventId).not.toHaveBeenCalled() + }) + it('propagates callback failures without acknowledging the event id', async () => { const event: ExecutionEvent = { type: 'block:started', diff --git a/apps/sim/hooks/use-execution-stream.ts b/apps/sim/hooks/use-execution-stream.ts index ffe862c4710..c00242df988 100644 --- a/apps/sim/hooks/use-execution-stream.ts +++ b/apps/sim/hooks/use-execution-stream.ts @@ -15,6 +15,8 @@ import type { ExecutionStartedData, StreamChunkData, StreamDoneData, + StreamThinkingData, + StreamToolData, } from '@/lib/workflows/executor/execution-events' import type { SerializableExecutionState } from '@/executor/execution/types' @@ -124,6 +126,12 @@ export async function processSSEStream( case 'stream:done': await callbacks.onStreamDone?.(event.data) break + case 'stream:thinking': + await callbacks.onStreamThinking?.(event.data) + break + case 'stream:tool': + await callbacks.onStreamTool?.(event.data) + break default: logger.warn('Unknown event type:', (event as any).type) } @@ -165,6 +173,8 @@ export interface ExecutionStreamCallbacks { onBlockChildWorkflowStarted?: (data: BlockChildWorkflowStartedData) => void | Promise onStreamChunk?: (data: StreamChunkData) => void | Promise onStreamDone?: (data: StreamDoneData) => void | Promise + onStreamThinking?: (data: StreamThinkingData) => void | Promise + onStreamTool?: (data: StreamToolData) => void | Promise onEventId?: (eventId: number) => void | Promise } diff --git a/apps/sim/lib/api/contracts/chats.include-thinking.test.ts b/apps/sim/lib/api/contracts/chats.include-thinking.test.ts new file mode 100644 index 00000000000..903e99e0baa --- /dev/null +++ b/apps/sim/lib/api/contracts/chats.include-thinking.test.ts @@ -0,0 +1,76 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + createChatBodySchema, + deployedChatConfigSchema, + updateChatBodySchema, +} from '@/lib/api/contracts/chats' +import { chatDetailSchema } from '@/lib/api/contracts/deployments' + +describe('chat includeThinking contracts (Step 4)', () => { + it('create defaults includeThinking to false', () => { + const parsed = createChatBodySchema.parse({ + workflowId: 'wf-1', + identifier: 'my-chat', + title: 'Support', + customizations: { + primaryColor: 'var(--brand-hover)', + welcomeMessage: 'Hi', + }, + }) + expect(parsed.includeThinking).toBe(false) + }) + + it('create accepts includeThinking true', () => { + const parsed = createChatBodySchema.parse({ + workflowId: 'wf-1', + identifier: 'my-chat', + title: 'Support', + customizations: { + primaryColor: 'var(--brand-hover)', + welcomeMessage: 'Hi', + }, + includeThinking: true, + }) + expect(parsed.includeThinking).toBe(true) + }) + + it('update accepts includeThinking toggle', () => { + expect(updateChatBodySchema.parse({ includeThinking: true }).includeThinking).toBe(true) + expect(updateChatBodySchema.parse({ includeThinking: false }).includeThinking).toBe(false) + expect(updateChatBodySchema.parse({ title: 'x' }).includeThinking).toBeUndefined() + }) + + it('chat detail and deployed config expose includeThinking (default false)', () => { + const detail = chatDetailSchema.parse({ + id: 'chat-1', + identifier: 'my-chat', + title: 'Support', + description: '', + authType: 'public', + allowedEmails: [], + outputConfigs: [], + isActive: true, + chatUrl: 'http://localhost/chat/my-chat', + hasPassword: false, + }) + expect(detail.includeThinking).toBe(false) + + const detailOn = chatDetailSchema.parse({ + ...detail, + includeThinking: true, + }) + expect(detailOn.includeThinking).toBe(true) + + const config = deployedChatConfigSchema.parse({ + id: 'chat-1', + title: 'Support', + description: '', + customizations: {}, + authType: 'public', + }) + expect(config.includeThinking).toBe(false) + }) +}) diff --git a/apps/sim/lib/api/contracts/chats.ts b/apps/sim/lib/api/contracts/chats.ts index d0840bca8a5..4e22ef7c5d7 100644 --- a/apps/sim/lib/api/contracts/chats.ts +++ b/apps/sim/lib/api/contracts/chats.ts @@ -41,6 +41,8 @@ export const createChatBodySchema = z.object({ password: z.string().optional(), allowedEmails: z.array(z.string()).optional().default([]), outputConfigs: z.array(chatOutputConfigSchema).optional().default([]), + /** When true, clients may receive thinking SSE if they also send the protocol header. Default off. */ + includeThinking: z.boolean().optional().default(false), }) export type CreateChatBody = z.input @@ -58,6 +60,7 @@ export const updateChatBodySchema = z.object({ password: z.string().optional(), allowedEmails: z.array(z.string()).optional(), outputConfigs: z.array(chatOutputConfigSchema).optional(), + includeThinking: z.boolean().optional(), }) export type UpdateChatBody = z.input @@ -101,6 +104,8 @@ export const deployedChatConfigSchema = z.object({ (value) => value ?? undefined, z.array(deployedChatOutputConfigSchema).optional() ), + /** Policy for thinking SSE; clients still need X-Sim-Stream-Protocol opt-in (Step 5). */ + includeThinking: z.preprocess((value) => value ?? false, z.boolean()), }) export type DeployedChatConfig = z.output @@ -209,8 +214,10 @@ export const deployedChatPostContract = defineRouteContract({ params: chatIdentifierParamsSchema, body: deployedChatPostBodySchema, response: { - mode: 'json', - schema: deployedChatConfigSchema, + // Message posts return SSE (`text/event-stream`). Auth-only POSTs use + // authenticateDeployedChatContract (JSON). Terminal frames: `final` or one + // `error`, then `[DONE]`. Thinking frames require includeThinking + protocol header. + mode: 'stream', }, }) diff --git a/apps/sim/lib/api/contracts/deployments.ts b/apps/sim/lib/api/contracts/deployments.ts index 1b9cd57db72..a807c31c573 100644 --- a/apps/sim/lib/api/contracts/deployments.ts +++ b/apps/sim/lib/api/contracts/deployments.ts @@ -128,6 +128,7 @@ export const chatDetailSchema = z.object({ }) ) ), + includeThinking: z.preprocess((value) => value ?? false, z.boolean()), customizations: z.preprocess( (value) => value ?? undefined, z diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts index b47614c19c0..282e3cd5786 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts @@ -301,6 +301,7 @@ export async function executeDeployChat( allowedEmails: (existing[0].allowedEmails as string[]) || [], outputConfigs: (existing[0].outputConfigs as Array<{ blockId: string; path: string }>) || [], + includeThinking: existing[0].includeThinking ?? false, welcomeMessage: (existing[0].customizations as { welcomeMessage?: string } | null) ?.welcomeMessage || 'Hi there! How can I help you today?', @@ -388,6 +389,10 @@ export async function executeDeployChat( blockId: string path: string }> + const resolvedIncludeThinking = + typeof params.includeThinking === 'boolean' + ? params.includeThinking + : (existingDeployment?.includeThinking ?? false) const welcomeMessage = typeof params.welcomeMessage === 'string' ? params.welcomeMessage @@ -417,6 +422,7 @@ export async function executeDeployChat( password: params.password, allowedEmails: resolvedAllowedEmails, outputConfigs: resolvedOutputConfigs, + includeThinking: resolvedIncludeThinking, workspaceId: workflowRecord.workspaceId, }) @@ -469,6 +475,7 @@ export async function executeDeployChat( authType: resolvedAuthType, allowedEmails: resolvedAllowedEmails, outputConfigs: resolvedOutputConfigs, + includeThinking: resolvedIncludeThinking, welcomeMessage: welcomeMessage || 'Hi there! How can I help you today?', primaryColor: params.customizations?.primaryColor || diff --git a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts index d3c1c3df7db..e0e0cc66452 100644 --- a/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts @@ -57,6 +57,7 @@ export async function executeCheckDeploymentStatus( authType: chat.authType, allowedEmails: chat.allowedEmails, outputConfigs: chat.outputConfigs, + includeThinking: chat.includeThinking, password: chat.password, customizations: chat.customizations, }) @@ -90,6 +91,7 @@ export async function executeCheckDeploymentStatus( authType: chatDeploy[0]?.authType || null, allowedEmails: chatDeploy[0]?.allowedEmails || null, outputConfigs: chatDeploy[0]?.outputConfigs || null, + includeThinking: chatDeploy[0]?.includeThinking ?? false, welcomeMessage: chatCustomizations.welcomeMessage || null, primaryColor: chatCustomizations.primaryColor || null, hasPassword: Boolean(chatDeploy[0]?.password), diff --git a/apps/sim/lib/copilot/tools/handlers/param-types.ts b/apps/sim/lib/copilot/tools/handlers/param-types.ts index 19eae663ba8..d72db002878 100644 --- a/apps/sim/lib/copilot/tools/handlers/param-types.ts +++ b/apps/sim/lib/copilot/tools/handlers/param-types.ts @@ -156,6 +156,7 @@ export interface DeployChatParams { subdomain?: string allowedEmails?: string[] outputConfigs?: unknown[] + includeThinking?: boolean } export interface DeployMcpParams { diff --git a/apps/sim/lib/core/execution-limits/types.test.ts b/apps/sim/lib/core/execution-limits/types.test.ts index 0b61b5a3efd..ec4bc2b16c2 100644 --- a/apps/sim/lib/core/execution-limits/types.test.ts +++ b/apps/sim/lib/core/execution-limits/types.test.ts @@ -66,4 +66,27 @@ describe('getExecutionTimeout', () => { vi.useRealTimers() } }) + + it('aborts with reason "timeout" when the timer fires', () => { + vi.useFakeTimers() + try { + const controller = createTimeoutAbortController(1000) + vi.advanceTimersByTime(1000) + expect(controller.signal.aborted).toBe(true) + expect(controller.isTimedOut()).toBe(true) + expect(controller.signal.reason).toBe('timeout') + controller.cleanup() + } finally { + vi.useRealTimers() + } + }) + + it('manual abort uses reason "user"', () => { + const controller = createTimeoutAbortController(60_000) + controller.abort() + expect(controller.signal.aborted).toBe(true) + expect(controller.isTimedOut()).toBe(false) + expect(controller.signal.reason).toBe('user') + controller.cleanup() + }) }) diff --git a/apps/sim/lib/core/execution-limits/types.ts b/apps/sim/lib/core/execution-limits/types.ts index d11ddc22892..0aac6ab4149 100644 --- a/apps/sim/lib/core/execution-limits/types.ts +++ b/apps/sim/lib/core/execution-limits/types.ts @@ -152,7 +152,8 @@ export function createTimeoutAbortController(timeoutMs?: number): TimeoutAbortCo if (timeoutMs) { timeoutId = setTimeout(() => { isTimedOut = true - abortController.abort() + // Typed reason so pumps/executors can distinguish timeout from user Stop. + abortController.abort('timeout') }, timeoutMs) } @@ -162,7 +163,8 @@ export function createTimeoutAbortController(timeoutMs?: number): TimeoutAbortCo cleanup: () => { if (timeoutId) clearTimeout(timeoutId) }, - abort: () => abortController.abort(), + // Manual abort is user/client cancellation (disconnect, Stop, registerManualExecutionAborter). + abort: () => abortController.abort('user'), timeoutMs, } } diff --git a/apps/sim/lib/workflows/executor/execute-workflow.ts b/apps/sim/lib/workflows/executor/execute-workflow.ts index 43408586eed..50d1da9ce7a 100644 --- a/apps/sim/lib/workflows/executor/execute-workflow.ts +++ b/apps/sim/lib/workflows/executor/execute-workflow.ts @@ -54,6 +54,8 @@ export interface ExecuteWorkflowOptions { executionMode?: 'sync' | 'stream' | 'async' /** Immutable actor/payer decision captured by preprocessing. */ billingAttribution?: BillingAttributionSnapshot + /** Deployed-chat thinking policy; persisted on the snapshot for resume. */ + includeThinking?: boolean } export interface WorkflowInfo { @@ -111,6 +113,7 @@ export async function executeWorkflow( largeValueKeys: streamConfig?.largeValueKeys, fileKeys: streamConfig?.fileKeys, executionMode: streamConfig?.executionMode, + includeThinking: streamConfig?.includeThinking === true ? true : undefined, } const snapshot = new ExecutionSnapshot( diff --git a/apps/sim/lib/workflows/executor/execution-events.ts b/apps/sim/lib/workflows/executor/execution-events.ts index d7212e8c1ed..761e2b4402f 100644 --- a/apps/sim/lib/workflows/executor/execution-events.ts +++ b/apps/sim/lib/workflows/executor/execution-events.ts @@ -18,6 +18,10 @@ export type ExecutionEventType = | 'block:childWorkflowStarted' | 'stream:chunk' | 'stream:done' + /** Live-only agent thinking delta (not buffered for reconnect replay). */ + | 'stream:thinking' + /** Live-only tool lifecycle (not buffered for reconnect replay). */ + | 'stream:tool' /** * Base event structure for SSE @@ -219,6 +223,36 @@ interface StreamDoneEvent extends BaseExecutionEvent { } } +/** + * Live thinking delta from an agent-events provider sink (canvas / draft runs). + * Builder runs show provider-exposed signals when the sink is attached + * (executor already disables the sink under PII redaction). + */ +interface StreamThinkingEvent extends BaseExecutionEvent { + type: 'stream:thinking' + workflowId: string + data: { + blockId: string + data: string + } +} + +/** + * Live tool lifecycle from an agent-events provider sink. + * Name + status only — never args or results. + */ +interface StreamToolEvent extends BaseExecutionEvent { + type: 'stream:tool' + workflowId: string + data: { + blockId: string + phase: 'start' | 'end' + id: string + name: string + status?: 'success' | 'error' | 'cancelled' + } +} + /** * Union type of all execution events */ @@ -234,6 +268,8 @@ export type ExecutionEvent = | BlockChildWorkflowStartedEvent | StreamChunkEvent | StreamDoneEvent + | StreamThinkingEvent + | StreamToolEvent export type ExecutionStartedData = ExecutionStartedEvent['data'] export type ExecutionCompletedData = ExecutionCompletedEvent['data'] @@ -246,6 +282,8 @@ export type BlockErrorData = BlockErrorEvent['data'] export type BlockChildWorkflowStartedData = BlockChildWorkflowStartedEvent['data'] export type StreamChunkData = StreamChunkEvent['data'] export type StreamDoneData = StreamDoneEvent['data'] +export type StreamThinkingData = StreamThinkingEvent['data'] +export type StreamToolData = StreamToolEvent['data'] /** * Helper to create SSE formatted message diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts index 343bed6a853..9df3a818cdf 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts @@ -26,6 +26,7 @@ import { LoggingSession } from '@/lib/logs/execution/logging-session' import { cleanupExecutionBase64Cache } from '@/lib/uploads/utils/user-file-base64.server' import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core' import type { ExecutionEvent } from '@/lib/workflows/executor/execution-events' +import { attachAgentStreamSink } from '@/lib/workflows/streaming/attach-agent-stream-sink' import { createPausedExecutionResumeMetadata, parsePausedExecutionResumeMetadata, @@ -1269,7 +1270,11 @@ export class PauseResumeManager { event: ExecutionEvent, terminalStatus?: TerminalExecutionStreamStatus ) => { - const isBuffered = event.type !== 'stream:chunk' && event.type !== 'stream:done' + const isBuffered = + event.type !== 'stream:chunk' && + event.type !== 'stream:done' && + event.type !== 'stream:thinking' && + event.type !== 'stream:tool' if (isBuffered) { const entry = terminalStatus ? await eventWriter.writeTerminal(event, terminalStatus).catch((error) => { @@ -1411,6 +1416,37 @@ export class PauseResumeManager { ? streamingExec.execution.blockId : undefined const blockId = typeof blockIdValue === 'string' ? blockIdValue : '' + + const unsubscribe = attachAgentStreamSink(streamingExec, { + onThinkingDelta: async (text) => { + await writeBufferedEvent({ + type: 'stream:thinking', + timestamp: new Date().toISOString(), + executionId: resumeExecutionId, + workflowId, + data: { blockId, data: text }, + } as ExecutionEvent) + }, + onToolCallStart: async (id, name) => { + await writeBufferedEvent({ + type: 'stream:tool', + timestamp: new Date().toISOString(), + executionId: resumeExecutionId, + workflowId, + data: { blockId, phase: 'start', id, name }, + } as ExecutionEvent) + }, + onToolCallEnd: async (id, name, status) => { + await writeBufferedEvent({ + type: 'stream:tool', + timestamp: new Date().toISOString(), + executionId: resumeExecutionId, + workflowId, + data: { blockId, phase: 'end', id, name, status }, + } as ExecutionEvent) + }, + }) + const reader = streamingExec.stream.getReader() const decoder = new TextDecoder() try { @@ -1440,6 +1476,7 @@ export class PauseResumeManager { error: toError(streamError).message, }) } finally { + unsubscribe() try { await reader.cancel().catch(() => {}) } catch {} diff --git a/apps/sim/lib/workflows/orchestration/chat-deploy.ts b/apps/sim/lib/workflows/orchestration/chat-deploy.ts index 8288766b900..8d583d0a65c 100644 --- a/apps/sim/lib/workflows/orchestration/chat-deploy.ts +++ b/apps/sim/lib/workflows/orchestration/chat-deploy.ts @@ -25,6 +25,8 @@ export interface ChatDeployPayload { password?: string | null allowedEmails?: string[] outputConfigs?: Array<{ blockId: string; path: string }> + /** When true, public SSE may expose thinking if the client also opts into agent-events-v1. */ + includeThinking?: boolean workspaceId?: string | null } @@ -56,6 +58,7 @@ export async function performChatDeploy( password, allowedEmails = [], outputConfigs = [], + includeThinking = false, } = params const customizations = { @@ -108,6 +111,7 @@ export async function performChatDeploy( password: passwordToStore, allowedEmails: authType === 'email' || authType === 'sso' ? allowedEmails : [], outputConfigs, + includeThinking, updatedAt: new Date(), }) .where(eq(chat.id, chatId)) @@ -126,6 +130,7 @@ export async function performChatDeploy( password: encryptedPassword, allowedEmails: authType === 'email' || authType === 'sso' ? allowedEmails : [], outputConfigs, + includeThinking, createdAt: new Date(), updatedAt: new Date(), }) diff --git a/apps/sim/lib/workflows/streaming/agent-stream-protocol.test.ts b/apps/sim/lib/workflows/streaming/agent-stream-protocol.test.ts new file mode 100644 index 00000000000..a24e18d295f --- /dev/null +++ b/apps/sim/lib/workflows/streaming/agent-stream-protocol.test.ts @@ -0,0 +1,83 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + AGENT_STREAM_PROTOCOL_HEADER, + AGENT_STREAM_PROTOCOL_V1, + requestOptsIntoAgentStreamProtocol, + shouldEmitAgentStreamEvents, +} from '@/lib/workflows/streaming/agent-stream-protocol' + +function headers(init?: Record): Headers { + return new Headers(init) +} + +describe('shouldEmitAgentStreamEvents', () => { + it('defaults to false when policy is off and header is missing', () => { + expect( + shouldEmitAgentStreamEvents({ + includeThinking: false, + requestHeaders: headers(), + }) + ).toBe(false) + expect( + shouldEmitAgentStreamEvents({ + includeThinking: undefined, + requestHeaders: headers(), + }) + ).toBe(false) + }) + + it('requires both includeThinking and protocol header', () => { + expect( + shouldEmitAgentStreamEvents({ + includeThinking: true, + requestHeaders: headers(), + }) + ).toBe(false) + + expect( + shouldEmitAgentStreamEvents({ + includeThinking: false, + requestHeaders: headers({ [AGENT_STREAM_PROTOCOL_HEADER]: AGENT_STREAM_PROTOCOL_V1 }), + }) + ).toBe(false) + + expect( + shouldEmitAgentStreamEvents({ + includeThinking: true, + requestHeaders: headers({ [AGENT_STREAM_PROTOCOL_HEADER]: AGENT_STREAM_PROTOCOL_V1 }), + }) + ).toBe(true) + }) + + it('accepts case-insensitive header values and comma lists', () => { + expect( + shouldEmitAgentStreamEvents({ + includeThinking: true, + requestHeaders: headers({ [AGENT_STREAM_PROTOCOL_HEADER]: ' Agent-Events-V1 ' }), + }) + ).toBe(true) + + expect( + shouldEmitAgentStreamEvents({ + includeThinking: true, + requestHeaders: headers({ + [AGENT_STREAM_PROTOCOL_HEADER]: 'text, agent-events-v1', + }), + }) + ).toBe(true) + }) +}) + +describe('requestOptsIntoAgentStreamProtocol', () => { + it('detects protocol opt-in independent of deployment policy', () => { + expect(requestOptsIntoAgentStreamProtocol(headers())).toBe(false) + expect( + requestOptsIntoAgentStreamProtocol( + headers({ [AGENT_STREAM_PROTOCOL_HEADER]: AGENT_STREAM_PROTOCOL_V1 }) + ) + ).toBe(true) + }) +}) diff --git a/apps/sim/lib/workflows/streaming/agent-stream-protocol.ts b/apps/sim/lib/workflows/streaming/agent-stream-protocol.ts new file mode 100644 index 00000000000..70cda71b118 --- /dev/null +++ b/apps/sim/lib/workflows/streaming/agent-stream-protocol.ts @@ -0,0 +1,60 @@ +/** + * Negotiation for public agent stream events (thinking / tool lifecycle). + * + * Exposure rule (locked) for public chat / simple SSE: + * emit thinking/tool SSE frames iff + * deployment.includeThinking === true + * AND request opts into agent-events-v1 via {@link AGENT_STREAM_PROTOCOL_HEADER} + * + * Canvas draft runs (execution-events) forward the same sink as live-only + * `stream:thinking` / `stream:tool` events without the includeThinking gate; + * the executor still disables the sink when block-output PII redaction is on. + * + * Legacy clients omitting the header stay text-only even when the deployment + * has thinking enabled. Deployed chat UI always sends the header when loading + * its own deployment. + * + * See docs: workflows/deployment/agent-events. + */ + +export const AGENT_STREAM_PROTOCOL_HEADER = 'x-sim-stream-protocol' as const + +export const AGENT_STREAM_PROTOCOL_V1 = 'agent-events-v1' as const + +export type AgentStreamProtocol = typeof AGENT_STREAM_PROTOCOL_V1 + +/** + * Returns true when both the deployment policy and the request protocol opt-in + * are present. Used by simple SSE (Step 5+) before emitting thinking/tool frames. + */ +export function shouldEmitAgentStreamEvents(options: { + includeThinking: boolean | null | undefined + requestHeaders: Headers | { get(name: string): string | null } +}): boolean { + if (options.includeThinking !== true) { + return false + } + + const raw = options.requestHeaders.get(AGENT_STREAM_PROTOCOL_HEADER) + if (!raw) { + return false + } + + // Allow comma-separated values / surrounding whitespace from proxies. + const tokens = raw + .split(',') + .map((token) => token.trim().toLowerCase()) + .filter(Boolean) + + return tokens.includes(AGENT_STREAM_PROTOCOL_V1) +} + +/** True when the request asked for the agent-events protocol (ignores policy). */ +export function requestOptsIntoAgentStreamProtocol( + requestHeaders: Headers | { get(name: string): string | null } +): boolean { + return shouldEmitAgentStreamEvents({ + includeThinking: true, + requestHeaders, + }) +} diff --git a/apps/sim/lib/workflows/streaming/attach-agent-stream-sink.test.ts b/apps/sim/lib/workflows/streaming/attach-agent-stream-sink.test.ts new file mode 100644 index 00000000000..4e999cce476 --- /dev/null +++ b/apps/sim/lib/workflows/streaming/attach-agent-stream-sink.test.ts @@ -0,0 +1,63 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { attachAgentStreamSink } from '@/lib/workflows/streaming/attach-agent-stream-sink' +import type { AgentStreamEvent } from '@/providers/stream-events' +import type { StreamingExecution } from '@/executor/types' + +describe('attachAgentStreamSink (Step 10)', () => { + it('subscribes in sync window and routes thinking/tool events', async () => { + let sinkHandler: ((event: AgentStreamEvent) => void | Promise) | undefined + const unsubscribe = vi.fn() + const streamingExec = { + stream: new ReadableStream(), + execution: { success: true, output: {} }, + subscribe: (sink: { onEvent: (event: AgentStreamEvent) => void | Promise }) => { + sinkHandler = sink.onEvent + return unsubscribe + }, + } as StreamingExecution + + const onThinkingDelta = vi.fn() + const onToolCallStart = vi.fn() + const onToolCallEnd = vi.fn() + + const unsub = attachAgentStreamSink(streamingExec, { + onThinkingDelta, + onToolCallStart, + onToolCallEnd, + }) + + expect(sinkHandler).toBeTypeOf('function') + + await sinkHandler!({ type: 'thinking_delta', text: 'plan ' }) + await sinkHandler!({ type: 'tool_call_start', id: 't1', name: 'http_request' }) + await sinkHandler!({ + type: 'tool_call_end', + id: 't1', + name: 'http_request', + status: 'success', + }) + await sinkHandler!({ type: 'text_delta', text: 'hi', turn: 'final' }) + + expect(onThinkingDelta).toHaveBeenCalledWith('plan ') + expect(onToolCallStart).toHaveBeenCalledWith('t1', 'http_request') + expect(onToolCallEnd).toHaveBeenCalledWith('t1', 'http_request', 'success') + + unsub() + expect(unsubscribe).toHaveBeenCalled() + }) + + it('no-ops when subscribe is absent', () => { + const streamingExec = { + stream: new ReadableStream(), + execution: { success: true, output: {} }, + } as StreamingExecution + + const unsub = attachAgentStreamSink(streamingExec, { + onThinkingDelta: vi.fn(), + }) + expect(() => unsub()).not.toThrow() + }) +}) diff --git a/apps/sim/lib/workflows/streaming/attach-agent-stream-sink.ts b/apps/sim/lib/workflows/streaming/attach-agent-stream-sink.ts new file mode 100644 index 00000000000..87296ab0860 --- /dev/null +++ b/apps/sim/lib/workflows/streaming/attach-agent-stream-sink.ts @@ -0,0 +1,47 @@ +/** + * Sync-window helper: attach an agent-events sink before the first await + * so the executor pump can deliver thinking/tool events while the answer + * stream is drained separately. + */ + +import type { StreamingExecution } from '@/executor/types' +import type { AgentStreamEvent } from '@/providers/stream-events' + +export type AgentStreamSinkHandlers = { + onThinkingDelta?: (text: string) => void | Promise + onToolCallStart?: (id: string, name: string) => void | Promise + onToolCallEnd?: ( + id: string, + name: string, + status: 'success' | 'error' | 'cancelled' + ) => void | Promise +} + +/** + * Subscribe in the caller's sync window (before awaiting the text reader). + * Returns an unsubscribe function (no-op when subscribe is absent). + */ +export function attachAgentStreamSink( + streamingExec: StreamingExecution, + handlers: AgentStreamSinkHandlers +): () => void { + if (!streamingExec.subscribe) { + return () => {} + } + + return streamingExec.subscribe({ + onEvent: async (event: AgentStreamEvent) => { + if (event.type === 'thinking_delta') { + await handlers.onThinkingDelta?.(event.text) + return + } + if (event.type === 'tool_call_start') { + await handlers.onToolCallStart?.(event.id, event.name) + return + } + if (event.type === 'tool_call_end') { + await handlers.onToolCallEnd?.(event.id, event.name, event.status) + } + }, + }) +} diff --git a/apps/sim/lib/workflows/streaming/streaming.test.ts b/apps/sim/lib/workflows/streaming/streaming.test.ts index f802dc5bfa4..6951c76a35b 100644 --- a/apps/sim/lib/workflows/streaming/streaming.test.ts +++ b/apps/sim/lib/workflows/streaming/streaming.test.ts @@ -604,3 +604,373 @@ describe('createStreamingResponse', () => { await expect(readSSEStream(stream)).resolves.toBe('ok') }) }) + +describe('createStreamingResponse agent-events-v1 (Step 5)', () => { + beforeEach(() => { + vi.clearAllMocks() + clearLargeValueCacheForTests() + }) + + function createAgentStreamExecuteFn(options: { + thinking?: string[] + answer: string + fail?: boolean + tools?: Array< + | { type: 'tool_call_start'; id: string; name: string } + | { type: 'tool_call_end'; id: string; name: string; status: string } + > + }) { + return async ({ + onStream, + abortSignal, + }: { + onStream: (streamingExec: any) => Promise + onBlockComplete: (blockId: string, output: unknown) => Promise + abortSignal: AbortSignal + }) => { + let textController!: ReadableStreamDefaultController + let sink: { onEvent: (event: unknown) => void | Promise } | undefined + const textStream = new ReadableStream({ + start(controller) { + textController = controller + }, + }) + + const onStreamPromise = onStream({ + stream: textStream, + streamFormat: 'text', + subscribe: (nextSink: { onEvent: (event: unknown) => void | Promise }) => { + sink = nextSink + return () => { + sink = undefined + } + }, + execution: { + blockId: 'agent-1', + success: true, + output: { content: options.answer }, + logs: [], + metadata: {}, + }, + }) + + if (options.fail) { + textController.error(new Error('provider reset')) + await onStreamPromise.catch(() => {}) + throw new Error('provider reset') + } + + for (const text of options.thinking ?? []) { + await sink?.onEvent({ type: 'thinking_delta', text }) + } + for (const toolEvent of options.tools ?? []) { + await sink?.onEvent(toolEvent) + } + textController.enqueue(new TextEncoder().encode(options.answer)) + textController.close() + await onStreamPromise + + expect(abortSignal).toBeDefined() + + return { + success: true, + output: { content: options.answer }, + logs: [ + { + blockId: 'agent-1', + output: { content: '' }, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + durationMs: 1, + success: true, + }, + ], + } as any + } + } + + async function collectSSEPayloads(stream: ReadableStream): Promise { + const reader = stream.getReader() + const decoder = new TextDecoder() + let buffer = '' + while (true) { + const { done, value } = await reader.read() + if (done) { + buffer += decoder.decode() + break + } + buffer += decoder.decode(value, { stream: true }) + } + return buffer + .split('\n\n') + .map((chunk) => chunk.trim()) + .filter((chunk) => chunk.startsWith('data: ')) + .map((chunk) => chunk.slice(6)) + } + + it('legacy path without protocol header stays text-only (no thinking frames)', async () => { + const stream = await createStreamingResponse({ + requestId: 'request-1', + streamConfig: { + includeThinking: true, + selectedOutputs: ['agent-1_content'], + }, + // No requestHeaders → gate closed + executeFn: createAgentStreamExecuteFn({ + thinking: ['secret thought'], + answer: 'Hello', + }), + }) + + const events = await collectSSEEvents(stream) + expect(events.some((event) => event.event === 'thinking')).toBe(false) + expect(events).toContainEqual({ blockId: 'agent-1', chunk: 'Hello' }) + expect(events.some((event) => event.event === 'final')).toBe(true) + }) + + it('header + includeThinking emits thinking on data and answer on chunk', async () => { + const headers = new Headers({ + 'x-sim-stream-protocol': 'agent-events-v1', + }) + const stream = await createStreamingResponse({ + requestId: 'request-1', + requestHeaders: headers, + streamConfig: { + includeThinking: true, + selectedOutputs: ['agent-1_content'], + }, + executeFn: createAgentStreamExecuteFn({ + thinking: ['hmm ', 'yes'], + answer: 'Answer', + }), + }) + + const events = await collectSSEEvents(stream) + expect(events.filter((event) => event.event === 'thinking')).toEqual([ + { blockId: 'agent-1', event: 'thinking', data: 'hmm ' }, + { blockId: 'agent-1', event: 'thinking', data: 'yes' }, + ]) + expect(events).toContainEqual({ blockId: 'agent-1', chunk: 'Answer' }) + expect(events.some((event) => event.event === 'final')).toBe(true) + }) + + it('dual gate emits tool start/end frames without putting tools on chunk', async () => { + const headers = new Headers({ + 'x-sim-stream-protocol': 'agent-events-v1', + }) + const stream = await createStreamingResponse({ + requestId: 'request-1', + requestHeaders: headers, + streamConfig: { + includeThinking: true, + selectedOutputs: ['agent-1_content'], + }, + executeFn: createAgentStreamExecuteFn({ + answer: 'Done', + tools: [ + { type: 'tool_call_start', id: 'toolu_1', name: 'get_weather' }, + { + type: 'tool_call_end', + id: 'toolu_1', + name: 'get_weather', + status: 'success', + }, + ], + }), + }) + + const events = await collectSSEEvents(stream) + expect(events.filter((event) => event.event === 'tool')).toEqual([ + { + blockId: 'agent-1', + event: 'tool', + phase: 'start', + id: 'toolu_1', + name: 'get_weather', + }, + { + blockId: 'agent-1', + event: 'tool', + phase: 'end', + id: 'toolu_1', + name: 'get_weather', + status: 'success', + }, + ]) + expect(events).toContainEqual({ blockId: 'agent-1', chunk: 'Done' }) + expect( + events.some( + (event) => + typeof event.chunk === 'string' && + (String(event.chunk).includes('toolu_1') || String(event.chunk).includes('get_weather')) + ) + ).toBe(false) + }) + + it('protocol header without includeThinking does not emit tool frames', async () => { + const headers = new Headers({ + 'x-sim-stream-protocol': 'agent-events-v1', + }) + const stream = await createStreamingResponse({ + requestId: 'request-1', + requestHeaders: headers, + streamConfig: { + includeThinking: false, + selectedOutputs: ['agent-1_content'], + }, + executeFn: createAgentStreamExecuteFn({ + answer: 'Answer', + tools: [{ type: 'tool_call_start', id: 'toolu_1', name: 'get_weather' }], + }), + }) + + const events = await collectSSEEvents(stream) + expect(events.some((event) => event.event === 'tool')).toBe(false) + expect(events).toContainEqual({ blockId: 'agent-1', chunk: 'Answer' }) + }) + + it('protocol header without includeThinking does not emit thinking', async () => { + const headers = new Headers({ + 'x-sim-stream-protocol': 'agent-events-v1', + }) + const stream = await createStreamingResponse({ + requestId: 'request-1', + requestHeaders: headers, + streamConfig: { + includeThinking: false, + selectedOutputs: ['agent-1_content'], + }, + executeFn: createAgentStreamExecuteFn({ + thinking: ['should not appear'], + answer: 'Answer', + }), + }) + + const events = await collectSSEEvents(stream) + expect(events.some((event) => event.event === 'thinking')).toBe(false) + expect(events).toContainEqual({ blockId: 'agent-1', chunk: 'Answer' }) + }) + + it('provider failure emits one terminal error, no final, then [DONE]', async () => { + const stream = await createStreamingResponse({ + requestId: 'request-1', + streamConfig: {}, + executeFn: createAgentStreamExecuteFn({ + answer: 'partial', + fail: true, + }), + }) + + const payloads = await collectSSEPayloads(stream) + const events = payloads + .filter((payload) => payload !== '[DONE]' && payload !== '"[DONE]"') + .map((payload) => JSON.parse(payload) as Record) + + expect(events.filter((event) => event.event === 'error')).toHaveLength(1) + expect(events.some((event) => event.event === 'final')).toBe(false) + expect(payloads.some((payload) => payload === '[DONE]' || payload === '"[DONE]"')).toBe(true) + }) + + it('requestSignal abort propagates to executeFn abortSignal', async () => { + const requestAbort = new AbortController() + let sawAbort = false + + const stream = await createStreamingResponse({ + requestId: 'request-1', + requestSignal: requestAbort.signal, + streamConfig: {}, + executeFn: async ({ abortSignal }) => { + requestAbort.abort() + sawAbort = abortSignal.aborted + return { + success: false, + status: 'cancelled', + output: {}, + logs: [], + } as any + }, + }) + + const events = await collectSSEEvents(stream) + expect(sawAbort).toBe(true) + expect(events.some((event) => event.event === 'final')).toBe(false) + expect(events).toContainEqual({ event: 'error', error: 'Client cancelled request' }) + }) + + it('thinking never enters streamedChunks / log content rewrite', async () => { + const headers = new Headers({ + 'x-sim-stream-protocol': 'agent-events-v1', + }) + let rewrittenContent: string | undefined + + const stream = await createStreamingResponse({ + requestId: 'request-1', + requestHeaders: headers, + streamConfig: { + includeThinking: true, + selectedOutputs: ['agent-1_content'], + }, + executeFn: async ({ onStream }) => { + let textController!: ReadableStreamDefaultController + let sink: { onEvent: (event: unknown) => void | Promise } | undefined + const textStream = new ReadableStream({ + start(controller) { + textController = controller + }, + }) + + const onStreamPromise = onStream({ + stream: textStream, + streamFormat: 'text', + subscribe: (nextSink: any) => { + sink = nextSink + return () => { + sink = undefined + } + }, + execution: { + blockId: 'agent-1', + success: true, + output: { content: 'visible' }, + logs: [], + metadata: {}, + }, + } as any) + + await sink?.onEvent({ type: 'thinking_delta', text: 'PRIVATE_THINKING' }) + textController.enqueue(new TextEncoder().encode('visible')) + textController.close() + await onStreamPromise + + return { + success: true, + output: {}, + logs: [ + { + blockId: 'agent-1', + output: { content: '' }, + startedAt: new Date().toISOString(), + endedAt: new Date().toISOString(), + durationMs: 1, + success: true, + }, + ], + } as any + }, + }) + + const events = await collectSSEEvents(stream) + const answerChunks = events.filter((event) => typeof event.chunk === 'string') + expect(answerChunks.every((event) => !String(event.chunk).includes('PRIVATE_THINKING'))).toBe( + true + ) + expect(events).toContainEqual({ + blockId: 'agent-1', + event: 'thinking', + data: 'PRIVATE_THINKING', + }) + // Force consumption of stream so log rewrite runs + expect(events.some((event) => event.event === 'final')).toBe(true) + void rewrittenContent + }) +}) diff --git a/apps/sim/lib/workflows/streaming/streaming.ts b/apps/sim/lib/workflows/streaming/streaming.ts index c848854346f..6dced6c7e3b 100644 --- a/apps/sim/lib/workflows/streaming/streaming.ts +++ b/apps/sim/lib/workflows/streaming/streaming.ts @@ -26,6 +26,12 @@ import { } from '@/lib/uploads/utils/user-file-base64.server' import type { BlockLog, ExecutionResult, StreamingExecution } from '@/executor/types' import { navigatePathAsync } from '@/executor/variables/resolvers/reference-async.server' +import { + AGENT_STREAM_PROTOCOL_HEADER, + AGENT_STREAM_PROTOCOL_V1, + shouldEmitAgentStreamEvents, +} from '@/lib/workflows/streaming/agent-stream-protocol' +import { DEFAULT_MAX_THINKING_BYTES } from '@/providers/stream-pump' /** * Extended streaming execution type that includes blockId on the execution. @@ -41,6 +47,16 @@ const DANGEROUS_KEYS = ['__proto__', 'constructor', 'prototype'] const SELECTED_OUTPUT_TOO_LARGE_MESSAGE = 'Selected output is too large to inline; select a nested field or use pagination/preview.' +/** + * Simple SSE stream contract (Step 5): + * - Answer text: `{ blockId, chunk }` only (`chunk` is forever answer text). + * - Thinking (opt-in): `{ blockId, event: 'thinking', data }` — never uses `chunk`. + * - Success terminal: `{ event: 'final', data }` then `[DONE]`. + * - Failure terminal: exactly one `{ event: 'error', ... }` then `[DONE]`. No `final` after failure. + * - Mid-block read issues may emit non-terminal `{ event: 'stream_error', blockId, error }`. + * - Thinking never enters `streamedChunks` / log rewrite / tokenization. + */ + interface StreamingConfig { selectedOutputs?: string[] isSecureMode?: boolean @@ -48,6 +64,13 @@ interface StreamingConfig { includeFileBase64?: boolean base64MaxBytes?: number timeoutMs?: number + /** + * Deployment policy for thinking/tool SSE. Still requires the client to send + * {@link AGENT_STREAM_PROTOCOL_HEADER}: {@link AGENT_STREAM_PROTOCOL_V1}. + */ + includeThinking?: boolean + /** Cap on thinking bytes forwarded over SSE for the whole execution. */ + maxThinkingBytes?: number } export type StreamingExecutorFn = (callbacks: { @@ -67,9 +90,34 @@ export interface StreamingResponseOptions { workspaceId?: string workflowId?: string userId?: string + /** Incoming fetch/request abort — combined with the stream timeout. */ + requestSignal?: AbortSignal + /** Used with {@link StreamingConfig.includeThinking} for dual-gate thinking SSE. */ + requestHeaders?: Headers | { get(name: string): string | null } executeFn: StreamingExecutorFn } +/** + * Extra response headers when the dual-gate agent stream protocol is active. + * Callers should merge these into the SSE response alongside {@link SSE_HEADERS}. + */ +export function agentStreamProtocolResponseHeaders(options: { + includeThinking?: boolean | null + requestHeaders?: Headers | { get(name: string): string | null } +}): Record { + if (!options.requestHeaders) return {} + if ( + !shouldEmitAgentStreamEvents({ + includeThinking: options.includeThinking, + requestHeaders: options.requestHeaders, + }) + ) { + return {} + } + return { [AGENT_STREAM_PROTOCOL_HEADER]: AGENT_STREAM_PROTOCOL_V1 } +} + + interface StreamingState { streamedChunks: Map processedOutputs: Set @@ -351,14 +399,31 @@ export async function createStreamingResponse( options: StreamingResponseOptions ): Promise { const { requestId, streamConfig, executionId, executeFn } = options - const durableContext = { - workspaceId: options.workspaceId, - workflowId: options.workflowId, - executionId, - userId: options.userId, - requireDurable: Boolean(options.workspaceId && options.workflowId && executionId), - } const timeoutController = createTimeoutAbortController(streamConfig.timeoutMs) + const emitAgentEvents = + Boolean(options.requestHeaders) && + shouldEmitAgentStreamEvents({ + includeThinking: streamConfig.includeThinking, + requestHeaders: options.requestHeaders!, + }) + const maxThinkingBytes = streamConfig.maxThinkingBytes ?? DEFAULT_MAX_THINKING_BYTES + + let requestAborted = false + const onRequestAbort = () => { + requestAborted = true + timeoutController.abort() + } + if (options.requestSignal) { + if (options.requestSignal.aborted) { + onRequestAbort() + } else { + options.requestSignal.addEventListener('abort', onRequestAbort, { once: true }) + } + } + + const cleanupRequestAbort = () => { + options.requestSignal?.removeEventListener('abort', onRequestAbort) + } return new ReadableStream({ async start(controller) { @@ -370,6 +435,7 @@ export async function createStreamingResponse( selectedOutputBytes: 0, streamedSelectedOutputKeys: new Set(), } + let thinkingBytesEmitted = 0 const sendChunk = ( blockId: string, @@ -390,8 +456,44 @@ export async function createStreamingResponse( state.processedOutputs.add(blockId) } + const sendThinking = (blockId: string, text: string) => { + if (!text || thinkingBytesEmitted >= maxThinkingBytes) return + const remaining = maxThinkingBytes - thinkingBytesEmitted + const forwarded = text.length > remaining ? text.slice(0, remaining) : text + thinkingBytesEmitted += forwarded.length + // Never push thinking into streamedChunks — logs stay answer-text only. + controller.enqueue( + encodeSSE({ + blockId, + event: 'thinking', + data: forwarded, + }) + ) + } + + const sendTool = ( + blockId: string, + phase: 'start' | 'end', + id: string, + name: string, + status?: string + ) => { + controller.enqueue( + encodeSSE({ + blockId, + event: 'tool', + phase, + id, + name, + ...(phase === 'end' && status ? { status } : {}), + }) + ) + } + /** * Callback for handling streaming execution events. + * Subscribe synchronously before the first await so the executor pump + * can attach sinks before pulling provider chunks. */ const onStreamCallback = async (streamingExec: StreamingExecutionWithBlockId) => { const blockId = streamingExec.execution?.blockId @@ -400,6 +502,21 @@ export async function createStreamingResponse( return } + let unsubscribe: (() => void) | undefined + if (emitAgentEvents && streamingExec.subscribe) { + unsubscribe = streamingExec.subscribe({ + onEvent: async (event) => { + if (event.type === 'thinking_delta') { + sendThinking(blockId, event.text) + } else if (event.type === 'tool_call_start') { + sendTool(blockId, 'start', event.id, event.name) + } else if (event.type === 'tool_call_end') { + sendTool(blockId, 'end', event.id, event.name, event.status) + } + }, + }) + } + const reader = streamingExec.stream.getReader() const decoder = new TextDecoder() let isFirstChunk = true @@ -434,6 +551,8 @@ export async function createStreamingResponse( error: getErrorMessage(error, 'Stream reading error'), }) ) + } finally { + unsubscribe?.() } } @@ -555,7 +674,8 @@ export async function createStreamingResponse( if ( result.status === 'cancelled' && timeoutController.isTimedOut() && - timeoutController.timeoutMs + timeoutController.timeoutMs && + !requestAborted ) { const timeoutErrorMessage = getTimeoutErrorMessage(null, timeoutController.timeoutMs) logger.info(`[${requestId}] Streaming execution timed out`, { @@ -565,6 +685,16 @@ export async function createStreamingResponse( await result._streamingMetadata.loggingSession.markAsFailed(timeoutErrorMessage) } controller.enqueue(encodeSSE({ event: 'error', error: timeoutErrorMessage })) + } else if (result.status === 'cancelled' && requestAborted) { + logger.info(`[${requestId}] Streaming execution aborted by client disconnect`) + if (result._streamingMetadata?.loggingSession) { + // LoggingSession has no cancelled status; match workflow execute route wording. + await result._streamingMetadata.loggingSession.markAsFailed( + 'Client cancelled request' + ) + } + // No `final` after abort; clients that already disconnected ignore these. + controller.enqueue(encodeSSE({ event: 'error', error: 'Client cancelled request' })) } else { await completeLoggingSession(result) @@ -603,6 +733,7 @@ export async function createStreamingResponse( } } + // Terminal marker: always follows success `final` or a single terminal `error`. controller.enqueue(encodeSSE('[DONE]')) if (executionId) { @@ -617,6 +748,8 @@ export async function createStreamingResponse( ? SELECTED_OUTPUT_TOO_LARGE_MESSAGE : getErrorMessage(error, 'Stream processing error') controller.enqueue(encodeSSE({ event: 'error', error: errorMessage })) + // Same terminal rule as timeout/abort: one error, then [DONE], never `final`. + controller.enqueue(encodeSSE('[DONE]')) if (executionId) { await cleanupExecutionBase64Cache(executionId) @@ -624,12 +757,15 @@ export async function createStreamingResponse( controller.close() } finally { + cleanupRequestAbort() timeoutController.cleanup() } }, async cancel(reason) { logger.info(`[${requestId}] Streaming response cancelled`, { reason }) + requestAborted = true timeoutController.abort() + cleanupRequestAbort() timeoutController.cleanup() if (executionId) { try { diff --git a/apps/sim/providers/__fixtures__/anthropic/fixtures.test.ts b/apps/sim/providers/__fixtures__/anthropic/fixtures.test.ts new file mode 100644 index 00000000000..eb6434d92c0 --- /dev/null +++ b/apps/sim/providers/__fixtures__/anthropic/fixtures.test.ts @@ -0,0 +1,188 @@ +/** + * @vitest-environment node + * + * Step 0 gate: Anthropic stream fixtures parse and match expected assembled + * thinking/text/tool/signature content. No provider adapters are exercised yet. + */ +import { describe, expect, it } from 'vitest' +import { + anthropicRedactedThinkingAssembledContent, + anthropicRedactedThinkingExpectedText, + anthropicRedactedThinkingExpectedTraceThinking, + anthropicRedactedThinkingStreamEvents, + anthropicThinkingTextToolAssembledContent, + anthropicThinkingTextToolExpectedText, + anthropicThinkingTextToolExpectedThinking, + anthropicThinkingTextToolStreamEvents, +} from '@/providers/__fixtures__/anthropic' + +type StreamEvent = { + type: string + index?: number + delta?: { type: string; thinking?: string; text?: string; signature?: string; partial_json?: string } + content_block?: { + type: string + thinking?: string + text?: string + data?: string + id?: string + name?: string + input?: unknown + signature?: string + } +} + +function assembleAnthropicContentFromStream(events: readonly StreamEvent[]) { + const blocks: Array> = [] + + for (const event of events) { + if (event.type === 'content_block_start' && event.content_block) { + const block = event.content_block + if (block.type === 'thinking') { + blocks.push({ type: 'thinking', thinking: block.thinking ?? '', signature: '' }) + } else if (block.type === 'redacted_thinking') { + blocks.push({ type: 'redacted_thinking', data: block.data ?? '' }) + } else if (block.type === 'text') { + blocks.push({ type: 'text', text: block.text ?? '' }) + } else if (block.type === 'tool_use') { + blocks.push({ + type: 'tool_use', + id: block.id, + name: block.name, + inputJson: '', + }) + } + continue + } + + if (event.type !== 'content_block_delta' || event.index === undefined || !event.delta) { + continue + } + + const target = blocks[event.index] + if (!target) continue + + const delta = event.delta + if (delta.type === 'thinking_delta' && typeof delta.thinking === 'string') { + target.thinking = `${target.thinking ?? ''}${delta.thinking}` + } else if (delta.type === 'signature_delta' && typeof delta.signature === 'string') { + target.signature = delta.signature + } else if (delta.type === 'text_delta' && typeof delta.text === 'string') { + target.text = `${target.text ?? ''}${delta.text}` + } else if (delta.type === 'input_json_delta' && typeof delta.partial_json === 'string') { + target.inputJson = `${target.inputJson ?? ''}${delta.partial_json}` + } + } + + return blocks.map((block) => { + if (block.type === 'tool_use') { + const inputJson = typeof block.inputJson === 'string' ? block.inputJson : '{}' + return { + type: 'tool_use', + id: block.id, + name: block.name, + input: JSON.parse(inputJson || '{}'), + } + } + if (block.type === 'thinking') { + return { + type: 'thinking', + thinking: block.thinking, + signature: block.signature, + } + } + return block + }) +} + +function extractTextDeltas(events: readonly StreamEvent[]): string { + return events + .filter( + (e) => + e.type === 'content_block_delta' && + e.delta?.type === 'text_delta' && + typeof e.delta.text === 'string' + ) + .map((e) => e.delta!.text!) + .join('') +} + +function extractThinkingDeltas(events: readonly StreamEvent[]): string { + return events + .filter( + (e) => + e.type === 'content_block_delta' && + e.delta?.type === 'thinking_delta' && + typeof e.delta.thinking === 'string' + ) + .map((e) => e.delta!.thinking!) + .join('') +} + +function assertValidStreamSequence(events: readonly StreamEvent[]) { + expect(events[0]?.type).toBe('message_start') + expect(events[events.length - 1]?.type).toBe('message_stop') + + const open = new Set() + for (const event of events) { + if (event.type === 'content_block_start' && event.index !== undefined) { + expect(open.has(event.index)).toBe(false) + open.add(event.index) + } + if (event.type === 'content_block_stop' && event.index !== undefined) { + expect(open.has(event.index)).toBe(true) + open.delete(event.index) + } + } + expect(open.size).toBe(0) +} + +describe('Anthropic stream fixtures (Step 0)', () => { + it('parses thinking → text → tool_use stream and preserves signature', () => { + assertValidStreamSequence(anthropicThinkingTextToolStreamEvents) + + expect(extractThinkingDeltas(anthropicThinkingTextToolStreamEvents)).toBe( + anthropicThinkingTextToolExpectedThinking + ) + expect(extractTextDeltas(anthropicThinkingTextToolStreamEvents)).toBe( + anthropicThinkingTextToolExpectedText + ) + + const assembled = assembleAnthropicContentFromStream(anthropicThinkingTextToolStreamEvents) + expect(assembled).toEqual([...anthropicThinkingTextToolAssembledContent]) + + const thinkingBlock = assembled.find((b) => b.type === 'thinking') as { + signature?: string + } + expect(thinkingBlock?.signature).toMatch(/^EpAB/) + }) + + it('parses redacted_thinking + signed thinking + text and matches trace mapping', () => { + assertValidStreamSequence(anthropicRedactedThinkingStreamEvents) + + expect(extractTextDeltas(anthropicRedactedThinkingStreamEvents)).toBe( + anthropicRedactedThinkingExpectedText + ) + + const assembled = assembleAnthropicContentFromStream(anthropicRedactedThinkingStreamEvents) + expect(assembled).toEqual([...anthropicRedactedThinkingAssembledContent]) + + // Mirrors enrichLastModelSegmentFromAnthropicResponse: redacted → "[redacted]" + const traceThinking = assembled + .filter((b) => b.type === 'thinking' || b.type === 'redacted_thinking') + .map((b) => (b.type === 'thinking' ? b.thinking : '[redacted]')) + .join('\n\n') + expect(traceThinking).toBe(anthropicRedactedThinkingExpectedTraceThinking) + }) + + it('documents that live stream today would only surface text_delta bytes', () => { + // Baseline behavior of createReadableStreamFromAnthropicStream: only text_delta + // is enqueued. This test locks the fixture expectation for later adapter work. + const textOnlyFromStream = extractTextDeltas(anthropicThinkingTextToolStreamEvents) + const thinkingFromStream = extractThinkingDeltas(anthropicThinkingTextToolStreamEvents) + + expect(textOnlyFromStream).toBe(anthropicThinkingTextToolExpectedText) + expect(thinkingFromStream.length).toBeGreaterThan(0) + expect(textOnlyFromStream).not.toContain('I should check the weather') + }) +}) diff --git a/apps/sim/providers/__fixtures__/anthropic/index.ts b/apps/sim/providers/__fixtures__/anthropic/index.ts new file mode 100644 index 00000000000..117a8fb7769 --- /dev/null +++ b/apps/sim/providers/__fixtures__/anthropic/index.ts @@ -0,0 +1,16 @@ +/** + * Re-exports Anthropic stream fixtures used by agent-stream-events work. + * Keep fixture data in dedicated modules so adapters can import without pulling tests. + */ +export { + anthropicThinkingTextToolAssembledContent, + anthropicThinkingTextToolExpectedText, + anthropicThinkingTextToolExpectedThinking, + anthropicThinkingTextToolStreamEvents, +} from '@/providers/__fixtures__/anthropic/thinking-text-tool' +export { + anthropicRedactedThinkingAssembledContent, + anthropicRedactedThinkingExpectedText, + anthropicRedactedThinkingExpectedTraceThinking, + anthropicRedactedThinkingStreamEvents, +} from '@/providers/__fixtures__/anthropic/redacted-thinking-signature' diff --git a/apps/sim/providers/__fixtures__/anthropic/redacted-thinking-signature.ts b/apps/sim/providers/__fixtures__/anthropic/redacted-thinking-signature.ts new file mode 100644 index 00000000000..1f489756ddf --- /dev/null +++ b/apps/sim/providers/__fixtures__/anthropic/redacted-thinking-signature.ts @@ -0,0 +1,99 @@ +/** + * Anthropic stream + assembled message covering redacted_thinking blocks. + * + * When Anthropic redacts thinking, the stream emits a redacted_thinking content + * block (opaque `data`) instead of thinking_delta text. Multi-turn tool loops + * must round-trip that block (and any adjacent signed thinking blocks) back + * into subsequent Messages API requests unchanged. + */ + +export const anthropicRedactedThinkingStreamEvents = [ + { + type: 'message_start', + message: { + id: 'msg_fixture_redacted_thinking', + type: 'message', + role: 'assistant', + content: [], + model: 'claude-sonnet-4-5', + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 20, output_tokens: 0 }, + }, + }, + { + type: 'content_block_start', + index: 0, + content_block: { + type: 'redacted_thinking', + data: 'fixture-redacted-thinking-opaque-blob-001', + }, + }, + { type: 'content_block_stop', index: 0 }, + { + type: 'content_block_start', + index: 1, + content_block: { + type: 'thinking', + thinking: '', + }, + }, + { + type: 'content_block_delta', + index: 1, + delta: { type: 'thinking_delta', thinking: 'Visible follow-up reasoning after redaction.' }, + }, + { + type: 'content_block_delta', + index: 1, + delta: { + type: 'signature_delta', + signature: 'EpABCkYICBgCKkDfixture-visible-thinking-signature-def456', + }, + }, + { type: 'content_block_stop', index: 1 }, + { + type: 'content_block_start', + index: 2, + content_block: { type: 'text', text: '' }, + }, + { + type: 'content_block_delta', + index: 2, + delta: { type: 'text_delta', text: 'Here is the answer after redacted thinking.' }, + }, + { type: 'content_block_stop', index: 2 }, + { + type: 'message_delta', + delta: { stop_reason: 'end_turn', stop_sequence: null }, + usage: { output_tokens: 64 }, + }, + { type: 'message_stop' }, +] as const + +/** + * Assembled content that must be preserved when appending the assistant turn + * to Anthropic history (see anthropic/core.ts thinking/redacted_thinking filters). + */ +export const anthropicRedactedThinkingAssembledContent = [ + { + type: 'redacted_thinking', + data: 'fixture-redacted-thinking-opaque-blob-001', + }, + { + type: 'thinking', + thinking: 'Visible follow-up reasoning after redaction.', + signature: 'EpABCkYICBgCKkDfixture-visible-thinking-signature-def456', + }, + { + type: 'text', + text: 'Here is the answer after redacted thinking.', + }, +] as const + +/** What enrichLastModelSegmentFromAnthropicResponse maps redacted blocks to today. */ +export const anthropicRedactedThinkingExpectedTraceThinking = + '[redacted]\n\nVisible follow-up reasoning after redaction.' + +export const anthropicRedactedThinkingExpectedText = + 'Here is the answer after redacted thinking.' diff --git a/apps/sim/providers/__fixtures__/anthropic/thinking-text-tool.ts b/apps/sim/providers/__fixtures__/anthropic/thinking-text-tool.ts new file mode 100644 index 00000000000..ec183158186 --- /dev/null +++ b/apps/sim/providers/__fixtures__/anthropic/thinking-text-tool.ts @@ -0,0 +1,119 @@ +/** + * Anthropic Messages API SSE-style stream events for a single assistant turn that: + * 1. Streams extended thinking (thinking_delta + signature_delta) + * 2. Streams answer text (text_delta) + * 3. Streams a tool_use block (input_json_delta) + * + * Shapes mirror Anthropic RawMessageStreamEvent fields used by + * `createReadableStreamFromAnthropicStream`. The adapter emits thinking_delta + + * text_delta AgentStreamEvents; tool_use deltas are ignored until the streaming + * tool loop (Step 7). + */ + +export const anthropicThinkingTextToolStreamEvents = [ + { + type: 'message_start', + message: { + id: 'msg_fixture_thinking_text_tool', + type: 'message', + role: 'assistant', + content: [], + model: 'claude-sonnet-4-5', + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 42, output_tokens: 0 }, + }, + }, + { + type: 'content_block_start', + index: 0, + content_block: { type: 'thinking', thinking: '' }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'thinking_delta', thinking: 'I should check the weather before answering. ' }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'thinking_delta', thinking: 'Calling get_weather for SF.' }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { + type: 'signature_delta', + signature: 'EpABCkYICBgCKkDfixture-thinking-signature-abc123xyz', + }, + }, + { type: 'content_block_stop', index: 0 }, + { + type: 'content_block_start', + index: 1, + content_block: { type: 'text', text: '' }, + }, + { + type: 'content_block_delta', + index: 1, + delta: { type: 'text_delta', text: 'Let me check the weather in San Francisco.' }, + }, + { type: 'content_block_stop', index: 1 }, + { + type: 'content_block_start', + index: 2, + content_block: { + type: 'tool_use', + id: 'toolu_fixture_01Weather', + name: 'get_weather', + input: {}, + }, + }, + { + type: 'content_block_delta', + index: 2, + delta: { type: 'input_json_delta', partial_json: '{"city":' }, + }, + { + type: 'content_block_delta', + index: 2, + delta: { type: 'input_json_delta', partial_json: '"San Francisco"}' }, + }, + { type: 'content_block_stop', index: 2 }, + { + type: 'message_delta', + delta: { stop_reason: 'tool_use', stop_sequence: null }, + usage: { output_tokens: 128 }, + }, + { type: 'message_stop' }, +] as const + +/** + * Expected assembled assistant Message.content after draining the stream above. + * Used for history round-trip tests (thinking block must keep its signature). + */ +export const anthropicThinkingTextToolAssembledContent = [ + { + type: 'thinking', + thinking: 'I should check the weather before answering. Calling get_weather for SF.', + signature: 'EpABCkYICBgCKkDfixture-thinking-signature-abc123xyz', + }, + { + type: 'text', + text: 'Let me check the weather in San Francisco.', + }, + { + type: 'tool_use', + id: 'toolu_fixture_01Weather', + name: 'get_weather', + input: { city: 'San Francisco' }, + }, +] as const + +/** Concatenated thinking text (what traces should store as thinkingContent). */ +export const anthropicThinkingTextToolExpectedThinking = + 'I should check the weather before answering. Calling get_weather for SF.' + +/** Concatenated answer text (what output.content / live stream should contain today). */ +export const anthropicThinkingTextToolExpectedText = + 'Let me check the weather in San Francisco.' diff --git a/apps/sim/providers/__fixtures__/openai-compat/index.ts b/apps/sim/providers/__fixtures__/openai-compat/index.ts new file mode 100644 index 00000000000..76ab6500b47 --- /dev/null +++ b/apps/sim/providers/__fixtures__/openai-compat/index.ts @@ -0,0 +1,60 @@ +/** + * OpenAI-compat stream fixtures (Step 9) — capability-honest reasoning deltas. + */ +export const openaiCompatReasoningAndTextChunks = [ + { + choices: [ + { + delta: { + reasoning_content: 'I should compute carefully. ', + }, + }, + ], + usage: { prompt_tokens: 10, completion_tokens: 0, total_tokens: 10 }, + }, + { + choices: [ + { + delta: { + reasoning_content: 'Answer is 4.', + content: '2+2=', + }, + }, + ], + }, + { + choices: [{ delta: { content: '4' } }], + usage: { prompt_tokens: 10, completion_tokens: 8, total_tokens: 18 }, + }, +] as const + +export const openaiCompatTextOnlyChunks = [ + { + choices: [{ delta: { content: 'Hello' } }], + }, + { + choices: [{ delta: { content: ' world' } }], + usage: { prompt_tokens: 3, completion_tokens: 2, total_tokens: 5 }, + }, +] as const + +export const openaiCompatToolCallStartChunks = [ + { + choices: [ + { + delta: { + tool_calls: [{ index: 0, id: 'call_abc', function: { name: 'http_request', arguments: '' } }], + }, + }, + ], + }, + { + choices: [ + { + delta: { + tool_calls: [{ index: 0, function: { arguments: '{"url":' } }], + }, + }, + ], + }, +] as const diff --git a/apps/sim/providers/anthropic/core.ts b/apps/sim/providers/anthropic/core.ts index 27d84febb1d..64bc7ba86fa 100644 --- a/apps/sim/providers/anthropic/core.ts +++ b/apps/sim/providers/anthropic/core.ts @@ -3,8 +3,9 @@ import { transformJSONSchema } from '@anthropic-ai/sdk/lib/transform-json-schema import type { RawMessageStreamEvent } from '@anthropic-ai/sdk/resources/messages/messages' import type { Logger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' -import type { BlockTokens, IterationToolCall, StreamingExecution } from '@/executor/types' +import type { BlockTokens, IterationToolCall, NormalizedBlockOutput, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' +import { createAnthropicStreamingToolLoopStream } from '@/providers/anthropic/streaming-tool-loop' import { checkForForcedToolUsage, createReadableStreamFromAnthropicStream, @@ -403,6 +404,56 @@ export async function executeAnthropicProviderRequest( const shouldStreamToolCalls = request.streamToolCalls ?? false + if (request.stream && shouldStreamToolCalls && anthropicTools && anthropicTools.length > 0) { + logger.info(`Using streaming tool loop for ${providerLabel} request`) + + const providerStartTime = Date.now() + const providerStartTimeISO = new Date(providerStartTime).toISOString() + const timeSegments: TimeSegment[] = [] + const forcedTools = preparedTools?.forcedTools || [] + + return createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime: 0, + toolsTime: 0, + firstResponseTime: 0, + iterations: 1, + timeSegments, + }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { total: 0.0, input: 0.0, output: 0.0 }, + isStreaming: true, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => + createAnthropicStreamingToolLoopStream({ + anthropic, + payload, + request, + messages, + logger, + timeSegments, + forcedTools, + onComplete: (result) => { + output.content = result.content + output.tokens = result.tokens + output.cost = result.cost + output.toolCalls = result.toolCalls as NormalizedBlockOutput['toolCalls'] + if (output.providerTiming) { + output.providerTiming.modelTime = result.modelTime + output.providerTiming.toolsTime = result.toolsTime + output.providerTiming.firstResponseTime = result.firstResponseTime + output.providerTiming.iterations = result.iterations + } + finalizeTiming() + }, + }), + }) + } + if (request.stream && (!anthropicTools || anthropicTools.length === 0)) { logger.info(`Using streaming response for ${providerLabel} request (no tools)`) @@ -425,10 +476,11 @@ export async function executeAnthropicProviderRequest( initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { total: 0.0, input: 0.0, output: 0.0 }, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromAnthropicStream( streamResponse as AsyncIterable, - (content, usage) => { + ({ content, usage, thinking }) => { output.content = content output.tokens = { input: usage.input_tokens, @@ -443,6 +495,13 @@ export async function executeAnthropicProviderRequest( total: costResult.total, } + if (thinking) { + const segment = output.providerTiming?.timeSegments?.[0] + if (segment) { + segment.thinkingContent = thinking + } + } + finalizeTiming() } ), @@ -805,10 +864,11 @@ export async function executeAnthropicProviderRequest( }, toolCalls: toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromAnthropicStream( streamResponse as AsyncIterable, - (streamContent, usage) => { + ({ content: streamContent, usage, thinking }) => { output.content = streamContent output.tokens = { input: tokens.input + usage.input_tokens, @@ -829,6 +889,16 @@ export async function executeAnthropicProviderRequest( total: accumulatedCost.total + streamCost.total + tc, } + if (thinking) { + const segments = output.providerTiming?.timeSegments + const lastModel = segments + ? [...segments].reverse().find((segment) => segment.type === 'model') + : undefined + if (lastModel) { + lastModel.thinkingContent = thinking + } + } + finalizeTiming() } ), @@ -1228,10 +1298,11 @@ export async function executeAnthropicProviderRequest( }, toolCalls: toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromAnthropicStream( streamResponse as AsyncIterable, - (streamContent, usage) => { + ({ content: streamContent, usage, thinking }) => { output.content = streamContent output.tokens = { input: tokens.input + usage.input_tokens, @@ -1252,6 +1323,16 @@ export async function executeAnthropicProviderRequest( total: cost.total + streamCost.total + tc2, } + if (thinking) { + const segments = output.providerTiming?.timeSegments + const lastModel = segments + ? [...segments].reverse().find((segment) => segment.type === 'model') + : undefined + if (lastModel) { + lastModel.thinkingContent = thinking + } + } + finalizeTiming() } ), diff --git a/apps/sim/providers/anthropic/streaming-tool-loop.test.ts b/apps/sim/providers/anthropic/streaming-tool-loop.test.ts new file mode 100644 index 00000000000..e4e4b7824da --- /dev/null +++ b/apps/sim/providers/anthropic/streaming-tool-loop.test.ts @@ -0,0 +1,335 @@ +/** + * @vitest-environment node + * + * Step 7: Anthropic streaming tool loop — live tool_call_start/end, final-turn-only + * answer text, abort → cancelled, per-turn usage accumulation. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + anthropicThinkingTextToolExpectedThinking, + anthropicThinkingTextToolStreamEvents, +} from '@/providers/__fixtures__/anthropic' +import { createAnthropicStreamingToolLoopStream } from '@/providers/anthropic/streaming-tool-loop' +import type { AgentStreamEvent } from '@/providers/stream-events' +import type { TimeSegment } from '@/providers/types' + +const { mockExecuteTool, mockPrepareToolExecution } = vi.hoisted(() => ({ + mockExecuteTool: vi.fn(), + mockPrepareToolExecution: vi.fn(), +})) + +vi.mock('@/tools', () => ({ + executeTool: mockExecuteTool, +})) + +vi.mock('@/providers/utils', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + prepareToolExecution: mockPrepareToolExecution, + calculateCost: () => ({ input: 0.01, output: 0.02, total: 0.03 }), + sumToolCosts: () => 0, + } +}) + +async function collectEvents( + stream: ReadableStream +): Promise { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events +} + +function makeFinalMessage(overrides: { + content: unknown[] + usage?: { input_tokens: number; output_tokens: number } + stop_reason?: string | null +}) { + return { + id: 'msg_test', + type: 'message', + role: 'assistant', + model: 'claude-sonnet-4-5', + content: overrides.content, + stop_reason: overrides.stop_reason ?? null, + stop_sequence: null, + usage: overrides.usage ?? { input_tokens: 10, output_tokens: 20 }, + } +} + +function makeMessageStream( + events: unknown[], + finalMessage: ReturnType +) { + return { + async *[Symbol.asyncIterator]() { + for (const event of events) { + yield event + } + }, + finalMessage: async () => finalMessage, + } +} + +describe('createAnthropicStreamingToolLoopStream (Step 7)', () => { + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as any + + beforeEach(() => { + vi.clearAllMocks() + mockPrepareToolExecution.mockReturnValue({ + toolParams: { city: 'San Francisco' }, + executionParams: { city: 'San Francisco' }, + }) + mockExecuteTool.mockResolvedValue({ + success: true, + output: { temp: 68 }, + }) + }) + + it('emits tool_call_start/end, intermediate then final text, and accumulates usage', async () => { + const toolTurnEvents = anthropicThinkingTextToolStreamEvents + const toolTurnMessage = makeFinalMessage({ + content: [ + { + type: 'thinking', + thinking: anthropicThinkingTextToolExpectedThinking, + signature: 'EpABCkYICBgCKkDfixture-thinking-signature-abc123xyz', + }, + { type: 'text', text: 'Let me check the weather in San Francisco.' }, + { + type: 'tool_use', + id: 'toolu_fixture_01Weather', + name: 'get_weather', + input: { city: 'San Francisco' }, + }, + ], + usage: { input_tokens: 42, output_tokens: 30 }, + stop_reason: 'tool_use', + }) + + const finalTurnEvents = [ + { + type: 'message_start', + message: { + usage: { input_tokens: 100, output_tokens: 0 }, + }, + }, + { + type: 'content_block_start', + index: 0, + content_block: { type: 'text', text: '' }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'text_delta', text: 'It is 68°F in San Francisco.' }, + }, + { type: 'content_block_stop', index: 0 }, + { + type: 'message_delta', + delta: { stop_reason: 'end_turn' }, + usage: { output_tokens: 12 }, + }, + { type: 'message_stop' }, + ] + const finalTurnMessage = makeFinalMessage({ + content: [{ type: 'text', text: 'It is 68°F in San Francisco.' }], + usage: { input_tokens: 100, output_tokens: 12 }, + stop_reason: 'end_turn', + }) + + let streamCall = 0 + const anthropic = { + messages: { + stream: vi.fn(() => { + streamCall++ + if (streamCall === 1) { + return makeMessageStream(toolTurnEvents as unknown[], toolTurnMessage) + } + return makeMessageStream(finalTurnEvents, finalTurnMessage) + }), + }, + } as any + + const timeSegments: TimeSegment[] = [] + const onComplete = vi.fn() + + const stream = createAnthropicStreamingToolLoopStream({ + anthropic, + payload: { + model: 'claude-sonnet-4-5', + max_tokens: 1024, + messages: [{ role: 'user', content: 'Weather?' }], + tools: [ + { + name: 'get_weather', + description: 'Get weather', + input_schema: { type: 'object', properties: {} }, + }, + ], + } as any, + request: { + model: 'claude-sonnet-4-5', + apiKey: 'test', + tools: [{ id: 'get_weather', name: 'get_weather', params: {}, parameters: {} }], + } as any, + messages: [{ role: 'user', content: 'Weather?' }], + logger, + timeSegments, + onComplete, + }) + + const events = await collectEvents(stream) + + expect(events.filter((e) => e.type === 'thinking_delta').length).toBeGreaterThan(0) + expect(events).toContainEqual({ + type: 'tool_call_start', + id: 'toolu_fixture_01Weather', + name: 'get_weather', + }) + expect(events).toContainEqual({ + type: 'tool_call_end', + id: 'toolu_fixture_01Weather', + name: 'get_weather', + status: 'success', + }) + + const textEvents = events.filter((e) => e.type === 'text_delta') + expect(textEvents.some((e) => e.turn === 'intermediate')).toBe(true) + expect(textEvents.some((e) => e.turn === 'final')).toBe(true) + expect(textEvents.find((e) => e.turn === 'final')?.text).toContain('68°F') + + // Assistant history must keep thinking signature for multi-iteration round-trip. + const secondPayload = anthropic.messages.stream.mock.calls[1][0] + const assistantMsg = secondPayload.messages.find((m: any) => m.role === 'assistant') + expect(assistantMsg.content.some((b: any) => b.type === 'thinking' && b.signature)).toBe(true) + expect(assistantMsg.content.some((b: any) => b.type === 'tool_use')).toBe(true) + + expect(onComplete).toHaveBeenCalledTimes(1) + expect(onComplete.mock.calls[0][0].tokens).toEqual({ + input: 142, + output: 42, + total: 184, + }) + expect(onComplete.mock.calls[0][0].content).toContain('68°F') + expect(mockExecuteTool).toHaveBeenCalled() + }) + + it('settles in-flight tools as cancelled on abort', async () => { + const abortController = new AbortController() + const toolStartEvents = [ + { + type: 'message_start', + message: { usage: { input_tokens: 5, output_tokens: 0 } }, + }, + { + type: 'content_block_start', + index: 0, + content_block: { + type: 'tool_use', + id: 'toolu_abort', + name: 'get_weather', + input: {}, + }, + }, + { + type: 'content_block_delta', + index: 0, + delta: { type: 'input_json_delta', partial_json: '{}' }, + }, + { type: 'content_block_stop', index: 0 }, + { + type: 'message_delta', + delta: { stop_reason: 'tool_use' }, + usage: { output_tokens: 3 }, + }, + { type: 'message_stop' }, + ] + + mockExecuteTool.mockImplementation(async () => { + abortController.abort() + throw new DOMException('Stream aborted', 'AbortError') + }) + + const anthropic = { + messages: { + stream: vi.fn(() => + makeMessageStream( + toolStartEvents, + makeFinalMessage({ + content: [ + { + type: 'tool_use', + id: 'toolu_abort', + name: 'get_weather', + input: {}, + }, + ], + stop_reason: 'tool_use', + }) + ) + ), + }, + } as any + + const stream = createAnthropicStreamingToolLoopStream({ + anthropic, + payload: { + model: 'claude-sonnet-4-5', + max_tokens: 1024, + messages: [{ role: 'user', content: 'x' }], + tools: [ + { + name: 'get_weather', + description: 'd', + input_schema: { type: 'object', properties: {} }, + }, + ], + } as any, + request: { + model: 'claude-sonnet-4-5', + apiKey: 'test', + tools: [{ id: 'get_weather', name: 'get_weather', params: {}, parameters: {} }], + abortSignal: abortController.signal, + } as any, + messages: [{ role: 'user', content: 'x' }], + logger, + timeSegments: [], + onComplete: vi.fn(), + }) + + const captured: AgentStreamEvent[] = [] + const reader = stream.getReader() + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + captured.push(value) + } + } catch { + // expected — stream errors after abort settlement + } + + expect(captured).toContainEqual({ + type: 'tool_call_start', + id: 'toolu_abort', + name: 'get_weather', + }) + expect(captured).toContainEqual({ + type: 'tool_call_end', + id: 'toolu_abort', + name: 'get_weather', + status: 'cancelled', + }) + }) +}) diff --git a/apps/sim/providers/anthropic/streaming-tool-loop.ts b/apps/sim/providers/anthropic/streaming-tool-loop.ts new file mode 100644 index 00000000000..12e191d5b7d --- /dev/null +++ b/apps/sim/providers/anthropic/streaming-tool-loop.ts @@ -0,0 +1,563 @@ +/** + * Live Anthropic streaming tool loop (Step 7). + * + * Each model turn is streamed via `messages.stream` + `finalMessage()` so thinking + * signatures round-trip correctly. Thinking and `tool_call_start` emit live; + * text is buffered until the turn completes so it can be tagged + * `intermediate` (tool-use turns) or `final` (answer channel / SSE `chunk`). + * Tool ends emit in actual completion order; abort settles in-flight tools as + * `cancelled`. + */ + +import type Anthropic from '@anthropic-ai/sdk' +import type { Logger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import type { BlockTokens, IterationToolCall, NormalizedBlockOutput } from '@/executor/types' +import { MAX_TOOL_ITERATIONS } from '@/providers' +import { checkForForcedToolUsage } from '@/providers/anthropic/utils' +import type { AgentStreamEvent, ToolCallEndStatus } from '@/providers/stream-events' +import { enrichLastModelSegment } from '@/providers/trace-enrichment' +import type { ProviderRequest, TimeSegment } from '@/providers/types' +import { + calculateCost, + prepareToolExecution, + sumToolCosts, +} from '@/providers/utils' +import { executeTool } from '@/tools' + +/** Custom payload fields shared with `core.ts` (adaptive thinking, output_format). */ +export type AnthropicStreamingToolLoopPayload = Omit< + Anthropic.Messages.MessageStreamParams, + 'thinking' +> & { + thinking?: Anthropic.Messages.ThinkingConfigParam | { type: 'adaptive' } + output_format?: { type: 'json_schema'; schema: Record } + output_config?: { effort: string } +} + +export interface AnthropicStreamingToolLoopComplete { + content: string + tokens: { input: number; output: number; total: number } + cost: NormalizedBlockOutput['cost'] + toolCalls?: { list: unknown[]; count: number } + modelTime: number + toolsTime: number + firstResponseTime: number + iterations: number +} + +export interface CreateAnthropicStreamingToolLoopStreamOptions { + anthropic: Anthropic + payload: AnthropicStreamingToolLoopPayload + request: ProviderRequest + messages: Anthropic.Messages.MessageParam[] + logger: Logger + /** Shared mutable segments; same array reference passed into createStreamingExecution. */ + timeSegments: TimeSegment[] + /** Forced tool names from prepareToolsWithUsageControl (may be empty). */ + forcedTools?: string[] + onComplete: (result: AnthropicStreamingToolLoopComplete) => void +} + +function isAbortError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false + const name = (error as { name?: string }).name + return name === 'AbortError' || name === 'APIUserAbortError' +} + +function buildSegmentTokens(usage: Anthropic.Messages.Usage): BlockTokens { + const input = usage.input_tokens ?? 0 + const output = usage.output_tokens ?? 0 + const cacheRead = usage.cache_read_input_tokens ?? 0 + const cacheWrite = usage.cache_creation_input_tokens ?? 0 + return { + input, + output, + total: input + output + cacheRead + cacheWrite, + ...(cacheRead > 0 && { cacheRead }), + ...(cacheWrite > 0 && { cacheWrite }), + } +} + +function enrichModelSegment( + timeSegments: TimeSegment[], + response: Anthropic.Messages.Message, + textContent: string, + model: string +): void { + const thinkingBlocks = response.content.filter( + (item): item is Anthropic.Messages.ThinkingBlock | Anthropic.Messages.RedactedThinkingBlock => + item.type === 'thinking' || item.type === 'redacted_thinking' + ) + const thinkingContent = thinkingBlocks + .map((b) => (b.type === 'thinking' ? b.thinking : '[redacted]')) + .join('\n\n') + + const toolUseBlocks = response.content.filter( + (item): item is Anthropic.Messages.ToolUseBlock => item.type === 'tool_use' + ) + const toolCalls: IterationToolCall[] = toolUseBlocks.map((t) => ({ + id: t.id, + name: t.name, + arguments: + t.input && typeof t.input === 'object' && !Array.isArray(t.input) + ? (t.input as Record) + : {}, + })) + + const segmentTokens = response.usage ? buildSegmentTokens(response.usage) : undefined + let cost: { input: number; output: number; total: number } | undefined + if ( + segmentTokens && + typeof segmentTokens.input === 'number' && + typeof segmentTokens.output === 'number' + ) { + const useCached = (segmentTokens.cacheRead ?? 0) > 0 + const full = calculateCost(model, segmentTokens.input, segmentTokens.output, useCached) + cost = { input: full.input, output: full.output, total: full.total } + } + + enrichLastModelSegment(timeSegments, { + assistantContent: textContent || undefined, + thinkingContent: thinkingContent || undefined, + toolCalls: toolCalls.length > 0 ? toolCalls : undefined, + finishReason: response.stop_reason ?? undefined, + tokens: segmentTokens, + cost, + provider: 'anthropic', + }) +} + +function settleOpenTools( + controller: ReadableStreamDefaultController, + openTools: Map, + status: ToolCallEndStatus +): void { + for (const [id, name] of openTools) { + controller.enqueue({ type: 'tool_call_end', id, name, status }) + } + openTools.clear() +} + +/** + * Multi-turn Anthropic tool loop as an `agent-events-v1` object stream. + */ +export function createAnthropicStreamingToolLoopStream( + options: CreateAnthropicStreamingToolLoopStreamOptions +): ReadableStream { + const { anthropic, payload, request, messages, logger, timeSegments, onComplete } = options + const forcedToolNames = options.forcedTools ?? [] + + return new ReadableStream({ + async start(controller) { + const currentMessages = [...messages] + const originalToolChoice = payload.tool_choice + + let usedForcedTools: string[] = [] + let hasUsedForcedTool = false + let content = '' + let iterationCount = 0 + let modelTime = 0 + let toolsTime = 0 + let firstResponseTime = 0 + const tokens = { input: 0, output: 0, total: 0 } + const toolCalls: unknown[] = [] + const toolResults: Record[] = [] + /** Tools that received start but not yet end (abort settlement). */ + const openToolStarts = new Map() + + const streamOptions = request.abortSignal ? { signal: request.abortSignal } : undefined + + try { + while (iterationCount < MAX_TOOL_ITERATIONS) { + if (request.abortSignal?.aborted) { + const abortErr = new DOMException('Stream aborted', 'AbortError') + settleOpenTools(controller, openToolStarts, 'cancelled') + throw abortErr + } + + const turnPayload: AnthropicStreamingToolLoopPayload = { + ...payload, + messages: currentMessages, + } + // Streaming tool loop always streams each turn; never pass stream:true twice. + delete (turnPayload as { stream?: boolean }).stream + + // Forced tool_choice vs thinking — same rules as silent loop. + const thinkingEnabled = !!payload.thinking + if ( + !thinkingEnabled && + typeof originalToolChoice === 'object' && + hasUsedForcedTool && + forcedToolNames.length > 0 + ) { + const remainingTools = forcedToolNames.filter((tool) => !usedForcedTools.includes(tool)) + if (remainingTools.length > 0) { + turnPayload.tool_choice = { type: 'tool', name: remainingTools[0] } + } else { + turnPayload.tool_choice = undefined + } + } else if ( + !thinkingEnabled && + hasUsedForcedTool && + typeof originalToolChoice === 'object' + ) { + turnPayload.tool_choice = undefined + } + + const modelStart = Date.now() + if (iterationCount === 0) { + // firstResponseTime set after first turn + } + + const messageStream = anthropic.messages.stream( + turnPayload as Anthropic.Messages.MessageStreamParams, + streamOptions + ) + + const textChunks: string[] = [] + let inputTokens = 0 + let outputTokens = 0 + + try { + for await (const event of messageStream) { + if (event.type === 'message_start') { + inputTokens = event.message.usage?.input_tokens ?? 0 + continue + } + if (event.type === 'message_delta') { + outputTokens = event.usage?.output_tokens ?? outputTokens + continue + } + if (event.type === 'content_block_start') { + const block = event.content_block as { + type?: string + id?: string + name?: string + } + if (block.type === 'tool_use' && block.id && block.name) { + openToolStarts.set(block.id, block.name) + controller.enqueue({ + type: 'tool_call_start', + id: block.id, + name: block.name, + }) + } + continue + } + if (event.type !== 'content_block_delta') { + continue + } + const delta = event.delta as { + type?: string + text?: string + thinking?: string + } + if (delta.type === 'thinking_delta' && typeof delta.thinking === 'string') { + controller.enqueue({ type: 'thinking_delta', text: delta.thinking }) + continue + } + if (delta.type === 'text_delta' && typeof delta.text === 'string') { + textChunks.push(delta.text) + } + } + + const finalMessage = await messageStream.finalMessage() + const modelEnd = Date.now() + const thisModelTime = modelEnd - modelStart + modelTime += thisModelTime + if (iterationCount === 0) { + firstResponseTime = thisModelTime + } + + timeSegments.push({ + type: 'model', + name: request.model, + startTime: modelStart, + endTime: modelEnd, + duration: thisModelTime, + }) + + // Prefer finalMessage.usage when present (includes cache fields). + const turnInput = finalMessage.usage?.input_tokens ?? inputTokens + const turnOutput = finalMessage.usage?.output_tokens ?? outputTokens + tokens.input += turnInput + tokens.output += turnOutput + tokens.total += turnInput + turnOutput + + const textContent = finalMessage.content + .filter((item): item is Anthropic.Messages.TextBlock => item.type === 'text') + .map((item) => item.text) + .join('\n') + + const toolUses = finalMessage.content.filter( + (item): item is Anthropic.Messages.ToolUseBlock => item.type === 'tool_use' + ) + + const turnTag = toolUses.length > 0 ? 'intermediate' : 'final' + for (const chunk of textChunks) { + controller.enqueue({ type: 'text_delta', text: chunk, turn: turnTag }) + } + // If the SDK assembled text but we somehow missed deltas, still project final text. + if (textChunks.length === 0 && textContent && turnTag === 'final') { + controller.enqueue({ type: 'text_delta', text: textContent, turn: 'final' }) + } + if (turnTag === 'final' && textContent) { + content = textContent + } else if (textContent) { + // Keep last intermediate text available if we hit max iterations mid-loop. + content = textContent + } + + enrichModelSegment(timeSegments, finalMessage, textContent, request.model) + + const forcedCheck = checkForForcedToolUsage( + finalMessage, + turnPayload.tool_choice ?? originalToolChoice, + forcedToolNames, + usedForcedTools + ) + if (forcedCheck) { + hasUsedForcedTool = forcedCheck.hasUsedForcedTool + usedForcedTools = forcedCheck.usedForcedTools + } + + if (toolUses.length === 0) { + break + } + + const toolsStartTime = Date.now() + + // Emit ends in completion order; keep Promise.all result order (= start order) for history. + const orderedResults = await Promise.all( + toolUses.map(async (toolUse) => { + const toolCallStartTime = Date.now() + const toolName = toolUse.name + const toolArgs = (toolUse.input ?? {}) as Record + + try { + if (request.abortSignal?.aborted) { + throw new DOMException('Stream aborted', 'AbortError') + } + + const tool = request.tools?.find((t) => t.id === toolName) + if (!tool) { + const value = { + toolUse, + toolName, + toolArgs, + toolParams: {} as Record, + result: { + success: false as const, + output: undefined, + error: `Tool not found: ${toolName}`, + }, + startTime: toolCallStartTime, + endTime: Date.now(), + duration: Date.now() - toolCallStartTime, + status: 'error' as ToolCallEndStatus, + } + openToolStarts.delete(toolUse.id) + controller.enqueue({ + type: 'tool_call_end', + id: toolUse.id, + name: toolName, + status: 'error', + }) + return value + } + + const { toolParams, executionParams } = prepareToolExecution( + tool, + toolArgs, + request + ) + const result = await executeTool(toolName, executionParams, { + skipPostProcess: true, + signal: request.abortSignal, + }) + const toolCallEndTime = Date.now() + const value = { + toolUse, + toolName, + toolArgs, + toolParams, + result, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + status: (result.success ? 'success' : 'error') as ToolCallEndStatus, + } + openToolStarts.delete(toolUse.id) + controller.enqueue({ + type: 'tool_call_end', + id: toolUse.id, + name: toolName, + status: value.status, + }) + return value + } catch (error) { + const toolCallEndTime = Date.now() + const cancelled = isAbortError(error) || !!request.abortSignal?.aborted + if (!cancelled) { + logger.error('Error processing tool call:', { error, toolName }) + } + const value = { + toolUse, + toolName, + toolArgs, + toolParams: {} as Record, + result: { + success: false as const, + output: undefined, + error: getErrorMessage(error, 'Tool execution failed'), + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + status: (cancelled ? 'cancelled' : 'error') as ToolCallEndStatus, + } + openToolStarts.delete(toolUse.id) + controller.enqueue({ + type: 'tool_call_end', + id: toolUse.id, + name: toolName, + status: value.status, + }) + return value + } + }) + ) + + const toolUseBlocks: Anthropic.Messages.ToolUseBlockParam[] = [] + const toolResultBlocks: Anthropic.Messages.ToolResultBlockParam[] = [] + + for (const value of orderedResults) { + const { + toolUse, + toolName, + toolArgs, + toolParams, + result, + startTime, + endTime, + duration, + } = value + + timeSegments.push({ + type: 'tool', + name: toolName, + startTime, + endTime, + duration, + toolCallId: toolUse.id, + }) + + let resultContent: unknown + if (result.success && result.output) { + toolResults.push(result.output as Record) + resultContent = result.output + } else { + resultContent = { + error: true, + message: result.error || 'Tool execution failed', + tool: toolName, + } + } + + toolCalls.push({ + name: toolName, + arguments: toolParams, + startTime: new Date(startTime).toISOString(), + endTime: new Date(endTime).toISOString(), + duration, + result: resultContent, + success: result.success, + }) + + toolUseBlocks.push({ + type: 'tool_use', + id: toolUse.id, + name: toolName, + input: toolArgs, + }) + + toolResultBlocks.push({ + type: 'tool_result', + tool_use_id: toolUse.id, + content: JSON.stringify(resultContent), + }) + } + + const thinkingBlocks = finalMessage.content.filter( + ( + item + ): item is + | Anthropic.Messages.ThinkingBlock + | Anthropic.Messages.RedactedThinkingBlock => + item.type === 'thinking' || item.type === 'redacted_thinking' + ) + + if (toolUseBlocks.length > 0) { + currentMessages.push({ + role: 'assistant', + content: [ + ...thinkingBlocks, + ...toolUseBlocks, + ] as Anthropic.Messages.ContentBlockParam[], + }) + } + if (toolResultBlocks.length > 0) { + currentMessages.push({ + role: 'user', + content: toolResultBlocks as Anthropic.Messages.ContentBlockParam[], + }) + } + + toolsTime += Date.now() - toolsStartTime + iterationCount++ + + if (request.abortSignal?.aborted) { + settleOpenTools(controller, openToolStarts, 'cancelled') + throw new DOMException('Stream aborted', 'AbortError') + } + } catch (error) { + settleOpenTools( + controller, + openToolStarts, + isAbortError(error) ? 'cancelled' : 'error' + ) + throw error + } + } + + const modelCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCostTotal = sumToolCosts(toolResults) + const cost = { + input: modelCost.input, + output: modelCost.output, + total: modelCost.total + (toolCostTotal || 0), + ...(toolCostTotal ? { toolCost: toolCostTotal } : {}), + } + + onComplete({ + content, + tokens, + cost, + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + modelTime, + toolsTime, + firstResponseTime, + iterations: iterationCount + 1, + }) + + controller.close() + } catch (error) { + const cancelled = isAbortError(error) + settleOpenTools(controller, openToolStarts, cancelled ? 'cancelled' : 'error') + controller.error(toError(error)) + } + }, + }) +} diff --git a/apps/sim/providers/anthropic/utils.test.ts b/apps/sim/providers/anthropic/utils.test.ts new file mode 100644 index 00000000000..b7202c511b7 --- /dev/null +++ b/apps/sim/providers/anthropic/utils.test.ts @@ -0,0 +1,106 @@ +/** + * @vitest-environment node + * + * Step 3 gate: Anthropic adapter emits AgentStreamEvent objects (thinking + text) + * from Messages stream fixtures; tool_use deltas are ignored until Step 7. + */ +import { describe, expect, it, vi } from 'vitest' +import { + anthropicRedactedThinkingExpectedText, + anthropicRedactedThinkingExpectedTraceThinking, + anthropicRedactedThinkingStreamEvents, + anthropicThinkingTextToolExpectedText, + anthropicThinkingTextToolExpectedThinking, + anthropicThinkingTextToolStreamEvents, +} from '@/providers/__fixtures__/anthropic' +import { createReadableStreamFromAnthropicStream } from '@/providers/anthropic/utils' +import type { AgentStreamEvent } from '@/providers/stream-events' + +async function collectEvents( + stream: ReadableStream +): Promise { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events +} + +describe('createReadableStreamFromAnthropicStream', () => { + it('emits thinking_delta then text_delta and ignores tool_use (thinking+text+tool fixture)', async () => { + const onComplete = vi.fn() + const stream = createReadableStreamFromAnthropicStream( + (async function* () { + yield* anthropicThinkingTextToolStreamEvents + })() as AsyncIterable, + onComplete + ) + + const events = await collectEvents(stream) + + expect(events.filter((e) => e.type === 'thinking_delta').map((e) => e.text)).toEqual([ + 'I should check the weather before answering. ', + 'Calling get_weather for SF.', + ]) + expect(events.filter((e) => e.type === 'text_delta')).toEqual([ + { + type: 'text_delta', + text: anthropicThinkingTextToolExpectedText, + turn: 'final', + }, + ]) + expect(events.some((e) => e.type === 'tool_call_start' || e.type === 'tool_call_end')).toBe( + false + ) + + expect(onComplete).toHaveBeenCalledTimes(1) + expect(onComplete.mock.calls[0][0]).toMatchObject({ + content: anthropicThinkingTextToolExpectedText, + thinking: anthropicThinkingTextToolExpectedThinking, + usage: { input_tokens: 42, output_tokens: expect.any(Number) }, + }) + }) + + it('records [redacted] for redacted_thinking blocks and streams text', async () => { + const onComplete = vi.fn() + const stream = createReadableStreamFromAnthropicStream( + (async function* () { + yield* anthropicRedactedThinkingStreamEvents + })() as AsyncIterable, + onComplete + ) + + const events = await collectEvents(stream) + expect(events.filter((e) => e.type === 'thinking_delta').map((e) => e.text)).toEqual([ + 'Visible follow-up reasoning after redaction.', + ]) + expect(events.filter((e) => e.type === 'text_delta')).toEqual([ + { + type: 'text_delta', + text: anthropicRedactedThinkingExpectedText, + turn: 'final', + }, + ]) + expect(onComplete.mock.calls[0][0]).toMatchObject({ + content: anthropicRedactedThinkingExpectedText, + thinking: anthropicRedactedThinkingExpectedTraceThinking, + }) + }) + + it('errors the readable stream when the source throws', async () => { + const stream = createReadableStreamFromAnthropicStream( + (async function* () { + yield { + type: 'content_block_delta', + delta: { type: 'text_delta', text: 'partial' }, + } + throw new Error('provider reset') + })() as AsyncIterable + ) + + await expect(collectEvents(stream)).rejects.toThrow('provider reset') + }) +}) diff --git a/apps/sim/providers/anthropic/utils.ts b/apps/sim/providers/anthropic/utils.ts index b9b001bb7a2..f9e27652c14 100644 --- a/apps/sim/providers/anthropic/utils.ts +++ b/apps/sim/providers/anthropic/utils.ts @@ -6,6 +6,7 @@ import type { } from '@anthropic-ai/sdk/resources' import { createLogger } from '@sim/logger' import { randomFloat } from '@sim/utils/random' +import type { AgentStreamEvent } from '@/providers/stream-events' import { trackForcedToolUsage } from '@/providers/utils' const logger = createLogger('AnthropicUtils') @@ -15,34 +16,101 @@ export interface AnthropicStreamUsage { output_tokens: number } +export interface AnthropicStreamComplete { + content: string + usage: AnthropicStreamUsage + /** Assembled thinking text for traces (redacted blocks become `[redacted]`). */ + thinking: string +} + +/** + * Converts an Anthropic Messages stream into an in-process + * {@link AgentStreamEvent} object stream (`thinking_delta` + `text_delta`). + * Tool_use / input_json deltas are ignored here — use + * {@link createAnthropicStreamingToolLoopStream} for the live tool loop. + */ export function createReadableStreamFromAnthropicStream( anthropicStream: AsyncIterable, - onComplete?: (content: string, usage: AnthropicStreamUsage) => void -): ReadableStream { - let fullContent = '' - let inputTokens = 0 - let outputTokens = 0 - - return new ReadableStream({ + onComplete?: (result: AnthropicStreamComplete) => void +): ReadableStream { + return new ReadableStream({ async start(controller) { try { + let fullContent = '' + const thinkingBlocks: string[] = [] + let currentThinking = '' + let inputTokens = 0 + let outputTokens = 0 + + const flushThinkingBlock = () => { + if (currentThinking) { + thinkingBlocks.push(currentThinking) + currentThinking = '' + } + } + for await (const event of anthropicStream) { if (event.type === 'message_start') { const startEvent = event as RawMessageStartEvent const usage: Usage = startEvent.message.usage inputTokens = usage.input_tokens - } else if (event.type === 'message_delta') { + continue + } + + if (event.type === 'message_delta') { const deltaEvent = event as RawMessageDeltaEvent outputTokens = deltaEvent.usage.output_tokens - } else if (event.type === 'content_block_delta' && event.delta.type === 'text_delta') { - const text = event.delta.text - fullContent += text - controller.enqueue(new TextEncoder().encode(text)) + continue + } + + if (event.type === 'content_block_start') { + const block = event.content_block as { type?: string } + if (block?.type === 'redacted_thinking') { + flushThinkingBlock() + thinkingBlocks.push('[redacted]') + } else if (block?.type === 'thinking') { + flushThinkingBlock() + } + continue + } + + if (event.type === 'content_block_stop') { + flushThinkingBlock() + continue + } + + if (event.type !== 'content_block_delta') { + continue + } + + const delta = event.delta as { + type?: string + text?: string + thinking?: string + } + + if (delta.type === 'thinking_delta' && typeof delta.thinking === 'string') { + currentThinking += delta.thinking + controller.enqueue({ type: 'thinking_delta', text: delta.thinking }) + continue + } + + if (delta.type === 'text_delta' && typeof delta.text === 'string') { + flushThinkingBlock() + fullContent += delta.text + controller.enqueue({ type: 'text_delta', text: delta.text, turn: 'final' }) } } + flushThinkingBlock() + if (onComplete) { - onComplete(fullContent, { input_tokens: inputTokens, output_tokens: outputTokens }) + onComplete({ + content: fullContent, + usage: { input_tokens: inputTokens, output_tokens: outputTokens }, + // Match enrichLastModelSegmentFromAnthropicResponse: join blocks with blank lines. + thinking: thinkingBlocks.filter(Boolean).join('\n\n'), + }) } controller.close() diff --git a/apps/sim/providers/azure-openai/index.ts b/apps/sim/providers/azure-openai/index.ts index e9c7cfefb4b..053d375a9c2 100644 --- a/apps/sim/providers/azure-openai/index.ts +++ b/apps/sim/providers/azure-openai/index.ts @@ -201,6 +201,7 @@ async function executeChatCompletionsRequest( timing: { kind: 'simple', segmentName: request.model }, initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromAzureOpenAIStream(streamResponse, (content, usage) => { output.content = content @@ -517,6 +518,7 @@ async function executeChatCompletionsRequest( count: toolCalls.length, } : undefined, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromAzureOpenAIStream(streamResponse, (content, usage) => { output.content = content diff --git a/apps/sim/providers/azure-openai/utils.ts b/apps/sim/providers/azure-openai/utils.ts index fec1e862e51..08a84c0209d 100644 --- a/apps/sim/providers/azure-openai/utils.ts +++ b/apps/sim/providers/azure-openai/utils.ts @@ -3,17 +3,24 @@ import type OpenAI from 'openai' import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' import type { Stream } from 'openai/streaming' -import { checkForForcedToolUsageOpenAI, createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' +import { checkForForcedToolUsageOpenAI } from '@/providers/utils' /** - * Creates a ReadableStream from an Azure OpenAI streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from an Azure OpenAI streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromAzureOpenAIStream( azureOpenAIStream: Stream, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(azureOpenAIStream, 'Azure OpenAI', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(azureOpenAIStream, { + providerName: 'Azure OpenAI', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } /** diff --git a/apps/sim/providers/baseten/index.ts b/apps/sim/providers/baseten/index.ts index a25fb29cb9c..a5f59d818bb 100644 --- a/apps/sim/providers/baseten/index.ts +++ b/apps/sim/providers/baseten/index.ts @@ -167,6 +167,7 @@ export const basetenProvider: ProviderConfig = { timing: { kind: 'simple', segmentName: request.model }, initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { output.content = content @@ -469,6 +470,7 @@ export const basetenProvider: ProviderConfig = { }, toolCalls: toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + streamFormat: 'agent-events-v1', createStream: ({ output }) => createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { output.content = content diff --git a/apps/sim/providers/baseten/utils.ts b/apps/sim/providers/baseten/utils.ts index ca5cf5dc5c0..d277f41a2c9 100644 --- a/apps/sim/providers/baseten/utils.ts +++ b/apps/sim/providers/baseten/utils.ts @@ -1,6 +1,8 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { checkForForcedToolUsageOpenAI, createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' +import { checkForForcedToolUsageOpenAI } from '@/providers/utils' /** * Checks if a model supports native structured outputs (json_schema). @@ -11,14 +13,19 @@ export async function supportsNativeStructuredOutputs(_modelId: string): Promise } /** - * Creates a ReadableStream from a Baseten streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a Baseten streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromOpenAIStream( openaiStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(openaiStream, 'Baseten', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(openaiStream, { + providerName: 'Baseten', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } /** diff --git a/apps/sim/providers/bedrock/index.ts b/apps/sim/providers/bedrock/index.ts index 4e512d15b48..8bda948d84d 100644 --- a/apps/sim/providers/bedrock/index.ts +++ b/apps/sim/providers/bedrock/index.ts @@ -15,7 +15,7 @@ import { } from '@aws-sdk/client-bedrock-runtime' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' -import type { IterationToolCall, StreamingExecution } from '@/executor/types' +import type { IterationToolCall, NormalizedBlockOutput, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { buildBedrockMessageContent } from '@/providers/attachments' import { @@ -24,6 +24,7 @@ import { generateToolUseId, getBedrockInferenceProfileId, } from '@/providers/bedrock/utils' +import { createBedrockStreamingToolLoopStream } from '@/providers/bedrock/streaming-tool-loop' import { getCachedProviderClient } from '@/providers/client-cache' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { createStreamingExecution } from '@/providers/streaming-execution' @@ -349,6 +350,60 @@ export const bedrockProvider: ProviderConfig = { const shouldStreamToolCalls = request.streamToolCalls ?? false + if (request.stream && shouldStreamToolCalls && bedrockTools && bedrockTools.length > 0) { + logger.info('Using streaming tool loop for Bedrock request') + + const providerStartTime = Date.now() + const providerStartTimeISO = new Date(providerStartTime).toISOString() + const timeSegments: TimeSegment[] = [] + const forcedTools = preparedTools?.forcedTools || [] + + return createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime: 0, + toolsTime: 0, + firstResponseTime: 0, + iterations: 1, + timeSegments, + }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { total: 0.0, input: 0.0, output: 0.0 }, + isStreaming: true, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => + createBedrockStreamingToolLoopStream({ + client, + modelId: bedrockModelId, + request, + messages, + system: systemPromptWithSchema.length > 0 ? systemPromptWithSchema : undefined, + inferenceConfig, + bedrockTools, + toolChoice, + logger, + timeSegments, + forcedTools, + onComplete: (result) => { + output.content = result.content + output.tokens = result.tokens + output.cost = result.cost + output.toolCalls = result.toolCalls as NormalizedBlockOutput['toolCalls'] + if (output.providerTiming) { + output.providerTiming.modelTime = result.modelTime + output.providerTiming.toolsTime = result.toolsTime + output.providerTiming.firstResponseTime = result.firstResponseTime + output.providerTiming.iterations = result.iterations + } + finalizeTiming() + }, + }), + }) + } + if (request.stream && (!bedrockTools || bedrockTools.length === 0)) { logger.info('Using streaming response for Bedrock request (no tools)') @@ -380,6 +435,7 @@ export const bedrockProvider: ProviderConfig = { initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { total: 0.0, input: 0.0, output: 0.0 }, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromBedrockStream(bedrockStream, (content, usage) => { output.content = content @@ -878,6 +934,7 @@ export const bedrockProvider: ProviderConfig = { toolCalls: toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromBedrockStream(bedrockStream, (streamContent, usage) => { output.content = streamContent diff --git a/apps/sim/providers/bedrock/streaming-tool-loop.test.ts b/apps/sim/providers/bedrock/streaming-tool-loop.test.ts new file mode 100644 index 00000000000..815b31e13fa --- /dev/null +++ b/apps/sim/providers/bedrock/streaming-tool-loop.test.ts @@ -0,0 +1,141 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { createBedrockStreamingToolLoopStream } from '@/providers/bedrock/streaming-tool-loop' +import type { AgentStreamEvent } from '@/providers/stream-events' + +async function collectEvents( + stream: ReadableStream +): Promise { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events +} + +vi.mock('@/tools', () => ({ + executeTool: vi.fn(async () => ({ + success: true, + output: { ok: true }, + })), +})) + +vi.mock('@/providers/utils', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + prepareToolExecution: vi.fn(() => ({ + toolParams: { url: 'https://example.com' }, + executionParams: { url: 'https://example.com' }, + })), + calculateCost: vi.fn(() => ({ + input: 0.01, + output: 0.02, + total: 0.03, + pricing: { input: 1, output: 2, updatedAt: new Date().toISOString() }, + })), + sumToolCosts: vi.fn(() => 0), + } +}) + +describe('createBedrockStreamingToolLoopStream (Step 9)', () => { + it('emits tool_call_start/end and final text; no invented thinking', async () => { + const turns = [ + (async function* () { + yield { + contentBlockStart: { + contentBlockIndex: 0, + start: { toolUse: { toolUseId: 'tooluse_1', name: 'http_request' } }, + }, + } + yield { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { toolUse: { input: '{"url":"https://example.com"}' } }, + }, + } + yield { + metadata: { usage: { inputTokens: 11, outputTokens: 4 } }, + } + yield { messageStop: { stopReason: 'tool_use' } } + })(), + (async function* () { + yield { + contentBlockDelta: { + contentBlockIndex: 0, + delta: { text: 'Request completed.' }, + }, + } + yield { + metadata: { usage: { inputTokens: 22, outputTokens: 6 } }, + } + yield { messageStop: { stopReason: 'end_turn' } } + })(), + ] + + let turnIdx = 0 + const client = { + send: vi.fn(async () => ({ stream: turns[turnIdx++] })), + } + + const onComplete = vi.fn() + const stream = createBedrockStreamingToolLoopStream({ + client: client as any, + modelId: 'us.anthropic.claude-sonnet-4-5-20250929-v1:0', + request: { + model: 'bedrock/anthropic.claude-sonnet-4-5-20250929-v1:0', + tools: [ + { + id: 'http_request', + name: 'http_request', + description: 'HTTP', + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + } as any, + messages: [{ role: 'user', content: [{ text: 'call it' }] }], + inferenceConfig: { temperature: 0.7 }, + bedrockTools: [ + { + toolSpec: { + name: 'http_request', + description: 'HTTP', + inputSchema: { json: { type: 'object', properties: {} } }, + }, + }, + ], + toolChoice: { auto: {} }, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as any, + timeSegments: [], + onComplete, + }) + + const events = await collectEvents(stream) + + expect(events.some((e) => e.type === 'thinking_delta')).toBe(false) + expect(events.filter((e) => e.type === 'tool_call_start')).toEqual([ + { type: 'tool_call_start', id: 'tooluse_1', name: 'http_request' }, + ]) + expect(events.filter((e) => e.type === 'tool_call_end')).toEqual([ + { type: 'tool_call_end', id: 'tooluse_1', name: 'http_request', status: 'success' }, + ]) + expect( + events + .filter((e) => e.type === 'text_delta' && e.turn === 'final') + .map((e) => e.text) + .join('') + ).toBe('Request completed.') + + expect(onComplete).toHaveBeenCalledWith( + expect.objectContaining({ + content: 'Request completed.', + toolCalls: expect.objectContaining({ count: 1 }), + }) + ) + }) +}) diff --git a/apps/sim/providers/bedrock/streaming-tool-loop.ts b/apps/sim/providers/bedrock/streaming-tool-loop.ts new file mode 100644 index 00000000000..b3e39816ea3 --- /dev/null +++ b/apps/sim/providers/bedrock/streaming-tool-loop.ts @@ -0,0 +1,541 @@ +/** + * Live Bedrock ConverseStream tool loop (Step 9). + * + * Capability-honest: text + tool_call_start/end only — no invented thinking. + * Final-turn-only answer projection via `turn` tags. Abort → cancelled. + */ + +import { + type ContentBlock, + type ConversationRole, + ConverseStreamCommand, + type BedrockRuntimeClient, + type Message as BedrockMessage, + type SystemContentBlock, + type Tool, + type ToolConfiguration, + type ToolResultBlock, + type ToolUseBlock, +} from '@aws-sdk/client-bedrock-runtime' +import type { Logger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import type { NormalizedBlockOutput } from '@/executor/types' +import { MAX_TOOL_ITERATIONS } from '@/providers' +import { checkForForcedToolUsage, generateToolUseId } from '@/providers/bedrock/utils' +import type { AgentStreamEvent, ToolCallEndStatus } from '@/providers/stream-events' +import { enrichLastModelSegment } from '@/providers/trace-enrichment' +import type { ProviderRequest, TimeSegment } from '@/providers/types' +import { + calculateCost, + prepareToolExecution, + sumToolCosts, +} from '@/providers/utils' +import { executeTool } from '@/tools' + +export interface BedrockStreamingToolLoopComplete { + content: string + tokens: { input: number; output: number; total: number } + cost: NormalizedBlockOutput['cost'] + toolCalls?: { list: unknown[]; count: number } + modelTime: number + toolsTime: number + firstResponseTime: number + iterations: number +} + +export interface CreateBedrockStreamingToolLoopStreamOptions { + client: BedrockRuntimeClient + modelId: string + request: ProviderRequest + messages: BedrockMessage[] + system?: SystemContentBlock[] + inferenceConfig: { temperature: number; maxTokens?: number } + bedrockTools: Tool[] + toolChoice: ToolConfiguration['toolChoice'] + logger: Logger + timeSegments: TimeSegment[] + forcedTools?: string[] + onComplete: (result: BedrockStreamingToolLoopComplete) => void +} + +interface AssembledToolUse { + toolUseId: string + name: string + inputJson: string +} + +function isAbortError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false + const name = (error as { name?: string }).name + return name === 'AbortError' || name === 'APIUserAbortError' +} + +function settleOpenTools( + controller: ReadableStreamDefaultController, + openTools: Map, + status: ToolCallEndStatus +): void { + for (const [id, name] of openTools) { + controller.enqueue({ type: 'tool_call_end', id, name, status }) + } + openTools.clear() +} + +function parseToolInput(inputJson: string): Record { + if (!inputJson.trim()) return {} + try { + const parsed = JSON.parse(inputJson) + return parsed && typeof parsed === 'object' && !Array.isArray(parsed) + ? (parsed as Record) + : {} + } catch { + return {} + } +} + +async function drainBedrockTurn( + stream: AsyncIterable, + controller: ReadableStreamDefaultController, + openTools: Map +): Promise<{ + text: string + textChunks: string[] + toolUses: AssembledToolUse[] + inputTokens: number + outputTokens: number + stopReason?: string +}> { + let text = '' + const textChunks: string[] = [] + const toolsByIndex = new Map() + let currentIndex: number | undefined + let inputTokens = 0 + let outputTokens = 0 + let stopReason: string | undefined + + for await (const event of stream) { + if (event.contentBlockStart) { + currentIndex = event.contentBlockStart.contentBlockIndex + const start = event.contentBlockStart.start + if (start && 'toolUse' in start && start.toolUse) { + const id = start.toolUse.toolUseId || generateToolUseId(start.toolUse.name || 'tool') + const name = start.toolUse.name || '' + if (typeof currentIndex === 'number') { + toolsByIndex.set(currentIndex, { toolUseId: id, name, inputJson: '' }) + } + if (id && name && !openTools.has(id)) { + openTools.set(id, name) + controller.enqueue({ type: 'tool_call_start', id, name }) + } + } + continue + } + + if (event.contentBlockDelta) { + const idx = event.contentBlockDelta.contentBlockIndex ?? currentIndex + const delta = event.contentBlockDelta.delta + if (delta?.text) { + text += delta.text + textChunks.push(delta.text) + } + if (delta && 'toolUse' in delta && delta.toolUse?.input && typeof idx === 'number') { + const pending = toolsByIndex.get(idx) + if (pending) { + pending.inputJson += delta.toolUse.input + } + } + continue + } + + if (event.metadata?.usage) { + inputTokens = event.metadata.usage.inputTokens ?? inputTokens + outputTokens = event.metadata.usage.outputTokens ?? outputTokens + continue + } + + if (event.messageStop?.stopReason) { + stopReason = event.messageStop.stopReason + } + } + + return { + text, + textChunks, + toolUses: [...toolsByIndex.values()], + inputTokens, + outputTokens, + stopReason, + } +} + +/** + * Multi-turn Bedrock ConverseStream tool loop as agent-events-v1. + */ +export function createBedrockStreamingToolLoopStream( + options: CreateBedrockStreamingToolLoopStreamOptions +): ReadableStream { + const { + client, + modelId, + request, + messages: initialMessages, + system, + inferenceConfig, + bedrockTools, + logger, + timeSegments, + onComplete, + } = options + const forcedTools = options.forcedTools ?? [] + const originalToolChoice = options.toolChoice + + return new ReadableStream({ + async start(controller) { + const currentMessages = [...initialMessages] + let toolChoice = originalToolChoice + let usedForcedTools: string[] = [] + let hasUsedForcedTool = false + + let content = '' + let iterationCount = 0 + let modelTime = 0 + let toolsTime = 0 + let firstResponseTime = 0 + const tokens = { input: 0, output: 0, total: 0 } + let costInput = 0 + let costOutput = 0 + let costTotal = 0 + let latestPricing: ReturnType['pricing'] | undefined + const toolCalls: unknown[] = [] + const toolResults: Record[] = [] + const openToolStarts = new Map() + + try { + while (iterationCount < MAX_TOOL_ITERATIONS) { + if (request.abortSignal?.aborted) { + settleOpenTools(controller, openToolStarts, 'cancelled') + throw new DOMException('Stream aborted', 'AbortError') + } + + const toolConfig: ToolConfiguration | undefined = bedrockTools.length + ? { tools: bedrockTools, toolChoice } + : undefined + + const modelStart = Date.now() + const command = new ConverseStreamCommand({ + modelId, + messages: currentMessages, + system: system && system.length > 0 ? system : undefined, + inferenceConfig, + toolConfig, + }) + + const streamResponse = await client.send( + command, + request.abortSignal ? { abortSignal: request.abortSignal } : undefined + ) + if (!streamResponse.stream) { + throw new Error('No stream returned from Bedrock') + } + + const drained = await drainBedrockTurn( + streamResponse.stream, + controller, + openToolStarts + ) + const modelEnd = Date.now() + const thisModelTime = modelEnd - modelStart + modelTime += thisModelTime + if (iterationCount === 0) { + firstResponseTime = thisModelTime + } + + timeSegments.push({ + type: 'model', + name: request.model, + startTime: modelStart, + endTime: modelEnd, + duration: thisModelTime, + }) + + tokens.input += drained.inputTokens + tokens.output += drained.outputTokens + tokens.total += drained.inputTokens + drained.outputTokens + + const turnCost = calculateCost(request.model, drained.inputTokens, drained.outputTokens) + costInput += turnCost.input + costOutput += turnCost.output + costTotal += turnCost.total + latestPricing = turnCost.pricing + + const turnTag = drained.toolUses.length > 0 ? 'intermediate' : 'final' + for (const chunk of drained.textChunks) { + controller.enqueue({ type: 'text_delta', text: chunk, turn: turnTag }) + } + if (drained.text) { + content = drained.text + } + + const assembledToolUses: ToolUseBlock[] = drained.toolUses.map((t) => ({ + toolUseId: t.toolUseId, + name: t.name, + input: parseToolInput(t.inputJson), + })) + + enrichLastModelSegment(timeSegments, { + assistantContent: drained.text || undefined, + toolCalls: + assembledToolUses.length > 0 + ? assembledToolUses.map((t) => ({ + id: t.toolUseId || '', + name: t.name || '', + arguments: (t.input as Record) || {}, + })) + : undefined, + finishReason: drained.stopReason, + tokens: { + input: drained.inputTokens, + output: drained.outputTokens, + total: drained.inputTokens + drained.outputTokens, + }, + cost: { + input: turnCost.input, + output: turnCost.output, + total: turnCost.total, + }, + provider: 'bedrock', + }) + + const forcedCheck = checkForForcedToolUsage( + assembledToolUses.map((t) => ({ name: t.name || '' })), + toolChoice, + forcedTools, + usedForcedTools + ) + if (forcedCheck) { + hasUsedForcedTool = forcedCheck.hasUsedForcedTool + usedForcedTools = forcedCheck.usedForcedTools + } + + if (assembledToolUses.length === 0) { + break + } + + const toolsStartTime = Date.now() + const orderedResults = await Promise.all( + assembledToolUses.map(async (toolUse) => { + const toolCallStartTime = Date.now() + const toolName = toolUse.name || '' + const toolArgs = (toolUse.input as Record) || {} + const toolUseId = toolUse.toolUseId || generateToolUseId(toolName) + + try { + if (request.abortSignal?.aborted) { + throw new DOMException('Stream aborted', 'AbortError') + } + + const tool = request.tools?.find((t) => t.id === toolName) + if (!tool) { + const value = { + toolUse, + toolUseId, + toolName, + toolArgs, + toolParams: {} as Record, + result: { + success: false as const, + output: undefined, + error: `Tool not found: ${toolName}`, + }, + startTime: toolCallStartTime, + endTime: Date.now(), + duration: Date.now() - toolCallStartTime, + status: 'error' as ToolCallEndStatus, + } + openToolStarts.delete(toolUseId) + controller.enqueue({ + type: 'tool_call_end', + id: toolUseId, + name: toolName, + status: 'error', + }) + return value + } + + const { toolParams, executionParams } = prepareToolExecution( + tool, + toolArgs, + request + ) + const result = await executeTool(toolName, executionParams, { + signal: request.abortSignal, + }) + const toolCallEndTime = Date.now() + const status: ToolCallEndStatus = result.success ? 'success' : 'error' + openToolStarts.delete(toolUseId) + controller.enqueue({ + type: 'tool_call_end', + id: toolUseId, + name: toolName, + status, + }) + return { + toolUse, + toolUseId, + toolName, + toolArgs, + toolParams, + result, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + status, + } + } catch (error) { + const toolCallEndTime = Date.now() + const cancelled = isAbortError(error) || !!request.abortSignal?.aborted + if (!cancelled) { + logger.error('Error processing tool call:', { error, toolName }) + } + const status: ToolCallEndStatus = cancelled ? 'cancelled' : 'error' + openToolStarts.delete(toolUseId) + controller.enqueue({ + type: 'tool_call_end', + id: toolUseId, + name: toolName, + status, + }) + return { + toolUse, + toolUseId, + toolName, + toolArgs, + toolParams: {} as Record, + result: { + success: false as const, + output: undefined, + error: getErrorMessage(error, 'Tool execution failed'), + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + status, + } + } + }) + ) + + toolsTime += Date.now() - toolsStartTime + + const assistantContent: ContentBlock[] = assembledToolUses.map((toolUse) => ({ + toolUse: { + toolUseId: toolUse.toolUseId, + name: toolUse.name, + input: toolUse.input, + }, + })) + currentMessages.push({ + role: 'assistant' as ConversationRole, + content: assistantContent, + }) + + const toolResultContent: ContentBlock[] = [] + for (const value of orderedResults) { + const { + toolUseId, + toolName, + toolParams, + result, + startTime, + endTime, + duration, + } = value + + timeSegments.push({ + type: 'tool', + name: toolName, + startTime, + endTime, + duration, + toolCallId: toolUseId, + }) + + let resultContent: unknown + if (result.success && result.output) { + toolResults.push(result.output as Record) + resultContent = result.output + } else { + resultContent = { + error: true, + message: result.error || 'Tool execution failed', + tool: toolName, + } + } + + toolCalls.push({ + name: toolName, + arguments: toolParams, + startTime: new Date(startTime).toISOString(), + endTime: new Date(endTime).toISOString(), + duration, + result: resultContent, + success: result.success, + }) + + const toolResultBlock: ToolResultBlock = { + toolUseId, + content: [{ text: JSON.stringify(resultContent) }], + } + toolResultContent.push({ toolResult: toolResultBlock }) + } + + if (toolResultContent.length > 0) { + currentMessages.push({ + role: 'user' as ConversationRole, + content: toolResultContent, + }) + } + + if (typeof originalToolChoice === 'object' && hasUsedForcedTool && forcedTools.length > 0) { + const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool)) + toolChoice = remainingTools.length > 0 ? { tool: { name: remainingTools[0] } } : { auto: {} } + } else if (hasUsedForcedTool && typeof originalToolChoice === 'object') { + toolChoice = { auto: {} } + } + + iterationCount += 1 + } + + const toolCost = sumToolCosts(toolResults) + onComplete({ + content, + tokens, + cost: { + input: costInput, + output: costOutput, + toolCost: toolCost || undefined, + total: costTotal + toolCost, + pricing: latestPricing, + }, + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + modelTime, + toolsTime, + firstResponseTime, + iterations: iterationCount + 1, + }) + controller.close() + } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + settleOpenTools(controller, openToolStarts, 'cancelled') + } else { + settleOpenTools(controller, openToolStarts, 'error') + logger.error('Bedrock streaming tool loop failed', { + error: toError(error).message, + }) + } + controller.error(error) + } + }, + }) +} diff --git a/apps/sim/providers/bedrock/utils.stream.test.ts b/apps/sim/providers/bedrock/utils.stream.test.ts new file mode 100644 index 00000000000..15c75fe6dd9 --- /dev/null +++ b/apps/sim/providers/bedrock/utils.stream.test.ts @@ -0,0 +1,51 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { createReadableStreamFromBedrockStream } from '@/providers/bedrock/utils' +import type { AgentStreamEvent } from '@/providers/stream-events' + +async function collectEvents( + stream: ReadableStream +): Promise { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events +} + +describe('createReadableStreamFromBedrockStream (Step 9)', () => { + it('emits tool_call_start then text; no invented thinking', async () => { + const onComplete = vi.fn() + const stream = createReadableStreamFromBedrockStream( + (async function* () { + yield { + contentBlockStart: { + start: { + toolUse: { toolUseId: 'tooluse_1', name: 'http_request' }, + }, + }, + } as any + yield { + contentBlockDelta: { delta: { text: 'Done' } }, + } as any + yield { + metadata: { usage: { inputTokens: 2, outputTokens: 3 } }, + } as any + })(), + onComplete + ) + + const events = await collectEvents(stream) + expect(events).toEqual([ + { type: 'tool_call_start', id: 'tooluse_1', name: 'http_request' }, + { type: 'text_delta', text: 'Done', turn: 'final' }, + ]) + expect(events.some((e) => e.type === 'thinking_delta')).toBe(false) + expect(onComplete).toHaveBeenCalledWith('Done', { inputTokens: 2, outputTokens: 3 }) + }) +}) diff --git a/apps/sim/providers/bedrock/utils.ts b/apps/sim/providers/bedrock/utils.ts index a385ffd053b..688943f16bd 100644 --- a/apps/sim/providers/bedrock/utils.ts +++ b/apps/sim/providers/bedrock/utils.ts @@ -1,6 +1,7 @@ import type { ConverseStreamOutput } from '@aws-sdk/client-bedrock-runtime' import { createLogger } from '@sim/logger' import { randomFloat } from '@sim/utils/random' +import type { AgentStreamEvent } from '@/providers/stream-events' import { trackForcedToolUsage } from '@/providers/utils' const logger = createLogger('BedrockUtils') @@ -10,10 +11,15 @@ export interface BedrockStreamUsage { outputTokens: number } +/** + * Bedrock ConverseStream → agent-events-v1. + * Capability-honest: text deltas always; tool_call_start when toolUse id+name + * appear on contentBlockStart. No thinking_delta unless/until Bedrock exposes it. + */ export function createReadableStreamFromBedrockStream( bedrockStream: AsyncIterable, onComplete?: (content: string, usage: BedrockStreamUsage) => void -): ReadableStream { +): ReadableStream { let fullContent = '' let inputTokens = 0 let outputTokens = 0 @@ -22,10 +28,20 @@ export function createReadableStreamFromBedrockStream( async start(controller) { try { for await (const event of bedrockStream) { + const startBlock = event.contentBlockStart?.start + if (startBlock && 'toolUse' in startBlock && startBlock.toolUse) { + const toolUse = startBlock.toolUse + const id = toolUse.toolUseId + const name = toolUse.name + if (id && name) { + controller.enqueue({ type: 'tool_call_start', id, name }) + } + } + if (event.contentBlockDelta?.delta?.text) { const text = event.contentBlockDelta.delta.text fullContent += text - controller.enqueue(new TextEncoder().encode(text)) + controller.enqueue({ type: 'text_delta', text, turn: 'final' }) } else if (event.metadata?.usage) { inputTokens = event.metadata.usage.inputTokens ?? 0 outputTokens = event.metadata.usage.outputTokens ?? 0 diff --git a/apps/sim/providers/cerebras/index.ts b/apps/sim/providers/cerebras/index.ts index c351ffc4268..992d4ffdb5a 100644 --- a/apps/sim/providers/cerebras/index.ts +++ b/apps/sim/providers/cerebras/index.ts @@ -133,6 +133,7 @@ export const cerebrasProvider: ProviderConfig = { initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => createReadableStreamFromCerebrasStream(streamResponse, (content, usage) => { output.content = content @@ -492,6 +493,7 @@ export const cerebrasProvider: ProviderConfig = { } : undefined, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => createReadableStreamFromCerebrasStream(streamResponse, (content, usage) => { output.content = content diff --git a/apps/sim/providers/cerebras/utils.ts b/apps/sim/providers/cerebras/utils.ts index 830d0d67140..96a36bd7be2 100644 --- a/apps/sim/providers/cerebras/utils.ts +++ b/apps/sim/providers/cerebras/utils.ts @@ -1,5 +1,6 @@ import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' interface CerebrasChunk { choices?: Array<{ @@ -15,12 +16,17 @@ interface CerebrasChunk { } /** - * Creates a ReadableStream from a Cerebras streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a Cerebras streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromCerebrasStream( cerebrasStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(cerebrasStream as any, 'Cerebras', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(cerebrasStream as any, { + providerName: 'Cerebras', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } diff --git a/apps/sim/providers/deepseek/index.test.ts b/apps/sim/providers/deepseek/index.test.ts new file mode 100644 index 00000000000..aa21248450c --- /dev/null +++ b/apps/sim/providers/deepseek/index.test.ts @@ -0,0 +1,99 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ProviderRequest } from '@/providers/types' + +const { mockCreate } = vi.hoisted(() => ({ + mockCreate: vi.fn(), +})) + +vi.mock('openai', () => ({ + default: vi.fn().mockImplementation( + class { + chat = { completions: { create: mockCreate } } + } + ), +})) + +vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 })) + +vi.mock('@/providers/models', () => ({ + getProviderModels: vi.fn(() => ['deepseek-chat']), + getProviderDefaultModel: vi.fn(() => 'deepseek-chat'), +})) + +vi.mock('@/providers/attachments', () => ({ + formatMessagesForProvider: vi.fn((messages) => messages), +})) + +vi.mock('@/providers/deepseek/utils', () => ({ + createReadableStreamFromDeepseekStream: vi.fn(), +})) + +vi.mock('@/providers/openai-compat/streaming-tool-loop', () => ({ + createOpenAICompatStreamingToolLoopStream: vi.fn(), +})) + +vi.mock('@/providers/streaming-execution', () => ({ + createStreamingExecution: vi.fn((args) => args), +})) + +vi.mock('@/providers/trace-enrichment', () => ({ + enrichLastModelSegmentFromChatCompletions: vi.fn(), +})) + +vi.mock('@/providers/utils', () => ({ + calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), + prepareToolExecution: vi.fn((_tool, args) => ({ toolParams: args, executionParams: args })), + prepareToolsWithUsageControl: vi.fn(() => ({ + tools: [], + toolChoice: undefined, + forcedTools: [], + hasFilteredTools: false, + })), + sumToolCosts: vi.fn(() => 0), + trackForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })), +})) + +vi.mock('@/tools', () => ({ executeTool: vi.fn() })) + +import { deepseekProvider } from '@/providers/deepseek/index' + +function request(overrides: Partial = {}): ProviderRequest { + return { + model: 'deepseek-chat', + apiKey: 'test-key', + messages: [{ role: 'user', content: 'hi' }], + ...overrides, + } +} + +describe('deepseekProvider thinking payload', () => { + beforeEach(() => { + mockCreate.mockReset() + mockCreate.mockResolvedValue({ + choices: [{ message: { content: 'ok', tool_calls: [] } }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }) + }) + + it('sets thinking: { type: enabled } when thinkingLevel is enabled', async () => { + await deepseekProvider.executeRequest(request({ thinkingLevel: 'enabled' })) + expect(mockCreate).toHaveBeenCalled() + const payload = mockCreate.mock.calls[0][0] + expect(payload.thinking).toEqual({ type: 'enabled' }) + }) + + it('omits thinking when thinkingLevel is none', async () => { + await deepseekProvider.executeRequest(request({ thinkingLevel: 'none' })) + const payload = mockCreate.mock.calls[0][0] + expect(payload.thinking).toBeUndefined() + }) + + it('omits thinking when thinkingLevel is unset', async () => { + await deepseekProvider.executeRequest(request()) + const payload = mockCreate.mock.calls[0][0] + expect(payload.thinking).toBeUndefined() + }) +}) diff --git a/apps/sim/providers/deepseek/index.ts b/apps/sim/providers/deepseek/index.ts index 37f09254be3..109592c41e7 100644 --- a/apps/sim/providers/deepseek/index.ts +++ b/apps/sim/providers/deepseek/index.ts @@ -1,11 +1,12 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import OpenAI from 'openai' -import type { StreamingExecution } from '@/executor/types' +import type { NormalizedBlockOutput, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { createReadableStreamFromDeepseekStream } from '@/providers/deepseek/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createOpenAICompatStreamingToolLoopStream } from '@/providers/openai-compat/streaming-tool-loop' import { createStreamingExecution } from '@/providers/streaming-execution' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' @@ -84,6 +85,11 @@ export const deepseekProvider: ProviderConfig = { if (request.temperature !== undefined) payload.temperature = request.temperature if (request.maxTokens != null) payload.max_tokens = request.maxTokens + // DeepSeek Think mode: reasoning_content streams when enabled (or inherent on reasoner). + if (request.thinkingLevel && request.thinkingLevel !== 'none') { + payload.thinking = { type: 'enabled' } + } + let preparedTools: ReturnType | null = null if (tools?.length) { @@ -111,6 +117,68 @@ export const deepseekProvider: ProviderConfig = { } } + const shouldStreamToolCalls = request.streamToolCalls ?? false + + if (request.stream && shouldStreamToolCalls && payload.tools?.length) { + logger.info('Using streaming tool loop for DeepSeek request') + + const timeSegments: TimeSegment[] = [] + const forcedTools = preparedTools?.forcedTools || [] + + return createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime: 0, + toolsTime: 0, + firstResponseTime: 0, + iterations: 1, + timeSegments, + }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { total: 0.0, input: 0.0, output: 0.0 }, + isStreaming: true, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => + createOpenAICompatStreamingToolLoopStream({ + providerName: 'Deepseek', + request, + basePayload: payload, + messages: formattedMessages as any, + createStream: async (params, options) => + deepseek.chat.completions.create( + { ...params, stream: true }, + options + ), + createBlocking: async (params, options) => + deepseek.chat.completions.create( + { ...params, stream: false }, + options + ), + logger, + timeSegments, + forcedTools, + preserveAssistantReasoning: + !!request.thinkingLevel && request.thinkingLevel !== 'none', + onComplete: (result) => { + output.content = result.content + output.tokens = result.tokens + output.cost = result.cost + output.toolCalls = result.toolCalls as NormalizedBlockOutput['toolCalls'] + if (output.providerTiming) { + output.providerTiming.modelTime = result.modelTime + output.providerTiming.toolsTime = result.toolsTime + output.providerTiming.firstResponseTime = result.firstResponseTime + output.providerTiming.iterations = result.iterations + } + finalizeTiming() + }, + }), + }) + } + if (request.stream && (!tools || tools.length === 0)) { logger.info('Using streaming response for DeepSeek request (no tools)') @@ -130,26 +198,37 @@ export const deepseekProvider: ProviderConfig = { initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromDeepseekStream(streamResponse as any, (content, usage) => { - output.content = content - output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } + createReadableStreamFromDeepseekStream( + streamResponse as any, + (content, usage, thinking) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } + + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, + } - const costResult = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, + if (thinking) { + const segment = output.providerTiming?.timeSegments?.[0] + if (segment) { + segment.thinkingContent = thinking + } + } } - }), + ), }) return streamingResult @@ -281,7 +360,8 @@ export const deepseekProvider: ProviderConfig = { const executionResults = await Promise.allSettled(toolExecutionPromises) - currentMessages.push({ + const assistantMessage = currentResponse.choices[0]?.message + const assistantHistory: Record = { role: 'assistant', content: null, tool_calls: toolCallsInResponse.map((tc) => ({ @@ -292,7 +372,17 @@ export const deepseekProvider: ProviderConfig = { arguments: tc.function.arguments, }, })), - }) + } + if (request.thinkingLevel && request.thinkingLevel !== 'none' && assistantMessage) { + const reasoningContent = (assistantMessage as { reasoning_content?: string }) + .reasoning_content + if (typeof reasoningContent === 'string' && reasoningContent.length > 0) { + assistantHistory.reasoning_content = reasoningContent + } + } + currentMessages.push( + assistantHistory as OpenAI.Chat.Completions.ChatCompletionMessageParam + ) for (const settledResult of executionResults) { if (settledResult.status === 'rejected' || !settledResult.value) continue @@ -480,28 +570,39 @@ export const deepseekProvider: ProviderConfig = { } : undefined, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromDeepseekStream(streamResponse as any, (content, usage) => { - output.content = content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } + createReadableStreamFromDeepseekStream( + streamResponse as any, + (content, usage, thinking) => { + output.content = content + output.tokens = { + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, + total: tokens.total + usage.total_tokens, + } - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, + const streamCost = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + const tc = sumToolCosts(toolResults) + output.cost = { + input: accumulatedCost.input + streamCost.input, + output: accumulatedCost.output + streamCost.output, + toolCost: tc || undefined, + total: accumulatedCost.total + streamCost.total + tc, + } + + if (thinking) { + const lastModel = [...timeSegments].reverse().find((s) => s.type === 'model') + if (lastModel) { + lastModel.thinkingContent = thinking + } + } } - }), + ), }) return streamingResult diff --git a/apps/sim/providers/deepseek/utils.ts b/apps/sim/providers/deepseek/utils.ts index 1c7a67488b2..ccfeb9e10fc 100644 --- a/apps/sim/providers/deepseek/utils.ts +++ b/apps/sim/providers/deepseek/utils.ts @@ -1,14 +1,20 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' /** - * Creates a ReadableStream from a DeepSeek streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a DeepSeek streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromDeepseekStream( deepseekStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(deepseekStream, 'Deepseek', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(deepseekStream, { + providerName: 'Deepseek', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } diff --git a/apps/sim/providers/fireworks/index.ts b/apps/sim/providers/fireworks/index.ts index c6981d4abf0..f30a58768bb 100644 --- a/apps/sim/providers/fireworks/index.ts +++ b/apps/sim/providers/fireworks/index.ts @@ -167,6 +167,7 @@ export const fireworksProvider: ProviderConfig = { timing: { kind: 'simple', segmentName: request.model }, initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { output.content = content @@ -469,6 +470,7 @@ export const fireworksProvider: ProviderConfig = { }, toolCalls: toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + streamFormat: 'agent-events-v1', createStream: ({ output }) => createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { output.content = content diff --git a/apps/sim/providers/fireworks/utils.ts b/apps/sim/providers/fireworks/utils.ts index 70444e07b69..c4abd3111bd 100644 --- a/apps/sim/providers/fireworks/utils.ts +++ b/apps/sim/providers/fireworks/utils.ts @@ -1,6 +1,8 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { checkForForcedToolUsageOpenAI, createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' +import { checkForForcedToolUsageOpenAI } from '@/providers/utils' /** * Checks if a model supports native structured outputs (json_schema). @@ -11,14 +13,19 @@ export async function supportsNativeStructuredOutputs(_modelId: string): Promise } /** - * Creates a ReadableStream from a Fireworks streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a Fireworks streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromOpenAIStream( openaiStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(openaiStream, 'Fireworks', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(openaiStream, { + providerName: 'Fireworks', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } /** diff --git a/apps/sim/providers/gemini/core.ts b/apps/sim/providers/gemini/core.ts index 1419df73782..67b2d8f5815 100644 --- a/apps/sim/providers/gemini/core.ts +++ b/apps/sim/providers/gemini/core.ts @@ -13,7 +13,7 @@ import { } from '@google/genai' import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' -import type { IterationToolCall, StreamingExecution } from '@/executor/types' +import type { IterationToolCall, NormalizedBlockOutput, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { checkForForcedToolUsage, @@ -29,6 +29,9 @@ import { supportsDisablingGemini25Thinking, } from '@/providers/google/utils' import { enrichLastModelSegment } from '@/providers/trace-enrichment' +import { ensureToolCallId } from '@/providers/tool-call-id' +import { createStreamingExecution } from '@/providers/streaming-execution' +import { createGeminiStreamingToolLoopStream } from '@/providers/gemini/streaming-tool-loop' import type { FunctionCallResponse, ProviderRequest, @@ -228,7 +231,7 @@ async function executeToolCallsBatch( startTime: r.startTime, endTime: r.endTime, duration: r.duration, - toolCallId: r.part.functionCall?.id ?? undefined, + toolCallId: ensureToolCallId(r.part.functionCall?.id, 'gemini'), }) totalToolsTime += r.duration @@ -955,8 +958,9 @@ export async function executeGeminiRequest( } // Gemini 3.x takes thinkingLevel directly; Gemini 2.5-series rejects it and needs thinkingBudget. + // includeThoughts: true is required for thought parts to appear (Step 9 capability-honest). if (request.thinkingLevel && request.thinkingLevel !== 'none') { - const thinkingConfig: ThinkingConfig = { includeThoughts: false } + const thinkingConfig: ThinkingConfig = { includeThoughts: true } if (isGemini3Model(model)) { thinkingConfig.thinkingLevel = mapToThinkingLevel(request.thinkingLevel) } else { @@ -1019,8 +1023,60 @@ export async function executeGeminiRequest( } const initialCallTime = Date.now() + const shouldStreamToolCalls = request.streamToolCalls ?? false const shouldStream = request.stream && !tools?.length + // Live streaming tool loop (Step 9) + if (request.stream && shouldStreamToolCalls && tools?.length) { + logger.info('Using streaming tool loop for Gemini request') + + const timeSegments: TimeSegment[] = [] + const forcedTools = preparedTools?.forcedTools ?? [] + + return createStreamingExecution({ + model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime: 0, + toolsTime: 0, + firstResponseTime: 0, + iterations: 1, + timeSegments, + }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { total: 0.0, input: 0.0, output: 0.0 }, + isStreaming: true, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => + createGeminiStreamingToolLoopStream({ + ai, + model, + baseConfig: geminiConfig, + contents, + request, + logger, + timeSegments, + forcedTools, + toolConfig, + onComplete: (result) => { + output.content = result.content + output.tokens = result.tokens + output.cost = result.cost + output.toolCalls = result.toolCalls as NormalizedBlockOutput['toolCalls'] + if (output.providerTiming) { + output.providerTiming.modelTime = result.modelTime + output.providerTiming.toolsTime = result.toolsTime + output.providerTiming.firstResponseTime = result.firstResponseTime + output.providerTiming.iterations = result.iterations + } + finalizeTiming() + }, + }), + }) + } + // Streaming without tools if (shouldStream) { logger.info('Handling Gemini streaming response') @@ -1042,7 +1098,7 @@ export async function executeGeminiRequest( const stream = createReadableStreamFromGeminiStream( streamGenerator, - (content: string, usage: GeminiUsage) => { + (content: string, usage: GeminiUsage, thinking?: string) => { streamingResult.execution.output.content = content streamingResult.execution.output.tokens = { input: usage.promptTokenCount, @@ -1057,6 +1113,13 @@ export async function executeGeminiRequest( ) streamingResult.execution.output.cost = costResult + if (thinking) { + const segment = streamingResult.execution.output.providerTiming?.timeSegments?.[0] + if (segment) { + segment.thinkingContent = thinking + } + } + const streamEndTime = Date.now() if (streamingResult.execution.output.providerTiming) { streamingResult.execution.output.providerTiming.endTime = new Date( @@ -1073,7 +1136,7 @@ export async function executeGeminiRequest( } ) - return { ...streamingResult, stream } + return { ...streamingResult, stream, streamFormat: 'agent-events-v1' as const } } // Non-streaming request @@ -1193,7 +1256,7 @@ export async function executeGeminiRequest( const stream = createReadableStreamFromGeminiStream( streamGenerator, - (streamContent: string, usage: GeminiUsage) => { + (streamContent: string, usage: GeminiUsage, thinking?: string) => { streamingResult.execution.output.content = streamContent streamingResult.execution.output.tokens = { input: accumulatedTokens.input + usage.promptTokenCount, @@ -1215,6 +1278,14 @@ export async function executeGeminiRequest( pricing: streamCost.pricing, } + if (thinking) { + const segments = streamingResult.execution.output.providerTiming?.timeSegments + const lastModel = segments ? [...segments].reverse().find((s) => s.type === 'model') : undefined + if (lastModel) { + lastModel.thinkingContent = thinking + } + } + if (streamingResult.execution.output.providerTiming) { streamingResult.execution.output.providerTiming.endTime = new Date().toISOString() streamingResult.execution.output.providerTiming.duration = @@ -1223,7 +1294,7 @@ export async function executeGeminiRequest( } ) - return { ...streamingResult, stream } + return { ...streamingResult, stream, streamFormat: 'agent-events-v1' as const } } // Non-streaming: get next response @@ -1318,7 +1389,7 @@ function enrichLastModelSegmentFromGeminiResponse( Boolean(p.functionCall) ) .map((p) => ({ - id: p.functionCall.id ?? '', + id: ensureToolCallId(p.functionCall.id, 'gemini'), name: p.functionCall.name ?? '', arguments: (p.functionCall.args ?? {}) as Record, })) diff --git a/apps/sim/providers/gemini/streaming-tool-loop.test.ts b/apps/sim/providers/gemini/streaming-tool-loop.test.ts new file mode 100644 index 00000000000..464d8bdaeac --- /dev/null +++ b/apps/sim/providers/gemini/streaming-tool-loop.test.ts @@ -0,0 +1,166 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { createGeminiStreamingToolLoopStream } from '@/providers/gemini/streaming-tool-loop' +import type { AgentStreamEvent } from '@/providers/stream-events' +import { resetLocalToolIdCounterForTests } from '@/providers/tool-call-id' + +async function collectEvents( + stream: ReadableStream +): Promise { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events +} + +vi.mock('@/tools', () => ({ + executeTool: vi.fn(async () => ({ + success: true, + output: { ok: true, url: 'https://httpbin.org/get' }, + })), +})) + +vi.mock('@/providers/utils', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + prepareToolExecution: vi.fn(() => ({ + toolParams: { url: 'https://httpbin.org/get' }, + executionParams: { url: 'https://httpbin.org/get' }, + })), + calculateCost: vi.fn(() => ({ + input: 0.01, + output: 0.02, + total: 0.03, + pricing: { input: 1, output: 2, updatedAt: new Date().toISOString() }, + })), + sumToolCosts: vi.fn(() => 0), + isGemini3Model: vi.fn(() => false), + } +}) + +describe('createGeminiStreamingToolLoopStream (Step 9)', () => { + it('emits thinking, tool lifecycle, then final answer; allocates local tool ids', async () => { + resetLocalToolIdCounterForTests() + + const turns = [ + // Turn 1: thinking + functionCall (no id) + (async function* () { + yield { + candidates: [ + { + content: { + parts: [ + { text: 'I should call the API. ', thought: true }, + { + functionCall: { + name: 'http_request', + args: { url: 'https://httpbin.org/get' }, + }, + }, + ], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 10, + candidatesTokenCount: 5, + totalTokenCount: 15, + }, + } as any + })(), + // Turn 2: final answer + (async function* () { + yield { + candidates: [ + { + content: { + parts: [{ text: 'Done: https://httpbin.org/get' }], + }, + finishReason: 'STOP', + }, + ], + usageMetadata: { + promptTokenCount: 20, + candidatesTokenCount: 8, + totalTokenCount: 28, + }, + } as any + })(), + ] + + let turnIdx = 0 + const ai = { + models: { + generateContentStream: vi.fn(async () => turns[turnIdx++]), + }, + } + + const onComplete = vi.fn() + const timeSegments: any[] = [] + const stream = createGeminiStreamingToolLoopStream({ + ai: ai as any, + model: 'gemini-2.5-flash', + baseConfig: {}, + contents: [{ role: 'user', parts: [{ text: 'fetch it' }] }], + request: { + model: 'gemini-2.5-flash', + tools: [ + { + id: 'http_request', + name: 'http_request', + description: 'HTTP', + parameters: { type: 'object', properties: {}, required: [] }, + }, + ], + } as any, + logger: { info: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() } as any, + timeSegments, + onComplete, + }) + + const events = await collectEvents(stream) + + expect(events.filter((e) => e.type === 'thinking_delta').map((e) => e.text)).toEqual([ + 'I should call the API. ', + ]) + + const starts = events.filter((e) => e.type === 'tool_call_start') + expect(starts).toHaveLength(1) + expect(starts[0]).toMatchObject({ name: 'http_request' }) + expect((starts[0] as { id: string }).id).toMatch(/^gemini_/) + + const ends = events.filter((e) => e.type === 'tool_call_end') + expect(ends).toEqual([ + { + type: 'tool_call_end', + id: (starts[0] as { id: string }).id, + name: 'http_request', + status: 'success', + }, + ]) + + const textEvents = events.filter((e) => e.type === 'text_delta') + expect(textEvents.some((e) => e.type === 'text_delta' && e.turn === 'final')).toBe(true) + expect( + textEvents + .filter((e) => e.type === 'text_delta' && e.turn === 'final') + .map((e) => e.text) + .join('') + ).toContain('Done:') + + expect(onComplete).toHaveBeenCalledWith( + expect.objectContaining({ + content: expect.stringContaining('Done:'), + toolCalls: expect.objectContaining({ count: 1 }), + }) + ) + }) +}) diff --git a/apps/sim/providers/gemini/streaming-tool-loop.ts b/apps/sim/providers/gemini/streaming-tool-loop.ts new file mode 100644 index 00000000000..b9de301ae05 --- /dev/null +++ b/apps/sim/providers/gemini/streaming-tool-loop.ts @@ -0,0 +1,542 @@ +/** + * Live Gemini streaming tool loop (Step 9). + * + * Each model turn uses generateContentStream. Thought parts → thinking_delta + * live; functionCall parts → tool_call_start (with local ids when missing); + * text buffered until the turn is classified intermediate vs final. + * Tool ends emit in completion order; abort → cancelled. + */ + +import { + type Content, + FunctionCallingConfigMode, + type GenerateContentConfig, + type GenerateContentResponse, + type GoogleGenAI, + type Part, + type Schema, + type ToolConfig, +} from '@google/genai' +import type { Logger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import type { IterationToolCall, NormalizedBlockOutput } from '@/executor/types' +import { MAX_TOOL_ITERATIONS } from '@/providers' +import { + checkForForcedToolUsage, + cleanSchemaForGemini, + convertUsageMetadata, + ensureStructResponse, +} from '@/providers/google/utils' +import type { AgentStreamEvent, ToolCallEndStatus } from '@/providers/stream-events' +import { ensureToolCallId } from '@/providers/tool-call-id' +import { enrichLastModelSegment } from '@/providers/trace-enrichment' +import type { ProviderRequest, TimeSegment } from '@/providers/types' +import { + calculateCost, + isGemini3Model, + prepareToolExecution, + sumToolCosts, +} from '@/providers/utils' +import { executeTool } from '@/tools' +import type { GeminiUsage } from './types' + +export interface GeminiStreamingToolLoopComplete { + content: string + tokens: { input: number; output: number; total: number } + cost: NormalizedBlockOutput['cost'] + toolCalls?: { list: unknown[]; count: number } + modelTime: number + toolsTime: number + firstResponseTime: number + iterations: number +} + +export interface CreateGeminiStreamingToolLoopStreamOptions { + ai: GoogleGenAI + model: string + baseConfig: GenerateContentConfig + contents: Content[] + request: ProviderRequest + logger: Logger + timeSegments: TimeSegment[] + forcedTools?: string[] + toolConfig?: ToolConfig + onComplete: (result: GeminiStreamingToolLoopComplete) => void +} + +function isAbortError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false + const name = (error as { name?: string }).name + return name === 'AbortError' || name === 'APIUserAbortError' +} + +function settleOpenTools( + controller: ReadableStreamDefaultController, + openTools: Map, + status: ToolCallEndStatus +): void { + for (const [id, name] of openTools) { + controller.enqueue({ type: 'tool_call_end', id, name, status }) + } + openTools.clear() +} + +function buildNextConfig( + baseConfig: GenerateContentConfig, + currentToolConfig: ToolConfig | undefined, + usedForcedTools: string[], + forcedTools: string[], + request: ProviderRequest, + logger: Logger, + model: string +): GenerateContentConfig { + const nextConfig = { ...baseConfig } + const allForcedToolsUsed = + forcedTools.length > 0 && usedForcedTools.length === forcedTools.length + + if (allForcedToolsUsed && request.responseFormat) { + nextConfig.tools = undefined + nextConfig.toolConfig = undefined + if (isGemini3Model(model)) { + logger.info('Gemini 3: Stripping tools after forced tool execution, schema already set') + } else { + nextConfig.responseMimeType = 'application/json' + nextConfig.responseSchema = cleanSchemaForGemini(request.responseFormat.schema) as Schema + logger.info('Using structured output for final response after tool execution') + } + } else if (currentToolConfig) { + nextConfig.toolConfig = currentToolConfig + } else { + nextConfig.toolConfig = { functionCallingConfig: { mode: FunctionCallingConfigMode.AUTO } } + } + + return nextConfig +} + +/** + * Drain one generateContentStream turn into live agent events + aggregated parts. + */ +async function drainGeminiTurn( + stream: AsyncGenerator, + controller: ReadableStreamDefaultController, + openTools: Map +): Promise<{ + text: string + thinking: string + functionCallParts: Part[] + usage: GeminiUsage + textChunks: string[] + finishReason?: string +}> { + let text = '' + let thinking = '' + const textChunks: string[] = [] + const functionCallParts: Part[] = [] + const seenKeys = new Set() + let usage: GeminiUsage = { promptTokenCount: 0, candidatesTokenCount: 0, totalTokenCount: 0 } + let finishReason: string | undefined + + for await (const chunk of stream) { + if (chunk.usageMetadata) { + usage = convertUsageMetadata(chunk.usageMetadata) + } + + const candidate = chunk.candidates?.[0] + if (candidate?.finishReason) { + finishReason = String(candidate.finishReason) + } + + const parts = candidate?.content?.parts + if (!Array.isArray(parts)) { + const fallback = chunk.text + if (fallback) { + text += fallback + textChunks.push(fallback) + } + continue + } + + for (const part of parts) { + if (part.functionCall) { + const id = ensureToolCallId(part.functionCall.id, 'gemini') + const name = part.functionCall.name ?? '' + const key = id + if (!seenKeys.has(key) && name) { + seenKeys.add(key) + const normalized: Part = { + ...part, + functionCall: { ...part.functionCall, id }, + } + functionCallParts.push(normalized) + if (!openTools.has(id)) { + openTools.set(id, name) + controller.enqueue({ type: 'tool_call_start', id, name }) + } + } + continue + } + + if (!part.text) continue + if (part.thought === true) { + thinking += part.text + controller.enqueue({ type: 'thinking_delta', text: part.text }) + } else { + text += part.text + textChunks.push(part.text) + } + } + } + + return { text, thinking, functionCallParts, usage, textChunks, finishReason } +} + +/** + * Multi-turn Gemini tool loop as an agent-events-v1 object stream. + */ +export function createGeminiStreamingToolLoopStream( + options: CreateGeminiStreamingToolLoopStreamOptions +): ReadableStream { + const { + ai, + model, + baseConfig, + contents: initialContents, + request, + logger, + timeSegments, + onComplete, + } = options + const forcedTools = options.forcedTools ?? [] + + return new ReadableStream({ + async start(controller) { + let contents = [...initialContents] + let currentToolConfig = options.toolConfig + let usedForcedTools: string[] = [] + + let content = '' + let iterationCount = 0 + let modelTime = 0 + let toolsTime = 0 + let firstResponseTime = 0 + const tokens = { input: 0, output: 0, total: 0 } + let costInput = 0 + let costOutput = 0 + let costTotal = 0 + let latestPricing: ReturnType['pricing'] | undefined + const toolCalls: unknown[] = [] + const toolResults: Record[] = [] + const openToolStarts = new Map() + + try { + while (iterationCount < MAX_TOOL_ITERATIONS) { + if (request.abortSignal?.aborted) { + settleOpenTools(controller, openToolStarts, 'cancelled') + throw new DOMException('Stream aborted', 'AbortError') + } + + const turnConfig = buildNextConfig( + baseConfig, + currentToolConfig, + usedForcedTools, + forcedTools, + request, + logger, + model + ) + + const modelStart = Date.now() + const streamGenerator = await ai.models.generateContentStream({ + model, + contents, + config: turnConfig, + }) + + const drained = await drainGeminiTurn(streamGenerator, controller, openToolStarts) + const modelEnd = Date.now() + const thisModelTime = modelEnd - modelStart + modelTime += thisModelTime + if (iterationCount === 0) { + firstResponseTime = thisModelTime + } + + timeSegments.push({ + type: 'model', + name: model, + startTime: modelStart, + endTime: modelEnd, + duration: thisModelTime, + }) + + tokens.input += drained.usage.promptTokenCount + tokens.output += drained.usage.candidatesTokenCount + tokens.total += drained.usage.totalTokenCount + + const turnCost = calculateCost( + model, + drained.usage.promptTokenCount, + drained.usage.candidatesTokenCount + ) + costInput += turnCost.input + costOutput += turnCost.output + costTotal += turnCost.total + latestPricing = turnCost.pricing + + const turnTag = drained.functionCallParts.length > 0 ? 'intermediate' : 'final' + for (const chunk of drained.textChunks) { + controller.enqueue({ type: 'text_delta', text: chunk, turn: turnTag }) + } + if (drained.text) { + content = drained.text + } + + const toolCallsForEnrich: IterationToolCall[] = drained.functionCallParts + .filter((p): p is Part & { functionCall: NonNullable } => + Boolean(p.functionCall) + ) + .map((p) => ({ + id: p.functionCall.id ?? '', + name: p.functionCall.name ?? '', + arguments: (p.functionCall.args ?? {}) as Record, + })) + + enrichLastModelSegment(timeSegments, { + assistantContent: drained.text || undefined, + thinkingContent: drained.thinking || undefined, + toolCalls: toolCallsForEnrich.length > 0 ? toolCallsForEnrich : undefined, + finishReason: drained.finishReason, + tokens: { + input: drained.usage.promptTokenCount, + output: drained.usage.candidatesTokenCount, + total: drained.usage.totalTokenCount, + }, + cost: { + input: turnCost.input, + output: turnCost.output, + total: turnCost.total, + }, + provider: 'google', + }) + + const forcedCheck = checkForForcedToolUsage( + drained.functionCallParts + .map((p) => p.functionCall) + .filter((fc): fc is NonNullable => Boolean(fc)), + currentToolConfig, + forcedTools, + usedForcedTools + ) + if (forcedCheck) { + usedForcedTools = forcedCheck.usedForcedTools + currentToolConfig = forcedCheck.nextToolConfig + } + + if (drained.functionCallParts.length === 0) { + break + } + + const toolsStartTime = Date.now() + + const orderedResults = await Promise.all( + drained.functionCallParts.map(async (part) => { + const functionCall = part.functionCall! + const toolCallId = ensureToolCallId(functionCall.id, 'gemini') + const toolName = functionCall.name ?? '' + const toolArgs = (functionCall.args ?? {}) as Record + const toolCallStartTime = Date.now() + + try { + if (request.abortSignal?.aborted) { + throw new DOMException('Stream aborted', 'AbortError') + } + + const tool = request.tools?.find((t) => t.id === toolName) + if (!tool) { + const value = { + part, + toolCallId, + toolName, + toolArgs, + toolParams: {} as Record, + resultContent: { + error: true, + message: `Tool ${toolName} not found`, + tool: toolName, + }, + result: undefined as + | { success: boolean; output?: unknown; error?: string } + | undefined, + startTime: toolCallStartTime, + endTime: Date.now(), + duration: Date.now() - toolCallStartTime, + status: 'error' as ToolCallEndStatus, + success: false, + } + openToolStarts.delete(toolCallId) + controller.enqueue({ + type: 'tool_call_end', + id: toolCallId, + name: toolName, + status: 'error', + }) + return value + } + + const { toolParams, executionParams } = prepareToolExecution( + tool, + toolArgs, + request + ) + const result = await executeTool(toolName, executionParams, { + signal: request.abortSignal, + }) + const toolCallEndTime = Date.now() + const resultContent: Record = result.success + ? ensureStructResponse(result.output) + : { + error: true, + message: result.error || 'Tool execution failed', + tool: toolName, + } + const status: ToolCallEndStatus = result.success ? 'success' : 'error' + openToolStarts.delete(toolCallId) + controller.enqueue({ + type: 'tool_call_end', + id: toolCallId, + name: toolName, + status, + }) + return { + part, + toolCallId, + toolName, + toolArgs, + toolParams, + resultContent, + result, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + status, + success: result.success, + } + } catch (error) { + const toolCallEndTime = Date.now() + const cancelled = isAbortError(error) || !!request.abortSignal?.aborted + if (!cancelled) { + logger.error('Error processing function call:', { + error: toError(error).message, + functionName: toolName, + }) + } + const status: ToolCallEndStatus = cancelled ? 'cancelled' : 'error' + openToolStarts.delete(toolCallId) + controller.enqueue({ + type: 'tool_call_end', + id: toolCallId, + name: toolName, + status, + }) + return { + part, + toolCallId, + toolName, + toolArgs, + toolParams: {} as Record, + resultContent: { + error: true, + message: getErrorMessage(error, 'Tool execution failed'), + tool: toolName, + }, + result: undefined, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + status, + success: false, + } + } + }) + ) + + toolsTime += Date.now() - toolsStartTime + + const modelParts: Part[] = orderedResults.map((r) => ({ + ...r.part, + functionCall: { + ...r.part.functionCall!, + id: r.toolCallId, + }, + })) + const userParts: Part[] = orderedResults.map((r) => ({ + functionResponse: { + name: r.toolName, + response: r.resultContent, + }, + })) + + contents = [ + ...contents, + { role: 'model', parts: modelParts }, + { role: 'user', parts: userParts }, + ] + + for (const r of orderedResults) { + toolCalls.push({ + name: r.toolName, + arguments: r.toolParams, + startTime: new Date(r.startTime).toISOString(), + endTime: new Date(r.endTime).toISOString(), + duration: r.duration, + result: r.resultContent, + success: r.success, + }) + if (r.success && r.result?.output) { + toolResults.push(r.result.output as Record) + } + timeSegments.push({ + type: 'tool', + name: r.toolName, + startTime: r.startTime, + endTime: r.endTime, + duration: r.duration, + toolCallId: r.toolCallId, + }) + } + + iterationCount += 1 + } + + const toolCost = sumToolCosts(toolResults) + onComplete({ + content, + tokens, + cost: { + input: costInput, + output: costOutput, + toolCost: toolCost || undefined, + total: costTotal + toolCost, + pricing: latestPricing, + }, + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + modelTime, + toolsTime, + firstResponseTime, + iterations: iterationCount + 1, + }) + controller.close() + } catch (error) { + if (isAbortError(error) || request.abortSignal?.aborted) { + settleOpenTools(controller, openToolStarts, 'cancelled') + } else { + settleOpenTools(controller, openToolStarts, 'error') + logger.error('Gemini streaming tool loop failed', { + error: toError(error).message, + }) + } + controller.error(error) + } + }, + }) +} diff --git a/apps/sim/providers/google/utils.stream.test.ts b/apps/sim/providers/google/utils.stream.test.ts new file mode 100644 index 00000000000..b9085efc92e --- /dev/null +++ b/apps/sim/providers/google/utils.stream.test.ts @@ -0,0 +1,74 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { createReadableStreamFromGeminiStream } from '@/providers/google/utils' +import type { AgentStreamEvent } from '@/providers/stream-events' + +async function collectEvents( + stream: ReadableStream +): Promise { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events +} + +describe('createReadableStreamFromGeminiStream (Step 9)', () => { + it('splits thought parts into thinking_delta and answer into text_delta', async () => { + const onComplete = vi.fn() + const stream = createReadableStreamFromGeminiStream( + (async function* () { + yield { + candidates: [ + { + content: { + parts: [ + { text: 'Reasoning step. ', thought: true }, + { text: 'Final answer.', thought: false }, + ], + }, + }, + ], + usageMetadata: { + promptTokenCount: 5, + candidatesTokenCount: 7, + totalTokenCount: 12, + }, + } as any + })(), + onComplete + ) + + const events = await collectEvents(stream) + expect(events).toEqual([ + { type: 'thinking_delta', text: 'Reasoning step. ' }, + { type: 'text_delta', text: 'Final answer.', turn: 'final' }, + ]) + expect(onComplete).toHaveBeenCalledWith( + 'Final answer.', + expect.objectContaining({ promptTokenCount: 5 }), + 'Reasoning step. ' + ) + }) + + it('does not invent thinking when only answer text is present', async () => { + const stream = createReadableStreamFromGeminiStream( + (async function* () { + yield { + text: 'Just text', + candidates: [{ content: { parts: [{ text: 'Just text' }] } }], + } as any + })() + ) + const events = await collectEvents(stream) + expect(events.some((e) => e.type === 'thinking_delta')).toBe(false) + expect(events.filter((e) => e.type === 'text_delta').map((e) => e.text).join('')).toContain( + 'Just text' + ) + }) +}) diff --git a/apps/sim/providers/google/utils.ts b/apps/sim/providers/google/utils.ts index 05aa74328f7..3a389261011 100644 --- a/apps/sim/providers/google/utils.ts +++ b/apps/sim/providers/google/utils.ts @@ -16,6 +16,7 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import { isRecordLike } from '@sim/utils/object' import { buildGeminiMessageParts } from '@/providers/attachments' +import type { AgentStreamEvent } from '@/providers/stream-events' import type { ProviderRequest } from '@/providers/types' import { trackForcedToolUsage } from '@/providers/utils' @@ -280,13 +281,16 @@ export function convertToGeminiFormat( } /** - * Creates a ReadableStream from a Google Gemini streaming response + * Creates an agent-events-v1 stream from a Google Gemini streaming response. + * Thought parts (`part.thought === true`) → thinking_delta; other text → text_delta. + * Capability-honest: thinking only appears when includeThoughts was requested. */ export function createReadableStreamFromGeminiStream( stream: AsyncGenerator, - onComplete?: (content: string, usage: GeminiUsage) => void -): ReadableStream { + onComplete?: (content: string, usage: GeminiUsage, thinking?: string) => void +): ReadableStream { let fullContent = '' + let fullThinking = '' let usage: GeminiUsage = { promptTokenCount: 0, candidatesTokenCount: 0, totalTokenCount: 0 } return new ReadableStream({ @@ -297,14 +301,30 @@ export function createReadableStreamFromGeminiStream( usage = convertUsageMetadata(chunk.usageMetadata) } + const parts = chunk.candidates?.[0]?.content?.parts + if (Array.isArray(parts)) { + for (const part of parts) { + if (!part.text) continue + if (part.thought === true) { + fullThinking += part.text + controller.enqueue({ type: 'thinking_delta', text: part.text }) + } else { + fullContent += part.text + controller.enqueue({ type: 'text_delta', text: part.text, turn: 'final' }) + } + } + continue + } + + // Fallback when parts are not exposed — answer text only (no false thinking). const text = chunk.text if (text) { fullContent += text - controller.enqueue(new TextEncoder().encode(text)) + controller.enqueue({ type: 'text_delta', text, turn: 'final' }) } } - onComplete?.(fullContent, usage) + onComplete?.(fullContent, usage, fullThinking || undefined) controller.close() } catch (error) { logger.error('Error reading Google Gemini stream', { diff --git a/apps/sim/providers/groq/index.test.ts b/apps/sim/providers/groq/index.test.ts new file mode 100644 index 00000000000..2ae5572b3ec --- /dev/null +++ b/apps/sim/providers/groq/index.test.ts @@ -0,0 +1,121 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { ProviderRequest } from '@/providers/types' + +const { mockCreate } = vi.hoisted(() => ({ + mockCreate: vi.fn(), +})) + +vi.mock('groq-sdk', () => ({ + Groq: vi.fn().mockImplementation( + class { + chat = { completions: { create: mockCreate } } + } + ), +})) + +vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 })) + +vi.mock('@/providers/models', () => ({ + getProviderModels: vi.fn(() => ['groq/openai/gpt-oss-120b']), + getProviderDefaultModel: vi.fn(() => 'groq/openai/gpt-oss-120b'), +})) + +vi.mock('@/providers/attachments', () => ({ + formatMessagesForProvider: vi.fn((messages) => messages), +})) + +vi.mock('@/providers/groq/utils', () => ({ + createReadableStreamFromGroqStream: vi.fn(), +})) + +vi.mock('@/providers/openai-compat/streaming-tool-loop', () => ({ + createOpenAICompatStreamingToolLoopStream: vi.fn(), +})) + +vi.mock('@/providers/streaming-execution', () => ({ + createStreamingExecution: vi.fn((args) => args), +})) + +vi.mock('@/providers/trace-enrichment', () => ({ + enrichLastModelSegmentFromChatCompletions: vi.fn(), +})) + +vi.mock('@/providers/utils', () => ({ + calculateCost: vi.fn(() => ({ input: 0, output: 0, total: 0 })), + prepareToolExecution: vi.fn((_tool, args) => ({ toolParams: args, executionParams: args })), + prepareToolsWithUsageControl: vi.fn(() => ({ + tools: [], + toolChoice: undefined, + forcedTools: [], + hasFilteredTools: false, + })), + sumToolCosts: vi.fn(() => 0), + trackForcedToolUsage: vi.fn(() => ({ hasUsedForcedTool: false, usedForcedTools: [] })), +})) + +vi.mock('@/tools', () => ({ executeTool: vi.fn() })) + +import { groqProvider } from '@/providers/groq/index' + +function request(overrides: Partial = {}): ProviderRequest { + return { + model: 'groq/openai/gpt-oss-120b', + apiKey: 'test-key', + messages: [{ role: 'user', content: 'hi' }], + ...overrides, + } +} + +describe('groqProvider reasoning payload', () => { + beforeEach(() => { + mockCreate.mockReset() + mockCreate.mockResolvedValue({ + choices: [{ message: { content: 'ok', tool_calls: [] } }], + usage: { prompt_tokens: 1, completion_tokens: 1, total_tokens: 2 }, + }) + }) + + it('GPT-OSS sets include_reasoning and reasoning_effort', async () => { + await groqProvider.executeRequest( + request({ model: 'groq/openai/gpt-oss-120b', reasoningEffort: 'high' }) + ) + const payload = mockCreate.mock.calls[0][0] + expect(payload.model).toBe('openai/gpt-oss-120b') + expect(payload.include_reasoning).toBe(true) + expect(payload.reasoning_effort).toBe('high') + expect(payload.reasoning_format).toBeUndefined() + }) + + it('GPT-OSS defaults reasoning_effort to medium when unset', async () => { + await groqProvider.executeRequest(request({ model: 'groq/openai/gpt-oss-20b' })) + const payload = mockCreate.mock.calls[0][0] + expect(payload.include_reasoning).toBe(true) + expect(payload.reasoning_effort).toBe('medium') + }) + + it('Qwen sets reasoning_format parsed when thinking enabled', async () => { + await groqProvider.executeRequest( + request({ + model: 'groq/qwen/qwen3-32b', + thinkingLevel: 'enabled', + }) + ) + const payload = mockCreate.mock.calls[0][0] + expect(payload.reasoning_format).toBe('parsed') + expect(payload.include_reasoning).toBeUndefined() + }) + + it('Qwen omits reasoning_format when thinking is none', async () => { + await groqProvider.executeRequest( + request({ + model: 'groq/qwen/qwen3.6-27b', + thinkingLevel: 'none', + }) + ) + const payload = mockCreate.mock.calls[0][0] + expect(payload.reasoning_format).toBeUndefined() + }) +}) diff --git a/apps/sim/providers/groq/index.ts b/apps/sim/providers/groq/index.ts index 15d854e145c..fdac6a74a3b 100644 --- a/apps/sim/providers/groq/index.ts +++ b/apps/sim/providers/groq/index.ts @@ -1,11 +1,12 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import { Groq } from 'groq-sdk' -import type { StreamingExecution } from '@/executor/types' +import type { NormalizedBlockOutput, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { createReadableStreamFromGroqStream } from '@/providers/groq/utils' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' +import { createOpenAICompatStreamingToolLoopStream } from '@/providers/openai-compat/streaming-tool-loop' import { createStreamingExecution } from '@/providers/streaming-execution' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' @@ -77,6 +78,22 @@ export const groqProvider: ProviderConfig = { if (request.temperature !== undefined) payload.temperature = request.temperature if (request.maxTokens != null) payload.max_completion_tokens = request.maxTokens + // Groq reasoning: GPT-OSS uses include_reasoning + reasoning_effort; + // Qwen uses reasoning_format: parsed (compatible with tools). + const groqModelId = payload.model as string + const isGptOss = groqModelId.includes('gpt-oss') + const isQwenReasoning = /qwen3/i.test(groqModelId) + if (isGptOss) { + const effort = + request.reasoningEffort && request.reasoningEffort !== 'auto' + ? request.reasoningEffort + : 'medium' + payload.include_reasoning = true + payload.reasoning_effort = effort + } else if (isQwenReasoning && request.thinkingLevel && request.thinkingLevel !== 'none') { + payload.reasoning_format = 'parsed' + } + if (request.responseFormat) { payload.response_format = { type: 'json_schema', @@ -112,6 +129,70 @@ export const groqProvider: ProviderConfig = { } } + const shouldStreamToolCalls = request.streamToolCalls ?? false + + if (request.stream && shouldStreamToolCalls && payload.tools?.length) { + logger.info('Using streaming tool loop for Groq request') + + const providerStartTime = Date.now() + const providerStartTimeISO = new Date(providerStartTime).toISOString() + const timeSegments: TimeSegment[] = [] + + return createStreamingExecution({ + model: request.model, + providerStartTime, + providerStartTimeISO, + timing: { + kind: 'accumulated', + modelTime: 0, + toolsTime: 0, + firstResponseTime: 0, + iterations: 1, + timeSegments, + }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { total: 0.0, input: 0.0, output: 0.0 }, + isStreaming: true, + streamFormat: 'agent-events-v1', + createStream: ({ output, finalizeTiming }) => + createOpenAICompatStreamingToolLoopStream({ + providerName: 'Groq', + request, + basePayload: payload, + messages: formattedMessages as any, + createStream: async (params, options) => + groq.chat.completions.create( + { ...params, stream: true }, + options + ) as any, + createBlocking: async (params, options) => + groq.chat.completions.create( + { ...params, stream: false }, + options + ) as any, + logger, + timeSegments, + forcedTools, + preserveAssistantReasoning: + (!!request.thinkingLevel && request.thinkingLevel !== 'none') || + (payload.model as string).includes('gpt-oss'), + onComplete: (result) => { + output.content = result.content + output.tokens = result.tokens + output.cost = result.cost + output.toolCalls = result.toolCalls as NormalizedBlockOutput['toolCalls'] + if (output.providerTiming) { + output.providerTiming.modelTime = result.modelTime + output.providerTiming.toolsTime = result.toolsTime + output.providerTiming.firstResponseTime = result.firstResponseTime + output.providerTiming.iterations = result.iterations + } + finalizeTiming() + }, + }), + }) + } + if (request.stream && (!tools || tools.length === 0)) { logger.info('Using streaming response for Groq request (no tools)') @@ -134,26 +215,37 @@ export const groqProvider: ProviderConfig = { initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromGroqStream(streamResponse as any, (content, usage) => { - output.content = content - output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } + createReadableStreamFromGroqStream( + streamResponse as any, + (content, usage, thinking) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } - const costResult = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, + } + + if (thinking) { + const segment = output.providerTiming?.timeSegments?.[0] + if (segment) { + segment.thinkingContent = thinking + } + } } - }), + ), }) return streamingResult @@ -440,28 +532,39 @@ export const groqProvider: ProviderConfig = { } : undefined, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromGroqStream(streamResponse as any, (content, usage) => { - output.content = content - output.tokens = { - input: tokens.input + usage.prompt_tokens, - output: tokens.output + usage.completion_tokens, - total: tokens.total + usage.total_tokens, - } + createReadableStreamFromGroqStream( + streamResponse as any, + (content, usage, thinking) => { + output.content = content + output.tokens = { + input: tokens.input + usage.prompt_tokens, + output: tokens.output + usage.completion_tokens, + total: tokens.total + usage.total_tokens, + } - const streamCost = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - const tc = sumToolCosts(toolResults) - output.cost = { - input: accumulatedCost.input + streamCost.input, - output: accumulatedCost.output + streamCost.output, - toolCost: tc || undefined, - total: accumulatedCost.total + streamCost.total + tc, + const streamCost = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + const tc = sumToolCosts(toolResults) + output.cost = { + input: accumulatedCost.input + streamCost.input, + output: accumulatedCost.output + streamCost.output, + toolCost: tc || undefined, + total: accumulatedCost.total + streamCost.total + tc, + } + + if (thinking) { + const lastModel = [...timeSegments].reverse().find((s) => s.type === 'model') + if (lastModel) { + lastModel.thinkingContent = thinking + } + } } - }), + ), }) return streamingResult diff --git a/apps/sim/providers/groq/utils.ts b/apps/sim/providers/groq/utils.ts index 61e8563e962..cb97a689ea5 100644 --- a/apps/sim/providers/groq/utils.ts +++ b/apps/sim/providers/groq/utils.ts @@ -1,14 +1,20 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' /** - * Creates a ReadableStream from a Groq streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a Groq streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromGroqStream( groqStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(groqStream, 'Groq', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(groqStream, { + providerName: 'Groq', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } diff --git a/apps/sim/providers/litellm/index.ts b/apps/sim/providers/litellm/index.ts index 0f5fc2d3d2c..e8ee2229e6e 100644 --- a/apps/sim/providers/litellm/index.ts +++ b/apps/sim/providers/litellm/index.ts @@ -219,6 +219,7 @@ export const litellmProvider: ProviderConfig = { initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromLiteLLMStream(streamResponse, (content, usage) => { let cleanContent = content @@ -588,6 +589,7 @@ export const litellmProvider: ProviderConfig = { } : undefined, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => createReadableStreamFromLiteLLMStream(streamResponse, (content, usage) => { let cleanContent = content diff --git a/apps/sim/providers/litellm/utils.ts b/apps/sim/providers/litellm/utils.ts index f779f95c703..bacbd3cacbb 100644 --- a/apps/sim/providers/litellm/utils.ts +++ b/apps/sim/providers/litellm/utils.ts @@ -1,14 +1,20 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' /** - * Creates a ReadableStream from a LiteLLM streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a LiteLLM streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromLiteLLMStream( litellmStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(litellmStream, 'LiteLLM', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(litellmStream, { + providerName: 'LiteLLM', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } diff --git a/apps/sim/providers/meta/index.ts b/apps/sim/providers/meta/index.ts index 96b0e0034ad..d5b7ab19bde 100644 --- a/apps/sim/providers/meta/index.ts +++ b/apps/sim/providers/meta/index.ts @@ -172,6 +172,7 @@ export const metaProvider: ProviderConfig = { initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => createReadableStreamFromMetaStream(streamResponse as any, (content, usage) => { output.content = content @@ -538,6 +539,7 @@ export const metaProvider: ProviderConfig = { } : undefined, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => createReadableStreamFromMetaStream(streamResponse as any, (content, usage) => { output.content = content diff --git a/apps/sim/providers/meta/utils.ts b/apps/sim/providers/meta/utils.ts index 1f72345ec5c..a8defd82d75 100644 --- a/apps/sim/providers/meta/utils.ts +++ b/apps/sim/providers/meta/utils.ts @@ -1,14 +1,20 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' /** - * Creates a ReadableStream from a Meta Model API streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a Meta Model API streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromMetaStream( metaStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(metaStream, 'Meta', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(metaStream, { + providerName: 'Meta', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } diff --git a/apps/sim/providers/mistral/index.ts b/apps/sim/providers/mistral/index.ts index 95c66422a72..4d95b1e96ad 100644 --- a/apps/sim/providers/mistral/index.ts +++ b/apps/sim/providers/mistral/index.ts @@ -155,6 +155,7 @@ export const mistralProvider: ProviderConfig = { timing: { kind: 'simple', segmentName: request.model }, initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromMistralStream(streamResponse, (content, usage) => { output.content = content @@ -480,6 +481,7 @@ export const mistralProvider: ProviderConfig = { count: toolCalls.length, } : undefined, + streamFormat: 'agent-events-v1', createStream: ({ output }) => createReadableStreamFromMistralStream(streamResponse, (content, usage) => { output.content = content diff --git a/apps/sim/providers/mistral/utils.ts b/apps/sim/providers/mistral/utils.ts index 61893928918..b1d16c95304 100644 --- a/apps/sim/providers/mistral/utils.ts +++ b/apps/sim/providers/mistral/utils.ts @@ -1,14 +1,20 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' /** - * Creates a ReadableStream from a Mistral streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a Mistral streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromMistralStream( mistralStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(mistralStream, 'Mistral', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(mistralStream, { + providerName: 'Mistral', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts index bc5aa24cbe7..4c907fc6a38 100644 --- a/apps/sim/providers/models.ts +++ b/apps/sim/providers/models.ts @@ -1853,7 +1853,12 @@ export const PROVIDER_DEFINITIONS: Record = { output: 0.87, updatedAt: '2026-06-16', }, - capabilities: {}, + capabilities: { + thinking: { + levels: ['enabled'], + default: 'enabled', + }, + }, contextWindow: 1000000, releaseDate: '2026-04-24', }, @@ -1867,6 +1872,10 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { temperature: { min: 0, max: 2 }, + thinking: { + levels: ['enabled'], + default: 'enabled', + }, }, contextWindow: 1000000, releaseDate: '2026-04-24', @@ -1881,6 +1890,10 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { temperature: { min: 0, max: 2 }, + thinking: { + levels: ['enabled'], + default: 'enabled', + }, }, contextWindow: 1000000, releaseDate: '2024-12-26', @@ -1908,7 +1921,12 @@ export const PROVIDER_DEFINITIONS: Record = { output: 2.19, updatedAt: '2026-04-01', }, - capabilities: {}, + capabilities: { + thinking: { + levels: ['enabled'], + default: 'enabled', + }, + }, contextWindow: 128000, releaseDate: '2025-01-20', deprecated: true, @@ -1921,7 +1939,12 @@ export const PROVIDER_DEFINITIONS: Record = { output: 0.28, updatedAt: '2026-06-11', }, - capabilities: {}, + capabilities: { + thinking: { + levels: ['enabled'], + default: 'enabled', + }, + }, contextWindow: 1000000, releaseDate: '2025-01-20', }, @@ -2251,6 +2274,9 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { maxOutputTokens: 65536, + reasoningEffort: { + values: ['low', 'medium', 'high'], + }, }, contextWindow: 131072, releaseDate: '2025-08-05', @@ -2266,6 +2292,9 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { maxOutputTokens: 65536, + reasoningEffort: { + values: ['low', 'medium', 'high'], + }, }, contextWindow: 131072, releaseDate: '2025-08-05', @@ -2280,6 +2309,9 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { maxOutputTokens: 65536, + reasoningEffort: { + values: ['low', 'medium', 'high'], + }, }, contextWindow: 131072, releaseDate: '2025-10-29', @@ -2293,6 +2325,10 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { maxOutputTokens: 40960, + thinking: { + levels: ['enabled'], + default: 'enabled', + }, }, contextWindow: 131072, releaseDate: '2025-04-29', @@ -2307,6 +2343,10 @@ export const PROVIDER_DEFINITIONS: Record = { }, capabilities: { maxOutputTokens: 32768, + thinking: { + levels: ['enabled'], + default: 'enabled', + }, }, contextWindow: 131072, releaseDate: '2026-04-21', diff --git a/apps/sim/providers/nvidia/index.ts b/apps/sim/providers/nvidia/index.ts index ffb7bf0fb25..87754ce3be9 100644 --- a/apps/sim/providers/nvidia/index.ts +++ b/apps/sim/providers/nvidia/index.ts @@ -152,6 +152,7 @@ export const nvidiaProvider: ProviderConfig = { initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => createReadableStreamFromNvidiaStream(streamResponse as any, (content, usage) => { output.content = content @@ -512,6 +513,7 @@ export const nvidiaProvider: ProviderConfig = { } : undefined, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => createReadableStreamFromNvidiaStream(streamResponse as any, (content, usage) => { output.content = content diff --git a/apps/sim/providers/nvidia/utils.ts b/apps/sim/providers/nvidia/utils.ts index ef9c37b3a50..45c0b526a4b 100644 --- a/apps/sim/providers/nvidia/utils.ts +++ b/apps/sim/providers/nvidia/utils.ts @@ -1,14 +1,20 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' /** - * Creates a ReadableStream from an NVIDIA NIM streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from an NVIDIA NIM streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromNvidiaStream( nvidiaStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(nvidiaStream, 'NVIDIA', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(nvidiaStream, { + providerName: 'NVIDIA', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } diff --git a/apps/sim/providers/ollama-cloud/utils.ts b/apps/sim/providers/ollama-cloud/utils.ts index 3ab364c66bb..d768b1d9134 100644 --- a/apps/sim/providers/ollama-cloud/utils.ts +++ b/apps/sim/providers/ollama-cloud/utils.ts @@ -1,14 +1,20 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' /** - * Creates a ReadableStream from an Ollama Cloud streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from an Ollama Cloud streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromOllamaCloudStream( ollamaStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(ollamaStream, 'Ollama Cloud', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(ollamaStream, { + providerName: 'Ollama Cloud', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } diff --git a/apps/sim/providers/ollama/core.ts b/apps/sim/providers/ollama/core.ts index 9e10b7c436a..3d5d23295e8 100644 --- a/apps/sim/providers/ollama/core.ts +++ b/apps/sim/providers/ollama/core.ts @@ -10,6 +10,7 @@ import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' import { createStreamingExecution } from '@/providers/streaming-execution' +import type { AgentStreamEvent } from '@/providers/stream-events' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { Message, ProviderRequest, ProviderResponse, TimeSegment } from '@/providers/types' @@ -54,8 +55,8 @@ export interface OllamaCoreConfig { createClient: () => OpenAI createStream: ( stream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void - ) => ReadableStream + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void + ) => ReadableStream logger: Logger } @@ -181,6 +182,7 @@ export async function executeOllamaProviderRequest( timing: { kind: 'simple', segmentName: request.model }, initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => config.createStream(streamResponse, (content, usage) => { output.content = content @@ -489,6 +491,7 @@ export async function executeOllamaProviderRequest( count: toolCalls.length, } : undefined, + streamFormat: 'agent-events-v1', createStream: ({ output }) => config.createStream(streamResponse, (content, usage) => { output.content = content diff --git a/apps/sim/providers/ollama/utils.ts b/apps/sim/providers/ollama/utils.ts index 45ea5d9957f..71e7890f7a5 100644 --- a/apps/sim/providers/ollama/utils.ts +++ b/apps/sim/providers/ollama/utils.ts @@ -1,14 +1,20 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' /** - * Creates a ReadableStream from an Ollama streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from an Ollama streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromOllamaStream( ollamaStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(ollamaStream, 'Ollama', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(ollamaStream, { + providerName: 'Ollama', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } diff --git a/apps/sim/providers/openai-compat/stream-events.test.ts b/apps/sim/providers/openai-compat/stream-events.test.ts new file mode 100644 index 00000000000..745b8a4f605 --- /dev/null +++ b/apps/sim/providers/openai-compat/stream-events.test.ts @@ -0,0 +1,110 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { + openaiCompatReasoningAndTextChunks, + openaiCompatTextOnlyChunks, + openaiCompatToolCallStartChunks, +} from '@/providers/__fixtures__/openai-compat' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' + +async function collectEvents( + stream: ReadableStream +): Promise { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events +} + +describe('createOpenAICompatibleAgentEventStream (Step 9)', () => { + it('emits thinking_delta from reasoning_content then text_delta', async () => { + const onComplete = vi.fn() + const stream = createOpenAICompatibleAgentEventStream( + (async function* () { + yield* openaiCompatReasoningAndTextChunks as any + })(), + { providerName: 'DeepSeek', onComplete } + ) + + const events = await collectEvents(stream) + expect(events.filter((e) => e.type === 'thinking_delta').map((e) => e.text)).toEqual([ + 'I should compute carefully. ', + 'Answer is 4.', + ]) + expect(events.filter((e) => e.type === 'text_delta')).toEqual([ + { type: 'text_delta', text: '2+2=', turn: 'final' }, + { type: 'text_delta', text: '4', turn: 'final' }, + ]) + expect(onComplete.mock.calls[0][0]).toMatchObject({ + content: '2+2=4', + thinking: 'I should compute carefully. Answer is 4.', + usage: { prompt_tokens: 10, completion_tokens: 8 }, + }) + }) + + it('stays text-only when no reasoning fields are present', async () => { + const stream = createOpenAICompatibleAgentEventStream( + (async function* () { + yield* openaiCompatTextOnlyChunks as any + })(), + { providerName: 'Groq' } + ) + const events = await collectEvents(stream) + expect(events.every((e) => e.type === 'text_delta')).toBe(true) + expect(events.some((e) => e.type === 'thinking_delta')).toBe(false) + }) + + it('emits tool_call_start when enabled and id+name are known', async () => { + const onComplete = vi.fn() + const stream = createOpenAICompatibleAgentEventStream( + (async function* () { + yield* openaiCompatToolCallStartChunks as any + yield { + choices: [ + { + delta: { + tool_calls: [{ index: 0, function: { arguments: '"https://x"}' } }], + }, + }, + ], + } + })(), + { providerName: 'Groq', emitToolCallStarts: true, onComplete } + ) + const events = await collectEvents(stream) + expect(events).toContainEqual({ + type: 'tool_call_start', + id: 'call_abc', + name: 'http_request', + }) + // Only once even if later deltas omit id + expect(events.filter((e) => e.type === 'tool_call_start')).toHaveLength(1) + expect(onComplete.mock.calls[0][0].toolCalls).toEqual([ + { + id: 'call_abc', + type: 'function', + function: { name: 'http_request', arguments: '{"url":"https://x"}' }, + }, + ]) + }) + + it('buffers text deltas when bufferTextDeltas is true', async () => { + const onComplete = vi.fn() + const stream = createOpenAICompatibleAgentEventStream( + (async function* () { + yield* openaiCompatTextOnlyChunks as any + })(), + { providerName: 'DeepSeek', bufferTextDeltas: true, onComplete } + ) + const events = await collectEvents(stream) + expect(events.some((e) => e.type === 'text_delta')).toBe(false) + expect(onComplete.mock.calls[0][0].content).toBe('Hello world') + }) +}) diff --git a/apps/sim/providers/openai-compat/stream-events.ts b/apps/sim/providers/openai-compat/stream-events.ts new file mode 100644 index 00000000000..0fd1b45dc32 --- /dev/null +++ b/apps/sim/providers/openai-compat/stream-events.ts @@ -0,0 +1,196 @@ +/** + * OpenAI Chat Completions → agent-events-v1 (Step 9). + * + * Capability-honest: emits `thinking_delta` only when the vendor streams a + * reasoning field on the delta (`reasoning_content`, `reasoning`, etc.). + * Non-reasoning models stay text-only. Tool_call starts emit when id+name known. + * Tool-call argument deltas are accumulated for tool-loop history. + */ + +import type { ChatCompletionChunk } from 'openai/resources/chat/completions' +import type { CompletionUsage } from 'openai/resources/completions' +import { createLogger } from '@sim/logger' +import type { AgentStreamEvent, TextDeltaTurn } from '@/providers/stream-events' + +export interface OpenAICompatAssembledToolCall { + id: string + type: 'function' + function: { name: string; arguments: string } +} + +export interface OpenAICompatStreamComplete { + content: string + thinking: string + /** DeepSeek-style CoT field accumulated from deltas. */ + reasoning_content?: string + /** Groq-style reasoning field accumulated from deltas. */ + reasoning?: string + usage: CompletionUsage + /** Assembled when emitToolCallStarts is true (id + name + args). */ + toolCalls?: OpenAICompatAssembledToolCall[] +} + +export interface CreateOpenAICompatibleAgentEventStreamOptions { + providerName: string + /** Tag for answer text (default `final`). Use `intermediate` inside tool loops. */ + turn?: TextDeltaTurn + /** Emit tool_call_start from delta.tool_calls when id+name known. Default false for no-tools path. */ + emitToolCallStarts?: boolean + /** + * When true, accumulate text but do not enqueue text_delta until the caller + * flushes with a known turn tag (tool loops need this). + */ + bufferTextDeltas?: boolean + onComplete?: (result: OpenAICompatStreamComplete) => void +} + +function extractDeltaReasoning(delta: Record | undefined): { + text: string + reasoning_content?: string + reasoning?: string +} { + if (!delta) return { text: '' } + + if (typeof delta.reasoning_content === 'string' && delta.reasoning_content) { + return { text: delta.reasoning_content, reasoning_content: delta.reasoning_content } + } + if (typeof delta.reasoning === 'string' && delta.reasoning) { + return { text: delta.reasoning, reasoning: delta.reasoning } + } + const nested = delta.reasoning + if ( + nested && + typeof nested === 'object' && + typeof (nested as { text?: string }).text === 'string' + ) { + const text = (nested as { text: string }).text + return { text, reasoning: text } + } + return { text: '' } +} + +/** + * Converts an OpenAI-compatible chat.completions stream into an in-process + * {@link AgentStreamEvent} object stream. + */ +export function createOpenAICompatibleAgentEventStream( + stream: AsyncIterable, + options: CreateOpenAICompatibleAgentEventStreamOptions +): ReadableStream { + const { + providerName, + turn = 'final', + emitToolCallStarts = false, + bufferTextDeltas = false, + onComplete, + } = options + const streamLogger = createLogger(`${providerName}Utils`) + + return new ReadableStream({ + async start(controller) { + let fullContent = '' + let fullThinking = '' + let reasoningContent = '' + let reasoning = '' + let promptTokens = 0 + let completionTokens = 0 + let totalTokens = 0 + const seenToolIds = new Set() + const toolBuffers = new Map< + number, + { id?: string; name?: string; args: string; started: boolean } + >() + + try { + for await (const chunk of stream) { + if (chunk.usage) { + promptTokens = chunk.usage.prompt_tokens ?? 0 + completionTokens = chunk.usage.completion_tokens ?? 0 + totalTokens = chunk.usage.total_tokens ?? 0 + } + + const choice = chunk.choices?.[0] + const delta = choice?.delta as Record | undefined + + const extracted = extractDeltaReasoning(delta) + if (extracted.text) { + fullThinking += extracted.text + if (extracted.reasoning_content) reasoningContent += extracted.reasoning_content + if (extracted.reasoning) reasoning += extracted.reasoning + controller.enqueue({ type: 'thinking_delta', text: extracted.text }) + } + + const content = typeof delta?.content === 'string' ? delta.content : '' + if (content) { + fullContent += content + if (!bufferTextDeltas) { + controller.enqueue({ type: 'text_delta', text: content, turn }) + } + } + + if (emitToolCallStarts && Array.isArray(delta?.tool_calls)) { + for (const tc of delta.tool_calls as Array<{ + index?: number + id?: string + type?: string + function?: { name?: string; arguments?: string } + }>) { + const index = typeof tc.index === 'number' ? tc.index : 0 + const buf = toolBuffers.get(index) ?? { + id: undefined, + name: undefined, + args: '', + started: false, + } + if (tc.id) buf.id = tc.id + if (tc.function?.name) buf.name = tc.function.name + if (typeof tc.function?.arguments === 'string') { + buf.args += tc.function.arguments + } + toolBuffers.set(index, buf) + + if (buf.id && buf.name && !buf.started && !seenToolIds.has(buf.id)) { + buf.started = true + seenToolIds.add(buf.id) + controller.enqueue({ type: 'tool_call_start', id: buf.id, name: buf.name }) + } + } + } + } + + if (onComplete) { + if (promptTokens === 0 && completionTokens === 0) { + streamLogger.warn(`${providerName} stream completed without usage data`) + } + const toolCalls: OpenAICompatAssembledToolCall[] = [] + if (emitToolCallStarts) { + for (const [, buf] of [...toolBuffers.entries()].sort(([a], [b]) => a - b)) { + if (!buf.id || !buf.name) continue + toolCalls.push({ + id: buf.id, + type: 'function', + function: { name: buf.name, arguments: buf.args || '{}' }, + }) + } + } + onComplete({ + content: fullContent, + thinking: fullThinking, + ...(reasoningContent ? { reasoning_content: reasoningContent } : {}), + ...(reasoning ? { reasoning } : {}), + usage: { + prompt_tokens: promptTokens, + completion_tokens: completionTokens, + total_tokens: totalTokens || promptTokens + completionTokens, + }, + ...(toolCalls.length > 0 ? { toolCalls } : {}), + }) + } + + controller.close() + } catch (error) { + controller.error(error) + } + }, + }) +} diff --git a/apps/sim/providers/openai-compat/streaming-tool-loop.test.ts b/apps/sim/providers/openai-compat/streaming-tool-loop.test.ts new file mode 100644 index 00000000000..32f60ebdfcc --- /dev/null +++ b/apps/sim/providers/openai-compat/streaming-tool-loop.test.ts @@ -0,0 +1,297 @@ +/** + * @vitest-environment node + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { openaiCompatToolCallStartChunks } from '@/providers/__fixtures__/openai-compat' +import { createOpenAICompatStreamingToolLoopStream } from '@/providers/openai-compat/streaming-tool-loop' +import type { AgentStreamEvent } from '@/providers/stream-events' +import type { TimeSegment } from '@/providers/types' + +const { mockExecuteTool, mockPrepareToolExecution } = vi.hoisted(() => ({ + mockExecuteTool: vi.fn(), + mockPrepareToolExecution: vi.fn(), +})) + +vi.mock('@/tools', () => ({ + executeTool: mockExecuteTool, +})) + +vi.mock('@/providers/utils', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + prepareToolExecution: mockPrepareToolExecution, + calculateCost: () => ({ input: 0.01, output: 0.02, total: 0.03 }), + sumToolCosts: () => 0, + } +}) + +vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 })) + +async function collectEvents( + stream: ReadableStream +): Promise { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events +} + +function toolThenAnswerChunks(toolName: string, args: string, answer: string) { + return [ + { + choices: [ + { + delta: { + reasoning_content: 'I should call the tool. ', + tool_calls: [ + { index: 0, id: 'call_1', type: 'function', function: { name: toolName, arguments: '' } }, + ], + }, + }, + ], + }, + { + choices: [ + { + delta: { + tool_calls: [{ index: 0, function: { arguments: args } }], + }, + }, + ], + usage: { prompt_tokens: 5, completion_tokens: 3, total_tokens: 8 }, + }, + // Second turn (final answer) — yielded by a separate createStream call + ] as const +} + +describe('createOpenAICompatStreamingToolLoopStream', () => { + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as any + + beforeEach(() => { + mockExecuteTool.mockReset() + mockPrepareToolExecution.mockReset() + logger.info.mockReset() + mockPrepareToolExecution.mockImplementation((_tool: unknown, args: unknown) => ({ + toolParams: args, + executionParams: args, + })) + mockExecuteTool.mockResolvedValue({ + success: true, + output: { answer: 42 }, + }) + }) + + it('keeps reasoning_content on assistant history when preserveAssistantReasoning is true', async () => { + const messageHistory: unknown[][] = [] + let call = 0 + + const createStream = vi.fn(async (params: { messages: unknown[] }) => { + messageHistory.push(params.messages) + call += 1 + if (call === 1) { + return (async function* () { + yield* toolThenAnswerChunks('lookup', '{}', '') + })() + } + return (async function* () { + yield { + choices: [{ delta: { content: 'done', reasoning_content: 'final thought' } }], + usage: { prompt_tokens: 8, completion_tokens: 2, total_tokens: 10 }, + } + })() + }) + + const timeSegments: TimeSegment[] = [] + const stream = createOpenAICompatStreamingToolLoopStream({ + providerName: 'Deepseek', + request: { + model: 'deepseek-chat', + apiKey: 'k', + messages: [], + tools: [{ id: 'lookup', name: 'lookup', description: 'd', parameters: {} } as any], + thinkingLevel: 'enabled', + }, + basePayload: { model: 'deepseek-chat' }, + messages: [{ role: 'user', content: 'use the tool' }], + createStream: createStream as any, + logger, + timeSegments, + preserveAssistantReasoning: true, + onComplete: () => {}, + }) + + await collectEvents(stream) + + expect(createStream).toHaveBeenCalledTimes(2) + const secondTurnMessages = messageHistory[1] as Array> + const assistantWithTools = secondTurnMessages.find( + (m) => m.role === 'assistant' && Array.isArray(m.tool_calls) + ) + expect(assistantWithTools).toMatchObject({ + role: 'assistant', + reasoning_content: 'I should call the tool. ', + }) + }) + + it('omits reasoning_content when preserveAssistantReasoning is false', async () => { + const messageHistory: unknown[][] = [] + let call = 0 + + const createStream = vi.fn(async (params: { messages: unknown[] }) => { + messageHistory.push(params.messages) + call += 1 + if (call === 1) { + return (async function* () { + yield* toolThenAnswerChunks('lookup', '{}', '') + })() + } + return (async function* () { + yield { + choices: [{ delta: { content: 'done' } }], + usage: { prompt_tokens: 8, completion_tokens: 2, total_tokens: 10 }, + } + })() + }) + + const stream = createOpenAICompatStreamingToolLoopStream({ + providerName: 'Groq', + request: { + model: 'groq/openai/gpt-oss-120b', + apiKey: 'k', + messages: [], + tools: [{ id: 'lookup', name: 'lookup', description: 'd', parameters: {} } as any], + }, + basePayload: { model: 'openai/gpt-oss-120b' }, + messages: [{ role: 'user', content: 'use the tool' }], + createStream: createStream as any, + logger, + timeSegments: [], + preserveAssistantReasoning: false, + onComplete: () => {}, + }) + + await collectEvents(stream) + + const secondTurnMessages = messageHistory[1] as Array> + const assistantWithTools = secondTurnMessages.find( + (m) => m.role === 'assistant' && Array.isArray(m.tool_calls) + ) + expect(assistantWithTools).toBeDefined() + expect(assistantWithTools?.reasoning_content).toBeUndefined() + }) + + it('rotates forced tool_choice to auto after the forced tool is used', async () => { + const toolChoices: unknown[] = [] + let call = 0 + + const createStream = vi.fn(async (params: { tool_choice?: unknown }) => { + toolChoices.push(params.tool_choice) + call += 1 + if (call === 1) { + return (async function* () { + yield* toolThenAnswerChunks('lookup', '{}', '') + })() + } + return (async function* () { + yield { + choices: [{ delta: { content: 'final answer' } }], + usage: { prompt_tokens: 8, completion_tokens: 2, total_tokens: 10 }, + } + })() + }) + + const events = await collectEvents( + createOpenAICompatStreamingToolLoopStream({ + providerName: 'Deepseek', + request: { + model: 'deepseek-chat', + apiKey: 'k', + messages: [], + tools: [{ id: 'lookup', name: 'lookup', description: 'd', parameters: {} } as any], + }, + basePayload: { + model: 'deepseek-chat', + tool_choice: { type: 'function', function: { name: 'lookup' } }, + }, + messages: [{ role: 'user', content: 'force lookup then answer' }], + createStream: createStream as any, + logger, + timeSegments: [], + forcedTools: ['lookup'], + onComplete: () => {}, + }) + ) + + expect(createStream).toHaveBeenCalledTimes(2) + expect(toolChoices[0]).toEqual({ type: 'function', function: { name: 'lookup' } }) + expect(toolChoices[1]).toBe('auto') + expect(events.some((e) => e.type === 'text_delta' && e.text === 'final answer')).toBe(true) + expect(logger.info).toHaveBeenCalledWith( + 'All forced tools have been used, switching to auto tool_choice' + ) + }) + + it('streams thinking live and assembles tool args from deltas', async () => { + let call = 0 + const createStream = vi.fn(async () => { + call += 1 + if (call === 1) { + return (async function* () { + yield* openaiCompatToolCallStartChunks as any + yield { + choices: [ + { + delta: { + tool_calls: [{ index: 0, function: { arguments: '"https://example.com"}' } }], + }, + }, + ], + usage: { prompt_tokens: 4, completion_tokens: 6, total_tokens: 10 }, + } + })() + } + return (async function* () { + yield { + choices: [{ delta: { content: 'fetched' } }], + usage: { prompt_tokens: 5, completion_tokens: 1, total_tokens: 6 }, + } + })() + }) + + await collectEvents( + createOpenAICompatStreamingToolLoopStream({ + providerName: 'Deepseek', + request: { + model: 'deepseek-chat', + apiKey: 'k', + messages: [], + tools: [ + { id: 'http_request', name: 'http_request', description: 'd', parameters: {} } as any, + ], + }, + basePayload: { model: 'deepseek-chat' }, + messages: [{ role: 'user', content: 'fetch' }], + createStream: createStream as any, + logger, + timeSegments: [], + onComplete: () => {}, + }) + ) + + expect(mockExecuteTool).toHaveBeenCalledWith( + 'http_request', + { url: 'https://example.com' }, + expect.objectContaining({ skipPostProcess: true }) + ) + }) +}) diff --git a/apps/sim/providers/openai-compat/streaming-tool-loop.ts b/apps/sim/providers/openai-compat/streaming-tool-loop.ts new file mode 100644 index 00000000000..f18de36e98e --- /dev/null +++ b/apps/sim/providers/openai-compat/streaming-tool-loop.ts @@ -0,0 +1,451 @@ +/** + * Shared OpenAI Chat Completions streaming tool loop (Step 9). + * + * Capability-honest: reasoning deltas only when the vendor streams them. + * Final-turn-only answer projection via `turn` tags. Tool ends in completion + * order; abort → cancelled. + * + * Streams each model turn live (thinking + tool_call_start). Answer text is + * buffered until the turn is classified intermediate vs final — same contract + * as Anthropic/Gemini/Bedrock loops. Tool args are assembled from streamed + * `tool_calls` deltas (no blocking hybrid). + */ + +import type OpenAI from 'openai' +import type { ChatCompletionChunk } from 'openai/resources/chat/completions' +import type { Logger } from '@sim/logger' +import { getErrorMessage, toError } from '@sim/utils/errors' +import type { NormalizedBlockOutput } from '@/executor/types' +import { MAX_TOOL_ITERATIONS } from '@/providers' +import { + createOpenAICompatibleAgentEventStream, + type OpenAICompatAssembledToolCall, +} from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent, ToolCallEndStatus } from '@/providers/stream-events' +import type { ProviderRequest, TimeSegment } from '@/providers/types' +import { + calculateCost, + prepareToolExecution, + sumToolCosts, + trackForcedToolUsage, +} from '@/providers/utils' +import { executeTool } from '@/tools' + +export interface OpenAICompatStreamingToolLoopComplete { + content: string + tokens: { input: number; output: number; total: number } + cost: NormalizedBlockOutput['cost'] + toolCalls?: { list: unknown[]; count: number } + modelTime: number + toolsTime: number + firstResponseTime: number + iterations: number +} + +export type OpenAICompatCreateCompletion = ( + params: OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming, + options?: { signal?: AbortSignal } +) => Promise> + +/** @deprecated Prefer streamed tool-arg assembly; retained for callers that still pass it. */ +export type OpenAICompatCreateCompletionBlocking = ( + params: OpenAI.Chat.Completions.ChatCompletionCreateParamsNonStreaming, + options?: { signal?: AbortSignal } +) => Promise + +export interface CreateOpenAICompatStreamingToolLoopOptions { + providerName: string + request: ProviderRequest + /** Base chat.completions payload (messages, tools, model, …) without stream. */ + basePayload: Record + messages: OpenAI.Chat.Completions.ChatCompletionMessageParam[] + createStream: OpenAICompatCreateCompletion + /** Ignored — tool args come from streamed deltas. Kept for call-site compatibility. */ + createBlocking?: OpenAICompatCreateCompletionBlocking + logger: Logger + timeSegments: TimeSegment[] + forcedTools?: string[] + /** + * When true, keep vendor reasoning fields on assistant history messages + * during the tool loop (required by DeepSeek thinking + tools). + */ + preserveAssistantReasoning?: boolean + onComplete: (result: OpenAICompatStreamingToolLoopComplete) => void +} + +function isAbortError(error: unknown): boolean { + if (!error || typeof error !== 'object') return false + const name = (error as { name?: string }).name + return name === 'AbortError' || name === 'APIUserAbortError' +} + +function settleOpenTools( + controller: ReadableStreamDefaultController, + openTools: Map, + status: ToolCallEndStatus +): void { + for (const [id, name] of openTools) { + controller.enqueue({ type: 'tool_call_end', id, name, status }) + } + openTools.clear() +} + +function nextForcedToolChoice( + forcedTools: string[], + usedForcedTools: string[] +): 'auto' | { type: 'function'; function: { name: string } } { + const remaining = forcedTools.filter((tool) => !usedForcedTools.includes(tool)) + if (remaining.length === 0) return 'auto' + return { type: 'function', function: { name: remaining[0] } } +} + +/** + * Multi-turn OpenAI-compat tool loop as an agent-events-v1 object stream. + */ +export function createOpenAICompatStreamingToolLoopStream( + options: CreateOpenAICompatStreamingToolLoopOptions +): ReadableStream { + const { + providerName, + request, + basePayload, + messages, + createStream, + logger, + timeSegments, + onComplete, + preserveAssistantReasoning = false, + } = options + const forcedTools = options.forcedTools ?? [] + + return new ReadableStream({ + async start(controller) { + const currentMessages = [...messages] + let content = '' + let iterationCount = 0 + let modelTime = 0 + let toolsTime = 0 + let firstResponseTime = 0 + const tokens = { input: 0, output: 0, total: 0 } + const toolCalls: unknown[] = [] + const toolResults: Record[] = [] + const openToolStarts = new Map() + const streamOpts = request.abortSignal ? { signal: request.abortSignal } : undefined + + let currentToolChoice = basePayload.tool_choice + let usedForcedTools: string[] = [] + let hasUsedForcedTool = false + + try { + while (iterationCount < MAX_TOOL_ITERATIONS) { + if (request.abortSignal?.aborted) { + settleOpenTools(controller, openToolStarts, 'cancelled') + throw new DOMException('Stream aborted', 'AbortError') + } + + const modelStart = Date.now() + const turnPayload = { + ...basePayload, + messages: currentMessages, + ...(currentToolChoice !== undefined ? { tool_choice: currentToolChoice } : {}), + stream: true as const, + } + + const stream = await createStream( + turnPayload as OpenAI.Chat.Completions.ChatCompletionCreateParamsStreaming, + streamOpts + ) + + let turnUsage = { prompt_tokens: 0, completion_tokens: 0, total_tokens: 0 } + let turnContent = '' + let turnReasoningContent = '' + let turnReasoning = '' + let assembledTools: OpenAICompatAssembledToolCall[] = [] + const bufferedText: string[] = [] + + const eventStream = createOpenAICompatibleAgentEventStream(stream, { + providerName, + emitToolCallStarts: true, + bufferTextDeltas: true, + onComplete: (result) => { + turnUsage = { + prompt_tokens: result.usage.prompt_tokens ?? 0, + completion_tokens: result.usage.completion_tokens ?? 0, + total_tokens: result.usage.total_tokens ?? 0, + } + turnContent = result.content || '' + turnReasoningContent = result.reasoning_content || '' + turnReasoning = result.reasoning || '' + assembledTools = result.toolCalls ?? [] + }, + }) + + { + const reader = eventStream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + if (value.type === 'thinking_delta' || value.type === 'tool_call_start') { + if (value.type === 'tool_call_start') { + openToolStarts.set(value.id, value.name) + } + controller.enqueue(value) + } else if (value.type === 'text_delta') { + // Should not arrive when bufferTextDeltas is true; keep for safety. + bufferedText.push(value.text) + } + } + } + + const pendingTools = assembledTools.filter((tc) => tc.id && tc.function?.name) + const turnTag = pendingTools.length > 0 ? 'intermediate' : 'final' + const textToFlush = turnContent || bufferedText.join('') + if (textToFlush) { + controller.enqueue({ type: 'text_delta', text: textToFlush, turn: turnTag }) + } + + const modelEnd = Date.now() + const thisModelTime = modelEnd - modelStart + modelTime += thisModelTime + if (iterationCount === 0) firstResponseTime = thisModelTime + timeSegments.push({ + type: 'model', + name: request.model, + startTime: modelStart, + endTime: modelEnd, + duration: thisModelTime, + }) + tokens.input += turnUsage.prompt_tokens + tokens.output += turnUsage.completion_tokens + tokens.total += + turnUsage.total_tokens || turnUsage.prompt_tokens + turnUsage.completion_tokens + + if (pendingTools.length === 0) { + content = textToFlush || content + break + } + + if ( + typeof currentToolChoice === 'object' && + currentToolChoice !== null && + pendingTools.length > 0 + ) { + const tracked = trackForcedToolUsage( + pendingTools, + currentToolChoice, + logger, + providerName.toLowerCase(), + forcedTools, + usedForcedTools + ) + hasUsedForcedTool = tracked.hasUsedForcedTool + usedForcedTools = tracked.usedForcedTools + } + + const assistantHistory: Record = { + role: 'assistant', + content: textToFlush || null, + tool_calls: pendingTools, + } + if (preserveAssistantReasoning) { + if (turnReasoningContent) { + assistantHistory.reasoning_content = turnReasoningContent + } + if (turnReasoning) { + assistantHistory.reasoning = turnReasoning + } + } + currentMessages.push( + assistantHistory as OpenAI.Chat.Completions.ChatCompletionMessageParam + ) + + const toolsStartTime = Date.now() + const orderedResults = await Promise.all( + pendingTools.map(async (tc) => { + const toolCallStartTime = Date.now() + const toolName = tc.function.name + const toolUseId = tc.id + let toolArgs: Record = {} + try { + toolArgs = JSON.parse(tc.function.arguments || '{}') + } catch { + toolArgs = {} + } + + try { + if (request.abortSignal?.aborted) { + throw new DOMException('Stream aborted', 'AbortError') + } + const tool = request.tools?.find((t) => t.id === toolName) + if (!tool) { + const value = { + toolUseId, + toolName, + toolArgs, + toolParams: {} as Record, + result: { + success: false as const, + output: undefined, + error: `Tool not found: ${toolName}`, + }, + startTime: toolCallStartTime, + endTime: Date.now(), + duration: Date.now() - toolCallStartTime, + status: 'error' as ToolCallEndStatus, + } + openToolStarts.delete(toolUseId) + controller.enqueue({ + type: 'tool_call_end', + id: toolUseId, + name: toolName, + status: 'error', + }) + return value + } + + const { toolParams, executionParams } = prepareToolExecution( + tool, + toolArgs, + request + ) + const result = await executeTool(toolName, executionParams, { + skipPostProcess: true, + signal: request.abortSignal, + }) + const toolCallEndTime = Date.now() + const value = { + toolUseId, + toolName, + toolArgs, + toolParams, + result, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + status: (result.success ? 'success' : 'error') as ToolCallEndStatus, + } + openToolStarts.delete(toolUseId) + controller.enqueue({ + type: 'tool_call_end', + id: toolUseId, + name: toolName, + status: value.status, + }) + return value + } catch (error) { + const cancelled = isAbortError(error) || !!request.abortSignal?.aborted + if (!cancelled) { + logger.error('Error processing tool call:', { error, toolName }) + } + const toolCallEndTime = Date.now() + const value = { + toolUseId, + toolName, + toolArgs, + toolParams: {} as Record, + result: { + success: false as const, + output: undefined, + error: getErrorMessage(error, 'Tool execution failed'), + }, + startTime: toolCallStartTime, + endTime: toolCallEndTime, + duration: toolCallEndTime - toolCallStartTime, + status: (cancelled ? 'cancelled' : 'error') as ToolCallEndStatus, + } + openToolStarts.delete(toolUseId) + controller.enqueue({ + type: 'tool_call_end', + id: toolUseId, + name: toolName, + status: value.status, + }) + return value + } + }) + ) + + for (const value of orderedResults) { + timeSegments.push({ + type: 'tool', + name: value.toolName, + startTime: value.startTime, + endTime: value.endTime, + duration: value.duration, + toolCallId: value.toolUseId, + }) + + let resultContent: unknown + if (value.result.success && value.result.output) { + toolResults.push(value.result.output as Record) + resultContent = value.result.output + } else { + resultContent = { + error: true, + message: value.result.error || 'Tool execution failed', + tool: value.toolName, + } + } + + toolCalls.push({ + name: value.toolName, + arguments: value.toolParams, + startTime: new Date(value.startTime).toISOString(), + endTime: new Date(value.endTime).toISOString(), + duration: value.duration, + result: resultContent, + success: value.result.success, + }) + + currentMessages.push({ + role: 'tool', + tool_call_id: value.toolUseId, + content: JSON.stringify(resultContent), + }) + } + + toolsTime += Date.now() - toolsStartTime + + // Rotate / clear forced tool_choice so the model can answer after forced tools. + if (typeof currentToolChoice === 'object' && currentToolChoice !== null) { + if (hasUsedForcedTool && forcedTools.length > 0) { + const next = nextForcedToolChoice(forcedTools, usedForcedTools) + currentToolChoice = next + if (next === 'auto') { + logger.info('All forced tools have been used, switching to auto tool_choice') + } else { + logger.info(`Forcing next tool: ${next.function.name}`) + } + } + } + + iterationCount++ + } + + const modelCost = calculateCost(request.model, tokens.input, tokens.output) + const toolCostTotal = sumToolCosts(toolResults) + onComplete({ + content, + tokens, + cost: { + input: modelCost.input, + output: modelCost.output, + total: modelCost.total + (toolCostTotal || 0), + ...(toolCostTotal ? { toolCost: toolCostTotal } : {}), + }, + toolCalls: + toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + modelTime, + toolsTime, + firstResponseTime, + iterations: iterationCount + 1, + }) + controller.close() + } catch (error) { + const cancelled = isAbortError(error) || !!request.abortSignal?.aborted + settleOpenTools(controller, openToolStarts, cancelled ? 'cancelled' : 'error') + controller.error(toError(error)) + } + }, + }) +} diff --git a/apps/sim/providers/openai/core.reasoning.test.ts b/apps/sim/providers/openai/core.reasoning.test.ts new file mode 100644 index 00000000000..2b4f481e727 --- /dev/null +++ b/apps/sim/providers/openai/core.reasoning.test.ts @@ -0,0 +1,107 @@ +/** + * @vitest-environment node + * + * OpenAI Responses harden: reasoning models always request summary: 'auto' + * even when effort is auto / unset. + */ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { executeResponsesProviderRequest } from '@/providers/openai/core' +import type { ProviderRequest } from '@/providers/types' + +vi.mock('@/providers', () => ({ MAX_TOOL_ITERATIONS: 5 })) + +vi.mock('@/providers/utils', async (importOriginal) => { + const actual = await importOriginal() + return { + ...actual, + calculateCost: () => ({ input: 0, output: 0, total: 0 }), + sumToolCosts: () => 0, + prepareToolsWithUsageControl: () => ({ + tools: [], + toolChoice: undefined, + forcedTools: [], + hasFilteredTools: false, + }), + } +}) + +vi.mock('@/tools', () => ({ executeTool: vi.fn() })) + +function jsonResponse(body: unknown, status = 200) { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }) +} + +describe('executeResponsesProviderRequest reasoning harden', () => { + const logger = { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + debug: vi.fn(), + } as any + + let fetchMock: ReturnType + + beforeEach(() => { + fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + id: 'resp_1', + status: 'completed', + output: [ + { + type: 'message', + role: 'assistant', + content: [{ type: 'output_text', text: 'hello' }], + }, + ], + usage: { input_tokens: 1, output_tokens: 1, total_tokens: 2 }, + }) + ) + }) + + function run(request: Partial & { model: string }) { + return executeResponsesProviderRequest( + { + apiKey: 'k', + messages: [{ role: 'user', content: 'hi' }], + ...request, + }, + { + providerId: 'openai', + providerLabel: 'OpenAI', + modelName: request.model, + endpoint: 'https://api.openai.com/v1/responses', + headers: { Authorization: 'Bearer k' }, + logger, + fetch: fetchMock as unknown as typeof fetch, + } + ) + } + + it('includes reasoning.summary auto when effort is auto', async () => { + await run({ model: 'gpt-5.5', reasoningEffort: 'auto' }) + expect(fetchMock).toHaveBeenCalled() + const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) + expect(body.reasoning).toEqual({ summary: 'auto' }) + }) + + it('includes reasoning.summary auto when effort is unset', async () => { + await run({ model: 'o3' }) + const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) + expect(body.reasoning).toEqual({ summary: 'auto' }) + }) + + it('includes effort and summary when effort is explicit', async () => { + await run({ model: 'gpt-5.5', reasoningEffort: 'high' }) + const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) + expect(body.reasoning).toEqual({ summary: 'auto', effort: 'high' }) + }) + + it('omits reasoning for non-reasoning models', async () => { + await run({ model: 'gpt-4o' }) + const body = JSON.parse(fetchMock.mock.calls[0][1].body as string) + expect(body.reasoning).toBeUndefined() + }) +}) diff --git a/apps/sim/providers/openai/core.ts b/apps/sim/providers/openai/core.ts index 913700ef5d7..6938c337a91 100644 --- a/apps/sim/providers/openai/core.ts +++ b/apps/sim/providers/openai/core.ts @@ -14,6 +14,7 @@ import { prepareToolExecution, prepareToolsWithUsageControl, sumToolCosts, + supportsReasoningEffort, trackForcedToolUsage, } from '@/providers/utils' import { executeTool } from '@/tools' @@ -98,10 +99,14 @@ export async function executeResponsesProviderRequest( if (request.temperature !== undefined) basePayload.temperature = request.temperature if (request.maxTokens != null) basePayload.max_output_tokens = request.maxTokens - if (request.reasoningEffort !== undefined && request.reasoningEffort !== 'auto') { + // Always request reasoning summaries on reasoning models so Thinking chrome works + // even when effort is `auto` / unset (API defaults effort). + if (supportsReasoningEffort(config.modelName)) { basePayload.reasoning = { - effort: request.reasoningEffort, summary: 'auto', + ...(request.reasoningEffort !== undefined && request.reasoningEffort !== 'auto' + ? { effort: request.reasoningEffort } + : {}), } } @@ -255,8 +260,9 @@ export async function executeResponsesProviderRequest( timing: { kind: 'simple', segmentName: request.model }, initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => - createReadableStreamFromResponses(streamResponse, (content, usage) => { + createReadableStreamFromResponses(streamResponse, (content, usage, thinking) => { output.content = content output.tokens = { input: usage?.promptTokens || 0, @@ -275,6 +281,14 @@ export async function executeResponsesProviderRequest( total: costResult.total, } + if (thinking) { + const segment = output.providerTiming?.timeSegments?.[0] + if (segment) { + // Label honestly: these are reasoning *summaries*, not raw CoT. + segment.thinkingContent = thinking + } + } + finalizeTiming() }), }) @@ -582,10 +596,12 @@ export async function executeResponsesProviderRequest( // Copy over non-tool related settings if (request.temperature !== undefined) finalPayload.temperature = request.temperature if (request.maxTokens != null) finalPayload.max_output_tokens = request.maxTokens - if (request.reasoningEffort !== undefined && request.reasoningEffort !== 'auto') { + if (supportsReasoningEffort(config.modelName)) { finalPayload.reasoning = { - effort: request.reasoningEffort, summary: 'auto', + ...(request.reasoningEffort !== undefined && request.reasoningEffort !== 'auto' + ? { effort: request.reasoningEffort } + : {}), } } if (request.verbosity !== undefined && request.verbosity !== 'auto') { @@ -681,8 +697,9 @@ export async function executeResponsesProviderRequest( total: accumulatedCost.total, }, toolCalls: toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromResponses(streamResponse, (content, usage) => { + createReadableStreamFromResponses(streamResponse, (content, usage, thinking) => { output.content = content output.tokens = { input: tokens.input + (usage?.promptTokens || 0), @@ -702,6 +719,13 @@ export async function executeResponsesProviderRequest( toolCost: tc || undefined, total: accumulatedCost.total + streamCost.total + tc, } + + if (thinking) { + const lastModel = [...timeSegments].reverse().find((s) => s.type === 'model') + if (lastModel) { + lastModel.thinkingContent = thinking + } + } }), }) diff --git a/apps/sim/providers/openai/utils.stream.test.ts b/apps/sim/providers/openai/utils.stream.test.ts new file mode 100644 index 00000000000..c044584c583 --- /dev/null +++ b/apps/sim/providers/openai/utils.stream.test.ts @@ -0,0 +1,75 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { createReadableStreamFromResponses } from '@/providers/openai/utils' +import type { AgentStreamEvent } from '@/providers/stream-events' + +function sseResponse(events: Array<{ event?: string; data: unknown }>): Response { + const body = events + .map((e) => { + const lines = [] + if (e.event) lines.push(`event: ${e.event}`) + lines.push(`data: ${JSON.stringify(e.data)}`) + return `${lines.join('\n')}\n\n` + }) + .join('') + return new Response(body, { headers: { 'Content-Type': 'text/event-stream' } }) +} + +async function collectEvents( + stream: ReadableStream +): Promise { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events +} + +describe('createReadableStreamFromResponses (Step 9)', () => { + it('emits reasoning summary deltas as thinking and output_text as final text', async () => { + const onComplete = vi.fn() + const response = sseResponse([ + { + event: 'response.reasoning_summary_text.delta', + data: { type: 'response.reasoning_summary_text.delta', delta: 'Summary thought. ' }, + }, + { + event: 'response.output_text.delta', + data: { type: 'response.output_text.delta', delta: 'Answer' }, + }, + { + event: 'response.completed', + data: { + type: 'response.completed', + response: { + usage: { input_tokens: 4, output_tokens: 6 }, + }, + }, + }, + ]) + + const events = await collectEvents(createReadableStreamFromResponses(response, onComplete)) + expect(events).toEqual([ + { type: 'thinking_delta', text: 'Summary thought. ' }, + { type: 'text_delta', text: 'Answer', turn: 'final' }, + ]) + expect(onComplete.mock.calls[0][0]).toBe('Answer') + expect(onComplete.mock.calls[0][2]).toBe('Summary thought. ') + }) + + it('stays text-only when no reasoning summary events arrive', async () => { + const response = sseResponse([ + { + event: 'response.output_text.delta', + data: { type: 'response.output_text.delta', delta: 'Hi' }, + }, + ]) + const events = await collectEvents(createReadableStreamFromResponses(response)) + expect(events).toEqual([{ type: 'text_delta', text: 'Hi', turn: 'final' }]) + }) +}) diff --git a/apps/sim/providers/openai/utils.ts b/apps/sim/providers/openai/utils.ts index 495a0eae05b..65d5d5da5ab 100644 --- a/apps/sim/providers/openai/utils.ts +++ b/apps/sim/providers/openai/utils.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import type OpenAI from 'openai' import { buildOpenAIMessageContent } from '@/providers/attachments' +import type { AgentStreamEvent } from '@/providers/stream-events' import type { Message } from '@/providers/types' const logger = createLogger('ResponsesUtils') @@ -411,18 +412,23 @@ export function parseResponsesUsage( } /** - * Creates a ReadableStream from a Responses API SSE stream. + * Creates an agent-events-v1 stream from a Responses API SSE stream. + * + * Capability-honest: emits `thinking_delta` only for streamable reasoning + * *summary* deltas (not encrypted_content / raw CoT). If the API only + * surfaces reasoning at completion, live thinking may be empty — traces still + * use extractResponseReasoning post-hoc. */ export function createReadableStreamFromResponses( response: Response, - onComplete?: (content: string, usage?: ResponsesUsageTokens) => void -): ReadableStream { + onComplete?: (content: string, usage?: ResponsesUsageTokens, thinking?: string) => void +): ReadableStream { let fullContent = '' + let fullThinking = '' let finalUsage: ResponsesUsageTokens | undefined let activeEventType: string | undefined - const encoder = new TextEncoder() - return new ReadableStream({ + return new ReadableStream({ async start(controller) { const reader = response.body?.getReader() if (!reader) { @@ -488,6 +494,28 @@ export function createReadableStreamFromResponses( return } + // Reasoning *summaries* only — never stream encrypted_content. + if ( + eventType === 'response.reasoning_summary_text.delta' || + eventType === 'response.reasoning_summary.delta' || + eventType === 'response.reasoning.delta' + ) { + let deltaText = '' + const delta = event.delta as string | Record | undefined + if (typeof delta === 'string') { + deltaText = delta + } else if (delta && typeof delta.text === 'string') { + deltaText = delta.text + } else if (typeof event.text === 'string') { + deltaText = event.text + } + if (deltaText.length > 0) { + fullThinking += deltaText + controller.enqueue({ type: 'thinking_delta', text: deltaText }) + } + continue + } + if ( eventType === 'response.output_text.delta' || eventType === 'response.output_json.delta' @@ -508,7 +536,7 @@ export function createReadableStreamFromResponses( if (deltaText.length > 0) { fullContent += deltaText - controller.enqueue(encoder.encode(deltaText)) + controller.enqueue({ type: 'text_delta', text: deltaText, turn: 'final' }) } } @@ -523,7 +551,7 @@ export function createReadableStreamFromResponses( } if (onComplete) { - onComplete(fullContent, finalUsage) + onComplete(fullContent, finalUsage, fullThinking || undefined) } controller.close() diff --git a/apps/sim/providers/openrouter/index.ts b/apps/sim/providers/openrouter/index.ts index 8821483fa4e..0508b28c2ce 100644 --- a/apps/sim/providers/openrouter/index.ts +++ b/apps/sim/providers/openrouter/index.ts @@ -169,6 +169,7 @@ export const openRouterProvider: ProviderConfig = { timing: { kind: 'simple', segmentName: request.model }, initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { output.content = content @@ -471,6 +472,7 @@ export const openRouterProvider: ProviderConfig = { }, toolCalls: toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + streamFormat: 'agent-events-v1', createStream: ({ output }) => createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { output.content = content diff --git a/apps/sim/providers/openrouter/utils.ts b/apps/sim/providers/openrouter/utils.ts index 51637f5148d..2f8a7850fc9 100644 --- a/apps/sim/providers/openrouter/utils.ts +++ b/apps/sim/providers/openrouter/utils.ts @@ -2,7 +2,9 @@ import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { checkForForcedToolUsageOpenAI, createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' +import { checkForForcedToolUsageOpenAI } from '@/providers/utils' const logger = createLogger('OpenRouterUtils') @@ -86,9 +88,14 @@ export async function supportsNativeStructuredOutputs(modelId: string): Promise< export function createReadableStreamFromOpenAIStream( openaiStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(openaiStream, 'OpenRouter', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(openaiStream, { + providerName: 'OpenRouter', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } export function checkForForcedToolUsage( diff --git a/apps/sim/providers/sakana/index.ts b/apps/sim/providers/sakana/index.ts index 3988d3e96db..39e61746ad1 100644 --- a/apps/sim/providers/sakana/index.ts +++ b/apps/sim/providers/sakana/index.ts @@ -150,6 +150,7 @@ export const sakanaProvider: ProviderConfig = { initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => createReadableStreamFromSakanaStream(streamResponse as any, (content, usage) => { output.content = content @@ -516,6 +517,7 @@ export const sakanaProvider: ProviderConfig = { } : undefined, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => createReadableStreamFromSakanaStream(streamResponse as any, (content, usage) => { output.content = content diff --git a/apps/sim/providers/sakana/utils.ts b/apps/sim/providers/sakana/utils.ts index ede98301a12..ba8b42329cf 100644 --- a/apps/sim/providers/sakana/utils.ts +++ b/apps/sim/providers/sakana/utils.ts @@ -1,14 +1,20 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' /** - * Creates a ReadableStream from a Sakana AI streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a Sakana AI streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromSakanaStream( sakanaStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(sakanaStream, 'Sakana', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(sakanaStream, { + providerName: 'Sakana', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } diff --git a/apps/sim/providers/step-10-smokes.test.ts b/apps/sim/providers/step-10-smokes.test.ts new file mode 100644 index 00000000000..cb1ac833f9a --- /dev/null +++ b/apps/sim/providers/step-10-smokes.test.ts @@ -0,0 +1,67 @@ +/** + * @vitest-environment node + * + * Step 10 smoke: Anthropic thinking+tool fixture, openai-compat thinking, + * and a non-thinking text-only stream — capability-honest expectations. + */ +import { describe, expect, it } from 'vitest' +import { anthropicThinkingTextToolStreamEvents } from '@/providers/__fixtures__/anthropic' +import { + openaiCompatReasoningAndTextChunks, + openaiCompatTextOnlyChunks, +} from '@/providers/__fixtures__/openai-compat' +import { createReadableStreamFromAnthropicStream } from '@/providers/anthropic/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' + +async function collectEvents( + stream: ReadableStream +): Promise { + const events: AgentStreamEvent[] = [] + const reader = stream.getReader() + while (true) { + const { done, value } = await reader.read() + if (done) break + events.push(value) + } + return events +} + +describe('Step 10 provider smokes', () => { + it('Anthropic fixture emits thinking then text (tools ignored on simple util)', async () => { + const stream = createReadableStreamFromAnthropicStream( + (async function* () { + yield* anthropicThinkingTextToolStreamEvents as any + })() + ) + const events = await collectEvents(stream) + expect(events.some((e) => e.type === 'thinking_delta')).toBe(true) + expect(events.some((e) => e.type === 'text_delta')).toBe(true) + // Simple Anthropic util does not emit tool_call_* (loop does). + expect(events.some((e) => e.type === 'tool_call_start')).toBe(false) + }) + + it('openai-compat reasoning model emits thinking_delta', async () => { + const stream = createOpenAICompatibleAgentEventStream( + (async function* () { + yield* openaiCompatReasoningAndTextChunks as any + })(), + { providerName: 'DeepSeek' } + ) + const events = await collectEvents(stream) + expect(events.filter((e) => e.type === 'thinking_delta').length).toBeGreaterThan(0) + expect(events.some((e) => e.type === 'text_delta')).toBe(true) + }) + + it('openai-compat non-thinking model stays text-only', async () => { + const stream = createOpenAICompatibleAgentEventStream( + (async function* () { + yield* openaiCompatTextOnlyChunks as any + })(), + { providerName: 'OpenAI' } + ) + const events = await collectEvents(stream) + expect(events.every((e) => e.type === 'text_delta')).toBe(true) + expect(events.some((e) => e.type === 'thinking_delta')).toBe(false) + }) +}) diff --git a/apps/sim/providers/stream-events.test.ts b/apps/sim/providers/stream-events.test.ts new file mode 100644 index 00000000000..477ed4abc42 --- /dev/null +++ b/apps/sim/providers/stream-events.test.ts @@ -0,0 +1,110 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it } from 'vitest' +import { + createAgentEventReadableStream, + isAgentEventReadableStream, + isAgentStreamEvent, + isAgentStreamFormat, + isTextDeltaTurn, + isToolCallEndStatus, + type AgentStreamEvent, +} from '@/providers/stream-events' + +describe('stream-events contract', () => { + describe('isAgentStreamFormat', () => { + it('accepts text and agent-events-v1 only', () => { + expect(isAgentStreamFormat('text')).toBe(true) + expect(isAgentStreamFormat('agent-events-v1')).toBe(true) + expect(isAgentStreamFormat('ndjson')).toBe(false) + expect(isAgentStreamFormat(undefined)).toBe(false) + }) + }) + + describe('isAgentStreamEvent', () => { + it('accepts valid event variants', () => { + const events: AgentStreamEvent[] = [ + { type: 'text_delta', text: 'hi' }, + { type: 'text_delta', text: 'mid', turn: 'intermediate' }, + { type: 'text_delta', text: 'bye', turn: 'final' }, + { type: 'thinking_delta', text: 'hmm' }, + { type: 'tool_call_start', id: 't1', name: 'search' }, + { type: 'tool_call_end', id: 't1', name: 'search', status: 'success' }, + { type: 'tool_call_end', id: 't1', name: 'search', status: 'error' }, + { type: 'tool_call_end', id: 't1', name: 'search', status: 'cancelled' }, + ] + + for (const event of events) { + expect(isAgentStreamEvent(event)).toBe(true) + } + }) + + it('rejects malformed events', () => { + expect(isAgentStreamEvent(null)).toBe(false) + expect(isAgentStreamEvent({ type: 'error', message: 'x' })).toBe(false) + expect(isAgentStreamEvent({ type: 'text_delta' })).toBe(false) + expect(isAgentStreamEvent({ type: 'text_delta', text: 'x', turn: 'other' })).toBe(false) + expect(isAgentStreamEvent({ type: 'tool_call_start', id: 't1' })).toBe(false) + expect( + isAgentStreamEvent({ type: 'tool_call_end', id: 't1', name: 'search', status: 'ok' }) + ).toBe(false) + }) + }) + + describe('status and turn guards', () => { + it('validates tool end statuses and text turns', () => { + expect(isToolCallEndStatus('success')).toBe(true) + expect(isToolCallEndStatus('failed')).toBe(false) + expect(isTextDeltaTurn('final')).toBe(true) + expect(isTextDeltaTurn('first')).toBe(false) + }) + }) + + describe('isAgentEventReadableStream', () => { + it('requires streamFormat agent-events-v1 and does not sniff', () => { + const stream = new ReadableStream() + expect(isAgentEventReadableStream(stream, 'agent-events-v1')).toBe(true) + expect(isAgentEventReadableStream(stream, 'text')).toBe(false) + }) + }) + + describe('createAgentEventReadableStream', () => { + it('enqueues events in order and closes', async () => { + const events: AgentStreamEvent[] = [ + { type: 'thinking_delta', text: 'a' }, + { type: 'text_delta', text: 'b', turn: 'final' }, + { type: 'tool_call_start', id: '1', name: 'lookup' }, + { type: 'tool_call_end', id: '1', name: 'lookup', status: 'success' }, + ] + + const stream = createAgentEventReadableStream(events) + const reader = stream.getReader() + const received: AgentStreamEvent[] = [] + + while (true) { + const { done, value } = await reader.read() + if (done) break + received.push(value) + } + + expect(received).toEqual(events) + }) + + it('errors when an invalid object is yielded', async () => { + async function* bad() { + yield { type: 'text_delta', text: 'ok' } as AgentStreamEvent + yield { type: 'nope' } as unknown as AgentStreamEvent + } + + const stream = createAgentEventReadableStream(bad()) + const reader = stream.getReader() + + await expect(reader.read()).resolves.toMatchObject({ + done: false, + value: { type: 'text_delta', text: 'ok' }, + }) + await expect(reader.read()).rejects.toThrow(/Invalid AgentStreamEvent/) + }) + }) +}) diff --git a/apps/sim/providers/stream-events.ts b/apps/sim/providers/stream-events.ts new file mode 100644 index 00000000000..08d73fbe6c9 --- /dev/null +++ b/apps/sim/providers/stream-events.ts @@ -0,0 +1,142 @@ +/** + * Canonical agent stream event contract (provider → executor). + * + * Providers with `streamFormat: 'agent-events-v1'` return a + * `ReadableStream` (in-process object stream — not NDJSON). + * Legacy providers keep `streamFormat: 'text'` and `ReadableStream`. + * + * The executor is the only consumer that projects these events into a text + * stream + optional sink; downstream SSE/chat must not re-parse provider streams. + */ + +export const AGENT_STREAM_FORMATS = ['text', 'agent-events-v1'] as const + +export type AgentStreamFormat = (typeof AGENT_STREAM_FORMATS)[number] + +export const AGENT_STREAM_EVENT_TYPES = [ + 'text_delta', + 'thinking_delta', + 'tool_call_start', + 'tool_call_end', +] as const + +export type AgentStreamEventType = (typeof AGENT_STREAM_EVENT_TYPES)[number] + +export const TOOL_CALL_END_STATUSES = ['success', 'error', 'cancelled'] as const + +export type ToolCallEndStatus = (typeof TOOL_CALL_END_STATUSES)[number] + +export type TextDeltaTurn = 'intermediate' | 'final' + +export type AgentStreamEvent = + | { type: 'text_delta'; text: string; turn?: TextDeltaTurn } + | { type: 'thinking_delta'; text: string } + | { type: 'tool_call_start'; id: string; name: string } + | { + type: 'tool_call_end' + id: string + name: string + status: ToolCallEndStatus + } + +/** Optional sink the executor pump pushes the full ordered timeline into. */ +export type AgentStreamSink = { + onEvent: (event: AgentStreamEvent) => void | Promise +} + +export type UnsubscribeAgentStreamSink = () => void + +function isRecord(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +export function isAgentStreamFormat(value: unknown): value is AgentStreamFormat { + return value === 'text' || value === 'agent-events-v1' +} + +export function isToolCallEndStatus(value: unknown): value is ToolCallEndStatus { + return value === 'success' || value === 'error' || value === 'cancelled' +} + +export function isTextDeltaTurn(value: unknown): value is TextDeltaTurn { + return value === 'intermediate' || value === 'final' +} + +export function isAgentStreamEvent(value: unknown): value is AgentStreamEvent { + if (!isRecord(value) || typeof value.type !== 'string') { + return false + } + + switch (value.type) { + case 'text_delta': + return ( + typeof value.text === 'string' && + (value.turn === undefined || isTextDeltaTurn(value.turn)) + ) + case 'thinking_delta': + return typeof value.text === 'string' + case 'tool_call_start': + return typeof value.id === 'string' && typeof value.name === 'string' + case 'tool_call_end': + return ( + typeof value.id === 'string' && + typeof value.name === 'string' && + isToolCallEndStatus(value.status) + ) + default: + return false + } +} + +/** + * Narrows a {@link ReadableStream} to an agent-events object stream. + * Callers must also check `streamFormat === 'agent-events-v1'` on the envelope — + * do not sniff chunk types from the stream. + */ +export function isAgentEventReadableStream( + stream: ReadableStream, + streamFormat: AgentStreamFormat +): stream is ReadableStream { + return streamFormat === 'agent-events-v1' +} + +/** + * Builds a {@link ReadableStream} that enqueues the given agent events in order. + * Intended for tests and provider adapters that already have an event sequence. + */ +export function createAgentEventReadableStream( + events: Iterable | AsyncIterable +): ReadableStream { + const iterator = + Symbol.asyncIterator in events + ? events[Symbol.asyncIterator]() + : (async function* () { + for (const event of events as Iterable) { + yield event + } + })() + + return new ReadableStream({ + async pull(controller) { + try { + const { done, value } = await iterator.next() + if (done) { + controller.close() + return + } + if (!isAgentStreamEvent(value)) { + controller.error(new Error('Invalid AgentStreamEvent enqueued on object stream')) + return + } + controller.enqueue(value) + } catch (error) { + controller.error(error) + } + }, + async cancel(reason) { + if (typeof iterator.return === 'function') { + await iterator.return(reason) + } + }, + }) +} diff --git a/apps/sim/providers/stream-pump.test.ts b/apps/sim/providers/stream-pump.test.ts new file mode 100644 index 00000000000..98fd5bd7ad2 --- /dev/null +++ b/apps/sim/providers/stream-pump.test.ts @@ -0,0 +1,494 @@ +/** + * @vitest-environment node + */ +import { describe, expect, it, vi } from 'vitest' +import { + createAgentEventReadableStream, + type AgentStreamEvent, + type AgentStreamSink, +} from '@/providers/stream-events' +import { createAgentStreamPump, DEFAULT_MAX_THINKING_BYTES } from '@/providers/stream-pump' + +async function readAllText(stream: ReadableStream | null): Promise { + if (!stream) return '' + const reader = stream.getReader() + const decoder = new TextDecoder() + let text = '' + while (true) { + const { done, value } = await reader.read() + if (done) break + text += decoder.decode(value, { stream: true }) + } + text += decoder.decode() + return text +} + +function collectingSink() { + const events: AgentStreamEvent[] = [] + const sink: AgentStreamSink = { + onEvent: async (event) => { + events.push(event) + }, + } + return { sink, events } +} + +describe('createAgentStreamPump', () => { + it('projects agent-events to final-turn answer text and full sink timeline', async () => { + const events: AgentStreamEvent[] = [ + { type: 'thinking_delta', text: 'plan ' }, + { type: 'thinking_delta', text: 'it' }, + { type: 'text_delta', text: 'Looking up…', turn: 'intermediate' }, + { type: 'tool_call_start', id: '1', name: 'search' }, + { type: 'tool_call_end', id: '1', name: 'search', status: 'success' }, + { type: 'text_delta', text: 'Done.', turn: 'final' }, + ] + + const pump = createAgentStreamPump({ + source: createAgentEventReadableStream(events), + streamFormat: 'agent-events-v1', + }) + const { sink, events: seen } = collectingSink() + pump.subscribe(sink) + + const textPromise = readAllText(pump.textStream) + const result = await pump.run() + const text = await textPromise + + expect(result.answerText).toBe('Done.') + expect(result.fullyDrained).toBe(true) + expect(text).toBe('Done.') + expect(seen).toEqual(events) + }) + + it('treats legacy text streams as final-turn answer bytes and sink text_delta', async () => { + const encoder = new TextEncoder() + const source = new ReadableStream({ + start(controller) { + controller.enqueue(encoder.encode('Hel')) + controller.enqueue(encoder.encode('lo')) + controller.close() + }, + }) + + const pump = createAgentStreamPump({ source, streamFormat: 'text' }) + const { sink, events } = collectingSink() + pump.subscribe(sink) + + const textPromise = readAllText(pump.textStream) + const result = await pump.run() + + expect(result.answerText).toBe('Hello') + expect(await textPromise).toBe('Hello') + expect(events).toEqual([ + { type: 'text_delta', text: 'Hel', turn: 'final' }, + { type: 'text_delta', text: 'lo', turn: 'final' }, + ]) + }) + + it('handles UTF-8 characters split across byte chunks', async () => { + // € in UTF-8 is E2 82 AC — split across two chunks + const source = new ReadableStream({ + start(controller) { + controller.enqueue(Uint8Array.of(0xe2, 0x82)) + controller.enqueue(Uint8Array.of(0xac, 0x20, 0x6f, 0x6b)) + controller.close() + }, + }) + + const pump = createAgentStreamPump({ source, streamFormat: 'text' }) + const textPromise = readAllText(pump.textStream) + const result = await pump.run() + + expect(result.answerText).toBe('€ ok') + expect(await textPromise).toBe('€ ok') + }) + + it('drops thinking when no sink is subscribed (no unbounded thinking buffer)', async () => { + const hugeThinking = 'x'.repeat(50_000) + const pump = createAgentStreamPump({ + source: createAgentEventReadableStream([ + { type: 'thinking_delta', text: hugeThinking }, + { type: 'text_delta', text: 'hi', turn: 'final' }, + ]), + streamFormat: 'agent-events-v1', + }) + + const textPromise = readAllText(pump.textStream) + const result = await pump.run() + + expect(result.answerText).toBe('hi') + expect(await textPromise).toBe('hi') + }) + + it('caps thinking forwarded to sinks', async () => { + const pump = createAgentStreamPump({ + source: createAgentEventReadableStream([ + { type: 'thinking_delta', text: 'abcdef' }, + { type: 'thinking_delta', text: 'ghijkl' }, + { type: 'text_delta', text: 'out', turn: 'final' }, + ]), + streamFormat: 'agent-events-v1', + maxThinkingBytes: 4, + }) + const { sink, events } = collectingSink() + pump.subscribe(sink) + + const textPromise = readAllText(pump.textStream) + await pump.run() + await textPromise + + expect(events.filter((e) => e.type === 'thinking_delta')).toEqual([ + { type: 'thinking_delta', text: 'abcd' }, + ]) + expect(DEFAULT_MAX_THINKING_BYTES).toBeGreaterThan(0) + }) + + it('allows late subscribers to receive only future events', async () => { + let pullCount = 0 + let releaseFirst!: () => void + const firstGate = new Promise((resolve) => { + releaseFirst = resolve + }) + + const source = new ReadableStream({ + async pull(controller) { + pullCount += 1 + if (pullCount === 1) { + controller.enqueue({ type: 'thinking_delta', text: 'early' }) + await firstGate + return + } + if (pullCount === 2) { + controller.enqueue({ type: 'text_delta', text: 'late', turn: 'final' }) + return + } + controller.close() + }, + }) + + const pump = createAgentStreamPump({ source, streamFormat: 'agent-events-v1' }) + const early = collectingSink() + pump.subscribe(early.sink) + + const runPromise = pump.run() + const textPromise = readAllText(pump.textStream) + + // Wait until first event has been dispatched to early sink + await vi.waitFor(() => { + expect(early.events.length).toBe(1) + }) + + const late = collectingSink() + pump.subscribe(late.sink) + releaseFirst() + + const result = await runPromise + await textPromise + + expect(early.events.map((e) => e.type)).toEqual(['thinking_delta', 'text_delta']) + expect(late.events.map((e) => e.type)).toEqual(['text_delta']) + expect(result.answerText).toBe('late') + }) + + it('unsubscribe mid-stream detaches without failing the pump', async () => { + const seen: AgentStreamEvent[] = [] + const sink: AgentStreamSink = { + onEvent: async (event) => { + seen.push(event) + if (event.type === 'tool_call_start') { + unsubscribe() + } + }, + } + + const pump = createAgentStreamPump({ + source: createAgentEventReadableStream([ + { type: 'tool_call_start', id: '1', name: 'x' }, + { type: 'tool_call_end', id: '1', name: 'x', status: 'success' }, + { type: 'text_delta', text: 'ok', turn: 'final' }, + ]), + streamFormat: 'agent-events-v1', + }) + const unsubscribe = pump.subscribe(sink) + + const textPromise = readAllText(pump.textStream) + const result = await pump.run() + await textPromise + + expect(result.answerText).toBe('ok') + expect(result.fullyDrained).toBe(true) + expect(seen.map((e) => e.type)).toEqual(['tool_call_start']) + }) + + it('sinkMode skips text stream and still drains', async () => { + const pump = createAgentStreamPump({ + source: createAgentEventReadableStream([ + { type: 'text_delta', text: 'only-sink', turn: 'final' }, + ]), + streamFormat: 'agent-events-v1', + sinkMode: true, + }) + expect(pump.textStream).toBeNull() + + const { sink, events } = collectingSink() + pump.subscribe(sink) + const result = await pump.run() + + expect(result.answerText).toBe('only-sink') + expect(events).toEqual([{ type: 'text_delta', text: 'only-sink', turn: 'final' }]) + }) + + it('awaits each sink before pulling the next event (per-event sync)', async () => { + let release!: () => void + const gate = new Promise((resolve) => { + release = resolve + }) + let inFlight = 0 + let maxInFlight = 0 + + const sink: AgentStreamSink = { + onEvent: async () => { + inFlight += 1 + maxInFlight = Math.max(maxInFlight, inFlight) + await gate + inFlight -= 1 + }, + } + + const pump = createAgentStreamPump({ + source: createAgentEventReadableStream([ + { type: 'text_delta', text: '1', turn: 'final' }, + { type: 'text_delta', text: '2', turn: 'final' }, + { type: 'text_delta', text: '3', turn: 'final' }, + ]), + streamFormat: 'agent-events-v1', + sinkMode: true, + }) + pump.subscribe(sink) + + const runPromise = pump.run() + await vi.waitFor(() => { + expect(maxInFlight).toBe(1) + }) + release() + await runPromise + expect(maxInFlight).toBe(1) + }) + + it('fails the pump on invalid agent-events chunks (no soft success)', async () => { + const source = new ReadableStream({ + start(controller) { + controller.enqueue({ type: 'nope' }) + controller.close() + }, + }) + + const pump = createAgentStreamPump({ + source, + streamFormat: 'agent-events-v1', + sinkMode: true, + }) + + await expect(pump.run()).rejects.toThrow(/Invalid AgentStreamEvent/) + }) + + it('fails the pump when the provider source errors', async () => { + const source = new ReadableStream({ + start(controller) { + controller.error(new Error('provider down')) + }, + }) + + const pump = createAgentStreamPump({ + source, + streamFormat: 'agent-events-v1', + sinkMode: true, + }) + + await expect(pump.run()).rejects.toThrow(/provider down/) + }) + + it('maps abort to cancelled result and distinguishes timeout reason', async () => { + const controller = new AbortController() + let pullCount = 0 + const source = new ReadableStream({ + async pull(ctrl) { + pullCount += 1 + if (pullCount === 1) { + ctrl.enqueue({ type: 'thinking_delta', text: '…' }) + controller.abort('timeout') + return + } + ctrl.close() + }, + }) + + const pump = createAgentStreamPump({ + source, + streamFormat: 'agent-events-v1', + sinkMode: true, + abortSignal: controller.signal, + }) + + const result = await pump.run() + expect(result.cancelled).toBe(true) + expect(result.cancelReason).toBe('timeout') + expect(result.fullyDrained).toBe(false) + }) + + it('does not start until run() — subscribe-before-pull', async () => { + let pulled = false + const source = new ReadableStream({ + pull(controller) { + pulled = true + controller.enqueue({ type: 'text_delta', text: 'x', turn: 'final' }) + controller.close() + }, + }) + + const pump = createAgentStreamPump({ + source, + streamFormat: 'agent-events-v1', + sinkMode: true, + }) + const { sink, events } = collectingSink() + pump.subscribe(sink) + + expect(pulled).toBe(false) + await pump.run() + expect(pulled).toBe(true) + expect(events).toHaveLength(1) + }) + + it('rejects double run()', async () => { + const pump = createAgentStreamPump({ + source: createAgentEventReadableStream([{ type: 'text_delta', text: 'a', turn: 'final' }]), + streamFormat: 'agent-events-v1', + sinkMode: true, + }) + await pump.run() + await expect(pump.run()).rejects.toThrow(/already started/) + }) + + it('detaches a sink that throws without failing the pump', async () => { + const bad: AgentStreamSink = { + onEvent: async () => { + throw new Error('sink exploded') + }, + } + const good = collectingSink() + + const pump = createAgentStreamPump({ + source: createAgentEventReadableStream([ + { type: 'text_delta', text: 'a', turn: 'final' }, + { type: 'text_delta', text: 'b', turn: 'final' }, + ]), + streamFormat: 'agent-events-v1', + sinkMode: true, + }) + pump.subscribe(bad) + pump.subscribe(good.sink) + + const result = await pump.run() + expect(result.answerText).toBe('ab') + expect(good.events.length).toBe(2) + }) + + it('synthesizes tool_call_end cancelled for open tools on abort', async () => { + const controller = new AbortController() + const source = new ReadableStream({ + start(c) { + c.enqueue({ type: 'tool_call_start', id: 't1', name: 'search' }) + c.enqueue({ type: 'thinking_delta', text: 'working' }) + // Never emits tool_call_end — abort mid-flight. + setTimeout(() => controller.abort('user'), 5) + }, + }) + + const pump = createAgentStreamPump({ + source, + streamFormat: 'agent-events-v1', + sinkMode: true, + abortSignal: controller.signal, + }) + const { sink, events } = collectingSink() + pump.subscribe(sink) + + const result = await pump.run() + expect(result.cancelled).toBe(true) + expect(events).toContainEqual({ + type: 'tool_call_end', + id: 't1', + name: 'search', + status: 'cancelled', + }) + }) + + it('synthesizes tool_call_end error for open tools on source failure', async () => { + let pulled = 0 + const source = new ReadableStream({ + pull(c) { + pulled += 1 + if (pulled === 1) { + c.enqueue({ type: 'tool_call_start', id: 't1', name: 'search' }) + return + } + c.error(new Error('provider reset')) + }, + }) + + const pump = createAgentStreamPump({ + source, + streamFormat: 'agent-events-v1', + sinkMode: true, + }) + const { sink, events } = collectingSink() + pump.subscribe(sink) + + await expect(pump.run()).rejects.toThrow('provider reset') + expect(events).toContainEqual({ + type: 'tool_call_start', + id: 't1', + name: 'search', + }) + expect(events).toContainEqual({ + type: 'tool_call_end', + id: 't1', + name: 'search', + status: 'error', + }) + }) +}) + +describe('projectStreamingExecutionToByteStream', () => { + it('projects agent-events final-turn text to UTF-8 bytes', async () => { + const { projectStreamingExecutionToByteStream } = await import('@/providers/stream-pump') + const byteStream = projectStreamingExecutionToByteStream({ + stream: createAgentEventReadableStream([ + { type: 'thinking_delta', text: 'secret' }, + { type: 'text_delta', text: 'Looking…', turn: 'intermediate' }, + { type: 'text_delta', text: 'Answer', turn: 'final' }, + ]), + streamFormat: 'agent-events-v1', + }) + + expect(await readAllText(byteStream)).toBe('Answer') + }) + + it('passes through legacy text byte streams unchanged', async () => { + const { projectStreamingExecutionToByteStream } = await import('@/providers/stream-pump') + const encoder = new TextEncoder() + const source = new ReadableStream({ + start(c) { + c.enqueue(encoder.encode('raw')) + c.close() + }, + }) + const byteStream = projectStreamingExecutionToByteStream({ + stream: source, + streamFormat: 'text', + }) + expect(await readAllText(byteStream)).toBe('raw') + }) +}) diff --git a/apps/sim/providers/stream-pump.ts b/apps/sim/providers/stream-pump.ts new file mode 100644 index 00000000000..21cb0194d28 --- /dev/null +++ b/apps/sim/providers/stream-pump.ts @@ -0,0 +1,424 @@ +/** + * Executor-facing agent stream pump (Step 2). + * + * Owns a single drain of a provider {@link StreamingExecution} source and: + * - projects final-turn answer text to an optional legacy byte stream + * - pushes the full ordered timeline (including text) to optional sinks + * - awaits each sink per event (simple backpressure; SSE enqueues are cheap) + * + * Wired into {@link BlockExecutor} `handleStreamingExecution` (Step 3). + */ + +import { + type AgentStreamEvent, + type AgentStreamFormat, + type AgentStreamSink, + type UnsubscribeAgentStreamSink, + isAgentStreamEvent, +} from '@/providers/stream-events' + +/** Default cap on thinking text forwarded to sinks (UTF-16 code units via `.length`). */ +export const DEFAULT_MAX_THINKING_BYTES = 512 * 1024 + +export type AgentStreamPumpCancelReason = 'user' | 'timeout' | 'unknown' + +export interface CreateAgentStreamPumpOptions { + source: ReadableStream + streamFormat: AgentStreamFormat + /** + * When true, no legacy text {@link ReadableStream} is created — upgraded + * consumers read only the sink. Avoids unbounded buffering into an unread stream. + */ + sinkMode?: boolean + maxThinkingBytes?: number + abortSignal?: AbortSignal +} + +export interface AgentStreamPumpResult { + /** Final-turn answer text only (`turn: 'intermediate'` excluded). */ + answerText: string + fullyDrained: boolean + cancelled: boolean + cancelReason?: AgentStreamPumpCancelReason +} + +export interface AgentStreamPump { + subscribe: (sink: AgentStreamSink) => UnsubscribeAgentStreamSink + /** Final-turn answer bytes, or `null` when {@link CreateAgentStreamPumpOptions.sinkMode}. */ + readonly textStream: ReadableStream | null + /** + * Starts draining the provider source. Call only after the synchronous + * subscribe window (e.g. after `onStream` returns). + */ + run: () => Promise +} + +interface SinkState { + sink: AgentStreamSink + detached: boolean +} + +function resolveCancelReason(signal: AbortSignal): AgentStreamPumpCancelReason { + const reason = signal.reason + if (reason === 'timeout' || reason === 'user') { + return reason + } + if (typeof reason === 'string') { + const lower = reason.toLowerCase() + if (lower.includes('timeout')) return 'timeout' + if (lower.includes('abort') || lower.includes('cancel') || lower.includes('user')) { + return 'user' + } + } + if (reason && typeof reason === 'object' && 'name' in reason) { + const name = String((reason as { name?: unknown }).name) + if (name === 'TimeoutError') return 'timeout' + } + return 'unknown' +} + +function isFinalTurnText(event: Extract): boolean { + return event.turn !== 'intermediate' +} + +/** + * Creates a pump over a provider stream. Subscribe synchronously before {@link AgentStreamPump.run}. + */ +export function createAgentStreamPump(options: CreateAgentStreamPumpOptions): AgentStreamPump { + const { + source, + streamFormat, + sinkMode = false, + maxThinkingBytes = DEFAULT_MAX_THINKING_BYTES, + abortSignal, + } = options + + const sinks = new Map() + let started = false + let closedTextStream = false + + let answerText = '' + let thinkingBytesForwarded = 0 + + let textController: ReadableStreamDefaultController | null = null + let textBackpressureWait: Promise | null = null + let resolveTextBackpressure: (() => void) | null = null + const textEncoder = new TextEncoder() + + const textStream = sinkMode + ? null + : new ReadableStream({ + start(controller) { + textController = controller + }, + pull() { + if (resolveTextBackpressure) { + const resolve = resolveTextBackpressure + resolveTextBackpressure = null + textBackpressureWait = null + resolve() + } + }, + cancel() { + closedTextStream = true + textController = null + if (resolveTextBackpressure) { + const resolve = resolveTextBackpressure + resolveTextBackpressure = null + textBackpressureWait = null + resolve() + } + }, + }) + + function subscribe(sink: AgentStreamSink): UnsubscribeAgentStreamSink { + if (sinks.has(sink)) { + return () => { + detachSink(sink) + } + } + + sinks.set(sink, { sink, detached: false }) + return () => detachSink(sink) + } + + function detachSink(sink: AgentStreamSink): void { + const state = sinks.get(sink) + if (!state || state.detached) return + state.detached = true + sinks.delete(sink) + } + + async function dispatchToSinks(event: AgentStreamEvent): Promise { + const active = [...sinks.values()].filter((s) => !s.detached) + if (active.length === 0) return + + await Promise.all( + active.map(async (state) => { + if (state.detached) return + try { + await state.sink.onEvent(event) + } catch { + detachSink(state.sink) + } + }) + ) + } + + async function enqueueAnswerText(text: string): Promise { + if (!text) return + + answerText += text + + if (sinkMode || closedTextStream || !textController) return + + const chunk = textEncoder.encode(text) + + while (!closedTextStream && textController) { + const desired = textController.desiredSize + if (desired === null) return + if (desired > 0) { + try { + textController.enqueue(chunk) + } catch { + closedTextStream = true + textController = null + } + return + } + if (!textBackpressureWait) { + textBackpressureWait = new Promise((resolve) => { + resolveTextBackpressure = resolve + }) + } + await textBackpressureWait + } + } + + function closeTextStream(error?: unknown): void { + if (sinkMode || closedTextStream) return + closedTextStream = true + try { + if (error !== undefined && textController) { + textController.error(error) + } else { + textController?.close() + } + } catch { + // already closed + } + textController = null + if (resolveTextBackpressure) { + resolveTextBackpressure() + resolveTextBackpressure = null + textBackpressureWait = null + } + } + + /** Open tool_call_start ids → names; settled on abort/error so sinks never hang. */ + const openTools = new Map() + + async function settleOpenTools(status: 'cancelled' | 'error'): Promise { + if (openTools.size === 0) return + const pending = [...openTools.entries()] + openTools.clear() + for (const [id, name] of pending) { + await dispatchToSinks({ type: 'tool_call_end', id, name, status }) + } + } + + async function handleEvent(event: AgentStreamEvent): Promise { + if (event.type === 'thinking_delta') { + const remaining = Math.max(0, maxThinkingBytes - thinkingBytesForwarded) + if (remaining <= 0) return + const forwarded = + event.text.length > remaining ? event.text.slice(0, remaining) : event.text + thinkingBytesForwarded += forwarded.length + if (forwarded.length > 0) { + await dispatchToSinks({ type: 'thinking_delta', text: forwarded }) + } + return + } + + if (event.type === 'text_delta') { + await dispatchToSinks(event) + if (isFinalTurnText(event)) { + await enqueueAnswerText(event.text) + } + return + } + + if (event.type === 'tool_call_start') { + openTools.set(event.id, event.name) + await dispatchToSinks(event) + return + } + + if (event.type === 'tool_call_end') { + openTools.delete(event.id) + await dispatchToSinks(event) + return + } + + await dispatchToSinks(event) + } + + async function run(): Promise { + if (started) { + throw new Error('Agent stream pump already started') + } + started = true + + let cancelled = false + let cancelReason: AgentStreamPumpCancelReason | undefined + let fullyDrained = false + let drainError: unknown + + const reader = source.getReader() + const decoder = new TextDecoder() + + const onAbort = () => { + cancelled = true + if (abortSignal) { + cancelReason = resolveCancelReason(abortSignal) + } else { + cancelReason = 'unknown' + } + void reader.cancel(abortSignal?.reason ?? 'aborted') + } + + if (abortSignal) { + if (abortSignal.aborted) { + onAbort() + } else { + abortSignal.addEventListener('abort', onAbort, { once: true }) + } + } + + try { + while (!cancelled) { + const { done, value } = await reader.read() + if (done) { + if (streamFormat === 'text') { + const tail = decoder.decode() + if (tail) { + await handleEvent({ type: 'text_delta', text: tail, turn: 'final' }) + } + } + fullyDrained = true + break + } + + if (streamFormat === 'text') { + if (!(value instanceof Uint8Array)) { + throw new Error('text streamFormat expected Uint8Array chunks') + } + const text = decoder.decode(value, { stream: true }) + if (text) { + await handleEvent({ type: 'text_delta', text, turn: 'final' }) + } + continue + } + + if (!isAgentStreamEvent(value)) { + throw new Error('Invalid AgentStreamEvent on agent-events-v1 stream') + } + await handleEvent(value) + } + } catch (error) { + drainError = error + } finally { + abortSignal?.removeEventListener('abort', onAbort) + try { + reader.releaseLock() + } catch { + // ignore + } + } + + // Synthesize tool ends — enqueue-then-error in provider loops can drop + // settlement frames (WHATWG resets the queue on error). + if (cancelled) { + await settleOpenTools('cancelled') + } else if (drainError) { + await settleOpenTools('error') + } + + if (drainError) { + closeTextStream(drainError) + throw drainError instanceof Error ? drainError : new Error(String(drainError)) + } + + if (cancelled) { + closeTextStream() + return { + answerText, + fullyDrained: false, + cancelled: true, + cancelReason: cancelReason ?? 'unknown', + } + } + + closeTextStream() + return { + answerText, + fullyDrained, + cancelled: false, + } + } + + return { + subscribe, + textStream, + run, + } +} + +/** + * Project a {@link StreamingExecution} to a byte stream suitable for an HTTP + * `Response` body. Agent-events object streams are pumped to final-turn answer + * bytes; legacy `text` streams pass through unchanged. + */ +export function projectStreamingExecutionToByteStream(streamingExec: { + stream: ReadableStream + streamFormat?: AgentStreamFormat +}): ReadableStream { + const streamFormat = streamingExec.streamFormat ?? 'text' + if (streamFormat === 'text') { + return streamingExec.stream as ReadableStream + } + + const pump = createAgentStreamPump({ + source: streamingExec.stream, + streamFormat, + }) + const textStream = pump.textStream + if (!textStream) { + throw new Error('Agent stream pump expected a text projection stream') + } + + return new ReadableStream({ + async start(controller) { + const runPromise = pump.run() + const reader = textStream.getReader() + try { + while (true) { + const { done, value } = await reader.read() + if (done) break + controller.enqueue(value) + } + await runPromise + controller.close() + } catch (error) { + try { + controller.error(error instanceof Error ? error : new Error(String(error))) + } catch { + // already closed/errored + } + } + }, + cancel(reason) { + void textStream.cancel(reason) + }, + }) +} diff --git a/apps/sim/providers/streaming-execution.test.ts b/apps/sim/providers/streaming-execution.test.ts index 25e7708b1f0..f5d4c678932 100644 --- a/apps/sim/providers/streaming-execution.test.ts +++ b/apps/sim/providers/streaming-execution.test.ts @@ -3,6 +3,7 @@ */ import { describe, expect, it, vi } from 'vitest' import type { NormalizedBlockOutput } from '@/executor/types' +import { createAgentEventReadableStream } from '@/providers/stream-events' import { createStreamingExecution } from '@/providers/streaming-execution' /** @@ -207,4 +208,42 @@ describe('createStreamingExecution', () => { vi.restoreAllMocks() }) + + it('defaults streamFormat to text and can attach agent-events-v1 object streams', async () => { + const textResult = createStreamingExecution({ + model: 'm', + providerStartTime, + providerStartTimeISO, + timing: { kind: 'simple', segmentName: 'm' }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { input: 0, output: 0, total: 0 }, + createStream: () => new ReadableStream(), + }) + expect(textResult.streamFormat).toBe('text') + + const events = [ + { type: 'thinking_delta' as const, text: 'reason' }, + { type: 'text_delta' as const, text: 'answer', turn: 'final' as const }, + ] + const eventResult = createStreamingExecution({ + model: 'm', + providerStartTime, + providerStartTimeISO, + timing: { kind: 'simple', segmentName: 'm' }, + initialTokens: { input: 0, output: 0, total: 0 }, + initialCost: { input: 0, output: 0, total: 0 }, + streamFormat: 'agent-events-v1', + createStream: () => createAgentEventReadableStream(events), + }) + + expect(eventResult.streamFormat).toBe('agent-events-v1') + const reader = eventResult.stream.getReader() + const received = [] + while (true) { + const { done, value } = await reader.read() + if (done) break + received.push(value) + } + expect(received).toEqual(events) + }) }) diff --git a/apps/sim/providers/streaming-execution.ts b/apps/sim/providers/streaming-execution.ts index 7a217af283d..ef020c5d328 100644 --- a/apps/sim/providers/streaming-execution.ts +++ b/apps/sim/providers/streaming-execution.ts @@ -1,4 +1,5 @@ import type { NormalizedBlockOutput, StreamingExecution } from '@/executor/types' +import type { AgentStreamEvent, AgentStreamFormat } from '@/providers/stream-events' import type { TimeSegment } from '@/providers/types' /** @@ -78,13 +79,21 @@ interface CreateStreamingExecutionOptions { toolCalls?: ToolCallSlice /** Marks `execution.isStreaming = true` when set. */ isStreaming?: boolean + /** + * Declares whether {@link createStream} returns UTF-8 answer bytes (`text`) + * or an in-process {@link AgentStreamEvent} object stream (`agent-events-v1`). + * Defaults to `'text'` so existing providers stay unchanged. + */ + streamFormat?: AgentStreamFormat /** * Builds the provider stream. Receives the live `output` object and a * `finalizeTiming` hook. The provider wires its native stream factory and, in * the drain callback, writes final content/tokens/cost onto `output` then * calls `finalizeTiming()`. */ - createStream: (handles: StreamFinalizer) => ReadableStream + createStream: ( + handles: StreamFinalizer + ) => ReadableStream | ReadableStream } /** @@ -104,6 +113,7 @@ export function createStreamingExecution( initialCost, toolCalls, isStreaming, + streamFormat = 'text', createStream, } = options @@ -155,6 +165,7 @@ export function createStreamingExecution( return { stream, + streamFormat, execution: { success: true, output, diff --git a/apps/sim/providers/together/index.ts b/apps/sim/providers/together/index.ts index 34c22f5c18c..c52f383d2ff 100644 --- a/apps/sim/providers/together/index.ts +++ b/apps/sim/providers/together/index.ts @@ -167,6 +167,7 @@ export const togetherProvider: ProviderConfig = { timing: { kind: 'simple', segmentName: request.model }, initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { output.content = content @@ -469,6 +470,7 @@ export const togetherProvider: ProviderConfig = { }, toolCalls: toolCalls.length > 0 ? { list: toolCalls, count: toolCalls.length } : undefined, + streamFormat: 'agent-events-v1', createStream: ({ output }) => createReadableStreamFromOpenAIStream(streamResponse, (content, usage) => { output.content = content diff --git a/apps/sim/providers/together/utils.ts b/apps/sim/providers/together/utils.ts index 22b7823f342..e187e881490 100644 --- a/apps/sim/providers/together/utils.ts +++ b/apps/sim/providers/together/utils.ts @@ -1,6 +1,8 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { checkForForcedToolUsageOpenAI, createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' +import { checkForForcedToolUsageOpenAI } from '@/providers/utils' /** * Together gates native `json_schema` per-model, so we use the broadly supported @@ -11,14 +13,19 @@ export async function supportsNativeStructuredOutputs(_modelId: string): Promise } /** - * Creates a ReadableStream from a Together AI streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a Together AI streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromOpenAIStream( openaiStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(openaiStream, 'Together', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(openaiStream, { + providerName: 'Together', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } /** diff --git a/apps/sim/providers/tool-call-id.ts b/apps/sim/providers/tool-call-id.ts new file mode 100644 index 00000000000..c9cdf875e5d --- /dev/null +++ b/apps/sim/providers/tool-call-id.ts @@ -0,0 +1,27 @@ +/** + * Stable execution-local tool call ids when a provider omits them (e.g. Gemini). + * Ensures `${blockId}:${toolCallId}` cannot collide within a run. + */ + +import { randomFloat } from '@sim/utils/random' + +let localToolIdCounter = 0 + +/** Reset counter — tests only. */ +export function resetLocalToolIdCounterForTests(): void { + localToolIdCounter = 0 +} + +export function allocateExecutionLocalToolId(prefix = 'local'): string { + localToolIdCounter += 1 + const rand = randomFloat().toString(36).slice(2, 8) + return `${prefix}_${localToolIdCounter}_${rand}` +} + +/** Returns provider id if non-empty, otherwise allocates a local one. */ +export function ensureToolCallId(providerId: string | null | undefined, prefix = 'local'): string { + if (typeof providerId === 'string' && providerId.trim().length > 0) { + return providerId + } + return allocateExecutionLocalToolId(prefix) +} diff --git a/apps/sim/providers/utils.test.ts b/apps/sim/providers/utils.test.ts index 1697c12f80d..b999804f54f 100644 --- a/apps/sim/providers/utils.test.ts +++ b/apps/sim/providers/utils.test.ts @@ -367,6 +367,8 @@ describe('Model Capabilities', () => { expect(supportsReasoningEffort('o4-mini')).toBe(true) expect(supportsReasoningEffort('azure/gpt-5')).toBe(true) expect(supportsReasoningEffort('azure/o3')).toBe(true) + expect(supportsReasoningEffort('groq/openai/gpt-oss-120b')).toBe(true) + expect(supportsReasoningEffort('groq/openai/gpt-oss-20b')).toBe(true) }) it.concurrent('should return false for models without reasoning effort capability', () => { @@ -416,6 +418,9 @@ describe('Model Capabilities', () => { expect(supportsThinking('claude-sonnet-4-5')).toBe(true) expect(supportsThinking('claude-haiku-4-5')).toBe(true) expect(supportsThinking('gemini-3-flash-preview')).toBe(true) + expect(supportsThinking('deepseek-chat')).toBe(true) + expect(supportsThinking('deepseek-reasoner')).toBe(true) + expect(supportsThinking('groq/qwen/qwen3.6-27b')).toBe(true) }) it.concurrent('should return false for models without thinking capability', () => { @@ -497,6 +502,8 @@ describe('Model Capabilities', () => { expect(MODELS_WITH_REASONING_EFFORT).not.toContain('gpt-4o') expect(MODELS_WITH_REASONING_EFFORT).not.toContain('claude-sonnet-4-5') + expect(MODELS_WITH_REASONING_EFFORT).toContain('groq/openai/gpt-oss-120b') + expect(MODELS_WITH_REASONING_EFFORT).toContain('groq/openai/gpt-oss-20b') }) it.concurrent('should have correct models in MODELS_WITH_VERBOSITY', () => { @@ -538,6 +545,10 @@ describe('Model Capabilities', () => { expect(MODELS_WITH_THINKING).toContain('claude-haiku-4-5') + expect(MODELS_WITH_THINKING).toContain('deepseek-chat') + expect(MODELS_WITH_THINKING).toContain('deepseek-reasoner') + expect(MODELS_WITH_THINKING).toContain('groq/qwen/qwen3.6-27b') + expect(MODELS_WITH_THINKING).not.toContain('gpt-4o') expect(MODELS_WITH_THINKING).not.toContain('gpt-5') expect(MODELS_WITH_THINKING).not.toContain('o3') diff --git a/apps/sim/providers/vllm/index.ts b/apps/sim/providers/vllm/index.ts index 936bf3af633..34ee8d9f256 100644 --- a/apps/sim/providers/vllm/index.ts +++ b/apps/sim/providers/vllm/index.ts @@ -240,6 +240,7 @@ export const vllmProvider: ProviderConfig = { timing: { kind: 'simple', segmentName: request.model }, initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, + streamFormat: 'agent-events-v1', createStream: ({ output, finalizeTiming }) => createReadableStreamFromVLLMStream(streamResponse, (content, usage) => { let cleanContent = content @@ -582,6 +583,7 @@ export const vllmProvider: ProviderConfig = { count: toolCalls.length, } : undefined, + streamFormat: 'agent-events-v1', createStream: ({ output }) => createReadableStreamFromVLLMStream(streamResponse, (content, usage) => { let cleanContent = content diff --git a/apps/sim/providers/vllm/utils.ts b/apps/sim/providers/vllm/utils.ts index 2b1db5bf553..f2580ab481b 100644 --- a/apps/sim/providers/vllm/utils.ts +++ b/apps/sim/providers/vllm/utils.ts @@ -1,16 +1,23 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { checkForForcedToolUsageOpenAI, createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' +import { checkForForcedToolUsageOpenAI } from '@/providers/utils' /** - * Creates a ReadableStream from a vLLM streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a vLLM streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromVLLMStream( vllmStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(vllmStream, 'vLLM', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(vllmStream, { + providerName: 'vLLM', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } /** diff --git a/apps/sim/providers/xai/index.ts b/apps/sim/providers/xai/index.ts index 42ebeb1254c..4e9c2b13b09 100644 --- a/apps/sim/providers/xai/index.ts +++ b/apps/sim/providers/xai/index.ts @@ -128,6 +128,7 @@ export const xAIProvider: ProviderConfig = { initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => createReadableStreamFromXAIStream(streamResponse, (content, usage) => { output.content = content @@ -523,6 +524,7 @@ export const xAIProvider: ProviderConfig = { } : undefined, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => createReadableStreamFromXAIStream(streamResponse as any, (content, usage) => { output.content = content diff --git a/apps/sim/providers/xai/utils.ts b/apps/sim/providers/xai/utils.ts index 3a2acd56eca..76a21c7505f 100644 --- a/apps/sim/providers/xai/utils.ts +++ b/apps/sim/providers/xai/utils.ts @@ -1,16 +1,23 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { checkForForcedToolUsageOpenAI, createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' +import { checkForForcedToolUsageOpenAI } from '@/providers/utils' /** - * Creates a ReadableStream from an xAI streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from an xAI streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromXAIStream( xaiStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(xaiStream, 'xAI', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(xaiStream, { + providerName: 'xAI', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } /** diff --git a/apps/sim/providers/zai/index.ts b/apps/sim/providers/zai/index.ts index 779b18942ea..430f3667b71 100644 --- a/apps/sim/providers/zai/index.ts +++ b/apps/sim/providers/zai/index.ts @@ -183,6 +183,7 @@ export const zaiProvider: ProviderConfig = { initialTokens: { input: 0, output: 0, total: 0 }, initialCost: { input: 0, output: 0, total: 0 }, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => createReadableStreamFromZaiStream(streamResponse as any, (content, usage) => { output.content = content @@ -547,6 +548,7 @@ export const zaiProvider: ProviderConfig = { } : undefined, isStreaming: true, + streamFormat: 'agent-events-v1', createStream: ({ output }) => createReadableStreamFromZaiStream(streamResponse as any, (content, usage) => { output.content = content diff --git a/apps/sim/providers/zai/utils.ts b/apps/sim/providers/zai/utils.ts index 4b86c6b7619..28ab12b1cd0 100644 --- a/apps/sim/providers/zai/utils.ts +++ b/apps/sim/providers/zai/utils.ts @@ -1,14 +1,20 @@ import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { createOpenAICompatibleStream } from '@/providers/utils' +import { createOpenAICompatibleAgentEventStream } from '@/providers/openai-compat/stream-events' +import type { AgentStreamEvent } from '@/providers/stream-events' /** - * Creates a ReadableStream from a Z.ai streaming response. - * Uses the shared OpenAI-compatible streaming utility. + * Creates an agent-events stream from a Z.ai streaming response. + * Uses the shared OpenAI-compatible agent event streaming utility. */ export function createReadableStreamFromZaiStream( zaiStream: AsyncIterable, - onComplete?: (content: string, usage: CompletionUsage) => void -): ReadableStream { - return createOpenAICompatibleStream(zaiStream, 'Z.ai', onComplete) + onComplete?: (content: string, usage: CompletionUsage, thinking?: string) => void +): ReadableStream { + return createOpenAICompatibleAgentEventStream(zaiStream, { + providerName: 'Z.ai', + onComplete: onComplete + ? (result) => onComplete(result.content, result.usage, result.thinking) + : undefined, + }) } diff --git a/apps/sim/stores/terminal/console/store.ts b/apps/sim/stores/terminal/console/store.ts index a3262620c3c..185f4d89639 100644 --- a/apps/sim/stores/terminal/console/store.ts +++ b/apps/sim/stores/terminal/console/store.ts @@ -612,6 +612,18 @@ export const useTerminalConsoleStore = create()( updatedEntry.childWorkflowInstanceId = update.childWorkflowInstanceId } + if (update.agentStreamThinking !== undefined) { + updatedEntry.agentStreamThinking = update.agentStreamThinking + } + + if (update.agentStreamToolCalls !== undefined) { + updatedEntry.agentStreamToolCalls = update.agentStreamToolCalls + } + + if (update.agentStreamActive !== undefined) { + updatedEntry.agentStreamActive = update.agentStreamActive + } + nextEntries[location.index] = updatedEntry } diff --git a/apps/sim/stores/terminal/console/types.ts b/apps/sim/stores/terminal/console/types.ts index d7a0fdadf2c..079a8d91a74 100644 --- a/apps/sim/stores/terminal/console/types.ts +++ b/apps/sim/stores/terminal/console/types.ts @@ -1,5 +1,6 @@ import type { ParentIteration } from '@/executor/execution/types' import type { NormalizedBlockOutput } from '@/executor/types' +import type { AgentStreamToolCall } from '@/components/agent-stream/agent-stream-chrome' import type { SubflowType } from '@/stores/workflows/workflow/types' export interface ConsoleEntry { @@ -32,6 +33,12 @@ export interface ConsoleEntry { childWorkflowName?: string /** Per-invocation unique ID linking this workflow block to its child block events */ childWorkflowInstanceId?: string + /** Live agent thinking text (canvas stream:thinking). Not part of answer content. */ + agentStreamThinking?: string + /** Live tool chips (canvas stream:tool). Name + status only. */ + agentStreamToolCalls?: AgentStreamToolCall[] + /** True while thinking/tool live updates may still arrive for this entry. */ + agentStreamActive?: boolean } export interface ConsoleUpdate { @@ -58,6 +65,9 @@ export interface ConsoleUpdate { childWorkflowBlockId?: string childWorkflowName?: string childWorkflowInstanceId?: string + agentStreamThinking?: string + agentStreamToolCalls?: AgentStreamToolCall[] + agentStreamActive?: boolean } export interface ConsoleEntryLocation { diff --git a/packages/db/migrations/0261_chat_include_thinking.sql b/packages/db/migrations/0261_chat_include_thinking.sql new file mode 100644 index 00000000000..4d3e8bd8049 --- /dev/null +++ b/packages/db/migrations/0261_chat_include_thinking.sql @@ -0,0 +1 @@ +ALTER TABLE "chat" ADD COLUMN IF NOT EXISTS "include_thinking" boolean DEFAULT false NOT NULL; diff --git a/packages/db/migrations/meta/0261_snapshot.json b/packages/db/migrations/meta/0261_snapshot.json new file mode 100644 index 00000000000..c9534b9e70d --- /dev/null +++ b/packages/db/migrations/meta/0261_snapshot.json @@ -0,0 +1,17701 @@ +{ + "id": "0e7737c0-3a5e-49b0-b162-736c5550d1bd", + "prevId": "d708558f-4e3a-4d38-96e5-ca0d65c83f45", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.academy_certificate": { + "name": "academy_certificate", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "course_id": { + "name": "course_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "academy_cert_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "issued_at": { + "name": "issued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "certificate_number": { + "name": "certificate_number", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "academy_certificate_user_id_idx": { + "name": "academy_certificate_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_course_id_idx": { + "name": "academy_certificate_course_id_idx", + "columns": [ + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_user_course_unique": { + "name": "academy_certificate_user_course_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "course_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_number_idx": { + "name": "academy_certificate_number_idx", + "columns": [ + { + "expression": "certificate_number", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "academy_certificate_status_idx": { + "name": "academy_certificate_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "academy_certificate_user_id_user_id_fk": { + "name": "academy_certificate_user_id_user_id_fk", + "tableFrom": "academy_certificate", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "academy_certificate_certificate_number_unique": { + "name": "academy_certificate_certificate_number_unique", + "nullsNotDistinct": false, + "columns": [ + "certificate_number" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.account": { + "name": "account", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "access_token": { + "name": "access_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "refresh_token": { + "name": "refresh_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "id_token": { + "name": "id_token", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "access_token_expires_at": { + "name": "access_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "refresh_token_expires_at": { + "name": "refresh_token_expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "scope": { + "name": "scope", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + } + }, + "indexes": { + "account_user_id_idx": { + "name": "account_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_account_on_account_id_provider_id": { + "name": "idx_account_on_account_id_provider_id", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "account_user_id_user_id_fk": { + "name": "account_user_id_user_id_fk", + "tableFrom": "account", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.api_key": { + "name": "api_key", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key_hash": { + "name": "key_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'personal'" + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "api_key_workspace_type_idx": { + "name": "api_key_workspace_type_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_user_type_idx": { + "name": "api_key_user_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "api_key_key_hash_idx": { + "name": "api_key_key_hash_idx", + "columns": [ + { + "expression": "key_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "api_key_user_id_user_id_fk": { + "name": "api_key_user_id_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_workspace_id_workspace_id_fk": { + "name": "api_key_workspace_id_workspace_id_fk", + "tableFrom": "api_key", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "api_key_created_by_user_id_fk": { + "name": "api_key_created_by_user_id_fk", + "tableFrom": "api_key", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "api_key_key_unique": { + "name": "api_key_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": { + "workspace_type_check": { + "name": "workspace_type_check", + "value": "(type = 'workspace' AND workspace_id IS NOT NULL) OR (type = 'personal' AND workspace_id IS NULL)" + } + }, + "isRLSEnabled": false + }, + "public.async_jobs": { + "name": "async_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "run_at": { + "name": "run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 3 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "output": { + "name": "output", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "async_jobs_status_started_at_idx": { + "name": "async_jobs_status_started_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_status_completed_at_idx": { + "name": "async_jobs_status_completed_at_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "completed_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_pending_run_at_idx": { + "name": "async_jobs_schedule_pending_run_at_idx", + "columns": [ + { + "expression": "run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'pending'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "async_jobs_schedule_processing_started_at_idx": { + "name": "async_jobs_schedule_processing_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"async_jobs\".\"type\" = 'schedule-execution' AND \"async_jobs\".\"status\" = 'processing'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_id": { + "name": "actor_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_name": { + "name": "actor_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "actor_email": { + "name": "actor_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "resource_name": { + "name": "resource_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "audit_log_workspace_created_idx": { + "name": "audit_log_workspace_created_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_workspace_created_at_id_idx": { + "name": "audit_log_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_actor_created_idx": { + "name": "audit_log_actor_created_idx", + "columns": [ + { + "expression": "actor_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_resource_idx": { + "name": "audit_log_resource_idx", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "audit_log_action_idx": { + "name": "audit_log_action_idx", + "columns": [ + { + "expression": "action", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "audit_log_workspace_id_workspace_id_fk": { + "name": "audit_log_workspace_id_workspace_id_fk", + "tableFrom": "audit_log", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "audit_log_actor_id_user_id_fk": { + "name": "audit_log_actor_id_user_id_fk", + "tableFrom": "audit_log", + "tableTo": "user", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.background_work_status": { + "name": "background_work_status", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "kind": { + "name": "kind", + "type": "background_work_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "background_work_status_value", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "message": { + "name": "message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "background_work_status_workspace_status_idx": { + "name": "background_work_status_workspace_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_workflow_status_idx": { + "name": "background_work_status_workflow_status_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_child_ws_idx": { + "name": "background_work_status_meta_child_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'childWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "background_work_status_meta_other_ws_idx": { + "name": "background_work_status_meta_other_ws_idx", + "columns": [ + { + "expression": "(\"metadata\" ->> 'otherWorkspaceId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "background_work_status_workspace_id_workspace_id_fk": { + "name": "background_work_status_workspace_id_workspace_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "background_work_status_workflow_id_workflow_id_fk": { + "name": "background_work_status_workflow_id_workflow_id_fk", + "tableFrom": "background_work_status", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.chat": { + "name": "chat", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "customizations": { + "name": "customizations", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "output_configs": { + "name": "output_configs", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "include_thinking": { + "name": "include_thinking", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "identifier_idx": { + "name": "identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"chat\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "chat_archived_at_partial_idx": { + "name": "chat_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"chat\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_chat_on_workflow_id_archived_at": { + "name": "idx_chat_on_workflow_id_archived_at", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "chat_workflow_id_workflow_id_fk": { + "name": "chat_workflow_id_workflow_id_fk", + "tableFrom": "chat", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "chat_user_id_user_id_fk": { + "name": "chat_user_id_user_id_fk", + "tableFrom": "chat", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_async_tool_calls": { + "name": "copilot_async_tool_calls", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "checkpoint_id": { + "name": "checkpoint_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "tool_call_id": { + "name": "tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "args": { + "name": "args", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "status": { + "name": "status", + "type": "copilot_async_tool_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "result": { + "name": "result", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "claimed_by": { + "name": "claimed_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_async_tool_calls_run_id_idx": { + "name": "copilot_async_tool_calls_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_checkpoint_id_idx": { + "name": "copilot_async_tool_calls_checkpoint_id_idx", + "columns": [ + { + "expression": "checkpoint_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_idx": { + "name": "copilot_async_tool_calls_tool_call_id_idx", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_status_idx": { + "name": "copilot_async_tool_calls_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_run_status_idx": { + "name": "copilot_async_tool_calls_run_status_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_async_tool_calls_tool_call_id_unique": { + "name": "copilot_async_tool_calls_tool_call_id_unique", + "columns": [ + { + "expression": "tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_async_tool_calls_run_id_copilot_runs_id_fk": { + "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk": { + "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", + "tableFrom": "copilot_async_tool_calls", + "tableTo": "copilot_run_checkpoints", + "columnsFrom": [ + "checkpoint_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_chats": { + "name": "copilot_chats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "type": { + "name": "type", + "type": "chat_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'copilot'" + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'claude-3-7-sonnet-latest'" + }, + "conversation_id": { + "name": "conversation_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "preview_yaml": { + "name": "preview_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "plan_artifact": { + "name": "plan_artifact", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "resources": { + "name": "resources", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_seen_at": { + "name": "last_seen_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "pinned": { + "name": "pinned", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_chats_user_id_idx": { + "name": "copilot_chats_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workflow_id_idx": { + "name": "copilot_chats_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workflow_idx": { + "name": "copilot_chats_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_user_workspace_idx": { + "name": "copilot_chats_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_created_at_idx": { + "name": "copilot_chats_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_updated_at_idx": { + "name": "copilot_chats_updated_at_idx", + "columns": [ + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_chats_workspace_created_at_id_idx": { + "name": "copilot_chats_workspace_created_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"created_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_chats_user_id_user_id_fk": { + "name": "copilot_chats_user_id_user_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workflow_id_workflow_id_fk": { + "name": "copilot_chats_workflow_id_workflow_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_chats_workspace_id_workspace_id_fk": { + "name": "copilot_chats_workspace_id_workspace_id_fk", + "tableFrom": "copilot_chats", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_feedback": { + "name": "copilot_feedback", + "schema": "", + "columns": { + "feedback_id": { + "name": "feedback_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_query": { + "name": "user_query", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent_response": { + "name": "agent_response", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_positive": { + "name": "is_positive", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "feedback": { + "name": "feedback", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_yaml": { + "name": "workflow_yaml", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_feedback_user_id_idx": { + "name": "copilot_feedback_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_chat_id_idx": { + "name": "copilot_feedback_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_user_chat_idx": { + "name": "copilot_feedback_user_chat_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_is_positive_idx": { + "name": "copilot_feedback_is_positive_idx", + "columns": [ + { + "expression": "is_positive", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_feedback_created_at_idx": { + "name": "copilot_feedback_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_feedback_user_id_user_id_fk": { + "name": "copilot_feedback_user_id_user_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_feedback_chat_id_copilot_chats_id_fk": { + "name": "copilot_feedback_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_feedback", + "tableTo": "copilot_chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_messages": { + "name": "copilot_messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parent_message_id": { + "name": "parent_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens_in": { + "name": "tokens_in", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "tokens_out": { + "name": "tokens_out", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "seq": { + "name": "seq", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_messages_chat_message_unique": { + "name": "copilot_messages_chat_message_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_created_at_idx": { + "name": "copilot_messages_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_seq_idx": { + "name": "copilot_messages_chat_seq_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "seq", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_chat_stream_idx": { + "name": "copilot_messages_chat_stream_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"stream_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_messages_user_created_at_idx": { + "name": "copilot_messages_user_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"copilot_messages\".\"role\" = 'user' AND \"copilot_messages\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_messages_chat_id_copilot_chats_id_fk": { + "name": "copilot_messages_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_messages", + "tableTo": "copilot_chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_run_checkpoints": { + "name": "copilot_run_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "run_id": { + "name": "run_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "pending_tool_call_id": { + "name": "pending_tool_call_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "conversation_snapshot": { + "name": "conversation_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "agent_state": { + "name": "agent_state", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "provider_request": { + "name": "provider_request", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_run_checkpoints_run_id_idx": { + "name": "copilot_run_checkpoints_run_id_idx", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_pending_tool_call_id_idx": { + "name": "copilot_run_checkpoints_pending_tool_call_id_idx", + "columns": [ + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_run_checkpoints_run_pending_tool_unique": { + "name": "copilot_run_checkpoints_run_pending_tool_unique", + "columns": [ + { + "expression": "run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "pending_tool_call_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_run_checkpoints_run_id_copilot_runs_id_fk": { + "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", + "tableFrom": "copilot_run_checkpoints", + "tableTo": "copilot_runs", + "columnsFrom": [ + "run_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_runs": { + "name": "copilot_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_run_id": { + "name": "parent_run_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stream_id": { + "name": "stream_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "agent": { + "name": "agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "copilot_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "request_context": { + "name": "request_context", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "copilot_runs_execution_id_idx": { + "name": "copilot_runs_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_parent_run_id_idx": { + "name": "copilot_runs_parent_run_id_idx", + "columns": [ + { + "expression": "parent_run_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_id_idx": { + "name": "copilot_runs_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_user_id_idx": { + "name": "copilot_runs_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workflow_id_idx": { + "name": "copilot_runs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_id_idx": { + "name": "copilot_runs_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_status_idx": { + "name": "copilot_runs_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_chat_execution_idx": { + "name": "copilot_runs_chat_execution_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_execution_started_at_idx": { + "name": "copilot_runs_execution_started_at_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_workspace_completed_at_id_idx": { + "name": "copilot_runs_workspace_completed_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"completed_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_runs_stream_id_unique": { + "name": "copilot_runs_stream_id_unique", + "columns": [ + { + "expression": "stream_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_runs_chat_id_copilot_chats_id_fk": { + "name": "copilot_runs_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "copilot_chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_user_id_user_id_fk": { + "name": "copilot_runs_user_id_user_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workflow_id_workflow_id_fk": { + "name": "copilot_runs_workflow_id_workflow_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_runs_workspace_id_workspace_id_fk": { + "name": "copilot_runs_workspace_id_workspace_id_fk", + "tableFrom": "copilot_runs", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.copilot_workflow_read_hashes": { + "name": "copilot_workflow_read_hashes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "hash": { + "name": "hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "copilot_workflow_read_hashes_chat_id_idx": { + "name": "copilot_workflow_read_hashes_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_workflow_id_idx": { + "name": "copilot_workflow_read_hashes_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "copilot_workflow_read_hashes_chat_workflow_unique": { + "name": "copilot_workflow_read_hashes_chat_workflow_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk": { + "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "copilot_chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "copilot_workflow_read_hashes_workflow_id_workflow_id_fk": { + "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", + "tableFrom": "copilot_workflow_read_hashes", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.credential": { + "name": "credential", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "credential_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "account_id": { + "name": "account_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_key": { + "name": "env_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "env_owner_user_id": { + "name": "env_owner_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_service_account_key": { + "name": "encrypted_service_account_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_workspace_id_idx": { + "name": "credential_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_type_idx": { + "name": "credential_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_provider_id_idx": { + "name": "credential_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_account_id_idx": { + "name": "credential_account_id_idx", + "columns": [ + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_env_owner_user_id_idx": { + "name": "credential_env_owner_user_id_idx", + "columns": [ + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_account_unique": { + "name": "credential_workspace_account_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "account_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "account_id IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_env_unique": { + "name": "credential_workspace_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_workspace'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_workspace_personal_env_unique": { + "name": "credential_workspace_personal_env_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "env_owner_user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "type = 'env_personal'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_workspace_id_workspace_id_fk": { + "name": "credential_workspace_id_workspace_id_fk", + "tableFrom": "credential", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_account_id_account_id_fk": { + "name": "credential_account_id_account_id_fk", + "tableFrom": "credential", + "tableTo": "account", + "columnsFrom": [ + "account_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_env_owner_user_id_user_id_fk": { + "name": "credential_env_owner_user_id_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": [ + "env_owner_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_created_by_user_id_fk": { + "name": "credential_created_by_user_id_fk", + "tableFrom": "credential", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "credential_oauth_source_check": { + "name": "credential_oauth_source_check", + "value": "(type <> 'oauth') OR (account_id IS NOT NULL AND provider_id IS NOT NULL)" + }, + "credential_workspace_env_source_check": { + "name": "credential_workspace_env_source_check", + "value": "(type <> 'env_workspace') OR (env_key IS NOT NULL AND env_owner_user_id IS NULL)" + }, + "credential_personal_env_source_check": { + "name": "credential_personal_env_source_check", + "value": "(type <> 'env_personal') OR (env_key IS NOT NULL AND env_owner_user_id IS NOT NULL)" + } + }, + "isRLSEnabled": false + }, + "public.credential_member": { + "name": "credential_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "credential_member_role", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'member'" + }, + "status": { + "name": "status", + "type": "credential_member_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "joined_at": { + "name": "joined_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "invited_by": { + "name": "invited_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "credential_member_user_id_idx": { + "name": "credential_member_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_role_idx": { + "name": "credential_member_role_idx", + "columns": [ + { + "expression": "role", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_status_idx": { + "name": "credential_member_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "credential_member_unique": { + "name": "credential_member_unique", + "columns": [ + { + "expression": "credential_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "credential_member_credential_id_credential_id_fk": { + "name": "credential_member_credential_id_credential_id_fk", + "tableFrom": "credential_member", + "tableTo": "credential", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_user_id_user_id_fk": { + "name": "credential_member_user_id_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "credential_member_invited_by_user_id_fk": { + "name": "credential_member_invited_by_user_id_fk", + "tableFrom": "credential_member", + "tableTo": "user", + "columnsFrom": [ + "invited_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_block": { + "name": "custom_block", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "icon_url": { + "name": "icon_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inputs": { + "name": "inputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "outputs": { + "name": "outputs", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_block_organization_id_idx": { + "name": "custom_block_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_workflow_id_idx": { + "name": "custom_block_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_block_organization_type_unique": { + "name": "custom_block_organization_type_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_block_organization_id_organization_id_fk": { + "name": "custom_block_organization_id_organization_id_fk", + "tableFrom": "custom_block", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_workflow_id_workflow_id_fk": { + "name": "custom_block_workflow_id_workflow_id_fk", + "tableFrom": "custom_block", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_block_created_by_user_id_fk": { + "name": "custom_block_created_by_user_id_fk", + "tableFrom": "custom_block", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.custom_tools": { + "name": "custom_tools", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "title": { + "name": "title", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schema": { + "name": "schema", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "code": { + "name": "code", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "custom_tools_workspace_id_idx": { + "name": "custom_tools_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "custom_tools_workspace_title_unique": { + "name": "custom_tools_workspace_title_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "title", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "custom_tools_workspace_id_workspace_id_fk": { + "name": "custom_tools_workspace_id_workspace_id_fk", + "tableFrom": "custom_tools", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "custom_tools_user_id_user_id_fk": { + "name": "custom_tools_user_id_user_id_fk", + "tableFrom": "custom_tools", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drain_runs": { + "name": "data_drain_runs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "drain_id": { + "name": "drain_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "data_drain_run_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "trigger": { + "name": "trigger", + "type": "data_drain_run_trigger", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "finished_at": { + "name": "finished_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "rows_exported": { + "name": "rows_exported", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "bytes_written": { + "name": "bytes_written", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "cursor_before": { + "name": "cursor_before", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cursor_after": { + "name": "cursor_after", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "locators": { + "name": "locators", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + } + }, + "indexes": { + "data_drain_runs_drain_started_idx": { + "name": "data_drain_runs_drain_started_idx", + "columns": [ + { + "expression": "drain_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drain_runs_drain_id_data_drains_id_fk": { + "name": "data_drain_runs_drain_id_data_drains_id_fk", + "tableFrom": "data_drain_runs", + "tableTo": "data_drains", + "columnsFrom": [ + "drain_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.data_drains": { + "name": "data_drains", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "data_drain_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_type": { + "name": "destination_type", + "type": "data_drain_destination", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "destination_config": { + "name": "destination_config", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "destination_credentials": { + "name": "destination_credentials", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "schedule_cadence": { + "name": "schedule_cadence", + "type": "data_drain_cadence", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "cursor": { + "name": "cursor", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_success_at": { + "name": "last_success_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "data_drains_org_idx": { + "name": "data_drains_org_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_due_idx": { + "name": "data_drains_due_idx", + "columns": [ + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "data_drains_org_name_unique": { + "name": "data_drains_org_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "data_drains_organization_id_organization_id_fk": { + "name": "data_drains_organization_id_organization_id_fk", + "tableFrom": "data_drains", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "data_drains_created_by_user_id_fk": { + "name": "data_drains_created_by_user_id_fk", + "tableFrom": "data_drains", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.docs_embeddings": { + "name": "docs_embeddings", + "schema": "", + "columns": { + "chunk_id": { + "name": "chunk_id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "chunk_text": { + "name": "chunk_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_document": { + "name": "source_document", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_link": { + "name": "source_link", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_text": { + "name": "header_text", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "header_level": { + "name": "header_level", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": true + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "chunk_text_tsv": { + "name": "chunk_text_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"docs_embeddings\".\"chunk_text\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "docs_emb_source_document_idx": { + "name": "docs_emb_source_document_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_header_level_idx": { + "name": "docs_emb_header_level_idx", + "columns": [ + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_source_header_idx": { + "name": "docs_emb_source_header_idx", + "columns": [ + { + "expression": "source_document", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "header_level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_model_idx": { + "name": "docs_emb_model_idx", + "columns": [ + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_emb_created_at_idx": { + "name": "docs_emb_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "docs_embedding_vector_hnsw_idx": { + "name": "docs_embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "docs_emb_metadata_gin_idx": { + "name": "docs_emb_metadata_gin_idx", + "columns": [ + { + "expression": "metadata", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "docs_emb_chunk_text_fts_idx": { + "name": "docs_emb_chunk_text_fts_idx", + "columns": [ + { + "expression": "chunk_text_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "docs_embedding_not_null_check": { + "name": "docs_embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + }, + "docs_header_level_check": { + "name": "docs_header_level_check", + "value": "\"header_level\" >= 1 AND \"header_level\" <= 6" + } + }, + "isRLSEnabled": false + }, + "public.document": { + "name": "document", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "filename": { + "name": "filename", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_key": { + "name": "storage_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "file_size": { + "name": "file_size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "mime_type": { + "name": "mime_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_count": { + "name": "chunk_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "character_count": { + "name": "character_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "processing_status": { + "name": "processing_status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_completed_at": { + "name": "processing_completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "processing_error": { + "name": "processing_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "user_excluded": { + "name": "user_excluded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "external_id": { + "name": "external_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_hash": { + "name": "content_hash", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "doc_kb_id_idx": { + "name": "doc_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_filename_idx": { + "name": "doc_filename_idx", + "columns": [ + { + "expression": "filename", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_processing_status_idx": { + "name": "doc_processing_status_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "processing_status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_external_id_idx": { + "name": "doc_connector_external_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "external_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"document\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_connector_id_idx": { + "name": "doc_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_storage_key_idx": { + "name": "doc_storage_key_idx", + "columns": [ + { + "expression": "storage_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"storage_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_archived_at_partial_idx": { + "name": "doc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_deleted_at_partial_idx": { + "name": "doc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"document\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag1_idx": { + "name": "doc_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag2_idx": { + "name": "doc_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag3_idx": { + "name": "doc_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag4_idx": { + "name": "doc_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag5_idx": { + "name": "doc_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag6_idx": { + "name": "doc_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_tag7_idx": { + "name": "doc_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number1_idx": { + "name": "doc_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number2_idx": { + "name": "doc_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number3_idx": { + "name": "doc_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number4_idx": { + "name": "doc_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_number5_idx": { + "name": "doc_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date1_idx": { + "name": "doc_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_date2_idx": { + "name": "doc_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean1_idx": { + "name": "doc_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean2_idx": { + "name": "doc_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "doc_boolean3_idx": { + "name": "doc_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "document_knowledge_base_id_knowledge_base_id_fk": { + "name": "document_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_base", + "columnsFrom": [ + "knowledge_base_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "document_connector_id_knowledge_connector_id_fk": { + "name": "document_connector_id_knowledge_connector_id_fk", + "tableFrom": "document", + "tableTo": "knowledge_connector", + "columnsFrom": [ + "connector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "document_uploaded_by_user_id_fk": { + "name": "document_uploaded_by_user_id_fk", + "tableFrom": "document", + "tableTo": "user", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.embedding": { + "name": "embedding", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "document_id": { + "name": "document_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chunk_index": { + "name": "chunk_index", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "chunk_hash": { + "name": "chunk_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content_length": { + "name": "content_length", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "embedding": { + "name": "embedding", + "type": "vector(1536)", + "primaryKey": false, + "notNull": false + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "start_offset": { + "name": "start_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "end_offset": { + "name": "end_offset", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "tag1": { + "name": "tag1", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag2": { + "name": "tag2", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag3": { + "name": "tag3", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag4": { + "name": "tag4", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag5": { + "name": "tag5", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag6": { + "name": "tag6", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tag7": { + "name": "tag7", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "number1": { + "name": "number1", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number2": { + "name": "number2", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number3": { + "name": "number3", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number4": { + "name": "number4", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "number5": { + "name": "number5", + "type": "double precision", + "primaryKey": false, + "notNull": false + }, + "date1": { + "name": "date1", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "date2": { + "name": "date2", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "boolean1": { + "name": "boolean1", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean2": { + "name": "boolean2", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "boolean3": { + "name": "boolean3", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "content_tsv": { + "name": "content_tsv", + "type": "tsvector", + "primaryKey": false, + "notNull": false, + "generated": { + "as": "to_tsvector('english', \"embedding\".\"content\")", + "type": "stored" + } + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "emb_kb_id_idx": { + "name": "emb_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_id_idx": { + "name": "emb_doc_id_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_chunk_idx": { + "name": "emb_doc_chunk_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chunk_index", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_model_idx": { + "name": "emb_kb_model_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "embedding_model", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_kb_enabled_idx": { + "name": "emb_kb_enabled_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_doc_enabled_idx": { + "name": "emb_doc_enabled_idx", + "columns": [ + { + "expression": "document_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "embedding_vector_hnsw_idx": { + "name": "embedding_vector_hnsw_idx", + "columns": [ + { + "expression": "embedding", + "isExpression": false, + "asc": true, + "nulls": "last", + "opclass": "vector_cosine_ops" + } + ], + "isUnique": false, + "concurrently": false, + "method": "hnsw", + "with": { + "m": 16, + "ef_construction": 64 + } + }, + "emb_tag1_idx": { + "name": "emb_tag1_idx", + "columns": [ + { + "expression": "tag1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag2_idx": { + "name": "emb_tag2_idx", + "columns": [ + { + "expression": "tag2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag3_idx": { + "name": "emb_tag3_idx", + "columns": [ + { + "expression": "tag3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag4_idx": { + "name": "emb_tag4_idx", + "columns": [ + { + "expression": "tag4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag5_idx": { + "name": "emb_tag5_idx", + "columns": [ + { + "expression": "tag5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag6_idx": { + "name": "emb_tag6_idx", + "columns": [ + { + "expression": "tag6", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_tag7_idx": { + "name": "emb_tag7_idx", + "columns": [ + { + "expression": "tag7", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number1_idx": { + "name": "emb_number1_idx", + "columns": [ + { + "expression": "number1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number2_idx": { + "name": "emb_number2_idx", + "columns": [ + { + "expression": "number2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number3_idx": { + "name": "emb_number3_idx", + "columns": [ + { + "expression": "number3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number4_idx": { + "name": "emb_number4_idx", + "columns": [ + { + "expression": "number4", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_number5_idx": { + "name": "emb_number5_idx", + "columns": [ + { + "expression": "number5", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date1_idx": { + "name": "emb_date1_idx", + "columns": [ + { + "expression": "date1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_date2_idx": { + "name": "emb_date2_idx", + "columns": [ + { + "expression": "date2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean1_idx": { + "name": "emb_boolean1_idx", + "columns": [ + { + "expression": "boolean1", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean2_idx": { + "name": "emb_boolean2_idx", + "columns": [ + { + "expression": "boolean2", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_boolean3_idx": { + "name": "emb_boolean3_idx", + "columns": [ + { + "expression": "boolean3", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "emb_content_fts_idx": { + "name": "emb_content_fts_idx", + "columns": [ + { + "expression": "content_tsv", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + } + }, + "foreignKeys": { + "embedding_knowledge_base_id_knowledge_base_id_fk": { + "name": "embedding_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "embedding", + "tableTo": "knowledge_base", + "columnsFrom": [ + "knowledge_base_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "embedding_document_id_document_id_fk": { + "name": "embedding_document_id_document_id_fk", + "tableFrom": "embedding", + "tableTo": "document", + "columnsFrom": [ + "document_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "embedding_not_null_check": { + "name": "embedding_not_null_check", + "value": "\"embedding\" IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.environment": { + "name": "environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "environment_user_id_user_id_fk": { + "name": "environment_user_id_user_id_fk", + "tableFrom": "environment", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "environment_user_id_unique": { + "name": "environment_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_dependencies": { + "name": "execution_large_value_dependencies", + "schema": "", + "columns": { + "parent_key": { + "name": "parent_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_key": { + "name": "child_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_dependencies_workspace_parent_key_idx": { + "name": "execution_large_value_dependencies_workspace_parent_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_value_dependencies_workspace_child_key_idx": { + "name": "execution_large_value_dependencies_workspace_child_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_dependencies_workspace_id_workspace_id_fk": { + "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_dependencies", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_dependencies_parent_key_child_key_pk": { + "name": "execution_large_value_dependencies_parent_key_child_key_pk", + "columns": [ + "parent_key", + "child_key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_value_references": { + "name": "execution_large_value_references", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "execution_large_value_reference_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "execution_large_value_references_workspace_execution_source_idx": { + "name": "execution_large_value_references_workspace_execution_source_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_value_references_workspace_id_workspace_id_fk": { + "name": "execution_large_value_references_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_value_references_workflow_id_workflow_id_fk": { + "name": "execution_large_value_references_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_value_references", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "execution_large_value_references_key_execution_id_source_pk": { + "name": "execution_large_value_references_key_execution_id_source_pk", + "columns": [ + "key", + "execution_id", + "source" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.execution_large_values": { + "name": "execution_large_values", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_execution_id": { + "name": "owner_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "execution_large_values_owner_execution_id_idx": { + "name": "execution_large_values_owner_execution_id_idx", + "columns": [ + { + "expression": "owner_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_cleanup_idx": { + "name": "execution_large_values_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "execution_large_values_tombstone_cleanup_idx": { + "name": "execution_large_values_tombstone_cleanup_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"execution_large_values\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "execution_large_values_workspace_id_workspace_id_fk": { + "name": "execution_large_values_workspace_id_workspace_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "execution_large_values_workflow_id_workflow_id_fk": { + "name": "execution_large_values_workflow_id_workflow_id_fk", + "tableFrom": "execution_large_values", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.idempotency_key": { + "name": "idempotency_key", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "result": { + "name": "result", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "idempotency_key_created_at_idx": { + "name": "idempotency_key_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation": { + "name": "invitation", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "kind": { + "name": "kind", + "type": "invitation_kind", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'organization'" + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "inviter_id": { + "name": "inviter_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "membership_intent": { + "name": "membership_intent", + "type": "invitation_membership_intent", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'internal'" + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "invitation_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_email_idx": { + "name": "invitation_email_idx", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_organization_id_idx": { + "name": "invitation_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_status_idx": { + "name": "invitation_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_pending_email_org_unique": { + "name": "invitation_pending_email_org_unique", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"invitation\".\"status\" = 'pending' AND \"invitation\".\"organization_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_inviter_id_user_id_fk": { + "name": "invitation_inviter_id_user_id_fk", + "tableFrom": "invitation", + "tableTo": "user", + "columnsFrom": [ + "inviter_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_organization_id_organization_id_fk": { + "name": "invitation_organization_id_organization_id_fk", + "tableFrom": "invitation", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "invitation_token_unique": { + "name": "invitation_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.invitation_workspace_grant": { + "name": "invitation_workspace_grant", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "invitation_id": { + "name": "invitation_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission": { + "name": "permission", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "invitation_workspace_grant_unique": { + "name": "invitation_workspace_grant_unique", + "columns": [ + { + "expression": "invitation_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "invitation_workspace_grant_workspace_id_idx": { + "name": "invitation_workspace_grant_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "invitation_workspace_grant_invitation_id_invitation_id_fk": { + "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "invitation", + "columnsFrom": [ + "invitation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "invitation_workspace_grant_workspace_id_workspace_id_fk": { + "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", + "tableFrom": "invitation_workspace_grant", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_execution_logs": { + "name": "job_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "schedule_id": { + "name": "schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "job_execution_logs_schedule_id_idx": { + "name": "job_execution_logs_schedule_id_idx", + "columns": [ + { + "expression": "schedule_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_started_at_idx": { + "name": "job_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_workspace_ended_at_id_idx": { + "name": "job_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_execution_id_unique": { + "name": "job_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "job_execution_logs_trigger_idx": { + "name": "job_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "job_execution_logs_schedule_id_workflow_schedule_id_fk": { + "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workflow_schedule", + "columnsFrom": [ + "schedule_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "job_execution_logs_workspace_id_workspace_id_fk": { + "name": "job_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "job_execution_logs", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base": { + "name": "knowledge_base", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token_count": { + "name": "token_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "embedding_model": { + "name": "embedding_model", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text-embedding-3-small'" + }, + "embedding_dimension": { + "name": "embedding_dimension", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1536 + }, + "chunking_config": { + "name": "chunking_config", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{\"maxSize\": 1024, \"minSize\": 1, \"overlap\": 200}'" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_user_id_idx": { + "name": "kb_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_id_idx": { + "name": "kb_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_user_workspace_idx": { + "name": "kb_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_deleted_at_idx": { + "name": "kb_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_deleted_partial_idx": { + "name": "kb_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_base\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_workspace_name_active_unique": { + "name": "kb_workspace_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"knowledge_base\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_user_id_user_id_fk": { + "name": "knowledge_base_user_id_user_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "knowledge_base_workspace_id_workspace_id_fk": { + "name": "knowledge_base_workspace_id_workspace_id_fk", + "tableFrom": "knowledge_base", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_base_tag_definitions": { + "name": "knowledge_base_tag_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tag_slot": { + "name": "tag_slot", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "field_type": { + "name": "field_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'text'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "kb_tag_definitions_kb_slot_idx": { + "name": "kb_tag_definitions_kb_slot_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "tag_slot", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_display_name_idx": { + "name": "kb_tag_definitions_kb_display_name_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kb_tag_definitions_kb_id_idx": { + "name": "kb_tag_definitions_kb_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_base_tag_definitions", + "tableTo": "knowledge_base", + "columnsFrom": [ + "knowledge_base_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector": { + "name": "knowledge_connector", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "knowledge_base_id": { + "name": "knowledge_base_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "connector_type": { + "name": "connector_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_config": { + "name": "source_config", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "sync_mode": { + "name": "sync_mode", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'full'" + }, + "sync_interval_minutes": { + "name": "sync_interval_minutes", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 1440 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_sync_at": { + "name": "last_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_sync_error": { + "name": "last_sync_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_sync_doc_count": { + "name": "last_sync_doc_count", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "next_sync_at": { + "name": "next_sync_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "consecutive_failures": { + "name": "consecutive_failures", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kc_knowledge_base_id_idx": { + "name": "kc_knowledge_base_id_idx", + "columns": [ + { + "expression": "knowledge_base_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_status_next_sync_idx": { + "name": "kc_status_next_sync_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "next_sync_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_archived_at_partial_idx": { + "name": "kc_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "kc_deleted_at_partial_idx": { + "name": "kc_deleted_at_partial_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"knowledge_connector\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_knowledge_base_id_knowledge_base_id_fk": { + "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", + "tableFrom": "knowledge_connector", + "tableTo": "knowledge_base", + "columnsFrom": [ + "knowledge_base_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.knowledge_connector_sync_log": { + "name": "knowledge_connector_sync_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "connector_id": { + "name": "connector_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "docs_added": { + "name": "docs_added", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_updated": { + "name": "docs_updated", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_deleted": { + "name": "docs_deleted", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_unchanged": { + "name": "docs_unchanged", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "docs_failed": { + "name": "docs_failed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "kcsl_connector_id_idx": { + "name": "kcsl_connector_id_idx", + "columns": [ + { + "expression": "connector_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk": { + "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", + "tableFrom": "knowledge_connector_sync_log", + "tableTo": "knowledge_connector", + "columnsFrom": [ + "connector_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_server_oauth": { + "name": "mcp_server_oauth", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_server_id": { + "name": "mcp_server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "client_information": { + "name": "client_information", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "tokens": { + "name": "tokens", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "code_verifier": { + "name": "code_verifier", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_created_at": { + "name": "state_created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_refreshed_at": { + "name": "last_refreshed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_server_oauth_server_unique": { + "name": "mcp_server_oauth_server_unique", + "columns": [ + { + "expression": "mcp_server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_server_oauth_state_idx": { + "name": "mcp_server_oauth_state_idx", + "columns": [ + { + "expression": "state", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk": { + "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "mcp_servers", + "columnsFrom": [ + "mcp_server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_server_oauth_user_id_user_id_fk": { + "name": "mcp_server_oauth_user_id_user_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "mcp_server_oauth_workspace_id_workspace_id_fk": { + "name": "mcp_server_oauth_workspace_id_workspace_id_fk", + "tableFrom": "mcp_server_oauth", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mcp_servers": { + "name": "mcp_servers", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "transport": { + "name": "transport", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "url": { + "name": "url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'headers'" + }, + "oauth_client_id": { + "name": "oauth_client_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "oauth_client_secret": { + "name": "oauth_client_secret", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "headers": { + "name": "headers", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "timeout": { + "name": "timeout", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 30000 + }, + "retries": { + "name": "retries", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 3 + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "last_connected": { + "name": "last_connected", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "connection_status": { + "name": "connection_status", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'disconnected'" + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status_config": { + "name": "status_config", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "tool_count": { + "name": "tool_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_tools_refresh": { + "name": "last_tools_refresh", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_requests": { + "name": "total_requests", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_used": { + "name": "last_used", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mcp_servers_workspace_enabled_idx": { + "name": "mcp_servers_workspace_enabled_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "enabled", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "mcp_servers_workspace_deleted_partial_idx": { + "name": "mcp_servers_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"mcp_servers\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mcp_servers_workspace_id_workspace_id_fk": { + "name": "mcp_servers_workspace_id_workspace_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mcp_servers_created_by_user_id_fk": { + "name": "mcp_servers_created_by_user_id_fk", + "tableFrom": "mcp_servers", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.member": { + "name": "member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "member_user_id_unique": { + "name": "member_user_id_unique", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "member_organization_id_idx": { + "name": "member_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "member_user_id_user_id_fk": { + "name": "member_user_id_user_id_fk", + "tableFrom": "member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "member_organization_id_organization_id_fk": { + "name": "member_organization_id_organization_id_fk", + "tableFrom": "member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.memory": { + "name": "memory", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "memory_key_idx": { + "name": "memory_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_idx": { + "name": "memory_workspace_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_key_idx": { + "name": "memory_workspace_key_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "memory_workspace_deleted_partial_idx": { + "name": "memory_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"memory\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "memory_workspace_id_workspace_id_fk": { + "name": "memory_workspace_id_workspace_id_fk", + "tableFrom": "memory", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_allowed_sender": { + "name": "mothership_inbox_allowed_sender", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "label": { + "name": "label", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "added_by": { + "name": "added_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "inbox_sender_ws_email_idx": { + "name": "inbox_sender_ws_email_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_allowed_sender_added_by_user_id_fk": { + "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", + "tableFrom": "mothership_inbox_allowed_sender", + "tableTo": "user", + "columnsFrom": [ + "added_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_task": { + "name": "mothership_inbox_task", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_email": { + "name": "from_email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "from_name": { + "name": "from_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "body_preview": { + "name": "body_preview", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_text": { + "name": "body_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "body_html": { + "name": "body_html", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_message_id": { + "name": "email_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "in_reply_to": { + "name": "in_reply_to", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "response_message_id": { + "name": "response_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "agentmail_message_id": { + "name": "agentmail_message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'received'" + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "trigger_job_id": { + "name": "trigger_job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "result_summary": { + "name": "result_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "rejection_reason": { + "name": "rejection_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "has_attachments": { + "name": "has_attachments", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "cc_recipients": { + "name": "cc_recipients", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processing_started_at": { + "name": "processing_started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "inbox_task_ws_created_at_idx": { + "name": "inbox_task_ws_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_ws_status_idx": { + "name": "inbox_task_ws_status_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_response_msg_id_idx": { + "name": "inbox_task_response_msg_id_idx", + "columns": [ + { + "expression": "response_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "inbox_task_email_msg_id_idx": { + "name": "inbox_task_email_msg_id_idx", + "columns": [ + { + "expression": "email_message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_inbox_task_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_task_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "mothership_inbox_task_chat_id_copilot_chats_id_fk": { + "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", + "tableFrom": "mothership_inbox_task", + "tableTo": "copilot_chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_inbox_webhook": { + "name": "mothership_inbox_webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "webhook_id": { + "name": "webhook_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "secret": { + "name": "secret", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "mothership_inbox_webhook_workspace_id_workspace_id_fk": { + "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", + "tableFrom": "mothership_inbox_webhook", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "mothership_inbox_webhook_workspace_id_unique": { + "name": "mothership_inbox_webhook_workspace_id_unique", + "nullsNotDistinct": false, + "columns": [ + "workspace_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.mothership_settings": { + "name": "mothership_settings", + "schema": "", + "columns": { + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "mcp_tool_refs": { + "name": "mcp_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "custom_tool_refs": { + "name": "custom_tool_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "skill_refs": { + "name": "skill_refs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'::jsonb" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "mothership_settings_workspace_id_idx": { + "name": "mothership_settings_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "mothership_settings_workspace_id_workspace_id_fk": { + "name": "mothership_settings_workspace_id_workspace_id_fk", + "tableFrom": "mothership_settings", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization": { + "name": "organization", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "logo": { + "name": "logo", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "whitelabel_settings": { + "name": "whitelabel_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "data_retention_settings": { + "name": "data_retention_settings", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "org_usage_limit": { + "name": "org_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "departed_member_usage": { + "name": "departed_member_usage", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.organization_member_usage_limit": { + "name": "organization_member_usage_limit", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "usage_limit": { + "name": "usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "set_by": { + "name": "set_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "org_member_usage_limit_org_user_unique": { + "name": "org_member_usage_limit_org_user_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "org_member_usage_limit_organization_id_idx": { + "name": "org_member_usage_limit_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "organization_member_usage_limit_organization_id_organization_id_fk": { + "name": "organization_member_usage_limit_organization_id_organization_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_user_id_user_id_fk": { + "name": "organization_member_usage_limit_user_id_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "organization_member_usage_limit_set_by_user_id_fk": { + "name": "organization_member_usage_limit_set_by_user_id_fk", + "tableFrom": "organization_member_usage_limit", + "tableTo": "user", + "columnsFrom": [ + "set_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.outbox_event": { + "name": "outbox_event", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "event_type": { + "name": "event_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "payload": { + "name": "payload", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "max_attempts": { + "name": "max_attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10 + }, + "available_at": { + "name": "available_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "locked_at": { + "name": "locked_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_error": { + "name": "last_error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "processed_at": { + "name": "processed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "outbox_event_status_available_idx": { + "name": "outbox_event_status_available_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "available_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_locked_at_idx": { + "name": "outbox_event_locked_at_idx", + "columns": [ + { + "expression": "locked_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "outbox_event_type_created_idx": { + "name": "outbox_event_type_created_idx", + "columns": [ + { + "expression": "event_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.paused_executions": { + "name": "paused_executions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_snapshot": { + "name": "execution_snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "pause_points": { + "name": "pause_points", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "total_pause_count": { + "name": "total_pause_count", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "resumed_count": { + "name": "resumed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "automatic_resume_retry_count": { + "name": "automatic_resume_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'paused'" + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "paused_at": { + "name": "paused_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "next_resume_at": { + "name": "next_resume_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "paused_executions_workflow_id_idx": { + "name": "paused_executions_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_status_idx": { + "name": "paused_executions_status_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_execution_id_unique": { + "name": "paused_executions_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "paused_executions_next_resume_at_idx": { + "name": "paused_executions_next_resume_at_idx", + "columns": [ + { + "expression": "next_resume_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'paused' AND next_resume_at IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "paused_executions_workflow_id_workflow_id_fk": { + "name": "paused_executions_workflow_id_workflow_id_fk", + "tableFrom": "paused_executions", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.pending_credential_draft": { + "name": "pending_credential_draft", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "credential_id": { + "name": "credential_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "pending_draft_user_provider_ws": { + "name": "pending_draft_user_provider_ws", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "pending_credential_draft_user_id_user_id_fk": { + "name": "pending_credential_draft_user_id_user_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_workspace_id_workspace_id_fk": { + "name": "pending_credential_draft_workspace_id_workspace_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "pending_credential_draft_credential_id_credential_id_fk": { + "name": "pending_credential_draft_credential_id_credential_id_fk", + "tableFrom": "pending_credential_draft", + "tableTo": "credential", + "columnsFrom": [ + "credential_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group": { + "name": "permission_group", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "is_default": { + "name": "is_default", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + } + }, + "indexes": { + "permission_group_created_by_idx": { + "name": "permission_group_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_name_unique": { + "name": "permission_group_organization_name_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_organization_default_unique": { + "name": "permission_group_organization_default_unique", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "is_default = true", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_organization_id_organization_id_fk": { + "name": "permission_group_organization_id_organization_id_fk", + "tableFrom": "permission_group", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_created_by_user_id_fk": { + "name": "permission_group_created_by_user_id_fk", + "tableFrom": "permission_group", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_member": { + "name": "permission_group_member", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "assigned_by": { + "name": "assigned_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "assigned_at": { + "name": "assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_member_group_id_idx": { + "name": "permission_group_member_group_id_idx", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_group_user_unique": { + "name": "permission_group_member_group_user_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_member_organization_user_idx": { + "name": "permission_group_member_organization_user_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_member_permission_group_id_permission_group_id_fk": { + "name": "permission_group_member_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "permission_group", + "columnsFrom": [ + "permission_group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_organization_id_organization_id_fk": { + "name": "permission_group_member_organization_id_organization_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_user_id_user_id_fk": { + "name": "permission_group_member_user_id_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_member_assigned_by_user_id_fk": { + "name": "permission_group_member_assigned_by_user_id_fk", + "tableFrom": "permission_group_member", + "tableTo": "user", + "columnsFrom": [ + "assigned_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permission_group_workspace": { + "name": "permission_group_workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "permission_group_id": { + "name": "permission_group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permission_group_workspace_workspace_id_idx": { + "name": "permission_group_workspace_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permission_group_workspace_group_workspace_unique": { + "name": "permission_group_workspace_group_workspace_unique", + "columns": [ + { + "expression": "permission_group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permission_group_workspace_permission_group_id_permission_group_id_fk": { + "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "permission_group", + "columnsFrom": [ + "permission_group_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_workspace_id_workspace_id_fk": { + "name": "permission_group_workspace_workspace_id_workspace_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "permission_group_workspace_organization_id_organization_id_fk": { + "name": "permission_group_workspace_organization_id_organization_id_fk", + "tableFrom": "permission_group_workspace", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.permissions": { + "name": "permissions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "entity_id": { + "name": "entity_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "permission_type": { + "name": "permission_type", + "type": "permission_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "permissions_user_id_idx": { + "name": "permissions_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_entity_idx": { + "name": "permissions_entity_idx", + "columns": [ + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_type_idx": { + "name": "permissions_user_entity_type_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_permission_idx": { + "name": "permissions_user_entity_permission_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "permission_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_user_entity_idx": { + "name": "permissions_user_entity_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "permissions_unique_constraint": { + "name": "permissions_unique_constraint", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "permissions_user_id_user_id_fk": { + "name": "permissions_user_id_user_id_fk", + "tableFrom": "permissions", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.public_share": { + "name": "public_share", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_id": { + "name": "resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "auth_type": { + "name": "auth_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'public'" + }, + "password": { + "name": "password", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "allowed_emails": { + "name": "allowed_emails", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'[]'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "public_share_token_unique": { + "name": "public_share_token_unique", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_unique": { + "name": "public_share_resource_unique", + "columns": [ + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_resource_id_idx": { + "name": "public_share_resource_id_idx", + "columns": [ + { + "expression": "resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "public_share_workspace_id_idx": { + "name": "public_share_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "public_share_workspace_id_workspace_id_fk": { + "name": "public_share_workspace_id_workspace_id_fk", + "tableFrom": "public_share", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "public_share_created_by_user_id_fk": { + "name": "public_share_created_by_user_id_fk", + "tableFrom": "public_share", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.rate_limit_bucket": { + "name": "rate_limit_bucket", + "schema": "", + "columns": { + "key": { + "name": "key", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "tokens": { + "name": "tokens", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "last_refill_at": { + "name": "last_refill_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resume_queue": { + "name": "resume_queue", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "paused_execution_id": { + "name": "paused_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_execution_id": { + "name": "parent_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "new_execution_id": { + "name": "new_execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "context_id": { + "name": "context_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resume_input": { + "name": "resume_input", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "queued_at": { + "name": "queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "claimed_at": { + "name": "claimed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "failure_reason": { + "name": "failure_reason", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "resume_queue_parent_status_idx": { + "name": "resume_queue_parent_status_idx", + "columns": [ + { + "expression": "parent_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "resume_queue_new_execution_idx": { + "name": "resume_queue_new_execution_idx", + "columns": [ + { + "expression": "new_execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resume_queue_paused_execution_id_paused_executions_id_fk": { + "name": "resume_queue_paused_execution_id_paused_executions_id_fk", + "tableFrom": "resume_queue", + "tableTo": "paused_executions", + "columnsFrom": [ + "paused_execution_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.session": { + "name": "session", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "token": { + "name": "token", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "active_organization_id": { + "name": "active_organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "impersonated_by": { + "name": "impersonated_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "session_user_id_idx": { + "name": "session_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "session_token_idx": { + "name": "session_token_idx", + "columns": [ + { + "expression": "token", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "session_user_id_user_id_fk": { + "name": "session_user_id_user_id_fk", + "tableFrom": "session", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "session_active_organization_id_organization_id_fk": { + "name": "session_active_organization_id_organization_id_fk", + "tableFrom": "session", + "tableTo": "organization", + "columnsFrom": [ + "active_organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "session_token_unique": { + "name": "session_token_unique", + "nullsNotDistinct": false, + "columns": [ + "token" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.settings": { + "name": "settings", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "theme": { + "name": "theme", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'system'" + }, + "auto_connect": { + "name": "auto_connect", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "telemetry_enabled": { + "name": "telemetry_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "email_preferences": { + "name": "email_preferences", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "billing_usage_notifications_enabled": { + "name": "billing_usage_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "show_training_controls": { + "name": "show_training_controls", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "super_user_mode_enabled": { + "name": "super_user_mode_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "mothership_environment": { + "name": "mothership_environment", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'default'" + }, + "error_notifications_enabled": { + "name": "error_notifications_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "snap_to_grid_size": { + "name": "snap_to_grid_size", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "show_action_bar": { + "name": "show_action_bar", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "copilot_enabled_models": { + "name": "copilot_enabled_models", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "copilot_auto_allowed_tools": { + "name": "copilot_auto_allowed_tools", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'[]'" + }, + "last_active_workspace_id": { + "name": "last_active_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "settings_user_id_user_id_fk": { + "name": "settings_user_id_user_id_fk", + "tableFrom": "settings", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "settings_user_id_unique": { + "name": "settings_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sim_trigger_state": { + "name": "sim_trigger_state", + "schema": "", + "columns": { + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope_key": { + "name": "scope_key", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "''" + }, + "last_fired_at": { + "name": "last_fired_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sim_trigger_state_workflow_id_workflow_id_fk": { + "name": "sim_trigger_state_workflow_id_workflow_id_fk", + "tableFrom": "sim_trigger_state", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "sim_trigger_state_workflow_id_block_id_scope_key_pk": { + "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", + "columns": [ + "workflow_id", + "block_id", + "scope_key" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.skill": { + "name": "skill", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "skill_workspace_name_unique": { + "name": "skill_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "skill_workspace_id_workspace_id_fk": { + "name": "skill_workspace_id_workspace_id_fk", + "tableFrom": "skill", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "skill_user_id_user_id_fk": { + "name": "skill_user_id_user_id_fk", + "tableFrom": "skill", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sso_provider": { + "name": "sso_provider", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "issuer": { + "name": "issuer", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "domain": { + "name": "domain", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "oidc_config": { + "name": "oidc_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "saml_config": { + "name": "saml_config", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "sso_provider_provider_id_idx": { + "name": "sso_provider_provider_id_idx", + "columns": [ + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_domain_idx": { + "name": "sso_provider_domain_idx", + "columns": [ + { + "expression": "domain", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_user_id_idx": { + "name": "sso_provider_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "sso_provider_organization_id_idx": { + "name": "sso_provider_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "sso_provider_user_id_user_id_fk": { + "name": "sso_provider_user_id_user_id_fk", + "tableFrom": "sso_provider", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "sso_provider_organization_id_organization_id_fk": { + "name": "sso_provider_organization_id_organization_id_fk", + "tableFrom": "sso_provider", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscription": { + "name": "subscription", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "reference_id": { + "name": "reference_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_subscription_id": { + "name": "stripe_subscription_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "period_start": { + "name": "period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "period_end": { + "name": "period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancel_at_period_end": { + "name": "cancel_at_period_end", + "type": "boolean", + "primaryKey": false, + "notNull": false + }, + "cancel_at": { + "name": "cancel_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "canceled_at": { + "name": "canceled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "seats": { + "name": "seats", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "trial_start": { + "name": "trial_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trial_end": { + "name": "trial_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_interval": { + "name": "billing_interval", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "stripe_schedule_id": { + "name": "stripe_schedule_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "json", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "subscription_reference_status_idx": { + "name": "subscription_reference_status_idx", + "columns": [ + { + "expression": "reference_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "check_enterprise_metadata": { + "name": "check_enterprise_metadata", + "value": "plan != 'enterprise' OR metadata IS NOT NULL" + } + }, + "isRLSEnabled": false + }, + "public.table_jobs": { + "name": "table_jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "payload": { + "name": "payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "rows_processed": { + "name": "rows_processed", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_jobs_one_active_per_table": { + "name": "table_jobs_one_active_per_table", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"table_jobs\".\"status\" = 'running' AND \"table_jobs\".\"type\" <> 'export'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_watchdog_idx": { + "name": "table_jobs_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_jobs_table_started_idx": { + "name": "table_jobs_table_started_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_jobs_table_id_user_table_definitions_id_fk": { + "name": "table_jobs_table_id_user_table_definitions_id_fk", + "tableFrom": "table_jobs", + "tableTo": "user_table_definitions", + "columnsFrom": [ + "table_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_jobs_workspace_id_workspace_id_fk": { + "name": "table_jobs_workspace_id_workspace_id_fk", + "tableFrom": "table_jobs", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_row_executions": { + "name": "table_row_executions", + "schema": "", + "columns": { + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "row_id": { + "name": "row_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "group_id": { + "name": "group_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_id": { + "name": "job_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "error": { + "name": "error", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "running_block_ids": { + "name": "running_block_ids", + "type": "text[]", + "primaryKey": false, + "notNull": true, + "default": "'{}'::text[]" + }, + "block_errors": { + "name": "block_errors", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "enrichment_details": { + "name": "enrichment_details", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "table_row_executions_table_status_idx": { + "name": "table_row_executions_table_status_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"status\" IN ('queued', 'running', 'pending')", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_execution_id_idx": { + "name": "table_row_executions_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"table_row_executions\".\"execution_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_row_executions_table_group_idx": { + "name": "table_row_executions_table_group_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "group_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_row_executions_table_id_user_table_definitions_id_fk": { + "name": "table_row_executions_table_id_user_table_definitions_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_definitions", + "columnsFrom": [ + "table_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_row_executions_row_id_user_table_rows_id_fk": { + "name": "table_row_executions_row_id_user_table_rows_id_fk", + "tableFrom": "table_row_executions", + "tableTo": "user_table_rows", + "columnsFrom": [ + "row_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": { + "table_row_executions_row_id_group_id_pk": { + "name": "table_row_executions_row_id_group_id_pk", + "columns": [ + "row_id", + "group_id" + ] + } + }, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.table_run_dispatches": { + "name": "table_run_dispatches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "request_id": { + "name": "request_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "mode": { + "name": "mode", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "scope": { + "name": "scope", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "cursor": { + "name": "cursor", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "limit": { + "name": "limit", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "processed_count": { + "name": "processed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "is_manual_run": { + "name": "is_manual_run", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "triggered_by_user_id": { + "name": "triggered_by_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "requested_at": { + "name": "requested_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "completed_at": { + "name": "completed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "table_run_dispatches_active_idx": { + "name": "table_run_dispatches_active_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "table_run_dispatches_watchdog_idx": { + "name": "table_run_dispatches_watchdog_idx", + "columns": [ + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "requested_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "table_run_dispatches_table_id_user_table_definitions_id_fk": { + "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user_table_definitions", + "columnsFrom": [ + "table_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_workspace_id_workspace_id_fk": { + "name": "table_run_dispatches_workspace_id_workspace_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "table_run_dispatches_triggered_by_user_id_user_id_fk": { + "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", + "tableFrom": "table_run_dispatches", + "tableTo": "user", + "columnsFrom": [ + "triggered_by_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.usage_log": { + "name": "usage_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "category": { + "name": "category", + "type": "usage_log_category", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "source": { + "name": "source", + "type": "usage_log_source", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost": { + "name": "cost", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "event_key": { + "name": "event_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_entity_type": { + "name": "billing_entity_type", + "type": "billing_entity_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "billing_entity_id": { + "name": "billing_entity_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "billing_period_start": { + "name": "billing_period_start", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "billing_period_end": { + "name": "billing_period_end", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "usage_log_user_created_at_idx": { + "name": "usage_log_user_created_at_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_source_idx": { + "name": "usage_log_source_idx", + "columns": [ + { + "expression": "source", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_id_idx": { + "name": "usage_log_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workflow_id_idx": { + "name": "usage_log_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_event_key_unique": { + "name": "usage_log_event_key_unique", + "columns": [ + { + "expression": "event_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"usage_log\".\"event_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_billing_entity_period_idx": { + "name": "usage_log_billing_entity_period_idx", + "columns": [ + { + "expression": "billing_entity_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_entity_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_start", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "billing_period_end", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"usage_log\".\"billing_entity_type\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_workspace_created_at_idx": { + "name": "usage_log_workspace_created_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "usage_log_execution_id_idx": { + "name": "usage_log_execution_id_idx", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "usage_log_user_id_user_id_fk": { + "name": "usage_log_user_id_user_id_fk", + "tableFrom": "usage_log", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "usage_log_workspace_id_workspace_id_fk": { + "name": "usage_log_workspace_id_workspace_id_fk", + "tableFrom": "usage_log", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "usage_log_workflow_id_workflow_id_fk": { + "name": "usage_log_workflow_id_workflow_id_fk", + "tableFrom": "usage_log", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "usage_log_billing_scope_all_or_none": { + "name": "usage_log_billing_scope_all_or_none", + "value": "(\n (\"usage_log\".\"billing_entity_type\" IS NULL AND \"usage_log\".\"billing_entity_id\" IS NULL AND \"usage_log\".\"billing_period_start\" IS NULL AND \"usage_log\".\"billing_period_end\" IS NULL)\n OR\n (\"usage_log\".\"billing_entity_type\" IS NOT NULL AND \"usage_log\".\"billing_entity_id\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" IS NOT NULL AND \"usage_log\".\"billing_period_end\" IS NOT NULL AND \"usage_log\".\"billing_period_start\" < \"usage_log\".\"billing_period_end\")\n )" + } + }, + "isRLSEnabled": false + }, + "public.user": { + "name": "user", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "normalized_email": { + "name": "normalized_email", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "email_verified": { + "name": "email_verified", + "type": "boolean", + "primaryKey": false, + "notNull": true + }, + "image": { + "name": "image", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "stripe_customer_id": { + "name": "stripe_customer_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "role": { + "name": "role", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'user'" + }, + "banned": { + "name": "banned", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "ban_reason": { + "name": "ban_reason", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "ban_expires": { + "name": "ban_expires", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_email_unique": { + "name": "user_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + }, + "user_normalized_email_unique": { + "name": "user_normalized_email_unique", + "nullsNotDistinct": false, + "columns": [ + "normalized_email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_stats": { + "name": "user_stats", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "total_manual_executions": { + "name": "total_manual_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_api_calls": { + "name": "total_api_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_webhook_triggers": { + "name": "total_webhook_triggers", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_scheduled_executions": { + "name": "total_scheduled_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_chat_executions": { + "name": "total_chat_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_executions": { + "name": "total_mcp_executions", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_tokens_used": { + "name": "total_tokens_used", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_cost": { + "name": "total_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_usage_limit": { + "name": "current_usage_limit", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'5'" + }, + "usage_limit_updated_at": { + "name": "usage_limit_updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "current_period_cost": { + "name": "current_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_cost": { + "name": "last_period_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "billed_overage_this_period": { + "name": "billed_overage_this_period", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "pro_period_cost_snapshot": { + "name": "pro_period_cost_snapshot", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "pro_period_cost_snapshot_at": { + "name": "pro_period_cost_snapshot_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "credit_balance": { + "name": "credit_balance", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "total_copilot_cost": { + "name": "total_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_copilot_cost": { + "name": "current_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "last_period_copilot_cost": { + "name": "last_period_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": false, + "default": "'0'" + }, + "total_copilot_tokens": { + "name": "total_copilot_tokens", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_copilot_calls": { + "name": "total_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_calls": { + "name": "total_mcp_copilot_calls", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "total_mcp_copilot_cost": { + "name": "total_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "current_period_mcp_copilot_cost": { + "name": "current_period_mcp_copilot_cost", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_active": { + "name": "last_active", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "billing_blocked": { + "name": "billing_blocked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "billing_blocked_reason": { + "name": "billing_blocked_reason", + "type": "billing_blocked_reason", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "limit_notifications": { + "name": "limit_notifications", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'::jsonb" + } + }, + "indexes": {}, + "foreignKeys": { + "user_stats_user_id_user_id_fk": { + "name": "user_stats_user_id_user_id_fk", + "tableFrom": "user_stats", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "user_stats_user_id_unique": { + "name": "user_stats_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_definitions": { + "name": "user_table_definitions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "schema": { + "name": "schema", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "max_rows": { + "name": "max_rows", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 10000 + }, + "row_count": { + "name": "row_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "rows_version": { + "name": "rows_version", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "user_table_def_workspace_id_idx": { + "name": "user_table_def_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_name_unique": { + "name": "user_table_def_workspace_name_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"user_table_definitions\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_archived_at_idx": { + "name": "user_table_def_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_def_workspace_archived_partial_idx": { + "name": "user_table_def_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"user_table_definitions\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_definitions_workspace_id_workspace_id_fk": { + "name": "user_table_definitions_workspace_id_workspace_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_definitions_created_by_user_id_fk": { + "name": "user_table_definitions_created_by_user_id_fk", + "tableFrom": "user_table_definitions", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.user_table_rows": { + "name": "user_table_rows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "table_id": { + "name": "table_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "position": { + "name": "position", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "order_key": { + "name": "order_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "user_table_rows_tenant_data_gin_idx": { + "name": "user_table_rows_tenant_data_gin_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"data\" jsonb_path_ops", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "user_table_rows_workspace_table_idx": { + "name": "user_table_rows_workspace_table_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_position_idx": { + "name": "user_table_rows_table_position_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "position", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_order_key_idx": { + "name": "user_table_rows_table_order_key_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "order_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "user_table_rows_table_id_id_idx": { + "name": "user_table_rows_table_id_id_idx", + "columns": [ + { + "expression": "table_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "user_table_rows_table_id_user_table_definitions_id_fk": { + "name": "user_table_rows_table_id_user_table_definitions_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user_table_definitions", + "columnsFrom": [ + "table_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_workspace_id_workspace_id_fk": { + "name": "user_table_rows_workspace_id_workspace_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "user_table_rows_created_by_user_id_fk": { + "name": "user_table_rows_created_by_user_id_fk", + "tableFrom": "user_table_rows", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.verification": { + "name": "verification", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "identifier": { + "name": "identifier", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "verification_identifier_idx": { + "name": "verification_identifier_idx", + "columns": [ + { + "expression": "identifier", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "verification_expires_at_idx": { + "name": "verification_expires_at_idx", + "columns": [ + { + "expression": "expires_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.waitlist": { + "name": "waitlist", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "email": { + "name": "email", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "waitlist_email_unique": { + "name": "waitlist_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.webhook": { + "name": "webhook", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "path": { + "name": "path", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "routing_key": { + "name": "routing_key", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider": { + "name": "provider", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "provider_config": { + "name": "provider_config", + "type": "json", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "path_deployment_unique": { + "name": "path_deployment_unique", + "columns": [ + { + "expression": "path", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_workflow_deployment_idx": { + "name": "webhook_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_routing_key_active_idx": { + "name": "webhook_routing_key_active_idx", + "columns": [ + { + "expression": "routing_key", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NULL AND \"webhook\".\"routing_key\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_archived_at_partial_idx": { + "name": "webhook_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468": { + "name": "idx_webhook_on_provider_is_active_workflow_id_deploym_bdeed5468", + "columns": [ + { + "expression": "provider", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "webhook_tiktok_credential_id_idx": { + "name": "webhook_tiktok_credential_id_idx", + "columns": [ + { + "expression": "((\"provider_config\")::jsonb ->> 'credentialId')", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"webhook\".\"provider\" = 'tiktok' AND \"webhook\".\"is_active\" = true AND \"webhook\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_webhook_on_workflow_id_block_id_updated_at_desc": { + "name": "idx_webhook_on_workflow_id_block_id_updated_at_desc", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "updated_at", + "isExpression": false, + "asc": false, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "webhook_workflow_id_workflow_id_fk": { + "name": "webhook_workflow_id_workflow_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "webhook_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "webhook", + "tableTo": "workflow_deployment_version", + "columnsFrom": [ + "deployment_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow": { + "name": "workflow", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_synced": { + "name": "last_synced", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "is_deployed": { + "name": "is_deployed", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deployed_at": { + "name": "deployed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "is_public_api": { + "name": "is_public_api", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_run_at": { + "name": "last_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_user_id_idx": { + "name": "workflow_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_id_idx": { + "name": "workflow_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_user_workspace_idx": { + "name": "workflow_user_workspace_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_folder_name_active_unique": { + "name": "workflow_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_sort_idx": { + "name": "workflow_folder_sort_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_archived_at_idx": { + "name": "workflow_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_workspace_archived_partial_idx": { + "name": "workflow_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_user_id_user_id_fk": { + "name": "workflow_user_id_user_id_fk", + "tableFrom": "workflow", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_workspace_id_workspace_id_fk": { + "name": "workflow_workspace_id_workspace_id_fk", + "tableFrom": "workflow", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_id_workflow_folder_id_fk": { + "name": "workflow_folder_id_workflow_folder_id_fk", + "tableFrom": "workflow", + "tableTo": "workflow_folder", + "columnsFrom": [ + "folder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_blocks": { + "name": "workflow_blocks", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "position_x": { + "name": "position_x", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "position_y": { + "name": "position_y", + "type": "numeric", + "primaryKey": false, + "notNull": true + }, + "enabled": { + "name": "enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "horizontal_handles": { + "name": "horizontal_handles", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "is_wide": { + "name": "is_wide", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "advanced_mode": { + "name": "advanced_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "trigger_mode": { + "name": "trigger_mode", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "height": { + "name": "height", + "type": "numeric", + "primaryKey": false, + "notNull": true, + "default": "'0'" + }, + "sub_blocks": { + "name": "sub_blocks", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "outputs": { + "name": "outputs", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "data": { + "name": "data", + "type": "jsonb", + "primaryKey": false, + "notNull": false, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_blocks_workflow_id_idx": { + "name": "workflow_blocks_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_blocks_type_idx": { + "name": "workflow_blocks_type_idx", + "columns": [ + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_blocks_workflow_id_workflow_id_fk": { + "name": "workflow_blocks_workflow_id_workflow_id_fk", + "tableFrom": "workflow_blocks", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_checkpoints": { + "name": "workflow_checkpoints", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "message_id": { + "name": "message_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workflow_state": { + "name": "workflow_state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_checkpoints_user_id_idx": { + "name": "workflow_checkpoints_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_id_idx": { + "name": "workflow_checkpoints_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_id_idx": { + "name": "workflow_checkpoints_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_message_id_idx": { + "name": "workflow_checkpoints_message_id_idx", + "columns": [ + { + "expression": "message_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_user_workflow_idx": { + "name": "workflow_checkpoints_user_workflow_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_workflow_chat_idx": { + "name": "workflow_checkpoints_workflow_chat_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_created_at_idx": { + "name": "workflow_checkpoints_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_checkpoints_chat_created_at_idx": { + "name": "workflow_checkpoints_chat_created_at_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_checkpoints_user_id_user_id_fk": { + "name": "workflow_checkpoints_user_id_user_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_workflow_id_workflow_id_fk": { + "name": "workflow_checkpoints_workflow_id_workflow_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_checkpoints_chat_id_copilot_chats_id_fk": { + "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", + "tableFrom": "workflow_checkpoints", + "tableTo": "copilot_chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_deployment_version": { + "name": "workflow_deployment_version", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state": { + "name": "state", + "type": "json", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_deployment_version_workflow_version_unique": { + "name": "workflow_deployment_version_workflow_version_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "version", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_workflow_active_idx": { + "name": "workflow_deployment_version_workflow_active_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_deployment_version_created_at_idx": { + "name": "workflow_deployment_version_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_deployment_version_workflow_id_workflow_id_fk": { + "name": "workflow_deployment_version_workflow_id_workflow_id_fk", + "tableFrom": "workflow_deployment_version", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_edges": { + "name": "workflow_edges", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_block_id": { + "name": "source_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_handle": { + "name": "source_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "target_handle": { + "name": "target_handle", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_edges_workflow_id_idx": { + "name": "workflow_edges_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_source_idx": { + "name": "workflow_edges_workflow_source_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_edges_workflow_target_idx": { + "name": "workflow_edges_workflow_target_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_edges_workflow_id_workflow_id_fk": { + "name": "workflow_edges_workflow_id_workflow_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_source_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": [ + "source_block_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_edges_target_block_id_workflow_blocks_id_fk": { + "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", + "tableFrom": "workflow_edges", + "tableTo": "workflow_blocks", + "columnsFrom": [ + "target_block_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_logs": { + "name": "workflow_execution_logs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "execution_id": { + "name": "execution_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_snapshot_id": { + "name": "state_snapshot_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "level": { + "name": "level", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'running'" + }, + "trigger": { + "name": "trigger", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "started_at": { + "name": "started_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true + }, + "ended_at": { + "name": "ended_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "total_duration_ms": { + "name": "total_duration_ms", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "execution_data": { + "name": "execution_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "cost": { + "name": "cost", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "cost_total": { + "name": "cost_total", + "type": "numeric", + "primaryKey": false, + "notNull": false + }, + "models_used": { + "name": "models_used", + "type": "text[]", + "primaryKey": false, + "notNull": false + }, + "files": { + "name": "files", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_execution_logs_workflow_id_idx": { + "name": "workflow_execution_logs_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_state_snapshot_id_idx": { + "name": "workflow_execution_logs_state_snapshot_id_idx", + "columns": [ + { + "expression": "state_snapshot_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_deployment_version_id_idx": { + "name": "workflow_execution_logs_deployment_version_id_idx", + "columns": [ + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_trigger_idx": { + "name": "workflow_execution_logs_trigger_idx", + "columns": [ + { + "expression": "trigger", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_level_idx": { + "name": "workflow_execution_logs_level_idx", + "columns": [ + { + "expression": "level", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_started_at_idx": { + "name": "workflow_execution_logs_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_execution_id_unique": { + "name": "workflow_execution_logs_execution_id_unique", + "columns": [ + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workflow_started_at_idx": { + "name": "workflow_execution_logs_workflow_started_at_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_idx": { + "name": "workflow_execution_logs_workspace_started_at_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_started_at_id_desc_idx": { + "name": "workflow_execution_logs_workspace_started_at_id_desc_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "\"started_at\" DESC NULLS LAST", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "\"id\" DESC", + "asc": true, + "isExpression": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_workspace_cost_total_idx": { + "name": "workflow_execution_logs_workspace_cost_total_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "cost_total", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_models_used_idx": { + "name": "workflow_execution_logs_models_used_idx", + "columns": [ + { + "expression": "models_used", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "gin", + "with": {} + }, + "workflow_execution_logs_workspace_ended_at_id_idx": { + "name": "workflow_execution_logs_workspace_ended_at_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "date_trunc('milliseconds', \"ended_at\")", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_running_started_at_idx": { + "name": "workflow_execution_logs_running_started_at_idx", + "columns": [ + { + "expression": "started_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "status = 'running'", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_execution_logs_completed_ended_at_idx": { + "name": "workflow_execution_logs_completed_ended_at_idx", + "columns": [ + { + "expression": "ended_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "execution_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_execution_logs\".\"status\" = 'completed' AND \"workflow_execution_logs\".\"level\" = 'info' AND \"workflow_execution_logs\".\"ended_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_logs_workflow_id_workflow_id_fk": { + "name": "workflow_execution_logs_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workflow_execution_logs_workspace_id_workspace_id_fk": { + "name": "workflow_execution_logs_workspace_id_workspace_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk": { + "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_execution_snapshots", + "columnsFrom": [ + "state_snapshot_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_execution_logs", + "tableTo": "workflow_deployment_version", + "columnsFrom": [ + "deployment_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_execution_snapshots": { + "name": "workflow_execution_snapshots", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "state_hash": { + "name": "state_hash", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "state_data": { + "name": "state_data", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_snapshots_workflow_id_idx": { + "name": "workflow_snapshots_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_hash_idx": { + "name": "workflow_snapshots_hash_idx", + "columns": [ + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_workflow_hash_idx": { + "name": "workflow_snapshots_workflow_hash_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "state_hash", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_snapshots_created_at_idx": { + "name": "workflow_snapshots_created_at_idx", + "columns": [ + { + "expression": "created_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_execution_snapshots_workflow_id_workflow_id_fk": { + "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", + "tableFrom": "workflow_execution_snapshots", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_folder": { + "name": "workflow_folder", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": false, + "default": "'#6B7280'" + }, + "is_expanded": { + "name": "is_expanded", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "locked": { + "name": "locked", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "workflow_folder_user_idx": { + "name": "workflow_folder_user_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_parent_idx": { + "name": "workflow_folder_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_parent_sort_idx": { + "name": "workflow_folder_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_archived_at_idx": { + "name": "workflow_folder_archived_at_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_folder_workspace_archived_partial_idx": { + "name": "workflow_folder_workspace_archived_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_folder\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_folder_user_id_user_id_fk": { + "name": "workflow_folder_user_id_user_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_folder_workspace_id_workspace_id_fk": { + "name": "workflow_folder_workspace_id_workspace_id_fk", + "tableFrom": "workflow_folder", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_server": { + "name": "workflow_mcp_server", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_public": { + "name": "is_public", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_server_workspace_id_idx": { + "name": "workflow_mcp_server_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_created_by_idx": { + "name": "workflow_mcp_server_created_by_idx", + "columns": [ + { + "expression": "created_by", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_deleted_at_idx": { + "name": "workflow_mcp_server_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_server_workspace_deleted_partial_idx": { + "name": "workflow_mcp_server_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_server\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_server_workspace_id_workspace_id_fk": { + "name": "workflow_mcp_server_workspace_id_workspace_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_server_created_by_user_id_fk": { + "name": "workflow_mcp_server_created_by_user_id_fk", + "tableFrom": "workflow_mcp_server", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_mcp_tool": { + "name": "workflow_mcp_tool", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "server_id": { + "name": "server_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_name": { + "name": "tool_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tool_description": { + "name": "tool_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "parameter_schema": { + "name": "parameter_schema", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "parameter_description_overrides": { + "name": "parameter_description_overrides", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'::json" + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_mcp_tool_server_id_idx": { + "name": "workflow_mcp_tool_server_id_idx", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_workflow_id_idx": { + "name": "workflow_mcp_tool_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_server_workflow_unique": { + "name": "workflow_mcp_tool_server_workflow_unique", + "columns": [ + { + "expression": "server_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_mcp_tool_archived_at_partial_idx": { + "name": "workflow_mcp_tool_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_mcp_tool\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk": { + "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow_mcp_server", + "columnsFrom": [ + "server_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_mcp_tool_workflow_id_workflow_id_fk": { + "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", + "tableFrom": "workflow_mcp_tool", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_schedule": { + "name": "workflow_schedule", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "deployment_version_id": { + "name": "deployment_version_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "block_id": { + "name": "block_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "cron_expression": { + "name": "cron_expression", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "next_run_at": { + "name": "next_run_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_ran_at": { + "name": "last_ran_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "last_queued_at": { + "name": "last_queued_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "trigger_type": { + "name": "trigger_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "timezone": { + "name": "timezone", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'UTC'" + }, + "failed_count": { + "name": "failed_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "infra_retry_count": { + "name": "infra_retry_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "status": { + "name": "status", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "last_failed_at": { + "name": "last_failed_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "source_type": { + "name": "source_type", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'workflow'" + }, + "job_title": { + "name": "job_title", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "prompt": { + "name": "prompt", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "lifecycle": { + "name": "lifecycle", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'persistent'" + }, + "success_condition": { + "name": "success_condition", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "max_runs": { + "name": "max_runs", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "run_count": { + "name": "run_count", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "source_chat_id": { + "name": "source_chat_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_task_name": { + "name": "source_task_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_user_id": { + "name": "source_user_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "job_history": { + "name": "job_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "contexts": { + "name": "contexts", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "excluded_dates": { + "name": "excluded_dates", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "ends_at": { + "name": "ends_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_schedule_workflow_block_deployment_unique": { + "name": "workflow_schedule_workflow_block_deployment_unique", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_workflow_deployment_idx": { + "name": "workflow_schedule_workflow_deployment_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_archived_at_partial_idx": { + "name": "workflow_schedule_archived_at_partial_idx", + "columns": [ + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6": { + "name": "idx_workflow_schedule_on_source_workspace_id_source_t_c07f3bba6", + "columns": [ + { + "expression": "source_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "source_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "archived_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "status", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_workflow_idx": { + "name": "workflow_schedule_due_workflow_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deployment_version_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND (\"workflow_schedule\".\"source_type\" = 'workflow' OR \"workflow_schedule\".\"source_type\" IS NULL)", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_schedule_due_job_idx": { + "name": "workflow_schedule_due_job_idx", + "columns": [ + { + "expression": "next_run_at", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "last_queued_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workflow_schedule\".\"archived_at\" IS NULL AND \"workflow_schedule\".\"status\" NOT IN ('disabled', 'completed') AND \"workflow_schedule\".\"source_type\" = 'job'", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_schedule_workflow_id_workflow_id_fk": { + "name": "workflow_schedule_workflow_id_workflow_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk": { + "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workflow_deployment_version", + "columnsFrom": [ + "deployment_version_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_user_id_user_id_fk": { + "name": "workflow_schedule_source_user_id_user_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "user", + "columnsFrom": [ + "source_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workflow_schedule_source_workspace_id_workspace_id_fk": { + "name": "workflow_schedule_source_workspace_id_workspace_id_fk", + "tableFrom": "workflow_schedule", + "tableTo": "workspace", + "columnsFrom": [ + "source_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workflow_subflows": { + "name": "workflow_subflows", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workflow_id": { + "name": "workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "config": { + "name": "config", + "type": "jsonb", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workflow_subflows_workflow_id_idx": { + "name": "workflow_subflows_workflow_id_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workflow_subflows_workflow_type_idx": { + "name": "workflow_subflows_workflow_type_idx", + "columns": [ + { + "expression": "workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workflow_subflows_workflow_id_workflow_id_fk": { + "name": "workflow_subflows_workflow_id_workflow_id_fk", + "tableFrom": "workflow_subflows", + "tableTo": "workflow", + "columnsFrom": [ + "workflow_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace": { + "name": "workspace", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "color": { + "name": "color", + "type": "text", + "primaryKey": false, + "notNull": true, + "default": "'#33C482'" + }, + "logo_url": { + "name": "logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "owner_id": { + "name": "owner_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "organization_id": { + "name": "organization_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "workspace_mode": { + "name": "workspace_mode", + "type": "workspace_mode", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'grandfathered_shared'" + }, + "billed_account_user_id": { + "name": "billed_account_user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "storage_used_bytes": { + "name": "storage_used_bytes", + "type": "bigint", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "allow_personal_api_keys": { + "name": "allow_personal_api_keys", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": true + }, + "inbox_enabled": { + "name": "inbox_enabled", + "type": "boolean", + "primaryKey": false, + "notNull": true, + "default": false + }, + "inbox_address": { + "name": "inbox_address", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "inbox_provider_id": { + "name": "inbox_provider_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "archived_at": { + "name": "archived_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "organization_assigned_at": { + "name": "organization_assigned_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "forked_from_workspace_id": { + "name": "forked_from_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_owner_id_idx": { + "name": "workspace_owner_id_idx", + "columns": [ + { + "expression": "owner_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_organization_id_idx": { + "name": "workspace_organization_id_idx", + "columns": [ + { + "expression": "organization_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_mode_idx": { + "name": "workspace_mode_idx", + "columns": [ + { + "expression": "workspace_mode", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_forked_from_workspace_id_idx": { + "name": "workspace_forked_from_workspace_id_idx", + "columns": [ + { + "expression": "forked_from_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_owner_id_user_id_fk": { + "name": "workspace_owner_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": [ + "owner_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_organization_id_organization_id_fk": { + "name": "workspace_organization_id_organization_id_fk", + "tableFrom": "workspace", + "tableTo": "organization", + "columnsFrom": [ + "organization_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_billed_account_user_id_user_id_fk": { + "name": "workspace_billed_account_user_id_user_id_fk", + "tableFrom": "workspace", + "tableTo": "user", + "columnsFrom": [ + "billed_account_user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + }, + "workspace_forked_from_workspace_id_workspace_id_fk": { + "name": "workspace_forked_from_workspace_id_workspace_id_fk", + "tableFrom": "workspace", + "tableTo": "workspace", + "columnsFrom": [ + "forked_from_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": { + "workspace_storage_used_bytes_non_negative": { + "name": "workspace_storage_used_bytes_non_negative", + "value": "\"workspace\".\"storage_used_bytes\" >= 0" + } + }, + "isRLSEnabled": false + }, + "public.workspace_byok_keys": { + "name": "workspace_byok_keys", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "provider_id": { + "name": "provider_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "encrypted_api_key": { + "name": "encrypted_api_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_byok_workspace_provider_idx": { + "name": "workspace_byok_workspace_provider_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "provider_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_byok_keys_workspace_id_workspace_id_fk": { + "name": "workspace_byok_keys_workspace_id_workspace_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_byok_keys_created_by_user_id_fk": { + "name": "workspace_byok_keys_created_by_user_id_fk", + "tableFrom": "workspace_byok_keys", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_environment": { + "name": "workspace_environment", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "variables": { + "name": "variables", + "type": "json", + "primaryKey": false, + "notNull": true, + "default": "'{}'" + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_environment_workspace_unique": { + "name": "workspace_environment_workspace_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_environment_workspace_id_workspace_id_fk": { + "name": "workspace_environment_workspace_id_workspace_id_fk", + "tableFrom": "workspace_environment", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file": { + "name": "workspace_file", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "uploaded_by": { + "name": "uploaded_by", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_workspace_id_idx": { + "name": "workspace_file_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_key_idx": { + "name": "workspace_file_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_deleted_at_idx": { + "name": "workspace_file_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_workspace_deleted_partial_idx": { + "name": "workspace_file_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_workspace_id_workspace_id_fk": { + "name": "workspace_file_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_uploaded_by_user_id_fk": { + "name": "workspace_file_uploaded_by_user_id_fk", + "tableFrom": "workspace_file", + "tableTo": "user", + "columnsFrom": [ + "uploaded_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "workspace_file_key_unique": { + "name": "workspace_file_key_unique", + "nullsNotDistinct": false, + "columns": [ + "key" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_file_folders": { + "name": "workspace_file_folders", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "name": { + "name": "name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_id": { + "name": "parent_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "sort_order": { + "name": "sort_order", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_file_folders_workspace_parent_idx": { + "name": "workspace_file_folders_workspace_parent_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_parent_sort_idx": { + "name": "workspace_file_folders_parent_sort_idx", + "columns": [ + { + "expression": "parent_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sort_order", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_deleted_at_idx": { + "name": "workspace_file_folders_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_deleted_partial_idx": { + "name": "workspace_file_folders_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_file_folders_workspace_parent_name_active_unique": { + "name": "workspace_file_folders_workspace_parent_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"parent_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_file_folders\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_file_folders_user_id_user_id_fk": { + "name": "workspace_file_folders_user_id_user_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_workspace_id_workspace_id_fk": { + "name": "workspace_file_folders_workspace_id_workspace_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_file_folders_parent_id_workspace_file_folders_id_fk": { + "name": "workspace_file_folders_parent_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_file_folders", + "tableTo": "workspace_file_folders", + "columnsFrom": [ + "parent_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_files": { + "name": "workspace_files", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "key": { + "name": "key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "user_id": { + "name": "user_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "workspace_id": { + "name": "workspace_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "folder_id": { + "name": "folder_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "context": { + "name": "context", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "chat_id": { + "name": "chat_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "original_name": { + "name": "original_name", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "display_name": { + "name": "display_name", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "content_type": { + "name": "content_type", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "size": { + "name": "size", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp", + "primaryKey": false, + "notNull": false + }, + "uploaded_at": { + "name": "uploaded_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_files_key_active_unique": { + "name": "workspace_files_key_active_unique", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_folder_name_active_unique": { + "name": "workspace_files_workspace_folder_name_active_unique", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "coalesce(\"folder_id\", '')", + "asc": true, + "isExpression": true, + "nulls": "last" + }, + { + "expression": "original_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"deleted_at\" IS NULL AND \"workspace_files\".\"context\" = 'workspace' AND \"workspace_files\".\"workspace_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_display_name_unique": { + "name": "workspace_files_chat_display_name_unique", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "display_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "where": "\"workspace_files\".\"context\" = 'mothership' AND \"workspace_files\".\"chat_id\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_key_idx": { + "name": "workspace_files_key_idx", + "columns": [ + { + "expression": "key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_user_id_idx": { + "name": "workspace_files_user_id_idx", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_id_idx": { + "name": "workspace_files_workspace_id_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_folder_id_idx": { + "name": "workspace_files_folder_id_idx", + "columns": [ + { + "expression": "folder_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_context_idx": { + "name": "workspace_files_context_idx", + "columns": [ + { + "expression": "context", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_chat_id_idx": { + "name": "workspace_files_chat_id_idx", + "columns": [ + { + "expression": "chat_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_deleted_at_idx": { + "name": "workspace_files_deleted_at_idx", + "columns": [ + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_files_workspace_deleted_partial_idx": { + "name": "workspace_files_workspace_deleted_partial_idx", + "columns": [ + { + "expression": "workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "deleted_at", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "where": "\"workspace_files\".\"deleted_at\" IS NOT NULL", + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_files_user_id_user_id_fk": { + "name": "workspace_files_user_id_user_id_fk", + "tableFrom": "workspace_files", + "tableTo": "user", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_workspace_id_workspace_id_fk": { + "name": "workspace_files_workspace_id_workspace_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace", + "columnsFrom": [ + "workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_files_folder_id_workspace_file_folders_id_fk": { + "name": "workspace_files_folder_id_workspace_file_folders_id_fk", + "tableFrom": "workspace_files", + "tableTo": "workspace_file_folders", + "columnsFrom": [ + "folder_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + }, + "workspace_files_chat_id_copilot_chats_id_fk": { + "name": "workspace_files_chat_id_copilot_chats_id_fk", + "tableFrom": "workspace_files", + "tableTo": "copilot_chats", + "columnsFrom": [ + "chat_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_block_map": { + "name": "workspace_fork_block_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_workflow_id": { + "name": "parent_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parent_block_id": { + "name": "parent_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_workflow_id": { + "name": "child_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_block_id": { + "name": "child_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_block_map_child_ws_parent_unique": { + "name": "workspace_fork_block_map_child_ws_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_unique": { + "name": "workspace_fork_block_map_child_ws_child_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_parent_wf_idx": { + "name": "workspace_fork_block_map_child_ws_parent_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_block_map_child_ws_child_wf_idx": { + "name": "workspace_fork_block_map_child_ws_child_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "child_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_block_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_block_map", + "tableTo": "workspace", + "columnsFrom": [ + "child_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_dependent_value": { + "name": "workspace_fork_dependent_value", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workflow_id": { + "name": "target_workflow_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_block_id": { + "name": "target_block_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "sub_block_key": { + "name": "sub_block_key", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "value": { + "name": "value", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_dependent_value_child_ws_wf_idx": { + "name": "workspace_fork_dependent_value_child_ws_wf_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_dependent_value_field_unique": { + "name": "workspace_fork_dependent_value_field_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workflow_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_block_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "sub_block_key", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_dependent_value", + "tableTo": "workspace", + "columnsFrom": [ + "child_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_promote_run": { + "name": "workspace_fork_promote_run", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "source_workspace_id": { + "name": "source_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "target_workspace_id": { + "name": "target_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "direction": { + "name": "direction", + "type": "workspace_fork_promote_direction", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "snapshot": { + "name": "snapshot", + "type": "jsonb", + "primaryKey": false, + "notNull": true + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_promote_run_child_ws_target_unique": { + "name": "workspace_fork_promote_run_child_ws_target_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_promote_run_target_ws_idx": { + "name": "workspace_fork_promote_run_target_ws_idx", + "columns": [ + { + "expression": "target_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_promote_run_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "workspace", + "columnsFrom": [ + "child_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_promote_run_created_by_user_id_fk": { + "name": "workspace_fork_promote_run_created_by_user_id_fk", + "tableFrom": "workspace_fork_promote_run", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.workspace_fork_resource_map": { + "name": "workspace_fork_resource_map", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "text", + "primaryKey": true, + "notNull": true + }, + "child_workspace_id": { + "name": "child_workspace_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "resource_type": { + "name": "resource_type", + "type": "workspace_fork_resource_type", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "parent_resource_id": { + "name": "parent_resource_id", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "child_resource_id": { + "name": "child_resource_id", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_by": { + "name": "created_by", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp", + "primaryKey": false, + "notNull": true, + "default": "now()" + } + }, + "indexes": { + "workspace_fork_resource_map_child_ws_idx": { + "name": "workspace_fork_resource_map_child_ws_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_ws_type_idx": { + "name": "workspace_fork_resource_map_child_ws_type_idx", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "workspace_fork_resource_map_child_type_parent_unique": { + "name": "workspace_fork_resource_map_child_type_parent_unique", + "columns": [ + { + "expression": "child_workspace_id", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "resource_type", + "isExpression": false, + "asc": true, + "nulls": "last" + }, + { + "expression": "parent_resource_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": true, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "workspace_fork_resource_map_child_workspace_id_workspace_id_fk": { + "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "workspace", + "columnsFrom": [ + "child_workspace_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "workspace_fork_resource_map_created_by_user_id_fk": { + "name": "workspace_fork_resource_map_created_by_user_id_fk", + "tableFrom": "workspace_fork_resource_map", + "tableTo": "user", + "columnsFrom": [ + "created_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.academy_cert_status": { + "name": "academy_cert_status", + "schema": "public", + "values": [ + "active", + "revoked", + "expired" + ] + }, + "public.background_work_kind": { + "name": "background_work_kind", + "schema": "public", + "values": [ + "deployment_side_effects", + "fork_content_copy", + "fork_sync", + "fork_rollback" + ] + }, + "public.background_work_status_value": { + "name": "background_work_status_value", + "schema": "public", + "values": [ + "pending", + "processing", + "completed", + "completed_with_warnings", + "failed" + ] + }, + "public.billing_blocked_reason": { + "name": "billing_blocked_reason", + "schema": "public", + "values": [ + "payment_failed", + "dispute" + ] + }, + "public.billing_entity_type": { + "name": "billing_entity_type", + "schema": "public", + "values": [ + "user", + "organization" + ] + }, + "public.chat_type": { + "name": "chat_type", + "schema": "public", + "values": [ + "mothership", + "copilot" + ] + }, + "public.copilot_async_tool_status": { + "name": "copilot_async_tool_status", + "schema": "public", + "values": [ + "pending", + "running", + "completed", + "failed", + "cancelled", + "delivered" + ] + }, + "public.copilot_run_status": { + "name": "copilot_run_status", + "schema": "public", + "values": [ + "active", + "paused_waiting_for_tool", + "resuming", + "complete", + "error", + "cancelled" + ] + }, + "public.credential_member_role": { + "name": "credential_member_role", + "schema": "public", + "values": [ + "admin", + "member" + ] + }, + "public.credential_member_status": { + "name": "credential_member_status", + "schema": "public", + "values": [ + "active", + "pending", + "revoked" + ] + }, + "public.credential_type": { + "name": "credential_type", + "schema": "public", + "values": [ + "oauth", + "env_workspace", + "env_personal", + "service_account" + ] + }, + "public.data_drain_cadence": { + "name": "data_drain_cadence", + "schema": "public", + "values": [ + "hourly", + "daily" + ] + }, + "public.data_drain_destination": { + "name": "data_drain_destination", + "schema": "public", + "values": [ + "s3", + "gcs", + "azure_blob", + "datadog", + "bigquery", + "snowflake", + "webhook" + ] + }, + "public.data_drain_run_status": { + "name": "data_drain_run_status", + "schema": "public", + "values": [ + "running", + "success", + "failed" + ] + }, + "public.data_drain_run_trigger": { + "name": "data_drain_run_trigger", + "schema": "public", + "values": [ + "cron", + "manual" + ] + }, + "public.data_drain_source": { + "name": "data_drain_source", + "schema": "public", + "values": [ + "workflow_logs", + "job_logs", + "audit_logs", + "copilot_chats", + "copilot_runs" + ] + }, + "public.execution_large_value_reference_source": { + "name": "execution_large_value_reference_source", + "schema": "public", + "values": [ + "execution_log", + "paused_snapshot" + ] + }, + "public.invitation_kind": { + "name": "invitation_kind", + "schema": "public", + "values": [ + "organization", + "workspace" + ] + }, + "public.invitation_membership_intent": { + "name": "invitation_membership_intent", + "schema": "public", + "values": [ + "internal", + "external" + ] + }, + "public.invitation_status": { + "name": "invitation_status", + "schema": "public", + "values": [ + "pending", + "accepted", + "rejected", + "cancelled", + "expired" + ] + }, + "public.permission_type": { + "name": "permission_type", + "schema": "public", + "values": [ + "admin", + "write", + "read" + ] + }, + "public.usage_log_category": { + "name": "usage_log_category", + "schema": "public", + "values": [ + "model", + "fixed", + "tool" + ] + }, + "public.usage_log_source": { + "name": "usage_log_source", + "schema": "public", + "values": [ + "workflow", + "wand", + "copilot", + "workspace-chat", + "mcp_copilot", + "mothership_block", + "knowledge-base", + "voice-input", + "enrichment" + ] + }, + "public.workspace_fork_promote_direction": { + "name": "workspace_fork_promote_direction", + "schema": "public", + "values": [ + "push", + "pull" + ] + }, + "public.workspace_fork_resource_type": { + "name": "workspace_fork_resource_type", + "schema": "public", + "values": [ + "workflow", + "oauth_credential", + "service_account_credential", + "env_var", + "table", + "knowledge_base", + "knowledge_document", + "file", + "mcp_server", + "workflow_mcp_server", + "custom_tool", + "skill" + ] + }, + "public.workspace_mode": { + "name": "workspace_mode", + "schema": "public", + "values": [ + "personal", + "organization", + "grandfathered_shared" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/db/migrations/meta/_journal.json b/packages/db/migrations/meta/_journal.json index 1cad973fa4f..b7dc44c1642 100644 --- a/packages/db/migrations/meta/_journal.json +++ b/packages/db/migrations/meta/_journal.json @@ -1821,6 +1821,13 @@ "when": 1783810442774, "tag": "0260_unknown_sinister_six", "breakpoints": true + }, + { + "idx": 261, + "version": "7", + "when": 1783900000000, + "tag": "0261_chat_include_thinking", + "breakpoints": true } ] } diff --git a/packages/db/schema.ts b/packages/db/schema.ts index 089cdf4c997..f71689e637b 100644 --- a/packages/db/schema.ts +++ b/packages/db/schema.ts @@ -1082,6 +1082,13 @@ export const chat = pgTable( // Output configuration outputConfigs: json('output_configs').default('[]'), // Array of {blockId, path} objects + /** + * When true, public chat SSE may expose provider thinking/tool events if the + * client also opts in via `X-Sim-Stream-Protocol: agent-events-v1`. + * Default off — never derived from auth type or isSecureMode. + */ + includeThinking: boolean('include_thinking').notNull().default(false), + archivedAt: timestamp('archived_at'), createdAt: timestamp('created_at').notNull().defaultNow(), updatedAt: timestamp('updated_at').notNull().defaultNow(), diff --git a/packages/testing/src/mocks/schema.mock.ts b/packages/testing/src/mocks/schema.mock.ts index f3470bf55a5..9257ac6f480 100644 --- a/packages/testing/src/mocks/schema.mock.ts +++ b/packages/testing/src/mocks/schema.mock.ts @@ -428,6 +428,7 @@ export const schemaMock = { password: 'password', allowedEmails: 'allowedEmails', outputConfigs: 'outputConfigs', + includeThinking: 'includeThinking', archivedAt: 'archivedAt', createdAt: 'createdAt', updatedAt: 'updatedAt', From a241ec198abc63a028e9f74303d4aea8c1a9a307 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Mon, 13 Jul 2026 20:36:32 -0700 Subject: [PATCH 02/13] fix(agent-stream): clear stuck streaming UI and format db snapshot Biome was failing CI on migrations/meta/0261_snapshot.json. Also settle assistant streaming/tool flags when SSE ends without a terminal frame, without clobbering Stop's finalized content. Co-authored-by: Cursor --- .../chat/hooks/use-chat-streaming.test.tsx | 41 + .../chat/hooks/use-chat-streaming.ts | 39 +- .../db/migrations/meta/0261_snapshot.json | 1598 ++++------------- 3 files changed, 463 insertions(+), 1215 deletions(-) diff --git a/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.test.tsx b/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.test.tsx index 997ed22b654..3c7807ea77a 100644 --- a/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.test.tsx +++ b/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.test.tsx @@ -196,6 +196,47 @@ describe('useChatStreaming thinking + abort (Step 6)', () => { expect(assistant?.isStreaming).toBe(false) }) + it('clears streaming flags when SSE ends without a terminal final/error frame', async () => { + mockReadSSEEvents.mockImplementation(async (_source, options) => { + await options.onEvent({ + blockId: 'agent-1', + event: 'thinking', + data: 'Halfway…', + }) + await options.onEvent({ + blockId: 'agent-1', + chunk: 'Partial answer', + }) + await options.onEvent({ + blockId: 'agent-1', + event: 'tool', + phase: 'start', + id: 't1', + name: 'search', + }) + // Stream closes abruptly — no final or error event. + }) + + await act(async () => { + await handle.latest().handleStreamedResponse( + makeSseResponse(), + setMessages, + vi.fn(), + vi.fn(), + false + ) + }) + await flushUiBatch() + + const assistant = messages.find((m) => m.id === 'msg-assistant-1') + expect(assistant?.content).toBe('Partial answer') + expect(assistant?.thinking).toBe('Halfway…') + expect(assistant?.isStreaming).toBe(false) + expect(assistant?.isThinkingStreaming).toBe(false) + expect(assistant?.isToolStreaming).toBe(false) + expect(assistant?.toolCalls?.some((t) => t.status === 'error')).toBe(true) + }) + it('does not append thinking payload into answer when mislabeled as chunk', async () => { mockReadSSEEvents.mockImplementation(async (_source, options) => { await options.onEvent({ diff --git a/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts b/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts index 312586be90a..05e0e3bbd63 100644 --- a/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts +++ b/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts @@ -308,10 +308,13 @@ export function useChatStreaming() { setIsLoading(false) let terminated = false + // Capture before Stop nulls abortControllerRef; needed when the reader + // resolves on abort instead of throwing AbortError. + const streamAbortSignal = abortControllerRef.current!.signal try { await readSSEEvents(response.body, { - signal: abortControllerRef.current!.signal, + signal: streamAbortSignal, onParseError: (_data, parseError) => { logger.error('Error parsing stream data:', parseError) }, @@ -630,7 +633,41 @@ export function useChatStreaming() { if (!terminated) { flushUI() + // Stream closed without a terminal final/error frame (abrupt disconnect, + // or only non-terminal stream_error). Clear live chrome so the UI does not + // stay stuck in a streaming/loading state. + const wasAborted = streamAbortSignal.aborted + settleInFlightTools(toolCallsMap, wasAborted ? 'cancelled' : 'error') + syncToolCallsRef() + isThinkingStreaming = false + const toolsSnapshot = snapshotToolCalls(toolCallOrder, toolCallsMap) + setMessages((prev) => + prev.map((msg) => { + if (msg.id !== messageId) return msg + // stopStreaming already wrote the stop notice into content; do not clobber it. + if (wasAborted) { + return { + ...msg, + isStreaming: false, + isThinkingStreaming: false, + isToolStreaming: false, + thinking: accumulatedThinking || msg.thinking, + toolCalls: toolsSnapshot ?? msg.toolCalls, + } + } + return { + ...msg, + isStreaming: false, + isThinkingStreaming: false, + isToolStreaming: false, + content: accumulatedText || msg.content, + thinking: accumulatedThinking || msg.thinking, + toolCalls: toolsSnapshot ?? msg.toolCalls, + } + }) + ) if ( + !wasAborted && shouldPlayAudio && streamingOptions?.audioStreamHandler && accumulatedText.length > lastAudioPosition diff --git a/packages/db/migrations/meta/0261_snapshot.json b/packages/db/migrations/meta/0261_snapshot.json index c9534b9e70d..499afb0848f 100644 --- a/packages/db/migrations/meta/0261_snapshot.json +++ b/packages/db/migrations/meta/0261_snapshot.json @@ -155,12 +155,8 @@ "name": "academy_certificate_user_id_user_id_fk", "tableFrom": "academy_certificate", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -170,9 +166,7 @@ "academy_certificate_certificate_number_unique": { "name": "academy_certificate_certificate_number_unique", "nullsNotDistinct": false, - "columns": [ - "certificate_number" - ] + "columns": ["certificate_number"] } }, "policies": {}, @@ -305,12 +299,8 @@ "name": "account_user_id_user_id_fk", "tableFrom": "account", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -465,12 +455,8 @@ "name": "api_key_user_id_user_id_fk", "tableFrom": "api_key", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -478,12 +464,8 @@ "name": "api_key_workspace_id_workspace_id_fk", "tableFrom": "api_key", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -491,12 +473,8 @@ "name": "api_key_created_by_user_id_fk", "tableFrom": "api_key", "tableTo": "user", - "columnsFrom": [ - "created_by" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["created_by"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -506,9 +484,7 @@ "api_key_key_unique": { "name": "api_key_key_unique", "nullsNotDistinct": false, - "columns": [ - "key" - ] + "columns": ["key"] } }, "policies": {}, @@ -919,12 +895,8 @@ "name": "audit_log_workspace_id_workspace_id_fk", "tableFrom": "audit_log", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, @@ -932,12 +904,8 @@ "name": "audit_log_actor_id_user_id_fk", "tableFrom": "audit_log", "tableTo": "user", - "columnsFrom": [ - "actor_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -1102,12 +1070,8 @@ "name": "background_work_status_workspace_id_workspace_id_fk", "tableFrom": "background_work_status", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1115,12 +1079,8 @@ "name": "background_work_status_workflow_id_workflow_id_fk", "tableFrom": "background_work_status", "tableTo": "workflow", - "columnsFrom": [ - "workflow_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1300,12 +1260,8 @@ "name": "chat_workflow_id_workflow_id_fk", "tableFrom": "chat", "tableTo": "workflow", - "columnsFrom": [ - "workflow_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1313,12 +1269,8 @@ "name": "chat_user_id_user_id_fk", "tableFrom": "chat", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1527,12 +1479,8 @@ "name": "copilot_async_tool_calls_run_id_copilot_runs_id_fk", "tableFrom": "copilot_async_tool_calls", "tableTo": "copilot_runs", - "columnsFrom": [ - "run_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["run_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1540,12 +1488,8 @@ "name": "copilot_async_tool_calls_checkpoint_id_copilot_run_checkpoints_id_fk", "tableFrom": "copilot_async_tool_calls", "tableTo": "copilot_run_checkpoints", - "columnsFrom": [ - "checkpoint_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["checkpoint_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1801,12 +1745,8 @@ "name": "copilot_chats_user_id_user_id_fk", "tableFrom": "copilot_chats", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1814,12 +1754,8 @@ "name": "copilot_chats_workflow_id_workflow_id_fk", "tableFrom": "copilot_chats", "tableTo": "workflow", - "columnsFrom": [ - "workflow_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -1827,12 +1763,8 @@ "name": "copilot_chats_workspace_id_workspace_id_fk", "tableFrom": "copilot_chats", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -1999,12 +1931,8 @@ "name": "copilot_feedback_user_id_user_id_fk", "tableFrom": "copilot_feedback", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -2012,12 +1940,8 @@ "name": "copilot_feedback_chat_id_copilot_chats_id_fk", "tableFrom": "copilot_feedback", "tableTo": "copilot_chats", - "columnsFrom": [ - "chat_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2248,12 +2172,8 @@ "name": "copilot_messages_chat_id_copilot_chats_id_fk", "tableFrom": "copilot_messages", "tableTo": "copilot_chats", - "columnsFrom": [ - "chat_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2381,12 +2301,8 @@ "name": "copilot_run_checkpoints_run_id_copilot_runs_id_fk", "tableFrom": "copilot_run_checkpoints", "tableTo": "copilot_runs", - "columnsFrom": [ - "run_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["run_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2713,12 +2629,8 @@ "name": "copilot_runs_chat_id_copilot_chats_id_fk", "tableFrom": "copilot_runs", "tableTo": "copilot_chats", - "columnsFrom": [ - "chat_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -2726,12 +2638,8 @@ "name": "copilot_runs_user_id_user_id_fk", "tableFrom": "copilot_runs", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -2739,12 +2647,8 @@ "name": "copilot_runs_workflow_id_workflow_id_fk", "tableFrom": "copilot_runs", "tableTo": "workflow", - "columnsFrom": [ - "workflow_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -2752,12 +2656,8 @@ "name": "copilot_runs_workspace_id_workspace_id_fk", "tableFrom": "copilot_runs", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -2870,12 +2770,8 @@ "name": "copilot_workflow_read_hashes_chat_id_copilot_chats_id_fk", "tableFrom": "copilot_workflow_read_hashes", "tableTo": "copilot_chats", - "columnsFrom": [ - "chat_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -2883,12 +2779,8 @@ "name": "copilot_workflow_read_hashes_workflow_id_workflow_id_fk", "tableFrom": "copilot_workflow_read_hashes", "tableTo": "workflow", - "columnsFrom": [ - "workflow_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -3151,12 +3043,8 @@ "name": "credential_workspace_id_workspace_id_fk", "tableFrom": "credential", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -3164,12 +3052,8 @@ "name": "credential_account_id_account_id_fk", "tableFrom": "credential", "tableTo": "account", - "columnsFrom": [ - "account_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["account_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -3177,12 +3061,8 @@ "name": "credential_env_owner_user_id_user_id_fk", "tableFrom": "credential", "tableTo": "user", - "columnsFrom": [ - "env_owner_user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["env_owner_user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -3190,12 +3070,8 @@ "name": "credential_created_by_user_id_fk", "tableFrom": "credential", "tableTo": "user", - "columnsFrom": [ - "created_by" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["created_by"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -3357,12 +3233,8 @@ "name": "credential_member_credential_id_credential_id_fk", "tableFrom": "credential_member", "tableTo": "credential", - "columnsFrom": [ - "credential_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -3370,12 +3242,8 @@ "name": "credential_member_user_id_user_id_fk", "tableFrom": "credential_member", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -3383,12 +3251,8 @@ "name": "credential_member_invited_by_user_id_fk", "tableFrom": "credential_member", "tableTo": "user", - "columnsFrom": [ - "invited_by" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["invited_by"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -3544,12 +3408,8 @@ "name": "custom_block_organization_id_organization_id_fk", "tableFrom": "custom_block", "tableTo": "organization", - "columnsFrom": [ - "organization_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -3557,12 +3417,8 @@ "name": "custom_block_workflow_id_workflow_id_fk", "tableFrom": "custom_block", "tableTo": "workflow", - "columnsFrom": [ - "workflow_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -3570,12 +3426,8 @@ "name": "custom_block_created_by_user_id_fk", "tableFrom": "custom_block", "tableTo": "user", - "columnsFrom": [ - "created_by" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["created_by"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -3684,12 +3536,8 @@ "name": "custom_tools_workspace_id_workspace_id_fk", "tableFrom": "custom_tools", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -3697,12 +3545,8 @@ "name": "custom_tools_user_id_user_id_fk", "tableFrom": "custom_tools", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -3824,12 +3668,8 @@ "name": "data_drain_runs_drain_id_data_drains_id_fk", "tableFrom": "data_drain_runs", "tableTo": "data_drains", - "columnsFrom": [ - "drain_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["drain_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -4005,12 +3845,8 @@ "name": "data_drains_organization_id_organization_id_fk", "tableFrom": "data_drains", "tableTo": "organization", - "columnsFrom": [ - "organization_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -4018,12 +3854,8 @@ "name": "data_drains_created_by_user_id_fk", "tableFrom": "data_drains", "tableTo": "user", - "columnsFrom": [ - "created_by" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["created_by"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" } @@ -4930,12 +4762,8 @@ "name": "document_knowledge_base_id_knowledge_base_id_fk", "tableFrom": "document", "tableTo": "knowledge_base", - "columnsFrom": [ - "knowledge_base_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -4943,12 +4771,8 @@ "name": "document_connector_id_knowledge_connector_id_fk", "tableFrom": "document", "tableTo": "knowledge_connector", - "columnsFrom": [ - "connector_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, @@ -4956,12 +4780,8 @@ "name": "document_uploaded_by_user_id_fk", "tableFrom": "document", "tableTo": "user", - "columnsFrom": [ - "uploaded_by" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -5593,12 +5413,8 @@ "name": "embedding_knowledge_base_id_knowledge_base_id_fk", "tableFrom": "embedding", "tableTo": "knowledge_base", - "columnsFrom": [ - "knowledge_base_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -5606,12 +5422,8 @@ "name": "embedding_document_id_document_id_fk", "tableFrom": "embedding", "tableTo": "document", - "columnsFrom": [ - "document_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["document_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -5663,12 +5475,8 @@ "name": "environment_user_id_user_id_fk", "tableFrom": "environment", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -5678,9 +5486,7 @@ "environment_user_id_unique": { "name": "environment_user_id_unique", "nullsNotDistinct": false, - "columns": [ - "user_id" - ] + "columns": ["user_id"] } }, "policies": {}, @@ -5766,12 +5572,8 @@ "name": "execution_large_value_dependencies_workspace_id_workspace_id_fk", "tableFrom": "execution_large_value_dependencies", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -5779,10 +5581,7 @@ "compositePrimaryKeys": { "execution_large_value_dependencies_parent_key_child_key_pk": { "name": "execution_large_value_dependencies_parent_key_child_key_pk", - "columns": [ - "parent_key", - "child_key" - ] + "columns": ["parent_key", "child_key"] } }, "uniqueConstraints": {}, @@ -5867,12 +5666,8 @@ "name": "execution_large_value_references_workspace_id_workspace_id_fk", "tableFrom": "execution_large_value_references", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -5880,12 +5675,8 @@ "name": "execution_large_value_references_workflow_id_workflow_id_fk", "tableFrom": "execution_large_value_references", "tableTo": "workflow", - "columnsFrom": [ - "workflow_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -5893,11 +5684,7 @@ "compositePrimaryKeys": { "execution_large_value_references_key_execution_id_source_pk": { "name": "execution_large_value_references_key_execution_id_source_pk", - "columns": [ - "key", - "execution_id", - "source" - ] + "columns": ["key", "execution_id", "source"] } }, "uniqueConstraints": {}, @@ -6031,12 +5818,8 @@ "name": "execution_large_values_workspace_id_workspace_id_fk", "tableFrom": "execution_large_values", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -6044,12 +5827,8 @@ "name": "execution_large_values_workflow_id_workflow_id_fk", "tableFrom": "execution_large_values", "tableTo": "workflow", - "columnsFrom": [ - "workflow_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -6267,12 +6046,8 @@ "name": "invitation_inviter_id_user_id_fk", "tableFrom": "invitation", "tableTo": "user", - "columnsFrom": [ - "inviter_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["inviter_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -6280,12 +6055,8 @@ "name": "invitation_organization_id_organization_id_fk", "tableFrom": "invitation", "tableTo": "organization", - "columnsFrom": [ - "organization_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -6295,9 +6066,7 @@ "invitation_token_unique": { "name": "invitation_token_unique", "nullsNotDistinct": false, - "columns": [ - "token" - ] + "columns": ["token"] } }, "policies": {}, @@ -6391,12 +6160,8 @@ "name": "invitation_workspace_grant_invitation_id_invitation_id_fk", "tableFrom": "invitation_workspace_grant", "tableTo": "invitation", - "columnsFrom": [ - "invitation_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["invitation_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -6404,12 +6169,8 @@ "name": "invitation_workspace_grant_workspace_id_workspace_id_fk", "tableFrom": "invitation_workspace_grant", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -6606,12 +6367,8 @@ "name": "job_execution_logs_schedule_id_workflow_schedule_id_fk", "tableFrom": "job_execution_logs", "tableTo": "workflow_schedule", - "columnsFrom": [ - "schedule_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["schedule_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, @@ -6619,12 +6376,8 @@ "name": "job_execution_logs_workspace_id_workspace_id_fk", "tableFrom": "job_execution_logs", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -6835,12 +6588,8 @@ "name": "knowledge_base_user_id_user_id_fk", "tableFrom": "knowledge_base", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -6848,12 +6597,8 @@ "name": "knowledge_base_workspace_id_workspace_id_fk", "tableFrom": "knowledge_base", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -6978,12 +6723,8 @@ "name": "knowledge_base_tag_definitions_knowledge_base_id_knowledge_base_id_fk", "tableFrom": "knowledge_base_tag_definitions", "tableTo": "knowledge_base", - "columnsFrom": [ - "knowledge_base_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -7188,12 +6929,8 @@ "name": "knowledge_connector_knowledge_base_id_knowledge_base_id_fk", "tableFrom": "knowledge_connector", "tableTo": "knowledge_base", - "columnsFrom": [ - "knowledge_base_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["knowledge_base_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -7303,12 +7040,8 @@ "name": "knowledge_connector_sync_log_connector_id_knowledge_connector_id_fk", "tableFrom": "knowledge_connector_sync_log", "tableTo": "knowledge_connector", - "columnsFrom": [ - "connector_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["connector_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -7435,12 +7168,8 @@ "name": "mcp_server_oauth_mcp_server_id_mcp_servers_id_fk", "tableFrom": "mcp_server_oauth", "tableTo": "mcp_servers", - "columnsFrom": [ - "mcp_server_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["mcp_server_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -7448,12 +7177,8 @@ "name": "mcp_server_oauth_user_id_user_id_fk", "tableFrom": "mcp_server_oauth", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, @@ -7461,12 +7186,8 @@ "name": "mcp_server_oauth_workspace_id_workspace_id_fk", "tableFrom": "mcp_server_oauth", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -7693,12 +7414,8 @@ "name": "mcp_servers_workspace_id_workspace_id_fk", "tableFrom": "mcp_servers", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -7706,12 +7423,8 @@ "name": "mcp_servers_created_by_user_id_fk", "tableFrom": "mcp_servers", "tableTo": "user", - "columnsFrom": [ - "created_by" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["created_by"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -7795,12 +7508,8 @@ "name": "member_user_id_user_id_fk", "tableFrom": "member", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -7808,12 +7517,8 @@ "name": "member_organization_id_organization_id_fk", "tableFrom": "member", "tableTo": "organization", - "columnsFrom": [ - "organization_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -7953,12 +7658,8 @@ "name": "memory_workspace_id_workspace_id_fk", "tableFrom": "memory", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -8039,12 +7740,8 @@ "name": "mothership_inbox_allowed_sender_workspace_id_workspace_id_fk", "tableFrom": "mothership_inbox_allowed_sender", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -8052,12 +7749,8 @@ "name": "mothership_inbox_allowed_sender_added_by_user_id_fk", "tableFrom": "mothership_inbox_allowed_sender", "tableTo": "user", - "columnsFrom": [ - "added_by" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["added_by"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -8293,12 +7986,8 @@ "name": "mothership_inbox_task_workspace_id_workspace_id_fk", "tableFrom": "mothership_inbox_task", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -8306,12 +7995,8 @@ "name": "mothership_inbox_task_chat_id_copilot_chats_id_fk", "tableFrom": "mothership_inbox_task", "tableTo": "copilot_chats", - "columnsFrom": [ - "chat_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -8364,12 +8049,8 @@ "name": "mothership_inbox_webhook_workspace_id_workspace_id_fk", "tableFrom": "mothership_inbox_webhook", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -8379,9 +8060,7 @@ "mothership_inbox_webhook_workspace_id_unique": { "name": "mothership_inbox_webhook_workspace_id_unique", "nullsNotDistinct": false, - "columns": [ - "workspace_id" - ] + "columns": ["workspace_id"] } }, "policies": {}, @@ -8456,12 +8135,8 @@ "name": "mothership_settings_workspace_id_workspace_id_fk", "tableFrom": "mothership_settings", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -8667,12 +8342,8 @@ "name": "organization_member_usage_limit_organization_id_organization_id_fk", "tableFrom": "organization_member_usage_limit", "tableTo": "organization", - "columnsFrom": [ - "organization_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -8680,12 +8351,8 @@ "name": "organization_member_usage_limit_user_id_user_id_fk", "tableFrom": "organization_member_usage_limit", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -8693,12 +8360,8 @@ "name": "organization_member_usage_limit_set_by_user_id_fk", "tableFrom": "organization_member_usage_limit", "tableTo": "user", - "columnsFrom": [ - "set_by" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["set_by"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -9014,12 +8677,8 @@ "name": "paused_executions_workflow_id_workflow_id_fk", "tableFrom": "paused_executions", "tableTo": "workflow", - "columnsFrom": [ - "workflow_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -9124,12 +8783,8 @@ "name": "pending_credential_draft_user_id_user_id_fk", "tableFrom": "pending_credential_draft", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -9137,12 +8792,8 @@ "name": "pending_credential_draft_workspace_id_workspace_id_fk", "tableFrom": "pending_credential_draft", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -9150,12 +8801,8 @@ "name": "pending_credential_draft_credential_id_credential_id_fk", "tableFrom": "pending_credential_draft", "tableTo": "credential", - "columnsFrom": [ - "credential_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["credential_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -9288,12 +8935,8 @@ "name": "permission_group_organization_id_organization_id_fk", "tableFrom": "permission_group", "tableTo": "organization", - "columnsFrom": [ - "organization_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -9301,12 +8944,8 @@ "name": "permission_group_created_by_user_id_fk", "tableFrom": "permission_group", "tableTo": "user", - "columnsFrom": [ - "created_by" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["created_by"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -9423,12 +9062,8 @@ "name": "permission_group_member_permission_group_id_permission_group_id_fk", "tableFrom": "permission_group_member", "tableTo": "permission_group", - "columnsFrom": [ - "permission_group_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -9436,12 +9071,8 @@ "name": "permission_group_member_organization_id_organization_id_fk", "tableFrom": "permission_group_member", "tableTo": "organization", - "columnsFrom": [ - "organization_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -9449,12 +9080,8 @@ "name": "permission_group_member_user_id_user_id_fk", "tableFrom": "permission_group_member", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -9462,12 +9089,8 @@ "name": "permission_group_member_assigned_by_user_id_fk", "tableFrom": "permission_group_member", "tableTo": "user", - "columnsFrom": [ - "assigned_by" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["assigned_by"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -9557,12 +9180,8 @@ "name": "permission_group_workspace_permission_group_id_permission_group_id_fk", "tableFrom": "permission_group_workspace", "tableTo": "permission_group", - "columnsFrom": [ - "permission_group_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["permission_group_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -9570,12 +9189,8 @@ "name": "permission_group_workspace_workspace_id_workspace_id_fk", "tableFrom": "permission_group_workspace", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -9583,12 +9198,8 @@ "name": "permission_group_workspace_organization_id_organization_id_fk", "tableFrom": "permission_group_workspace", "tableTo": "organization", - "columnsFrom": [ - "organization_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -9794,12 +9405,8 @@ "name": "permissions_user_id_user_id_fk", "tableFrom": "permissions", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -9965,12 +9572,8 @@ "name": "public_share_workspace_id_workspace_id_fk", "tableFrom": "public_share", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -9978,12 +9581,8 @@ "name": "public_share_created_by_user_id_fk", "tableFrom": "public_share", "tableTo": "user", - "columnsFrom": [ - "created_by" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["created_by"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -10154,12 +9753,8 @@ "name": "resume_queue_paused_execution_id_paused_executions_id_fk", "tableFrom": "resume_queue", "tableTo": "paused_executions", - "columnsFrom": [ - "paused_execution_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["paused_execution_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -10272,12 +9867,8 @@ "name": "session_user_id_user_id_fk", "tableFrom": "session", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -10285,12 +9876,8 @@ "name": "session_active_organization_id_organization_id_fk", "tableFrom": "session", "tableTo": "organization", - "columnsFrom": [ - "active_organization_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["active_organization_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -10300,9 +9887,7 @@ "session_token_unique": { "name": "session_token_unique", "nullsNotDistinct": false, - "columns": [ - "token" - ] + "columns": ["token"] } }, "policies": {}, @@ -10442,12 +10027,8 @@ "name": "settings_user_id_user_id_fk", "tableFrom": "settings", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -10457,9 +10038,7 @@ "settings_user_id_unique": { "name": "settings_user_id_unique", "nullsNotDistinct": false, - "columns": [ - "user_id" - ] + "columns": ["user_id"] } }, "policies": {}, @@ -10509,12 +10088,8 @@ "name": "sim_trigger_state_workflow_id_workflow_id_fk", "tableFrom": "sim_trigger_state", "tableTo": "workflow", - "columnsFrom": [ - "workflow_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -10522,11 +10097,7 @@ "compositePrimaryKeys": { "sim_trigger_state_workflow_id_block_id_scope_key_pk": { "name": "sim_trigger_state_workflow_id_block_id_scope_key_pk", - "columns": [ - "workflow_id", - "block_id", - "scope_key" - ] + "columns": ["workflow_id", "block_id", "scope_key"] } }, "uniqueConstraints": {}, @@ -10617,12 +10188,8 @@ "name": "skill_workspace_id_workspace_id_fk", "tableFrom": "skill", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -10630,12 +10197,8 @@ "name": "skill_user_id_user_id_fk", "tableFrom": "skill", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -10766,12 +10329,8 @@ "name": "sso_provider_user_id_user_id_fk", "tableFrom": "sso_provider", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -10779,12 +10338,8 @@ "name": "sso_provider_organization_id_organization_id_fk", "tableFrom": "sso_provider", "tableTo": "organization", - "columnsFrom": [ - "organization_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -11083,12 +10638,8 @@ "name": "table_jobs_table_id_user_table_definitions_id_fk", "tableFrom": "table_jobs", "tableTo": "user_table_definitions", - "columnsFrom": [ - "table_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["table_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -11096,12 +10647,8 @@ "name": "table_jobs_workspace_id_workspace_id_fk", "tableFrom": "table_jobs", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -11264,12 +10811,8 @@ "name": "table_row_executions_table_id_user_table_definitions_id_fk", "tableFrom": "table_row_executions", "tableTo": "user_table_definitions", - "columnsFrom": [ - "table_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["table_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -11277,12 +10820,8 @@ "name": "table_row_executions_row_id_user_table_rows_id_fk", "tableFrom": "table_row_executions", "tableTo": "user_table_rows", - "columnsFrom": [ - "row_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["row_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -11290,10 +10829,7 @@ "compositePrimaryKeys": { "table_row_executions_row_id_group_id_pk": { "name": "table_row_executions_row_id_group_id_pk", - "columns": [ - "row_id", - "group_id" - ] + "columns": ["row_id", "group_id"] } }, "uniqueConstraints": {}, @@ -11450,12 +10986,8 @@ "name": "table_run_dispatches_table_id_user_table_definitions_id_fk", "tableFrom": "table_run_dispatches", "tableTo": "user_table_definitions", - "columnsFrom": [ - "table_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["table_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -11463,12 +10995,8 @@ "name": "table_run_dispatches_workspace_id_workspace_id_fk", "tableFrom": "table_run_dispatches", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -11476,12 +11004,8 @@ "name": "table_run_dispatches_triggered_by_user_id_user_id_fk", "tableFrom": "table_run_dispatches", "tableTo": "user", - "columnsFrom": [ - "triggered_by_user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["triggered_by_user_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -11756,12 +11280,8 @@ "name": "usage_log_user_id_user_id_fk", "tableFrom": "usage_log", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -11769,12 +11289,8 @@ "name": "usage_log_workspace_id_workspace_id_fk", "tableFrom": "usage_log", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, @@ -11782,12 +11298,8 @@ "name": "usage_log_workflow_id_workflow_id_fk", "tableFrom": "usage_log", "tableTo": "workflow", - "columnsFrom": [ - "workflow_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -11895,16 +11407,12 @@ "user_email_unique": { "name": "user_email_unique", "nullsNotDistinct": false, - "columns": [ - "email" - ] + "columns": ["email"] }, "user_normalized_email_unique": { "name": "user_normalized_email_unique", "nullsNotDistinct": false, - "columns": [ - "normalized_email" - ] + "columns": ["normalized_email"] } }, "policies": {}, @@ -12136,12 +11644,8 @@ "name": "user_stats_user_id_user_id_fk", "tableFrom": "user_stats", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -12151,9 +11655,7 @@ "user_stats_user_id_unique": { "name": "user_stats_user_id_unique", "nullsNotDistinct": false, - "columns": [ - "user_id" - ] + "columns": ["user_id"] } }, "policies": {}, @@ -12329,12 +11831,8 @@ "name": "user_table_definitions_workspace_id_workspace_id_fk", "tableFrom": "user_table_definitions", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -12342,12 +11840,8 @@ "name": "user_table_definitions_created_by_user_id_fk", "tableFrom": "user_table_definitions", "tableTo": "user", - "columnsFrom": [ - "created_by" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["created_by"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -12538,12 +12032,8 @@ "name": "user_table_rows_table_id_user_table_definitions_id_fk", "tableFrom": "user_table_rows", "tableTo": "user_table_definitions", - "columnsFrom": [ - "table_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["table_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -12551,12 +12041,8 @@ "name": "user_table_rows_workspace_id_workspace_id_fk", "tableFrom": "user_table_rows", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -12564,12 +12050,8 @@ "name": "user_table_rows_created_by_user_id_fk", "tableFrom": "user_table_rows", "tableTo": "user", - "columnsFrom": [ - "created_by" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["created_by"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -12705,9 +12187,7 @@ "waitlist_email_unique": { "name": "waitlist_email_unique", "nullsNotDistinct": false, - "columns": [ - "email" - ] + "columns": ["email"] } }, "policies": {}, @@ -12971,12 +12451,8 @@ "name": "webhook_workflow_id_workflow_id_fk", "tableFrom": "webhook", "tableTo": "workflow", - "columnsFrom": [ - "workflow_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -12984,12 +12460,8 @@ "name": "webhook_deployment_version_id_workflow_deployment_version_id_fk", "tableFrom": "webhook", "tableTo": "workflow_deployment_version", - "columnsFrom": [ - "deployment_version_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -13263,12 +12735,8 @@ "name": "workflow_user_id_user_id_fk", "tableFrom": "workflow", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -13276,12 +12744,8 @@ "name": "workflow_workspace_id_workspace_id_fk", "tableFrom": "workflow", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -13289,12 +12753,8 @@ "name": "workflow_folder_id_workflow_folder_id_fk", "tableFrom": "workflow", "tableTo": "workflow_folder", - "columnsFrom": [ - "folder_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -13467,12 +12927,8 @@ "name": "workflow_blocks_workflow_id_workflow_id_fk", "tableFrom": "workflow_blocks", "tableTo": "workflow", - "columnsFrom": [ - "workflow_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -13684,12 +13140,8 @@ "name": "workflow_checkpoints_user_id_user_id_fk", "tableFrom": "workflow_checkpoints", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -13697,12 +13149,8 @@ "name": "workflow_checkpoints_workflow_id_workflow_id_fk", "tableFrom": "workflow_checkpoints", "tableTo": "workflow", - "columnsFrom": [ - "workflow_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -13710,12 +13158,8 @@ "name": "workflow_checkpoints_chat_id_copilot_chats_id_fk", "tableFrom": "workflow_checkpoints", "tableTo": "copilot_chats", - "columnsFrom": [ - "chat_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -13851,12 +13295,8 @@ "name": "workflow_deployment_version_workflow_id_workflow_id_fk", "tableFrom": "workflow_deployment_version", "tableTo": "workflow", - "columnsFrom": [ - "workflow_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -13979,12 +13419,8 @@ "name": "workflow_edges_workflow_id_workflow_id_fk", "tableFrom": "workflow_edges", "tableTo": "workflow", - "columnsFrom": [ - "workflow_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -13992,12 +13428,8 @@ "name": "workflow_edges_source_block_id_workflow_blocks_id_fk", "tableFrom": "workflow_edges", "tableTo": "workflow_blocks", - "columnsFrom": [ - "source_block_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["source_block_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -14005,12 +13437,8 @@ "name": "workflow_edges_target_block_id_workflow_blocks_id_fk", "tableFrom": "workflow_edges", "tableTo": "workflow_blocks", - "columnsFrom": [ - "target_block_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["target_block_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -14425,12 +13853,8 @@ "name": "workflow_execution_logs_workflow_id_workflow_id_fk", "tableFrom": "workflow_execution_logs", "tableTo": "workflow", - "columnsFrom": [ - "workflow_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, @@ -14438,12 +13862,8 @@ "name": "workflow_execution_logs_workspace_id_workspace_id_fk", "tableFrom": "workflow_execution_logs", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -14451,12 +13871,8 @@ "name": "workflow_execution_logs_state_snapshot_id_workflow_execution_snapshots_id_fk", "tableFrom": "workflow_execution_logs", "tableTo": "workflow_execution_snapshots", - "columnsFrom": [ - "state_snapshot_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["state_snapshot_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" }, @@ -14464,12 +13880,8 @@ "name": "workflow_execution_logs_deployment_version_id_workflow_deployment_version_id_fk", "tableFrom": "workflow_execution_logs", "tableTo": "workflow_deployment_version", - "columnsFrom": [ - "deployment_version_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -14589,12 +14001,8 @@ "name": "workflow_execution_snapshots_workflow_id_workflow_id_fk", "tableFrom": "workflow_execution_snapshots", "tableTo": "workflow", - "columnsFrom": [ - "workflow_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -14789,12 +14197,8 @@ "name": "workflow_folder_user_id_user_id_fk", "tableFrom": "workflow_folder", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -14802,12 +14206,8 @@ "name": "workflow_folder_workspace_id_workspace_id_fk", "tableFrom": "workflow_folder", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -14954,12 +14354,8 @@ "name": "workflow_mcp_server_workspace_id_workspace_id_fk", "tableFrom": "workflow_mcp_server", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -14967,12 +14363,8 @@ "name": "workflow_mcp_server_created_by_user_id_fk", "tableFrom": "workflow_mcp_server", "tableTo": "user", - "columnsFrom": [ - "created_by" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["created_by"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -15127,12 +14519,8 @@ "name": "workflow_mcp_tool_server_id_workflow_mcp_server_id_fk", "tableFrom": "workflow_mcp_tool", "tableTo": "workflow_mcp_server", - "columnsFrom": [ - "server_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["server_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -15140,12 +14528,8 @@ "name": "workflow_mcp_tool_workflow_id_workflow_id_fk", "tableFrom": "workflow_mcp_tool", "tableTo": "workflow", - "columnsFrom": [ - "workflow_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -15523,12 +14907,8 @@ "name": "workflow_schedule_workflow_id_workflow_id_fk", "tableFrom": "workflow_schedule", "tableTo": "workflow", - "columnsFrom": [ - "workflow_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -15536,12 +14916,8 @@ "name": "workflow_schedule_deployment_version_id_workflow_deployment_version_id_fk", "tableFrom": "workflow_schedule", "tableTo": "workflow_deployment_version", - "columnsFrom": [ - "deployment_version_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["deployment_version_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -15549,12 +14925,8 @@ "name": "workflow_schedule_source_user_id_user_id_fk", "tableFrom": "workflow_schedule", "tableTo": "user", - "columnsFrom": [ - "source_user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["source_user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -15562,12 +14934,8 @@ "name": "workflow_schedule_source_workspace_id_workspace_id_fk", "tableFrom": "workflow_schedule", "tableTo": "workspace", - "columnsFrom": [ - "source_workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["source_workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -15665,12 +15033,8 @@ "name": "workflow_subflows_workflow_id_workflow_id_fk", "tableFrom": "workflow_subflows", "tableTo": "workflow", - "columnsFrom": [ - "workflow_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workflow_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -15869,12 +15233,8 @@ "name": "workspace_owner_id_user_id_fk", "tableFrom": "workspace", "tableTo": "user", - "columnsFrom": [ - "owner_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["owner_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -15882,12 +15242,8 @@ "name": "workspace_organization_id_organization_id_fk", "tableFrom": "workspace", "tableTo": "organization", - "columnsFrom": [ - "organization_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["organization_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, @@ -15895,12 +15251,8 @@ "name": "workspace_billed_account_user_id_user_id_fk", "tableFrom": "workspace", "tableTo": "user", - "columnsFrom": [ - "billed_account_user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["billed_account_user_id"], + "columnsTo": ["id"], "onDelete": "no action", "onUpdate": "no action" }, @@ -15908,12 +15260,8 @@ "name": "workspace_forked_from_workspace_id_workspace_id_fk", "tableFrom": "workspace", "tableTo": "workspace", - "columnsFrom": [ - "forked_from_workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["forked_from_workspace_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -16012,12 +15360,8 @@ "name": "workspace_byok_keys_workspace_id_workspace_id_fk", "tableFrom": "workspace_byok_keys", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -16025,12 +15369,8 @@ "name": "workspace_byok_keys_created_by_user_id_fk", "tableFrom": "workspace_byok_keys", "tableTo": "user", - "columnsFrom": [ - "created_by" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["created_by"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -16101,12 +15441,8 @@ "name": "workspace_environment_workspace_id_workspace_id_fk", "tableFrom": "workspace_environment", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -16251,12 +15587,8 @@ "name": "workspace_file_workspace_id_workspace_id_fk", "tableFrom": "workspace_file", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -16264,12 +15596,8 @@ "name": "workspace_file_uploaded_by_user_id_fk", "tableFrom": "workspace_file", "tableTo": "user", - "columnsFrom": [ - "uploaded_by" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["uploaded_by"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -16279,9 +15607,7 @@ "workspace_file_key_unique": { "name": "workspace_file_key_unique", "nullsNotDistinct": false, - "columns": [ - "key" - ] + "columns": ["key"] } }, "policies": {}, @@ -16464,12 +15790,8 @@ "name": "workspace_file_folders_user_id_user_id_fk", "tableFrom": "workspace_file_folders", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -16477,12 +15799,8 @@ "name": "workspace_file_folders_workspace_id_workspace_id_fk", "tableFrom": "workspace_file_folders", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -16490,12 +15808,8 @@ "name": "workspace_file_folders_parent_id_workspace_file_folders_id_fk", "tableFrom": "workspace_file_folders", "tableTo": "workspace_file_folders", - "columnsFrom": [ - "parent_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["parent_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -16797,12 +16111,8 @@ "name": "workspace_files_user_id_user_id_fk", "tableFrom": "workspace_files", "tableTo": "user", - "columnsFrom": [ - "user_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["user_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -16810,12 +16120,8 @@ "name": "workspace_files_workspace_id_workspace_id_fk", "tableFrom": "workspace_files", "tableTo": "workspace", - "columnsFrom": [ - "workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -16823,12 +16129,8 @@ "name": "workspace_files_folder_id_workspace_file_folders_id_fk", "tableFrom": "workspace_files", "tableTo": "workspace_file_folders", - "columnsFrom": [ - "folder_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["folder_id"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" }, @@ -16836,12 +16138,8 @@ "name": "workspace_files_chat_id_copilot_chats_id_fk", "tableFrom": "workspace_files", "tableTo": "copilot_chats", - "columnsFrom": [ - "chat_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["chat_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -16998,12 +16296,8 @@ "name": "workspace_fork_block_map_child_workspace_id_workspace_id_fk", "tableFrom": "workspace_fork_block_map", "tableTo": "workspace", - "columnsFrom": [ - "child_workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -17130,12 +16424,8 @@ "name": "workspace_fork_dependent_value_child_workspace_id_workspace_id_fk", "tableFrom": "workspace_fork_dependent_value", "tableTo": "workspace", - "columnsFrom": [ - "child_workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" } @@ -17244,12 +16534,8 @@ "name": "workspace_fork_promote_run_child_workspace_id_workspace_id_fk", "tableFrom": "workspace_fork_promote_run", "tableTo": "workspace", - "columnsFrom": [ - "child_workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -17257,12 +16543,8 @@ "name": "workspace_fork_promote_run_created_by_user_id_fk", "tableFrom": "workspace_fork_promote_run", "tableTo": "user", - "columnsFrom": [ - "created_by" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["created_by"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -17399,12 +16681,8 @@ "name": "workspace_fork_resource_map_child_workspace_id_workspace_id_fk", "tableFrom": "workspace_fork_resource_map", "tableTo": "workspace", - "columnsFrom": [ - "child_workspace_id" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["child_workspace_id"], + "columnsTo": ["id"], "onDelete": "cascade", "onUpdate": "no action" }, @@ -17412,12 +16690,8 @@ "name": "workspace_fork_resource_map_created_by_user_id_fk", "tableFrom": "workspace_fork_resource_map", "tableTo": "user", - "columnsFrom": [ - "created_by" - ], - "columnsTo": [ - "id" - ], + "columnsFrom": ["created_by"], + "columnsTo": ["id"], "onDelete": "set null", "onUpdate": "no action" } @@ -17433,209 +16707,112 @@ "public.academy_cert_status": { "name": "academy_cert_status", "schema": "public", - "values": [ - "active", - "revoked", - "expired" - ] + "values": ["active", "revoked", "expired"] }, "public.background_work_kind": { "name": "background_work_kind", "schema": "public", - "values": [ - "deployment_side_effects", - "fork_content_copy", - "fork_sync", - "fork_rollback" - ] + "values": ["deployment_side_effects", "fork_content_copy", "fork_sync", "fork_rollback"] }, "public.background_work_status_value": { "name": "background_work_status_value", "schema": "public", - "values": [ - "pending", - "processing", - "completed", - "completed_with_warnings", - "failed" - ] + "values": ["pending", "processing", "completed", "completed_with_warnings", "failed"] }, "public.billing_blocked_reason": { "name": "billing_blocked_reason", "schema": "public", - "values": [ - "payment_failed", - "dispute" - ] + "values": ["payment_failed", "dispute"] }, "public.billing_entity_type": { "name": "billing_entity_type", "schema": "public", - "values": [ - "user", - "organization" - ] + "values": ["user", "organization"] }, "public.chat_type": { "name": "chat_type", "schema": "public", - "values": [ - "mothership", - "copilot" - ] + "values": ["mothership", "copilot"] }, "public.copilot_async_tool_status": { "name": "copilot_async_tool_status", "schema": "public", - "values": [ - "pending", - "running", - "completed", - "failed", - "cancelled", - "delivered" - ] + "values": ["pending", "running", "completed", "failed", "cancelled", "delivered"] }, "public.copilot_run_status": { "name": "copilot_run_status", "schema": "public", - "values": [ - "active", - "paused_waiting_for_tool", - "resuming", - "complete", - "error", - "cancelled" - ] + "values": ["active", "paused_waiting_for_tool", "resuming", "complete", "error", "cancelled"] }, "public.credential_member_role": { "name": "credential_member_role", "schema": "public", - "values": [ - "admin", - "member" - ] + "values": ["admin", "member"] }, "public.credential_member_status": { "name": "credential_member_status", "schema": "public", - "values": [ - "active", - "pending", - "revoked" - ] + "values": ["active", "pending", "revoked"] }, "public.credential_type": { "name": "credential_type", "schema": "public", - "values": [ - "oauth", - "env_workspace", - "env_personal", - "service_account" - ] + "values": ["oauth", "env_workspace", "env_personal", "service_account"] }, "public.data_drain_cadence": { "name": "data_drain_cadence", "schema": "public", - "values": [ - "hourly", - "daily" - ] + "values": ["hourly", "daily"] }, "public.data_drain_destination": { "name": "data_drain_destination", "schema": "public", - "values": [ - "s3", - "gcs", - "azure_blob", - "datadog", - "bigquery", - "snowflake", - "webhook" - ] + "values": ["s3", "gcs", "azure_blob", "datadog", "bigquery", "snowflake", "webhook"] }, "public.data_drain_run_status": { "name": "data_drain_run_status", "schema": "public", - "values": [ - "running", - "success", - "failed" - ] + "values": ["running", "success", "failed"] }, "public.data_drain_run_trigger": { "name": "data_drain_run_trigger", "schema": "public", - "values": [ - "cron", - "manual" - ] + "values": ["cron", "manual"] }, "public.data_drain_source": { "name": "data_drain_source", "schema": "public", - "values": [ - "workflow_logs", - "job_logs", - "audit_logs", - "copilot_chats", - "copilot_runs" - ] + "values": ["workflow_logs", "job_logs", "audit_logs", "copilot_chats", "copilot_runs"] }, "public.execution_large_value_reference_source": { "name": "execution_large_value_reference_source", "schema": "public", - "values": [ - "execution_log", - "paused_snapshot" - ] + "values": ["execution_log", "paused_snapshot"] }, "public.invitation_kind": { "name": "invitation_kind", "schema": "public", - "values": [ - "organization", - "workspace" - ] + "values": ["organization", "workspace"] }, "public.invitation_membership_intent": { "name": "invitation_membership_intent", "schema": "public", - "values": [ - "internal", - "external" - ] + "values": ["internal", "external"] }, "public.invitation_status": { "name": "invitation_status", "schema": "public", - "values": [ - "pending", - "accepted", - "rejected", - "cancelled", - "expired" - ] + "values": ["pending", "accepted", "rejected", "cancelled", "expired"] }, "public.permission_type": { "name": "permission_type", "schema": "public", - "values": [ - "admin", - "write", - "read" - ] + "values": ["admin", "write", "read"] }, "public.usage_log_category": { "name": "usage_log_category", "schema": "public", - "values": [ - "model", - "fixed", - "tool" - ] + "values": ["model", "fixed", "tool"] }, "public.usage_log_source": { "name": "usage_log_source", @@ -17655,10 +16832,7 @@ "public.workspace_fork_promote_direction": { "name": "workspace_fork_promote_direction", "schema": "public", - "values": [ - "push", - "pull" - ] + "values": ["push", "pull"] }, "public.workspace_fork_resource_type": { "name": "workspace_fork_resource_type", @@ -17681,11 +16855,7 @@ "public.workspace_mode": { "name": "workspace_mode", "schema": "public", - "values": [ - "personal", - "organization", - "grandfathered_shared" - ] + "values": ["personal", "organization", "grandfathered_shared"] } }, "schemas": {}, From 6f58d91b6055e321b945905da90372fd490f2c63 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Mon, 13 Jul 2026 20:42:00 -0700 Subject: [PATCH 03/13] fix(agent-stream): satisfy biome format and import order Auto-format the sim package for CI lint:check, and repair the Anthropic streaming tool-loop payload after an unsafe delete-to-undefined rewrite. Co-authored-by: Cursor --- .../chat/components/message/message.test.tsx | 2 +- .../chat/hooks/use-chat-streaming.test.tsx | 109 ++++++------------ .../chat/hooks/use-chat-streaming.ts | 5 +- .../app/api/workflows/[id]/execute/route.ts | 5 +- .../hooks/use-workflow-execution.ts | 8 +- .../agent-stream/agent-stream-chrome.test.tsx | 9 +- .../agent-stream/agent-stream-chrome.tsx | 2 +- .../executor/execution/block-executor.test.ts | 13 ++- apps/sim/executor/execution/block-executor.ts | 4 +- apps/sim/executor/types.ts | 5 +- .../executor/human-in-the-loop-manager.ts | 2 +- .../attach-agent-stream-sink.test.ts | 2 +- apps/sim/lib/workflows/streaming/streaming.ts | 9 +- .../__fixtures__/anthropic/fixtures.test.ts | 8 +- .../providers/__fixtures__/anthropic/index.ts | 13 ++- .../anthropic/redacted-thinking-signature.ts | 3 +- .../anthropic/thinking-text-tool.ts | 3 +- .../__fixtures__/openai-compat/index.ts | 4 +- apps/sim/providers/anthropic/core.ts | 7 +- .../anthropic/streaming-tool-loop.test.ts | 5 +- .../anthropic/streaming-tool-loop.ts | 14 +-- apps/sim/providers/bedrock/index.ts | 2 +- .../providers/bedrock/streaming-tool-loop.ts | 35 ++---- apps/sim/providers/deepseek/index.ts | 10 +- apps/sim/providers/gemini/core.ts | 10 +- .../providers/gemini/streaming-tool-loop.ts | 3 +- .../sim/providers/google/utils.stream.test.ts | 9 +- apps/sim/providers/groq/index.ts | 57 ++++----- apps/sim/providers/ollama/core.ts | 2 +- .../providers/openai-compat/stream-events.ts | 2 +- .../openai-compat/streaming-tool-loop.test.ts | 7 +- .../openai-compat/streaming-tool-loop.ts | 4 +- apps/sim/providers/stream-events.test.ts | 2 +- apps/sim/providers/stream-events.ts | 3 +- apps/sim/providers/stream-pump.test.ts | 2 +- apps/sim/providers/stream-pump.ts | 5 +- apps/sim/stores/terminal/console/types.ts | 2 +- 37 files changed, 161 insertions(+), 226 deletions(-) diff --git a/apps/sim/app/(interfaces)/chat/components/message/message.test.tsx b/apps/sim/app/(interfaces)/chat/components/message/message.test.tsx index e0e48731e65..43daf5cd018 100644 --- a/apps/sim/app/(interfaces)/chat/components/message/message.test.tsx +++ b/apps/sim/app/(interfaces)/chat/components/message/message.test.tsx @@ -31,9 +31,9 @@ vi.mock('@/app/(interfaces)/chat/components/message/components/markdown-renderer })) import { + type ChatMessage, ClientChatMessage, escapeHtml, - type ChatMessage, } from '@/app/(interfaces)/chat/components/message/message' describe('escapeHtml', () => { diff --git a/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.test.tsx b/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.test.tsx index 3c7807ea77a..e78023dfb5c 100644 --- a/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.test.tsx +++ b/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.test.tsx @@ -139,13 +139,9 @@ describe('useChatStreaming thinking + abort (Step 6)', () => { }) await act(async () => { - await handle.latest().handleStreamedResponse( - makeSseResponse(), - setMessages, - vi.fn(), - vi.fn(), - false - ) + await handle + .latest() + .handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn(), false) }) await flushUiBatch() @@ -179,13 +175,9 @@ describe('useChatStreaming thinking + abort (Step 6)', () => { }) await act(async () => { - await handle.latest().handleStreamedResponse( - makeSseResponse(), - setMessages, - vi.fn(), - vi.fn(), - false - ) + await handle + .latest() + .handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn(), false) }) await flushUiBatch() @@ -218,13 +210,9 @@ describe('useChatStreaming thinking + abort (Step 6)', () => { }) await act(async () => { - await handle.latest().handleStreamedResponse( - makeSseResponse(), - setMessages, - vi.fn(), - vi.fn(), - false - ) + await handle + .latest() + .handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn(), false) }) await flushUiBatch() @@ -256,13 +244,9 @@ describe('useChatStreaming thinking + abort (Step 6)', () => { }) await act(async () => { - await handle.latest().handleStreamedResponse( - makeSseResponse(), - setMessages, - vi.fn(), - vi.fn(), - false - ) + await handle + .latest() + .handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn(), false) }) await flushUiBatch() @@ -291,21 +275,16 @@ describe('useChatStreaming thinking + abort (Step 6)', () => { }) await act(async () => { - await handle.latest().handleStreamedResponse( - makeSseResponse(), - setMessages, - vi.fn(), - vi.fn(), - false, - { + await handle + .latest() + .handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn(), false, { voiceSettings: { isVoiceEnabled: true, voiceId: 'voice-1', autoPlayResponses: true, }, audioStreamHandler, - } - ) + }) }) await flushUiBatch() @@ -343,14 +322,11 @@ describe('useChatStreaming thinking + abort (Step 6)', () => { }) const streamPromise = act(async () => { - await handle.latest().handleStreamedResponse( - makeSseResponse(), - setMessages, - vi.fn(), - vi.fn(), - false, - { abortController } - ) + await handle + .latest() + .handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn(), false, { + abortController, + }) }) await flushUiBatch() @@ -384,13 +360,9 @@ describe('useChatStreaming thinking + abort (Step 6)', () => { }) await act(async () => { - await handle.latest().handleStreamedResponse( - makeSseResponse(), - setMessages, - vi.fn(), - vi.fn(), - false - ) + await handle + .latest() + .handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn(), false) }) await flushUiBatch() @@ -451,13 +423,9 @@ describe('useChatStreaming tool lifecycle (Step 8)', () => { }) await act(async () => { - await handle.latest().handleStreamedResponse( - makeSseResponse(), - setMessages, - vi.fn(), - vi.fn(), - false - ) + await handle + .latest() + .handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn(), false) }) await flushUiBatch() @@ -515,14 +483,11 @@ describe('useChatStreaming tool lifecycle (Step 8)', () => { }) const streamPromise = act(async () => { - await handle.latest().handleStreamedResponse( - makeSseResponse(), - setMessages, - vi.fn(), - vi.fn(), - false, - { abortController } - ) + await handle + .latest() + .handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn(), false, { + abortController, + }) }) await flushUiBatch() @@ -556,13 +521,9 @@ describe('useChatStreaming tool lifecycle (Step 8)', () => { }) await act(async () => { - await handle.latest().handleStreamedResponse( - makeSseResponse(), - setMessages, - vi.fn(), - vi.fn(), - false - ) + await handle + .latest() + .handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn(), false) }) await flushUiBatch() diff --git a/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts b/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts index 05e0e3bbd63..329d5a580c0 100644 --- a/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts +++ b/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts @@ -3,9 +3,9 @@ import { useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { generateId } from '@sim/utils/id' +import { humanizeToolName } from '@/lib/copilot/tools/tool-display' import { readSSEEvents } from '@/lib/core/utils/sse' import { isUserFileWithMetadata } from '@/lib/core/utils/user-file' -import { humanizeToolName } from '@/lib/copilot/tools/tool-display' import type { ChatFile, ChatMessage, @@ -357,8 +357,7 @@ export function useChatStreaming() { error: errText, }) // Non-terminal: keep streaming; surface in thinking chrome so it is visible. - accumulatedThinking += - (accumulatedThinking ? '\n\n' : '') + `[Stream error] ${errText}` + accumulatedThinking += `${accumulatedThinking ? '\n\n' : ''}[Stream error] ${errText}` accumulatedThinkingRef.current = accumulatedThinking isThinkingStreaming = true uiDirty = true diff --git a/apps/sim/app/api/workflows/[id]/execute/route.ts b/apps/sim/app/api/workflows/[id]/execute/route.ts index 309e16c43c2..c71506f1263 100644 --- a/apps/sim/app/api/workflows/[id]/execute/route.ts +++ b/apps/sim/app/api/workflows/[id]/execute/route.ts @@ -87,8 +87,11 @@ import { loadDeployedWorkflowState, loadWorkflowFromNormalizedTables, } from '@/lib/workflows/persistence/utils' -import { createStreamingResponse, agentStreamProtocolResponseHeaders } from '@/lib/workflows/streaming/streaming' import { attachAgentStreamSink } from '@/lib/workflows/streaming/attach-agent-stream-sink' +import { + agentStreamProtocolResponseHeaders, + createStreamingResponse, +} from '@/lib/workflows/streaming/streaming' import { createHttpResponseFromBlock, workflowHasResponseBlock } from '@/lib/workflows/utils' import { getWorkspaceBillingSettings } from '@/lib/workspaces/utils' import { executeWorkflowJob, type WorkflowExecutionPayload } from '@/background/workflow-execution' diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts index c1ad48bf5e2..ceac34db1ec 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/hooks/use-workflow-execution.ts @@ -9,9 +9,9 @@ import { useParams } from 'next/navigation' import { useShallow } from 'zustand/react/shallow' import { requestJson } from '@/lib/api/client/request' import { cancelWorkflowExecutionContract, workflowLogContract } from '@/lib/api/contracts/workflows' +import { humanizeToolName } from '@/lib/copilot/tools/tool-display' import { buildTraceSpans } from '@/lib/logs/execution/trace-spans/trace-spans' import { processStreamingBlockLogs } from '@/lib/tokenization' -import { humanizeToolName } from '@/lib/copilot/tools/tool-display' import type { ExecutionPausedData } from '@/lib/workflows/executor/execution-events' import { collectInputFormatFiles, isFileFieldType } from '@/lib/workflows/input-format' import { @@ -1255,11 +1255,7 @@ export function useWorkflowExecution() { executionIdRef.current ) } else { - updateConsole( - data.blockId, - { agentStreamActive: false }, - executionIdRef.current - ) + updateConsole(data.blockId, { agentStreamActive: false }, executionIdRef.current) } }, diff --git a/apps/sim/components/agent-stream/agent-stream-chrome.test.tsx b/apps/sim/components/agent-stream/agent-stream-chrome.test.tsx index cdb0e7e6ef6..af9ddd245d4 100644 --- a/apps/sim/components/agent-stream/agent-stream-chrome.test.tsx +++ b/apps/sim/components/agent-stream/agent-stream-chrome.test.tsx @@ -15,10 +15,7 @@ vi.mock('@/lib/copilot/tools/tool-display', () => ({ import { AgentStreamThinkingChrome } from '@/components/agent-stream/agent-stream-chrome' -function renderChrome(props: { - thinking: string - isStreaming?: boolean -}): { +function renderChrome(props: { thinking: string; isStreaming?: boolean }): { container: HTMLDivElement rerender: (next: { thinking: string; isStreaming?: boolean }) => void unmount: () => void @@ -30,9 +27,7 @@ function renderChrome(props: { const mount = (p: { thinking: string; isStreaming?: boolean }) => { act(() => { - root.render( - - ) + root.render() }) } diff --git a/apps/sim/components/agent-stream/agent-stream-chrome.tsx b/apps/sim/components/agent-stream/agent-stream-chrome.tsx index 24dd39f1476..d6790255a57 100644 --- a/apps/sim/components/agent-stream/agent-stream-chrome.tsx +++ b/apps/sim/components/agent-stream/agent-stream-chrome.tsx @@ -1,8 +1,8 @@ 'use client' import { useEffect, useLayoutEffect, useRef, useState } from 'react' -import { Check, ChevronDown, Circle, Square, X } from 'lucide-react' import { cn } from '@sim/emcn' +import { Check, ChevronDown, Circle, Square, X } from 'lucide-react' import { humanizeToolName } from '@/lib/copilot/tools/tool-display' export type AgentStreamToolStatus = 'running' | 'success' | 'error' | 'cancelled' diff --git a/apps/sim/executor/execution/block-executor.test.ts b/apps/sim/executor/execution/block-executor.test.ts index 5c71be6c9a6..4593adbe502 100644 --- a/apps/sim/executor/execution/block-executor.test.ts +++ b/apps/sim/executor/execution/block-executor.test.ts @@ -549,9 +549,9 @@ describe('BlockExecutor streaming pump (Step 3)', () => { { type: 'text_delta', text: 'Hello ', turn: 'final' }, { type: 'text_delta', text: 'world', turn: 'final' }, ]) - expect( - state.getBlockOutput(block.id)?.providerTiming?.timeSegments?.[0]?.thinkingContent - ).toBe('hmm yes') + expect(state.getBlockOutput(block.id)?.providerTiming?.timeSegments?.[0]?.thinkingContent).toBe( + 'hmm yes' + ) }) it('drains without onStream and still persists answer content', async () => { @@ -602,7 +602,12 @@ describe('BlockExecutor streaming pump (Step 3)', () => { const result = (await originalExecute(ctx, block, inputs)) as { stream: ReadableStream streamFormat: 'agent-events-v1' - execution: { success: boolean; output: Record; logs: unknown[]; metadata: Record } + execution: { + success: boolean + output: Record + logs: unknown[] + metadata: Record + } } // Abort after a tick so the pump can project the first text_delta. queueMicrotask(() => abortController.abort('user')) diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index 1dd2466557e..29ad84e9d6e 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -48,7 +48,6 @@ import { getIterationContext, } from '@/executor/utils/iteration-context' import { isJSONString } from '@/executor/utils/json' -import { createAgentStreamPump } from '@/providers/stream-pump' import { filterOutputForLog } from '@/executor/utils/output-filter' import { buildBranchNodeId, @@ -60,6 +59,7 @@ import { FUNCTION_BLOCK_DISPLAY_CODE_KEY, type VariableResolver, } from '@/executor/variables/resolver' +import { createAgentStreamPump } from '@/providers/stream-pump' import type { SerializedBlock } from '@/serializer/types' import { SYSTEM_SUBBLOCK_IDS } from '@/triggers/constants' @@ -974,7 +974,7 @@ function stripThinkingContentFromOutput(output: unknown): void { if (!Array.isArray(segments)) return for (const segment of segments) { if (segment && typeof segment === 'object' && 'thinkingContent' in segment) { - delete (segment as { thinkingContent?: string }).thinkingContent + ;(segment as { thinkingContent?: string }).thinkingContent = undefined } } } diff --git a/apps/sim/executor/types.ts b/apps/sim/executor/types.ts index 8f0c9dcf0cd..ec478b447b4 100644 --- a/apps/sim/executor/types.ts +++ b/apps/sim/executor/types.ts @@ -10,10 +10,7 @@ import type { SerializableExecutionState, } from '@/executor/execution/types' import type { RunFromBlockContext } from '@/executor/utils/run-from-block' -import type { - AgentStreamSink, - UnsubscribeAgentStreamSink, -} from '@/providers/stream-events' +import type { AgentStreamSink, UnsubscribeAgentStreamSink } from '@/providers/stream-events' import type { SerializedBlock, SerializedWorkflow } from '@/serializer/types' import type { SubflowType } from '@/stores/workflows/workflow/types' diff --git a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts index 9df3a818cdf..b82d2a52f2e 100644 --- a/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts +++ b/apps/sim/lib/workflows/executor/human-in-the-loop-manager.ts @@ -26,7 +26,6 @@ import { LoggingSession } from '@/lib/logs/execution/logging-session' import { cleanupExecutionBase64Cache } from '@/lib/uploads/utils/user-file-base64.server' import { executeWorkflowCore } from '@/lib/workflows/executor/execution-core' import type { ExecutionEvent } from '@/lib/workflows/executor/execution-events' -import { attachAgentStreamSink } from '@/lib/workflows/streaming/attach-agent-stream-sink' import { createPausedExecutionResumeMetadata, parsePausedExecutionResumeMetadata, @@ -36,6 +35,7 @@ import { normalizeAutomaticResumeWaitingReason, resolveAutomaticResumeAdmissionFailure, } from '@/lib/workflows/executor/resume-policy' +import { attachAgentStreamSink } from '@/lib/workflows/streaming/attach-agent-stream-sink' import { ExecutionSnapshot } from '@/executor/execution/snapshot' import type { ChildWorkflowContext, diff --git a/apps/sim/lib/workflows/streaming/attach-agent-stream-sink.test.ts b/apps/sim/lib/workflows/streaming/attach-agent-stream-sink.test.ts index 4e999cce476..e9d2f18063c 100644 --- a/apps/sim/lib/workflows/streaming/attach-agent-stream-sink.test.ts +++ b/apps/sim/lib/workflows/streaming/attach-agent-stream-sink.test.ts @@ -3,8 +3,8 @@ */ import { describe, expect, it, vi } from 'vitest' import { attachAgentStreamSink } from '@/lib/workflows/streaming/attach-agent-stream-sink' -import type { AgentStreamEvent } from '@/providers/stream-events' import type { StreamingExecution } from '@/executor/types' +import type { AgentStreamEvent } from '@/providers/stream-events' describe('attachAgentStreamSink (Step 10)', () => { it('subscribes in sync window and routes thinking/tool events', async () => { diff --git a/apps/sim/lib/workflows/streaming/streaming.ts b/apps/sim/lib/workflows/streaming/streaming.ts index 6dced6c7e3b..db468d6d82b 100644 --- a/apps/sim/lib/workflows/streaming/streaming.ts +++ b/apps/sim/lib/workflows/streaming/streaming.ts @@ -24,13 +24,13 @@ import { cleanupExecutionBase64Cache, hydrateUserFilesWithBase64, } from '@/lib/uploads/utils/user-file-base64.server' -import type { BlockLog, ExecutionResult, StreamingExecution } from '@/executor/types' -import { navigatePathAsync } from '@/executor/variables/resolvers/reference-async.server' import { AGENT_STREAM_PROTOCOL_HEADER, AGENT_STREAM_PROTOCOL_V1, shouldEmitAgentStreamEvents, } from '@/lib/workflows/streaming/agent-stream-protocol' +import type { BlockLog, ExecutionResult, StreamingExecution } from '@/executor/types' +import { navigatePathAsync } from '@/executor/variables/resolvers/reference-async.server' import { DEFAULT_MAX_THINKING_BYTES } from '@/providers/stream-pump' /** @@ -117,7 +117,6 @@ export function agentStreamProtocolResponseHeaders(options: { return { [AGENT_STREAM_PROTOCOL_HEADER]: AGENT_STREAM_PROTOCOL_V1 } } - interface StreamingState { streamedChunks: Map processedOutputs: Set @@ -689,9 +688,7 @@ export async function createStreamingResponse( logger.info(`[${requestId}] Streaming execution aborted by client disconnect`) if (result._streamingMetadata?.loggingSession) { // LoggingSession has no cancelled status; match workflow execute route wording. - await result._streamingMetadata.loggingSession.markAsFailed( - 'Client cancelled request' - ) + await result._streamingMetadata.loggingSession.markAsFailed('Client cancelled request') } // No `final` after abort; clients that already disconnected ignore these. controller.enqueue(encodeSSE({ event: 'error', error: 'Client cancelled request' })) diff --git a/apps/sim/providers/__fixtures__/anthropic/fixtures.test.ts b/apps/sim/providers/__fixtures__/anthropic/fixtures.test.ts index eb6434d92c0..b5c90d6cd85 100644 --- a/apps/sim/providers/__fixtures__/anthropic/fixtures.test.ts +++ b/apps/sim/providers/__fixtures__/anthropic/fixtures.test.ts @@ -19,7 +19,13 @@ import { type StreamEvent = { type: string index?: number - delta?: { type: string; thinking?: string; text?: string; signature?: string; partial_json?: string } + delta?: { + type: string + thinking?: string + text?: string + signature?: string + partial_json?: string + } content_block?: { type: string thinking?: string diff --git a/apps/sim/providers/__fixtures__/anthropic/index.ts b/apps/sim/providers/__fixtures__/anthropic/index.ts index 117a8fb7769..222e7af8c71 100644 --- a/apps/sim/providers/__fixtures__/anthropic/index.ts +++ b/apps/sim/providers/__fixtures__/anthropic/index.ts @@ -2,15 +2,16 @@ * Re-exports Anthropic stream fixtures used by agent-stream-events work. * Keep fixture data in dedicated modules so adapters can import without pulling tests. */ -export { - anthropicThinkingTextToolAssembledContent, - anthropicThinkingTextToolExpectedText, - anthropicThinkingTextToolExpectedThinking, - anthropicThinkingTextToolStreamEvents, -} from '@/providers/__fixtures__/anthropic/thinking-text-tool' + export { anthropicRedactedThinkingAssembledContent, anthropicRedactedThinkingExpectedText, anthropicRedactedThinkingExpectedTraceThinking, anthropicRedactedThinkingStreamEvents, } from '@/providers/__fixtures__/anthropic/redacted-thinking-signature' +export { + anthropicThinkingTextToolAssembledContent, + anthropicThinkingTextToolExpectedText, + anthropicThinkingTextToolExpectedThinking, + anthropicThinkingTextToolStreamEvents, +} from '@/providers/__fixtures__/anthropic/thinking-text-tool' diff --git a/apps/sim/providers/__fixtures__/anthropic/redacted-thinking-signature.ts b/apps/sim/providers/__fixtures__/anthropic/redacted-thinking-signature.ts index 1f489756ddf..7e70d0a44e5 100644 --- a/apps/sim/providers/__fixtures__/anthropic/redacted-thinking-signature.ts +++ b/apps/sim/providers/__fixtures__/anthropic/redacted-thinking-signature.ts @@ -95,5 +95,4 @@ export const anthropicRedactedThinkingAssembledContent = [ export const anthropicRedactedThinkingExpectedTraceThinking = '[redacted]\n\nVisible follow-up reasoning after redaction.' -export const anthropicRedactedThinkingExpectedText = - 'Here is the answer after redacted thinking.' +export const anthropicRedactedThinkingExpectedText = 'Here is the answer after redacted thinking.' diff --git a/apps/sim/providers/__fixtures__/anthropic/thinking-text-tool.ts b/apps/sim/providers/__fixtures__/anthropic/thinking-text-tool.ts index ec183158186..5840906a7a2 100644 --- a/apps/sim/providers/__fixtures__/anthropic/thinking-text-tool.ts +++ b/apps/sim/providers/__fixtures__/anthropic/thinking-text-tool.ts @@ -115,5 +115,4 @@ export const anthropicThinkingTextToolExpectedThinking = 'I should check the weather before answering. Calling get_weather for SF.' /** Concatenated answer text (what output.content / live stream should contain today). */ -export const anthropicThinkingTextToolExpectedText = - 'Let me check the weather in San Francisco.' +export const anthropicThinkingTextToolExpectedText = 'Let me check the weather in San Francisco.' diff --git a/apps/sim/providers/__fixtures__/openai-compat/index.ts b/apps/sim/providers/__fixtures__/openai-compat/index.ts index 76ab6500b47..806c8578dc7 100644 --- a/apps/sim/providers/__fixtures__/openai-compat/index.ts +++ b/apps/sim/providers/__fixtures__/openai-compat/index.ts @@ -43,7 +43,9 @@ export const openaiCompatToolCallStartChunks = [ choices: [ { delta: { - tool_calls: [{ index: 0, id: 'call_abc', function: { name: 'http_request', arguments: '' } }], + tool_calls: [ + { index: 0, id: 'call_abc', function: { name: 'http_request', arguments: '' } }, + ], }, }, ], diff --git a/apps/sim/providers/anthropic/core.ts b/apps/sim/providers/anthropic/core.ts index 64bc7ba86fa..23fb6bc6e86 100644 --- a/apps/sim/providers/anthropic/core.ts +++ b/apps/sim/providers/anthropic/core.ts @@ -3,7 +3,12 @@ import { transformJSONSchema } from '@anthropic-ai/sdk/lib/transform-json-schema import type { RawMessageStreamEvent } from '@anthropic-ai/sdk/resources/messages/messages' import type { Logger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' -import type { BlockTokens, IterationToolCall, NormalizedBlockOutput, StreamingExecution } from '@/executor/types' +import type { + BlockTokens, + IterationToolCall, + NormalizedBlockOutput, + StreamingExecution, +} from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { createAnthropicStreamingToolLoopStream } from '@/providers/anthropic/streaming-tool-loop' import { diff --git a/apps/sim/providers/anthropic/streaming-tool-loop.test.ts b/apps/sim/providers/anthropic/streaming-tool-loop.test.ts index e4e4b7824da..681b030c6bb 100644 --- a/apps/sim/providers/anthropic/streaming-tool-loop.test.ts +++ b/apps/sim/providers/anthropic/streaming-tool-loop.test.ts @@ -62,10 +62,7 @@ function makeFinalMessage(overrides: { } } -function makeMessageStream( - events: unknown[], - finalMessage: ReturnType -) { +function makeMessageStream(events: unknown[], finalMessage: ReturnType) { return { async *[Symbol.asyncIterator]() { for (const event of events) { diff --git a/apps/sim/providers/anthropic/streaming-tool-loop.ts b/apps/sim/providers/anthropic/streaming-tool-loop.ts index 12e191d5b7d..1d0899b3116 100644 --- a/apps/sim/providers/anthropic/streaming-tool-loop.ts +++ b/apps/sim/providers/anthropic/streaming-tool-loop.ts @@ -18,11 +18,7 @@ import { checkForForcedToolUsage } from '@/providers/anthropic/utils' import type { AgentStreamEvent, ToolCallEndStatus } from '@/providers/stream-events' import { enrichLastModelSegment } from '@/providers/trace-enrichment' import type { ProviderRequest, TimeSegment } from '@/providers/types' -import { - calculateCost, - prepareToolExecution, - sumToolCosts, -} from '@/providers/utils' +import { calculateCost, prepareToolExecution, sumToolCosts } from '@/providers/utils' import { executeTool } from '@/tools' /** Custom payload fields shared with `core.ts` (adaptive thinking, output_format). */ @@ -181,7 +177,7 @@ export function createAnthropicStreamingToolLoopStream( messages: currentMessages, } // Streaming tool loop always streams each turn; never pass stream:true twice. - delete (turnPayload as { stream?: boolean }).stream + ;(turnPayload as { stream?: boolean }).stream = undefined // Forced tool_choice vs thinking — same rules as silent loop. const thinkingEnabled = !!payload.thinking @@ -522,11 +518,7 @@ export function createAnthropicStreamingToolLoopStream( throw new DOMException('Stream aborted', 'AbortError') } } catch (error) { - settleOpenTools( - controller, - openToolStarts, - isAbortError(error) ? 'cancelled' : 'error' - ) + settleOpenTools(controller, openToolStarts, isAbortError(error) ? 'cancelled' : 'error') throw error } } diff --git a/apps/sim/providers/bedrock/index.ts b/apps/sim/providers/bedrock/index.ts index 8bda948d84d..6a0b7621285 100644 --- a/apps/sim/providers/bedrock/index.ts +++ b/apps/sim/providers/bedrock/index.ts @@ -18,13 +18,13 @@ import { getErrorMessage, toError } from '@sim/utils/errors' import type { IterationToolCall, NormalizedBlockOutput, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { buildBedrockMessageContent } from '@/providers/attachments' +import { createBedrockStreamingToolLoopStream } from '@/providers/bedrock/streaming-tool-loop' import { checkForForcedToolUsage, createReadableStreamFromBedrockStream, generateToolUseId, getBedrockInferenceProfileId, } from '@/providers/bedrock/utils' -import { createBedrockStreamingToolLoopStream } from '@/providers/bedrock/streaming-tool-loop' import { getCachedProviderClient } from '@/providers/client-cache' import { getProviderDefaultModel, getProviderModels } from '@/providers/models' import { createStreamingExecution } from '@/providers/streaming-execution' diff --git a/apps/sim/providers/bedrock/streaming-tool-loop.ts b/apps/sim/providers/bedrock/streaming-tool-loop.ts index b3e39816ea3..2c8e1a1cd8e 100644 --- a/apps/sim/providers/bedrock/streaming-tool-loop.ts +++ b/apps/sim/providers/bedrock/streaming-tool-loop.ts @@ -6,11 +6,11 @@ */ import { + type Message as BedrockMessage, + type BedrockRuntimeClient, type ContentBlock, type ConversationRole, ConverseStreamCommand, - type BedrockRuntimeClient, - type Message as BedrockMessage, type SystemContentBlock, type Tool, type ToolConfiguration, @@ -25,11 +25,7 @@ import { checkForForcedToolUsage, generateToolUseId } from '@/providers/bedrock/ import type { AgentStreamEvent, ToolCallEndStatus } from '@/providers/stream-events' import { enrichLastModelSegment } from '@/providers/trace-enrichment' import type { ProviderRequest, TimeSegment } from '@/providers/types' -import { - calculateCost, - prepareToolExecution, - sumToolCosts, -} from '@/providers/utils' +import { calculateCost, prepareToolExecution, sumToolCosts } from '@/providers/utils' import { executeTool } from '@/tools' export interface BedrockStreamingToolLoopComplete { @@ -238,11 +234,7 @@ export function createBedrockStreamingToolLoopStream( throw new Error('No stream returned from Bedrock') } - const drained = await drainBedrockTurn( - streamResponse.stream, - controller, - openToolStarts - ) + const drained = await drainBedrockTurn(streamResponse.stream, controller, openToolStarts) const modelEnd = Date.now() const thisModelTime = modelEnd - modelStart modelTime += thisModelTime @@ -441,15 +433,7 @@ export function createBedrockStreamingToolLoopStream( const toolResultContent: ContentBlock[] = [] for (const value of orderedResults) { - const { - toolUseId, - toolName, - toolParams, - result, - startTime, - endTime, - duration, - } = value + const { toolUseId, toolName, toolParams, result, startTime, endTime, duration } = value timeSegments.push({ type: 'tool', @@ -496,9 +480,14 @@ export function createBedrockStreamingToolLoopStream( }) } - if (typeof originalToolChoice === 'object' && hasUsedForcedTool && forcedTools.length > 0) { + if ( + typeof originalToolChoice === 'object' && + hasUsedForcedTool && + forcedTools.length > 0 + ) { const remainingTools = forcedTools.filter((tool) => !usedForcedTools.includes(tool)) - toolChoice = remainingTools.length > 0 ? { tool: { name: remainingTools[0] } } : { auto: {} } + toolChoice = + remainingTools.length > 0 ? { tool: { name: remainingTools[0] } } : { auto: {} } } else if (hasUsedForcedTool && typeof originalToolChoice === 'object') { toolChoice = { auto: {} } } diff --git a/apps/sim/providers/deepseek/index.ts b/apps/sim/providers/deepseek/index.ts index 109592c41e7..8e9812319d6 100644 --- a/apps/sim/providers/deepseek/index.ts +++ b/apps/sim/providers/deepseek/index.ts @@ -148,15 +148,9 @@ export const deepseekProvider: ProviderConfig = { basePayload: payload, messages: formattedMessages as any, createStream: async (params, options) => - deepseek.chat.completions.create( - { ...params, stream: true }, - options - ), + deepseek.chat.completions.create({ ...params, stream: true }, options), createBlocking: async (params, options) => - deepseek.chat.completions.create( - { ...params, stream: false }, - options - ), + deepseek.chat.completions.create({ ...params, stream: false }, options), logger, timeSegments, forcedTools, diff --git a/apps/sim/providers/gemini/core.ts b/apps/sim/providers/gemini/core.ts index 67b2d8f5815..a7d2690be22 100644 --- a/apps/sim/providers/gemini/core.ts +++ b/apps/sim/providers/gemini/core.ts @@ -15,6 +15,7 @@ import { createLogger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' import type { IterationToolCall, NormalizedBlockOutput, StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' +import { createGeminiStreamingToolLoopStream } from '@/providers/gemini/streaming-tool-loop' import { checkForForcedToolUsage, cleanSchemaForGemini, @@ -28,10 +29,9 @@ import { mapToThinkingLevel, supportsDisablingGemini25Thinking, } from '@/providers/google/utils' -import { enrichLastModelSegment } from '@/providers/trace-enrichment' -import { ensureToolCallId } from '@/providers/tool-call-id' import { createStreamingExecution } from '@/providers/streaming-execution' -import { createGeminiStreamingToolLoopStream } from '@/providers/gemini/streaming-tool-loop' +import { ensureToolCallId } from '@/providers/tool-call-id' +import { enrichLastModelSegment } from '@/providers/trace-enrichment' import type { FunctionCallResponse, ProviderRequest, @@ -1280,7 +1280,9 @@ export async function executeGeminiRequest( if (thinking) { const segments = streamingResult.execution.output.providerTiming?.timeSegments - const lastModel = segments ? [...segments].reverse().find((s) => s.type === 'model') : undefined + const lastModel = segments + ? [...segments].reverse().find((s) => s.type === 'model') + : undefined if (lastModel) { lastModel.thinkingContent = thinking } diff --git a/apps/sim/providers/gemini/streaming-tool-loop.ts b/apps/sim/providers/gemini/streaming-tool-loop.ts index b9de301ae05..21eb5229f75 100644 --- a/apps/sim/providers/gemini/streaming-tool-loop.ts +++ b/apps/sim/providers/gemini/streaming-tool-loop.ts @@ -91,8 +91,7 @@ function buildNextConfig( model: string ): GenerateContentConfig { const nextConfig = { ...baseConfig } - const allForcedToolsUsed = - forcedTools.length > 0 && usedForcedTools.length === forcedTools.length + const allForcedToolsUsed = forcedTools.length > 0 && usedForcedTools.length === forcedTools.length if (allForcedToolsUsed && request.responseFormat) { nextConfig.tools = undefined diff --git a/apps/sim/providers/google/utils.stream.test.ts b/apps/sim/providers/google/utils.stream.test.ts index b9085efc92e..570be2733e2 100644 --- a/apps/sim/providers/google/utils.stream.test.ts +++ b/apps/sim/providers/google/utils.stream.test.ts @@ -67,8 +67,11 @@ describe('createReadableStreamFromGeminiStream (Step 9)', () => { ) const events = await collectEvents(stream) expect(events.some((e) => e.type === 'thinking_delta')).toBe(false) - expect(events.filter((e) => e.type === 'text_delta').map((e) => e.text).join('')).toContain( - 'Just text' - ) + expect( + events + .filter((e) => e.type === 'text_delta') + .map((e) => e.text) + .join('') + ).toContain('Just text') }) }) diff --git a/apps/sim/providers/groq/index.ts b/apps/sim/providers/groq/index.ts index fdac6a74a3b..4d8c71ced6f 100644 --- a/apps/sim/providers/groq/index.ts +++ b/apps/sim/providers/groq/index.ts @@ -161,15 +161,9 @@ export const groqProvider: ProviderConfig = { basePayload: payload, messages: formattedMessages as any, createStream: async (params, options) => - groq.chat.completions.create( - { ...params, stream: true }, - options - ) as any, + groq.chat.completions.create({ ...params, stream: true }, options) as any, createBlocking: async (params, options) => - groq.chat.completions.create( - { ...params, stream: false }, - options - ) as any, + groq.chat.completions.create({ ...params, stream: false }, options) as any, logger, timeSegments, forcedTools, @@ -217,35 +211,32 @@ export const groqProvider: ProviderConfig = { isStreaming: true, streamFormat: 'agent-events-v1', createStream: ({ output }) => - createReadableStreamFromGroqStream( - streamResponse as any, - (content, usage, thinking) => { - output.content = content - output.tokens = { - input: usage.prompt_tokens, - output: usage.completion_tokens, - total: usage.total_tokens, - } + createReadableStreamFromGroqStream(streamResponse as any, (content, usage, thinking) => { + output.content = content + output.tokens = { + input: usage.prompt_tokens, + output: usage.completion_tokens, + total: usage.total_tokens, + } - const costResult = calculateCost( - request.model, - usage.prompt_tokens, - usage.completion_tokens - ) - output.cost = { - input: costResult.input, - output: costResult.output, - total: costResult.total, - } + const costResult = calculateCost( + request.model, + usage.prompt_tokens, + usage.completion_tokens + ) + output.cost = { + input: costResult.input, + output: costResult.output, + total: costResult.total, + } - if (thinking) { - const segment = output.providerTiming?.timeSegments?.[0] - if (segment) { - segment.thinkingContent = thinking - } + if (thinking) { + const segment = output.providerTiming?.timeSegments?.[0] + if (segment) { + segment.thinkingContent = thinking } } - ), + }), }) return streamingResult diff --git a/apps/sim/providers/ollama/core.ts b/apps/sim/providers/ollama/core.ts index 3d5d23295e8..c41134fac80 100644 --- a/apps/sim/providers/ollama/core.ts +++ b/apps/sim/providers/ollama/core.ts @@ -9,8 +9,8 @@ import type { CompletionUsage } from 'openai/resources/completions' import type { StreamingExecution } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { formatMessagesForProvider } from '@/providers/attachments' -import { createStreamingExecution } from '@/providers/streaming-execution' import type { AgentStreamEvent } from '@/providers/stream-events' +import { createStreamingExecution } from '@/providers/streaming-execution' import { adaptOpenAIChatToolSchema } from '@/providers/tool-schema-adapter' import { enrichLastModelSegmentFromChatCompletions } from '@/providers/trace-enrichment' import type { Message, ProviderRequest, ProviderResponse, TimeSegment } from '@/providers/types' diff --git a/apps/sim/providers/openai-compat/stream-events.ts b/apps/sim/providers/openai-compat/stream-events.ts index 0fd1b45dc32..9097595e095 100644 --- a/apps/sim/providers/openai-compat/stream-events.ts +++ b/apps/sim/providers/openai-compat/stream-events.ts @@ -7,9 +7,9 @@ * Tool-call argument deltas are accumulated for tool-loop history. */ +import { createLogger } from '@sim/logger' import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { CompletionUsage } from 'openai/resources/completions' -import { createLogger } from '@sim/logger' import type { AgentStreamEvent, TextDeltaTurn } from '@/providers/stream-events' export interface OpenAICompatAssembledToolCall { diff --git a/apps/sim/providers/openai-compat/streaming-tool-loop.test.ts b/apps/sim/providers/openai-compat/streaming-tool-loop.test.ts index 32f60ebdfcc..e867454a0b2 100644 --- a/apps/sim/providers/openai-compat/streaming-tool-loop.test.ts +++ b/apps/sim/providers/openai-compat/streaming-tool-loop.test.ts @@ -49,7 +49,12 @@ function toolThenAnswerChunks(toolName: string, args: string, answer: string) { delta: { reasoning_content: 'I should call the tool. ', tool_calls: [ - { index: 0, id: 'call_1', type: 'function', function: { name: toolName, arguments: '' } }, + { + index: 0, + id: 'call_1', + type: 'function', + function: { name: toolName, arguments: '' }, + }, ], }, }, diff --git a/apps/sim/providers/openai-compat/streaming-tool-loop.ts b/apps/sim/providers/openai-compat/streaming-tool-loop.ts index f18de36e98e..958b431bd4b 100644 --- a/apps/sim/providers/openai-compat/streaming-tool-loop.ts +++ b/apps/sim/providers/openai-compat/streaming-tool-loop.ts @@ -11,10 +11,10 @@ * `tool_calls` deltas (no blocking hybrid). */ -import type OpenAI from 'openai' -import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { Logger } from '@sim/logger' import { getErrorMessage, toError } from '@sim/utils/errors' +import type OpenAI from 'openai' +import type { ChatCompletionChunk } from 'openai/resources/chat/completions' import type { NormalizedBlockOutput } from '@/executor/types' import { MAX_TOOL_ITERATIONS } from '@/providers' import { diff --git a/apps/sim/providers/stream-events.test.ts b/apps/sim/providers/stream-events.test.ts index 477ed4abc42..0cf8624eb5a 100644 --- a/apps/sim/providers/stream-events.test.ts +++ b/apps/sim/providers/stream-events.test.ts @@ -3,13 +3,13 @@ */ import { describe, expect, it } from 'vitest' import { + type AgentStreamEvent, createAgentEventReadableStream, isAgentEventReadableStream, isAgentStreamEvent, isAgentStreamFormat, isTextDeltaTurn, isToolCallEndStatus, - type AgentStreamEvent, } from '@/providers/stream-events' describe('stream-events contract', () => { diff --git a/apps/sim/providers/stream-events.ts b/apps/sim/providers/stream-events.ts index 08d73fbe6c9..45c35eefb19 100644 --- a/apps/sim/providers/stream-events.ts +++ b/apps/sim/providers/stream-events.ts @@ -70,8 +70,7 @@ export function isAgentStreamEvent(value: unknown): value is AgentStreamEvent { switch (value.type) { case 'text_delta': return ( - typeof value.text === 'string' && - (value.turn === undefined || isTextDeltaTurn(value.turn)) + typeof value.text === 'string' && (value.turn === undefined || isTextDeltaTurn(value.turn)) ) case 'thinking_delta': return typeof value.text === 'string' diff --git a/apps/sim/providers/stream-pump.test.ts b/apps/sim/providers/stream-pump.test.ts index 98fd5bd7ad2..9fc05304199 100644 --- a/apps/sim/providers/stream-pump.test.ts +++ b/apps/sim/providers/stream-pump.test.ts @@ -3,9 +3,9 @@ */ import { describe, expect, it, vi } from 'vitest' import { - createAgentEventReadableStream, type AgentStreamEvent, type AgentStreamSink, + createAgentEventReadableStream, } from '@/providers/stream-events' import { createAgentStreamPump, DEFAULT_MAX_THINKING_BYTES } from '@/providers/stream-pump' diff --git a/apps/sim/providers/stream-pump.ts b/apps/sim/providers/stream-pump.ts index 21cb0194d28..1e4ff84f4d1 100644 --- a/apps/sim/providers/stream-pump.ts +++ b/apps/sim/providers/stream-pump.ts @@ -13,8 +13,8 @@ import { type AgentStreamEvent, type AgentStreamFormat, type AgentStreamSink, - type UnsubscribeAgentStreamSink, isAgentStreamEvent, + type UnsubscribeAgentStreamSink, } from '@/providers/stream-events' /** Default cap on thinking text forwarded to sinks (UTF-16 code units via `.length`). */ @@ -231,8 +231,7 @@ export function createAgentStreamPump(options: CreateAgentStreamPumpOptions): Ag if (event.type === 'thinking_delta') { const remaining = Math.max(0, maxThinkingBytes - thinkingBytesForwarded) if (remaining <= 0) return - const forwarded = - event.text.length > remaining ? event.text.slice(0, remaining) : event.text + const forwarded = event.text.length > remaining ? event.text.slice(0, remaining) : event.text thinkingBytesForwarded += forwarded.length if (forwarded.length > 0) { await dispatchToSinks({ type: 'thinking_delta', text: forwarded }) diff --git a/apps/sim/stores/terminal/console/types.ts b/apps/sim/stores/terminal/console/types.ts index 079a8d91a74..7939ee826e6 100644 --- a/apps/sim/stores/terminal/console/types.ts +++ b/apps/sim/stores/terminal/console/types.ts @@ -1,6 +1,6 @@ +import type { AgentStreamToolCall } from '@/components/agent-stream/agent-stream-chrome' import type { ParentIteration } from '@/executor/execution/types' import type { NormalizedBlockOutput } from '@/executor/types' -import type { AgentStreamToolCall } from '@/components/agent-stream/agent-stream-chrome' import type { SubflowType } from '@/stores/workflows/workflow/types' export interface ConsoleEntry { From 3e139eb990cf522eaee238690be6d8409f1d9e9b Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Mon, 13 Jul 2026 20:46:50 -0700 Subject: [PATCH 04/13] fix(agent-stream): keep drained answer on abort and update migration journal test Treat AbortError from reader.cancel as a cancelled pump result so soft-complete retains answerText. Point the workspace storage migration journal assertion at 0261_chat_include_thinking. Co-authored-by: Cursor --- .../executor/execution/block-executor.test.ts | 24 +++++------------- apps/sim/providers/stream-pump.test.ts | 25 +++++++++++++++++++ apps/sim/providers/stream-pump.ts | 12 ++++++++- ...space-storage-accounting-migration.test.ts | 2 +- 4 files changed, 43 insertions(+), 20 deletions(-) diff --git a/apps/sim/executor/execution/block-executor.test.ts b/apps/sim/executor/execution/block-executor.test.ts index 4593adbe502..768429a0954 100644 --- a/apps/sim/executor/execution/block-executor.test.ts +++ b/apps/sim/executor/execution/block-executor.test.ts @@ -596,23 +596,6 @@ describe('BlockExecutor streaming pump (Step 3)', () => { { type: 'thinking_delta', text: 'more' }, ], }) - // Override execute to abort mid-stream after first text chunk is projected. - const originalExecute = handler.execute.bind(handler) - handler.execute = async (ctx, block, inputs) => { - const result = (await originalExecute(ctx, block, inputs)) as { - stream: ReadableStream - streamFormat: 'agent-events-v1' - execution: { - success: boolean - output: Record - logs: unknown[] - metadata: Record - } - } - // Abort after a tick so the pump can project the first text_delta. - queueMicrotask(() => abortController.abort('user')) - return result - } const { executor, block, state } = createExecutor(handler) const ctx = createContext(state) @@ -621,6 +604,10 @@ describe('BlockExecutor streaming pump (Step 3)', () => { streamingExec.subscribe?.({ onEvent: async () => {} }) const reader = streamingExec.stream.getReader() try { + // Drain the first projected answer chunk, then Stop — pump must keep it. + const first = await reader.read() + expect(first.done).toBe(false) + abortController.abort('user') while (true) { const { done } = await reader.read() if (done) break @@ -634,7 +621,8 @@ describe('BlockExecutor streaming pump (Step 3)', () => { const output = state.getBlockOutput(block.id) expect(output?.error).toBeUndefined() - // May or may not have content depending on race; must not be a failed error output. + // Soft-complete must keep text already projected before Stop — not empty content. + expect(output?.content).toBe('partial answer') expect(output).not.toMatchObject({ error: expect.any(String) }) }) diff --git a/apps/sim/providers/stream-pump.test.ts b/apps/sim/providers/stream-pump.test.ts index 9fc05304199..c3826bd4703 100644 --- a/apps/sim/providers/stream-pump.test.ts +++ b/apps/sim/providers/stream-pump.test.ts @@ -337,6 +337,31 @@ describe('createAgentStreamPump', () => { expect(result.fullyDrained).toBe(false) }) + it('preserves drained answerText when user abort surfaces as AbortError from reader', async () => { + const controller = new AbortController() + const source = new ReadableStream({ + start(c) { + c.enqueue({ type: 'text_delta', text: 'kept answer', turn: 'final' }) + c.enqueue({ type: 'thinking_delta', text: 'still thinking' }) + // Abort while the reader is waiting for more — cancel() rejects read(). + queueMicrotask(() => controller.abort('user')) + }, + }) + + const pump = createAgentStreamPump({ + source, + streamFormat: 'agent-events-v1', + sinkMode: true, + abortSignal: controller.signal, + }) + + const result = await pump.run() + expect(result.cancelled).toBe(true) + expect(result.cancelReason).toBe('user') + expect(result.answerText).toBe('kept answer') + expect(result.fullyDrained).toBe(false) + }) + it('does not start until run() — subscribe-before-pull', async () => { let pulled = false const source = new ReadableStream({ diff --git a/apps/sim/providers/stream-pump.ts b/apps/sim/providers/stream-pump.ts index 1e4ff84f4d1..c8bc2ef3b7c 100644 --- a/apps/sim/providers/stream-pump.ts +++ b/apps/sim/providers/stream-pump.ts @@ -335,6 +335,16 @@ export function createAgentStreamPump(options: CreateAgentStreamPumpOptions): Ag } } + // reader.cancel() / aborted read often surfaces as AbortError. That is the + // cancel path, not a hard drain failure — keep accumulated answerText. + const isAbortError = + (drainError instanceof DOMException && drainError.name === 'AbortError') || + (drainError instanceof Error && drainError.name === 'AbortError') + if (!cancelled && isAbortError && abortSignal?.aborted) { + cancelled = true + cancelReason = resolveCancelReason(abortSignal) + } + // Synthesize tool ends — enqueue-then-error in provider loops can drop // settlement frames (WHATWG resets the queue on error). if (cancelled) { @@ -343,7 +353,7 @@ export function createAgentStreamPump(options: CreateAgentStreamPumpOptions): Ag await settleOpenTools('error') } - if (drainError) { + if (drainError && !cancelled) { closeTextStream(drainError) throw drainError instanceof Error ? drainError : new Error(String(drainError)) } diff --git a/packages/db/workspace-storage-accounting-migration.test.ts b/packages/db/workspace-storage-accounting-migration.test.ts index 2b86c008d9d..9f9e4858bac 100644 --- a/packages/db/workspace-storage-accounting-migration.test.ts +++ b/packages/db/workspace-storage-accounting-migration.test.ts @@ -68,6 +68,6 @@ describe('workspace storage accounting migration', () => { readFileSync(new URL('./migrations/meta/_journal.json', import.meta.url), 'utf8') ) as { entries: Array<{ idx: number; tag: string }> } const lastEntry = journal.entries[journal.entries.length - 1] - expect(lastEntry).toMatchObject({ idx: 260, tag: '0260_unknown_sinister_six' }) + expect(lastEntry).toMatchObject({ idx: 261, tag: '0261_chat_include_thinking' }) }) }) From 3b9e24dfc78d9bdefcee19a267b7be99d363659d Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Mon, 13 Jul 2026 20:55:34 -0700 Subject: [PATCH 05/13] fix(chat): keep Stop notice when server emits cancel error Ignore terminal SSE error frames after the user aborts so "Client cancelled request" cannot overwrite "Response stopped by user". Co-authored-by: Cursor --- .../chat/hooks/use-chat-streaming.test.tsx | 52 +++++++++++++++++++ .../chat/hooks/use-chat-streaming.ts | 26 ++++++++++ 2 files changed, 78 insertions(+) diff --git a/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.test.tsx b/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.test.tsx index e78023dfb5c..652fee8b7dc 100644 --- a/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.test.tsx +++ b/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.test.tsx @@ -347,6 +347,58 @@ describe('useChatStreaming thinking + abort (Step 6)', () => { expect(assistant?.isThinkingStreaming).toBe(false) }) + it('does not replace Stop notice with server Client cancelled request error', async () => { + const abortController = new AbortController() + let resolveStream!: () => void + const streamDone = new Promise((resolve) => { + resolveStream = resolve + }) + + mockReadSSEEvents.mockImplementation(async (_source, options) => { + await options.onEvent({ + blockId: 'agent-1', + chunk: 'partial answer', + }) + await new Promise((resolve) => { + options.signal?.addEventListener( + 'abort', + () => { + // Server still emits terminal cancel error while the reader finishes. + void options.onEvent({ + event: 'error', + error: 'Client cancelled request', + }) + resolve() + }, + { once: true } + ) + streamDone.then(() => resolve()) + }) + }) + + const streamPromise = act(async () => { + await handle + .latest() + .handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn(), false, { + abortController, + }) + }) + + await flushUiBatch() + + act(() => { + handle.latest().stopStreaming(setMessages) + }) + resolveStream() + await streamPromise + + const assistant = messages.find((m) => m.id === 'msg-assistant-1') + expect(String(assistant?.content)).toContain('partial answer') + expect(String(assistant?.content)).toContain('Response stopped by user') + expect(String(assistant?.content)).not.toContain('Client cancelled request') + expect(assistant?.isStreaming).toBe(false) + }) + it('leaves thinking undefined when no thinking events arrive', async () => { mockReadSSEEvents.mockImplementation(async (_source, options) => { await options.onEvent({ diff --git a/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts b/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts index 329d5a580c0..5ddbe789ccd 100644 --- a/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts +++ b/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts @@ -322,6 +322,32 @@ export function useChatStreaming() { const { blockId, chunk: contentChunk, event: eventType } = json if (eventType === 'error' || json.event === 'error') { + // User Stop aborts the fetch; the server often still emits a terminal + // `{ event: 'error', error: 'Client cancelled request' }` before the + // SSE reader finishes. Do not overwrite the stop notice. + if (streamAbortSignal.aborted) { + settleInFlightTools(toolCallsMap, 'cancelled') + syncToolCallsRef() + const toolsSnapshot = snapshotToolCalls(toolCallOrder, toolCallsMap) + setMessages((prev) => + prev.map((msg) => + msg.id === messageId + ? { + ...msg, + isStreaming: false, + isThinkingStreaming: false, + isToolStreaming: false, + thinking: accumulatedThinking || msg.thinking, + toolCalls: toolsSnapshot ?? msg.toolCalls, + } + : msg + ) + ) + setIsLoading(false) + terminated = true + return true + } + const errorMessage = json.error || CHAT_ERROR_MESSAGES.GENERIC_ERROR settleInFlightTools(toolCallsMap, 'error') syncToolCallsRef() From 6966850a60a65d494417b0cf7b6edc9f412fa25f Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 14 Jul 2026 08:35:42 -0700 Subject: [PATCH 06/13] improvement(chat): ChatGPT-style thinking shimmer and stick-to-bottom scroll Add left-to-right shimmer on live thinking label/body, keep scroll working by shimmering an inner node, and follow the answer only while near the bottom. Co-authored-by: Cursor --- .../(interfaces)/chat/[identifier]/chat.tsx | 64 ++++++++++--------- .../chat/hooks/use-chat-streaming.test.tsx | 36 ++++------- .../chat/hooks/use-chat-streaming.ts | 14 ++-- .../agent-stream/agent-stream-chrome.test.tsx | 58 +++++++++++++++-- .../agent-stream/agent-stream-chrome.tsx | 45 +++++++++++-- .../sim/components/ui/shimmer-text.module.css | 5 +- apps/sim/components/ui/shimmer-text.tsx | 20 ++++-- 7 files changed, 167 insertions(+), 75 deletions(-) diff --git a/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx b/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx index 80369fd8935..b8e5382b0f0 100644 --- a/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx +++ b/apps/sim/app/(interfaces)/chat/[identifier]/chat.tsx @@ -99,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) @@ -135,10 +136,31 @@ export default function ChatClient({ identifier }: { identifier: string }) { const audioContextRef = useRef(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( @@ -169,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, @@ -224,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(), @@ -323,8 +330,7 @@ export default function ChatClient({ identifier }: { identifier: string }) { response, setMessages, setIsLoading, - scrollToBottom, - userHasScrolled, + () => scrollToBottom({ behavior: 'auto' }), { voiceSettings: { isVoiceEnabled: shouldPlayAudio, @@ -445,7 +451,7 @@ export default function ChatClient({ identifier }: { identifier: string }) { showScrollButton={showScrollButton} messagesContainerRef={messagesContainerRef as RefObject} messagesEndRef={messagesEndRef as RefObject} - scrollToBottom={scrollToBottom} + scrollToBottom={() => scrollToBottom({ behavior: 'smooth', force: true })} scrollToMessage={scrollToMessage} chatConfig={chatConfig} /> diff --git a/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.test.tsx b/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.test.tsx index 652fee8b7dc..d2da6d72ee5 100644 --- a/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.test.tsx +++ b/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.test.tsx @@ -139,9 +139,7 @@ describe('useChatStreaming thinking + abort (Step 6)', () => { }) await act(async () => { - await handle - .latest() - .handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn(), false) + await handle.latest().handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn()) }) await flushUiBatch() @@ -175,9 +173,7 @@ describe('useChatStreaming thinking + abort (Step 6)', () => { }) await act(async () => { - await handle - .latest() - .handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn(), false) + await handle.latest().handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn()) }) await flushUiBatch() @@ -210,9 +206,7 @@ describe('useChatStreaming thinking + abort (Step 6)', () => { }) await act(async () => { - await handle - .latest() - .handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn(), false) + await handle.latest().handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn()) }) await flushUiBatch() @@ -244,9 +238,7 @@ describe('useChatStreaming thinking + abort (Step 6)', () => { }) await act(async () => { - await handle - .latest() - .handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn(), false) + await handle.latest().handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn()) }) await flushUiBatch() @@ -277,7 +269,7 @@ describe('useChatStreaming thinking + abort (Step 6)', () => { await act(async () => { await handle .latest() - .handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn(), false, { + .handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn(), { voiceSettings: { isVoiceEnabled: true, voiceId: 'voice-1', @@ -324,7 +316,7 @@ describe('useChatStreaming thinking + abort (Step 6)', () => { const streamPromise = act(async () => { await handle .latest() - .handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn(), false, { + .handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn(), { abortController, }) }) @@ -379,7 +371,7 @@ describe('useChatStreaming thinking + abort (Step 6)', () => { const streamPromise = act(async () => { await handle .latest() - .handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn(), false, { + .handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn(), { abortController, }) }) @@ -412,9 +404,7 @@ describe('useChatStreaming thinking + abort (Step 6)', () => { }) await act(async () => { - await handle - .latest() - .handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn(), false) + await handle.latest().handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn()) }) await flushUiBatch() @@ -475,9 +465,7 @@ describe('useChatStreaming tool lifecycle (Step 8)', () => { }) await act(async () => { - await handle - .latest() - .handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn(), false) + await handle.latest().handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn()) }) await flushUiBatch() @@ -537,7 +525,7 @@ describe('useChatStreaming tool lifecycle (Step 8)', () => { const streamPromise = act(async () => { await handle .latest() - .handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn(), false, { + .handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn(), { abortController, }) }) @@ -573,9 +561,7 @@ describe('useChatStreaming tool lifecycle (Step 8)', () => { }) await act(async () => { - await handle - .latest() - .handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn(), false) + await handle.latest().handleStreamedResponse(makeSseResponse(), setMessages, vi.fn(), vi.fn()) }) await flushUiBatch() diff --git a/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts b/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts index 5ddbe789ccd..50c3764b465 100644 --- a/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts +++ b/apps/sim/app/(interfaces)/chat/hooks/use-chat-streaming.ts @@ -205,7 +205,6 @@ export function useChatStreaming() { setMessages: React.Dispatch>, setIsLoading: React.Dispatch>, scrollToBottom: () => void, - userHasScrolled?: boolean, streamingOptions?: StreamingOptions ) => { logger.info('[useChatStreaming] handleStreamedResponse called') @@ -280,6 +279,10 @@ export function useChatStreaming() { } }) ) + // Caller supplies a stick-to-bottom-aware scroller (no-ops if user scrolled away). + requestAnimationFrame(() => { + scrollToBottom() + }) } const scheduleUIFlush = () => { @@ -740,11 +743,10 @@ export function useChatStreaming() { setIsStreamingResponse(false) abortControllerRef.current = null - if (!userHasScrolled) { - setTimeout(() => { - scrollToBottom() - }, 300) - } + // Stick-to-bottom-aware; no-ops if the user scrolled away mid-stream. + setTimeout(() => { + scrollToBottom() + }, 300) if (shouldPlayAudio) { streamingOptions?.onAudioEnd?.() diff --git a/apps/sim/components/agent-stream/agent-stream-chrome.test.tsx b/apps/sim/components/agent-stream/agent-stream-chrome.test.tsx index af9ddd245d4..0dd978ee0fd 100644 --- a/apps/sim/components/agent-stream/agent-stream-chrome.test.tsx +++ b/apps/sim/components/agent-stream/agent-stream-chrome.test.tsx @@ -13,6 +13,27 @@ vi.mock('@/lib/copilot/tools/tool-display', () => ({ humanizeToolName: (name: string) => name, })) +vi.mock('@/components/ui', () => ({ + ShimmerText: ({ + as: Comp = 'span', + children, + className, + ...props + }: { + as?: 'span' | 'div' + children: React.ReactNode + className?: string + [key: string]: unknown + }) => { + const Tag = Comp + return ( + + {children} + + ) + }, +})) + import { AgentStreamThinkingChrome } from '@/components/agent-stream/agent-stream-chrome' function renderChrome(props: { thinking: string; isStreaming?: boolean }): { @@ -70,8 +91,19 @@ describe('AgentStreamThinkingChrome', () => { expect(toggle.getAttribute('aria-expanded')).toBe('true') expect(toggle.textContent).toContain('Thinking…') + expect( + container + .querySelector('[data-testid="agent-stream-thinking-label"]') + ?.getAttribute('data-shimmer') + ).toBe('true') expect(body.className).toContain('max-h-40') expect(body.className).toContain('overflow-y-auto') + expect(body.getAttribute('data-shimmer')).toBeNull() + expect( + container + .querySelector('[data-testid="agent-stream-thinking-shimmer"]') + ?.getAttribute('data-shimmer') + ).toBe('true') expect(body.textContent).toContain('step one') }) @@ -87,36 +119,50 @@ describe('AgentStreamThinkingChrome', () => { const toggle = container.querySelector( '[data-testid="agent-stream-thinking-toggle"]' ) as HTMLButtonElement + const body = container.querySelector( + '[data-testid="agent-stream-thinking-body"]' + ) as HTMLDivElement expect(toggle.getAttribute('aria-expanded')).toBe('false') expect(toggle.textContent).toContain('Thought for a moment') + expect( + container + .querySelector('[data-testid="agent-stream-thinking-label"]') + ?.getAttribute('data-shimmer') + ).toBeNull() + expect(container.querySelector('[data-testid="agent-stream-thinking-shimmer"]')).toBeNull() + expect(body.className).toContain('text-[var(--text-muted)]') }) it('stays open after manual reopen once collapsed', () => { const { container, rerender, unmount } = renderChrome({ - thinking: 'reason', + thinking: 'reason\n'.repeat(40), isStreaming: true, }) mounts.push(unmount) - rerender({ thinking: 'reason', isStreaming: false }) + rerender({ thinking: 'reason\n'.repeat(40), isStreaming: false }) const toggle = container.querySelector( '[data-testid="agent-stream-thinking-toggle"]' ) as HTMLButtonElement expect(toggle.getAttribute('aria-expanded')).toBe('false') + const body = container.querySelector( + '[data-testid="agent-stream-thinking-body"]' + ) as HTMLDivElement + Object.defineProperty(body, 'scrollTop', { value: 80, writable: true, configurable: true }) + act(() => { toggle.click() }) expect(toggle.getAttribute('aria-expanded')).toBe('true') - expect( - container.querySelector('[data-testid="agent-stream-thinking-body"]')?.textContent - ).toContain('reason') + expect(body.scrollTop).toBe(0) + expect(body.textContent).toContain('reason') // Re-render with same done state should not force-close a user pin. - rerender({ thinking: 'reason', isStreaming: false }) + rerender({ thinking: 'reason\n'.repeat(40), isStreaming: false }) expect(toggle.getAttribute('aria-expanded')).toBe('true') }) diff --git a/apps/sim/components/agent-stream/agent-stream-chrome.tsx b/apps/sim/components/agent-stream/agent-stream-chrome.tsx index d6790255a57..6d40ee2ae77 100644 --- a/apps/sim/components/agent-stream/agent-stream-chrome.tsx +++ b/apps/sim/components/agent-stream/agent-stream-chrome.tsx @@ -3,6 +3,7 @@ import { useEffect, useLayoutEffect, useRef, useState } from 'react' import { cn } from '@sim/emcn' import { Check, ChevronDown, Circle, Square, X } from 'lucide-react' +import { ShimmerText } from '@/components/ui' import { humanizeToolName } from '@/lib/copilot/tools/tool-display' export type AgentStreamToolStatus = 'running' | 'success' | 'error' | 'cancelled' @@ -32,6 +33,8 @@ export function AgentStreamThinkingChrome({ const [overflowing, setOverflowing] = useState(false) const scrollRef = useRef(null) const wasStreamingRef = useRef(!!isStreaming) + /** After a manual reopen of completed thoughts, jump to the top once. */ + const reopenFromTopRef = useRef(false) useEffect(() => { const wasStreaming = wasStreamingRef.current @@ -56,6 +59,12 @@ export function AgentStreamThinkingChrome({ setOverflowing(el.scrollHeight > el.clientHeight + 1) + if (reopenFromTopRef.current && !isStreaming) { + el.scrollTop = 0 + reopenFromTopRef.current = false + return + } + if (isStreaming && stickToBottom) { el.scrollTop = el.scrollHeight } @@ -70,7 +79,13 @@ export function AgentStreamThinkingChrome({ setUserPinnedOpen(false) } if (next) { - setStickToBottom(true) + if (isStreaming) { + setStickToBottom(true) + } else { + // ChatGPT-style: reopen completed thoughts from the top. + setStickToBottom(false) + reopenFromTopRef.current = true + } } return next }) @@ -103,7 +118,16 @@ export function AgentStreamThinkingChrome({ )} strokeWidth={2} /> - {label} + {isStreaming ? ( + + {label} + + ) : ( + {label} + )}
- {thinking} + {/* Shimmer on an inner node — never on the scroll shell. background-clip:text + on overflow-y-auto breaks scroll/overflow in Chromium. */} + {isStreaming ? ( + + {thinking} + + ) : ( + thinking + )}
{open && isStreaming && overflowing && (
= { + as?: T children: React.ReactNode className?: string -} +} & Omit, 'as' | 'children' | 'className'> /** * Sweeping-highlight shimmer over a text phrase — the same treatment as the @@ -12,6 +14,16 @@ interface ShimmerTextProps { * Size and weight come from the consumer's className; the gradient replaces * the text color, so color classes are ignored while shimmering. */ -export function ShimmerText({ children, className }: ShimmerTextProps) { - return {children} +export function ShimmerText({ + as, + children, + className, + ...props +}: ShimmerTextProps) { + const Comp = as ?? 'span' + return ( + + {children} + + ) } From eac2a222ad4f1074214a8ab2c1d9a613c6322a78 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 14 Jul 2026 08:39:58 -0700 Subject: [PATCH 07/13] fix(agent-stream): stop pump on client disconnect; soft-complete agents only Abort the agent stream pump when the projected HTTP body is cancelled so provider work does not continue after disconnect. Limit AbortError soft-success to Agent blocks so Function/HTTP cancels still fail in logs. Co-authored-by: Cursor --- .../executor/execution/block-executor.test.ts | 49 +++++++++++++++++++ apps/sim/executor/execution/block-executor.ts | 9 ++-- apps/sim/providers/stream-pump.test.ts | 37 ++++++++++++++ apps/sim/providers/stream-pump.ts | 27 +++++++++- 4 files changed, 117 insertions(+), 5 deletions(-) diff --git a/apps/sim/executor/execution/block-executor.test.ts b/apps/sim/executor/execution/block-executor.test.ts index 768429a0954..9d8d6931f05 100644 --- a/apps/sim/executor/execution/block-executor.test.ts +++ b/apps/sim/executor/execution/block-executor.test.ts @@ -387,6 +387,55 @@ describe('BlockExecutor', () => { ) expect(state.getBlockOutput(block.id)).toEqual(output) }) + + it('does not soft-succeed non-agent blocks on user AbortError', async () => { + const block = createBlock() + const workflow: SerializedWorkflow = { + version: '1', + blocks: [block], + connections: [], + loops: {}, + parallels: {}, + } + const state = new ExecutionState() + const resolver = new VariableResolver(workflow, {}, state) + const abortController = new AbortController() + const handler: BlockHandler = { + canHandle: () => true, + execute: async () => { + abortController.abort('user') + throw new DOMException('The operation was aborted.', 'AbortError') + }, + } + const executor = new BlockExecutor( + [handler], + resolver, + { + workspaceId: 'workspace-1', + executionId: 'execution-1', + userId: 'user-1', + metadata: { + requestId: 'request-1', + executionId: 'execution-1', + workflowId: 'workflow-1', + workspaceId: 'workspace-1', + userId: 'user-1', + triggerType: 'manual', + useDraftState: false, + startTime: new Date().toISOString(), + }, + }, + state + ) + const ctx = createContext(state) + ctx.abortSignal = abortController.signal + + await expect(executor.execute(ctx, createNode(block), block)).rejects.toThrow(/abort/i) + + const output = state.getBlockOutput(block.id) + expect(output?.error).toBeTruthy() + expect(output).not.toEqual({ content: '' }) + }) }) describe('BlockExecutor streaming pump (Step 3)', () => { diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index 29ad84e9d6e..01ac5cd5bd7 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -379,13 +379,16 @@ export class BlockExecutor { ? inputsForLog : ((block.config?.params as Record | undefined) ?? {}) - // Routine user Stop: don't paint a failed agent block (workflow is already cancelled). - // Timeouts abort with reason `'timeout'` (see createTimeoutAbortController). + // Routine user Stop on Agent streams: don't paint a failed agent block + // (workflow is already cancelled). Timeouts abort with reason `'timeout'`. + // Non-agent blocks (HTTP, Function, etc.) still fail normally on AbortError + // so logs don't show a green empty success. const isAbort = (error instanceof DOMException && error.name === 'AbortError') || (error instanceof Error && error.name === 'AbortError') const isTimeout = ctx.abortSignal?.reason === 'timeout' - if (isAbort && !isTimeout && ctx.abortSignal?.aborted) { + const isAgentBlock = block.metadata?.id === BlockType.AGENT + if (isAbort && !isTimeout && ctx.abortSignal?.aborted && isAgentBlock) { const softOutput: NormalizedBlockOutput = { content: '', } diff --git a/apps/sim/providers/stream-pump.test.ts b/apps/sim/providers/stream-pump.test.ts index c3826bd4703..e1036ab45a0 100644 --- a/apps/sim/providers/stream-pump.test.ts +++ b/apps/sim/providers/stream-pump.test.ts @@ -516,4 +516,41 @@ describe('projectStreamingExecutionToByteStream', () => { }) expect(await readAllText(byteStream)).toBe('raw') }) + + it('aborts the agent pump when the projected byte stream is cancelled', async () => { + const { projectStreamingExecutionToByteStream } = await import('@/providers/stream-pump') + let sourceCancelled = false + let resolveHang!: () => void + const hang = new Promise((resolve) => { + resolveHang = resolve + }) + + const source = new ReadableStream({ + start(c) { + c.enqueue({ type: 'text_delta', text: 'hi', turn: 'final' }) + }, + async pull() { + // Stay open until the pump cancels the source on client disconnect. + await hang + }, + cancel() { + sourceCancelled = true + resolveHang() + }, + }) + + const byteStream = projectStreamingExecutionToByteStream({ + stream: source, + streamFormat: 'agent-events-v1', + }) + const reader = byteStream.getReader() + const first = await reader.read() + expect(first.done).toBe(false) + expect(new TextDecoder().decode(first.value)).toBe('hi') + + await reader.cancel('client disconnect') + await hang + + expect(sourceCancelled).toBe(true) + }) }) diff --git a/apps/sim/providers/stream-pump.ts b/apps/sim/providers/stream-pump.ts index c8bc2ef3b7c..682834dfd1d 100644 --- a/apps/sim/providers/stream-pump.ts +++ b/apps/sim/providers/stream-pump.ts @@ -387,6 +387,9 @@ export function createAgentStreamPump(options: CreateAgentStreamPumpOptions): Ag * Project a {@link StreamingExecution} to a byte stream suitable for an HTTP * `Response` body. Agent-events object streams are pumped to final-turn answer * bytes; legacy `text` streams pass through unchanged. + * + * Cancelling the returned stream aborts the pump so provider work stops when + * the HTTP client disconnects (billing/provider reads do not continue). */ export function projectStreamingExecutionToByteStream(streamingExec: { stream: ReadableStream @@ -397,19 +400,23 @@ export function projectStreamingExecutionToByteStream(streamingExec: { return streamingExec.stream as ReadableStream } + const abortController = new AbortController() const pump = createAgentStreamPump({ source: streamingExec.stream, streamFormat, + abortSignal: abortController.signal, }) const textStream = pump.textStream if (!textStream) { throw new Error('Agent stream pump expected a text projection stream') } + let reader: ReadableStreamDefaultReader | undefined + return new ReadableStream({ async start(controller) { const runPromise = pump.run() - const reader = textStream.getReader() + reader = textStream.getReader() try { while (true) { const { done, value } = await reader.read() @@ -419,15 +426,31 @@ export function projectStreamingExecutionToByteStream(streamingExec: { await runPromise controller.close() } catch (error) { + if (abortController.signal.aborted) { + try { + controller.close() + } catch { + // already closed/errored + } + return + } try { controller.error(error instanceof Error ? error : new Error(String(error))) } catch { // already closed/errored } + } finally { + try { + reader?.releaseLock() + } catch { + // ignore + } } }, cancel(reason) { - void textStream.cancel(reason) + abortController.abort(reason ?? 'user') + void reader?.cancel(reason).catch(() => {}) + void textStream.cancel(reason).catch(() => {}) }, }) } From 2533f7ec61ab584a238d8c24e219730959eceb83 Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 14 Jul 2026 08:43:20 -0700 Subject: [PATCH 08/13] fix(agent-stream): persist includeThinking across pause snapshots Paused chat runs with Include thinking enabled were dropping the flag when serializing the pause snapshot, so resume always rebuilt streams without thinking/tool SSE frames. Co-authored-by: Cursor --- .../execution/snapshot-serializer.test.ts | 23 +++++++++++++++++++ .../executor/execution/snapshot-serializer.ts | 2 ++ 2 files changed, 25 insertions(+) diff --git a/apps/sim/executor/execution/snapshot-serializer.test.ts b/apps/sim/executor/execution/snapshot-serializer.test.ts index cf3198e9c6f..ead4073efaa 100644 --- a/apps/sim/executor/execution/snapshot-serializer.test.ts +++ b/apps/sim/executor/execution/snapshot-serializer.test.ts @@ -189,4 +189,27 @@ describe('serializePauseSnapshot', () => { expect(serialized.metadata.billingAttribution).toEqual(billingAttribution) }) + + it('preserves includeThinking on pause so chat resume can emit thinking SSE', () => { + const context = createContext({ + metadata: { + ...createContext().metadata, + includeThinking: true, + executionMode: 'stream', + }, + }) + + const snapshot = serializePauseSnapshot(context, ['next-block']) + const serialized = JSON.parse(snapshot.snapshot) + + expect(serialized.metadata.includeThinking).toBe(true) + expect(serialized.metadata.executionMode).toBe('stream') + }) + + it('omits includeThinking when the live run did not enable it', () => { + const snapshot = serializePauseSnapshot(createContext(), ['next-block']) + const serialized = JSON.parse(snapshot.snapshot) + + expect(serialized.metadata.includeThinking).toBeUndefined() + }) }) diff --git a/apps/sim/executor/execution/snapshot-serializer.ts b/apps/sim/executor/execution/snapshot-serializer.ts index e26efc9d1df..325f6488d4c 100644 --- a/apps/sim/executor/execution/snapshot-serializer.ts +++ b/apps/sim/executor/execution/snapshot-serializer.ts @@ -252,6 +252,8 @@ export function serializePauseSnapshot( startTime: metadataFromContext?.startTime ?? new Date().toISOString(), isClientSession: metadataFromContext?.isClientSession, executionMode: metadataFromContext?.executionMode, + // Preserve deployed-chat thinking gate across HITL pause/resume. + includeThinking: metadataFromContext?.includeThinking === true ? true : undefined, } const snapshot = new ExecutionSnapshot( From 8618407365880bc485fbf3a5aa9885ccda66891f Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 14 Jul 2026 08:48:40 -0700 Subject: [PATCH 09/13] fix(agent-stream): keep drained answer text when stream times out Persist pump answerText onto the streaming execution before throwing on timeout, and carry that partial content into the failed block output so logs match what the client already saw. Co-authored-by: Cursor --- .../executor/execution/block-executor.test.ts | 35 +++++++++++++ apps/sim/executor/execution/block-executor.ts | 52 ++++++++++++++----- 2 files changed, 74 insertions(+), 13 deletions(-) diff --git a/apps/sim/executor/execution/block-executor.test.ts b/apps/sim/executor/execution/block-executor.test.ts index 9d8d6931f05..29f65a25f75 100644 --- a/apps/sim/executor/execution/block-executor.test.ts +++ b/apps/sim/executor/execution/block-executor.test.ts @@ -675,6 +675,41 @@ describe('BlockExecutor streaming pump (Step 3)', () => { expect(output).not.toMatchObject({ error: expect.any(String) }) }) + it('fails on timeout but keeps drained answer text in block output', async () => { + const abortController = new AbortController() + const handler = createAgentEventsStreamingHandler({ + events: [ + { type: 'text_delta', text: 'partial before timeout', turn: 'final' }, + { type: 'thinking_delta', text: 'more' }, + ], + }) + + const { executor, block, state } = createExecutor(handler) + const ctx = createContext(state) + ctx.abortSignal = abortController.signal + ctx.onStream = async (streamingExec) => { + streamingExec.subscribe?.({ onEvent: async () => {} }) + const reader = streamingExec.stream.getReader() + try { + const first = await reader.read() + expect(first.done).toBe(false) + abortController.abort('timeout') + while (true) { + const { done } = await reader.read() + if (done) break + } + } catch { + // timeout may cancel the text stream + } + } + + await expect(executor.execute(ctx, createNode(block), block)).rejects.toThrow(/timed out/i) + + const output = state.getBlockOutput(block.id) + expect(output?.error).toBeTruthy() + expect(output?.content).toBe('partial before timeout') + }) + it('with PII redaction: no live forward and strips thinking from traces', async () => { const { redactObjectStrings } = await import('@/lib/logs/execution/pii-redaction') vi.mocked(redactObjectStrings).mockImplementation(async (value) => { diff --git a/apps/sim/executor/execution/block-executor.ts b/apps/sim/executor/execution/block-executor.ts index 01ac5cd5bd7..1e856c07edf 100644 --- a/apps/sim/executor/execution/block-executor.ts +++ b/apps/sim/executor/execution/block-executor.ts @@ -177,6 +177,7 @@ export class BlockExecutor { } cleanupSelfReference?.() + let streamingPartialOutput: Record | undefined try { const output = handler.executeWithNode ? await handler.executeWithNode(ctx, block, resolvedInputs, nodeMetadata) @@ -193,14 +194,21 @@ export class BlockExecutor { // even with no `onStream`. When block-output redaction is on we do not // live-forward chunks; content is masked before persist and the masked // final output reaches the client via block-complete. - await this.handleStreamingExecution( - ctx, - node, - block, - streamingExec, - resolvedInputs, - normalizeStringArray(ctx.selectedOutputs) - ) + try { + await this.handleStreamingExecution( + ctx, + node, + block, + streamingExec, + resolvedInputs, + normalizeStringArray(ctx.selectedOutputs) + ) + } catch (streamError) { + // Timeout / drain failures may still have projected answer text — keep it + // for the failed block output so logs match what the client already saw. + streamingPartialOutput = streamingExec.execution?.output + throw streamError + } normalizedOutput = this.normalizeOutput( streamingExec.execution.output ?? streamingExec.execution @@ -311,7 +319,8 @@ export class BlockExecutor { blockLog, inputsForLog, isSentinel, - 'execution' + 'execution', + streamingPartialOutput ) } } @@ -368,7 +377,8 @@ export class BlockExecutor { blockLog: BlockLog | undefined, inputsForLog: Record, isSentinel: boolean, - phase: 'input_resolution' | 'execution' + phase: 'input_resolution' | 'execution', + streamingPartialOutput?: Record ): Promise { const endedAt = new Date().toISOString() const duration = performance.now() - startTime @@ -431,6 +441,13 @@ export class BlockExecutor { error: errorMessage, } + // Keep any answer text already drained before timeout/failure so logs match + // what was projected to the client. + const partialContent = streamingPartialOutput?.content + if (typeof partialContent === 'string' && partialContent) { + errorOutput.content = partialContent + } + if (ChildWorkflowError.isChildWorkflowError(error)) { errorOutput.childWorkflowName = error.childWorkflowName if (error.childWorkflowSnapshotId) { @@ -881,10 +898,19 @@ export class BlockExecutor { await onStreamPromise } - // Timeout still fails the block. User Stop soft-completes with any drained - // answer text so logs don't show a scary red agent block for a routine cancel - // (workflow status remains `cancelled` via the engine abort flag). + // Timeout still fails the block, but keep any drained answer text so logs + // match what was already projected to the client before the deadline. + // User Stop soft-completes below so logs don't show a scary red agent block + // for a routine cancel (workflow status remains `cancelled` via abort). if (pumpResult.cancelled && pumpResult.cancelReason === 'timeout') { + const truncated = pumpResult.answerText + if (truncated && streamingExec.execution?.output) { + streamingExec.execution.output.content = truncated + } + this.execLogger.warn('Stream timed out; persisting drained answer before failing block', { + blockId, + hasContent: Boolean(truncated), + }) throw new DOMException('Provider request timed out', 'AbortError') } From db4e26e12b0beeaafebf144ccc86a583d36b815a Mon Sep 17 00:00:00 2001 From: Bill Leoutsakos Date: Tue, 14 Jul 2026 09:04:53 -0700 Subject: [PATCH 10/13] improvement(chat): auto-collapse tools chrome when tool streaming ends Match thinking UX: open while tools run, collapse when finished, and keep the panel open only if the user manually reopens it. Co-authored-by: Cursor --- .../agent-stream/agent-stream-chrome.test.tsx | 102 +++++++++++++++++- .../agent-stream/agent-stream-chrome.tsx | 30 +++++- 2 files changed, 129 insertions(+), 3 deletions(-) diff --git a/apps/sim/components/agent-stream/agent-stream-chrome.test.tsx b/apps/sim/components/agent-stream/agent-stream-chrome.test.tsx index 0dd978ee0fd..09502ec70d6 100644 --- a/apps/sim/components/agent-stream/agent-stream-chrome.test.tsx +++ b/apps/sim/components/agent-stream/agent-stream-chrome.test.tsx @@ -34,7 +34,11 @@ vi.mock('@/components/ui', () => ({ }, })) -import { AgentStreamThinkingChrome } from '@/components/agent-stream/agent-stream-chrome' +import { + AgentStreamThinkingChrome, + type AgentStreamToolCall, + AgentStreamToolCallsChrome, +} from '@/components/agent-stream/agent-stream-chrome' function renderChrome(props: { thinking: string; isStreaming?: boolean }): { container: HTMLDivElement @@ -184,3 +188,99 @@ describe('AgentStreamThinkingChrome', () => { expect(toggle.textContent).toContain('Thinking…') }) }) + +const sampleTools: AgentStreamToolCall[] = [ + { + key: 'agent-1:t1', + id: 't1', + name: 'http_request', + displayName: 'Http Request', + status: 'success', + }, +] + +function renderToolsChrome(props: { toolCalls?: AgentStreamToolCall[]; isStreaming?: boolean }): { + container: HTMLDivElement + rerender: (next: { toolCalls?: AgentStreamToolCall[]; isStreaming?: boolean }) => void + 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) + + const mount = (p: { toolCalls?: AgentStreamToolCall[]; isStreaming?: boolean }) => { + act(() => { + root.render( + + ) + }) + } + + mount(props) + + return { + container, + rerender: (next) => mount(next), + unmount: () => { + act(() => { + root.unmount() + }) + container.remove() + }, + } +} + +describe('AgentStreamToolCallsChrome', () => { + const mounts: Array<() => void> = [] + + afterEach(() => { + while (mounts.length) { + mounts.pop()?.() + } + }) + + it('opens while tools are streaming and auto-collapses when they finish', () => { + const { container, rerender, unmount } = renderToolsChrome({ + isStreaming: true, + toolCalls: [{ ...sampleTools[0], status: 'running' }], + }) + mounts.push(unmount) + + const toggle = container.querySelector( + '[data-testid="agent-stream-tools-toggle"]' + ) as HTMLButtonElement + expect(toggle.getAttribute('aria-expanded')).toBe('true') + expect(toggle.textContent).toContain('Using tools…') + expect(container.textContent).toContain('Http Request') + + rerender({ isStreaming: false, toolCalls: sampleTools }) + expect(toggle.getAttribute('aria-expanded')).toBe('false') + expect(toggle.textContent).toContain('Tools') + expect(container.textContent).not.toContain('Http Request') + }) + + it('stays open after manual reopen once collapsed', () => { + const { container, rerender, unmount } = renderToolsChrome({ isStreaming: true }) + mounts.push(unmount) + + rerender({ isStreaming: false }) + + const toggle = container.querySelector( + '[data-testid="agent-stream-tools-toggle"]' + ) as HTMLButtonElement + expect(toggle.getAttribute('aria-expanded')).toBe('false') + + act(() => { + toggle.click() + }) + expect(toggle.getAttribute('aria-expanded')).toBe('true') + expect(container.textContent).toContain('Http Request') + + rerender({ isStreaming: false }) + expect(toggle.getAttribute('aria-expanded')).toBe('true') + }) +}) diff --git a/apps/sim/components/agent-stream/agent-stream-chrome.tsx b/apps/sim/components/agent-stream/agent-stream-chrome.tsx index 6d40ee2ae77..9bac3fd8bee 100644 --- a/apps/sim/components/agent-stream/agent-stream-chrome.tsx +++ b/apps/sim/components/agent-stream/agent-stream-chrome.tsx @@ -197,20 +197,46 @@ export function AgentStreamToolCallsChrome({ isStreaming?: boolean }) { const [open, setOpen] = useState(!!isStreaming) + /** After auto-collapse, a manual open pins the panel until the user closes it. */ + const [userPinnedOpen, setUserPinnedOpen] = useState(false) + const wasStreamingRef = useRef(!!isStreaming) useEffect(() => { + const wasStreaming = wasStreamingRef.current + wasStreamingRef.current = !!isStreaming + if (isStreaming) { setOpen(true) + setUserPinnedOpen(false) + return + } + + // Auto-collapse when tools finish, unless the user pinned the panel open. + if (wasStreaming && !isStreaming && !userPinnedOpen) { + setOpen(false) } - }, [isStreaming]) + }, [isStreaming, userPinnedOpen]) + + const handleToggle = () => { + setOpen((prev) => { + const next = !prev + if (!isStreaming) { + setUserPinnedOpen(next) + } else { + setUserPinnedOpen(false) + } + return next + }) + } return (