-
-
+
+
- |
+ |
Open your workspaceOpen Sim
|
@@ -175,93 +193,86 @@
- |
+ |
- 
-
+ />
|
- |
+ |
- Sim Desktop
+ Your workspace, on your Mac
|
- |
+ |
- Chat with Sim, browse signed-in sites, and run terminal commands in one Mac app. Your workspace, side
- by side.
+ Bring Chat, a built-in browser, terminal sessions, and
+ approved local files into one macOS app. Sign in with your
+ existing Sim account. On supported deployments, you can also
+ enable native Mac app control.
|
-
-
+
+
-
-
-
- |
-
-
- |
+ |
- Sim CLI
+ Sim, from your terminal
|
- |
+ |
- Run workflows, query tables, and inspect logs from your terminal. Built for scripts, CI pipelines, and
- coding agents.
+ Run workflows, query tables, move files, and inspect logs.
+ Structured JSON output fits into scripts, CI pipelines, and
+ coding agents. Find commands by task with
+ sim cli search.
|
-
-
+
+
+
+
+ bun add -g sim sim login sim workflows
+ list
+ |
+
+
+ |
+
+
+
+
|
Install Sim CLIGet started with the CLI
|
@@ -427,56 +437,54 @@
- |
+ |
- Sim MCP
+ Use Sim from Claude, Codex, or Cursor
|
- |
+ |
- Bring your Sim workspace into Claude, Codex, or Cursor. Run workflows, query tables, and manage files
- from the AI tools you already use.
+ Connect through MCP, the standard that lets AI apps use tools.
+ Run and deploy workflows, query tables, and work with files
+ from your preferred AI app.
|
-
-
+
+
-
+
-
-
+ Live answers from your company’s tools
+
|
-
-
+
- Sim Search for enterprise
-
+ Search for Enterprise teams
+
|
- |
+ |
- Search Slack, Google Drive, GitHub, and more. Get live answers with citations, while respecting each
- person’s access.
+ Ask questions across connected Slack, Google Drive, GitHub,
+ Gmail, and more, with links back to the sources. Search
+ queries sources live and now ranks closer matches across apps
+ while checking each person’s current access.
|
-
-
+
+
|
+ |
+
+ Also new in your workspace
+
+ |
+
+
+ |
+
+ Chat finds the tools you need. Sim can discover connected integrations, workspace MCP
+ servers, and skills as you work.
+
+ |
+
+
+ |
+
+ Compare before you deploy. Review prompt edits, steps, and connections in a draft
+ or two saved deployments. Enterprise teams with workspace
+ forks can also review source changes before syncing.
+
+ |
+
+
+ |
+
+ Try a different model. GPT-6.1 Sol, Claude Haiku 5.5, Grok 4.7, and Gemini 4
+ Argon are available in the Agent block. Nebius Token Factory
+ adds open models with your own API key.
+
+ |
+
+
+ |
+
+ Connect more of your team’s work. New integrations include Help Scout, Fivetran, Oracle
+ Database, Checkr, Power BI, and Microsoft Intune, with
+ expanded Excel and Greenhouse support.
+
+ |
+
+
+ |
+ See all recent updates
+
+ |
+
+
+ |
+
- Thanks for building with us. The Sim team
+ Try it on something real. Open a new Chat and ask: “Build an agent that summarizes new support tickets and
+ posts a daily digest to Slack.”
|
-
-
+
+
- |
+ |
Open your workspaceOpen Sim
|
@@ -699,60 +894,75 @@
- |
+ |
+
+ Thanks for building with us. The Sim team
+
+ |
+
+
+ |
|
- |
- Sim, 80 Langton St, San Francisco, CA 94103, USA
+ Sim · 80 Langton St, San Francisco, CA 94103, USA
Questions?
help@sim.ai
Privacy Policy
- ·
+ ·
Terms of Service
- ·
+ ·
Unsubscribe
- © 2026 Sim, All Rights Reserved
+ © 2026 Sim. All rights reserved.
|
diff --git a/apps/sim/lib/api/contracts/copilot-messages.ts b/apps/sim/lib/api/contracts/copilot-messages.ts
index dea3d32a4ea..e20fca16841 100644
--- a/apps/sim/lib/api/contracts/copilot-messages.ts
+++ b/apps/sim/lib/api/contracts/copilot-messages.ts
@@ -1,3 +1,4 @@
+import { isRecordLike } from '@sim/utils/object'
import { z } from 'zod'
import {
MothershipStreamV1CompletionStatus,
@@ -8,6 +9,7 @@ import {
MothershipStreamV1ToolOutcome,
MothershipStreamV1ToolPhase,
} from '@/lib/mothership/generated/mothership-stream-v1'
+import type { ToolActivity } from '@/lib/mothership/generated/protocol'
const persistedToolStateSchema = z.enum([
...Object.values(MothershipStreamV1ToolOutcome),
@@ -23,6 +25,12 @@ const persistedToolCallSchema = z.object({
name: z.string(),
state: persistedToolStateSchema,
activityDescription: z.string().optional(),
+ /**
+ * Display grouping, held beside `params`; older rows carry it in `params.activity`. Validated
+ * where history is read (`normalizeMessage`), which keeps the protocol schemas out of every page
+ * that imports this contract.
+ */
+ activity: z.custom(isRecordLike).optional(),
params: z.record(z.string(), z.unknown()).optional(),
result: z
.object({ success: z.boolean(), output: z.unknown().optional(), error: z.string().optional() })
diff --git a/apps/sim/lib/api/contracts/mothership-chat-context.ts b/apps/sim/lib/api/contracts/mothership-chat-context.ts
new file mode 100644
index 00000000000..e3c11828771
--- /dev/null
+++ b/apps/sim/lib/api/contracts/mothership-chat-context.ts
@@ -0,0 +1,152 @@
+import { z } from 'zod'
+import {
+ mothershipResourceSchema,
+ mothershipTableViewContextSchema,
+} from '@/lib/api/contracts/mothership-resources'
+import { MAX_CHAT_CONTEXT_LABEL_LENGTH } from '@/lib/mothership/chat/context-limits'
+import {
+ CHAT_SELECTION_SOURCE_TITLE_MAX_LENGTH,
+ MAX_FILE_SELECTION_TEXT_LENGTH,
+ MAX_TABLE_SELECTION_COLUMNS,
+ MAX_TABLE_SELECTION_ROWS,
+ safeBrowserSelectionUrl,
+} from '@/lib/mothership/chat/selection-context'
+import { FileOperationOwner } from '@/lib/mothership/generated/file-owner'
+
+const CHAT_SELECTION_TEXT_MAX_LENGTH = 100_000
+const CHAT_SELECTION_SOURCE_URL_MAX_LENGTH = 8_192
+const TERMINAL_SELECTION_LINE_MAX = 10_000_000
+
+/** Non-strings pass through for the schema to reject; strings are sanitized. */
+function sanitizeBrowserSelectionUrl(value: unknown): unknown {
+ return typeof value === 'string' ? safeBrowserSelectionUrl(value) : value
+}
+
+const BrowserTextSelectionSchema = z
+ .object({
+ text: z.string().min(1).max(CHAT_SELECTION_TEXT_MAX_LENGTH),
+ url: z.preprocess(
+ sanitizeBrowserSelectionUrl,
+ z.string().max(CHAT_SELECTION_SOURCE_URL_MAX_LENGTH).optional()
+ ),
+ title: z.string().max(CHAT_SELECTION_SOURCE_TITLE_MAX_LENGTH).optional(),
+ })
+ .strict()
+ .transform(({ text, title, url }) => ({
+ text,
+ ...(url ? { url } : {}),
+ ...(title ? { title } : {}),
+ }))
+
+const TerminalTextSelectionSchema = z
+ .object({
+ text: z.string().min(1).max(CHAT_SELECTION_TEXT_MAX_LENGTH),
+ startLine: z.number().int().positive().max(TERMINAL_SELECTION_LINE_MAX),
+ endLine: z.number().int().positive().max(TERMINAL_SELECTION_LINE_MAX),
+ })
+ .strict()
+ .refine(({ startLine, endLine }) => endLine >= startLine, {
+ message: 'endLine must be greater than or equal to startLine',
+ path: ['endLine'],
+ })
+
+export const chatContextSchema = z
+ .object({
+ kind: z.enum([
+ 'past_chat',
+ 'workflow',
+ 'current_workflow',
+ 'blocks',
+ 'logs',
+ 'workflow_block',
+ 'knowledge',
+ 'docs',
+ 'table',
+ 'table_selection',
+ 'file',
+ 'file_selection',
+ 'dashboard',
+ 'folder',
+ 'filefolder',
+ 'integration',
+ 'skill',
+ 'mcp',
+ 'browser_tab',
+ 'terminal_tab',
+ 'workspace',
+ 'project',
+ ]),
+ label: z.string().max(MAX_CHAT_CONTEXT_LABEL_LENGTH),
+ chatId: z.string().optional(),
+ workflowId: z.string().optional(),
+ knowledgeId: z.string().optional(),
+ blockId: z.string().optional(),
+ blockIds: z.array(z.string()).optional(),
+ blockType: z.string().min(1).max(200).optional(),
+ executionId: z.string().optional(),
+ tableId: z.string().optional(),
+ viewId: mothershipResourceSchema.shape.viewId,
+ currentView: mothershipTableViewContextSchema.optional(),
+ fileId: z.string().optional(),
+ owner: FileOperationOwner.optional(),
+ dashboardId: z.string().optional(),
+ folderId: z.string().optional(),
+ fileFolderId: z.string().optional(),
+ skillId: z.string().optional(),
+ workspaceId: z.string().min(1).max(200).optional(),
+ projectId: z.string().min(1).max(200).optional(),
+ serverId: z.string().optional(),
+ scheduleId: z.string().optional(),
+ tabId: z.string().optional(),
+ terminalId: z.string().optional(),
+ text: z.string().max(MAX_FILE_SELECTION_TEXT_LENGTH).optional(),
+ fileName: z.string().optional(),
+ startLine: z.number().int().positive().optional(),
+ endLine: z.number().int().positive().optional(),
+ tableName: z.string().optional(),
+ rowIds: z.array(z.string()).max(MAX_TABLE_SELECTION_ROWS).optional(),
+ columnIds: z.array(z.string()).max(MAX_TABLE_SELECTION_COLUMNS).optional(),
+ selection: z.union([BrowserTextSelectionSchema, TerminalTextSelectionSchema]).optional(),
+ })
+ .superRefine(({ kind, selection, workspaceId, projectId, owner }, refinementContext) => {
+ if (
+ (kind === 'project' && (!projectId || workspaceId !== undefined)) ||
+ (kind !== 'project' && projectId !== undefined)
+ ) {
+ refinementContext.addIssue({
+ code: 'custom',
+ path: ['projectId'],
+ message: 'A Project context requires only a Project target',
+ })
+ }
+ if (
+ owner &&
+ ((kind !== 'file' && kind !== 'file_selection') ||
+ (owner.entityType !== 'workspace' && workspaceId) ||
+ (owner.entityType === 'workspace' && workspaceId && owner.entityId !== workspaceId))
+ )
+ refinementContext.addIssue({
+ code: 'custom',
+ path: ['owner'],
+ message: 'File owner conflicts with the context target',
+ })
+ if (kind === 'workspace' && !workspaceId) {
+ refinementContext.addIssue({
+ code: 'custom',
+ message: 'workspaceId is required for a workspace context',
+ path: ['workspaceId'],
+ })
+ }
+ if (!selection) return
+ const isTerminalSelection = 'startLine' in selection
+ const selectionMatchesKind =
+ (kind === 'browser_tab' && !isTerminalSelection) ||
+ (kind === 'terminal_tab' && isTerminalSelection)
+ if (!selectionMatchesKind) {
+ refinementContext.addIssue({
+ code: 'custom',
+ message: 'selection must match its browser_tab or terminal_tab context kind',
+ path: ['selection'],
+ })
+ }
+ })
diff --git a/apps/sim/lib/mothership/application/file-owner-context.ts b/apps/sim/lib/mothership/application/file-owner-context.ts
index 8d4dd2dff6b..9a405a40595 100644
--- a/apps/sim/lib/mothership/application/file-owner-context.ts
+++ b/apps/sim/lib/mothership/application/file-owner-context.ts
@@ -1,7 +1,7 @@
import { createLogger } from '@sim/logger'
import { getErrorMessage } from '@sim/utils/errors'
import { generateId } from '@sim/utils/id'
-import { omit } from '@sim/utils/object'
+import { omit, toArray } from '@sim/utils/object'
import { asOrchestrationError } from '@/lib/core/orchestration/types'
import { executeCopilotProjectDiscovery } from '@/lib/mothership/application/execute-project-use-case'
import {
@@ -10,6 +10,7 @@ import {
} from '@/lib/mothership/auth/application-delegation'
import { getCopilotFileOwnerAdapter } from '@/lib/mothership/file-owners'
import type { FileOwnerContext } from '@/lib/mothership/generated/file-owner'
+import { WORKSPACE_FILE_COPY_CAPABILITY } from '@/lib/mothership/generated/protocol'
import { supportsFileOwnerProtocol } from '@/lib/mothership/request/lifecycle/file-owner-protocol'
import { getCurrentWorkspaceProject } from '@/lib/projects/application'
import { isProjectsEnabled } from '@/lib/projects/rollout.server'
@@ -17,11 +18,13 @@ import { isProjectsEnabled } from '@/lib/projects/rollout.server'
const logger = createLogger('CopilotFileOwnerContext')
/**
- * Adds current owner hints to admitted authoring turns using the worker's generated contract.
- * Hints are best-effort: a failed Project lookup sends the turn with no Project rather than failing
- * it. Support is probed on the selected worker URL, which may front several worker releases, and a
- * worker that predates the hint keys rejects them; rolling the worker back therefore requires
- * turning the `projects` flag off first.
+ * Adds what admitted authoring turns may do with files, using the worker's generated contract.
+ * Every such turn declares `workspace-file-copy`: copies between workspaces need the turn's
+ * admission but not Projects, and a worker that predates the token ignores it. With Projects on,
+ * it also gets current owner hints. Hints are best-effort: a failed Project lookup sends the turn
+ * with no Project rather than failing it. Support is probed on the selected worker URL, which may
+ * front several worker releases, and a worker that predates the hint keys rejects them; rolling
+ * the worker back therefore requires turning the `projects` flag off first.
*/
export async function withFileOwnerContext(
payload: Record,
@@ -30,14 +33,18 @@ export async function withFileOwnerContext(
route: string
): Promise> {
const clean = omit(payload, ['project', 'fileOwnerCapabilities', 'fileOwnerProtocolVersion'])
- if (
- !['/api/copilot', '/api/mothership'].includes(route) ||
- context.requestMode === 'assistant' ||
- !hasCopilotResourceAdmission(context) ||
- !(await isProjectsEnabled()) ||
- !(await supportsFileOwnerProtocol(baseURL))
+ if (!['/api/copilot', '/api/mothership'].includes(route)) return clean
+ const admitted = context.requestMode !== 'assistant' && hasCopilotResourceAdmission(context)
+ const declared = toArray(clean.clientCapabilities).filter(
+ (capability) => capability !== WORKSPACE_FILE_COPY_CAPABILITY
)
- return clean
+ const turn = admitted
+ ? { ...clean, clientCapabilities: [...declared, WORKSPACE_FILE_COPY_CAPABILITY] }
+ : clean.clientCapabilities === undefined
+ ? clean
+ : { ...clean, clientCapabilities: declared }
+ if (!admitted || !(await isProjectsEnabled()) || !(await supportsFileOwnerProtocol(baseURL)))
+ return turn
const hints: FileOwnerContext = {
fileOwnerProtocolVersion: 1,
@@ -70,5 +77,5 @@ export async function withFileOwnerContext(
})
}
}
- return { ...clean, ...hints }
+ return { ...turn, ...hints }
}
diff --git a/apps/sim/lib/mothership/chat/context.ts b/apps/sim/lib/mothership/chat/context.ts
new file mode 100644
index 00000000000..19117e18223
--- /dev/null
+++ b/apps/sim/lib/mothership/chat/context.ts
@@ -0,0 +1,70 @@
+import { isRecordLike } from '@sim/utils/object'
+import { chatContextSchema } from '@/lib/api/contracts/mothership-chat-context'
+import type { ChatContext } from '@/stores/panel'
+
+export const UNAVAILABLE_CONTEXT_NOTICE =
+ 'Some context in this message was not saved. Reattach it before sending.'
+
+/** Requires both send-valid metadata and a resource identity before restoring a stored context. */
+export function isChatContext(value: unknown): value is ChatContext {
+ if (!isRecordLike(value) || !chatContextSchema.safeParse(value).success) {
+ return false
+ }
+
+ switch (value.kind) {
+ case 'past_chat':
+ return typeof value.chatId === 'string'
+ case 'workflow':
+ case 'current_workflow':
+ return typeof value.workflowId === 'string'
+ case 'blocks':
+ return Array.isArray(value.blockIds) && value.blockIds.every((id) => typeof id === 'string')
+ case 'logs':
+ return value.executionId === undefined || typeof value.executionId === 'string'
+ case 'workflow_block':
+ return typeof value.workflowId === 'string' && typeof value.blockId === 'string'
+ case 'knowledge':
+ return value.knowledgeId === undefined || typeof value.knowledgeId === 'string'
+ case 'table':
+ return typeof value.tableId === 'string'
+ case 'table_selection':
+ return (
+ typeof value.tableId === 'string' &&
+ typeof value.tableName === 'string' &&
+ Array.isArray(value.rowIds) &&
+ value.rowIds.every((id) => typeof id === 'string')
+ )
+ case 'file':
+ return typeof value.fileId === 'string'
+ case 'file_selection':
+ return (
+ typeof value.fileId === 'string' &&
+ typeof value.fileName === 'string' &&
+ typeof value.text === 'string'
+ )
+ case 'dashboard':
+ return typeof value.dashboardId === 'string'
+ case 'project':
+ return typeof value.projectId === 'string'
+ case 'folder':
+ return typeof value.folderId === 'string'
+ case 'filefolder':
+ return typeof value.fileFolderId === 'string'
+ case 'workspace':
+ return typeof value.workspaceId === 'string'
+ case 'docs':
+ return true
+ case 'integration':
+ return typeof value.blockType === 'string'
+ case 'skill':
+ return typeof value.skillId === 'string'
+ case 'mcp':
+ return typeof value.serverId === 'string'
+ case 'browser_tab':
+ return typeof value.tabId === 'string'
+ case 'terminal_tab':
+ return typeof value.terminalId === 'string'
+ default:
+ return false
+ }
+}
diff --git a/apps/sim/lib/mothership/chat/display-message.ts b/apps/sim/lib/mothership/chat/display-message.ts
index 4123b436bf1..6454d94fc20 100644
--- a/apps/sim/lib/mothership/chat/display-message.ts
+++ b/apps/sim/lib/mothership/chat/display-message.ts
@@ -56,6 +56,7 @@ function toToolCallInfo(block: PersistedContentBlock, stored: boolean): ToolCall
status,
displayTitle: status === ToolCallStatus.cancelled ? 'Stopped by user' : tc.display?.title,
...(activityDescription ? { activityDescription } : {}),
+ ...(tc.activity ? { activity: tc.activity } : {}),
params: tc.params,
calledBy: tc.calledBy,
result: tc.result,
@@ -226,6 +227,7 @@ export function toDisplayMessage(msg: PersistedMessage): ChatMessage {
const attachments = toDisplayAttachment(msg.fileAttachments)
if (attachments.length > 0) {
display.attachments = attachments
+ display.fileAttachments = msg.fileAttachments
}
display.contexts = toDisplayContexts(msg.contexts)
diff --git a/apps/sim/lib/mothership/chat/effective-transcript.test.ts b/apps/sim/lib/mothership/chat/effective-transcript.test.ts
index 487c09696ea..b121b47481b 100644
--- a/apps/sim/lib/mothership/chat/effective-transcript.test.ts
+++ b/apps/sim/lib/mothership/chat/effective-transcript.test.ts
@@ -1,5 +1,8 @@
import { describe, expect, it } from 'vitest'
-import { buildEffectiveChatTranscript } from '@/lib/mothership/chat/effective-transcript'
+import {
+ buildEffectiveChatTranscript,
+ buildLiveAssistantMessage,
+} from '@/lib/mothership/chat/effective-transcript'
import {
getLiveAssistantMessageId,
isLiveAssistantMessageId,
@@ -16,6 +19,7 @@ import {
MothershipStreamV1ToolOutcome,
} from '@/lib/mothership/generated/mothership-stream-v1'
import type { StreamBatchEvent } from '@/lib/mothership/request/session/types'
+import { readCallActivity } from '@/lib/mothership/tools/activity'
function toBatchEvent(eventId: number, event: StreamBatchEvent['event']): StreamBatchEvent {
return {
@@ -99,6 +103,46 @@ describe('buildEffectiveChatTranscript', () => {
}
)
+ it("keeps a resolved operation's own grouping-shaped activity argument beside the gateway's grouping", () => {
+ const ownActivity = { id: 'case-42', completedTitle: 'Closed the case' }
+ const grouping = { id: 'filing', title: 'Filing tasks', completedTitle: 'Filed tasks' }
+ const call = (seq: number, toolName: string, args: Record) =>
+ toBatchEvent(seq, {
+ v: 1,
+ seq,
+ ts: new Date(seq).toISOString(),
+ type: MothershipStreamV1EventType.tool,
+ stream: { streamId: 'stream-1' },
+ payload: {
+ phase: 'call',
+ executor: 'sim',
+ mode: 'async',
+ toolCallId: 'gateway-call',
+ toolName,
+ arguments: args,
+ },
+ })
+ const operationArgs = { title: 'Ship it', activity: ownActivity }
+ const message = buildLiveAssistantMessage({
+ streamId: 'stream-1',
+ events: [
+ call(1, 'call_integration_tool', {
+ toolId: 'azure_devops_create_work_item',
+ description: 'Filing the task',
+ activity: grouping,
+ arguments: operationArgs,
+ }),
+ call(2, 'azure_devops_create_work_item', operationArgs),
+ call(3, 'azure_devops_create_work_item', operationArgs),
+ ],
+ status: 'cancelled',
+ })
+ const toolCall = message?.contentBlocks?.find((block) => block.toolCall)?.toolCall
+
+ expect(toolCall?.params).toEqual(operationArgs)
+ expect(readCallActivity(toolCall)).toEqual(grouping)
+ })
+
it('returns the existing transcript when the stream owner is no longer the trailing user', () => {
const messages = [
buildUserMessage('stream-1', 'Hello'),
diff --git a/apps/sim/lib/mothership/chat/effective-transcript.ts b/apps/sim/lib/mothership/chat/effective-transcript.ts
index 36938e9806f..754ed08da1f 100644
--- a/apps/sim/lib/mothership/chat/effective-transcript.ts
+++ b/apps/sim/lib/mothership/chat/effective-transcript.ts
@@ -16,10 +16,13 @@ import {
MothershipStreamV1ToolOutcome,
MothershipStreamV1ToolPhase,
} from '@/lib/mothership/generated/mothership-stream-v1'
+import type { ToolActivity } from '@/lib/mothership/generated/protocol'
+import { CallIntegrationTool } from '@/lib/mothership/generated/tool-catalog-v1'
import { isTerminalStreamStatus } from '@/lib/mothership/request/session/contract'
import type { FilePreviewSession } from '@/lib/mothership/request/session/file-preview-session-contract'
import type { StreamBatchEvent } from '@/lib/mothership/request/session/types'
import type { TaskBlockInfo } from '@/lib/mothership/request/types'
+import { readFrameActivity } from '@/lib/mothership/tools/activity'
import {
CONTEXT_COMPACTION_DISPLAY_TITLE,
getToolDisplayTitle,
@@ -151,6 +154,8 @@ export function buildLiveAssistantMessage(params: {
// subagent (observed: Sim's reads rendered under Superagent) with no later
// event able to correct it.
const toolOwnershipSettled = new Set()
+ // Gateway calls rebound to their resolved operation, whose arguments are the operation's own.
+ const resolvedOperations = new Set()
const ensureToolBlock = (input: {
toolCallId: string
@@ -161,6 +166,7 @@ export function buildLiveAssistantMessage(params: {
parentSpanId?: string
displayTitle?: string
activityDescription?: string
+ activity?: ToolActivity
params?: Record
result?: { success: boolean; output?: unknown; error?: string }
state?: string
@@ -186,6 +192,7 @@ export function buildLiveAssistantMessage(params: {
(typeof existingToolCall?.state === 'string' ? existingToolCall.state : 'executing'),
...(ownershipWritable && input.calledBy ? { calledBy: input.calledBy } : {}),
...(input.params ? { params: input.params } : {}),
+ ...(input.activity ? { activity: input.activity } : {}),
...(input.result ? { result: input.result } : {}),
...(activityDescription ? { activityDescription } : {}),
...(displayTitle
@@ -225,6 +232,7 @@ export function buildLiveAssistantMessage(params: {
state: input.state ?? 'executing',
...(input.calledBy ? { calledBy: input.calledBy } : {}),
...(input.params ? { params: input.params } : {}),
+ ...(input.activity ? { activity: input.activity } : {}),
...(input.result ? { result: input.result } : {}),
...(input.activityDescription ? { activityDescription: input.activityDescription } : {}),
...(input.displayTitle
@@ -347,20 +355,30 @@ export function buildLiveAssistantMessage(params: {
}
const activityDescription = normalizeToolActivityDescription(payload.activityDescription)
+ const args = isRecordLike(payload.arguments) ? payload.arguments : undefined
+ const existingIndex = toolIndexById.get(toolCallId)
+ const existingName =
+ existingIndex === undefined
+ ? undefined
+ : asPayloadRecord(blocks[existingIndex].toolCall)?.name
+ const call = {
+ name: typeof existingName === 'string' ? existingName : undefined,
+ resolvedOperation: resolvedOperations.has(toolCallId),
+ }
+ const activity = readFrameActivity({ toolName: payload.toolName, args }, call)
+ if (call.name === CallIntegrationTool.id && payload.toolName !== call.name) {
+ resolvedOperations.add(toolCallId)
+ }
ensureToolBlock({
toolCallId,
toolName: payload.toolName,
activityDescription,
+ activity,
calledBy: scopedSubagent,
...(parentForBlock ? { parentToolCallId: parentForBlock } : {}),
...spanIdentity,
- displayTitle:
- activityDescription ??
- getToolDisplayTitle(
- payload.toolName,
- isRecordLike(payload.arguments) ? payload.arguments : undefined
- ),
- params: isRecordLike(payload.arguments) ? payload.arguments : undefined,
+ displayTitle: activityDescription ?? getToolDisplayTitle(payload.toolName, args),
+ params: args,
state: typeof payload.status === 'string' ? payload.status : 'executing',
isCallFrame: payload.phase === MothershipStreamV1ToolPhase.call,
})
diff --git a/apps/sim/lib/mothership/chat/persisted-message.test.ts b/apps/sim/lib/mothership/chat/persisted-message.test.ts
index cd59ffe8152..704dbbb47f7 100644
--- a/apps/sim/lib/mothership/chat/persisted-message.test.ts
+++ b/apps/sim/lib/mothership/chat/persisted-message.test.ts
@@ -4,6 +4,7 @@ import type { PersistedToolState } from '@/lib/api/contracts/copilot-messages'
import { toDisplayMessage } from '@/lib/mothership/chat/display-message'
import { getLiveAssistantMessageId } from '@/lib/mothership/chat/live-message-id'
import type { OrchestratorResult } from '@/lib/mothership/request/types'
+import { readCallActivity } from '@/lib/mothership/tools/activity'
import { getToolDisplayTitle } from '@/lib/mothership/tools/display'
import { resolveMessageCitations } from '@/app/workspace/[workspaceId]/home/components/message-content/resolve-citations'
import {
@@ -101,6 +102,38 @@ describe('persisted-message', () => {
}
)
+ it.each([
+ ['with its arguments', { category_id: null }],
+ ['without arguments', undefined],
+ ['with its own activity argument', { title: 'Ship it', activity: 'Development' }],
+ ])("keeps a resolved operation's inherited grouping beside its arguments %s", (_case, params) => {
+ const grouping = { id: 'gauge-read', title: 'Reading Gauge', completedTitle: 'Read Gauge' }
+ const persisted = buildPersistedAssistantMessage({
+ success: true,
+ content: '',
+ toolCalls: [],
+ contentBlocks: [
+ {
+ type: 'tool_call',
+ timestamp: 1,
+ toolCall: {
+ id: 'gateway-call',
+ name: 'mcp-server-get_overview',
+ status: 'success',
+ params,
+ activity: grouping,
+ },
+ },
+ ],
+ })
+ // utils-lint-allow: exercises the JSON persistence boundary a stored row crosses
+ const stored = JSON.parse(JSON.stringify(persisted)) as Record
+ const reloaded = toDisplayMessage(normalizeMessage(stored)).contentBlocks?.[0].toolCall
+
+ expect(reloaded?.params).toEqual(params)
+ expect(readCallActivity(reloaded)).toEqual(grouping)
+ })
+
it('preserves activity metadata on legacy tool blocks without inferring it from old titles', () => {
const message = normalizeMessage({
id: 'message-1',
diff --git a/apps/sim/lib/mothership/chat/persisted-message.ts b/apps/sim/lib/mothership/chat/persisted-message.ts
index e96b053061a..f54e7f58afa 100644
--- a/apps/sim/lib/mothership/chat/persisted-message.ts
+++ b/apps/sim/lib/mothership/chat/persisted-message.ts
@@ -1,5 +1,5 @@
import { generateId } from '@sim/utils/id'
-import { isPlainRecord } from '@sim/utils/object'
+import { filterUndefined, isPlainRecord, isRecordLike } from '@sim/utils/object'
import { truncateAtCodePoint } from '@sim/utils/string'
import type {
PersistedContentBlock,
@@ -24,6 +24,7 @@ import {
MothershipStreamV1ToolOutcome,
MothershipStreamV1ToolPhase,
} from '@/lib/mothership/generated/mothership-stream-v1'
+import { ToolActivity } from '@/lib/mothership/generated/protocol'
import type {
ContentBlock,
LocalToolCallStatus,
@@ -45,6 +46,13 @@ export interface PersistedMessageContext {
owner?: FileOperationOwner
kind: string
label: string
+ workspaceId?: string
+ projectId?: string
+ executionId?: string
+ fileFolderId?: string
+ command?: string
+ blockId?: string
+ blockIds?: string[]
workflowId?: string
knowledgeId?: string
tableId?: string
@@ -97,6 +105,13 @@ export function copyPersistedMessageContext(c: PersistedMessageContext): Persist
kind: c.kind,
label: c.label,
...(c.owner ? { owner: { ...c.owner } } : {}),
+ ...(c.workspaceId ? { workspaceId: c.workspaceId } : {}),
+ ...(c.projectId ? { projectId: c.projectId } : {}),
+ ...(c.executionId ? { executionId: c.executionId } : {}),
+ ...(c.fileFolderId ? { fileFolderId: c.fileFolderId } : {}),
+ ...(c.command ? { command: c.command } : {}),
+ ...(c.blockId ? { blockId: c.blockId } : {}),
+ ...(Array.isArray(c.blockIds) ? { blockIds: [...c.blockIds] } : {}),
...(c.workflowId ? { workflowId: c.workflowId } : {}),
...(c.knowledgeId ? { knowledgeId: c.knowledgeId } : {}),
...(c.tableId ? { tableId: c.tableId } : {}),
@@ -390,9 +405,7 @@ function mapContentBlockBody(block: ContentBlock): PersistedContentBlock {
...(isSubagentTool && isNonTerminal ? {} : { result: redactedResult }),
...(isSubagentTool && isNonTerminal
? {}
- : block.toolCall.params
- ? { params: block.toolCall.params }
- : {}),
+ : filterUndefined({ params: block.toolCall.params, activity: block.toolCall.activity })),
...(block.calledBy ? { calledBy: block.calledBy } : {}),
...(block.toolCall.displayTitle
? {
@@ -619,6 +632,7 @@ interface RawBlock {
result?: { success: boolean; output?: unknown; error?: string }
display?: { text?: string; title?: string; phaseLabel?: string }
activityDescription?: string
+ activity?: unknown
calledBy?: string
durationMs?: number
error?: string
@@ -678,11 +692,15 @@ function normalizeCanonicalBlock(block: RawBlock): PersistedContentBlock {
if (block.parentToolCallId) result.parentToolCallId = block.parentToolCallId
if (block.toolCall) {
const activityDescription = normalizeToolActivityDescription(block.toolCall.activityDescription)
+ const activity = isRecordLike(block.toolCall.activity)
+ ? ToolActivity.safeParse(block.toolCall.activity)
+ : undefined
result.toolCall = {
id: block.toolCall.id ?? '',
name: block.toolCall.name ?? '',
state: normalizeToolState(block.toolCall.state),
...(activityDescription ? { activityDescription } : {}),
+ ...(activity?.success ? { activity: activity.data } : {}),
...(block.toolCall.params ? { params: block.toolCall.params } : {}),
...(block.toolCall.result ? { result: block.toolCall.result } : {}),
...(block.toolCall.calledBy ? { calledBy: block.toolCall.calledBy } : {}),
diff --git a/apps/sim/lib/mothership/chat/post.ts b/apps/sim/lib/mothership/chat/post.ts
index bde5019d46e..e0ed949b5c4 100644
--- a/apps/sim/lib/mothership/chat/post.ts
+++ b/apps/sim/lib/mothership/chat/post.ts
@@ -9,11 +9,8 @@ import {
type WorkspaceSearchFilters,
workspaceSearchFiltersSchema,
} from '@/lib/api/contracts/knowledge/search'
-import {
- mothershipResourceSchema,
- mothershipTableViewContextSchema,
- mothershipResourceAttachmentSchema as ResourceAttachmentSchema,
-} from '@/lib/api/contracts/mothership-resources'
+import { chatContextSchema } from '@/lib/api/contracts/mothership-chat-context'
+import { mothershipResourceAttachmentSchema as ResourceAttachmentSchema } from '@/lib/api/contracts/mothership-resources'
import { isZodError, validationErrorResponse } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import {
@@ -40,11 +37,7 @@ import {
prepareOrganizationChatAttachments,
} from '@/lib/mothership/chat/assistant-images'
import { buildOnComplete, buildOnError } from '@/lib/mothership/chat/completion'
-import {
- MAX_CHAT_CONTEXT_LABEL_LENGTH,
- MAX_CHAT_CONTEXTS,
- MAX_CHAT_MESSAGE_LENGTH,
-} from '@/lib/mothership/chat/context-limits'
+import { MAX_CHAT_CONTEXTS, MAX_CHAT_MESSAGE_LENGTH } from '@/lib/mothership/chat/context-limits'
import {
DESKTOP_TERMINAL_HINT_ID_MAX_LENGTH,
DESKTOP_TERMINAL_HINT_TEXT_MAX_LENGTH,
@@ -58,13 +51,6 @@ import { authorizeOrganizationChat } from '@/lib/mothership/chat/organization-ch
import { buildCopilotRequestPayload } from '@/lib/mothership/chat/payload'
import { processContextsServer } from '@/lib/mothership/chat/process-contents'
import { resolveResourceAttachmentContexts } from '@/lib/mothership/chat/resource-attachments'
-import {
- CHAT_SELECTION_SOURCE_TITLE_MAX_LENGTH,
- MAX_FILE_SELECTION_TEXT_LENGTH,
- MAX_TABLE_SELECTION_COLUMNS,
- MAX_TABLE_SELECTION_ROWS,
- safeBrowserSelectionUrl,
-} from '@/lib/mothership/chat/selection-context'
import {
COPILOT_REQUEST_MODES,
MOTHERSHIP_CHAT_ID_HEADER,
@@ -73,7 +59,6 @@ import {
import { prepareCopilotEnvironmentContext } from '@/lib/mothership/environment-context'
import { isMothershipModelSelectorEnabled, isPlanModeEnabled } from '@/lib/mothership/feature-flags'
import { AssistantSearchLevel } from '@/lib/mothership/generated/assistant'
-import { FileOperationOwner } from '@/lib/mothership/generated/file-owner'
import {
type ChatContextItem,
type ChatRequest,
@@ -111,9 +96,6 @@ import type { ChatContext } from '@/stores/panel'
const logger = createLogger('UnifiedChatAPI')
const DEFAULT_MODEL = 'claude-opus-4-8'
-const CHAT_SELECTION_TEXT_MAX_LENGTH = 100_000
-const CHAT_SELECTION_SOURCE_URL_MAX_LENGTH = 8_192
-const TERMINAL_SELECTION_LINE_MAX = 10_000_000
const FileAttachmentSchema = z.object({
id: z.string(),
@@ -138,140 +120,6 @@ function dropUnaddressableAttachments(value: unknown): unknown {
})
}
-/** Non-strings pass through for the schema to reject; strings are sanitized. */
-function sanitizeBrowserSelectionUrl(value: unknown): unknown {
- return typeof value === 'string' ? safeBrowserSelectionUrl(value) : value
-}
-
-const BrowserTextSelectionSchema = z
- .object({
- text: z.string().min(1).max(CHAT_SELECTION_TEXT_MAX_LENGTH),
- url: z.preprocess(
- sanitizeBrowserSelectionUrl,
- z.string().max(CHAT_SELECTION_SOURCE_URL_MAX_LENGTH).optional()
- ),
- title: z.string().max(CHAT_SELECTION_SOURCE_TITLE_MAX_LENGTH).optional(),
- })
- .strict()
- .transform(({ text, title, url }) => ({
- text,
- ...(url ? { url } : {}),
- ...(title ? { title } : {}),
- }))
-
-const TerminalTextSelectionSchema = z
- .object({
- text: z.string().min(1).max(CHAT_SELECTION_TEXT_MAX_LENGTH),
- startLine: z.number().int().positive().max(TERMINAL_SELECTION_LINE_MAX),
- endLine: z.number().int().positive().max(TERMINAL_SELECTION_LINE_MAX),
- })
- .strict()
- .refine(({ startLine, endLine }) => endLine >= startLine, {
- message: 'endLine must be greater than or equal to startLine',
- path: ['endLine'],
- })
-
-const ChatContextSchema = z
- .object({
- kind: z.enum([
- 'past_chat',
- 'workflow',
- 'current_workflow',
- 'blocks',
- 'logs',
- 'workflow_block',
- 'knowledge',
- 'docs',
- 'table',
- 'table_selection',
- 'file',
- 'file_selection',
- 'dashboard',
- 'folder',
- 'filefolder',
- 'integration',
- 'skill',
- 'mcp',
- 'browser_tab',
- 'terminal_tab',
- 'workspace',
- 'project',
- ]),
- label: z.string().max(MAX_CHAT_CONTEXT_LABEL_LENGTH),
- chatId: z.string().optional(),
- workflowId: z.string().optional(),
- knowledgeId: z.string().optional(),
- blockId: z.string().optional(),
- blockIds: z.array(z.string()).optional(),
- blockType: z.string().min(1).max(200).optional(),
- executionId: z.string().optional(),
- tableId: z.string().optional(),
- viewId: mothershipResourceSchema.shape.viewId,
- currentView: mothershipTableViewContextSchema.optional(),
- fileId: z.string().optional(),
- owner: FileOperationOwner.optional(),
- dashboardId: z.string().optional(),
- folderId: z.string().optional(),
- fileFolderId: z.string().optional(),
- skillId: z.string().optional(),
- workspaceId: z.string().min(1).max(200).optional(),
- projectId: z.string().min(1).max(200).optional(),
- serverId: z.string().optional(),
- scheduleId: z.string().optional(),
- tabId: z.string().optional(),
- terminalId: z.string().optional(),
- text: z.string().max(MAX_FILE_SELECTION_TEXT_LENGTH).optional(),
- fileName: z.string().optional(),
- startLine: z.number().int().positive().optional(),
- endLine: z.number().int().positive().optional(),
- tableName: z.string().optional(),
- rowIds: z.array(z.string()).max(MAX_TABLE_SELECTION_ROWS).optional(),
- columnIds: z.array(z.string()).max(MAX_TABLE_SELECTION_COLUMNS).optional(),
- selection: z.union([BrowserTextSelectionSchema, TerminalTextSelectionSchema]).optional(),
- })
- .superRefine(({ kind, selection, workspaceId, projectId, owner }, refinementContext) => {
- if (
- (kind === 'project' && (!projectId || workspaceId !== undefined)) ||
- (kind !== 'project' && projectId !== undefined)
- ) {
- refinementContext.addIssue({
- code: 'custom',
- path: ['projectId'],
- message: 'A Project context requires only a Project target',
- })
- }
- if (
- owner &&
- ((kind !== 'file' && kind !== 'file_selection') ||
- (owner.entityType !== 'workspace' && workspaceId) ||
- (owner.entityType === 'workspace' && workspaceId && owner.entityId !== workspaceId))
- )
- refinementContext.addIssue({
- code: 'custom',
- path: ['owner'],
- message: 'File owner conflicts with the context target',
- })
- if (kind === 'workspace' && !workspaceId) {
- refinementContext.addIssue({
- code: 'custom',
- message: 'workspaceId is required for a workspace context',
- path: ['workspaceId'],
- })
- }
- if (!selection) return
- const isTerminalSelection = 'startLine' in selection
- const selectionMatchesKind =
- (kind === 'browser_tab' && !isTerminalSelection) ||
- (kind === 'terminal_tab' && isTerminalSelection)
- if (!selectionMatchesKind) {
- refinementContext.addIssue({
- code: 'custom',
- message: 'selection must match its browser_tab or terminal_tab context kind',
- path: ['selection'],
- })
- }
- })
-
const ChatMessageSchema = z
.object({
message: z.string().max(MAX_CHAT_MESSAGE_LENGTH),
@@ -297,7 +145,7 @@ const ChatMessageSchema = z
.preprocess(dropUnaddressableAttachments, z.array(ResourceAttachmentSchema))
.optional(),
provider: z.string().optional(),
- contexts: z.array(ChatContextSchema).max(MAX_CHAT_CONTEXTS).optional(),
+ contexts: z.array(chatContextSchema).max(MAX_CHAT_CONTEXTS).optional(),
commands: z.array(z.string()).optional(),
userTimezone: z.string().optional(),
effort: z.enum(['none', 'low', 'medium', 'high', 'xhigh', 'max']).optional(),
@@ -1408,7 +1256,7 @@ export async function handleUnifiedChatPost(req: NextRequest) {
if (!lease || !sendClaim?.claimToken || sendClaim.storageMethod !== 'database') {
throw new Error('Chat admission requires its controller and durable send claim')
}
- const resourceAuthoringVersion = await durableResourceAuthoringVersion(
+ const resourceAuthoringVersion = durableResourceAuthoringVersion(
executionContext.copilotResourceAdmission !== undefined
)
admittedRun = await admitChatTurn.execute({
diff --git a/apps/sim/lib/mothership/generated/protocol.ts b/apps/sim/lib/mothership/generated/protocol.ts
index 7b05e68d04d..43aaed612f2 100644
--- a/apps/sim/lib/mothership/generated/protocol.ts
+++ b/apps/sim/lib/mothership/generated/protocol.ts
@@ -303,6 +303,13 @@ export const TOOL_DEADLINE_SETTLE_MARGIN_MS = 90_000;
/** The `clientCapabilities` token a Sim sends when it honors `StreamToolDeadline`. */
export const TOOL_DEADLINE_CAPABILITY = "tool-deadline";
+/**
+ * The `clientCapabilities` token a Sim sends when this turn may copy files within or between
+ * workspaces it can reach (`files copy`) without negotiated file owners, so also where Projects
+ * are off.
+ */
+export const WORKSPACE_FILE_COPY_CAPABILITY = "workspace-file-copy";
+
/** POST /api/mothership — the chat request sim sends. */
export interface ChatRequest extends StreamResponseReceipt {
fileOwnerProtocolVersion?: 1 | undefined;
@@ -348,7 +355,8 @@ export interface ChatRequest extends StreamResponseReceipt {
* grace windows and run tools server-side immediately. ABSENT = older/unknown caller —
* dispatch keeps its conservative grace (deploy-skew safe: a stale tab that predates
* this field still gets waited on). Known capability: "workflow-tool-pickup". Sim also
- * declares its own {@link TOOL_DEADLINE_CAPABILITY} here.
+ * declares its own {@link TOOL_DEADLINE_CAPABILITY} and {@link WORKSPACE_FILE_COPY_CAPABILITY}
+ * here.
*/
clientCapabilities?: string[] | undefined;
/**
diff --git a/apps/sim/lib/mothership/request/handlers/handlers.test.ts b/apps/sim/lib/mothership/request/handlers/handlers.test.ts
index 9f86f1ba63e..3eb6c5c1306 100644
--- a/apps/sim/lib/mothership/request/handlers/handlers.test.ts
+++ b/apps/sim/lib/mothership/request/handlers/handlers.test.ts
@@ -1,3 +1,4 @@
+import { flushMacrotask } from '@sim/testing/helpers/async'
import { createDeferred } from '@sim/testing/helpers/deferred'
import {
mothershipAsyncRunsMock,
@@ -2252,6 +2253,124 @@ describe('sse-handlers tool lifecycle', () => {
)
})
+ it("executes a resolved MCP operation with its own arguments under the gateway's grouping", async () => {
+ isSimExecuted.mockReturnValue(false)
+ executeTool.mockResolvedValueOnce({ success: true, output: {} })
+ const grouping = { id: 'gauge-read', title: 'Reading Gauge', completedTitle: 'Read Gauge' }
+
+ await sseHandlers.tool(
+ {
+ type: MothershipStreamV1EventType.tool,
+ payload: {
+ toolCallId: 'gateway-mcp',
+ toolName: 'call_integration_tool',
+ arguments: {
+ toolId: 'mcp-server-get_overview',
+ description: 'Pulling the overview',
+ activity: grouping,
+ arguments: { category_id: null },
+ },
+ executor: MothershipStreamV1ToolExecutor.go,
+ mode: MothershipStreamV1ToolMode.sync,
+ phase: MothershipStreamV1ToolPhase.call,
+ },
+ } satisfies StreamEvent,
+ context,
+ execContext,
+ { interactive: false, timeout: 1000 }
+ )
+
+ await sseHandlers.tool(
+ {
+ type: MothershipStreamV1EventType.tool,
+ payload: {
+ toolCallId: 'gateway-mcp',
+ toolName: 'mcp-server-get_overview',
+ arguments: { category_id: null },
+ executor: MothershipStreamV1ToolExecutor.sim,
+ mode: MothershipStreamV1ToolMode.async,
+ phase: MothershipStreamV1ToolPhase.call,
+ },
+ } satisfies StreamEvent,
+ context,
+ execContext,
+ { interactive: false, timeout: 1000 }
+ )
+
+ await flushMacrotask()
+
+ expect(executeTool).toHaveBeenCalledWith(
+ 'mcp-server-get_overview',
+ { category_id: null },
+ expect.any(Object)
+ )
+ expect(context.toolCalls.get('gateway-mcp')?.activity).toEqual(grouping)
+ })
+
+ it.each([
+ ['a value', 'Development'],
+ ["shaped like the gateway's grouping", { id: 'filing' }],
+ ['shaped like a grouping of its own', { id: 'case-42', completedTitle: 'Closed the case' }],
+ ])(
+ "keeps an integration operation's own activity argument as %s under the gateway's grouping",
+ async (_case, ownActivity) => {
+ isSimExecuted.mockReturnValue(false)
+ executeTool.mockResolvedValueOnce({ success: true, output: {} })
+ const grouping = { id: 'filing', title: 'Filing tasks', completedTitle: 'Filed tasks' }
+
+ await sseHandlers.tool(
+ {
+ type: MothershipStreamV1EventType.tool,
+ payload: {
+ toolCallId: 'gateway-work-item',
+ toolName: 'call_integration_tool',
+ arguments: {
+ toolId: 'azure_devops_create_work_item',
+ description: 'Filing the task',
+ activity: grouping,
+ arguments: { title: 'Ship it', activity: ownActivity },
+ },
+ executor: MothershipStreamV1ToolExecutor.go,
+ mode: MothershipStreamV1ToolMode.sync,
+ phase: MothershipStreamV1ToolPhase.call,
+ },
+ } satisfies StreamEvent,
+ context,
+ execContext,
+ { interactive: false, timeout: 1000 }
+ )
+
+ // A repeated frame for the rebound operation still carries the operation's own arguments.
+ for (let frame = 0; frame < 2; frame++) {
+ await sseHandlers.tool(
+ {
+ type: MothershipStreamV1EventType.tool,
+ payload: {
+ toolCallId: 'gateway-work-item',
+ toolName: 'azure_devops_create_work_item',
+ arguments: { title: 'Ship it', activity: ownActivity },
+ executor: MothershipStreamV1ToolExecutor.sim,
+ mode: MothershipStreamV1ToolMode.async,
+ phase: MothershipStreamV1ToolPhase.call,
+ },
+ } satisfies StreamEvent,
+ context,
+ execContext,
+ { interactive: false, timeout: 1000 }
+ )
+ }
+
+ await flushMacrotask()
+
+ expect(executeTool).toHaveBeenCalledWith(
+ 'azure_devops_create_work_item',
+ { title: 'Ship it', activity: ownActivity },
+ expect.any(Object)
+ )
+ expect(context.toolCalls.get('gateway-work-item')?.activity).toEqual(grouping)
+ }
+ )
+
it('clears pending continuation state when a run resumes', async () => {
context.awaitingAsyncContinuation = {
checkpointId: 'cp-1',
diff --git a/apps/sim/lib/mothership/request/handlers/tool.ts b/apps/sim/lib/mothership/request/handlers/tool.ts
index 533cee21dc5..5db40b04c59 100644
--- a/apps/sim/lib/mothership/request/handlers/tool.ts
+++ b/apps/sim/lib/mothership/request/handlers/tool.ts
@@ -63,7 +63,7 @@ import type {
ToolCallState,
} from '@/lib/mothership/request/types'
import { getToolEntry, isSimExecuted } from '@/lib/mothership/tool-executor'
-import { readToolActivity } from '@/lib/mothership/tools/activity'
+import { readCallActivity, readFrameActivity } from '@/lib/mothership/tools/activity'
import { isToolHiddenInUi } from '@/lib/mothership/tools/client/hidden-tools'
import { isWorkflowToolName } from '@/lib/mothership/tools/client-executed-tools'
import {
@@ -153,9 +153,10 @@ function handleToolArgsDelta(
if (!toolCall) return
toolCall.streamingArgs = `${toolCall.streamingArgs ?? ''}${data.argumentsDelta}`
- // Progressive CLI title: the row upgrades from "Running CLI command" to the
- // specific verb as soon as enough argv tokens have streamed to name it —
- // no wait for the full (possibly huge) argument payload.
+ // Progressive CLI title: the row upgrades from its placeholder to the specific
+ // verb as soon as enough argv tokens have streamed to name it — no wait for the
+ // full (possibly huge) argument payload. The activity header holds it back until
+ // the model's description arrives (see `isAwaitingTitle`).
if (toolCall.name === 'sim_cli' || toolCall.name.startsWith('cli_')) {
const refined = refineStreamingCliToolName(toolCall.streamingArgs)
if (refined && refined !== toolCall.name) {
@@ -206,6 +207,7 @@ function rebindResolvedIntegrationCall(
}
toolCall.name = toolName
toolCall.params = args
+ toolCall.resolvedOperation = true
applyToolDisplay(toolCall)
return true
}
@@ -579,15 +581,17 @@ async function handleCallPhase(
existing.activityDescription ??= activityDescription
}
/** Persist visible labels even when the introducing discovery call is omitted from the transcript. */
- const suppliedActivity = readToolActivity(args) ?? readToolActivity(existing?.params)
+ const suppliedActivity =
+ readFrameActivity({ toolName, args }, existing) ?? readCallActivity(existing)
const activity = suppliedActivity?.completedTitle
? suppliedActivity
: suppliedActivity &&
Array.from(context.toolCalls.values())
.filter((tool) => tool.agentId === agentId && tool.parentToolCallId === parentToolCallId)
- .map((tool) => readToolActivity(tool.params))
+ .map(readCallActivity)
.find((candidate) => candidate?.id === suppliedActivity.id && candidate.completedTitle)
- const displayArgs = activity ? { ...args, activity } : args
+ // Kept off `args`, which execute as given (an MCP schema refuses keys it never declared).
+ if (existing && activity) existing.activity = activity
const isSubagent = scope === 'subagent'
const ui = getToolCallUI(data)
@@ -605,8 +609,8 @@ async function handleCallPhase(
if (isSubagent) {
if (wasToolResultSeen(context, toolCallId) || existing?.endTime) {
- if (!rebindResolvedIntegrationCall(existing, toolName, displayArgs)) {
- if (existing) updateToolCallFromFrame(existing, toolName, displayArgs, !isPartial)
+ if (!rebindResolvedIntegrationCall(existing, toolName, args)) {
+ if (existing) updateToolCallFromFrame(existing, toolName, args, !isPartial)
}
applyToolDisplay(existing)
return
@@ -616,8 +620,8 @@ async function handleCallPhase(
existing?.endTime ||
(existing && existing.status !== 'pending' && existing.status !== 'executing')
) {
- if (!rebindResolvedIntegrationCall(existing, toolName, displayArgs)) {
- updateToolCallFromFrame(existing, toolName, displayArgs, !isPartial)
+ if (!rebindResolvedIntegrationCall(existing, toolName, args)) {
+ updateToolCallFromFrame(existing, toolName, args, !isPartial)
}
applyToolDisplay(existing)
return
@@ -629,7 +633,7 @@ async function handleCallPhase(
context,
toolCallId,
toolName,
- displayArgs,
+ args,
parentToolCallId!,
agentId,
ui,
@@ -642,7 +646,7 @@ async function handleCallPhase(
context,
toolCallId,
toolName,
- displayArgs,
+ args,
existing,
agentId,
ui,
@@ -653,6 +657,7 @@ async function handleCallPhase(
const registered = context.toolCalls.get(toolCallId)
if (registered && data.workspaceId) registered.targetWorkspaceId = data.workspaceId
+ if (registered && activity) registered.activity = activity
if (isPartial || data.replay) return
if (!isSubagent && wasToolResultSeen(context, toolCallId)) return
if (context.pendingToolPromises.has(toolCallId) || existing?.status === 'executing') {
diff --git a/apps/sim/lib/mothership/request/lifecycle/recovery-config.test.ts b/apps/sim/lib/mothership/request/lifecycle/recovery-config.test.ts
index a00a2fba458..bad76508c79 100644
--- a/apps/sim/lib/mothership/request/lifecycle/recovery-config.test.ts
+++ b/apps/sim/lib/mothership/request/lifecycle/recovery-config.test.ts
@@ -104,15 +104,22 @@ describe('recovery marker written by an admitted turn', () => {
}),
}
- it('stays off a recovery config older builds must still parse while Projects are off', async () => {
- featureFlagsMockFns.mockIsFeatureEnabled.mockResolvedValue(false)
- const recovery = await streamRecoveryConfig(options, workspaceRequest)
- expect(recovery).not.toHaveProperty('resourceAuthoringVersion')
- })
+ it.each([true, false])(
+ 'restores authoring admission on recovery whether Projects are on (%s) or off',
+ async (projects) => {
+ featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation(
+ async (flag) => projects && flag === 'projects'
+ )
+ const recovery = await streamRecoveryConfig(options, workspaceRequest)
+ expect(recovery?.resourceAuthoringVersion).toBe(1)
+ }
+ )
- it('restores authoring admission on recovery while Projects are on', async () => {
- featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation(async (flag) => flag === 'projects')
- const recovery = await streamRecoveryConfig(options, workspaceRequest)
- expect(recovery?.resourceAuthoringVersion).toBe(1)
+ it('stays off a turn that was never admitted', async () => {
+ const recovery = await streamRecoveryConfig(
+ { ...options, copilotResourceAdmission: undefined },
+ workspaceRequest
+ )
+ expect(recovery).not.toHaveProperty('resourceAuthoringVersion')
})
})
diff --git a/apps/sim/lib/mothership/request/lifecycle/recovery-config.ts b/apps/sim/lib/mothership/request/lifecycle/recovery-config.ts
index e6a4334178f..68b51d5b05d 100644
--- a/apps/sim/lib/mothership/request/lifecycle/recovery-config.ts
+++ b/apps/sim/lib/mothership/request/lifecycle/recovery-config.ts
@@ -3,7 +3,6 @@ import { hasCopilotResourceAdmission } from '@/lib/mothership/auth/application-d
import { COPILOT_REQUEST_MODES } from '@/lib/mothership/constants'
import { ChatPayloadSchema } from '@/lib/mothership/generated/protocol'
import type { CopilotLifecycleOptions } from '@/lib/mothership/request/lifecycle/run'
-import { isProjectsEnabled } from '@/lib/projects/rollout.server'
export const BillingAdmissionSchema = z
.object({
@@ -28,14 +27,14 @@ export const DurableChatRequestSchema = ChatPayloadSchema.safeExtend({
export const RESOURCE_AUTHORING_VERSION = 1
/**
- * The marker an admitted turn stores for recovery. Builds before it parse recovery strictly and
- * would refuse to recover the run, and only Project authoring reads it, so it is written only
- * while Projects are on.
+ * The marker an admitted turn stores for recovery, so a recovered turn keeps its admission. It is
+ * written whether or not Projects are on, since copies between workspaces need the admission
+ * without them; every release since the marker shipped parses it.
*/
-export async function durableResourceAuthoringVersion(
+export function durableResourceAuthoringVersion(
admitted: boolean
-): Promise {
- return admitted && (await isProjectsEnabled()) ? RESOURCE_AUTHORING_VERSION : undefined
+): typeof RESOURCE_AUTHORING_VERSION | undefined {
+ return admitted ? RESOURCE_AUTHORING_VERSION : undefined
}
export const StreamRecoveryConfigSchema = z
@@ -66,7 +65,7 @@ export async function streamRecoveryConfig(
request: Record
) {
if (options.interactive !== true) return undefined
- const resourceAuthoringVersion = await durableResourceAuthoringVersion(
+ const resourceAuthoringVersion = durableResourceAuthoringVersion(
hasCopilotResourceAdmission({
...options,
...options.executionContext,
diff --git a/apps/sim/lib/mothership/request/session/replay-budget.integration.ts b/apps/sim/lib/mothership/request/session/replay-budget.integration.ts
index 3d723c3176f..d24837d9f7d 100644
--- a/apps/sim/lib/mothership/request/session/replay-budget.integration.ts
+++ b/apps/sim/lib/mothership/request/session/replay-budget.integration.ts
@@ -167,6 +167,7 @@ import { STREAM_STRING_PREVIEW_UNITS } from '@/lib/mothership/request/session/re
import type { StreamEvent } from '@/lib/mothership/request/session/types'
import { StreamWriter } from '@/lib/mothership/request/session/writer'
import type { StreamingContext } from '@/lib/mothership/request/types'
+import { readCallActivity } from '@/lib/mothership/tools/activity'
import {
type PendingFileIntent,
storeFileIntent,
@@ -365,7 +366,7 @@ describe.runIf(Boolean(redisUrl))('leased Chat stream writer with Redis', () =>
id: call.id,
name: call.name,
status: call.status,
- activity: call.params?.activity,
+ activity: readCallActivity(call),
})),
pending: context.pendingToolPromises.size,
})
diff --git a/apps/sim/lib/mothership/request/tools/workflow-client-settlement.integration.ts b/apps/sim/lib/mothership/request/tools/workflow-client-settlement.integration.ts
index f4669a97022..f5406b717c3 100644
--- a/apps/sim/lib/mothership/request/tools/workflow-client-settlement.integration.ts
+++ b/apps/sim/lib/mothership/request/tools/workflow-client-settlement.integration.ts
@@ -21,6 +21,7 @@ import {
copilotAsyncToolCalls,
copilotChats,
copilotRuns,
+ pausedExecutions,
user,
workflow,
workflowExecutionLogs,
@@ -43,9 +44,19 @@ import {
reportQueuedClientWorkflowTool,
reportSettledClientWorkflowTool,
} from '@/lib/mothership/request/tools/workflow-client-settlement'
+import { PauseResumeManager } from '@/lib/workflows/executor/human-in-the-loop-manager'
/** Longer than the waiter's durable poll, far shorter than the hour it used to park for. */
const WAIT_MS = 10_000
+const APPROVAL_CONTEXT_ID = 'approval'
+
+/** Carries no snapshot metadata, so a resume of it fails the run. */
+const UNRESUMABLE_SNAPSHOT = '{}'
+
+/** The resume engine, private to the manager; the one piece a test of its settling stands in for. */
+interface ResumeEngine {
+ runResumeExecution: (...args: unknown[]) => Promise
+}
/**
* The confirmation a report published for the worker's durable waiter. Reads on the publisher's
@@ -126,6 +137,8 @@ describe.runIf(Boolean(redisUrl))('settled client-claimed workflow tools', () =>
afterAll(async () => {
await db.delete(copilotChats).where(eq(copilotChats.id, chatId))
+ // Deleting a paused execution cascades to its resume queue entries.
+ await db.delete(pausedExecutions).where(eq(pausedExecutions.workflowId, workflowId))
await db.delete(workflowExecutionLogs).where(eq(workflowExecutionLogs.workspaceId, workspaceId))
if (snapshotIds.length)
await db
@@ -186,6 +199,44 @@ describe.runIf(Boolean(redisUrl))('settled client-claimed workflow tools', () =>
return { toolCallId, executionId }
}
+ /**
+ * A claimed call whose bound execution paused at a human approval: the execute route settled the
+ * claim and its own report left the call running, as it does for every paused execution.
+ */
+ async function claimedAndPaused(snapshot: string = UNRESUMABLE_SNAPSHOT) {
+ const { toolCallId, executionId } = await claimed()
+ await executionLog(toolCallId, executionId, 'pending')
+ await db.insert(pausedExecutions).values({
+ id: generateId(),
+ workflowId,
+ executionId,
+ executionSnapshot: { snapshot, triggerIds: [] },
+ pausePoints: {
+ [APPROVAL_CONTEXT_ID]: {
+ contextId: APPROVAL_CONTEXT_ID,
+ blockId: APPROVAL_CONTEXT_ID,
+ response: {},
+ registeredAt: new Date().toISOString(),
+ resumeStatus: 'paused',
+ snapshotReady: true,
+ pauseKind: 'human',
+ },
+ },
+ totalPauseCount: 1,
+ })
+ await settleClientWorkflowToolExecution(toolCallId, executionId)
+ await reportSettledClientWorkflowTool({ toolCallId, executionId, workflowId })
+ expect(await toolRow(toolCallId)).toMatchObject({ status: 'running', result: null })
+ return { toolCallId, executionId }
+ }
+
+ async function setLogStatus(executionId: string, status: 'completed' | 'failed' | 'cancelled') {
+ await db
+ .update(workflowExecutionLogs)
+ .set({ status, endedAt: new Date() })
+ .where(eq(workflowExecutionLogs.executionId, executionId))
+ }
+
async function toolRow(toolCallId: string) {
const [row] = await db
.select()
@@ -275,13 +326,132 @@ describe.runIf(Boolean(redisUrl))('settled client-claimed workflow tools', () =>
expect(await toolRow(toolCallId)).toMatchObject({ status: 'failed', claimedBy: null })
})
- it('leaves a paused execution to the client', async () => {
- const { toolCallId, executionId } = await claimed()
- await executionLog(toolCallId, executionId, 'pending')
+ it('delivers a paused execution a resume completes', async () => {
+ const { toolCallId, executionId } = await claimedAndPaused(
+ JSON.stringify({
+ metadata: {
+ workspaceId,
+ userId,
+ billingAttribution: {
+ actorUserId: userId,
+ workspaceId,
+ organizationId: null,
+ billedAccountUserId: userId,
+ billingEntity: { type: 'user', id: userId },
+ billingPeriod: { start: '2026-07-01T00:00:00.000Z', end: '2026-08-01T00:00:00.000Z' },
+ payerSubscription: null,
+ },
+ },
+ })
+ )
+ const waiting = waitForWorkflowToolCompletion({ toolCallId, workflowId, timeoutMs: WAIT_MS })
+ const started = await PauseResumeManager.enqueueOrStartResume({
+ executionId,
+ workflowId,
+ contextId: APPROVAL_CONTEXT_ID,
+ resumeInput: {},
+ userId,
+ })
+ if (started.status !== 'starting') throw new Error('The resume was queued instead of started')
+ // Only the engine stands in: it completes the parent's log as a finished resume does.
+ // double-cast-allowed: runResumeExecution is private; the test stands in only for the resume engine
+ const resumeEngine = PauseResumeManager as unknown as ResumeEngine
+ const engine = vi.spyOn(resumeEngine, 'runResumeExecution').mockImplementationOnce(async () => {
+ await setLogStatus(executionId, 'completed')
+ return {
+ success: true,
+ status: 'completed',
+ output: {},
+ logs: [],
+ metadata: { executionId, duration: 1, startTime: 'start' },
+ }
+ })
- await reportSettledClientWorkflowTool({ toolCallId, executionId, workflowId })
+ try {
+ await PauseResumeManager.startResumeExecution({
+ resumeEntryId: started.resumeEntryId,
+ resumeExecutionId: started.resumeExecutionId,
+ pausedExecution: started.pausedExecution,
+ contextId: started.contextId,
+ resumeInput: started.resumeInput,
+ userId: started.userId,
+ })
+ } finally {
+ engine.mockRestore()
+ }
- expect(await toolRow(toolCallId)).toMatchObject({ status: 'running', result: null })
+ expect(await waiting).toMatchObject({
+ status: 'success',
+ data: { success: true, workflowId, executionId },
+ })
+ expect(await toolRow(toolCallId)).toMatchObject({ status: 'completed', claimedBy: null })
+ })
+
+ it('delivers a paused execution whose resume failed the run', async () => {
+ const { toolCallId, executionId } = await claimedAndPaused()
+ const waiting = waitForWorkflowToolCompletion({ toolCallId, workflowId, timeoutMs: WAIT_MS })
+ const started = await PauseResumeManager.enqueueOrStartResume({
+ executionId,
+ workflowId,
+ contextId: APPROVAL_CONTEXT_ID,
+ resumeInput: {},
+ userId,
+ })
+ if (started.status !== 'starting') throw new Error('The resume was queued instead of started')
+
+ await expect(
+ PauseResumeManager.startResumeExecution({
+ resumeEntryId: started.resumeEntryId,
+ resumeExecutionId: started.resumeExecutionId,
+ pausedExecution: started.pausedExecution,
+ contextId: started.contextId,
+ resumeInput: started.resumeInput,
+ userId: started.userId,
+ })
+ ).rejects.toThrow()
+
+ expect(await waiting).toMatchObject({
+ status: 'error',
+ data: { success: false, workflowId, executionId },
+ })
+ expect(await toolRow(toolCallId)).toMatchObject({ status: 'failed', claimedBy: null })
+ expect(await publishedConfirmation(toolCallId)).toMatchObject({ status: 'error', executionId })
+ })
+
+ it('delivers a paused execution cancelled before it resumed', async () => {
+ const { toolCallId, executionId } = await claimedAndPaused()
+ const waiting = waitForWorkflowToolCompletion({ toolCallId, workflowId, timeoutMs: WAIT_MS })
+
+ expect(await PauseResumeManager.beginPausedCancellation(executionId, workflowId)).toBe(true)
+ expect(await PauseResumeManager.completePausedCancellation(executionId, workflowId)).toBe(true)
+
+ expect(await waiting).toMatchObject({
+ status: 'cancelled',
+ data: { success: false, workflowId, executionId },
+ })
+ expect(await toolRow(toolCallId)).toMatchObject({ status: 'cancelled', claimedBy: null })
+ expect(await publishedConfirmation(toolCallId)).toMatchObject({
+ status: 'cancelled',
+ executionId,
+ })
+ })
+
+ it('delivers a paused execution whose cancel lost the race to a run that ended unreported', async () => {
+ const { toolCallId, executionId } = await claimedAndPaused()
+ const waiting = waitForWorkflowToolCompletion({ toolCallId, workflowId, timeoutMs: WAIT_MS })
+ expect(await PauseResumeManager.beginPausedCancellation(executionId, workflowId)).toBe(true)
+ // The stale-execution sweep fails the run's log and reports nothing to Copilot.
+ await setLogStatus(executionId, 'failed')
+
+ expect(
+ await PauseResumeManager.finalizePausedCancellationForTerminalRun(executionId, workflowId, [])
+ ).toBe(true)
+
+ expect(await waiting).toMatchObject({
+ status: 'error',
+ data: { success: false, workflowId, executionId },
+ })
+ expect(await toolRow(toolCallId)).toMatchObject({ status: 'failed', claimedBy: null })
})
it('moves a queued async run to the background when the browser never reports', async () => {
diff --git a/apps/sim/lib/mothership/request/types.ts b/apps/sim/lib/mothership/request/types.ts
index 7ee453ecb4a..d96ed2ea5b8 100644
--- a/apps/sim/lib/mothership/request/types.ts
+++ b/apps/sim/lib/mothership/request/types.ts
@@ -3,7 +3,7 @@ import {
type MothershipStreamV1CompletionStatus,
MothershipStreamV1ToolOutcome,
} from '@/lib/mothership/generated/mothership-stream-v1'
-import type { StreamActivityReceipt } from '@/lib/mothership/generated/protocol'
+import type { StreamActivityReceipt, ToolActivity } from '@/lib/mothership/generated/protocol'
import type { RequestTraceV1Span } from '@/lib/mothership/generated/request-trace-v1'
import type { ProviderToolCallIdentity } from '@/lib/mothership/request/go/tool-call-identity'
import type { StreamEvent } from '@/lib/mothership/request/session'
@@ -42,6 +42,14 @@ export interface ToolCallState {
activityDescription?: string
/** Model-authored activity text for a gateway-resolved integration call. */
integrationDescription?: string
+ /**
+ * Display grouping, from the call's own arguments or inherited (a gateway's grouping on its
+ * resolved operation), with titles completed from its activity's earlier calls. Held beside
+ * `params`, which execute and persist as given.
+ */
+ activity?: ToolActivity
+ /** A gateway call rebound to its resolved operation, whose arguments are the operation's own. */
+ resolvedOperation?: boolean
/** Accumulated partial JSON of the arguments while the model streams them. */
streamingArgs?: string
params?: Record
diff --git a/apps/sim/lib/mothership/tools/activity.ts b/apps/sim/lib/mothership/tools/activity.ts
index add0260af05..5ab3f7df40c 100644
--- a/apps/sim/lib/mothership/tools/activity.ts
+++ b/apps/sim/lib/mothership/tools/activity.ts
@@ -1,5 +1,6 @@
import { isRecordLike } from '@sim/utils/object'
import { ToolActivity } from '@/lib/mothership/generated/protocol'
+import { CallIntegrationTool } from '@/lib/mothership/generated/tool-catalog-v1'
import {
firstWordOf,
getToolCompletedTitle,
@@ -56,6 +57,33 @@ export function readToolActivity(
return undefined
}
+/**
+ * The display grouping a call frame's arguments supply. A gateway's resolved operation owns its
+ * arguments, `activity` included, so its frames supply none and the call keeps the grouping it
+ * inherited from the gateway. Read before the frame rebinds the call to its operation.
+ */
+export function readFrameActivity(
+ frame: { toolName: string; args?: Record },
+ call: { name?: string; resolvedOperation?: boolean } | undefined
+): ToolActivity | undefined {
+ const ownsArguments =
+ frame.toolName !== CallIntegrationTool.id &&
+ (call?.name === CallIntegrationTool.id || call?.resolvedOperation === true)
+ return ownsArguments ? undefined : readToolActivity(frame.args)
+}
+
+/**
+ * A call's display grouping: held beside its arguments, or read from them where it was never
+ * separated (older history, a call still streaming).
+ */
+export function readCallActivity(
+ call:
+ | { activity?: ToolActivity; params?: Record; streamingArgs?: string }
+ | undefined
+): ToolActivity | undefined {
+ return call?.activity ?? readToolActivity(call?.params, call?.streamingArgs)
+}
+
/** Two actions on one object, so adjacent summary phrases can share it ("listed, read tables"). */
interface SharedObjectActivity {
verb: string
diff --git a/apps/sim/lib/mothership/tools/client/run-tool-execution.test.ts b/apps/sim/lib/mothership/tools/client/run-tool-execution.test.ts
index 523e8d63168..4f2df728f32 100644
--- a/apps/sim/lib/mothership/tools/client/run-tool-execution.test.ts
+++ b/apps/sim/lib/mothership/tools/client/run-tool-execution.test.ts
@@ -243,6 +243,25 @@ describe('run tool execution cancellation', () => {
}
})
+ it('leaves a paused run for Sim to report, and a reload neither re-sends nor restarts it', async () => {
+ const fetchMock = vi.fn().mockResolvedValue({ ok: true })
+ vi.stubGlobal('fetch', fetchMock)
+ executeWorkflowWithFullLogging.mockResolvedValueOnce({
+ success: true,
+ status: 'paused',
+ output: {},
+ logs: [],
+ })
+
+ executeRunToolOnClient('tool-paused', 'run_workflow', { workflowId: 'wf-1' })
+ await vi.waitFor(() => expect(isRunToolActiveForWorkflow('wf-1')).toBe(false))
+
+ expect(fetchMock).not.toHaveBeenCalled()
+ expect(clearExecutionPointer).toHaveBeenCalledWith('wf-1')
+ await expect(bindRunToolToExecution('tool-paused', 'wf-1')).resolves.toBe(true)
+ expect(fetchMock).not.toHaveBeenCalled()
+ })
+
it('cancels and reports the owned execution even when its workflow has a newer UI pointer', async () => {
const fetchMock = vi.fn().mockResolvedValue({ ok: true })
vi.stubGlobal('fetch', fetchMock)
diff --git a/apps/sim/lib/mothership/tools/client/run-tool-execution.ts b/apps/sim/lib/mothership/tools/client/run-tool-execution.ts
index c1a2aea59ed..8a1f38ee498 100644
--- a/apps/sim/lib/mothership/tools/client/run-tool-execution.ts
+++ b/apps/sim/lib/mothership/tools/client/run-tool-execution.ts
@@ -85,6 +85,8 @@ const manuallyStoppedToolCallIds = new Set()
type RunToolReleaseListener = (workflowId: string) => void
const runToolReleaseListeners = new Set()
const PENDING_COMPLETION_STORAGE_PREFIX = 'sim:copilot:run-tool-completion:'
+/** A run tool whose workflow paused for approval: Sim reports its outcome once the run settles. */
+const SERVER_REPORTED_STORAGE_PREFIX = 'sim:copilot:run-tool-server-reported:'
/**
* Tab-local record of a completion this tab still owes Sim for a tool call,
@@ -435,6 +437,27 @@ async function enqueueAsyncWorkflowRun(
})
}
+function markServerReported(toolCallId: string): void {
+ if (typeof window === 'undefined') return
+ try {
+ window.sessionStorage.setItem(`${SERVER_REPORTED_STORAGE_PREFIX}${toolCallId}`, '1')
+ } catch (error) {
+ logger.warn('[RunTool] Failed to persist server-reported run tool', {
+ toolCallId,
+ error: toError(error).message,
+ })
+ }
+}
+
+function isServerReported(toolCallId: string): boolean {
+ if (typeof window === 'undefined') return false
+ try {
+ return window.sessionStorage.getItem(`${SERVER_REPORTED_STORAGE_PREFIX}${toolCallId}`) !== null
+ } catch {
+ return false
+ }
+}
+
function pendingCompletionStorageKey(toolCallId: string): string {
return `${PENDING_COMPLETION_STORAGE_PREFIX}${toolCallId}`
}
@@ -501,6 +524,13 @@ export async function bindRunToolToExecution(
})
return true
}
+ if (isServerReported(toolCallId)) {
+ logger.info('[RunTool] Recovery skipped: Sim reports this paused run', {
+ workflowId,
+ toolCallId,
+ })
+ return true
+ }
// Another run's pointer says nothing about this call.
const otherRunOwnsWorkflow = owner != null
const pointer = otherRunOwnsWorkflow
@@ -885,6 +915,11 @@ async function doExecuteRunTool(
toolCallId,
toolName,
})
+ } else if (isPlainRecord(result) && result.status === 'paused') {
+ // A paused run has no outcome to confirm yet; Sim reports it once a resume
+ // or cancellation settles the run.
+ logger.info('[RunTool] Workflow execution paused', { toolCallId, toolName, executionId })
+ markServerReported(toolCallId)
} else if (succeeded) {
logger.info('[RunTool] Workflow execution succeeded', { toolCallId, toolName })
const pendingCompletion = {
diff --git a/apps/sim/lib/mothership/tools/sandbox-file-copy.ts b/apps/sim/lib/mothership/tools/sandbox-file-copy.ts
index 1cf7e30d7f1..f9212ee994a 100644
--- a/apps/sim/lib/mothership/tools/sandbox-file-copy.ts
+++ b/apps/sim/lib/mothership/tools/sandbox-file-copy.ts
@@ -13,6 +13,7 @@ import {
recordSandboxResourceEffects,
type SandboxResourceScope,
} from '@/lib/mothership/tools/sandbox-resources'
+import { copiesWithinWorkspaces } from '@/lib/workspace-files/application/copy-operation'
const logger = createLogger('SandboxFileCopy')
@@ -22,15 +23,17 @@ export async function proxySandboxFileCopyRequest(
token: string,
scope: SandboxResourceScope
): Promise {
+ const unavailable = () =>
+ Response.json(
+ { error: 'Paired file owner routing is unavailable or contradictory' },
+ { status: 403 }
+ )
if (
- scope.fileOwnerProtocolVersion !== 1 ||
+ (scope.fileOwnerProtocolVersion !== 1 && !scope.workspaceFileCopy) ||
request.headers.has('x-mothership-file-owner') ||
request.headers.has('x-mothership-workspace-id')
)
- return Response.json(
- { error: 'Paired file owner routing is unavailable or contradictory' },
- { status: 403 }
- )
+ return unavailable()
if (request.method !== 'POST')
return Response.json({ error: 'Method not allowed' }, { status: 405 })
const parsed = await parseRequest(
@@ -43,6 +46,9 @@ export async function proxySandboxFileCopyRequest(
}
)
if (!parsed.success) return parsed.response
+ // Without the negotiated protocol only workspace owners exist for this lease.
+ if (scope.fileOwnerProtocolVersion !== 1 && !copiesWithinWorkspaces(parsed.data.body))
+ return unavailable()
const endpoint = getInternalApiBaseUrl()
const transport = createFileCopyCliTransport(
endpoint,
diff --git a/apps/sim/lib/mothership/tools/sandbox-resource-transport.test.ts b/apps/sim/lib/mothership/tools/sandbox-resource-transport.test.ts
index 6c0f18210db..789bf606cb6 100644
--- a/apps/sim/lib/mothership/tools/sandbox-resource-transport.test.ts
+++ b/apps/sim/lib/mothership/tools/sandbox-resource-transport.test.ts
@@ -16,6 +16,7 @@ import { assertWorkspaceInvocationScope } from '@/lib/core/application/workspace
import { OrchestrationError } from '@/lib/core/orchestration/types'
import { chatSandboxSessionKey } from '@/lib/mothership/tools/sandbox-session-key'
import { projectFileOperations } from '@/lib/projects/files/application/operations'
+import { fileCopyOperation } from '@/lib/workspace-files/application/copy-operation'
import { fileOperations } from '@/lib/workspace-files/application/operations'
const { readScope, recordEffects, fetcher, routeMatcher, recordInput } = vi.hoisted(() => ({
@@ -436,26 +437,72 @@ it.each([
}
)
-it('refuses a Project copy destination on a lease without the file-owner protocol', async () => {
- readScope.mockResolvedValue({ ...scope, fileOwnerProtocolVersion: undefined })
- routeMatcher.mockReturnValue({ params: {}, load: async () => ({ POST: fetcher }) })
+it('binds a workspace pair under the paired copy grant on an admitted lease without the protocol', async () => {
+ readScope.mockResolvedValue({
+ ...scope,
+ fileOwnerProtocolVersion: undefined,
+ workspaceFileCopy: true,
+ })
+ routeMatcher.mockReturnValue({
+ pattern: '/api/v2/files/copy',
+ params: {},
+ load: async () => ({ POST: fetcher }),
+ })
+ fetcher.mockImplementation(async (forwarded: Request) => {
+ expect(
+ copilotRequestPrincipal(forwarded, fileCopyOperation, {
+ operation: fileCopyOperation,
+ delegationAudience: fileCopyOperation.delegationAudience,
+ })
+ ).toMatchObject({
+ kind: 'resource_delegated',
+ subjectUserId: scope.userId,
+ invocation: { kind: 'chat', chatId: scope.chatId },
+ scope: { kind: 'file_copy', destination: { owner: workspaceOwner } },
+ })
+ return Response.json({ data: { copied: [] } })
+ })
const response = await proxySandboxResourceRequest(
request('/api/v2/files/copy', {
method: 'POST',
body: JSON.stringify({
source: { owner: workspaceOwner, fileIds: ['file'] },
- destination: { owner: { entityType: 'project', entityId: 'project' } },
+ destination: { owner: workspaceOwner },
}),
}),
token
)
- expect(response.status).toBe(403)
- expect(await response.json()).toEqual({
- error: 'Paired file owner routing is unavailable or contradictory',
- })
- expect(fetcher).not.toHaveBeenCalled()
+ expect(response.status).toBe(200)
+ expect(fetcher).toHaveBeenCalledTimes(1)
})
+it.each([undefined, true as const])(
+ 'refuses a Project copy destination on a lease without the file-owner protocol (workspace copies %s)',
+ async (workspaceFileCopy) => {
+ readScope.mockResolvedValue({
+ ...scope,
+ fileOwnerProtocolVersion: undefined,
+ workspaceFileCopy,
+ })
+ routeMatcher.mockReturnValue({ params: {}, load: async () => ({ POST: fetcher }) })
+ const response = await proxySandboxResourceRequest(
+ request('/api/v2/files/copy', {
+ method: 'POST',
+ body: JSON.stringify({
+ source: { owner: workspaceOwner, fileIds: ['file'] },
+ destination: { owner: { entityType: 'project', entityId: 'project' } },
+ }),
+ }),
+ token
+ )
+ expect(response.status).toBe(403)
+ expect(await response.json()).toEqual({
+ error: 'Paired file owner routing is unavailable or contradictory',
+ })
+ expect(fetcher).not.toHaveBeenCalled()
+ }
+)
+
it('validates a copy body itself once the lease negotiated the file-owner protocol', async () => {
readScope.mockResolvedValue({ ...scope, fileOwnerProtocolVersion: 1 })
routeMatcher.mockReturnValue({ params: {}, load: async () => ({ POST: fetcher }) })
diff --git a/apps/sim/lib/mothership/tools/sandbox-resource-transport.ts b/apps/sim/lib/mothership/tools/sandbox-resource-transport.ts
index 3e0daa2a5c8..699cbdca1ed 100644
--- a/apps/sim/lib/mothership/tools/sandbox-resource-transport.ts
+++ b/apps/sim/lib/mothership/tools/sandbox-resource-transport.ts
@@ -28,6 +28,7 @@ import {
type SandboxResourceScope,
} from '@/lib/mothership/tools/sandbox-resources'
import { chatSandboxSessionKey } from '@/lib/mothership/tools/sandbox-session-key'
+import { copiesWithinWorkspaces } from '@/lib/workspace-files/application/copy-operation'
const logger = createLogger('MothershipSandboxResourceTransport')
@@ -54,15 +55,20 @@ export async function proxySandboxResourceRequest(
if (!scope)
return Response.json({ error: 'Sandbox tool execution is no longer active' }, { status: 403 })
/**
- * Paired owners need the negotiated protocol. Without it, only a copy that names a non-workspace
- * owner is refused there; workspace copies and invalid bodies keep the workspace route and its
+ * Copies bind both owners under the paired grant. Project owners need the negotiated protocol,
+ * and an admitted lease copies between workspaces without it; a copy naming a Project owner is
+ * refused there. Otherwise workspace copies and invalid bodies keep the workspace route and its
* v2 validation response.
*/
if (path === v2CopyFileItemsContract.path) {
const buffered = await bufferCopyRequest(request)
if (buffered instanceof Response) return buffered
request = buffered
- if (scope.fileOwnerProtocolVersion === 1 || (await copiesBeyondWorkspaces(request)))
+ if (
+ scope.fileOwnerProtocolVersion === 1 ||
+ scope.workspaceFileCopy ||
+ (await copiesBeyondWorkspaces(request))
+ )
return proxySandboxFileCopyRequest(request, token, scope)
}
const ownerHeader = request.headers.get('x-mothership-file-owner')
@@ -245,9 +251,5 @@ async function copiesBeyondWorkspaces(request: Request): Promise {
{},
V2_PARSE_DEFAULTS
)
- return (
- parsed.success &&
- (parsed.data.body.source.owner.entityType !== 'workspace' ||
- parsed.data.body.destination.owner.entityType !== 'workspace')
- )
+ return parsed.success && !copiesWithinWorkspaces(parsed.data.body)
}
diff --git a/apps/sim/lib/mothership/tools/sandbox-resources.test.ts b/apps/sim/lib/mothership/tools/sandbox-resources.test.ts
index 29e1aa6e9fe..f737879fee2 100644
--- a/apps/sim/lib/mothership/tools/sandbox-resources.test.ts
+++ b/apps/sim/lib/mothership/tools/sandbox-resources.test.ts
@@ -308,7 +308,7 @@ it.each([
[true, false, true, undefined],
[true, true, false, undefined],
] as const)(
- 'negotiates the callback protocol only with admission=%s projects=%s peer=%s',
+ 'negotiates the callback protocol only with admission=%s projects=%s peer=%s; copies follow admission alone',
async (admitted, enabled, supported, expected) => {
featureFlagsMockFns.mockIsFeatureEnabled.mockResolvedValue(enabled)
vi.stubGlobal(
@@ -334,6 +334,7 @@ it.each([
if (!token) throw new Error('Callback token missing')
const scope = await readSandboxResourceScope(token, 'secret')
expect(scope?.fileOwnerProtocolVersion).toBe(expected)
+ expect(scope?.workspaceFileCopy).toBe(admitted ? true : undefined)
}
)
}
diff --git a/apps/sim/lib/mothership/tools/sandbox-resources.ts b/apps/sim/lib/mothership/tools/sandbox-resources.ts
index 6f0245558cd..4fb021da7fb 100644
--- a/apps/sim/lib/mothership/tools/sandbox-resources.ts
+++ b/apps/sim/lib/mothership/tools/sandbox-resources.ts
@@ -28,6 +28,7 @@ const scopeSchema = z
organizationId: z.string().optional(),
apiKeyHash: z.string(),
fileOwnerProtocolVersion: z.literal(1).optional(),
+ workspaceFileCopy: z.literal(true).optional(),
})
.refine(
(scope) => Boolean(scope.workspaceId) !== Boolean(scope.organizationId),
@@ -46,6 +47,7 @@ interface ActiveSandboxResourceScope {
signal: AbortSignal
token?: string
fileOwnerProtocolVersion?: 1
+ workspaceFileCopy?: true
}
const resourceScope = new AsyncLocalStorage()
@@ -60,6 +62,11 @@ export function activeSandboxFileOwnerProtocol() {
return resourceScope.getStore()?.fileOwnerProtocolVersion
}
+/** An admitted tool's lease copies between workspaces whether or not Projects are on. */
+export function activeSandboxWorkspaceFileCopy() {
+ return resourceScope.getStore()?.workspaceFileCopy
+}
+
const contextKey = (token: string) => `mothership:sandbox-resources:${token}:context`
const inboxKey = (token: string) => `mothership:sandbox-resources:${token}:inbox`
const seenKey = (token: string) => `mothership:sandbox-resources:${token}:seen`
@@ -96,6 +103,7 @@ export async function sandboxResourceEndpoint(
(await supportsFileOwnerProtocol(identity.mothershipBaseURL))
? 1
: undefined
+ const workspaceFileCopy = identity.resourceAdmitted ? (true as const) : undefined
const scope: SandboxResourceScope = {
toolCallId: identity.toolCallId,
runId: identity.runId,
@@ -106,8 +114,10 @@ export async function sandboxResourceEndpoint(
organizationId: identity.organizationId,
apiKeyHash: sha256Hex(apiKey),
fileOwnerProtocolVersion,
+ workspaceFileCopy,
}
active.fileOwnerProtocolVersion = fileOwnerProtocolVersion
+ active.workspaceFileCopy = workspaceFileCopy
await redisClient().set(contextKey(token), JSON.stringify(scope), 'EX', CONTEXT_TTL_SECONDS)
active.token = token
if (active.signal.aborted) {
diff --git a/apps/sim/lib/mothership/tools/sandbox-session.test.ts b/apps/sim/lib/mothership/tools/sandbox-session.test.ts
index 75f6d6eeff7..4d3a2e14046 100644
--- a/apps/sim/lib/mothership/tools/sandbox-session.test.ts
+++ b/apps/sim/lib/mothership/tools/sandbox-session.test.ts
@@ -10,11 +10,12 @@ import {
import { urlsMockFns } from '@sim/testing/mocks/urls.mock'
import { beforeEach, describe, expect, it, vi } from 'vitest'
-const { read, mint, connection, endpoint } = vi.hoisted(() => ({
+const { read, mint, connection, endpoint, workspaceFileCopy } = vi.hoisted(() => ({
connection: vi.fn(),
endpoint: vi.fn(),
read: vi.fn(),
mint: vi.fn(),
+ workspaceFileCopy: vi.fn(),
}))
vi.mock('@/lib/mothership/transport/connection', () => ({ getSimConnection: connection }))
vi.mock('node:fs/promises', () => ({ readFile: read }))
@@ -85,6 +86,14 @@ describe('deployment-owned workbench tooling', () => {
})
})
+ it('tells the workbench an admitted lease copies between workspaces without Projects', async () => {
+ read.mockResolvedValue('bundle')
+ workspaceFileCopy.mockReturnValue(true)
+ const session = await buildMothershipSandboxSession(request)
+ expect(session.envs).toMatchObject({ SIM_WORKSPACE_FILE_COPY: '1' })
+ expect(session.envs).not.toHaveProperty('SIM_FILE_OWNER_PROTOCOL')
+ })
+
it('refuses setup when the deployment artifact is missing instead of fetching another CLI release', async () => {
read.mockRejectedValue(new Error('missing workbench bundle'))
await expect(buildMothershipSandboxSession(request)).rejects.toThrow('missing workbench bundle')
@@ -130,6 +139,7 @@ vi.mock('@/lib/mothership/tools/sandbox-resources', () => ({
sandboxResourceEndpoint: endpoint,
activeSandboxChatOwner: () => undefined,
activeSandboxFileOwnerProtocol: () => undefined,
+ activeSandboxWorkspaceFileCopy: workspaceFileCopy,
}))
it('does not inject authentication when no active scoped callback can be established', async () => {
diff --git a/apps/sim/lib/mothership/tools/sandbox-session.ts b/apps/sim/lib/mothership/tools/sandbox-session.ts
index 1cbd26b006c..b502314401c 100644
--- a/apps/sim/lib/mothership/tools/sandbox-session.ts
+++ b/apps/sim/lib/mothership/tools/sandbox-session.ts
@@ -20,6 +20,7 @@ import { getMothershipBaseURL } from '@/lib/mothership/server/agent-url'
import {
activeSandboxChatOwner,
activeSandboxFileOwnerProtocol,
+ activeSandboxWorkspaceFileCopy,
sandboxResourceEndpoint,
} from '@/lib/mothership/tools/sandbox-resources'
import { getSimConnection } from '@/lib/mothership/transport/connection'
@@ -153,6 +154,7 @@ export async function buildMothershipSandboxSession(args: {
: { SIM_WORKSPACE: args.workspaceId! }),
SIM_ENDPOINT: scopedEndpoint,
...(activeSandboxFileOwnerProtocol() === 1 ? { SIM_FILE_OWNER_PROTOCOL: '1' } : {}),
+ ...(activeSandboxWorkspaceFileCopy() ? { SIM_WORKSPACE_FILE_COPY: '1' } : {}),
}
}
} catch (error) {
diff --git a/apps/sim/lib/projects/files/__integration__/authorization-bulk.integration.ts b/apps/sim/lib/projects/files/__integration__/authorization-bulk.integration.ts
index a9c4e7c77e1..db4c7cde905 100644
--- a/apps/sim/lib/projects/files/__integration__/authorization-bulk.integration.ts
+++ b/apps/sim/lib/projects/files/__integration__/authorization-bulk.integration.ts
@@ -390,6 +390,33 @@ describe('compound copy owner authority', () => {
await expect(db.transaction(authorize)).rejects.toMatchObject({ code: 'forbidden' })
})
+ check(
+ 'copy between workspaces runs with Projects off; a Project owner still needs them',
+ async () => {
+ featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation(async () => false)
+ const f = await fixture()
+ await grant(f.readerId, f.workspaces[1], 'write')
+ const withProject = selection(f)
+ const between = {
+ ...withProject,
+ source: {
+ ...withProject.source,
+ owner: { entityType: 'workspace' as const, entityId: f.workspaces[0] },
+ },
+ }
+ for (const principal of [f.reader, copyPrincipal(f, between)]) {
+ const authorize = await createFileCopyAuthorizer(principal, between)
+ expect(await db.transaction(authorize)).toMatchObject({
+ source: { owner: between.source.owner },
+ destination: { owner: between.destination.owner },
+ })
+ }
+ await expect(createFileCopyAuthorizer(f.reader, withProject)).rejects.toMatchObject({
+ statusCode: 503,
+ })
+ }
+ )
+
check(
'copy locks opposite Project directions consistently in overlapping transactions',
async () => {
diff --git a/apps/sim/lib/projects/files/__integration__/authorization-product.integration.ts b/apps/sim/lib/projects/files/__integration__/authorization-product.integration.ts
index a206ddd632c..26b8ed56aad 100644
--- a/apps/sim/lib/projects/files/__integration__/authorization-product.integration.ts
+++ b/apps/sim/lib/projects/files/__integration__/authorization-product.integration.ts
@@ -30,6 +30,7 @@ import {
type CopilotExecutionContext,
createCopilotResourceAdmission,
} from '@/lib/mothership/auth/application-delegation'
+import { WORKSPACE_FILE_COPY_CAPABILITY } from '@/lib/mothership/generated/protocol'
import { listProjects } from '@/lib/projects/application'
import { getProjectFileMetadata } from '@/lib/projects/files/application'
import { projectFileOperations } from '@/lib/projects/files/application/operations'
@@ -391,8 +392,10 @@ describe('Mothership origin navigation and independent Project authority', () =>
},
],
}
+ const copies = { clientCapabilities: [WORKSPACE_FILE_COPY_CAPABILITY] }
const noProject = {
message: 'hello',
+ ...copies,
fileOwnerProtocolVersion: 1,
project: null,
fileOwnerCapabilities: [],
@@ -407,7 +410,19 @@ describe('Mothership origin navigation and independent Project authority', () =>
const send = () => withFileOwnerContext(forged, context(f), workerURL, '/api/mothership')
featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation(async () => false)
- expect(await send()).toEqual({ message: 'hello' })
+ // Projects off: the admitted turn still copies between workspaces, and only it does.
+ expect(await send()).toEqual({ message: 'hello', ...copies })
+ expect(
+ await withFileOwnerContext(
+ forged,
+ { ...context(f), requestMode: 'assistant' },
+ workerURL,
+ '/api/mothership'
+ )
+ ).toEqual({ message: 'hello' })
+ expect(
+ await withFileOwnerContext(forged, context(f), workerURL, '/api/tools/resume')
+ ).toEqual({ message: 'hello' })
featureFlagsMockFns.mockIsFeatureEnabled.mockImplementation(
async (flag) => flag === 'projects'
diff --git a/apps/sim/lib/workflows/executor/execution-state.ts b/apps/sim/lib/workflows/executor/execution-state.ts
index 835f491f1a5..a498069d74b 100644
--- a/apps/sim/lib/workflows/executor/execution-state.ts
+++ b/apps/sim/lib/workflows/executor/execution-state.ts
@@ -154,6 +154,40 @@ export async function getWorkflowExecutionLogStatus(
return row?.status
}
+/** The log's own correlation wins; older logs carried it only on the trigger data. */
+function selectCorrelation(
+ topLevel: unknown,
+ fromTrigger: unknown
+): Record | undefined {
+ if (isRecordLike(topLevel)) return topLevel
+ return isRecordLike(fromTrigger) ? fromTrigger : undefined
+}
+
+/**
+ * The Copilot tool call an execution was launched for, from the server-persisted
+ * correlation on its log, or `undefined` when no Copilot tool call bound it.
+ */
+export async function getWorkflowExecutionCopilotToolCallId(
+ executionId: string,
+ workflowId: string
+): Promise {
+ const [row] = await db
+ .select({
+ correlation: sql`${workflowExecutionLogs.executionData} -> 'correlation'`,
+ triggerCorrelation: sql`${workflowExecutionLogs.executionData} #> '{trigger,data,correlation}'`,
+ })
+ .from(workflowExecutionLogs)
+ .where(
+ and(
+ eq(workflowExecutionLogs.executionId, executionId),
+ eq(workflowExecutionLogs.workflowId, workflowId)
+ )
+ )
+ .limit(1)
+ const toolCallId = selectCorrelation(row?.correlation, row?.triggerCorrelation)?.copilotToolCallId
+ return typeof toolCallId === 'string' ? toolCallId : undefined
+}
+
/** Loads a terminal workflow result only when its server-persisted Copilot binding matches. */
export async function getTrustedWorkflowToolExecution(
executionId: string,
@@ -171,21 +205,13 @@ export async function getTrustedWorkflowToolExecution(
const executionData = await materializeExecutionDataFromRow(row)
const state = extractExecutionState(executionData)
const provenance = state?.resolvedSecretTraceProvenance
- const topLevelCorrelation = executionData?.correlation
- const triggerCorrelation = isRecordLike(executionData?.trigger)
- ? executionData.trigger.data
- : undefined
- const correlation = isRecordLike(topLevelCorrelation)
- ? topLevelCorrelation
- : isRecordLike(triggerCorrelation) && isRecordLike(triggerCorrelation.correlation)
- ? triggerCorrelation.correlation
- : undefined
+ const triggerData = isRecordLike(executionData?.trigger) ? executionData.trigger.data : undefined
+ const correlation = selectCorrelation(
+ executionData?.correlation,
+ isRecordLike(triggerData) ? triggerData.correlation : undefined
+ )
- if (
- !executionData ||
- !isRecordLike(correlation) ||
- correlation.copilotToolCallId !== copilotToolCallId
- ) {
+ if (!executionData || correlation?.copilotToolCallId !== copilotToolCallId) {
return null
}
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 a4963191ec4..d7b811e187c 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
@@ -47,6 +47,7 @@ import {
type ExecutionEvent,
LIVE_ONLY_EXECUTION_EVENT_TYPES,
} from '@/lib/workflows/executor/execution-events'
+import { getWorkflowExecutionCopilotToolCallId } from '@/lib/workflows/executor/execution-state'
import {
createPausedExecutionResumeMetadata,
parsePausedExecutionResumeMetadata,
@@ -125,6 +126,31 @@ async function releaseCancelledResumeReservations(
)
}
+/**
+ * Reports the Copilot workflow tool bound to a paused execution that has now
+ * settled; a paused run has no browser left to report it. Best effort.
+ */
+async function reportCopilotToolForSettledPause(
+ executionId: string,
+ workflowId: string
+): Promise {
+ try {
+ const toolCallId = await getWorkflowExecutionCopilotToolCallId(executionId, workflowId)
+ if (!toolCallId) return
+ // Loaded only for a Copilot-bound run: its confirmation channel opens Redis connections at import.
+ const { reportSettledClientWorkflowTool } = await import(
+ '@/lib/mothership/request/tools/workflow-client-settlement'
+ )
+ await reportSettledClientWorkflowTool({ toolCallId, executionId, workflowId })
+ } catch (error) {
+ logger.warn('Failed to report a settled paused Copilot workflow tool', {
+ executionId,
+ workflowId,
+ error: toError(error).message,
+ })
+ }
+}
+
/**
* A resume attempt that was not admitted, carrying the status the caller should
* see. Every admission refusal must be raised through this rather than a bare
@@ -1006,6 +1032,10 @@ export class PauseResumeManager {
stopAfterBlockReached: result.metadata?.stopAfterBlockReached === true,
})
}
+ await reportCopilotToolForSettledPause(
+ pausedExecution.executionId,
+ pausedExecution.workflowId
+ )
}
if (result.status === 'paused') {
@@ -1075,6 +1105,10 @@ export class PauseResumeManager {
}),
})
if (!(error instanceof ResumeAdmissionError)) {
+ await reportCopilotToolForSettledPause(
+ pausedExecution.executionId,
+ pausedExecution.workflowId
+ )
await PauseResumeManager.processQueuedResumes(
pausedExecution.executionId,
pausedExecution.workflowId
@@ -2855,6 +2889,9 @@ export class PauseResumeManager {
})
await releaseCancelledResumeReservations(transition.claimedResumeEntryIds)
+ if (transition.cancelled) {
+ await reportCopilotToolForSettledPause(executionId, workflowId)
+ }
return transition.cancelled
}
@@ -2958,6 +2995,11 @@ export class PauseResumeManager {
})
await releaseCancelledResumeReservations(transition.claimedResumeEntryIds)
+ // The terminal writer may never report it (the stale-execution sweep, a resume worker killed
+ // after its log write), which would leave the Copilot tool waiting.
+ if (transition.finalized) {
+ await reportCopilotToolForSettledPause(executionId, workflowId)
+ }
return transition.finalized
}
diff --git a/apps/sim/lib/workspace-files/application/copy-authorization.ts b/apps/sim/lib/workspace-files/application/copy-authorization.ts
index a7a185bb07b..ce6258c86eb 100644
--- a/apps/sim/lib/workspace-files/application/copy-authorization.ts
+++ b/apps/sim/lib/workspace-files/application/copy-authorization.ts
@@ -27,6 +27,7 @@ import {
import { lockProject, lockProjectBackfillWrites } from '@/lib/projects/membership'
import { requireProjectApiEnabled } from '@/lib/projects/rollout.server'
import {
+ copiesWithinWorkspaces,
FILE_COPY_DELEGATION_TTL_MS,
fileCopyOperation,
} from '@/lib/workspace-files/application/copy-operation'
@@ -149,7 +150,7 @@ export async function createFileCopyAuthorizer(
input: CopyFileItemsInput
): Promise<(tx: DbTransaction) => Promise> {
requireCopyPrincipal(principal, input)
- await requireProjectApiEnabled()
+ if (!copiesWithinWorkspaces(input)) await requireProjectApiEnabled()
// actorless-unsupported: files.copy rejects executors and workspace keys; both owner policies require the acting human.
const userId = await requireFileSubject(principal)
const invocationScope =
diff --git a/apps/sim/lib/workspace-files/application/copy-operation.ts b/apps/sim/lib/workspace-files/application/copy-operation.ts
index 9e70495fe8f..8503d24e930 100644
--- a/apps/sim/lib/workspace-files/application/copy-operation.ts
+++ b/apps/sim/lib/workspace-files/application/copy-operation.ts
@@ -21,3 +21,17 @@ Object.freeze(policy.delegatedServices)
/** Compound copies bind independent source-read and destination-write authority. */
export const fileCopyOperation = Object.freeze(policy)
+
+/**
+ * Whether a copy names only workspace owners. Every workspace belongs to a Project whether or not
+ * Projects are on, so only a copy that names a Project owner needs them.
+ */
+export function copiesWithinWorkspaces(input: {
+ source: { owner: { entityType: string } }
+ destination: { owner: { entityType: string } }
+}): boolean {
+ return (
+ input.source.owner.entityType === 'workspace' &&
+ input.destination.owner.entityType === 'workspace'
+ )
+}
diff --git a/apps/sim/providers/gemini/core.test.ts b/apps/sim/providers/gemini/core.test.ts
index f956d2a0eaf..7e051494ded 100644
--- a/apps/sim/providers/gemini/core.test.ts
+++ b/apps/sim/providers/gemini/core.test.ts
@@ -139,6 +139,21 @@ describe('Vertex Gemini request compatibility', () => {
})
})
+ /** Omitting thinkingConfig leaves Gemini 3 thinking at its default, so 'none' must send a level. */
+ it.each([
+ ['vertex/gemini-3.6-flash', 'MINIMAL'],
+ ['vertex/gemini-3.8-flash', 'LOW'],
+ ['vertex/projects/p/locations/global/publishers/google/models/gemini-3.8-flash', 'LOW'],
+ ])("sends the lowest thinking level for 'none' on %s", async (model, thinkingLevel) => {
+ const generateContent = vi.fn().mockResolvedValue(textTurn())
+ await run(model, generateContent, { thinkingLevel: 'none' })
+
+ expect(generateContent.mock.calls[0][0].config.thinkingConfig).toEqual({
+ thinkingLevel,
+ includeThoughts: false,
+ })
+ })
+
it.each(['vertex/gemini-2.5-flash', 'vertex/Custom-Gemini-Model'])(
'preserves temperature for supported or uncataloged %s',
async (model) => {
diff --git a/apps/sim/providers/gemini/core.ts b/apps/sim/providers/gemini/core.ts
index b1764a78d46..a8e886cf189 100644
--- a/apps/sim/providers/gemini/core.ts
+++ b/apps/sim/providers/gemini/core.ts
@@ -36,7 +36,7 @@ import {
mapToThinkingLevel,
supportsDisablingGemini25Thinking,
} from '@/providers/google/utils'
-import { getModelCapabilities, isKnownModelId } from '@/providers/models'
+import { getModelCapabilities, getThinkingLevelsForModel, isKnownModelId } from '@/providers/models'
import { withProviderRetry } from '@/providers/retry'
import { executeProviderTool } from '@/providers/runtime-context'
import { createSettledAgentEventStream } from '@/providers/stream-events'
@@ -57,6 +57,7 @@ import {
prepareToolExecution,
prepareToolsWithUsageControl,
sumToolCosts,
+ toBareGeminiModelId,
} from '@/providers/utils'
import type { ExecutionState, GeminiProviderType, GeminiUsage } from './types'
@@ -1010,17 +1011,26 @@ export async function executeGeminiRequest(
// Gemini 3 and later take thinkingLevel directly; Gemini 2.5-series rejects it and needs thinkingBudget.
// includeThoughts is required for thought parts to appear; it is requested
// only on agent-events runs so legacy runs keep the pre-agent-events payload.
- if (request.thinkingLevel && request.thinkingLevel !== 'none') {
+ const isGemini3 = isGemini3OrLaterModel(model)
+ // Gemini 3 has no off switch and omitting thinkingConfig means the model's default level, so
+ // 'none' sends the lowest declared level (levels are listed lowest-first; MINIMAL is closest to off).
+ // The catalog keys Vertex entries as vertex/, so resource-name IDs are reduced first.
+ const catalogModelId = `${providerType === 'vertex' ? 'vertex/' : ''}${toBareGeminiModelId(model)}`
+ const thinkingLevel =
+ isGemini3 && request.thinkingLevel === 'none'
+ ? getThinkingLevelsForModel(catalogModelId)?.[0]
+ : request.thinkingLevel
+ if (thinkingLevel && thinkingLevel !== 'none') {
const thinkingConfig: ThinkingConfig = { includeThoughts: request.agentEvents === true }
- if (isGemini3OrLaterModel(model)) {
- thinkingConfig.thinkingLevel = mapToThinkingLevel(request.thinkingLevel)
+ if (isGemini3) {
+ thinkingConfig.thinkingLevel = mapToThinkingLevel(thinkingLevel)
} else {
- thinkingConfig.thinkingBudget = mapToThinkingBudget(model, request.thinkingLevel)
+ thinkingConfig.thinkingBudget = mapToThinkingBudget(model, thinkingLevel)
}
geminiConfig.thinkingConfig = thinkingConfig
} else if (
+ !isGemini3 &&
request.thinkingLevel === 'none' &&
- !isGemini3OrLaterModel(model) &&
supportsDisablingGemini25Thinking(model)
) {
// Omitting thinkingConfig falls back to the API's dynamic default (ON for gemini-2.5-flash),
@@ -1085,7 +1095,7 @@ export async function executeGeminiRequest(
*/
const hasActiveTools = Boolean(geminiConfig.tools?.length)
const responseFormatNeedsFinalPass =
- Boolean(request.responseFormat) && hasActiveTools && !isGemini3OrLaterModel(model)
+ Boolean(request.responseFormat) && hasActiveTools && !isGemini3
const liveToolLoopSupported = !responseFormatNeedsFinalPass
const shouldStream = request.stream && !hasActiveTools
diff --git a/apps/sim/providers/models.ts b/apps/sim/providers/models.ts
index ac769c5421c..38298c3b43f 100644
--- a/apps/sim/providers/models.ts
+++ b/apps/sim/providers/models.ts
@@ -89,7 +89,8 @@ export interface ModelCapabilities {
streamed?: ThinkingStreamVisibility
/**
* Explicit API mode for the pickers' `none` level on models that think by
- * default. Omitted, `none` sends no thinking config.
+ * default. Omitted, `none` sends no thinking config, except on Gemini 3+, which
+ * has no off switch and sends its lowest declared level instead.
*/
noneMode?: 'between_tools' | 'disabled'
/** Whether forced tool choice can be combined with configured thinking. */
diff --git a/apps/sim/providers/utils.ts b/apps/sim/providers/utils.ts
index 93b6e9125b9..cdb4ac188fd 100644
--- a/apps/sim/providers/utils.ts
+++ b/apps/sim/providers/utils.ts
@@ -1491,16 +1491,23 @@ export function isDeepResearchModel(model: string): boolean {
return MODELS_WITH_DEEP_RESEARCH.includes(model.toLowerCase())
}
-/** Accepts bare Gemini IDs, google/ or vertex/ prefixes, and Google publisher resource names. */
-export function isGemini3OrLaterModel(model: string): boolean {
- const normalized = model
+/**
+ * Reduces a Gemini model reference to its bare lowercase ID (`gemini-3.8-flash`). Accepts bare
+ * Gemini IDs, google/ or vertex/ prefixes, and Google publisher resource names.
+ */
+export function toBareGeminiModelId(model: string): string {
+ return model
.toLowerCase()
.replace(/^vertex\//, '')
.replace(
/^(?:google\/|(?:projects\/[^/]+\/locations\/[^/]+\/)?publishers\/google\/models\/)/,
''
)
- return Number(normalized.match(/^gemini-(\d+)(?:[.-]|$)/)?.[1]) >= 3
+}
+
+/** Accepts bare Gemini IDs, google/ or vertex/ prefixes, and Google publisher resource names. */
+export function isGemini3OrLaterModel(model: string): boolean {
+ return Number(toBareGeminiModelId(model).match(/^gemini-(\d+)(?:[.-]|$)/)?.[1]) >= 3
}
/**
diff --git a/apps/sim/public/email/broadcast/desktop-cli-search/desktop-overview.jpg b/apps/sim/public/email/broadcast/desktop-cli-search/desktop-overview.jpg
new file mode 100644
index 00000000000..175cd5beecd
Binary files /dev/null and b/apps/sim/public/email/broadcast/desktop-cli-search/desktop-overview.jpg differ
diff --git a/apps/sim/public/library/best-claude-cowork-alternatives-2026/cover.jpg b/apps/sim/public/library/best-claude-cowork-alternatives-2026/cover.jpg
new file mode 100644
index 00000000000..2eeccf9e2bb
Binary files /dev/null and b/apps/sim/public/library/best-claude-cowork-alternatives-2026/cover.jpg differ
diff --git a/apps/sim/public/library/best-lindy-alternatives-2026/cover.jpg b/apps/sim/public/library/best-lindy-alternatives-2026/cover.jpg
new file mode 100644
index 00000000000..0535f785bfc
Binary files /dev/null and b/apps/sim/public/library/best-lindy-alternatives-2026/cover.jpg differ
diff --git a/apps/sim/public/library/best-openclaw-alternatives-for-teams-2026/cover.jpg b/apps/sim/public/library/best-openclaw-alternatives-for-teams-2026/cover.jpg
new file mode 100644
index 00000000000..766da3beaad
Binary files /dev/null and b/apps/sim/public/library/best-openclaw-alternatives-for-teams-2026/cover.jpg differ
diff --git a/apps/sim/public/library/best-workato-alternatives-ai-agents-2026/cover.jpg b/apps/sim/public/library/best-workato-alternatives-ai-agents-2026/cover.jpg
new file mode 100644
index 00000000000..90324d90664
Binary files /dev/null and b/apps/sim/public/library/best-workato-alternatives-ai-agents-2026/cover.jpg differ
diff --git a/apps/sim/stores/mothership-queue/store.test.ts b/apps/sim/stores/mothership-queue/store.test.ts
index 53ad30f1d6a..3c0bf93ed65 100644
--- a/apps/sim/stores/mothership-queue/store.test.ts
+++ b/apps/sim/stores/mothership-queue/store.test.ts
@@ -127,6 +127,7 @@ describe('useMothershipQueueStore', () => {
id: 'm1',
content: 'original',
hold: 'user',
+ hasUnavailableContexts: true,
resumeUserMessageId: 'prior-request',
/** Its Stop never settled, so it was never sent: the server cannot hold it. */
admissionUnknown: false,
@@ -151,6 +152,7 @@ describe('useMothershipQueueStore', () => {
})
expect(edited?.resumeUserMessageId).toBeUndefined()
expect(edited?.hold).toBeUndefined()
+ expect(edited?.hasUnavailableContexts).toBeUndefined()
})
it('strips queuedSendHandoff on edit so a fresh handoff is minted at send time', () => {
diff --git a/apps/sim/stores/mothership-queue/store.ts b/apps/sim/stores/mothership-queue/store.ts
index ee6c72c8c2d..732ab6b0107 100644
--- a/apps/sim/stores/mothership-queue/store.ts
+++ b/apps/sim/stores/mothership-queue/store.ts
@@ -207,6 +207,7 @@ export const useMothershipQueueStore = create()(
const {
queuedSendHandoff,
resumeUserMessageId: _staleResume,
+ hasUnavailableContexts: _unavailableContexts,
hold: _hold,
heldSurface: _surface,
retry: _retry,
diff --git a/bun.lock b/bun.lock
index 1811b6fa345..43a6da0e39d 100644
--- a/bun.lock
+++ b/bun.lock
@@ -113,6 +113,7 @@
"zod": "4.3.6",
},
"devDependencies": {
+ "@playwright/test": "1.61.1",
"@sim/tsconfig": "workspace:*",
"@tailwindcss/postcss": "^4.3.3",
"@types/node": "24.19.2",
@@ -847,8 +848,11 @@
"postgres@3.4.9": "patches/postgres@3.4.9.patch",
},
"overrides": {
+ "@grpc/grpc-js": "1.14.6",
"@hono/node-server": "1.19.15",
"@next/env": "16.4.0",
+ "@xmldom/xmldom": "0.8.15",
+ "axios": "1.20.0",
"brace-expansion": "5.0.12",
"deepmerge-ts": "8.0.0",
"dompurify": "3.4.17",
@@ -856,8 +860,10 @@
"e2b": "2.50.0",
"engine.io": "6.6.11",
"fast-uri": "3.1.8",
+ "follow-redirects": "1.16.1",
"hono": "4.13.13",
"ip-address": "10.7.4",
+ "joi": "17.13.8",
"marked": "18.0.11",
"mermaid": "11.16.1",
"minimatch": "10.2.6",
@@ -869,8 +875,11 @@
"prosemirror-transform": "1.12.0",
"prosemirror-view": "1.42.6",
"protobufjs": "7.6.5",
+ "proxy-addr": "2.0.8",
+ "qs": "6.16.0",
"react": "19.2.4",
"react-dom": "19.2.4",
+ "shell-quote": "1.12.0",
"socket.io": "4.8.4",
"socket.io-parser": "4.2.7",
"ws": "8.21.3",
@@ -1355,7 +1364,7 @@
"@graphql-typed-document-node/core": ["@graphql-typed-document-node/core@3.2.0", "", { "peerDependencies": { "graphql": "^0.8.0 || ^0.9.0 || ^0.10.0 || ^0.11.0 || ^0.12.0 || ^0.13.0 || ^14.0.0 || ^15.0.0 || ^16.0.0 || ^17.0.0" } }, "sha512-mB9oAsNCm9aM3/SOv4YtBMqZbYj10R7dkq8byBqxGY/ncFwhf2oQzMV+LCRlWoDSEBJ3COiR1yeDvMtsoOsuFQ=="],
- "@grpc/grpc-js": ["@grpc/grpc-js@1.14.4", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-k9Dj3DV/itK9D06Y8f190Qgop7/Ui+D0njFV3LHMPwPT75DpXLQohE9Wmz0QElrJnzsjB7KPWiKJbOl7IPDArQ=="],
+ "@grpc/grpc-js": ["@grpc/grpc-js@1.14.6", "", { "dependencies": { "@grpc/proto-loader": "^0.8.0", "@js-sdsl/ordered-map": "^4.4.2" } }, "sha512-SgC/B3/VZ8lTyOZ4Cwk6f1skWhmkNAaVfiX90qd5u1rRXGKk1//8FTUEN9GvOUq7rfWIqAYkA3CoyumYVpOS6g=="],
"@grpc/proto-loader": ["@grpc/proto-loader@0.8.1", "", { "dependencies": { "lodash.camelcase": "^4.3.0", "long": "^5.0.0", "protobufjs": "^7.5.5", "yargs": "^17.7.2" }, "bin": { "proto-loader-gen-types": "build/bin/proto-loader-gen-types.js" } }, "sha512-wtF6h+DY6M3YaDBPAmvuuA6jV8Sif9MjtOI5euKFWRgCDl5PeDpPsHR9u2l6St5ceY8AZgoNDww5+HvEsXFsGg=="],
@@ -2485,7 +2494,7 @@
"@xmldom/is-dom-node": ["@xmldom/is-dom-node@1.0.1", "", {}, "sha512-CJDxIgE5I0FH+ttq/Fxy6nRpxP70+e2O048EPe85J2use3XKdatVM7dDVvFNjQudd9B49NPoZ+8PG49zj4Er8Q=="],
- "@xmldom/xmldom": ["@xmldom/xmldom@0.8.13", "", {}, "sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw=="],
+ "@xmldom/xmldom": ["@xmldom/xmldom@0.8.15", "", {}, "sha512-/5NV/vDALVFDXgLmfsy9TRCBlKwO2LNBFzpzvb9iIj+jR+eSc6DLYYvVOdivT/jm7MtU6TebYuRmzEOI7w40UA=="],
"@xterm/addon-fit": ["@xterm/addon-fit@0.11.0", "", {}, "sha512-jYcgT6xtVYhnhgxh3QgYDnnNMYTcf8ElbxxFzX0IZo+vabQqSPAjC3c1wJrKB5E19VwQei89QCiZZP86DCPF7g=="],
@@ -2585,7 +2594,7 @@
"aws4fetch": ["aws4fetch@1.0.20", "", {}, "sha512-/djoAN709iY65ETD6LKCtyyEI04XIBP5xVvfmNxsEP0uJB5tyaGBztSryRr4HqMStr9R06PisQE7m9zDTXKu6g=="],
- "axios": ["axios@1.19.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-ht/iuYZXEjFxLH/Hkezgd7m6JKlHHXEUSneaDz8uZe1Gj5QZtCnpyDsckvAiEnT89OEbCLmnte4R4sn7P0EKFw=="],
+ "axios": ["axios@1.20.0", "", { "dependencies": { "follow-redirects": "^1.16.0", "form-data": "^4.0.6", "https-proxy-agent": "^5.0.1", "proxy-from-env": "^2.1.0" } }, "sha512-r8aOh8j9cGKpgQAqpzrUHnSIc6a59Y3Xf/cv8sy1DrHCkZHzQGEuoq1tARk6qSyDdtQGSDgpb9kFlruzPvrgwg=="],
"b4a": ["b4a@1.8.1", "", { "peerDependencies": { "react-native-b4a": "*" }, "optionalPeers": ["react-native-b4a"] }, "sha512-aiqre1Nr0B/6DgE2N5vwTc+2/oQZ4Wh1t4NznYY4E00y8LCt6NqdRv81so00oo27D8MVKTpUa/MwUUtBLXCoDw=="],
@@ -3233,7 +3242,7 @@
"finalhandler": ["finalhandler@2.1.1", "", { "dependencies": { "debug": "^4.4.0", "encodeurl": "^2.0.0", "escape-html": "^1.0.3", "on-finished": "^2.4.1", "parseurl": "^1.3.3", "statuses": "^2.0.1" } }, "sha512-S8KoZgRZN+a5rNwqTxlZZePjT/4cnm0ROV70LedRHZ0p8u9fRID0hJUZQpkKLzro8LfmC8sx23bY6tVNxv8pQA=="],
- "follow-redirects": ["follow-redirects@1.16.0", "", {}, "sha512-y5rN/uOsadFT/JfYwhxRS5R7Qce+g3zG97+JrtFZlC9klX/W5hD7iiLzScI4nZqUS7DNUdhPgw4xI8W2LuXlUw=="],
+ "follow-redirects": ["follow-redirects@1.16.1", "", { "peerDependencies": { "debug": "*" }, "optionalPeers": ["debug"] }, "sha512-FNvFGzoMLWmE6Yj9spb/zjd7yiNCHiAW9/Tg9CXrQ8wuu32HtlJOwWO11OJafl5FfY3DxTdQ0vj42zU1kvv5jg=="],
"form-data": ["form-data@4.0.6", "", { "dependencies": { "asynckit": "^0.4.0", "combined-stream": "^1.0.8", "es-set-tostringtag": "^2.1.0", "hasown": "^2.0.4", "mime-types": "^2.1.35" } }, "sha512-vKatAh4SlVfgbv+YtmhiRjhEMJsYpsG1Y2rMQtR+SVSbytsSD1YGzDIcrAJmdFec88u/+VoGmxnl+80gL1tRCQ=="],
@@ -3513,7 +3522,7 @@
"jmespath": ["jmespath@0.16.0", "", {}, "sha512-9FzQjJ7MATs1tSpnco1K6ayiYE3figslrXA72G2HQ/n76RzvYlofyi5QM+iX4YRs/pu3yzxlVQSST23+dMDknw=="],
- "joi": ["joi@17.13.6", "", { "dependencies": { "@hapi/hoek": "^9.3.0", "@hapi/topo": "^5.1.0", "@sideway/address": "^4.1.5", "@sideway/formula": "^3.0.1", "@sideway/pinpoint": "^2.0.0" } }, "sha512-ImNZaq/LSysofih+xIGYfR0WUXMA9GLUNB//YTCSrZptoRmVgaNAdJyi6K1kXi9pkLEoSkoI8I4UwtNiu/D7nw=="],
+ "joi": ["joi@17.13.8", "", { "dependencies": { "@hapi/hoek": "^9.3.0", "@hapi/topo": "^5.1.0", "@sideway/address": "^4.1.5", "@sideway/formula": "^3.0.1", "@sideway/pinpoint": "^2.0.0" } }, "sha512-iPKOGmiRw1jxf/JOPwxmCcUQAOdF359mdzYiP2DJ+TMX0YK2zjK3D+zYOaGjpumWxOFF/l2xVWjRVK5bGSLdEw=="],
"jose": ["jose@6.2.3", "", {}, "sha512-YYVDInQKFJfR/xa3ojUTl8c2KoTwiL1R5Wg9YCydwH0x0B9grbzlg5HC7mMjCtUJjbQ/YnGEZIhI5tCgfTb4Hw=="],
@@ -4175,7 +4184,7 @@
"protobufjs": ["protobufjs@7.6.5", "", { "dependencies": { "@protobufjs/aspromise": "^1.1.2", "@protobufjs/base64": "^1.1.2", "@protobufjs/codegen": "^2.0.5", "@protobufjs/eventemitter": "^1.1.1", "@protobufjs/fetch": "^1.1.1", "@protobufjs/float": "^1.0.2", "@protobufjs/path": "^1.1.2", "@protobufjs/pool": "^1.1.0", "@protobufjs/utf8": "^1.1.1", "@types/node": ">=13.7.0", "long": "^5.3.2" } }, "sha512-/FPD0nUc9jH6rfFjji9IBqOz4pcSE3CsT1m7Ep6Mdb0LxSUMj8hgl6GomOvZzpNpAqqGaXA0P3VSrZLFzIhQrw=="],
- "proxy-addr": ["proxy-addr@2.0.7", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg=="],
+ "proxy-addr": ["proxy-addr@2.0.8", "", { "dependencies": { "forwarded": "0.2.0", "ipaddr.js": "1.9.1" } }, "sha512-5nnx0yGyVUcY6t9RnWcARWtwT9F1D8O9rt08htPvnd49W1IgZtmLkhu9WfMzQj1cFxjHIO6connUNVW5k7AVyQ=="],
"proxy-agent-negotiate": ["proxy-agent-negotiate@1.1.0", "", { "peerDependencies": { "kerberos": "^2.0.0" }, "optionalPeers": ["kerberos"] }, "sha512-N8IBcM3UgCVzz2L2Lqv8DVntDnnC8/hiV4nEDUPkqq72TPUgYWjQc+bdZlBPZK9LzPAvOY//gAt0S0DApoOXWQ=="],
@@ -4191,7 +4200,7 @@
"pvutils": ["pvutils@1.1.5", "", {}, "sha512-KTqnxsgGiQ6ZAzZCVlJH5eOjSnvlyEgx1m8bkRJfOhmGRqfo5KLvmAlACQkrjEtOQ4B7wF9TdSLIs9O90MX9xA=="],
- "qs": ["qs@6.15.2", "", { "dependencies": { "side-channel": "^1.1.0" } }, "sha512-Rzq0KEyX/w/tEybncDgdkZrJgVUsUMk3xjh3t5bv3S1HTAtg+uOYt72+ZfwiQwKdysThkTBdL/rTi6HDmX9Ddw=="],
+ "qs": ["qs@6.16.0", "", { "dependencies": { "es-define-property": "^1.0.1", "side-channel": "^1.1.1" } }, "sha512-h6fhOIaRrID2CbEY2fqs+7t+UXZo+MLAnU5gRIq85uFtdiUPCdsApMlHhXogKVM4HM2DVbIjGNTTYH2OcmP1vA=="],
"query-selector-shadow-dom": ["query-selector-shadow-dom@1.0.1", "", {}, "sha512-lT5yCqEBgfoMYpf3F2xQRK7zEr1rhIIZuceDK6+xRkJQ4NMbHTwXqk4NkwDwQMNqXgG9r9fyHnzwNVs6zV5KRw=="],
@@ -5241,8 +5250,6 @@
"@daytona/sdk/dotenv": ["dotenv@17.4.2", "", {}, "sha512-nI4U3TottKAcAD9LLud4Cb7b2QztQMUEfHbvhTH09bqXTxnSie8WnjPALV/WMCrJZ6UV/qHJ6L03OqO3LcdYZw=="],
- "@daytona/sdk/shell-quote": ["shell-quote@1.10.0", "", {}, "sha512-w1aiOKwKuRgtwAReIIj89puqg+I7GvX4IbLrvmhXbzQsj1+Zwi4VO3+fa6ZF91TWSjIxoEkKnMeHcLEODK5ZXA=="],
-
"@daytona/sdk/socket.io-client": ["socket.io-client@4.8.3", "", { "dependencies": { "@socket.io/component-emitter": "~3.1.0", "debug": "~4.4.1", "engine.io-client": "~6.6.1", "socket.io-parser": "~4.2.4" } }, "sha512-uP0bpjWrjQmUt5DTHq9RuoCBdFJF10cdX9X+a368j/Ft0wmaVgxlrjvK3kjvgCODOMMOz9lcaRzxmso0bTWZ/g=="],
"@daytona/sdk/tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="],
diff --git a/package.json b/package.json
index 3d14a74a0f3..9b097687c2d 100644
--- a/package.json
+++ b/package.json
@@ -146,20 +146,28 @@
"mermaid": "11.16.1",
"zod": "4.3.6",
"e2b": "2.50.0",
+ "@grpc/grpc-js": "1.14.6",
"@hono/node-server": "1.19.15",
+ "@xmldom/xmldom": "0.8.15",
+ "axios": "1.20.0",
"brace-expansion": "5.0.12",
"deepmerge-ts": "8.0.0",
"dompurify": "3.4.17",
"engine.io": "6.6.11",
"fast-uri": "3.1.8",
+ "follow-redirects": "1.16.1",
"hono": "4.13.13",
"ip-address": "10.7.4",
+ "joi": "17.13.8",
"postcss": "8.5.30",
"prosemirror-model": "1.25.12",
"prosemirror-state": "1.4.4",
"prosemirror-transform": "1.12.0",
"prosemirror-view": "1.42.6",
"protobufjs": "7.6.5",
+ "proxy-addr": "2.0.8",
+ "qs": "6.16.0",
+ "shell-quote": "1.12.0",
"socket.io": "4.8.4",
"socket.io-parser": "4.2.7",
"ws": "8.21.3"
diff --git a/scripts/check-source-conventions.baseline.json b/scripts/check-source-conventions.baseline.json
index 1e344f1c09e..426c67429a0 100644
--- a/scripts/check-source-conventions.baseline.json
+++ b/scripts/check-source-conventions.baseline.json
@@ -64,7 +64,6 @@
"apps/sim/app/workspace/[workspaceId]/home/components/mothership-view/mothership-view.tsx": 1,
"apps/sim/app/workspace/[workspaceId]/home/home.tsx": 3,
"apps/sim/app/workspace/[workspaceId]/home/hooks/message-reconcile.ts": 1,
- "apps/sim/app/workspace/[workspaceId]/home/hooks/send-handoff.test.ts": 1,
"apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-span-event.test.ts": 3,
"apps/sim/app/workspace/[workspaceId]/home/hooks/stream/handle-tool-event.test.ts": 4,
"apps/sim/app/workspace/[workspaceId]/home/hooks/stream/stream-context.test.ts": 2,
| | | | | | | | | | | |