diff --git a/apps/sim/app/api/billing/update-cost/route.ts b/apps/sim/app/api/billing/update-cost/route.ts index 65899d55572..743ddb17011 100644 --- a/apps/sim/app/api/billing/update-cost/route.ts +++ b/apps/sim/app/api/billing/update-cost/route.ts @@ -4,7 +4,7 @@ import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { recordUsage } from '@/lib/billing/core/usage-log' import { checkAndBillOverageThreshold } from '@/lib/billing/threshold-billing' -import { checkInternalApiKey } from '@/lib/copilot/utils' +import { checkInternalApiKey } from '@/lib/copilot/request/http' import { isBillingEnabled } from '@/lib/core/config/feature-flags' import { generateRequestId } from '@/lib/core/utils/request' diff --git a/apps/sim/app/api/copilot/api-keys/validate/route.ts b/apps/sim/app/api/copilot/api-keys/validate/route.ts index 77521f3b3ed..b653f4d55af 100644 --- a/apps/sim/app/api/copilot/api-keys/validate/route.ts +++ b/apps/sim/app/api/copilot/api-keys/validate/route.ts @@ -2,7 +2,7 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkServerSideUsageLimits } from '@/lib/billing/calculations/usage-monitor' -import { checkInternalApiKey } from '@/lib/copilot/utils' +import { checkInternalApiKey } from '@/lib/copilot/request/http' const logger = createLogger('CopilotApiKeysValidate') diff --git a/apps/sim/app/api/copilot/chat/abort/route.ts b/apps/sim/app/api/copilot/chat/abort/route.ts index 33fe68c8d88..5cd333f0731 100644 --- a/apps/sim/app/api/copilot/chat/abort/route.ts +++ b/apps/sim/app/api/copilot/chat/abort/route.ts @@ -1,10 +1,12 @@ +import { createLogger } from '@sim/logger' import { NextResponse } from 'next/server' import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository' -import { abortActiveStream, waitForPendingChatStream } from '@/lib/copilot/chat-streaming' import { SIM_AGENT_API_URL } from '@/lib/copilot/constants' -import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request-helpers' +import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request/http' +import { abortActiveStream } from '@/lib/copilot/request/session/abort' import { env } from '@/lib/core/config/env' +const logger = createLogger('CopilotChatAbortAPI') const GO_EXPLICIT_ABORT_TIMEOUT_MS = 3000 export async function POST(request: Request) { @@ -15,7 +17,12 @@ export async function POST(request: Request) { return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }) } - const body = await request.json().catch(() => ({})) + const body = await request.json().catch((err) => { + logger.warn('Abort request body parse failed; continuing with empty object', { + error: err instanceof Error ? err.message : String(err), + }) + return {} + }) const streamId = typeof body.streamId === 'string' ? body.streamId : '' let chatId = typeof body.chatId === 'string' ? body.chatId : '' @@ -24,7 +31,13 @@ export async function POST(request: Request) { } if (!chatId) { - const run = await getLatestRunForStream(streamId, authenticatedUserId).catch(() => null) + const run = await getLatestRunForStream(streamId, authenticatedUserId).catch((err) => { + logger.warn('getLatestRunForStream failed while resolving chatId for abort', { + streamId, + error: err instanceof Error ? err.message : String(err), + }) + return null + }) if (run?.chatId) { chatId = run.chatId } @@ -50,15 +63,13 @@ export async function POST(request: Request) { if (!response.ok) { throw new Error(`Explicit abort marker request failed: ${response.status}`) } - } catch { - // best effort: local abort should still proceed even if Go marker fails + } catch (err) { + logger.warn('Explicit abort marker request failed; proceeding with local abort', { + streamId, + error: err instanceof Error ? err.message : String(err), + }) } const aborted = await abortActiveStream(streamId) - if (chatId) { - await waitForPendingChatStream(chatId, GO_EXPLICIT_ABORT_TIMEOUT_MS + 1000, streamId).catch( - () => false - ) - } return NextResponse.json({ aborted }) } diff --git a/apps/sim/app/api/copilot/chat/delete/route.test.ts b/apps/sim/app/api/copilot/chat/delete/route.test.ts index c53c2a11256..0493b3ffe89 100644 --- a/apps/sim/app/api/copilot/chat/delete/route.test.ts +++ b/apps/sim/app/api/copilot/chat/delete/route.test.ts @@ -36,11 +36,11 @@ vi.mock('drizzle-orm', () => ({ eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), })) -vi.mock('@/lib/copilot/chat-lifecycle', () => ({ +vi.mock('@/lib/copilot/chat/lifecycle', () => ({ getAccessibleCopilotChat: mockGetAccessibleCopilotChat, })) -vi.mock('@/lib/copilot/task-events', () => ({ +vi.mock('@/lib/copilot/tasks', () => ({ taskPubSub: { publishStatusChanged: vi.fn() }, })) diff --git a/apps/sim/app/api/copilot/chat/delete/route.ts b/apps/sim/app/api/copilot/chat/delete/route.ts index 652f732e676..1742d9e7e55 100644 --- a/apps/sim/app/api/copilot/chat/delete/route.ts +++ b/apps/sim/app/api/copilot/chat/delete/route.ts @@ -5,8 +5,8 @@ import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' -import { getAccessibleCopilotChat } from '@/lib/copilot/chat-lifecycle' -import { taskPubSub } from '@/lib/copilot/task-events' +import { getAccessibleCopilotChat } from '@/lib/copilot/chat/lifecycle' +import { taskPubSub } from '@/lib/copilot/tasks' const logger = createLogger('DeleteChatAPI') diff --git a/apps/sim/app/api/copilot/chat/queries.ts b/apps/sim/app/api/copilot/chat/queries.ts new file mode 100644 index 00000000000..6f1f548a09f --- /dev/null +++ b/apps/sim/app/api/copilot/chat/queries.ts @@ -0,0 +1,119 @@ +import { db } from '@sim/db' +import { copilotChats } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, desc, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { getAccessibleCopilotChat } from '@/lib/copilot/chat/lifecycle' +import { + authenticateCopilotRequestSessionOnly, + createBadRequestResponse, + createInternalServerErrorResponse, + createUnauthorizedResponse, +} from '@/lib/copilot/request/http' +import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' +import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils' + +const logger = createLogger('CopilotChatAPI') + +function transformChat(chat: { + id: string + title: string | null + model: string | null + messages: unknown + planArtifact?: unknown + config?: unknown + conversationId?: string | null + resources?: unknown + createdAt: Date | null + updatedAt: Date | null +}) { + return { + id: chat.id, + title: chat.title, + model: chat.model, + messages: Array.isArray(chat.messages) ? chat.messages : [], + messageCount: Array.isArray(chat.messages) ? chat.messages.length : 0, + planArtifact: chat.planArtifact || null, + config: chat.config || null, + ...('conversationId' in chat ? { activeStreamId: chat.conversationId || null } : {}), + ...('resources' in chat + ? { resources: Array.isArray(chat.resources) ? chat.resources : [] } + : {}), + createdAt: chat.createdAt, + updatedAt: chat.updatedAt, + } +} + +export async function GET(req: NextRequest) { + try { + const { searchParams } = new URL(req.url) + const workflowId = searchParams.get('workflowId') + const workspaceId = searchParams.get('workspaceId') + const chatId = searchParams.get('chatId') + + const { userId: authenticatedUserId, isAuthenticated } = + await authenticateCopilotRequestSessionOnly() + if (!isAuthenticated || !authenticatedUserId) { + return createUnauthorizedResponse() + } + + if (chatId) { + const chat = await getAccessibleCopilotChat(chatId, authenticatedUserId) + if (!chat) { + return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 }) + } + + logger.info(`Retrieved chat ${chatId}`) + return NextResponse.json({ success: true, chat: transformChat(chat) }) + } + + if (!workflowId && !workspaceId) { + return createBadRequestResponse('workflowId, workspaceId, or chatId is required') + } + + if (workspaceId) { + await assertActiveWorkspaceAccess(workspaceId, authenticatedUserId) + } + + if (workflowId) { + const authorization = await authorizeWorkflowByWorkspacePermission({ + workflowId, + userId: authenticatedUserId, + action: 'read', + }) + if (!authorization.allowed) { + return createUnauthorizedResponse() + } + } + + const scopeFilter = workflowId + ? eq(copilotChats.workflowId, workflowId) + : eq(copilotChats.workspaceId, workspaceId!) + + const chats = await db + .select({ + id: copilotChats.id, + title: copilotChats.title, + model: copilotChats.model, + messages: copilotChats.messages, + planArtifact: copilotChats.planArtifact, + config: copilotChats.config, + createdAt: copilotChats.createdAt, + updatedAt: copilotChats.updatedAt, + }) + .from(copilotChats) + .where(and(eq(copilotChats.userId, authenticatedUserId), scopeFilter)) + .orderBy(desc(copilotChats.updatedAt)) + + const scope = workflowId ? `workflow ${workflowId}` : `workspace ${workspaceId}` + logger.info(`Retrieved ${chats.length} chats for ${scope}`) + + return NextResponse.json({ + success: true, + chats: chats.map(transformChat), + }) + } catch (error) { + logger.error('Error fetching copilot chats:', error) + return createInternalServerErrorResponse('Failed to fetch chats') + } +} diff --git a/apps/sim/app/api/copilot/chat/rename/route.ts b/apps/sim/app/api/copilot/chat/rename/route.ts new file mode 100644 index 00000000000..7587f577411 --- /dev/null +++ b/apps/sim/app/api/copilot/chat/rename/route.ts @@ -0,0 +1,65 @@ +import { db } from '@sim/db' +import { copilotChats } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { getSession } from '@/lib/auth' +import { getAccessibleCopilotChat } from '@/lib/copilot/chat/lifecycle' +import { taskPubSub } from '@/lib/copilot/tasks' + +const logger = createLogger('RenameChatAPI') + +const RenameChatSchema = z.object({ + chatId: z.string().min(1), + title: z.string().min(1).max(200), +}) + +export async function PATCH(request: NextRequest) { + try { + const session = await getSession() + if (!session?.user?.id) { + return NextResponse.json({ success: false, error: 'Unauthorized' }, { status: 401 }) + } + + const body = await request.json() + const { chatId, title } = RenameChatSchema.parse(body) + + const chat = await getAccessibleCopilotChat(chatId, session.user.id) + if (!chat) { + return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 }) + } + + const now = new Date() + const [updated] = await db + .update(copilotChats) + .set({ title, updatedAt: now, lastSeenAt: now }) + .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, session.user.id))) + .returning({ id: copilotChats.id, workspaceId: copilotChats.workspaceId }) + + if (!updated) { + return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 }) + } + + logger.info('Chat renamed', { chatId, title }) + + if (updated.workspaceId) { + taskPubSub?.publishStatusChanged({ + workspaceId: updated.workspaceId, + chatId, + type: 'renamed', + }) + } + + return NextResponse.json({ success: true }) + } catch (error) { + if (error instanceof z.ZodError) { + return NextResponse.json( + { success: false, error: 'Invalid request data', details: error.errors }, + { status: 400 } + ) + } + logger.error('Error renaming chat:', error) + return NextResponse.json({ success: false, error: 'Failed to rename chat' }, { status: 500 }) + } +} diff --git a/apps/sim/app/api/copilot/chat/resources/route.ts b/apps/sim/app/api/copilot/chat/resources/route.ts index 69d7bb204dc..219166cd63d 100644 --- a/apps/sim/app/api/copilot/chat/resources/route.ts +++ b/apps/sim/app/api/copilot/chat/resources/route.ts @@ -10,8 +10,8 @@ import { createInternalServerErrorResponse, createNotFoundResponse, createUnauthorizedResponse, -} from '@/lib/copilot/request-helpers' -import type { ChatResource, ResourceType } from '@/lib/copilot/resources' +} from '@/lib/copilot/request/http' +import type { ChatResource, ResourceType } from '@/lib/copilot/resources/persistence' const logger = createLogger('CopilotChatResourcesAPI') diff --git a/apps/sim/app/api/copilot/chat/route.ts b/apps/sim/app/api/copilot/chat/route.ts index c1938b5f06c..0e52273571f 100644 --- a/apps/sim/app/api/copilot/chat/route.ts +++ b/apps/sim/app/api/copilot/chat/route.ts @@ -1,45 +1,45 @@ import { db } from '@sim/db' import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' -import { and, desc, eq, sql } from 'drizzle-orm' +import { eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' -import { createRunSegment } from '@/lib/copilot/async-runs/repository' -import { getAccessibleCopilotChat, resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle' -import { buildCopilotRequestPayload } from '@/lib/copilot/chat-payload' +import { type ChatLoadResult, resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle' +import { buildCopilotRequestPayload } from '@/lib/copilot/chat/payload' import { - acquirePendingChatStream, - createSSEStream, - releasePendingChatStream, - requestChatTitle, - SSE_RESPONSE_HEADERS, -} from '@/lib/copilot/chat-streaming' -import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models' -import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator' -import { getStreamMeta, readStreamEvents } from '@/lib/copilot/orchestrator/stream/buffer' -import type { OrchestratorResult } from '@/lib/copilot/orchestrator/types' -import { resolveActiveResourceContext } from '@/lib/copilot/process-contents' + buildPersistedAssistantMessage, + buildPersistedUserMessage, +} from '@/lib/copilot/chat/persisted-message' +import { + processContextsServer, + resolveActiveResourceContext, +} from '@/lib/copilot/chat/process-contents' +import { COPILOT_REQUEST_MODES } from '@/lib/copilot/constants' import { - authenticateCopilotRequestSessionOnly, createBadRequestResponse, - createInternalServerErrorResponse, createRequestTracker, createUnauthorizedResponse, -} from '@/lib/copilot/request-helpers' +} from '@/lib/copilot/request/http' +import { createSSEStream, SSE_RESPONSE_HEADERS } from '@/lib/copilot/request/lifecycle/start' import { - authorizeWorkflowByWorkspacePermission, - resolveWorkflowIdForUser, -} from '@/lib/workflows/utils' -import { - assertActiveWorkspaceAccess, - getUserEntityPermissions, -} from '@/lib/workspaces/permissions/utils' + acquirePendingChatStream, + getPendingChatStreamId, + releasePendingChatStream, +} from '@/lib/copilot/request/session' +import type { OrchestratorResult } from '@/lib/copilot/request/types' +import { getWorkflowById, resolveWorkflowIdForUser } from '@/lib/workflows/utils' +import { getUserEntityPermissions } from '@/lib/workspaces/permissions/utils' +import type { ChatContext } from '@/stores/panel' export const maxDuration = 3600 const logger = createLogger('CopilotChatAPI') +// --------------------------------------------------------------------------- +// Schemas +// --------------------------------------------------------------------------- + const FileAttachmentSchema = z.object({ id: z.string(), key: z.string(), @@ -66,7 +66,6 @@ const ChatMessageSchema = z.object({ mode: z.enum(COPILOT_REQUEST_MODES).optional().default('agent'), prefetch: z.boolean().optional(), createNewChat: z.boolean().optional().default(false), - stream: z.boolean().optional().default(true), implicitFeedback: z.string().optional(), fileAttachments: z.array(FileAttachmentSchema).optional(), resourceAttachments: z.array(ResourceAttachmentSchema).optional(), @@ -104,27 +103,25 @@ const ChatMessageSchema = z.object({ userTimezone: z.string().optional(), }) -/** - * POST /api/copilot/chat - * Send messages to sim agent and handle chat persistence - */ +// --------------------------------------------------------------------------- +// POST /api/copilot/chat +// --------------------------------------------------------------------------- + export async function POST(req: NextRequest) { const tracker = createRequestTracker() let actualChatId: string | undefined - let pendingChatStreamAcquired = false - let pendingChatStreamHandedOff = false - let pendingChatStreamID: string | undefined + let chatStreamLockAcquired = false + let userMessageIdToUse = '' try { - // Get session to access user information including name + // 1. Auth const session = await getSession() - if (!session?.user?.id) { return createUnauthorizedResponse() } - const authenticatedUserId = session.user.id + // 2. Parse & validate const body = await req.json() const { message, @@ -137,7 +134,6 @@ export async function POST(req: NextRequest) { mode, prefetch, createNewChat, - stream, implicitFeedback, fileAttachments, resourceAttachments, @@ -151,17 +147,12 @@ export async function POST(req: NextRequest) { ? contexts.map((ctx) => { if (ctx.kind !== 'blocks') return ctx if (Array.isArray(ctx.blockIds) && ctx.blockIds.length > 0) return ctx - if (ctx.blockId) { - return { - ...ctx, - blockIds: [ctx.blockId], - } - } + if (ctx.blockId) return { ...ctx, blockIds: [ctx.blockId] } return ctx }) : contexts - // Copilot route always requires a workflow scope + // 3. Resolve workflow & workspace const resolved = await resolveWorkflowIdForUser( authenticatedUserId, providedWorkflowId, @@ -173,47 +164,28 @@ export async function POST(req: NextRequest) { 'No workflows found. Create a workflow first or provide a valid workflowId.' ) } - const workflowId = resolved.workflowId - const workflowResolvedName = resolved.workflowName + const { workflowId, workflowName: workflowResolvedName } = resolved - // Resolve workspace from workflow so it can be sent as implicit context to the copilot. let resolvedWorkspaceId: string | undefined try { - const { getWorkflowById } = await import('@/lib/workflows/utils') const wf = await getWorkflowById(workflowId) resolvedWorkspaceId = wf?.workspaceId ?? undefined } catch { - logger - .withMetadata({ requestId: tracker.requestId, messageId: userMessageId }) - .warn('Failed to resolve workspaceId from workflow') + logger.warn(`[${tracker.requestId}] Failed to resolve workspaceId from workflow`) } - const userMessageIdToUse = userMessageId || crypto.randomUUID() - const reqLogger = logger.withMetadata({ - requestId: tracker.requestId, - messageId: userMessageIdToUse, + userMessageIdToUse = userMessageId || crypto.randomUUID() + const selectedModel = model || 'claude-opus-4-6' + + logger.info(`[${tracker.requestId}] Received chat POST`, { + workflowId, + contextsCount: Array.isArray(normalizedContexts) ? normalizedContexts.length : 0, }) - try { - reqLogger.info('Received chat POST', { - workflowId, - hasContexts: Array.isArray(normalizedContexts), - contextsCount: Array.isArray(normalizedContexts) ? normalizedContexts.length : 0, - contextsPreview: Array.isArray(normalizedContexts) - ? normalizedContexts.map((c: any) => ({ - kind: c?.kind, - chatId: c?.chatId, - workflowId: c?.workflowId, - executionId: (c as any)?.executionId, - label: c?.label, - })) - : undefined, - }) - } catch {} - let currentChat: any = null - let conversationHistory: any[] = [] + // 4. Resolve or create chat + let currentChat: ChatLoadResult['chat'] = null + let conversationHistory: unknown[] = [] actualChatId = chatId - const selectedModel = model || 'claude-opus-4-6' if (chatId || createNewChat) { const chatResult = await resolveOrCreateChat({ @@ -233,37 +205,48 @@ export async function POST(req: NextRequest) { } } + if (actualChatId) { + chatStreamLockAcquired = await acquirePendingChatStream(actualChatId, userMessageIdToUse) + if (!chatStreamLockAcquired) { + const activeStreamId = await getPendingChatStreamId(actualChatId) + return NextResponse.json( + { + error: 'A response is already in progress for this chat.', + ...(activeStreamId ? { activeStreamId } : {}), + }, + { status: 409 } + ) + } + } + + // 5. Process contexts let agentContexts: Array<{ type: string; content: string }> = [] + if (Array.isArray(normalizedContexts) && normalizedContexts.length > 0) { try { - const { processContextsServer } = await import('@/lib/copilot/process-contents') const processed = await processContextsServer( - normalizedContexts as any, + normalizedContexts as ChatContext[], authenticatedUserId, message, resolvedWorkspaceId, actualChatId ) agentContexts = processed - reqLogger.info('Contexts processed for request', { + logger.info(`[${tracker.requestId}] Contexts processed`, { processedCount: agentContexts.length, kinds: agentContexts.map((c) => c.type), - lengthPreview: agentContexts.map((c) => c.content?.length ?? 0), }) - if ( - Array.isArray(normalizedContexts) && - normalizedContexts.length > 0 && - agentContexts.length === 0 - ) { - reqLogger.warn( - 'Contexts provided but none processed. Check executionId for logs contexts.' + if (agentContexts.length === 0) { + logger.warn( + `[${tracker.requestId}] Contexts provided but none processed. Check executionId for logs contexts.` ) } } catch (e) { - reqLogger.error('Failed to process contexts', e) + logger.error(`[${tracker.requestId}] Failed to process contexts`, e) } } + // 5b. Process resource attachments if ( Array.isArray(resourceAttachments) && resourceAttachments.length > 0 && @@ -279,26 +262,30 @@ export async function POST(req: NextRequest) { actualChatId ) if (!ctx) return null - return { - ...ctx, - tag: r.active ? '@active_tab' : '@open_tab', - } + return { ...ctx, tag: r.active ? '@active_tab' : '@open_tab' } }) ) for (const result of results) { if (result.status === 'fulfilled' && result.value) { agentContexts.push(result.value) } else if (result.status === 'rejected') { - reqLogger.error('Failed to resolve resource attachment', result.reason) + logger.error( + `[${tracker.requestId}] Failed to resolve resource attachment`, + result.reason + ) } } } - const effectiveMode = mode === 'agent' ? 'build' : mode - + // 6. Build copilot request payload const userPermission = resolvedWorkspaceId ? await getUserEntityPermissions(authenticatedUserId, 'workspace', resolvedWorkspaceId).catch( - () => null + (err) => { + logger.warn('Failed to load user permissions', { + error: err instanceof Error ? err.message : String(err), + }) + return null + } ) : null @@ -322,55 +309,24 @@ export async function POST(req: NextRequest) { userPermission: userPermission ?? undefined, userTimezone, }, - { - selectedModel, - } + { selectedModel } ) - try { - reqLogger.info('About to call Sim Agent', { - hasContext: agentContexts.length > 0, - contextCount: agentContexts.length, - hasFileAttachments: Array.isArray(requestPayload.fileAttachments), - messageLength: message.length, - mode: effectiveMode, - hasTools: Array.isArray(requestPayload.tools), - toolCount: Array.isArray(requestPayload.tools) ? requestPayload.tools.length : 0, - hasBaseTools: Array.isArray(requestPayload.baseTools), - baseToolCount: Array.isArray(requestPayload.baseTools) - ? requestPayload.baseTools.length - : 0, - hasCredentials: !!requestPayload.credentials, - }) - } catch {} - - if (stream && actualChatId) { - const acquired = await acquirePendingChatStream(actualChatId, userMessageIdToUse) - if (!acquired) { - return NextResponse.json( - { - error: - 'A response is already in progress for this chat. Wait for it to finish or use Stop.', - }, - { status: 409 } - ) - } - pendingChatStreamAcquired = true - pendingChatStreamID = userMessageIdToUse - } + logger.info(`[${tracker.requestId}] About to call Sim Agent`, { + contextCount: agentContexts.length, + hasFileAttachments: Array.isArray(requestPayload.fileAttachments), + messageLength: message.length, + mode, + }) + // 7. Persist user message if (actualChatId) { - const userMsg = { + const userMsg = buildPersistedUserMessage({ id: userMessageIdToUse, - role: 'user' as const, content: message, - timestamp: new Date().toISOString(), - ...(fileAttachments && fileAttachments.length > 0 && { fileAttachments }), - ...(Array.isArray(normalizedContexts) && - normalizedContexts.length > 0 && { - contexts: normalizedContexts, - }), - } + fileAttachments, + contexts: normalizedContexts, + }) const [updated] = await db .update(copilotChats) @@ -383,268 +339,66 @@ export async function POST(req: NextRequest) { .returning({ messages: copilotChats.messages }) if (updated) { - const freshMessages: any[] = Array.isArray(updated.messages) ? updated.messages : [] - conversationHistory = freshMessages.filter((m: any) => m.id !== userMessageIdToUse) + const freshMessages: Record[] = Array.isArray(updated.messages) + ? updated.messages + : [] + conversationHistory = freshMessages.filter( + (m: Record) => m.id !== userMessageIdToUse + ) } } - if (stream) { - const executionId = crypto.randomUUID() - const runId = crypto.randomUUID() - const sseStream = createSSEStream({ - requestPayload, - userId: authenticatedUserId, - streamId: userMessageIdToUse, - executionId, - runId, - chatId: actualChatId, - currentChat, - isNewChat: conversationHistory.length === 0, - message, - titleModel: selectedModel, - titleProvider: provider, - requestId: tracker.requestId, - workspaceId: resolvedWorkspaceId, - pendingChatStreamAlreadyRegistered: Boolean(actualChatId && stream), - orchestrateOptions: { - userId: authenticatedUserId, - workflowId, - chatId: actualChatId, - executionId, - runId, - goRoute: '/api/copilot', - autoExecuteTools: true, - interactive: true, - onComplete: async (result: OrchestratorResult) => { - if (!actualChatId) return - if (!result.success) return - - const assistantMessage: Record = { - id: crypto.randomUUID(), - role: 'assistant' as const, - content: result.content, - timestamp: new Date().toISOString(), - ...(result.requestId ? { requestId: result.requestId } : {}), - } - if (result.toolCalls.length > 0) { - assistantMessage.toolCalls = result.toolCalls - } - if (result.contentBlocks.length > 0) { - assistantMessage.contentBlocks = result.contentBlocks.map((block) => { - const stored: Record = { type: block.type } - if (block.content) stored.content = block.content - if (block.type === 'tool_call' && block.toolCall) { - const state = - block.toolCall.result?.success !== undefined - ? block.toolCall.result.success - ? 'success' - : 'error' - : block.toolCall.status - const isSubagentTool = !!block.calledBy - const isNonTerminal = - state === 'cancelled' || state === 'pending' || state === 'executing' - stored.toolCall = { - id: block.toolCall.id, - name: block.toolCall.name, - state, - ...(isSubagentTool && isNonTerminal ? {} : { result: block.toolCall.result }), - ...(isSubagentTool && isNonTerminal - ? {} - : block.toolCall.params - ? { params: block.toolCall.params } - : {}), - ...(block.calledBy ? { calledBy: block.calledBy } : {}), - } - } - return stored - }) - } - - try { - const [row] = await db - .select({ messages: copilotChats.messages }) - .from(copilotChats) - .where(eq(copilotChats.id, actualChatId)) - .limit(1) - - const msgs: any[] = Array.isArray(row?.messages) ? row.messages : [] - const userIdx = msgs.findIndex((m: any) => m.id === userMessageIdToUse) - const alreadyHasResponse = - userIdx >= 0 && - userIdx + 1 < msgs.length && - (msgs[userIdx + 1] as any)?.role === 'assistant' - - if (!alreadyHasResponse) { - await db - .update(copilotChats) - .set({ - messages: sql`${copilotChats.messages} || ${JSON.stringify([assistantMessage])}::jsonb`, - conversationId: sql`CASE WHEN ${copilotChats.conversationId} = ${userMessageIdToUse} THEN NULL ELSE ${copilotChats.conversationId} END`, - updatedAt: new Date(), - }) - .where(eq(copilotChats.id, actualChatId)) - } - } catch (error) { - reqLogger.error('Failed to persist chat messages', { - chatId: actualChatId, - error: error instanceof Error ? error.message : 'Unknown error', - }) - } - }, - }, - }) - pendingChatStreamHandedOff = true - - return new Response(sseStream, { headers: SSE_RESPONSE_HEADERS }) - } + // 8. Create SSE stream with onComplete for assistant message persistence + const executionId = crypto.randomUUID() + const runId = crypto.randomUUID() - const nsExecutionId = crypto.randomUUID() - const nsRunId = crypto.randomUUID() - - if (actualChatId) { - await createRunSegment({ - id: nsRunId, - executionId: nsExecutionId, - chatId: actualChatId, - userId: authenticatedUserId, - workflowId, - streamId: userMessageIdToUse, - }).catch(() => {}) - } - - const nonStreamingResult = await orchestrateCopilotStream(requestPayload, { + const sseStream = createSSEStream({ + requestPayload, userId: authenticatedUserId, - workflowId, + streamId: userMessageIdToUse, + executionId, + runId, chatId: actualChatId, - executionId: nsExecutionId, - runId: nsRunId, - goRoute: '/api/copilot', - autoExecuteTools: true, - interactive: true, - }) - - const responseData = { - content: nonStreamingResult.content, - toolCalls: nonStreamingResult.toolCalls, - model: selectedModel, - provider: typeof requestPayload?.provider === 'string' ? requestPayload.provider : undefined, - } - - reqLogger.info('Non-streaming response from orchestrator', { - hasContent: !!responseData.content, - contentLength: responseData.content?.length || 0, - model: responseData.model, - provider: responseData.provider, - toolCallsCount: responseData.toolCalls?.length || 0, - }) - - // Save messages if we have a chat - if (currentChat && responseData.content) { - const userMessage = { - id: userMessageIdToUse, // Consistent ID used for request and persistence - role: 'user', - content: message, - timestamp: new Date().toISOString(), - ...(fileAttachments && fileAttachments.length > 0 && { fileAttachments }), - ...(Array.isArray(normalizedContexts) && - normalizedContexts.length > 0 && { - contexts: normalizedContexts, - }), - ...(Array.isArray(normalizedContexts) && - normalizedContexts.length > 0 && { - contentBlocks: [ - { type: 'contexts', contexts: normalizedContexts as any, timestamp: Date.now() }, - ], - }), - } - - const assistantMessage = { - id: crypto.randomUUID(), - role: 'assistant', - content: responseData.content, - timestamp: new Date().toISOString(), - } - - const updatedMessages = [...conversationHistory, userMessage, assistantMessage] - - // Start title generation in parallel if this is first message (non-streaming) - if (actualChatId && !currentChat.title && conversationHistory.length === 0) { - reqLogger.info('Starting title generation for non-streaming response') - requestChatTitle({ message, model: selectedModel, provider, messageId: userMessageIdToUse }) - .then(async (title) => { - if (title) { - await db - .update(copilotChats) - .set({ - title, - updatedAt: new Date(), - }) - .where(eq(copilotChats.id, actualChatId!)) - reqLogger.info(`Generated and saved title: ${title}`) - } - }) - .catch((error) => { - reqLogger.error('Title generation failed', error) - }) - } - - // Update chat in database immediately (without blocking for title) - await db - .update(copilotChats) - .set({ - messages: updatedMessages, - updatedAt: new Date(), - }) - .where(eq(copilotChats.id, actualChatId!)) - } - - reqLogger.info('Returning non-streaming response', { - duration: tracker.getDuration(), - chatId: actualChatId, - responseLength: responseData.content?.length || 0, - }) - - return NextResponse.json({ - success: true, - response: responseData, - chatId: actualChatId, - metadata: { - requestId: tracker.requestId, - message, - duration: tracker.getDuration(), + currentChat, + isNewChat: conversationHistory.length === 0, + message, + titleModel: selectedModel, + titleProvider: provider, + requestId: tracker.requestId, + workspaceId: resolvedWorkspaceId, + orchestrateOptions: { + userId: authenticatedUserId, + workflowId, + chatId: actualChatId, + executionId, + runId, + goRoute: '/api/copilot', + autoExecuteTools: true, + interactive: true, + onComplete: buildOnComplete(actualChatId, userMessageIdToUse, tracker.requestId), }, }) + + return new Response(sseStream, { headers: SSE_RESPONSE_HEADERS }) } catch (error) { - if ( - actualChatId && - pendingChatStreamAcquired && - !pendingChatStreamHandedOff && - pendingChatStreamID - ) { - await releasePendingChatStream(actualChatId, pendingChatStreamID).catch(() => {}) + if (chatStreamLockAcquired && actualChatId && userMessageIdToUse) { + await releasePendingChatStream(actualChatId, userMessageIdToUse) } const duration = tracker.getDuration() if (error instanceof z.ZodError) { - logger - .withMetadata({ requestId: tracker.requestId, messageId: pendingChatStreamID ?? undefined }) - .error('Validation error', { - duration, - errors: error.errors, - }) + logger.error(`[${tracker.requestId}] Validation error:`, { duration, errors: error.errors }) return NextResponse.json( { error: 'Invalid request data', details: error.errors }, { status: 400 } ) } - logger - .withMetadata({ requestId: tracker.requestId, messageId: pendingChatStreamID ?? undefined }) - .error('Error handling copilot chat', { - duration, - error: error instanceof Error ? error.message : 'Unknown error', - stack: error instanceof Error ? error.stack : undefined, - }) + logger.error(`[${tracker.requestId}] Error handling copilot chat:`, { + duration, + error: error instanceof Error ? error.message : 'Unknown error', + stack: error instanceof Error ? error.stack : undefined, + }) return NextResponse.json( { error: error instanceof Error ? error.message : 'Internal server error' }, @@ -653,132 +407,55 @@ export async function POST(req: NextRequest) { } } -export async function GET(req: NextRequest) { - try { - const { searchParams } = new URL(req.url) - const workflowId = searchParams.get('workflowId') - const workspaceId = searchParams.get('workspaceId') - const chatId = searchParams.get('chatId') - - const { userId: authenticatedUserId, isAuthenticated } = - await authenticateCopilotRequestSessionOnly() - if (!isAuthenticated || !authenticatedUserId) { - return createUnauthorizedResponse() - } - - if (chatId) { - const chat = await getAccessibleCopilotChat(chatId, authenticatedUserId) +// --------------------------------------------------------------------------- +// onComplete: persist assistant message after streaming finishes +// --------------------------------------------------------------------------- - if (!chat) { - return NextResponse.json({ success: false, error: 'Chat not found' }, { status: 404 }) - } +function buildOnComplete( + chatId: string | undefined, + userMessageId: string, + requestId: string +): (result: OrchestratorResult) => Promise { + return async (result) => { + if (!chatId || !result.success) return - let streamSnapshot: { - events: Array<{ eventId: number; streamId: string; event: Record }> - status: string - } | null = null - - if (chat.conversationId) { - try { - const [meta, events] = await Promise.all([ - getStreamMeta(chat.conversationId), - readStreamEvents(chat.conversationId, 0), - ]) - streamSnapshot = { - events: events || [], - status: meta?.status || 'unknown', - } - } catch (err) { - logger - .withMetadata({ messageId: chat.conversationId || undefined }) - .warn('Failed to read stream snapshot for chat', { - chatId, - conversationId: chat.conversationId, - error: err instanceof Error ? err.message : String(err), - }) - } - } + const assistantMessage = buildPersistedAssistantMessage(result, result.requestId) - const transformedChat = { - id: chat.id, - title: chat.title, - model: chat.model, - messages: Array.isArray(chat.messages) ? chat.messages : [], - messageCount: Array.isArray(chat.messages) ? chat.messages.length : 0, - planArtifact: chat.planArtifact || null, - config: chat.config || null, - conversationId: chat.conversationId || null, - resources: Array.isArray(chat.resources) ? chat.resources : [], - createdAt: chat.createdAt, - updatedAt: chat.updatedAt, - ...(streamSnapshot ? { streamSnapshot } : {}), + try { + const [row] = await db + .select({ messages: copilotChats.messages }) + .from(copilotChats) + .where(eq(copilotChats.id, chatId)) + .limit(1) + + const msgs: Record[] = Array.isArray(row?.messages) ? row.messages : [] + const userIdx = msgs.findIndex((m: Record) => m.id === userMessageId) + const alreadyHasResponse = + userIdx >= 0 && + userIdx + 1 < msgs.length && + (msgs[userIdx + 1] as Record)?.role === 'assistant' + + if (!alreadyHasResponse) { + await db + .update(copilotChats) + .set({ + messages: sql`${copilotChats.messages} || ${JSON.stringify([assistantMessage])}::jsonb`, + conversationId: sql`CASE WHEN ${copilotChats.conversationId} = ${userMessageId} THEN NULL ELSE ${copilotChats.conversationId} END`, + updatedAt: new Date(), + }) + .where(eq(copilotChats.id, chatId)) } - - logger - .withMetadata({ messageId: chat.conversationId || undefined }) - .info(`Retrieved chat ${chatId}`) - return NextResponse.json({ success: true, chat: transformedChat }) - } - - if (!workflowId && !workspaceId) { - return createBadRequestResponse('workflowId, workspaceId, or chatId is required') - } - - if (workspaceId) { - await assertActiveWorkspaceAccess(workspaceId, authenticatedUserId) - } - - if (workflowId) { - const authorization = await authorizeWorkflowByWorkspacePermission({ - workflowId, - userId: authenticatedUserId, - action: 'read', + } catch (error) { + logger.error(`[${requestId}] Failed to persist chat messages`, { + chatId, + error: error instanceof Error ? error.message : 'Unknown error', }) - if (!authorization.allowed) { - return createUnauthorizedResponse() - } } - - const scopeFilter = workflowId - ? eq(copilotChats.workflowId, workflowId) - : eq(copilotChats.workspaceId, workspaceId!) - - const chats = await db - .select({ - id: copilotChats.id, - title: copilotChats.title, - model: copilotChats.model, - messages: copilotChats.messages, - planArtifact: copilotChats.planArtifact, - config: copilotChats.config, - createdAt: copilotChats.createdAt, - updatedAt: copilotChats.updatedAt, - }) - .from(copilotChats) - .where(and(eq(copilotChats.userId, authenticatedUserId), scopeFilter)) - .orderBy(desc(copilotChats.updatedAt)) - - const transformedChats = chats.map((chat) => ({ - id: chat.id, - title: chat.title, - model: chat.model, - messages: Array.isArray(chat.messages) ? chat.messages : [], - messageCount: Array.isArray(chat.messages) ? chat.messages.length : 0, - planArtifact: chat.planArtifact || null, - config: chat.config || null, - createdAt: chat.createdAt, - updatedAt: chat.updatedAt, - })) - - const scope = workflowId ? `workflow ${workflowId}` : `workspace ${workspaceId}` - logger.info(`Retrieved ${transformedChats.length} chats for ${scope}`) - - return NextResponse.json({ - success: true, - chats: transformedChats, - }) - } catch (error) { - logger.error('Error fetching copilot chats', error) - return createInternalServerErrorResponse('Failed to fetch chats') } } + +// --------------------------------------------------------------------------- +// GET handler (read-only queries, extracted to queries.ts) +// --------------------------------------------------------------------------- + +export { GET } from './queries' diff --git a/apps/sim/app/api/copilot/chat/stream/route.test.ts b/apps/sim/app/api/copilot/chat/stream/route.test.ts index 993f10501a8..ff5115e637a 100644 --- a/apps/sim/app/api/copilot/chat/stream/route.test.ts +++ b/apps/sim/app/api/copilot/chat/stream/route.test.ts @@ -4,25 +4,67 @@ import { NextRequest } from 'next/server' import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + MothershipStreamV1CompletionStatus, + MothershipStreamV1EventType, +} from '@/lib/copilot/generated/mothership-stream-v1' -const { getStreamMeta, readStreamEvents, authenticateCopilotRequestSessionOnly } = vi.hoisted( - () => ({ - getStreamMeta: vi.fn(), - readStreamEvents: vi.fn(), - authenticateCopilotRequestSessionOnly: vi.fn(), - }) -) +const { + getLatestRunForStream, + readEvents, + checkForReplayGap, + authenticateCopilotRequestSessionOnly, +} = vi.hoisted(() => ({ + getLatestRunForStream: vi.fn(), + readEvents: vi.fn(), + checkForReplayGap: vi.fn(), + authenticateCopilotRequestSessionOnly: vi.fn(), +})) + +vi.mock('@/lib/copilot/async-runs/repository', () => ({ + getLatestRunForStream, +})) -vi.mock('@/lib/copilot/orchestrator/stream/buffer', () => ({ - getStreamMeta, - readStreamEvents, +vi.mock('@/lib/copilot/request/session', () => ({ + readEvents, + checkForReplayGap, + createEvent: (event: Record) => ({ + stream: { + streamId: event.streamId, + cursor: event.cursor, + }, + seq: event.seq, + trace: { requestId: event.requestId ?? '' }, + type: event.type, + payload: event.payload, + }), + encodeSSEEnvelope: (event: Record) => + new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`), + SSE_RESPONSE_HEADERS: { + 'Content-Type': 'text/event-stream', + }, })) -vi.mock('@/lib/copilot/request-helpers', () => ({ +vi.mock('@/lib/copilot/request/http', () => ({ authenticateCopilotRequestSessionOnly, })) -import { GET } from '@/app/api/copilot/chat/stream/route' +import { GET } from './route' + +async function readAllChunks(response: Response): Promise { + const reader = response.body?.getReader() + expect(reader).toBeTruthy() + + const chunks: string[] = [] + while (true) { + const { done, value } = await reader!.read() + if (done) { + break + } + chunks.push(new TextDecoder().decode(value)) + } + return chunks +} describe('copilot chat stream replay route', () => { beforeEach(() => { @@ -31,29 +73,54 @@ describe('copilot chat stream replay route', () => { userId: 'user-1', isAuthenticated: true, }) - readStreamEvents.mockResolvedValue([]) + readEvents.mockResolvedValue([]) + checkForReplayGap.mockResolvedValue(null) }) - it('stops replay polling when stream meta becomes cancelled', async () => { - getStreamMeta + it('stops replay polling when run becomes cancelled', async () => { + getLatestRunForStream .mockResolvedValueOnce({ status: 'active', - userId: 'user-1', + executionId: 'exec-1', + id: 'run-1', }) .mockResolvedValueOnce({ status: 'cancelled', - userId: 'user-1', + executionId: 'exec-1', + id: 'run-1', }) const response = await GET( - new NextRequest('http://localhost:3000/api/copilot/chat/stream?streamId=stream-1') + new NextRequest('http://localhost:3000/api/copilot/chat/stream?streamId=stream-1&after=0') ) - const reader = response.body?.getReader() - expect(reader).toBeTruthy() + const chunks = await readAllChunks(response) + expect(chunks.join('')).toContain( + JSON.stringify({ + status: MothershipStreamV1CompletionStatus.cancelled, + reason: 'terminal_status', + }) + ) + expect(getLatestRunForStream).toHaveBeenCalledTimes(2) + }) + + it('emits structured terminal replay error when run metadata disappears', async () => { + getLatestRunForStream + .mockResolvedValueOnce({ + status: 'active', + executionId: 'exec-1', + id: 'run-1', + }) + .mockResolvedValueOnce(null) + + const response = await GET( + new NextRequest('http://localhost:3000/api/copilot/chat/stream?streamId=stream-1&after=0') + ) - const first = await reader!.read() - expect(first.done).toBe(true) - expect(getStreamMeta).toHaveBeenCalledTimes(2) + const chunks = await readAllChunks(response) + const body = chunks.join('') + expect(body).toContain(`"type":"${MothershipStreamV1EventType.error}"`) + expect(body).toContain('"code":"resume_run_unavailable"') + expect(body).toContain(`"type":"${MothershipStreamV1EventType.complete}"`) }) }) diff --git a/apps/sim/app/api/copilot/chat/stream/route.ts b/apps/sim/app/api/copilot/chat/stream/route.ts index b56d9471817..b051cf4e914 100644 --- a/apps/sim/app/api/copilot/chat/stream/route.ts +++ b/apps/sim/app/api/copilot/chat/stream/route.ts @@ -1,12 +1,18 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' +import { getLatestRunForStream } from '@/lib/copilot/async-runs/repository' import { - getStreamMeta, - readStreamEvents, - type StreamMeta, -} from '@/lib/copilot/orchestrator/stream/buffer' -import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request-helpers' -import { SSE_HEADERS } from '@/lib/core/utils/sse' + MothershipStreamV1CompletionStatus, + MothershipStreamV1EventType, +} from '@/lib/copilot/generated/mothership-stream-v1' +import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request/http' +import { + checkForReplayGap, + createEvent, + encodeSSEEnvelope, + readEvents, + SSE_RESPONSE_HEADERS, +} from '@/lib/copilot/request/session' export const maxDuration = 3600 @@ -14,8 +20,59 @@ const logger = createLogger('CopilotChatStreamAPI') const POLL_INTERVAL_MS = 250 const MAX_STREAM_MS = 60 * 60 * 1000 -function encodeEvent(event: Record): Uint8Array { - return new TextEncoder().encode(`data: ${JSON.stringify(event)}\n\n`) +function isTerminalStatus( + status: string | null | undefined +): status is MothershipStreamV1CompletionStatus { + return ( + status === MothershipStreamV1CompletionStatus.complete || + status === MothershipStreamV1CompletionStatus.error || + status === MothershipStreamV1CompletionStatus.cancelled + ) +} + +function buildResumeTerminalEnvelopes(options: { + streamId: string + afterCursor: string + status: MothershipStreamV1CompletionStatus + message?: string + code: string + reason?: string +}) { + const baseSeq = Number(options.afterCursor || '0') + const seq = Number.isFinite(baseSeq) ? baseSeq : 0 + const envelopes: ReturnType[] = [] + + if (options.status === MothershipStreamV1CompletionStatus.error) { + envelopes.push( + createEvent({ + streamId: options.streamId, + cursor: String(seq + 1), + seq: seq + 1, + requestId: '', + type: MothershipStreamV1EventType.error, + payload: { + message: options.message || 'Stream recovery failed before completion.', + code: options.code, + }, + }) + ) + } + + envelopes.push( + createEvent({ + streamId: options.streamId, + cursor: String(seq + envelopes.length + 1), + seq: seq + envelopes.length + 1, + requestId: '', + type: MothershipStreamV1EventType.complete, + payload: { + status: options.status, + ...(options.reason ? { reason: options.reason } : {}), + }, + }) + ) + + return envelopes } export async function GET(request: NextRequest) { @@ -28,68 +85,36 @@ export async function GET(request: NextRequest) { const url = new URL(request.url) const streamId = url.searchParams.get('streamId') || '' - const fromParam = url.searchParams.get('from') || '0' - const fromEventId = Number(fromParam || 0) - // If batch=true, return buffered events as JSON instead of SSE - const batchMode = url.searchParams.get('batch') === 'true' - const toParam = url.searchParams.get('to') - const toEventId = toParam ? Number(toParam) : undefined - - const reqLogger = logger.withMetadata({ messageId: streamId || undefined }) - - reqLogger.info('[Resume] Received resume request', { - streamId: streamId || undefined, - fromEventId, - toEventId, - batchMode, - }) + const afterCursor = url.searchParams.get('after') || '' if (!streamId) { return NextResponse.json({ error: 'streamId is required' }, { status: 400 }) } - const meta = (await getStreamMeta(streamId)) as StreamMeta | null - reqLogger.info('[Resume] Stream lookup', { + const run = await getLatestRunForStream(streamId, authenticatedUserId).catch((err) => { + logger.warn('Failed to fetch latest run for stream', { + streamId, + error: err instanceof Error ? err.message : String(err), + }) + return null + }) + logger.info('[Resume] Stream lookup', { streamId, - fromEventId, - toEventId, - batchMode, - hasMeta: !!meta, - metaStatus: meta?.status, + afterCursor, + hasRun: !!run, + runStatus: run?.status, }) - if (!meta) { + if (!run) { return NextResponse.json({ error: 'Stream not found' }, { status: 404 }) } - if (meta.userId && meta.userId !== authenticatedUserId) { - return NextResponse.json({ error: 'Unauthorized' }, { status: 403 }) - } - - // Batch mode: return all buffered events as JSON - if (batchMode) { - const events = await readStreamEvents(streamId, fromEventId) - const filteredEvents = toEventId ? events.filter((e) => e.eventId <= toEventId) : events - reqLogger.info('[Resume] Batch response', { - streamId, - fromEventId, - toEventId, - eventCount: filteredEvents.length, - }) - return NextResponse.json({ - success: true, - events: filteredEvents, - status: meta.status, - executionId: meta.executionId, - runId: meta.runId, - }) - } const startTime = Date.now() const stream = new ReadableStream({ async start(controller) { - let lastEventId = Number.isFinite(fromEventId) ? fromEventId : 0 - let latestMeta = meta + let cursor = afterCursor || '0' let controllerClosed = false + let sawTerminalEvent = false const closeController = () => { if (controllerClosed) return @@ -97,14 +122,14 @@ export async function GET(request: NextRequest) { try { controller.close() } catch { - // Controller already closed by runtime/client - treat as normal. + // Controller already closed by runtime/client } } - const enqueueEvent = (payload: Record) => { + const enqueueEvent = (payload: unknown) => { if (controllerClosed) return false try { - controller.enqueue(encodeEvent(payload)) + controller.enqueue(encodeSSEEnvelope(payload)) return true } catch { controllerClosed = true @@ -118,47 +143,96 @@ export async function GET(request: NextRequest) { request.signal.addEventListener('abort', abortListener, { once: true }) const flushEvents = async () => { - const events = await readStreamEvents(streamId, lastEventId) + const events = await readEvents(streamId, cursor) if (events.length > 0) { - reqLogger.info('[Resume] Flushing events', { + logger.info('[Resume] Flushing events', { streamId, - fromEventId: lastEventId, + afterCursor: cursor, eventCount: events.length, }) } - for (const entry of events) { - lastEventId = entry.eventId - const payload = { - ...entry.event, - eventId: entry.eventId, - streamId: entry.streamId, - executionId: latestMeta?.executionId, - runId: latestMeta?.runId, + for (const envelope of events) { + cursor = envelope.stream.cursor ?? String(envelope.seq) + if (envelope.type === MothershipStreamV1EventType.complete) { + sawTerminalEvent = true + } + if (!enqueueEvent(envelope)) { + break + } + } + } + + const emitTerminalIfMissing = ( + status: MothershipStreamV1CompletionStatus, + options?: { message?: string; code: string; reason?: string } + ) => { + if (controllerClosed || sawTerminalEvent) { + return + } + for (const envelope of buildResumeTerminalEnvelopes({ + streamId, + afterCursor: cursor, + status, + message: options?.message, + code: options?.code ?? 'resume_terminal', + reason: options?.reason, + })) { + cursor = envelope.stream.cursor ?? String(envelope.seq) + if (envelope.type === MothershipStreamV1EventType.complete) { + sawTerminalEvent = true } - if (!enqueueEvent(payload)) { + if (!enqueueEvent(envelope)) { break } } } try { + const gap = await checkForReplayGap(streamId, afterCursor) + if (gap) { + for (const envelope of gap.envelopes) { + enqueueEvent(envelope) + } + return + } + await flushEvents() while (!controllerClosed && Date.now() - startTime < MAX_STREAM_MS) { - const currentMeta = await getStreamMeta(streamId) - if (!currentMeta) break - latestMeta = currentMeta + const currentRun = await getLatestRunForStream(streamId, authenticatedUserId).catch( + (err) => { + logger.warn('Failed to poll latest run for stream', { + streamId, + error: err instanceof Error ? err.message : String(err), + }) + return null + } + ) + if (!currentRun) { + emitTerminalIfMissing(MothershipStreamV1CompletionStatus.error, { + message: 'The stream could not be recovered because its run metadata is unavailable.', + code: 'resume_run_unavailable', + reason: 'run_unavailable', + }) + break + } await flushEvents() if (controllerClosed) { break } - if ( - currentMeta.status === 'complete' || - currentMeta.status === 'error' || - currentMeta.status === 'cancelled' - ) { + if (isTerminalStatus(currentRun.status)) { + emitTerminalIfMissing(currentRun.status, { + message: + currentRun.status === MothershipStreamV1CompletionStatus.error + ? typeof currentRun.error === 'string' + ? currentRun.error + : 'The recovered stream ended with an error.' + : undefined, + code: 'resume_terminal_status', + reason: 'terminal_status', + }) break } @@ -169,12 +243,24 @@ export async function GET(request: NextRequest) { await new Promise((resolve) => setTimeout(resolve, POLL_INTERVAL_MS)) } + if (!controllerClosed && Date.now() - startTime >= MAX_STREAM_MS) { + emitTerminalIfMissing(MothershipStreamV1CompletionStatus.error, { + message: 'The stream recovery timed out before completion.', + code: 'resume_timeout', + reason: 'timeout', + }) + } } catch (error) { if (!controllerClosed && !request.signal.aborted) { - reqLogger.warn('Stream replay failed', { + logger.warn('Stream replay failed', { streamId, error: error instanceof Error ? error.message : String(error), }) + emitTerminalIfMissing(MothershipStreamV1CompletionStatus.error, { + message: 'The stream replay failed before completion.', + code: 'resume_internal', + reason: 'stream_replay_failed', + }) } } finally { request.signal.removeEventListener('abort', abortListener) @@ -183,5 +269,5 @@ export async function GET(request: NextRequest) { }, }) - return new Response(stream, { headers: SSE_HEADERS }) + return new Response(stream, { headers: SSE_RESPONSE_HEADERS }) } diff --git a/apps/sim/app/api/copilot/chat/update-messages/route.test.ts b/apps/sim/app/api/copilot/chat/update-messages/route.test.ts index 0376005c283..512a05cfd84 100644 --- a/apps/sim/app/api/copilot/chat/update-messages/route.test.ts +++ b/apps/sim/app/api/copilot/chat/update-messages/route.test.ts @@ -327,7 +327,35 @@ describe('Copilot Chat Update Messages API Route', () => { }) expect(mockSet).toHaveBeenCalledWith({ - messages, + messages: [ + { + id: 'msg-1', + role: 'user', + content: 'Hello', + timestamp: '2024-01-01T10:00:00.000Z', + }, + { + id: 'msg-2', + role: 'assistant', + content: 'Hi there!', + timestamp: '2024-01-01T10:01:00.000Z', + contentBlocks: [ + { + type: 'text', + content: 'Here is the weather information', + }, + { + type: 'tool', + phase: 'call', + toolCall: { + id: 'tool-1', + name: 'get_weather', + state: 'pending', + }, + }, + ], + }, + ], updatedAt: expect.any(Date), }) }) diff --git a/apps/sim/app/api/copilot/chat/update-messages/route.ts b/apps/sim/app/api/copilot/chat/update-messages/route.ts index 574c2241ede..ee2dfee0bb7 100644 --- a/apps/sim/app/api/copilot/chat/update-messages/route.ts +++ b/apps/sim/app/api/copilot/chat/update-messages/route.ts @@ -4,15 +4,16 @@ import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { getAccessibleCopilotChat } from '@/lib/copilot/chat-lifecycle' -import { COPILOT_MODES } from '@/lib/copilot/models' +import { getAccessibleCopilotChat } from '@/lib/copilot/chat/lifecycle' +import { normalizeMessage, type PersistedMessage } from '@/lib/copilot/chat/persisted-message' +import { COPILOT_MODES } from '@/lib/copilot/constants' import { authenticateCopilotRequestSessionOnly, createInternalServerErrorResponse, createNotFoundResponse, createRequestTracker, createUnauthorizedResponse, -} from '@/lib/copilot/request-helpers' +} from '@/lib/copilot/request/http' const logger = createLogger('CopilotChatUpdateAPI') @@ -78,12 +79,15 @@ export async function POST(req: NextRequest) { } const { chatId, messages, planArtifact, config } = UpdateMessagesSchema.parse(body) + const normalizedMessages: PersistedMessage[] = messages.map((message) => + normalizeMessage(message as Record) + ) // Debug: Log what we're about to save - const lastMsgParsed = messages[messages.length - 1] + const lastMsgParsed = normalizedMessages[normalizedMessages.length - 1] if (lastMsgParsed?.role === 'assistant') { logger.info(`[${tracker.requestId}] Parsed messages to save`, { - messageCount: messages.length, + messageCount: normalizedMessages.length, lastMsgId: lastMsgParsed.id, lastMsgContentLength: lastMsgParsed.content?.length || 0, lastMsgContentBlockCount: lastMsgParsed.contentBlocks?.length || 0, @@ -99,8 +103,8 @@ export async function POST(req: NextRequest) { } // Update chat with new messages, plan artifact, and config - const updateData: Record = { - messages: messages, + const updateData: Record = { + messages: normalizedMessages, updatedAt: new Date(), } @@ -116,14 +120,14 @@ export async function POST(req: NextRequest) { logger.info(`[${tracker.requestId}] Successfully updated chat`, { chatId, - newMessageCount: messages.length, + newMessageCount: normalizedMessages.length, hasPlanArtifact: !!planArtifact, hasConfig: !!config, }) return NextResponse.json({ success: true, - messageCount: messages.length, + messageCount: normalizedMessages.length, }) } catch (error) { logger.error(`[${tracker.requestId}] Error updating chat messages:`, error) diff --git a/apps/sim/app/api/copilot/chats/route.test.ts b/apps/sim/app/api/copilot/chats/route.test.ts index 32088fe093a..3dbbf2791f8 100644 --- a/apps/sim/app/api/copilot/chats/route.test.ts +++ b/apps/sim/app/api/copilot/chats/route.test.ts @@ -66,7 +66,7 @@ vi.mock('drizzle-orm', () => ({ sql: vi.fn(), })) -vi.mock('@/lib/copilot/request-helpers', () => ({ +vi.mock('@/lib/copilot/request/http', () => ({ authenticateCopilotRequestSessionOnly: mockAuthenticate, createUnauthorizedResponse: mockCreateUnauthorizedResponse, createInternalServerErrorResponse: mockCreateInternalServerErrorResponse, diff --git a/apps/sim/app/api/copilot/chats/route.ts b/apps/sim/app/api/copilot/chats/route.ts index 7010d84e92b..b0142c27f7b 100644 --- a/apps/sim/app/api/copilot/chats/route.ts +++ b/apps/sim/app/api/copilot/chats/route.ts @@ -4,14 +4,14 @@ import { createLogger } from '@sim/logger' import { and, desc, eq, isNull, or, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle' +import { resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle' import { authenticateCopilotRequestSessionOnly, createBadRequestResponse, createInternalServerErrorResponse, createUnauthorizedResponse, -} from '@/lib/copilot/request-helpers' -import { taskPubSub } from '@/lib/copilot/task-events' +} from '@/lib/copilot/request/http' +import { taskPubSub } from '@/lib/copilot/tasks' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -37,7 +37,7 @@ export async function GET(_request: NextRequest) { title: copilotChats.title, workflowId: copilotChats.workflowId, workspaceId: copilotChats.workspaceId, - conversationId: copilotChats.conversationId, + activeStreamId: copilotChats.conversationId, updatedAt: copilotChats.updatedAt, }) .from(copilotChats) diff --git a/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts b/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts index fe3246181d4..e128ed9ec44 100644 --- a/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts +++ b/apps/sim/app/api/copilot/checkpoints/revert/route.test.ts @@ -43,7 +43,7 @@ vi.mock('@/lib/workflows/utils', () => ({ authorizeWorkflowByWorkspacePermission: mockAuthorize, })) -vi.mock('@/lib/copilot/chat-lifecycle', () => ({ +vi.mock('@/lib/copilot/chat/lifecycle', () => ({ getAccessibleCopilotChat: mockGetAccessibleCopilotChat, })) diff --git a/apps/sim/app/api/copilot/checkpoints/revert/route.ts b/apps/sim/app/api/copilot/checkpoints/revert/route.ts index 2edf7d2dec7..88cd3d0c7ab 100644 --- a/apps/sim/app/api/copilot/checkpoints/revert/route.ts +++ b/apps/sim/app/api/copilot/checkpoints/revert/route.ts @@ -4,14 +4,14 @@ import { createLogger } from '@sim/logger' import { and, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { getAccessibleCopilotChat } from '@/lib/copilot/chat-lifecycle' +import { getAccessibleCopilotChat } from '@/lib/copilot/chat/lifecycle' import { authenticateCopilotRequestSessionOnly, createInternalServerErrorResponse, createNotFoundResponse, createRequestTracker, createUnauthorizedResponse, -} from '@/lib/copilot/request-helpers' +} from '@/lib/copilot/request/http' import { getInternalApiBaseUrl } from '@/lib/core/utils/urls' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' import { isUuidV4 } from '@/executor/constants' diff --git a/apps/sim/app/api/copilot/checkpoints/route.test.ts b/apps/sim/app/api/copilot/checkpoints/route.test.ts index eedf688af37..e1b3a1f4e81 100644 --- a/apps/sim/app/api/copilot/checkpoints/route.test.ts +++ b/apps/sim/app/api/copilot/checkpoints/route.test.ts @@ -62,7 +62,7 @@ vi.mock('drizzle-orm', () => ({ desc: vi.fn((field: unknown) => ({ field, type: 'desc' })), })) -vi.mock('@/lib/copilot/chat-lifecycle', () => ({ +vi.mock('@/lib/copilot/chat/lifecycle', () => ({ getAccessibleCopilotChat: mockGetAccessibleCopilotChat, })) diff --git a/apps/sim/app/api/copilot/checkpoints/route.ts b/apps/sim/app/api/copilot/checkpoints/route.ts index 58b4cde4bb2..c800e519542 100644 --- a/apps/sim/app/api/copilot/checkpoints/route.ts +++ b/apps/sim/app/api/copilot/checkpoints/route.ts @@ -4,14 +4,14 @@ import { createLogger } from '@sim/logger' import { and, desc, eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { getAccessibleCopilotChat } from '@/lib/copilot/chat-lifecycle' +import { getAccessibleCopilotChat } from '@/lib/copilot/chat/lifecycle' import { authenticateCopilotRequestSessionOnly, createBadRequestResponse, createInternalServerErrorResponse, createRequestTracker, createUnauthorizedResponse, -} from '@/lib/copilot/request-helpers' +} from '@/lib/copilot/request/http' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' const logger = createLogger('WorkflowCheckpointsAPI') diff --git a/apps/sim/app/api/copilot/confirm/route.test.ts b/apps/sim/app/api/copilot/confirm/route.test.ts index 8570d637646..0b40d981e84 100644 --- a/apps/sim/app/api/copilot/confirm/route.test.ts +++ b/apps/sim/app/api/copilot/confirm/route.test.ts @@ -38,7 +38,7 @@ const { publishToolConfirmation: vi.fn(), })) -vi.mock('@/lib/copilot/request-helpers', () => ({ +vi.mock('@/lib/copilot/request/http', () => ({ authenticateCopilotRequestSessionOnly, createBadRequestResponse, createInternalServerErrorResponse, @@ -54,7 +54,7 @@ vi.mock('@/lib/copilot/async-runs/repository', () => ({ completeAsyncToolCall, })) -vi.mock('@/lib/copilot/orchestrator/persistence', () => ({ +vi.mock('@/lib/copilot/persistence/tool-confirm', () => ({ publishToolConfirmation, })) diff --git a/apps/sim/app/api/copilot/confirm/route.ts b/apps/sim/app/api/copilot/confirm/route.ts index bf154487f05..7246381105a 100644 --- a/apps/sim/app/api/copilot/confirm/route.ts +++ b/apps/sim/app/api/copilot/confirm/route.ts @@ -1,13 +1,14 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' +import { ASYNC_TOOL_STATUS } from '@/lib/copilot/async-runs/lifecycle' import { completeAsyncToolCall, getAsyncToolCall, getRunSegment, upsertAsyncToolCall, } from '@/lib/copilot/async-runs/repository' -import { publishToolConfirmation } from '@/lib/copilot/orchestrator/persistence' +import { publishToolConfirmation } from '@/lib/copilot/persistence/tool-confirm' import { authenticateCopilotRequestSessionOnly, createBadRequestResponse, @@ -16,7 +17,7 @@ import { createRequestTracker, createUnauthorizedResponse, type NotificationStatus, -} from '@/lib/copilot/request-helpers' +} from '@/lib/copilot/request/http' const logger = createLogger('CopilotConfirmAPI') @@ -42,17 +43,17 @@ async function updateToolCallStatus( const toolCallId = existing.toolCallId const durableStatus = status === 'success' - ? 'completed' + ? ASYNC_TOOL_STATUS.completed : status === 'cancelled' - ? 'cancelled' + ? ASYNC_TOOL_STATUS.cancelled : status === 'error' || status === 'rejected' - ? 'failed' - : 'pending' + ? ASYNC_TOOL_STATUS.failed + : ASYNC_TOOL_STATUS.pending try { if ( - durableStatus === 'completed' || - durableStatus === 'failed' || - durableStatus === 'cancelled' + durableStatus === ASYNC_TOOL_STATUS.completed || + durableStatus === ASYNC_TOOL_STATUS.failed || + durableStatus === ASYNC_TOOL_STATUS.cancelled ) { await completeAsyncToolCall({ toolCallId, @@ -107,13 +108,25 @@ export async function POST(req: NextRequest) { const body = await req.json() const { toolCallId, status, message, data } = ConfirmationSchema.parse(body) - const existing = await getAsyncToolCall(toolCallId).catch(() => null) + const existing = await getAsyncToolCall(toolCallId).catch((err) => { + logger.warn('Failed to fetch async tool call', { + toolCallId, + error: err instanceof Error ? err.message : String(err), + }) + return null + }) if (!existing) { return createNotFoundResponse('Tool call not found') } - const run = await getRunSegment(existing.runId).catch(() => null) + const run = await getRunSegment(existing.runId).catch((err) => { + logger.warn('Failed to fetch run segment', { + runId: existing.runId, + error: err instanceof Error ? err.message : String(err), + }) + return null + }) if (!run) { return createNotFoundResponse('Tool call run not found') } diff --git a/apps/sim/app/api/copilot/credentials/route.ts b/apps/sim/app/api/copilot/credentials/route.ts index 2f764429d74..82d031c9e64 100644 --- a/apps/sim/app/api/copilot/credentials/route.ts +++ b/apps/sim/app/api/copilot/credentials/route.ts @@ -1,5 +1,5 @@ import { type NextRequest, NextResponse } from 'next/server' -import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request-helpers' +import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request/http' import { routeExecution } from '@/lib/copilot/tools/server/router' /** diff --git a/apps/sim/app/api/copilot/feedback/route.test.ts b/apps/sim/app/api/copilot/feedback/route.test.ts index f74aecf77a7..3f3a28598a6 100644 --- a/apps/sim/app/api/copilot/feedback/route.test.ts +++ b/apps/sim/app/api/copilot/feedback/route.test.ts @@ -57,7 +57,7 @@ vi.mock('drizzle-orm', () => ({ eq: vi.fn((field: unknown, value: unknown) => ({ field, value, type: 'eq' })), })) -vi.mock('@/lib/copilot/request-helpers', () => ({ +vi.mock('@/lib/copilot/request/http', () => ({ authenticateCopilotRequestSessionOnly: mockAuthenticate, createUnauthorizedResponse: mockCreateUnauthorizedResponse, createBadRequestResponse: mockCreateBadRequestResponse, diff --git a/apps/sim/app/api/copilot/feedback/route.ts b/apps/sim/app/api/copilot/feedback/route.ts index 4786d1d7d86..a9eacaf02f0 100644 --- a/apps/sim/app/api/copilot/feedback/route.ts +++ b/apps/sim/app/api/copilot/feedback/route.ts @@ -10,7 +10,7 @@ import { createInternalServerErrorResponse, createRequestTracker, createUnauthorizedResponse, -} from '@/lib/copilot/request-helpers' +} from '@/lib/copilot/request/http' const logger = createLogger('CopilotFeedbackAPI') diff --git a/apps/sim/app/api/copilot/models/route.ts b/apps/sim/app/api/copilot/models/route.ts index d1773797453..7e23e38df69 100644 --- a/apps/sim/app/api/copilot/models/route.ts +++ b/apps/sim/app/api/copilot/models/route.ts @@ -1,8 +1,14 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { SIM_AGENT_API_URL } from '@/lib/copilot/constants' -import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request-helpers' -import type { AvailableModel } from '@/lib/copilot/types' +import { authenticateCopilotRequestSessionOnly } from '@/lib/copilot/request/http' + +interface AvailableModel { + id: string + friendlyName: string + provider: string +} + import { env } from '@/lib/core/config/env' const logger = createLogger('CopilotModelsAPI') diff --git a/apps/sim/app/api/copilot/stats/route.test.ts b/apps/sim/app/api/copilot/stats/route.test.ts index 176a97eb371..ca6e97704f0 100644 --- a/apps/sim/app/api/copilot/stats/route.test.ts +++ b/apps/sim/app/api/copilot/stats/route.test.ts @@ -23,7 +23,7 @@ const { mockFetch: vi.fn(), })) -vi.mock('@/lib/copilot/request-helpers', () => ({ +vi.mock('@/lib/copilot/request/http', () => ({ authenticateCopilotRequestSessionOnly: mockAuthenticateCopilotRequestSessionOnly, createUnauthorizedResponse: mockCreateUnauthorizedResponse, createBadRequestResponse: mockCreateBadRequestResponse, diff --git a/apps/sim/app/api/copilot/stats/route.ts b/apps/sim/app/api/copilot/stats/route.ts index 493f6e4ec90..75ed6d096b1 100644 --- a/apps/sim/app/api/copilot/stats/route.ts +++ b/apps/sim/app/api/copilot/stats/route.ts @@ -7,7 +7,7 @@ import { createInternalServerErrorResponse, createRequestTracker, createUnauthorizedResponse, -} from '@/lib/copilot/request-helpers' +} from '@/lib/copilot/request/http' import { env } from '@/lib/core/config/env' const BodySchema = z.object({ diff --git a/apps/sim/app/api/copilot/training/examples/route.ts b/apps/sim/app/api/copilot/training/examples/route.ts index 934ce256875..a9318940b91 100644 --- a/apps/sim/app/api/copilot/training/examples/route.ts +++ b/apps/sim/app/api/copilot/training/examples/route.ts @@ -4,7 +4,7 @@ import { z } from 'zod' import { authenticateCopilotRequestSessionOnly, createUnauthorizedResponse, -} from '@/lib/copilot/request-helpers' +} from '@/lib/copilot/request/http' import { env } from '@/lib/core/config/env' const logger = createLogger('CopilotTrainingExamplesAPI') diff --git a/apps/sim/app/api/copilot/training/route.ts b/apps/sim/app/api/copilot/training/route.ts index e6e58f59bb0..e30918b8212 100644 --- a/apps/sim/app/api/copilot/training/route.ts +++ b/apps/sim/app/api/copilot/training/route.ts @@ -4,7 +4,7 @@ import { z } from 'zod' import { authenticateCopilotRequestSessionOnly, createUnauthorizedResponse, -} from '@/lib/copilot/request-helpers' +} from '@/lib/copilot/request/http' import { env } from '@/lib/core/config/env' const logger = createLogger('CopilotTrainingAPI') diff --git a/apps/sim/app/api/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts index 02db5236704..766bbb88fad 100644 --- a/apps/sim/app/api/function/execute/route.test.ts +++ b/apps/sim/app/api/function/execute/route.test.ts @@ -24,6 +24,27 @@ vi.mock('@/lib/auth/hybrid', () => ({ vi.mock('@/lib/execution/e2b', () => ({ executeInE2B: mockExecuteInE2B, + executeShellInE2B: vi.fn(), +})) + +vi.mock('@/lib/copilot/request/tools/files', () => ({ + FORMAT_TO_CONTENT_TYPE: { + json: 'application/json', + csv: 'text/csv', + txt: 'text/plain', + md: 'text/markdown', + html: 'text/html', + }, + normalizeOutputWorkspaceFileName: vi.fn((p: string) => p.replace(/^files\//, '')), + resolveOutputFormat: vi.fn(() => 'json'), +})) + +vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({ + uploadWorkspaceFile: vi.fn(), +})) + +vi.mock('@/lib/workflows/utils', () => ({ + getWorkflowById: vi.fn(), })) vi.mock('@/lib/core/config/feature-flags', () => ({ @@ -32,6 +53,7 @@ vi.mock('@/lib/core/config/feature-flags', () => ({ isProd: false, isDev: false, isTest: true, + isEmailVerificationEnabled: false, })) import { validateProxyUrl } from '@/lib/core/security/input-validation' diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts index 24e992401b7..e6698c328c8 100644 --- a/apps/sim/app/api/function/execute/route.ts +++ b/apps/sim/app/api/function/execute/route.ts @@ -1,11 +1,18 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { checkInternalAuth } from '@/lib/auth/hybrid' +import { + FORMAT_TO_CONTENT_TYPE, + normalizeOutputWorkspaceFileName, + resolveOutputFormat, +} from '@/lib/copilot/request/tools/files' import { isE2bEnabled } from '@/lib/core/config/feature-flags' import { generateRequestId } from '@/lib/core/utils/request' -import { executeInE2B } from '@/lib/execution/e2b' +import { executeInE2B, executeShellInE2B } from '@/lib/execution/e2b' import { executeInIsolatedVM } from '@/lib/execution/isolated-vm' import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages' +import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { getWorkflowById } from '@/lib/workflows/utils' import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants' import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference' import { formatLiteralForCode } from '@/executor/utils/code-formatting' @@ -580,6 +587,96 @@ function cleanStdout(stdout: string): string { return stdout } +async function maybeExportSandboxFileToWorkspace(args: { + authUserId: string + workflowId?: string + workspaceId?: string + outputPath?: string + outputFormat?: string + outputSandboxPath?: string + exportedFileContent?: string + stdout: string + executionTime: number +}) { + const { + authUserId, + workflowId, + workspaceId, + outputPath, + outputFormat, + outputSandboxPath, + exportedFileContent, + stdout, + executionTime, + } = args + + if (!outputSandboxPath) return null + + if (!outputPath) { + return NextResponse.json( + { + success: false, + error: + 'outputSandboxPath requires outputPath. Set outputPath to the destination workspace file, e.g. "files/result.csv".', + output: { result: null, stdout: cleanStdout(stdout), executionTime }, + }, + { status: 400 } + ) + } + + const resolvedWorkspaceId = + workspaceId || (workflowId ? (await getWorkflowById(workflowId))?.workspaceId : undefined) + + if (!resolvedWorkspaceId) { + return NextResponse.json( + { + success: false, + error: 'Workspace context required to save sandbox file to workspace', + output: { result: null, stdout: cleanStdout(stdout), executionTime }, + }, + { status: 400 } + ) + } + + if (exportedFileContent === undefined) { + return NextResponse.json( + { + success: false, + error: `Sandbox file "${outputSandboxPath}" was not found or could not be read`, + output: { result: null, stdout: cleanStdout(stdout), executionTime }, + }, + { status: 500 } + ) + } + + const fileName = normalizeOutputWorkspaceFileName(outputPath) + const format = resolveOutputFormat(fileName, outputFormat) + const contentType = FORMAT_TO_CONTENT_TYPE[format] + const uploaded = await uploadWorkspaceFile( + resolvedWorkspaceId, + authUserId, + Buffer.from(exportedFileContent, 'utf-8'), + fileName, + contentType + ) + + return NextResponse.json({ + success: true, + output: { + result: { + message: `Sandbox file exported to files/${fileName}`, + fileId: uploaded.id, + fileName, + downloadUrl: uploaded.url, + sandboxPath: outputSandboxPath, + }, + stdout: cleanStdout(stdout), + executionTime, + }, + resources: [{ type: 'file', id: uploaded.id, title: fileName }], + }) +} + export async function POST(req: NextRequest) { const requestId = generateRequestId() const startTime = Date.now() @@ -603,12 +700,16 @@ export async function POST(req: NextRequest) { params = {}, timeout = DEFAULT_EXECUTION_TIMEOUT_MS, language = DEFAULT_CODE_LANGUAGE, + outputPath, + outputFormat, + outputSandboxPath, envVars = {}, blockData = {}, blockNameMapping = {}, blockOutputSchemas = {}, workflowVariables = {}, workflowId, + workspaceId, isCustomTool = false, _sandboxFiles, } = body @@ -652,6 +753,82 @@ export async function POST(req: NextRequest) { hasImports = jsImports.trim().length > 0 || hasRequireStatements } + if (lang === CodeLanguage.Shell) { + if (!isE2bEnabled) { + throw new Error( + 'Shell execution requires E2B to be enabled. Please contact your administrator to enable E2B.' + ) + } + + const shellEnvs: Record = {} + for (const [k, v] of Object.entries(envVars)) { + shellEnvs[k] = String(v) + } + for (const [k, v] of Object.entries(contextVariables)) { + shellEnvs[k] = String(v) + } + + logger.info(`[${requestId}] E2B shell execution`, { + enabled: isE2bEnabled, + hasApiKey: Boolean(process.env.E2B_API_KEY), + envVarCount: Object.keys(shellEnvs).length, + }) + + const execStart = Date.now() + const { + result: shellResult, + stdout: shellStdout, + sandboxId, + error: shellError, + exportedFileContent, + } = await executeShellInE2B({ + code: resolvedCode, + envs: shellEnvs, + timeoutMs: timeout, + sandboxFiles: _sandboxFiles, + outputSandboxPath, + }) + const executionTime = Date.now() - execStart + + logger.info(`[${requestId}] E2B shell sandbox`, { + sandboxId, + stdoutPreview: shellStdout?.slice(0, 200), + error: shellError, + executionTime, + }) + + if (shellError) { + return NextResponse.json( + { + success: false, + error: shellError, + output: { result: null, stdout: cleanStdout(shellStdout), executionTime }, + }, + { status: 500 } + ) + } + + if (outputSandboxPath) { + const fileExportResponse = await maybeExportSandboxFileToWorkspace({ + authUserId: auth.userId, + workflowId, + workspaceId, + outputPath, + outputFormat, + outputSandboxPath, + exportedFileContent, + stdout: shellStdout, + executionTime, + }) + if (fileExportResponse) return fileExportResponse + } + + return NextResponse.json({ + success: true, + output: { result: shellResult ?? null, stdout: cleanStdout(shellStdout), executionTime }, + }) + } + if (lang === CodeLanguage.Python && !isE2bEnabled) { throw new Error( 'Python execution requires E2B to be enabled. Please contact your administrator to enable E2B, or use JavaScript instead.' @@ -719,11 +896,13 @@ export async function POST(req: NextRequest) { stdout: e2bStdout, sandboxId, error: e2bError, + exportedFileContent, } = await executeInE2B({ code: codeForE2B, language: CodeLanguage.JavaScript, timeoutMs: timeout, sandboxFiles: _sandboxFiles, + outputSandboxPath, }) const executionTime = Date.now() - execStart stdout += e2bStdout @@ -752,6 +931,21 @@ export async function POST(req: NextRequest) { ) } + if (outputSandboxPath) { + const fileExportResponse = await maybeExportSandboxFileToWorkspace({ + authUserId: auth.userId, + workflowId, + workspaceId, + outputPath, + outputFormat, + outputSandboxPath, + exportedFileContent, + stdout, + executionTime, + }) + if (fileExportResponse) return fileExportResponse + } + return NextResponse.json({ success: true, output: { result: e2bResult ?? null, stdout: cleanStdout(stdout), executionTime }, @@ -783,11 +977,13 @@ export async function POST(req: NextRequest) { stdout: e2bStdout, sandboxId, error: e2bError, + exportedFileContent, } = await executeInE2B({ code: codeForE2B, language: CodeLanguage.Python, timeoutMs: timeout, sandboxFiles: _sandboxFiles, + outputSandboxPath, }) const executionTime = Date.now() - execStart stdout += e2bStdout @@ -816,6 +1012,21 @@ export async function POST(req: NextRequest) { ) } + if (outputSandboxPath) { + const fileExportResponse = await maybeExportSandboxFileToWorkspace({ + authUserId: auth.userId, + workflowId, + workspaceId, + outputPath, + outputFormat, + outputSandboxPath, + exportedFileContent, + stdout, + executionTime, + }) + if (fileExportResponse) return fileExportResponse + } + return NextResponse.json({ success: true, output: { result: e2bResult ?? null, stdout: cleanStdout(stdout), executionTime }, diff --git a/apps/sim/app/api/mcp/copilot/route.ts b/apps/sim/app/api/mcp/copilot/route.ts index b61dbc39806..33fa20707b5 100644 --- a/apps/sim/app/api/mcp/copilot/route.ts +++ b/apps/sim/app/api/mcp/copilot/route.ts @@ -18,14 +18,11 @@ import { eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { validateOAuthAccessToken } from '@/lib/auth/oauth-token' import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription' -import { createRunSegment } from '@/lib/copilot/async-runs/repository' import { ORCHESTRATION_TIMEOUT_MS, SIM_AGENT_API_URL } from '@/lib/copilot/constants' -import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator' -import { orchestrateSubagentStream } from '@/lib/copilot/orchestrator/subagent' -import { - executeToolServerSide, - prepareExecutionContext, -} from '@/lib/copilot/orchestrator/tool-executor' +import { runCopilotLifecycle } from '@/lib/copilot/request/lifecycle/run' +import { orchestrateSubagentStream } from '@/lib/copilot/request/subagent' +import { ensureHandlersRegistered, executeTool } from '@/lib/copilot/tool-executor' +import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context' import { DIRECT_TOOL_DEFS, SUBAGENT_TOOL_DEFS } from '@/lib/copilot/tools/mcp/definitions' import { env } from '@/lib/core/config/env' import { RateLimiter } from '@/lib/core/rate-limiter' @@ -645,7 +642,8 @@ async function handleDirectToolCall( startTime: Date.now(), } - const result = await executeToolServerSide(toolCall, execContext) + ensureHandlersRegistered() + const result = await executeTool(toolCall.name, toolCall.params || {}, execContext) return { content: [ @@ -728,25 +726,10 @@ async function handleBuildToolCall( chatId, } - const executionId = crypto.randomUUID() - const runId = crypto.randomUUID() - const messageId = requestPayload.messageId as string - - await createRunSegment({ - id: runId, - executionId, - chatId, - userId, - workflowId: resolved.workflowId, - streamId: messageId, - }).catch(() => {}) - - const result = await orchestrateCopilotStream(requestPayload, { + const result = await runCopilotLifecycle(requestPayload, { userId, workflowId: resolved.workflowId, chatId, - executionId, - runId, goRoute: '/api/mcp', autoExecuteTools: true, timeout: ORCHESTRATION_TIMEOUT_MS, diff --git a/apps/sim/app/api/mothership/chat/route.ts b/apps/sim/app/api/mothership/chat/route.ts index 6accb899a1a..e8212acd674 100644 --- a/apps/sim/app/api/mothership/chat/route.ts +++ b/apps/sim/app/api/mothership/chat/route.ts @@ -5,18 +5,26 @@ import { eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' -import { resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle' -import { buildCopilotRequestPayload } from '@/lib/copilot/chat-payload' +import { resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle' +import { buildCopilotRequestPayload } from '@/lib/copilot/chat/payload' +import { + buildPersistedAssistantMessage, + buildPersistedUserMessage, +} from '@/lib/copilot/chat/persisted-message' +import { + processContextsServer, + resolveActiveResourceContext, +} from '@/lib/copilot/chat/process-contents' +import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context' +import { createRequestTracker, createUnauthorizedResponse } from '@/lib/copilot/request/http' +import { createSSEStream, SSE_RESPONSE_HEADERS } from '@/lib/copilot/request/lifecycle/start' import { acquirePendingChatStream, - createSSEStream, - SSE_RESPONSE_HEADERS, -} from '@/lib/copilot/chat-streaming' -import type { OrchestratorResult } from '@/lib/copilot/orchestrator/types' -import { processContextsServer, resolveActiveResourceContext } from '@/lib/copilot/process-contents' -import { createRequestTracker, createUnauthorizedResponse } from '@/lib/copilot/request-helpers' -import { taskPubSub } from '@/lib/copilot/task-events' -import { generateWorkspaceContext } from '@/lib/copilot/workspace-context' + getPendingChatStreamId, + releasePendingChatStream, +} from '@/lib/copilot/request/session' +import type { OrchestratorResult } from '@/lib/copilot/request/types' +import { taskPubSub } from '@/lib/copilot/tasks' import { assertActiveWorkspaceAccess, getUserEntityPermissions, @@ -37,7 +45,6 @@ const FileAttachmentSchema = z.object({ const ResourceAttachmentSchema = z.object({ type: z.enum(['workflow', 'table', 'file', 'knowledgebase']), id: z.string().min(1), - title: z.string().optional(), active: z.boolean().optional(), }) @@ -87,7 +94,9 @@ const MothershipMessageSchema = z.object({ */ export async function POST(req: NextRequest) { const tracker = createRequestTracker() - let userMessageIdForLogs: string | undefined + let lockChatId: string | undefined + let lockStreamId = '' + let chatStreamLockAcquired = false try { const session = await getSession() @@ -110,27 +119,23 @@ export async function POST(req: NextRequest) { } = MothershipMessageSchema.parse(body) const userMessageId = providedMessageId || crypto.randomUUID() - userMessageIdForLogs = userMessageId - const reqLogger = logger.withMetadata({ - requestId: tracker.requestId, - messageId: userMessageId, - }) + lockStreamId = userMessageId - reqLogger.info('Received mothership chat start request', { - workspaceId, - chatId, - createNewChat, - hasContexts: Array.isArray(contexts) && contexts.length > 0, - contextsCount: Array.isArray(contexts) ? contexts.length : 0, - hasResourceAttachments: Array.isArray(resourceAttachments) && resourceAttachments.length > 0, - resourceAttachmentCount: Array.isArray(resourceAttachments) ? resourceAttachments.length : 0, - hasFileAttachments: Array.isArray(fileAttachments) && fileAttachments.length > 0, - fileAttachmentCount: Array.isArray(fileAttachments) ? fileAttachments.length : 0, - }) + // Phase 1: workspace access + chat resolution in parallel + const [accessResult, chatResult] = await Promise.allSettled([ + assertActiveWorkspaceAccess(workspaceId, authenticatedUserId), + chatId || createNewChat + ? resolveOrCreateChat({ + chatId, + userId: authenticatedUserId, + workspaceId, + model: 'claude-opus-4-6', + type: 'mothership', + }) + : null, + ]) - try { - await assertActiveWorkspaceAccess(workspaceId, authenticatedUserId) - } catch { + if (accessResult.status === 'rejected') { return NextResponse.json({ error: 'Workspace not found or access denied' }, { status: 403 }) } @@ -138,18 +143,12 @@ export async function POST(req: NextRequest) { let conversationHistory: any[] = [] let actualChatId = chatId - if (chatId || createNewChat) { - const chatResult = await resolveOrCreateChat({ - chatId, - userId: authenticatedUserId, - workspaceId, - model: 'claude-opus-4-6', - type: 'mothership', - }) - currentChat = chatResult.chat - actualChatId = chatResult.chatId || chatId - conversationHistory = Array.isArray(chatResult.conversationHistory) - ? chatResult.conversationHistory + if (chatResult.status === 'fulfilled' && chatResult.value) { + const resolved = chatResult.value + currentChat = resolved.chat + actualChatId = resolved.chatId || chatId + conversationHistory = Array.isArray(resolved.conversationHistory) + ? resolved.conversationHistory : [] if (chatId && !currentChat) { @@ -157,76 +156,73 @@ export async function POST(req: NextRequest) { } } - let agentContexts: Array<{ type: string; content: string }> = [] - if (Array.isArray(contexts) && contexts.length > 0) { - try { - agentContexts = await processContextsServer( - contexts as any, - authenticatedUserId, - message, - workspaceId, - actualChatId + if (actualChatId) { + chatStreamLockAcquired = await acquirePendingChatStream(actualChatId, userMessageId) + if (!chatStreamLockAcquired) { + const activeStreamId = await getPendingChatStreamId(actualChatId) + return NextResponse.json( + { + error: 'A response is already in progress for this chat.', + ...(activeStreamId ? { activeStreamId } : {}), + }, + { status: 409 } ) - } catch (e) { - reqLogger.error('Failed to process contexts', e) } + lockChatId = actualChatId } - if (Array.isArray(resourceAttachments) && resourceAttachments.length > 0) { - const results = await Promise.allSettled( - resourceAttachments.map(async (r) => { - const ctx = await resolveActiveResourceContext( - r.type, - r.id, - workspaceId, + // Phase 2: contexts + workspace context + user message persistence in parallel + const contextPromise = (async () => { + let agentCtxs: Array<{ type: string; content: string }> = [] + if (Array.isArray(contexts) && contexts.length > 0) { + try { + agentCtxs = await processContextsServer( + contexts as any, authenticatedUserId, + message, + workspaceId, actualChatId ) - if (!ctx) return null - return { - ...ctx, - tag: r.active ? '@active_tab' : '@open_tab', + } catch (e) { + logger.error(`[${tracker.requestId}] Failed to process contexts`, e) + } + } + if (Array.isArray(resourceAttachments) && resourceAttachments.length > 0) { + const results = await Promise.allSettled( + resourceAttachments.map(async (r) => { + const ctx = await resolveActiveResourceContext( + r.type, + r.id, + workspaceId, + authenticatedUserId, + actualChatId + ) + if (!ctx) return null + return { ...ctx, tag: r.active ? '@active_tab' : '@open_tab' } + }) + ) + for (const result of results) { + if (result.status === 'fulfilled' && result.value) { + agentCtxs.push(result.value) + } else if (result.status === 'rejected') { + logger.error( + `[${tracker.requestId}] Failed to resolve resource attachment`, + result.reason + ) } - }) - ) - for (const result of results) { - if (result.status === 'fulfilled' && result.value) { - agentContexts.push(result.value) - } else if (result.status === 'rejected') { - reqLogger.error('Failed to resolve resource attachment', result.reason) } } - } + return agentCtxs + })() - if (actualChatId) { - const userMsg = { + const userMsgPromise = (async () => { + if (!actualChatId) return + const userMsg = buildPersistedUserMessage({ id: userMessageId, - role: 'user' as const, content: message, - timestamp: new Date().toISOString(), - ...(fileAttachments && - fileAttachments.length > 0 && { - fileAttachments: fileAttachments.map((f) => ({ - id: f.id, - key: f.key, - filename: f.filename, - media_type: f.media_type, - size: f.size, - })), - }), - ...(contexts && - contexts.length > 0 && { - contexts: contexts.map((c) => ({ - kind: c.kind, - label: c.label, - ...(c.workflowId && { workflowId: c.workflowId }), - ...(c.knowledgeId && { knowledgeId: c.knowledgeId }), - ...(c.tableId && { tableId: c.tableId }), - ...(c.fileId && { fileId: c.fileId }), - })), - }), - } - + fileAttachments, + contexts, + }) const [updated] = await db .update(copilotChats) .set({ @@ -242,11 +238,15 @@ export async function POST(req: NextRequest) { conversationHistory = freshMessages.filter((m: any) => m.id !== userMessageId) taskPubSub?.publishStatusChanged({ workspaceId, chatId: actualChatId, type: 'started' }) } - } + })() - const [workspaceContext, userPermission] = await Promise.all([ - generateWorkspaceContext(workspaceId, authenticatedUserId), - getUserEntityPermissions(authenticatedUserId, 'workspace', workspaceId).catch(() => null), + const [agentContexts, [workspaceContext, userPermission]] = await Promise.all([ + contextPromise, + Promise.all([ + generateWorkspaceContext(workspaceId, authenticatedUserId), + getUserEntityPermissions(authenticatedUserId, 'workspace', workspaceId).catch(() => null), + ]), + userMsgPromise, ]) const requestPayload = await buildCopilotRequestPayload( @@ -267,19 +267,6 @@ export async function POST(req: NextRequest) { { selectedModel: '' } ) - if (actualChatId) { - const acquired = await acquirePendingChatStream(actualChatId, userMessageId) - if (!acquired) { - return NextResponse.json( - { - error: - 'A response is already in progress for this chat. Wait for it to finish or use Stop.', - }, - { status: 409 } - ) - } - } - const executionId = crypto.randomUUID() const runId = crypto.randomUUID() const stream = createSSEStream({ @@ -295,7 +282,6 @@ export async function POST(req: NextRequest) { titleModel: 'claude-opus-4-6', requestId: tracker.requestId, workspaceId, - pendingChatStreamAlreadyRegistered: Boolean(actualChatId), orchestrateOptions: { userId: authenticatedUserId, workspaceId, @@ -309,46 +295,7 @@ export async function POST(req: NextRequest) { if (!actualChatId) return if (!result.success) return - const assistantMessage: Record = { - id: crypto.randomUUID(), - role: 'assistant' as const, - content: result.content, - timestamp: new Date().toISOString(), - ...(result.requestId ? { requestId: result.requestId } : {}), - } - if (result.toolCalls.length > 0) { - assistantMessage.toolCalls = result.toolCalls - } - if (result.contentBlocks.length > 0) { - assistantMessage.contentBlocks = result.contentBlocks.map((block) => { - const stored: Record = { type: block.type } - if (block.content) stored.content = block.content - if (block.type === 'tool_call' && block.toolCall) { - const state = - block.toolCall.result?.success !== undefined - ? block.toolCall.result.success - ? 'success' - : 'error' - : block.toolCall.status - const isSubagentTool = !!block.calledBy - const isNonTerminal = - state === 'cancelled' || state === 'pending' || state === 'executing' - stored.toolCall = { - id: block.toolCall.id, - name: block.toolCall.name, - state, - ...(isSubagentTool && isNonTerminal ? {} : { result: block.toolCall.result }), - ...(isSubagentTool && isNonTerminal - ? {} - : block.toolCall.params - ? { params: block.toolCall.params } - : {}), - ...(block.calledBy ? { calledBy: block.calledBy } : {}), - } - } - return stored - }) - } + const assistantMessage = buildPersistedAssistantMessage(result, result.requestId) try { const [row] = await db @@ -381,7 +328,7 @@ export async function POST(req: NextRequest) { }) } } catch (error) { - reqLogger.error('Failed to persist chat messages', { + logger.error(`[${tracker.requestId}] Failed to persist chat messages`, { chatId: actualChatId, error: error instanceof Error ? error.message : 'Unknown error', }) @@ -392,6 +339,9 @@ export async function POST(req: NextRequest) { return new Response(stream, { headers: SSE_RESPONSE_HEADERS }) } catch (error) { + if (chatStreamLockAcquired && lockChatId && lockStreamId) { + await releasePendingChatStream(lockChatId, lockStreamId) + } if (error instanceof z.ZodError) { return NextResponse.json( { error: 'Invalid request data', details: error.errors }, @@ -399,11 +349,9 @@ export async function POST(req: NextRequest) { ) } - logger - .withMetadata({ requestId: tracker.requestId, messageId: userMessageIdForLogs }) - .error('Error handling mothership chat', { - error: error instanceof Error ? error.message : 'Unknown error', - }) + logger.error(`[${tracker.requestId}] Error handling mothership chat:`, { + error: error instanceof Error ? error.message : 'Unknown error', + }) return NextResponse.json( { error: error instanceof Error ? error.message : 'Internal server error' }, diff --git a/apps/sim/app/api/mothership/chat/stop/route.ts b/apps/sim/app/api/mothership/chat/stop/route.ts index 8eb5185ff1e..cc5cac32a29 100644 --- a/apps/sim/app/api/mothership/chat/stop/route.ts +++ b/apps/sim/app/api/mothership/chat/stop/route.ts @@ -5,8 +5,9 @@ import { and, eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { getSession } from '@/lib/auth' -import { releasePendingChatStream } from '@/lib/copilot/chat-streaming' -import { taskPubSub } from '@/lib/copilot/task-events' +import { normalizeMessage, type PersistedMessage } from '@/lib/copilot/chat/persisted-message' +import { releasePendingChatStream } from '@/lib/copilot/request/session' +import { taskPubSub } from '@/lib/copilot/tasks' const logger = createLogger('MothershipChatStopAPI') @@ -26,15 +27,25 @@ const StoredToolCallSchema = z display: z .object({ text: z.string().optional(), + title: z.string().optional(), + phaseLabel: z.string().optional(), }) .optional(), calledBy: z.string().optional(), + durationMs: z.number().optional(), + error: z.string().optional(), }) .nullable() const ContentBlockSchema = z.object({ type: z.string(), + lane: z.enum(['main', 'subagent']).optional(), content: z.string().optional(), + channel: z.enum(['assistant', 'thinking']).optional(), + phase: z.enum(['call', 'args_delta', 'result']).optional(), + kind: z.enum(['subagent', 'structured_result', 'subagent_result']).optional(), + lifecycle: z.enum(['start', 'end']).optional(), + status: z.enum(['complete', 'error', 'cancelled']).optional(), toolCall: StoredToolCallSchema.optional(), }) @@ -70,15 +81,14 @@ export async function POST(req: NextRequest) { const hasBlocks = Array.isArray(contentBlocks) && contentBlocks.length > 0 if (hasContent || hasBlocks) { - const assistantMessage: Record = { + const normalized = normalizeMessage({ id: crypto.randomUUID(), - role: 'assistant' as const, + role: 'assistant', content, timestamp: new Date().toISOString(), - } - if (hasBlocks) { - assistantMessage.contentBlocks = contentBlocks - } + ...(hasBlocks ? { contentBlocks } : {}), + }) + const assistantMessage: PersistedMessage = normalized setClause.messages = sql`${copilotChats.messages} || ${JSON.stringify([assistantMessage])}::jsonb` } diff --git a/apps/sim/app/api/mothership/chats/[chatId]/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/route.ts index e41a7c713d6..37dab60f35d 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/route.ts @@ -4,15 +4,15 @@ import { createLogger } from '@sim/logger' import { and, eq, sql } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { getAccessibleCopilotChat } from '@/lib/copilot/chat-lifecycle' -import { getStreamMeta, readStreamEvents } from '@/lib/copilot/orchestrator/stream/buffer' +import { getAccessibleCopilotChat } from '@/lib/copilot/chat/lifecycle' import { authenticateCopilotRequestSessionOnly, createBadRequestResponse, createInternalServerErrorResponse, createUnauthorizedResponse, -} from '@/lib/copilot/request-helpers' -import { taskPubSub } from '@/lib/copilot/task-events' +} from '@/lib/copilot/request/http' +import { readEvents } from '@/lib/copilot/request/session/buffer' +import { taskPubSub } from '@/lib/copilot/tasks' const logger = createLogger('MothershipChatAPI') @@ -46,29 +46,24 @@ export async function GET( } let streamSnapshot: { - events: Array<{ eventId: number; streamId: string; event: Record }> + events: unknown[] status: string } | null = null if (chat.conversationId) { try { - const [meta, events] = await Promise.all([ - getStreamMeta(chat.conversationId), - readStreamEvents(chat.conversationId, 0), - ]) + const events = await readEvents(chat.conversationId, '0') streamSnapshot = { events: events || [], - status: meta?.status || 'unknown', + status: events.length > 0 ? 'active' : 'unknown', } } catch (error) { - logger - .withMetadata({ messageId: chat.conversationId || undefined }) - .warn('Failed to read stream snapshot for mothership chat', { - chatId, - conversationId: chat.conversationId, - error: error instanceof Error ? error.message : String(error), - }) + logger.warn('Failed to read stream snapshot for mothership chat', { + chatId, + conversationId: chat.conversationId, + error: error instanceof Error ? error.message : String(error), + }) } } diff --git a/apps/sim/app/api/mothership/chats/read/route.ts b/apps/sim/app/api/mothership/chats/read/route.ts new file mode 100644 index 00000000000..344687ddfdc --- /dev/null +++ b/apps/sim/app/api/mothership/chats/read/route.ts @@ -0,0 +1,43 @@ +import { db } from '@sim/db' +import { copilotChats } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, sql } from 'drizzle-orm' +import { type NextRequest, NextResponse } from 'next/server' +import { z } from 'zod' +import { + authenticateCopilotRequestSessionOnly, + createBadRequestResponse, + createInternalServerErrorResponse, + createUnauthorizedResponse, +} from '@/lib/copilot/request/http' + +const logger = createLogger('MarkTaskReadAPI') + +const MarkReadSchema = z.object({ + chatId: z.string().min(1), +}) + +export async function POST(request: NextRequest) { + try { + const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() + if (!isAuthenticated || !userId) { + return createUnauthorizedResponse() + } + + const body = await request.json() + const { chatId } = MarkReadSchema.parse(body) + + await db + .update(copilotChats) + .set({ lastSeenAt: sql`GREATEST(${copilotChats.updatedAt}, NOW())` }) + .where(and(eq(copilotChats.id, chatId), eq(copilotChats.userId, userId))) + + return NextResponse.json({ success: true }) + } catch (error) { + if (error instanceof z.ZodError) { + return createBadRequestResponse('chatId is required') + } + logger.error('Error marking task as read:', error) + return createInternalServerErrorResponse('Failed to mark task as read') + } +} diff --git a/apps/sim/app/api/mothership/chats/route.ts b/apps/sim/app/api/mothership/chats/route.ts index 91177b0a9d6..a9c0f5ab7d5 100644 --- a/apps/sim/app/api/mothership/chats/route.ts +++ b/apps/sim/app/api/mothership/chats/route.ts @@ -9,8 +9,8 @@ import { createBadRequestResponse, createInternalServerErrorResponse, createUnauthorizedResponse, -} from '@/lib/copilot/request-helpers' -import { taskPubSub } from '@/lib/copilot/task-events' +} from '@/lib/copilot/request/http' +import { taskPubSub } from '@/lib/copilot/tasks' import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('MothershipChatsAPI') @@ -38,7 +38,7 @@ export async function GET(request: NextRequest) { id: copilotChats.id, title: copilotChats.title, updatedAt: copilotChats.updatedAt, - conversationId: copilotChats.conversationId, + activeStreamId: copilotChats.conversationId, lastSeenAt: copilotChats.lastSeenAt, }) .from(copilotChats) diff --git a/apps/sim/app/api/mothership/events/route.ts b/apps/sim/app/api/mothership/events/route.ts index 38abba7b33f..4f1646f6e34 100644 --- a/apps/sim/app/api/mothership/events/route.ts +++ b/apps/sim/app/api/mothership/events/route.ts @@ -7,7 +7,7 @@ * Auth is handled via session cookies (EventSource sends cookies automatically). */ -import { taskPubSub } from '@/lib/copilot/task-events' +import { taskPubSub } from '@/lib/copilot/tasks' import { createWorkspaceSSE } from '@/lib/events/sse-endpoint' export const dynamic = 'force-dynamic' diff --git a/apps/sim/app/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts index 0570a808e45..8f0e2dec61f 100644 --- a/apps/sim/app/api/mothership/execute/route.ts +++ b/apps/sim/app/api/mothership/execute/route.ts @@ -2,10 +2,9 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' import { checkInternalAuth } from '@/lib/auth/hybrid' -import { createRunSegment } from '@/lib/copilot/async-runs/repository' -import { buildIntegrationToolSchemas } from '@/lib/copilot/chat-payload' -import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator' -import { generateWorkspaceContext } from '@/lib/copilot/workspace-context' +import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload' +import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context' +import { runCopilotLifecycle } from '@/lib/copilot/request/lifecycle/run' import { assertActiveWorkspaceAccess, getUserEntityPermissions, @@ -72,34 +71,25 @@ export async function POST(req: NextRequest) { ...(userPermission ? { userPermission } : {}), } - const executionId = crypto.randomUUID() - const runId = crypto.randomUUID() - - await createRunSegment({ - id: runId, - executionId, - chatId: effectiveChatId, - userId, - workspaceId, - streamId: messageId, - }).catch(() => {}) - - const result = await orchestrateCopilotStream(requestPayload, { + const result = await runCopilotLifecycle(requestPayload, { userId, workspaceId, chatId: effectiveChatId, - executionId, - runId, goRoute: '/api/mothership/execute', autoExecuteTools: true, interactive: false, }) if (!result.success) { - reqLogger.error('Mothership execute failed', { - error: result.error, - errors: result.errors, - }) + logger.error( + messageId + ? `Mothership execute failed [messageId:${messageId}]` + : 'Mothership execute failed', + { + error: result.error, + errors: result.errors, + } + ) return NextResponse.json( { error: result.error || 'Mothership execution failed', @@ -135,9 +125,12 @@ export async function POST(req: NextRequest) { ) } - logger.withMetadata({ messageId }).error('Mothership execute error', { - error: error instanceof Error ? error.message : 'Unknown error', - }) + logger.error( + messageId ? `Mothership execute error [messageId:${messageId}]` : 'Mothership execute error', + { + error: error instanceof Error ? error.message : 'Unknown error', + } + ) return NextResponse.json( { error: error instanceof Error ? error.message : 'Internal server error' }, diff --git a/apps/sim/app/api/templates/approved/sanitized/route.ts b/apps/sim/app/api/templates/approved/sanitized/route.ts index 2b6fad9652a..dd72ef80464 100644 --- a/apps/sim/app/api/templates/approved/sanitized/route.ts +++ b/apps/sim/app/api/templates/approved/sanitized/route.ts @@ -3,7 +3,7 @@ import { templates } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' import { type NextRequest, NextResponse } from 'next/server' -import { checkInternalApiKey } from '@/lib/copilot/utils' +import { checkInternalApiKey } from '@/lib/copilot/request/http' import { generateRequestId } from '@/lib/core/utils/request' import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' diff --git a/apps/sim/app/api/v1/copilot/chat/route.ts b/apps/sim/app/api/v1/copilot/chat/route.ts index 09a5a70f3e1..3f3d8fc496c 100644 --- a/apps/sim/app/api/v1/copilot/chat/route.ts +++ b/apps/sim/app/api/v1/copilot/chat/route.ts @@ -1,9 +1,8 @@ import { createLogger } from '@sim/logger' import { type NextRequest, NextResponse } from 'next/server' import { z } from 'zod' -import { createRunSegment } from '@/lib/copilot/async-runs/repository' -import { COPILOT_REQUEST_MODES } from '@/lib/copilot/models' -import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator' +import { COPILOT_REQUEST_MODES } from '@/lib/copilot/constants' +import { runCopilotLifecycle } from '@/lib/copilot/request/lifecycle/run' import { getWorkflowById, resolveWorkflowIdForUser } from '@/lib/workflows/utils' import { authenticateV1Request } from '@/app/api/v1/auth' @@ -83,15 +82,19 @@ export async function POST(req: NextRequest) { const chatId = parsed.chatId || crypto.randomUUID() messageId = crypto.randomUUID() - const reqLogger = logger.withMetadata({ messageId }) - reqLogger.info('Received headless copilot chat start request', { - workflowId: resolved.workflowId, - workflowName: parsed.workflowName, - chatId, - mode: transportMode, - autoExecuteTools: parsed.autoExecuteTools, - timeout: parsed.timeout, - }) + logger.info( + messageId + ? `Received headless copilot chat start request [messageId:${messageId}]` + : 'Received headless copilot chat start request', + { + workflowId: resolved.workflowId, + workflowName: parsed.workflowName, + chatId, + mode: transportMode, + autoExecuteTools: parsed.autoExecuteTools, + timeout: parsed.timeout, + } + ) const requestPayload = { message: parsed.message, workflowId: resolved.workflowId, @@ -102,24 +105,10 @@ export async function POST(req: NextRequest) { chatId, } - const executionId = crypto.randomUUID() - const runId = crypto.randomUUID() - - await createRunSegment({ - id: runId, - executionId, - chatId, - userId: auth.userId, - workflowId: resolved.workflowId, - streamId: messageId, - }).catch(() => {}) - - const result = await orchestrateCopilotStream(requestPayload, { + const result = await runCopilotLifecycle(requestPayload, { userId: auth.userId, workflowId: resolved.workflowId, chatId, - executionId, - runId, goRoute: '/api/mcp', autoExecuteTools: parsed.autoExecuteTools, timeout: parsed.timeout, @@ -141,9 +130,14 @@ export async function POST(req: NextRequest) { ) } - logger.withMetadata({ messageId }).error('Headless copilot request failed', { - error: error instanceof Error ? error.message : String(error), - }) + logger.error( + messageId + ? `Headless copilot request failed [messageId:${messageId}]` + : 'Headless copilot request failed', + { + error: error instanceof Error ? error.message : String(error), + } + ) return NextResponse.json({ success: false, error: 'Internal server error' }, { status: 500 }) } } diff --git a/apps/sim/app/api/webhooks/trigger/[path]/route.ts b/apps/sim/app/api/webhooks/trigger/[path]/route.ts index 36c9fe06ebd..1257c134409 100644 --- a/apps/sim/app/api/webhooks/trigger/[path]/route.ts +++ b/apps/sim/app/api/webhooks/trigger/[path]/route.ts @@ -155,19 +155,14 @@ async function handleWebhookPost( if (shouldSkipWebhookEvent(foundWebhook, body, requestId)) { continue } - - try { - const response = await queueWebhookExecution(foundWebhook, foundWorkflow, body, request, { - requestId, - path, - actorUserId: preprocessResult.actorUserId, - executionId: preprocessResult.executionId, - correlation: preprocessResult.correlation, - }) - responses.push(response) - } catch (error) { - throw error - } + const response = await queueWebhookExecution(foundWebhook, foundWorkflow, body, request, { + requestId, + path, + actorUserId: preprocessResult.actorUserId, + executionId: preprocessResult.executionId, + correlation: preprocessResult.correlation, + }) + responses.push(response) } if (responses.length === 0) { diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index 83d19f44aa8..26356855238 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -1,21 +1,17 @@ 'use client' -import type { AgentGroupItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components' import { - AgentGroup, - ChatContent, - CircleStop, - Options, - PendingTagIndicator, -} from '@/app/workspace/[workspaceId]/home/components/message-content/components' -import type { - ContentBlock, - MothershipToolName, - OptionItem, - SubagentName, - ToolCallData, -} from '@/app/workspace/[workspaceId]/home/types' -import { SUBAGENT_LABELS, TOOL_UI_METADATA } from '@/app/workspace/[workspaceId]/home/types' + FileWrite, + Read as ReadTool, + ToolSearchToolRegex, + WorkspaceFile, +} from '@/lib/copilot/generated/tool-catalog-v1' +import { resolveToolDisplay } from '@/lib/copilot/tools/client/store-utils' +import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-display-registry' +import type { ContentBlock, OptionItem, ToolCallData } from '../../types' +import { SUBAGENT_LABELS, TOOL_UI_METADATA } from '../../types' +import type { AgentGroupItem } from './components' +import { AgentGroup, ChatContent, CircleStop, Options, PendingTagIndicator } from './components' interface TextSegment { type: 'text' @@ -52,11 +48,19 @@ const SUBAGENT_KEYS = new Set(Object.keys(SUBAGENT_LABELS)) * group is absorbed so it doesn't render as a separate Mothership entry. */ const SUBAGENT_DISPATCH_TOOLS: Record = { - file_write: 'workspace_file', + [FileWrite.id]: WorkspaceFile.id, +} + +function formatToolName(name: string): string { + return name + .replace(/_v\d+$/, '') + .split('_') + .map((w) => w.charAt(0).toUpperCase() + w.slice(1)) + .join(' ') } function resolveAgentLabel(key: string): string { - return SUBAGENT_LABELS[key as SubagentName] ?? key + return SUBAGENT_LABELS[key] ?? formatToolName(key) } function isToolDone(status: ToolCallData['status']): boolean { @@ -67,12 +71,41 @@ function isDelegatingTool(tc: NonNullable): boolean { return tc.status === 'executing' } +function mapToolStatusToClientState( + status: ContentBlock['toolCall'] extends { status: infer T } ? T : string +) { + switch (status) { + case 'success': + return ClientToolCallState.success + case 'error': + return ClientToolCallState.error + case 'cancelled': + return ClientToolCallState.cancelled + default: + return ClientToolCallState.executing + } +} + +function getOverrideDisplayTitle(tc: NonNullable): string | undefined { + if (tc.name === ReadTool.id || tc.name.endsWith('_respond')) { + return resolveToolDisplay(tc.name, mapToolStatusToClientState(tc.status), tc.id, tc.params) + ?.text + } + return undefined +} + function toToolData(tc: NonNullable): ToolCallData { + const overrideDisplayTitle = getOverrideDisplayTitle(tc) + const displayTitle = + overrideDisplayTitle || + tc.displayTitle || + TOOL_UI_METADATA[tc.name as keyof typeof TOOL_UI_METADATA]?.title || + formatToolName(tc.name) + return { id: tc.id, toolName: tc.name, - displayTitle: - tc.displayTitle ?? TOOL_UI_METADATA[tc.name as MothershipToolName]?.title ?? tc.name, + displayTitle, status: tc.status, params: tc.params, result: tc.result, @@ -172,7 +205,7 @@ function parseBlocks(blocks: ContentBlock[]): MessageSegment[] { if (block.type === 'tool_call') { if (!block.toolCall) continue const tc = block.toolCall - if (tc.name === 'tool_search_tool_regex') continue + if (tc.name === ToolSearchToolRegex.id) continue const isDispatch = SUBAGENT_KEYS.has(tc.name) && !tc.calledBy if (isDispatch) { @@ -312,7 +345,7 @@ export function MessageContent({ if (segments.length === 0) { if (isStreaming) { return ( -
+
) @@ -341,7 +374,7 @@ export function MessageContent({ )?.id return ( -
+
{segments.map((segment, i) => { switch (segment.type) { case 'text': @@ -384,9 +417,11 @@ export function MessageContent({ ) case 'stopped': return ( -
+
- Stopped by user + + Stopped by user +
) } diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts index 0c7807a576d..a71a8dc30ff 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/utils.ts @@ -23,96 +23,33 @@ import { } from '@/components/emcn' import { Table as TableIcon } from '@/components/emcn/icons' import { AgentIcon } from '@/components/icons' -import type { MothershipToolName, SubagentName } from '@/app/workspace/[workspaceId]/home/types' export type IconComponent = ComponentType> -const TOOL_ICONS: Record = { +const TOOL_ICONS: Record = { mothership: Blimp, - // Workspace glob: FolderCode, grep: Search, read: File, - // Search search_online: Search, scrape_page: Search, get_page_contents: Search, search_library_docs: Library, - crawl_website: Search, - // Execution + manage_mcp_tool: Settings, + manage_skill: Asterisk, + user_memory: Database, function_execute: TerminalWindow, superagent: Blimp, - run_workflow: PlayOutline, - run_block: PlayOutline, - run_from_block: PlayOutline, - run_workflow_until_block: PlayOutline, - complete_job: PlayOutline, - get_execution_summary: ClipboardList, - get_job_logs: ClipboardList, - get_workflow_logs: ClipboardList, - get_workflow_data: Layout, - get_block_outputs: ClipboardList, - get_block_upstream_references: ClipboardList, - get_deployed_workflow_state: Rocket, - check_deployment_status: Rocket, - // Workflows & folders + user_table: TableIcon, + workspace_file: File, create_workflow: Layout, - delete_workflow: Layout, edit_workflow: Pencil, - rename_workflow: Pencil, - move_workflow: Layout, - create_folder: FolderCode, - delete_folder: FolderCode, - move_folder: FolderCode, - list_folders: FolderCode, - list_user_workspaces: Layout, - revert_to_version: Rocket, - get_deployment_version: Rocket, - open_resource: Eye, - // Files - workspace_file: File, - download_to_workspace_file: File, - materialize_file: File, - generate_image: File, - generate_visualization: File, - // Tables & knowledge - user_table: TableIcon, - knowledge_base: Database, - // Jobs - create_job: Calendar, - manage_job: Calendar, - update_job_history: Calendar, - job_respond: Calendar, - // Management - manage_mcp_tool: Settings, - manage_skill: Asterisk, - manage_credential: Integration, - manage_custom_tool: Wrench, - update_workspace_mcp_server: Settings, - delete_workspace_mcp_server: Settings, - create_workspace_mcp_server: Settings, - list_workspace_mcp_servers: Settings, - oauth_get_auth_link: Integration, - oauth_request_access: Integration, - set_environment_variables: Settings, - set_global_workflow_variables: Settings, - get_platform_actions: Settings, - search_documentation: Library, - search_patterns: Search, - deploy_api: Rocket, - deploy_chat: Rocket, - deploy_mcp: Rocket, - redeploy: Rocket, - generate_api_key: Asterisk, - user_memory: Database, - context_write: Pencil, - context_compaction: Asterisk, - // Subagents build: Hammer, run: PlayOutline, deploy: Rocket, auth: Integration, knowledge: Database, + knowledge_base: Database, table: TableIcon, job: Calendar, agent: AgentIcon, @@ -122,6 +59,8 @@ const TOOL_ICONS: Record diff --git a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts index 5fa965d9abe..10a50932999 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/hooks/use-chat.ts @@ -2,51 +2,53 @@ import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import { createLogger } from '@sim/logger' import { useQueryClient } from '@tanstack/react-query' import { usePathname } from 'next/navigation' +import { toDisplayMessage } from '@/lib/copilot/chat/display-message' +import type { + PersistedFileAttachment, + PersistedMessage, +} from '@/lib/copilot/chat/persisted-message' +import { COPILOT_CHAT_API_PATH, MOTHERSHIP_CHAT_API_PATH } from '@/lib/copilot/constants' +import type { MothershipStreamV1EventEnvelope } from '@/lib/copilot/generated/mothership-stream-v1' import { - cancelRunToolExecution, - executeRunToolOnClient, - markRunToolManuallyStopped, - reportManualRunToolStop, -} from '@/lib/copilot/client-sse/run-tool-execution' + MothershipStreamV1EventType, + MothershipStreamV1ResourceOp, + MothershipStreamV1RunKind, + MothershipStreamV1SessionKind, + MothershipStreamV1SpanLifecycleEvent, + MothershipStreamV1SpanPayloadKind, + MothershipStreamV1ToolOutcome, + MothershipStreamV1ToolPhase, +} from '@/lib/copilot/generated/mothership-stream-v1' import { - COPILOT_CHAT_API_PATH, - COPILOT_CHAT_STREAM_API_PATH, - MOTHERSHIP_CHAT_API_PATH, -} from '@/lib/copilot/constants' + DeployApi, + DeployChat, + DeployMcp, + FileWrite, + Read as ReadTool, + Redeploy, + ToolSearchToolRegex, + WorkspaceFile, +} from '@/lib/copilot/generated/tool-catalog-v1' import { extractResourcesFromToolResult, - isEphemeralResource, isResourceToolName, -} from '@/lib/copilot/resource-extraction' -import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resource-types' -import { isWorkflowToolName } from '@/lib/copilot/workflow-tools' +} from '@/lib/copilot/resources/extraction' +import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resources/types' +import { + cancelRunToolExecution, + executeRunToolOnClient, + isRunToolActiveForId, + markRunToolManuallyStopped, + reportManualRunToolStop, +} from '@/lib/copilot/tools/client/run-tool-execution' +import { isWorkflowToolName } from '@/lib/copilot/tools/workflow-tools' import { getNextWorkflowColor } from '@/lib/workflows/colors' import { getQueryClient } from '@/app/_shell/providers/get-query-client' import { invalidateResourceQueries } from '@/app/workspace/[workspaceId]/home/components/mothership-view/components/resource-registry' -import type { - ChatMessage, - ChatMessageAttachment, - ContentBlock, - ContentBlockType, - FileAttachmentForApi, - GenericResourceData, - GenericResourceEntry, - MothershipResource, - MothershipResourceType, - QueuedMessage, - SSEPayload, - SSEPayloadData, - ToolCallStatus, -} from '@/app/workspace/[workspaceId]/home/types' import { deploymentKeys } from '@/hooks/queries/deployments' import { fetchChatHistory, - type StreamSnapshot, type TaskChatHistory, - type TaskStoredContentBlock, - type TaskStoredFileAttachment, - type TaskStoredMessage, - type TaskStoredToolCall, taskKeys, useChatHistory, } from '@/hooks/queries/tasks' @@ -58,9 +60,18 @@ import { workflowKeys } from '@/hooks/queries/workflows' import { useExecutionStream } from '@/hooks/use-execution-stream' import { useExecutionStore } from '@/stores/execution/store' import type { ChatContext } from '@/stores/panel' -import { consolePersistence, useTerminalConsoleStore } from '@/stores/terminal' +import { useTerminalConsoleStore } from '@/stores/terminal' import { useWorkflowRegistry } from '@/stores/workflows/registry/store' import type { WorkflowMetadata } from '@/stores/workflows/registry/types' +import type { + ChatMessage, + ContentBlock, + FileAttachmentForApi, + GenericResourceData, + MothershipResource, + MothershipResourceType, + QueuedMessage, +} from '../types' export interface UseChatReturn { messages: ChatMessage[] @@ -85,226 +96,60 @@ export interface UseChatReturn { sendNow: (id: string) => Promise editQueuedMessage: (id: string) => QueuedMessage | undefined streamingFile: { fileName: string; content: string } | null - genericResourceData: GenericResourceData + genericResourceData: GenericResourceData | null } -const STATE_TO_STATUS: Record = { - success: 'success', - error: 'error', - cancelled: 'cancelled', - rejected: 'error', - skipped: 'success', -} as const - -const DEPLOY_TOOL_NAMES = new Set(['deploy_api', 'deploy_chat', 'deploy_mcp', 'redeploy']) +const DEPLOY_TOOL_NAMES: Set = new Set([ + DeployApi.id, + DeployChat.id, + DeployMcp.id, + Redeploy.id, +]) const RECONNECT_TAIL_ERROR = 'Live reconnect failed before the stream finished. The latest response may be incomplete.' -const TERMINAL_STREAM_STATUSES = new Set(['complete', 'error', 'cancelled']) -const MAX_RECONNECT_ATTEMPTS = 10 -const RECONNECT_BASE_DELAY_MS = 1000 -const RECONNECT_MAX_DELAY_MS = 30_000 - -interface StreamEventEnvelope { - eventId: number - streamId: string - event: Record -} +const RECOVERY_RETRY_DELAYS_MS = [250, 500, 1000, 2000] as const -interface StreamBatchResponse { - success: boolean - events: StreamEventEnvelope[] - status: string -} - -interface StreamTerminationResult { - sawStreamError: boolean - sawDoneEvent: boolean - lastEventId: number -} +const logger = createLogger('useChat') -interface StreamProcessingOptions { - expectedGen?: number - initialLastEventId?: number - preserveExistingState?: boolean -} +type StreamPayload = Record -interface AttachToStreamOptions { - streamId: string - assistantId: string - expectedGen: number - snapshot?: StreamSnapshot | null - initialLastEventId?: number +type StreamToolUI = { + hidden?: boolean + title?: string + phaseLabel?: string + clientExecutable?: boolean } -interface AttachToStreamResult { +type StreamRecoveryResult = { + attached: boolean + hadStreamError: boolean aborted: boolean - error: boolean -} - -interface PendingStreamRecovery { - streamId: string - snapshot?: StreamSnapshot | null -} - -function isTerminalStreamStatus(status?: string | null): boolean { - return Boolean(status && TERMINAL_STREAM_STATUSES.has(status)) -} - -function isActiveStreamConflictError(input: unknown): boolean { - if (typeof input !== 'string') return false - return input.includes('A response is already in progress for this chat') -} - -/** - * Extracts tool call IDs from snapshot events so that replayed client-executable - * tool calls are not re-executed after a page refresh. - */ -function extractToolCallIdsFromSnapshot(snapshot?: StreamSnapshot | null): Set { - const ids = new Set() - if (!snapshot?.events) return ids - for (const entry of snapshot.events) { - const event = entry.event - if (event.type === 'tool_call' && typeof event.toolCallId === 'string') { - ids.add(event.toolCallId) - } - } - return ids } -function buildReplayStream(events: StreamEventEnvelope[]): ReadableStream { - const encoder = new TextEncoder() - return new ReadableStream({ - start(controller) { - if (events.length > 0) { - const payload = events - .map( - (entry) => - `data: ${JSON.stringify({ ...entry.event, eventId: entry.eventId, streamId: entry.streamId })}\n\n` - ) - .join('') - controller.enqueue(encoder.encode(payload)) - } - controller.close() - }, - }) +function asPayloadRecord(value: unknown): StreamPayload | undefined { + return value && typeof value === 'object' ? (value as StreamPayload) : undefined } -function mapStoredBlock(block: TaskStoredContentBlock): ContentBlock { - if (block.type === 'thinking') { - return { - type: 'text', - content: block.content ? `${block.content}` : '', - } - } - - const mapped: ContentBlock = { - type: block.type as ContentBlockType, - content: block.content, - } - - if (block.type === 'tool_call' && block.toolCall) { - const resolvedStatus = STATE_TO_STATUS[block.toolCall.state ?? ''] ?? 'error' - mapped.toolCall = { - id: block.toolCall.id ?? '', - name: block.toolCall.name ?? 'unknown', - status: resolvedStatus, - displayTitle: - resolvedStatus === 'cancelled' ? 'Stopped by user' : block.toolCall.display?.text, - params: block.toolCall.params, - calledBy: block.toolCall.calledBy, - result: block.toolCall.result, - } - } - - return mapped +function getPayloadData(event: MothershipStreamV1EventEnvelope): StreamPayload { + return asPayloadRecord(event.payload) ?? {} } -function mapStoredToolCall(tc: TaskStoredToolCall): ContentBlock { - const resolvedStatus = (STATE_TO_STATUS[tc.status] ?? 'error') as ToolCallStatus - return { - type: 'tool_call', - toolCall: { - id: tc.id, - name: tc.name, - status: resolvedStatus, - displayTitle: resolvedStatus === 'cancelled' ? 'Stopped by user' : undefined, - params: tc.params, - result: - tc.result != null - ? { - success: tc.status === 'success', - output: tc.result, - error: tc.error, - } - : undefined, - }, +function getToolUI(payload: StreamPayload): StreamToolUI | undefined { + const raw = asPayloadRecord(payload.ui) + if (!raw) { + return undefined } -} -function toDisplayAttachment(f: TaskStoredFileAttachment): ChatMessageAttachment { return { - id: f.id, - filename: f.filename, - media_type: f.media_type, - size: f.size, - previewUrl: f.media_type.startsWith('image/') - ? `/api/files/serve/${encodeURIComponent(f.key)}?context=mothership` - : undefined, + ...(typeof raw.hidden === 'boolean' ? { hidden: raw.hidden } : {}), + ...(typeof raw.title === 'string' ? { title: raw.title } : {}), + ...(typeof raw.phaseLabel === 'string' ? { phaseLabel: raw.phaseLabel } : {}), + ...(typeof raw.clientExecutable === 'boolean' + ? { clientExecutable: raw.clientExecutable } + : {}), } } -function mapStoredMessage(msg: TaskStoredMessage): ChatMessage { - const mapped: ChatMessage = { - id: msg.id, - role: msg.role, - content: msg.content, - ...(msg.requestId ? { requestId: msg.requestId } : {}), - } - - const hasContentBlocks = Array.isArray(msg.contentBlocks) && msg.contentBlocks.length > 0 - const hasToolCalls = Array.isArray(msg.toolCalls) && msg.toolCalls.length > 0 - const contentBlocksHaveTools = - hasContentBlocks && msg.contentBlocks!.some((b) => b.type === 'tool_call') - - if (hasContentBlocks && (!hasToolCalls || contentBlocksHaveTools)) { - const blocks = msg.contentBlocks!.map(mapStoredBlock) - const hasText = blocks.some((b) => b.type === 'text' && b.content?.trim()) - if (!hasText && msg.content?.trim()) { - blocks.push({ type: 'text', content: msg.content }) - } - mapped.contentBlocks = blocks - } else if (hasToolCalls) { - const blocks: ContentBlock[] = msg.toolCalls!.map(mapStoredToolCall) - if (msg.content?.trim()) { - blocks.push({ type: 'text', content: msg.content }) - } - mapped.contentBlocks = blocks - } - - if (Array.isArray(msg.fileAttachments) && msg.fileAttachments.length > 0) { - mapped.attachments = msg.fileAttachments.map(toDisplayAttachment) - } - - if (Array.isArray(msg.contexts) && msg.contexts.length > 0) { - mapped.contexts = msg.contexts.map((c) => ({ - kind: c.kind, - label: c.label, - ...(c.workflowId && { workflowId: c.workflowId }), - ...(c.knowledgeId && { knowledgeId: c.knowledgeId }), - ...(c.tableId && { tableId: c.tableId }), - ...(c.fileId && { fileId: c.fileId }), - })) - } - - return mapped -} - -const logger = createLogger('useChat') - -function getPayloadData(payload: SSEPayload): SSEPayloadData | undefined { - return typeof payload.data === 'object' ? payload.data : undefined -} - /** Adds a workflow to the React Query cache with a top-insertion sort order if it doesn't already exist. */ function ensureWorkflowInRegistry(resourceId: string, title: string, workspaceId: string): boolean { const workflows = getWorkflows(workspaceId) @@ -416,6 +261,7 @@ export function useChat( const [resolvedChatId, setResolvedChatId] = useState(initialChatId) const [resources, setResources] = useState([]) const [activeResourceId, setActiveResourceId] = useState(null) + const [genericResourceData, setGenericResourceData] = useState(null) const onResourceEventRef = useRef(options?.onResourceEvent) onResourceEventRef.current = options?.onResourceEvent const apiPathRef = useRef(options?.apiPath ?? MOTHERSHIP_CHAT_API_PATH) @@ -452,39 +298,29 @@ export function useChat( const streamingFileRef = useRef(streamingFile) streamingFileRef.current = streamingFile - const [genericResourceData, setGenericResourceData] = useState({ - entries: [], - }) - const genericResourceDataRef = useRef({ entries: [] }) - const [messageQueue, setMessageQueue] = useState([]) const messageQueueRef = useRef([]) messageQueueRef.current = messageQueue - const [pendingRecoveryMessage, setPendingRecoveryMessage] = useState(null) - const pendingRecoveryMessageRef = useRef(null) - pendingRecoveryMessageRef.current = pendingRecoveryMessage const sendMessageRef = useRef(async () => {}) const processSSEStreamRef = useRef< ( reader: ReadableStreamDefaultReader, assistantId: string, - options?: StreamProcessingOptions - ) => Promise - >(async () => ({ - sawStreamError: false, - sawDoneEvent: false, - lastEventId: 0, - })) - const finalizeRef = useRef<(options?: { error?: boolean }) => void>(() => {}) - const retryReconnectRef = useRef< - (opts: { - streamId: string + expectedGen?: number, + options?: { preserveExistingState?: boolean } + ) => Promise<{ sawStreamError: boolean; sawComplete: boolean }> + >(async () => ({ sawStreamError: false, sawComplete: false })) + const reattachToStreamRef = useRef< + (params: { assistantId: string - gen: number - initialSnapshot?: StreamSnapshot | null - }) => Promise - >(async () => false) + expectedGen: number + abortController: AbortController + preferredStreamId?: string + chatId?: string + }) => Promise + >(async () => ({ attached: false, hadStreamError: false, aborted: true })) + const finalizeRef = useRef<(options?: { error?: boolean }) => void>(() => {}) const abortControllerRef = useRef(null) const streamReaderRef = useRef | null>(null) @@ -495,7 +331,7 @@ export function useChat( const appliedChatIdRef = useRef(undefined) const pendingUserMsgRef = useRef<{ id: string; content: string } | null>(null) const streamIdRef = useRef(undefined) - const lastEventIdRef = useRef(0) + const lastCursorRef = useRef('0') const sendingRef = useRef(false) const streamGenRef = useRef(0) const streamingContentRef = useRef('') @@ -518,7 +354,7 @@ export function useChat( }) setActiveResourceId(resource.id) - if (isEphemeralResource(resource)) { + if (resource.id === 'streaming-file') { return true } @@ -543,6 +379,56 @@ export function useChat( setResources(newOrder) }, []) + const startClientWorkflowTool = useCallback( + (toolCallId: string, toolName: string, toolArgs: Record) => { + if (!isWorkflowToolName(toolName)) { + return + } + if (clientExecutionStartedRef.current.has(toolCallId) && isRunToolActiveForId(toolCallId)) { + return + } + clientExecutionStartedRef.current.add(toolCallId) + + const targetWorkflowId = + typeof toolArgs.workflowId === 'string' + ? toolArgs.workflowId + : useWorkflowRegistry.getState().activeWorkflowId + if (targetWorkflowId) { + const meta = getWorkflowById(workspaceId, targetWorkflowId) + const wasAdded = addResource({ + type: 'workflow', + id: targetWorkflowId, + title: meta?.name ?? 'Workflow', + }) + if (!wasAdded && activeResourceIdRef.current !== targetWorkflowId) { + setActiveResourceId(targetWorkflowId) + } + onResourceEventRef.current?.() + } + + executeRunToolOnClient(toolCallId, toolName, toolArgs) + }, + [addResource, workspaceId] + ) + + const recoverPendingClientWorkflowTools = useCallback( + (nextMessages: ChatMessage[]) => { + for (const message of nextMessages) { + for (const block of message.contentBlocks ?? []) { + const toolCall = block.toolCall + if (!toolCall || !isWorkflowToolName(toolCall.name)) { + continue + } + if (toolCall.status !== 'executing') { + continue + } + startClientWorkflowTool(toolCall.id, toolCall.name, toolCall.params ?? {}) + } + } + }, + [startClientWorkflowTool] + ) + useEffect(() => { if (sendingRef.current) { const streamOwnerId = chatIdRef.current @@ -558,10 +444,6 @@ export function useChat( abortControllerRef.current = null sendingRef.current = false setIsSending(false) - setIsReconnecting(false) - lastEventIdRef.current = 0 - pendingRecoveryMessageRef.current = null - setPendingRecoveryMessage(null) if (abandonedChatId) { queryClient.invalidateQueries({ queryKey: taskKeys.detail(abandonedChatId) }) } @@ -572,6 +454,7 @@ export function useChat( } } chatIdRef.current = initialChatId + lastCursorRef.current = '0' setResolvedChatId(initialChatId) appliedChatIdRef.current = undefined setMessages([]) @@ -582,13 +465,7 @@ export function useChat( setActiveResourceId(null) setStreamingFile(null) streamingFileRef.current = null - genericResourceDataRef.current = { entries: [] } - setGenericResourceData({ entries: [] }) setMessageQueue([]) - lastEventIdRef.current = 0 - clientExecutionStartedRef.current.clear() - pendingRecoveryMessageRef.current = null - setPendingRecoveryMessage(null) }, [initialChatId, queryClient]) useEffect(() => { @@ -596,6 +473,7 @@ export function useChat( if (!isHomePage || !chatIdRef.current) return streamGenRef.current++ chatIdRef.current = undefined + lastCursorRef.current = '0' setResolvedChatId(undefined) appliedChatIdRef.current = undefined abortControllerRef.current = null @@ -608,286 +486,62 @@ export function useChat( setActiveResourceId(null) setStreamingFile(null) streamingFileRef.current = null - genericResourceDataRef.current = { entries: [] } - setGenericResourceData({ entries: [] }) setMessageQueue([]) - lastEventIdRef.current = 0 - clientExecutionStartedRef.current.clear() - pendingRecoveryMessageRef.current = null - setPendingRecoveryMessage(null) }, [isHomePage]) - const fetchStreamBatch = useCallback( - async ( - streamId: string, - fromEventId: number, - signal?: AbortSignal - ): Promise => { - const response = await fetch( - `${COPILOT_CHAT_STREAM_API_PATH}?streamId=${encodeURIComponent(streamId)}&from=${fromEventId}&batch=true`, - { signal } - ) - - if (!response.ok) { - throw new Error(`Stream resume batch failed: ${response.status}`) - } - - return response.json() - }, - [] - ) - - const attachToExistingStream = useCallback( - async ({ - streamId, - assistantId, - expectedGen, - snapshot, - initialLastEventId = 0, - }: AttachToStreamOptions): Promise => { - let latestEventId = initialLastEventId - let seedEvents = snapshot?.events ?? [] - let streamStatus = snapshot?.status ?? 'unknown' - let attachAttempt = 0 - - setIsSending(true) - setIsReconnecting(true) - setError(null) - - logger.info('Attaching to existing stream', { - streamId, - expectedGen, - initialLastEventId, - seedEventCount: seedEvents.length, - streamStatus, - }) - - try { - while (streamGenRef.current === expectedGen) { - if (seedEvents.length > 0) { - const replayResult = await processSSEStreamRef.current( - buildReplayStream(seedEvents).getReader(), - assistantId, - { - expectedGen, - initialLastEventId: latestEventId, - preserveExistingState: true, - } - ) - latestEventId = Math.max( - replayResult.lastEventId, - seedEvents[seedEvents.length - 1]?.eventId ?? latestEventId - ) - lastEventIdRef.current = latestEventId - seedEvents = [] - - if (replayResult.sawStreamError) { - logger.warn('Replay stream ended with error event', { streamId, latestEventId }) - return { aborted: false, error: true } - } - } - - if (isTerminalStreamStatus(streamStatus)) { - logger.info('Existing stream already reached terminal status', { - streamId, - latestEventId, - streamStatus, - }) - if (streamStatus === 'error') { - setError(RECONNECT_TAIL_ERROR) - } - return { aborted: false, error: streamStatus === 'error' } - } - - const activeAbortController = abortControllerRef.current - if (!activeAbortController) { - return { aborted: true, error: false } - } - - logger.info('Opening live stream tail', { - streamId, - fromEventId: latestEventId, - attempt: attachAttempt, - }) - - const sseRes = await fetch( - `${COPILOT_CHAT_STREAM_API_PATH}?streamId=${encodeURIComponent(streamId)}&from=${latestEventId}`, - { signal: activeAbortController.signal } - ) - if (!sseRes.ok || !sseRes.body) { - throw new Error(RECONNECT_TAIL_ERROR) - } - - setIsReconnecting(false) - - const liveResult = await processSSEStreamRef.current( - sseRes.body.getReader(), - assistantId, - { - expectedGen, - initialLastEventId: latestEventId, - preserveExistingState: true, - } - ) - latestEventId = Math.max(latestEventId, liveResult.lastEventId) - lastEventIdRef.current = latestEventId - - if (liveResult.sawStreamError) { - logger.warn('Live stream tail ended with error event', { streamId, latestEventId }) - return { aborted: false, error: true } - } - - attachAttempt += 1 - setIsReconnecting(true) - - logger.warn('Live stream ended without terminal event, fetching replay batch', { - streamId, - latestEventId, - attempt: attachAttempt, - }) - - const batch = await fetchStreamBatch( - streamId, - latestEventId, - activeAbortController.signal - ) - seedEvents = batch.events - streamStatus = batch.status - - if (batch.events.length > 0) { - latestEventId = batch.events[batch.events.length - 1].eventId - lastEventIdRef.current = latestEventId - } - - logger.info('Fetched replay batch after non-terminal stream close', { - streamId, - latestEventId, - streamStatus, - eventCount: batch.events.length, - attempt: attachAttempt, - }) - - if (batch.events.length === 0 && !isTerminalStreamStatus(batch.status)) { - logger.info('No new replay events yet; reopening active stream tail', { - streamId, - latestEventId, - streamStatus, - attempt: attachAttempt, - }) - if (activeAbortController.signal.aborted || streamGenRef.current !== expectedGen) { - return { aborted: true, error: false } - } - } - } + useEffect(() => { + if (!chatHistory || appliedChatIdRef.current === chatHistory.id) return - return { aborted: true, error: false } - } catch (err) { - if (err instanceof Error && err.name === 'AbortError') { - return { aborted: true, error: false } + const activeStreamId = chatHistory.activeStreamId + appliedChatIdRef.current = chatHistory.id + const mappedMessages = chatHistory.messages.map(toDisplayMessage) + const shouldPreserveActiveStreamingMessage = + sendingRef.current && Boolean(activeStreamId) && activeStreamId === streamIdRef.current + + if (shouldPreserveActiveStreamingMessage) { + setMessages((prev) => { + const localStreamingAssistant = prev[prev.length - 1] + if (localStreamingAssistant?.role !== 'assistant') { + return mappedMessages } - logger.error('Failed to attach to existing stream, will throw for outer retry', { - streamId, - latestEventId, - error: err instanceof Error ? err.message : String(err), - }) - throw err - } finally { - setIsReconnecting(false) - } - }, - [fetchStreamBatch] - ) - - const applyChatHistorySnapshot = useCallback( - (history: TaskChatHistory, options?: { preserveActiveStreamingMessage?: boolean }) => { - const preserveActiveStreamingMessage = options?.preserveActiveStreamingMessage ?? false - const activeStreamId = history.activeStreamId - appliedChatIdRef.current = history.id - - const mappedMessages = history.messages.map(mapStoredMessage) - const shouldPreserveActiveStreamingMessage = - preserveActiveStreamingMessage && - sendingRef.current && - Boolean(activeStreamId) && - activeStreamId === streamIdRef.current - - if (shouldPreserveActiveStreamingMessage) { - setMessages((prev) => { - const localStreamingAssistant = prev[prev.length - 1] - if (localStreamingAssistant?.role !== 'assistant') { - return mappedMessages - } - - const nextMessages = - mappedMessages[mappedMessages.length - 1]?.role === 'assistant' - ? mappedMessages.slice(0, -1) - : mappedMessages - - return [...nextMessages, localStreamingAssistant] - }) - } else { - setMessages(mappedMessages) - } - - if (history.resources.some((r) => r.id === 'streaming-file')) { - fetch('/api/copilot/chat/resources', { - method: 'DELETE', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ - chatId: history.id, - resourceType: 'file', - resourceId: 'streaming-file', - }), - }).catch(() => {}) - } + const nextMessages = + mappedMessages[mappedMessages.length - 1]?.role === 'assistant' + ? mappedMessages.slice(0, -1) + : mappedMessages - const persistedResources = history.resources.filter((r) => r.id !== 'streaming-file') - if (persistedResources.length > 0) { - setResources(persistedResources) - setActiveResourceId(persistedResources[persistedResources.length - 1].id) + return [...nextMessages, localStreamingAssistant] + }) + } else { + setMessages(mappedMessages) + } - for (const resource of persistedResources) { - if (resource.type !== 'workflow') continue - ensureWorkflowInRegistry(resource.id, resource.title, workspaceId) - } - } else if (history.resources.some((r) => r.id === 'streaming-file')) { - setResources([]) - setActiveResourceId(null) - } - }, - [workspaceId] - ) + recoverPendingClientWorkflowTools(mappedMessages) - const preparePendingStreamRecovery = useCallback( - async (chatId: string): Promise => { - const latestHistory = await fetchChatHistory(chatId) - queryClient.setQueryData(taskKeys.detail(chatId), latestHistory) - applyChatHistorySnapshot(latestHistory) + if (chatHistory.resources.some((r) => r.id === 'streaming-file')) { + fetch('/api/copilot/chat/resources', { + method: 'DELETE', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + chatId: chatHistory.id, + resourceType: 'file', + resourceId: 'streaming-file', + }), + }).catch(() => {}) + } - if (!latestHistory.activeStreamId) { - return null - } + const persistedResources = chatHistory.resources.filter((r) => r.id !== 'streaming-file') + if (persistedResources.length > 0) { + setResources(persistedResources) + setActiveResourceId(persistedResources[persistedResources.length - 1].id) - return { - streamId: latestHistory.activeStreamId, - snapshot: latestHistory.streamSnapshot, + for (const resource of persistedResources) { + if (resource.type !== 'workflow') continue + ensureWorkflowInRegistry(resource.id, resource.title, workspaceId) } - }, - [applyChatHistorySnapshot, queryClient] - ) - - useEffect(() => { - if (!chatHistory) return - - const activeStreamId = chatHistory.activeStreamId - const snapshot = chatHistory.streamSnapshot - const isNewChat = appliedChatIdRef.current !== chatHistory.id - - if (isNewChat) { - applyChatHistorySnapshot(chatHistory, { preserveActiveStreamingMessage: true }) - } else if (!activeStreamId || sendingRef.current) { - return + } else if (chatHistory.resources.some((r) => r.id === 'streaming-file')) { + setResources([]) + setActiveResourceId(null) } if (activeStreamId && !sendingRef.current) { @@ -895,80 +549,82 @@ export function useChat( const abortController = new AbortController() abortControllerRef.current = abortController streamIdRef.current = activeStreamId - lastEventIdRef.current = snapshot?.events?.[snapshot.events.length - 1]?.eventId ?? 0 sendingRef.current = true - streamingContentRef.current = '' - streamingBlocksRef.current = [] - clientExecutionStartedRef.current = extractToolCallIdsFromSnapshot(snapshot) const assistantId = crypto.randomUUID() const reconnect = async () => { - const succeeded = await retryReconnectRef.current({ - streamId: activeStreamId, + const recovery = await reattachToStreamRef.current({ assistantId, - gen, - initialSnapshot: snapshot, + expectedGen: gen, + abortController, + preferredStreamId: activeStreamId, + chatId: chatHistory.id, }) - if (!succeeded && streamGenRef.current === gen) { - try { - finalizeRef.current({ error: true }) - } catch { - sendingRef.current = false - setIsSending(false) - setIsReconnecting(false) - abortControllerRef.current = null - setError('Failed to reconnect to the active stream') - } + if (recovery.aborted) { + return + } + if (streamGenRef.current === gen) { + finalizeRef.current( + recovery.attached && !recovery.hadStreamError ? undefined : { error: true } + ) } } reconnect() } - }, [applyChatHistorySnapshot, chatHistory, queryClient]) + }, [chatHistory, workspaceId, queryClient, recoverPendingClientWorkflowTools]) const processSSEStream = useCallback( async ( reader: ReadableStreamDefaultReader, assistantId: string, - options?: StreamProcessingOptions + expectedGen?: number, + options?: { preserveExistingState?: boolean } ) => { - const { expectedGen, initialLastEventId = 0, preserveExistingState = false } = options ?? {} const decoder = new TextDecoder() streamReaderRef.current = reader let buffer = '' - const blocks: ContentBlock[] = preserveExistingState ? [...streamingBlocksRef.current] : [] + + const preserveState = options?.preserveExistingState === true + const blocks: ContentBlock[] = preserveState ? [...streamingBlocksRef.current] : [] const toolMap = new Map() const toolArgsMap = new Map>() - // Maps toolCallId → index in genericResourceDataRef.current.entries for fast lookup - const genericEntryMap = new Map() - if (preserveExistingState) { - for (const [idx, entry] of genericResourceDataRef.current.entries.entries()) { - genericEntryMap.set(entry.toolCallId, idx) + + if (preserveState) { + for (let i = 0; i < blocks.length; i++) { + const tc = blocks[i].toolCall + if (tc) { + toolMap.set(tc.id, i) + if (tc.params) toolArgsMap.set(tc.id, tc.params) + } } } - const clientExecutionStarted = clientExecutionStartedRef.current + let activeSubagent: string | undefined + let activeSubagentParentToolCallId: string | undefined let activeCompactionId: string | undefined - let runningText = preserveExistingState ? streamingContentRef.current : '' + + if (preserveState) { + for (let i = blocks.length - 1; i >= 0; i--) { + if (blocks[i].type === 'subagent' && blocks[i].content) { + activeSubagent = blocks[i].content + break + } + if (blocks[i].type === 'subagent_end') { + break + } + } + } + + let runningText = preserveState ? streamingContentRef.current || '' : '' let lastContentSource: 'main' | 'subagent' | null = null let streamRequestId: string | undefined - let lastEventId = initialLastEventId - let sawDoneEvent = false - if (!preserveExistingState) { + if (!preserveState) { streamingContentRef.current = '' streamingBlocksRef.current = [] } - for (const [index, block] of blocks.entries()) { - if (block.type === 'tool_call' && block.toolCall?.id) { - toolMap.set(block.toolCall.id, index) - if (block.toolCall.params) { - toolArgsMap.set(block.toolCall.id, block.toolCall.params) - } - } - } - const ensureTextBlock = (): ContentBlock => { const last = blocks[blocks.length - 1] if (last?.type === 'text' && last.subagent === activeSubagent) return last @@ -988,14 +644,15 @@ export function useChat( flush() } - const buildInlineErrorTag = (payload: SSEPayload) => { - const data = getPayloadData(payload) as Record | undefined + const buildInlineErrorTag = (event: MothershipStreamV1EventEnvelope) => { + const data = getPayloadData(event) const message = - (data?.displayMessage as string | undefined) || - payload.error || + (typeof data.displayMessage === 'string' ? data.displayMessage : undefined) || + (typeof data.message === 'string' ? data.message : undefined) || + (typeof data.error === 'string' ? data.error : undefined) || 'An unexpected error occurred' - const provider = (data?.provider as string | undefined) || undefined - const code = (data?.code as string | undefined) || undefined + const provider = typeof data.provider === 'string' ? data.provider : undefined + const code = typeof data.code === 'string' ? data.code : undefined return `${JSON.stringify({ message, ...(code ? { code } : {}), @@ -1005,6 +662,7 @@ export function useChat( const isStale = () => expectedGen !== undefined && streamGenRef.current !== expectedGen let sawStreamError = false + let sawCompleteEvent = false const flush = () => { if (isStale()) return @@ -1027,23 +685,6 @@ export function useChat( }) } - const appendGenericEntry = (entry: GenericResourceEntry): number => { - const entries = [...genericResourceDataRef.current.entries, entry] - genericResourceDataRef.current.entries = entries - setGenericResourceData({ entries }) - return entries.length - 1 - } - - const updateGenericEntry = ( - entryIdx: number, - changes: Partial - ): void => { - const entries = genericResourceDataRef.current.entries.slice() - entries[entryIdx] = { ...entries[entryIdx], ...changes } - genericResourceDataRef.current.entries = entries - setGenericResourceData({ entries }) - } - try { while (true) { const { done, value } = await reader.read() @@ -1059,34 +700,47 @@ export function useChat( if (!line.startsWith('data: ')) continue const raw = line.slice(6) - let parsed: SSEPayload + let parsed: MothershipStreamV1EventEnvelope try { parsed = JSON.parse(raw) } catch { continue } - if (typeof (parsed as SSEPayload & { eventId?: unknown }).eventId === 'number') { - lastEventId = Math.max( - lastEventId, - (parsed as SSEPayload & { eventId: number }).eventId - ) - lastEventIdRef.current = lastEventId + if (parsed.trace?.requestId && parsed.trace.requestId !== streamRequestId) { + streamRequestId = parsed.trace.requestId + flush() + } + if (parsed.stream?.streamId) { + streamIdRef.current = parsed.stream.streamId + } + if (parsed.stream?.cursor) { + lastCursorRef.current = parsed.stream.cursor + } else if (typeof parsed.seq === 'number') { + lastCursorRef.current = String(parsed.seq) } logger.debug('SSE event received', parsed) switch (parsed.type) { - case 'chat_id': { - if (parsed.chatId) { + case MothershipStreamV1EventType.session: { + const payload = getPayloadData(parsed) + const kind = typeof payload.kind === 'string' ? payload.kind : '' + const payloadChatId = + typeof payload.chatId === 'string' + ? payload.chatId + : typeof parsed.stream?.chatId === 'string' + ? parsed.stream.chatId + : undefined + if (kind === MothershipStreamV1SessionKind.chat && payloadChatId) { const isNewChat = !chatIdRef.current - chatIdRef.current = parsed.chatId + chatIdRef.current = payloadChatId const selected = selectedChatIdRef.current if (selected == null) { if (isNewChat) { - setResolvedChatId(parsed.chatId) + setResolvedChatId(payloadChatId) } - } else if (parsed.chatId === selected) { - setResolvedChatId(parsed.chatId) + } else if (payloadChatId === selected) { + setResolvedChatId(payloadChatId) } queryClient.invalidateQueries({ queryKey: taskKeys.list(workspaceId), @@ -1095,14 +749,15 @@ export function useChat( const userMsg = pendingUserMsgRef.current const activeStreamId = streamIdRef.current if (userMsg && activeStreamId) { - queryClient.setQueryData(taskKeys.detail(parsed.chatId), { - id: parsed.chatId, + queryClient.setQueryData(taskKeys.detail(payloadChatId), { + id: payloadChatId, title: null, messages: [ { id: userMsg.id, role: 'user', content: userMsg.content, + timestamp: new Date().toISOString(), }, ], activeStreamId, @@ -1113,23 +768,22 @@ export function useChat( window.history.replaceState( null, '', - `/workspace/${workspaceId}/task/${parsed.chatId}` + `/workspace/${workspaceId}/task/${payloadChatId}` ) } } } - break - } - case 'request_id': { - const rid = typeof parsed.data === 'string' ? parsed.data : undefined - if (rid) { - streamRequestId = rid - flush() + if (kind === MothershipStreamV1SessionKind.title) { + queryClient.invalidateQueries({ + queryKey: taskKeys.list(workspaceId), + }) + onTitleUpdateRef.current?.() } break } - case 'content': { - const chunk = typeof parsed.data === 'string' ? parsed.data : (parsed.content ?? '') + case MothershipStreamV1EventType.text: { + const payload = getPayloadData(parsed) + const chunk = typeof payload.text === 'string' ? payload.text : '' if (chunk) { const contentSource: 'main' | 'subagent' = activeSubagent ? 'subagent' : 'main' const needsBoundaryNewline = @@ -1148,251 +802,131 @@ export function useChat( } break } - case 'reasoning': { - const d = ( - parsed.data && typeof parsed.data === 'object' ? parsed.data : {} - ) as Record - const phase = d.phase as string | undefined - if (phase === 'start') { - const tb = ensureTextBlock() - tb.content = `${tb.content ?? ''}` - runningText += '' - streamingContentRef.current = runningText - flush() - } else if (phase === 'end') { - const tb = ensureTextBlock() - tb.content = `${tb.content ?? ''}` - runningText += '' - streamingContentRef.current = runningText - flush() - } else { - const chunk = - typeof d.data === 'string' ? d.data : (parsed.content as string | undefined) - if (chunk) { - const tb = ensureTextBlock() - tb.content = (tb.content ?? '') + chunk - runningText += chunk - streamingContentRef.current = runningText - flush() - } - } - break - } - case 'tool_generating': - case 'tool_call': { - const id = parsed.toolCallId - const data = getPayloadData(parsed) - const name = parsed.toolName || data?.name || 'unknown' - const isPartial = data?.partial === true + case MothershipStreamV1EventType.tool: { + const payload = getPayloadData(parsed) + const phase = + typeof payload.phase === 'string' + ? payload.phase + : MothershipStreamV1ToolPhase.call + const id = + typeof payload.toolCallId === 'string' + ? payload.toolCallId + : typeof payload.id === 'string' + ? payload.id + : undefined if (!id) break - if (name === 'tool_search_tool_regex') { - break - } - const ui = parsed.ui || data?.ui - if (ui?.hidden) break - const displayTitle = ui?.title || ui?.phaseLabel - const phaseLabel = ui?.phaseLabel - const args = (data?.arguments ?? data?.input) as Record | undefined - if (!toolMap.has(id)) { - toolMap.set(id, blocks.length) - blocks.push({ - type: 'tool_call', - toolCall: { - id, - name, - status: 'executing', - displayTitle, - phaseLabel, - params: args, - calledBy: activeSubagent, - }, - }) - if (name === 'read' || isResourceToolName(name)) { - if (args) toolArgsMap.set(id, args) - } - } else { - const idx = toolMap.get(id)! - const tc = blocks[idx].toolCall - if (tc) { - tc.name = name - if (displayTitle) tc.displayTitle = displayTitle - if (phaseLabel) tc.phaseLabel = phaseLabel - if (args) tc.params = args - } - } - flush() - - // TODO: Uncomment when rich UI for Results tab is ready - // if (shouldOpenGenericResource(name)) { - // if (!genericEntryMap.has(id)) { - // const entryIdx = appendGenericEntry({ - // toolCallId: id, - // toolName: name, - // displayTitle: displayTitle ?? name, - // status: 'executing', - // params: args, - // }) - // genericEntryMap.set(id, entryIdx) - // const opened = addResource({ type: 'generic', id: 'results', title: 'Results' }) - // if (opened) onResourceEventRef.current?.() - // else setActiveResourceId('results') - // } else { - // const entryIdx = genericEntryMap.get(id) - // if (entryIdx !== undefined) { - // updateGenericEntry(entryIdx, { - // toolName: name, - // ...(displayTitle && { displayTitle }), - // ...(args && { params: args }), - // }) - // } - // } - // } - - if ( - parsed.type === 'tool_call' && - ui?.clientExecutable && - isWorkflowToolName(name) && - !isPartial && - !clientExecutionStarted.has(id) - ) { - clientExecutionStarted.add(id) - const args = data?.arguments ?? data?.input ?? {} - const targetWorkflowId = - typeof (args as Record).workflowId === 'string' - ? ((args as Record).workflowId as string) - : useWorkflowRegistry.getState().activeWorkflowId - if (targetWorkflowId) { - const meta = getWorkflowById(workspaceId, targetWorkflowId) - const wasAdded = addResource({ - type: 'workflow', - id: targetWorkflowId, - title: meta?.name ?? 'Workflow', - }) - if (!wasAdded && activeResourceIdRef.current !== targetWorkflowId) { - setActiveResourceId(targetWorkflowId) + if (phase === MothershipStreamV1ToolPhase.args_delta) { + const delta = + typeof payload.argumentsDelta === 'string' ? payload.argumentsDelta : '' + if (!delta) break + + const toolName = + typeof payload.toolName === 'string' + ? payload.toolName + : (blocks[toolMap.get(id) ?? -1]?.toolCall?.name ?? '') + const streamWorkspaceFile = + activeSubagent === FileWrite.id || toolName === WorkspaceFile.id + + if (streamWorkspaceFile) { + let prev = streamingFileRef.current + if (!prev) { + prev = { fileName: '', content: '' } + streamingFileRef.current = prev + setStreamingFile(prev) } - onResourceEventRef.current?.() - } - executeRunToolOnClient(id, name, args as Record) - } - break - } - case 'tool_call_delta': { - const id = parsed.toolCallId - const delta = typeof parsed.data === 'string' ? parsed.data : '' - if (!id || !delta) break - - const toolName = typeof parsed.toolName === 'string' ? parsed.toolName : '' - const streamWorkspaceFile = - activeSubagent === 'file_write' || toolName === 'workspace_file' - - if (streamWorkspaceFile) { - let prev = streamingFileRef.current - if (!prev) { - prev = { fileName: '', content: '' } - streamingFileRef.current = prev - setStreamingFile(prev) - } - const raw = prev.content + delta - let fileName = prev.fileName - if (!fileName) { - const m = raw.match(/"fileName"\s*:\s*"([^"]+)"/) - if (m) { - fileName = m[1] + const raw = prev.content + delta + let fileName = prev.fileName + if (!fileName) { + const match = raw.match(/"fileName"\s*:\s*"([^"]+)"/) + if (match) { + fileName = match[1] + } } - } - const fileIdMatch = raw.match(/"fileId"\s*:\s*"([^"]+)"/) - const matchedResourceId = fileIdMatch?.[1] - if ( - matchedResourceId && - resourcesRef.current.some( - (resource) => resource.type === 'file' && resource.id === matchedResourceId - ) - ) { - setActiveResourceId(matchedResourceId) - setResources((rs) => rs.filter((resource) => resource.id !== 'streaming-file')) - } else if (fileName || fileIdMatch) { - const hasStreamingResource = resourcesRef.current.some( - (resource) => resource.id === 'streaming-file' - ) - if (!hasStreamingResource) { - addResource({ - type: 'file', - id: 'streaming-file', - title: fileName || 'Writing file...', - }) - } else if (fileName) { + const fileIdMatch = raw.match(/"fileId"\s*:\s*"([^"]+)"/) + const matchedResourceId = fileIdMatch?.[1] + if ( + matchedResourceId && + resourcesRef.current.some( + (resource) => resource.type === 'file' && resource.id === matchedResourceId + ) + ) { + setActiveResourceId(matchedResourceId) setResources((rs) => - rs.map((resource) => - resource.id === 'streaming-file' - ? { ...resource, title: fileName } - : resource - ) + rs.filter((resource) => resource.id !== 'streaming-file') ) + } else if (fileName || fileIdMatch) { + const hasStreamingResource = resourcesRef.current.some( + (resource) => resource.id === 'streaming-file' + ) + if (!hasStreamingResource) { + addResource({ + type: 'file', + id: 'streaming-file', + title: fileName || 'Writing file...', + }) + } else if (fileName) { + setResources((rs) => + rs.map((resource) => + resource.id === 'streaming-file' + ? { ...resource, title: fileName } + : resource + ) + ) + } } + const next = { fileName, content: raw } + streamingFileRef.current = next + setStreamingFile(next) } - const next = { fileName, content: raw } - streamingFileRef.current = next - setStreamingFile(next) - } - const idx = toolMap.get(id) - if (idx !== undefined && blocks[idx].toolCall) { - const tc = blocks[idx].toolCall! - tc.streamingArgs = (tc.streamingArgs ?? '') + delta - flush() + const idx = toolMap.get(id) + if (idx !== undefined && blocks[idx].toolCall) { + const tc = blocks[idx].toolCall! + tc.streamingArgs = (tc.streamingArgs ?? '') + delta + flush() + } + break } - // TODO: Uncomment when rich UI for Results tab is ready - // if (toolName && shouldOpenGenericResource(toolName)) { - // const entryIdx = genericEntryMap.get(id) - // if (entryIdx !== undefined) { - // const entry = genericResourceDataRef.current.entries[entryIdx] - // if (entry) { - // updateGenericEntry(entryIdx, { - // streamingArgs: (entry.streamingArgs ?? '') + delta, - // }) - // } - // } - // } - - break - } - case 'tool_result': { - const id = parsed.toolCallId || getPayloadData(parsed)?.id - if (!id) break - const idx = toolMap.get(id) - if (idx !== undefined && blocks[idx].toolCall) { + if (phase === MothershipStreamV1ToolPhase.result) { + const idx = toolMap.get(id) + if (idx === undefined || !blocks[idx].toolCall) { + break + } const tc = blocks[idx].toolCall! - - const payloadData = getPayloadData(parsed) - const resultObj = - parsed.result && typeof parsed.result === 'object' - ? (parsed.result as Record) - : undefined + const resultObj = asPayloadRecord(payload.result) + const success = + typeof payload.success === 'boolean' + ? payload.success + : payload.status === MothershipStreamV1ToolOutcome.success const isCancelled = resultObj?.reason === 'user_cancelled' || resultObj?.cancelledByUser === true || - (payloadData as Record | undefined)?.reason === - 'user_cancelled' || - (payloadData as Record | undefined)?.cancelledByUser === true + payload.reason === 'user_cancelled' || + payload.cancelledByUser === true || + payload.status === MothershipStreamV1ToolOutcome.cancelled if (isCancelled) { tc.status = 'cancelled' tc.displayTitle = 'Stopped by user' } else { - tc.status = parsed.success ? 'success' : 'error' + tc.status = success ? 'success' : 'error' } tc.streamingArgs = undefined tc.result = { - success: !!parsed.success, - output: parsed.result ?? getPayloadData(parsed)?.result, - error: (parsed.error ?? getPayloadData(parsed)?.error) as string | undefined, + success: !!success, + output: + payload.result !== undefined + ? payload.result + : payload.output !== undefined + ? payload.output + : payload.data, + error: typeof payload.error === 'string' ? payload.error : undefined, } flush() - if (tc.name === 'read' && tc.status === 'success') { + if (tc.name === ReadTool.id && tc.status === 'success') { const readArgs = toolArgsMap.get(id) const resource = extractResourceFromReadResult( readArgs?.path as string | undefined, @@ -1444,8 +978,11 @@ export function useChat( } onToolResultRef.current?.(tc.name, tc.status === 'success', tc.result?.output) + if (isWorkflowToolName(tc.name)) { + clientExecutionStartedRef.current.delete(id) + } - if (tc.name === 'workspace_file') { + if (tc.name === WorkspaceFile.id) { setStreamingFile(null) streamingFileRef.current = null @@ -1464,66 +1001,77 @@ export function useChat( } } - // TODO: Uncomment when rich UI for Results tab is ready - // if ( - // shouldOpenGenericResource(tc.name) || - // (isDeferredResourceTool(tc.name) && extractedResources.length === 0) - // ) { - // const entryIdx = genericEntryMap.get(id) - // if (entryIdx !== undefined) { - // updateGenericEntry(entryIdx, { - // status: tc.status, - // result: tc.result ?? undefined, - // streamingArgs: undefined, - // }) - // } else { - // const newIdx = appendGenericEntry({ - // toolCallId: id, - // toolName: tc.name, - // displayTitle: tc.displayTitle ?? tc.name, - // status: tc.status, - // params: toolArgsMap.get(id) as Record | undefined, - // result: tc.result ?? undefined, - // }) - // genericEntryMap.set(id, newIdx) - // if (addResource({ type: 'generic', id: 'results', title: 'Results' })) { - // onResourceEventRef.current?.() - // } - // } - // } + if (tc.status === 'error' && tc.name === WorkspaceFile.id) { + setStreamingFile(null) + streamingFileRef.current = null + setResources((rs) => rs.filter((resource) => resource.id !== 'streaming-file')) + } + break } - break - } - case 'resource_added': { - const resource = parsed.resource - if (resource?.type && resource?.id) { - const wasAdded = addResource(resource) - invalidateResourceQueries(queryClient, workspaceId, resource.type, resource.id) - - if (!wasAdded && activeResourceIdRef.current !== resource.id) { - setActiveResourceId(resource.id) - } - onResourceEventRef.current?.() + const name = + typeof payload.toolName === 'string' + ? payload.toolName + : typeof payload.name === 'string' + ? payload.name + : 'unknown' + const isPartial = payload.partial === true + if (name === ToolSearchToolRegex.id) { + break + } + const ui = getToolUI(payload) + if (ui?.hidden) break + const displayTitle = ui?.title || ui?.phaseLabel + const phaseLabel = ui?.phaseLabel + const args = (asPayloadRecord(payload.arguments) ?? + asPayloadRecord(payload.input)) as Record | undefined - if (resource.type === 'workflow') { - const wasRegistered = ensureWorkflowInRegistry( - resource.id, - resource.title, - workspaceId - ) - if (wasAdded && wasRegistered) { - useWorkflowRegistry.getState().setActiveWorkflow(resource.id) - } else { - useWorkflowRegistry.getState().loadWorkflowState(resource.id) - } + if (!toolMap.has(id)) { + toolMap.set(id, blocks.length) + blocks.push({ + type: 'tool_call', + toolCall: { + id, + name, + status: 'executing', + displayTitle, + phaseLabel, + params: args, + calledBy: activeSubagent, + }, + }) + if (name === ReadTool.id || isResourceToolName(name)) { + if (args) toolArgsMap.set(id, args) } + } else { + const idx = toolMap.get(id)! + const tc = blocks[idx].toolCall + if (tc) { + tc.name = name + if (displayTitle) tc.displayTitle = displayTitle + if (phaseLabel) tc.phaseLabel = phaseLabel + if (args) tc.params = args + } + } + flush() + + if (ui?.clientExecutable && isWorkflowToolName(name) && !isPartial) { + startClientWorkflowTool(id, name, args ?? {}) } break } - case 'resource_deleted': { - const resource = parsed.resource - if (resource?.type && resource?.id) { + case MothershipStreamV1EventType.resource: { + const payload = getPayloadData(parsed) + const resource = asPayloadRecord(payload.resource) + if ( + !resource || + typeof resource.type !== 'string' || + typeof resource.id !== 'string' + ) { + break + } + + if (payload.op === MothershipStreamV1ResourceOp.remove) { removeResource(resource.type as MothershipResourceType, resource.id) invalidateResourceQueries( queryClient, @@ -1532,108 +1080,142 @@ export function useChat( resource.id ) onResourceEventRef.current?.() + break + } + + const nextResource = { + type: resource.type as MothershipResourceType, + id: resource.id, + title: typeof resource.title === 'string' ? resource.title : resource.id, + } + const wasAdded = addResource(nextResource) + invalidateResourceQueries( + queryClient, + workspaceId, + nextResource.type, + nextResource.id + ) + + if (!wasAdded && activeResourceIdRef.current !== nextResource.id) { + setActiveResourceId(nextResource.id) + } + onResourceEventRef.current?.() + + if (nextResource.type === 'workflow') { + const wasRegistered = ensureWorkflowInRegistry( + nextResource.id, + nextResource.title, + workspaceId + ) + if (wasAdded && wasRegistered) { + useWorkflowRegistry.getState().setActiveWorkflow(nextResource.id) + } else { + useWorkflowRegistry.getState().loadWorkflowState(nextResource.id) + } } break } - case 'context_compaction_start': { - const compactionId = `compaction_${Date.now()}` - activeCompactionId = compactionId - toolMap.set(compactionId, blocks.length) - blocks.push({ - type: 'tool_call', - toolCall: { - id: compactionId, - name: 'context_compaction', - status: 'executing', - displayTitle: 'Compacting context...', - }, - }) - flush() - break - } - case 'context_compaction': { - const compactionId = activeCompactionId || `compaction_${Date.now()}` - activeCompactionId = undefined - const idx = toolMap.get(compactionId) - if (idx !== undefined && blocks[idx]?.toolCall) { - blocks[idx].toolCall!.status = 'success' - blocks[idx].toolCall!.displayTitle = 'Compacted context' - } else { + case MothershipStreamV1EventType.run: { + const payload = getPayloadData(parsed) + const kind = typeof payload.kind === 'string' ? payload.kind : '' + if (kind === MothershipStreamV1RunKind.compaction_start) { + const compactionId = `compaction_${Date.now()}` + activeCompactionId = compactionId toolMap.set(compactionId, blocks.length) blocks.push({ type: 'tool_call', toolCall: { id: compactionId, name: 'context_compaction', - status: 'success', - displayTitle: 'Compacted context', + status: 'executing', + displayTitle: 'Compacting context...', }, }) - } - flush() - break - } - case 'tool_error': { - const id = parsed.toolCallId || getPayloadData(parsed)?.id - if (!id) break - const idx = toolMap.get(id) - if (idx !== undefined && blocks[idx].toolCall) { - const toolCallName = blocks[idx].toolCall!.name - blocks[idx].toolCall!.status = 'error' - if (toolCallName === 'workspace_file') { - setStreamingFile(null) - streamingFileRef.current = null - setResources((rs) => rs.filter((resource) => resource.id !== 'streaming-file')) + flush() + } else if (kind === MothershipStreamV1RunKind.compaction_done) { + const compactionId = activeCompactionId || `compaction_${Date.now()}` + activeCompactionId = undefined + const idx = toolMap.get(compactionId) + if (idx !== undefined && blocks[idx]?.toolCall) { + blocks[idx].toolCall!.status = 'success' + blocks[idx].toolCall!.displayTitle = 'Compacted context' + } else { + toolMap.set(compactionId, blocks.length) + blocks.push({ + type: 'tool_call', + toolCall: { + id: compactionId, + name: 'context_compaction', + status: 'success', + displayTitle: 'Compacted context', + }, + }) } flush() - - // TODO: Uncomment when rich UI for Results tab is ready - // if (toolCallName && shouldOpenGenericResource(toolCallName)) { - // const entryIdx = genericEntryMap.get(id) - // if (entryIdx !== undefined) { - // updateGenericEntry(entryIdx, { status: 'error', streamingArgs: undefined }) - // } - // } } break } - case 'subagent_start': { - const name = parsed.subagent || getPayloadData(parsed)?.agent - if (name) { + case MothershipStreamV1EventType.span: { + const payload = getPayloadData(parsed) + const kind = typeof payload.kind === 'string' ? payload.kind : '' + if (kind !== MothershipStreamV1SpanPayloadKind.subagent) { + break + } + const spanEvent = typeof payload.event === 'string' ? payload.event : '' + const spanData = asPayloadRecord(payload.data) + const parentToolCallId = + typeof parsed.scope?.parentToolCallId === 'string' + ? parsed.scope.parentToolCallId + : typeof spanData?.tool_call_id === 'string' + ? spanData.tool_call_id + : undefined + const isPendingPause = spanData?.pending === true + const name = + typeof payload.agent === 'string' + ? payload.agent + : typeof parsed.scope?.agentId === 'string' + ? parsed.scope.agentId + : undefined + if (spanEvent === MothershipStreamV1SpanLifecycleEvent.start && name) { + const isSameActiveSubagent = + activeSubagent === name && + activeSubagentParentToolCallId && + parentToolCallId === activeSubagentParentToolCallId activeSubagent = name - blocks.push({ type: 'subagent', content: name }) - if (name === 'file_write') { + activeSubagentParentToolCallId = parentToolCallId + if (!isSameActiveSubagent) { + blocks.push({ type: 'subagent', content: name }) + } + if (name === FileWrite.id) { const emptyFile = { fileName: '', content: '' } - // Ref must be updated synchronously: tool_call_delta can arrive before React - // re-renders after setStreamingFile, and the handler only appends when prev exists. streamingFileRef.current = emptyFile setStreamingFile(emptyFile) } flush() + } else if (spanEvent === MothershipStreamV1SpanLifecycleEvent.end) { + if (isPendingPause) { + break + } + activeSubagent = undefined + activeSubagentParentToolCallId = undefined + blocks.push({ type: 'subagent_end' }) + flush() } break } - case 'subagent_end': { - activeSubagent = undefined - blocks.push({ type: 'subagent_end' }) - flush() - break - } - case 'title_updated': { - queryClient.invalidateQueries({ - queryKey: taskKeys.list(workspaceId), - }) - onTitleUpdateRef.current?.() - break - } - case 'error': { + case MothershipStreamV1EventType.error: { + const payload = getPayloadData(parsed) sawStreamError = true - setError(parsed.error || 'An error occurred') + setError( + (typeof payload.message === 'string' ? payload.message : undefined) || + (typeof payload.error === 'string' ? payload.error : undefined) || + 'An error occurred' + ) appendInlineErrorTag(buildInlineErrorTag(parsed)) break } - case 'done': { - sawDoneEvent = true + case MothershipStreamV1EventType.complete: { + sawCompleteEvent = true break } } @@ -1644,16 +1226,146 @@ export function useChat( streamReaderRef.current = null } } - return { - sawStreamError, - sawDoneEvent, - lastEventId, - } + return { sawStreamError, sawComplete: sawCompleteEvent } }, [workspaceId, queryClient, addResource, removeResource] ) processSSEStreamRef.current = processSSEStream + const getActiveStreamIdForChat = useCallback( + async (chatId: string): Promise => { + const cached = queryClient.getQueryData(taskKeys.detail(chatId)) + if (cached?.activeStreamId) { + return cached.activeStreamId + } + + try { + const history = await fetchChatHistory(chatId) + queryClient.setQueryData(taskKeys.detail(chatId), history) + return history.activeStreamId ?? null + } catch (error) { + logger.warn('Failed to load chat history while recovering stream', { + chatId, + error: error instanceof Error ? error.message : String(error), + }) + return null + } + }, + [queryClient] + ) + + const reattachToStream = useCallback( + async (params: { + assistantId: string + expectedGen: number + abortController: AbortController + preferredStreamId?: string + chatId?: string + }): Promise => { + const { assistantId, expectedGen, abortController, preferredStreamId, chatId } = params + let streamId = preferredStreamId + let lastError = RECONNECT_TAIL_ERROR + + const isStale = () => streamGenRef.current !== expectedGen + const waitForRetry = async (ms: number) => { + await new Promise((resolve) => { + const timeout = setTimeout(() => { + abortController.signal.removeEventListener('abort', onAbort) + resolve() + }, ms) + const onAbort = () => { + clearTimeout(timeout) + abortController.signal.removeEventListener('abort', onAbort) + resolve() + } + abortController.signal.addEventListener('abort', onAbort, { once: true }) + }) + } + + setIsSending(true) + setIsReconnecting(true) + + try { + for (let attempt = 0; attempt <= RECOVERY_RETRY_DELAYS_MS.length; attempt++) { + if (abortController.signal.aborted || isStale()) { + return { attached: false, hadStreamError: false, aborted: true } + } + + if (!streamId && chatId) { + streamId = (await getActiveStreamIdForChat(chatId)) ?? undefined + } + + if (!streamId) { + lastError = RECONNECT_TAIL_ERROR + } else { + try { + streamIdRef.current = streamId + const resumeAfter = lastCursorRef.current || '0' + const response = await fetch( + `/api/copilot/chat/stream?streamId=${streamId}&after=${encodeURIComponent(resumeAfter)}`, + { signal: abortController.signal } + ) + + if (response.ok && response.body) { + setIsReconnecting(false) + const result = await processSSEStreamRef.current( + response.body.getReader(), + assistantId, + expectedGen, + { preserveExistingState: true } + ) + if ( + !result.sawComplete && + !result.sawStreamError && + !isStale() && + !abortController.signal.aborted + ) { + continue + } + return { attached: true, hadStreamError: result.sawStreamError, aborted: false } + } + + const errorData = await response.json().catch(() => ({})) + lastError = + (typeof errorData.error === 'string' ? errorData.error : undefined) || + `Reconnect failed: ${response.status}` + + if (chatId) { + streamId = + (typeof errorData.activeStreamId === 'string' + ? errorData.activeStreamId + : undefined) || + ((await getActiveStreamIdForChat(chatId)) ?? undefined) + } + } catch (error) { + if (error instanceof Error && error.name === 'AbortError') { + return { attached: false, hadStreamError: false, aborted: true } + } + lastError = + error instanceof Error ? error.message : 'Failed to reconnect to the active stream' + if (chatId) { + streamId = (await getActiveStreamIdForChat(chatId)) ?? streamId + } + } + } + + if (attempt < RECOVERY_RETRY_DELAYS_MS.length) { + await waitForRetry(RECOVERY_RETRY_DELAYS_MS[attempt] ?? 1000) + } + } + + setError(lastError) + return { attached: false, hadStreamError: true, aborted: false } + } finally { + if (!abortController.signal.aborted && !isStale()) { + setIsReconnecting(false) + } + } + }, + [getActiveStreamIdForChat] + ) + reattachToStreamRef.current = reattachToStream + const persistPartialResponse = useCallback(async () => { const chatId = chatIdRef.current const streamId = streamIdRef.current @@ -1661,7 +1373,7 @@ export function useChat( const content = streamingContentRef.current - const storedBlocks: TaskStoredContentBlock[] = streamingBlocksRef.current.map((block) => { + const storedBlocks = streamingBlocksRef.current.map((block) => { if (block.type === 'tool_call' && block.toolCall) { const isCancelled = block.toolCall.status === 'executing' || block.toolCall.status === 'cancelled' @@ -1671,7 +1383,7 @@ export function useChat( toolCall: { id: block.toolCall.id, name: block.toolCall.name, - state: isCancelled ? 'cancelled' : block.toolCall.status, + state: isCancelled ? MothershipStreamV1ToolOutcome.cancelled : block.toolCall.status, params: block.toolCall.params, result: block.toolCall.result, display: { @@ -1685,7 +1397,7 @@ export function useChat( }) if (storedBlocks.length > 0) { - storedBlocks.push({ type: 'stopped' }) + storedBlocks.push({ type: 'stopped', content: undefined }) } try { @@ -1720,22 +1432,11 @@ export function useChat( const messagesRef = useRef(messages) messagesRef.current = messages - const visibleMessageQueue = useMemo( - () => - pendingRecoveryMessage - ? [ - pendingRecoveryMessage, - ...messageQueue.filter((msg) => msg.id !== pendingRecoveryMessage.id), - ] - : messageQueue, - [messageQueue, pendingRecoveryMessage] - ) const finalize = useCallback( (options?: { error?: boolean }) => { sendingRef.current = false setIsSending(false) - setIsReconnecting(false) abortControllerRef.current = null invalidateChatQueries() @@ -1747,27 +1448,10 @@ export function useChat( } if (options?.error) { - pendingRecoveryMessageRef.current = null - setPendingRecoveryMessage(null) setMessageQueue([]) return } - const recoveryMessage = pendingRecoveryMessageRef.current - if (recoveryMessage) { - setPendingRecoveryMessage(null) - const gen = streamGenRef.current - queueMicrotask(() => { - if (streamGenRef.current !== gen) return - sendMessageRef.current( - recoveryMessage.content, - recoveryMessage.fileAttachments, - recoveryMessage.contexts - ) - }) - return - } - const next = messageQueueRef.current[0] if (next) { setMessageQueue((prev) => prev.filter((m) => m.id !== next.id)) @@ -1782,108 +1466,6 @@ export function useChat( ) finalizeRef.current = finalize - const resumeOrFinalize = useCallback( - async (opts: { - streamId: string - assistantId: string - gen: number - fromEventId: number - snapshot?: StreamSnapshot | null - signal?: AbortSignal - }): Promise => { - const { streamId, assistantId, gen, fromEventId, snapshot, signal } = opts - - const batch = - snapshot ?? - (await (async () => { - const b = await fetchStreamBatch(streamId, fromEventId, signal) - if (streamGenRef.current !== gen) return null - return { events: b.events, status: b.status } as StreamSnapshot - })()) - - if (!batch || streamGenRef.current !== gen) return - - if (isTerminalStreamStatus(batch.status)) { - finalize(batch.status === 'error' ? { error: true } : undefined) - return - } - - const reconnectResult = await attachToExistingStream({ - streamId, - assistantId, - expectedGen: gen, - snapshot: batch, - initialLastEventId: batch.events[batch.events.length - 1]?.eventId ?? fromEventId, - }) - - if (streamGenRef.current === gen && !reconnectResult.aborted) { - finalize(reconnectResult.error ? { error: true } : undefined) - } - }, - [fetchStreamBatch, attachToExistingStream, finalize] - ) - - const retryReconnect = useCallback( - async (opts: { - streamId: string - assistantId: string - gen: number - initialSnapshot?: StreamSnapshot | null - }): Promise => { - const { streamId, assistantId, gen, initialSnapshot } = opts - - for (let attempt = 0; attempt <= MAX_RECONNECT_ATTEMPTS; attempt++) { - if (streamGenRef.current !== gen) return true - if (abortControllerRef.current?.signal.aborted) return true - - if (attempt > 0) { - const delayMs = Math.min( - RECONNECT_BASE_DELAY_MS * 2 ** (attempt - 1), - RECONNECT_MAX_DELAY_MS - ) - logger.warn('Reconnect attempt', { - streamId, - attempt, - maxAttempts: MAX_RECONNECT_ATTEMPTS, - delayMs, - }) - setIsReconnecting(true) - await new Promise((resolve) => setTimeout(resolve, delayMs)) - if (streamGenRef.current !== gen) return true - if (abortControllerRef.current?.signal.aborted) return true - } - - try { - await resumeOrFinalize({ - streamId, - assistantId, - gen, - fromEventId: lastEventIdRef.current, - snapshot: attempt === 0 ? initialSnapshot : undefined, - signal: abortControllerRef.current?.signal, - }) - return true - } catch (err) { - if (err instanceof Error && err.name === 'AbortError') return true - logger.warn('Reconnect attempt failed', { - streamId, - attempt: attempt + 1, - error: err instanceof Error ? err.message : String(err), - }) - } - } - - logger.error('All reconnect attempts exhausted', { - streamId, - maxAttempts: MAX_RECONNECT_ATTEMPTS, - }) - setIsReconnecting(false) - return false - }, - [resumeOrFinalize] - ) - retryReconnectRef.current = retryReconnect - const sendMessage = useCallback( async (message: string, fileAttachments?: FileAttachmentForApi[], contexts?: ChatContext[]) => { if (!message.trim() || !workspaceId) return @@ -1910,10 +1492,9 @@ export function useChat( pendingUserMsgRef.current = { id: userMessageId, content: message } streamIdRef.current = userMessageId - lastEventIdRef.current = 0 - clientExecutionStartedRef.current.clear() + lastCursorRef.current = '0' - const storedAttachments: TaskStoredFileAttachment[] | undefined = + const storedAttachments: PersistedFileAttachment[] | undefined = fileAttachments && fileAttachments.length > 0 ? fileAttachments.map((f) => ({ id: f.id, @@ -1925,14 +1506,12 @@ export function useChat( : undefined const requestChatId = selectedChatIdRef.current ?? chatIdRef.current - const previousChatHistory = requestChatId - ? queryClient.getQueryData(taskKeys.detail(requestChatId)) - : undefined if (requestChatId) { - const cachedUserMsg: TaskStoredMessage = { + const cachedUserMsg: PersistedMessage = { id: userMessageId, role: 'user' as const, content: message, + timestamp: new Date().toISOString(), ...(storedAttachments && { fileAttachments: storedAttachments }), } queryClient.setQueryData(taskKeys.detail(requestChatId), (old) => { @@ -1946,8 +1525,15 @@ export function useChat( }) } - const userAttachments = storedAttachments?.map(toDisplayAttachment) - const previousMessages = messagesRef.current + const userAttachments = storedAttachments?.map((f) => ({ + id: f.id, + filename: f.filename, + media_type: f.media_type, + size: f.size, + previewUrl: f.media_type.startsWith('image/') + ? `/api/files/serve/${encodeURIComponent(f.key)}?context=mothership` + : undefined, + })) const messageContexts = contexts?.map((c) => ({ kind: c.kind, @@ -2006,113 +1592,65 @@ export function useChat( if (!response.ok) { const errorData = await response.json().catch(() => ({})) + if (response.status === 409) { + const recovery = await reattachToStream({ + assistantId, + expectedGen: gen, + abortController, + preferredStreamId: + typeof errorData.activeStreamId === 'string' ? errorData.activeStreamId : undefined, + chatId: requestChatId, + }) + if (recovery.aborted) return + if (streamGenRef.current === gen) { + finalize(recovery.attached && !recovery.hadStreamError ? undefined : { error: true }) + } + return + } throw new Error(errorData.error || `Request failed: ${response.status}`) } if (!response.body) throw new Error('No response body') - const termination = await processSSEStream(response.body.getReader(), assistantId, { - expectedGen: gen, - }) + const streamResult = await processSSEStream(response.body.getReader(), assistantId, gen) if (streamGenRef.current === gen) { - if (termination.sawStreamError) { + if (streamResult.sawStreamError) { finalize({ error: true }) - return - } - - await resumeOrFinalize({ - streamId: userMessageId, - assistantId, - gen, - fromEventId: termination.lastEventId, - signal: abortController.signal, - }) - } - } catch (err) { - if (err instanceof Error && err.name === 'AbortError') return - const errorMessage = err instanceof Error ? err.message : 'Failed to send message' - if (requestChatId && isActiveStreamConflictError(errorMessage)) { - logger.info('Active stream conflict detected while sending message; reattaching', { - chatId: requestChatId, - attemptedStreamId: userMessageId, - }) - - if (previousChatHistory) { - queryClient.setQueryData(taskKeys.detail(requestChatId), previousChatHistory) - } - setMessages(previousMessages) - const queuedMessage: QueuedMessage = { - id: crypto.randomUUID(), - content: message, - fileAttachments, - contexts, - } - pendingRecoveryMessageRef.current = queuedMessage - setPendingRecoveryMessage(queuedMessage) - - try { - const pendingRecovery = await preparePendingStreamRecovery(requestChatId) - if (!pendingRecovery) { - setError(errorMessage) - if (streamGenRef.current === gen) { - finalize({ error: true }) - } - return - } - - streamIdRef.current = pendingRecovery.streamId - lastEventIdRef.current = - pendingRecovery.snapshot?.events?.[pendingRecovery.snapshot.events.length - 1] - ?.eventId ?? 0 - - const rehydratedMessages = messagesRef.current - const lastAssistantMsg = [...rehydratedMessages] - .reverse() - .find((m) => m.role === 'assistant') - const recoveryAssistantId = lastAssistantMsg?.id ?? assistantId - - await resumeOrFinalize({ - streamId: pendingRecovery.streamId, - assistantId: recoveryAssistantId, - gen, - fromEventId: lastEventIdRef.current, - snapshot: pendingRecovery.snapshot, - }) - return - } catch (recoveryError) { - logger.warn('Failed to recover active stream after conflict', { + } else if (!streamResult.sawComplete) { + const recovery = await reattachToStream({ + assistantId, + expectedGen: gen, + abortController, + preferredStreamId: userMessageId, chatId: requestChatId, - error: recoveryError instanceof Error ? recoveryError.message : String(recoveryError), }) + if (!recovery.aborted && streamGenRef.current === gen) { + finalize(recovery.attached && !recovery.hadStreamError ? undefined : { error: true }) + } + } else { + finalize() } } - - const activeStreamId = streamIdRef.current - if (activeStreamId && streamGenRef.current === gen) { - const succeeded = await retryReconnect({ - streamId: activeStreamId, - assistantId, - gen, - }) - if (succeeded) return + } catch (err) { + if (err instanceof Error && err.name === 'AbortError') return + const recovery = await reattachToStream({ + assistantId, + expectedGen: gen, + abortController, + preferredStreamId: userMessageId, + chatId: requestChatId, + }) + if (recovery.aborted) return + if (!recovery.attached) { + setError(err instanceof Error ? err.message : 'Failed to send message') } - - setError(errorMessage) if (streamGenRef.current === gen) { - finalize({ error: true }) + finalize(recovery.attached && !recovery.hadStreamError ? undefined : { error: true }) } return } }, - [ - workspaceId, - queryClient, - processSSEStream, - finalize, - resumeOrFinalize, - retryReconnect, - preparePendingStreamRecovery, - ] + [workspaceId, queryClient, processSSEStream, finalize, reattachToStream] ) sendMessageRef.current = sendMessage @@ -2131,10 +1669,6 @@ export function useChat( abortControllerRef.current = null sendingRef.current = false setIsSending(false) - setIsReconnecting(false) - lastEventIdRef.current = 0 - pendingRecoveryMessageRef.current = null - setPendingRecoveryMessage(null) setMessages((prev) => prev.map((msg) => { @@ -2213,7 +1747,6 @@ export function useChat( }) executionStream.cancel(workflowId) - consolePersistence.executionEnded() execState.setIsExecuting(workflowId, false) execState.setIsDebugging(workflowId, false) execState.setActiveBlocks(workflowId, new Set()) @@ -2223,47 +1756,24 @@ export function useChat( }, [invalidateChatQueries, persistPartialResponse, executionStream]) const removeFromQueue = useCallback((id: string) => { - if (pendingRecoveryMessageRef.current?.id === id) { - pendingRecoveryMessageRef.current = null - setPendingRecoveryMessage(null) - return - } messageQueueRef.current = messageQueueRef.current.filter((m) => m.id !== id) setMessageQueue((prev) => prev.filter((m) => m.id !== id)) }, []) const sendNow = useCallback( async (id: string) => { - const recoveryMessage = pendingRecoveryMessageRef.current - const msg = - recoveryMessage?.id === id - ? recoveryMessage - : messageQueueRef.current.find((m) => m.id === id) + const msg = messageQueueRef.current.find((m) => m.id === id) if (!msg) return // Eagerly update ref so a rapid second click finds the message already gone - if (recoveryMessage?.id === id) { - pendingRecoveryMessageRef.current = null - setPendingRecoveryMessage(null) - } else { - messageQueueRef.current = messageQueueRef.current.filter((m) => m.id !== id) - } + messageQueueRef.current = messageQueueRef.current.filter((m) => m.id !== id) await stopGeneration() - if (recoveryMessage?.id !== id) { - setMessageQueue((prev) => prev.filter((m) => m.id !== id)) - } + setMessageQueue((prev) => prev.filter((m) => m.id !== id)) await sendMessage(msg.content, msg.fileAttachments, msg.contexts) }, [stopGeneration, sendMessage] ) const editQueuedMessage = useCallback((id: string): QueuedMessage | undefined => { - const recoveryMessage = pendingRecoveryMessageRef.current - if (recoveryMessage?.id === id) { - pendingRecoveryMessageRef.current = null - setPendingRecoveryMessage(null) - return recoveryMessage - } - const msg = messageQueueRef.current.find((m) => m.id === id) if (!msg) return undefined messageQueueRef.current = messageQueueRef.current.filter((m) => m.id !== id) @@ -2277,9 +1787,6 @@ export function useChat( abortControllerRef.current = null streamGenRef.current++ sendingRef.current = false - lastEventIdRef.current = 0 - clientExecutionStartedRef.current.clear() - pendingRecoveryMessageRef.current = null } }, []) @@ -2297,7 +1804,7 @@ export function useChat( addResource, removeResource, reorderResources, - messageQueue: visibleMessageQueue, + messageQueue, removeFromQueue, sendNow, editQueuedMessage, diff --git a/apps/sim/app/workspace/[workspaceId]/home/types.ts b/apps/sim/app/workspace/[workspaceId]/home/types.ts index 1395c99db6d..9601ca57144 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/types.ts +++ b/apps/sim/app/workspace/[workspaceId]/home/types.ts @@ -1,10 +1,39 @@ -import type { MothershipResourceType } from '@/lib/copilot/resource-types' +import { + Agent, + Auth, + Build, + CreateWorkflow, + Debug, + Deploy, + EditWorkflow, + FunctionExecute, + GetPageContents, + Glob, + Grep, + Job, + Knowledge, + KnowledgeBase, + ManageMcpTool, + ManageSkill, + OpenResource, + Read as ReadTool, + Research, + Run, + ScrapePage, + SearchLibraryDocs, + SearchOnline, + Superagent, + Table, + UserMemory, + UserTable, + WorkspaceFile, +} from '@/lib/copilot/generated/tool-catalog-v1' import type { ChatContext } from '@/stores/panel' export type { MothershipResource, MothershipResourceType, -} from '@/lib/copilot/resource-types' +} from '@/lib/copilot/resources/types' export interface FileAttachmentForApi { id: string @@ -21,169 +50,34 @@ export interface QueuedMessage { contexts?: ChatContext[] } -/** - * SSE event types emitted by the Go orchestrator backend. - * - * @example - * ```json - * { "type": "content", "data": "Hello world" } - * { "type": "tool_call", "state": "executing", "toolCallId": "toolu_...", "toolName": "glob", "ui": { "title": "..." } } - * { "type": "subagent_start", "subagent": "build" } - * ``` - */ -export type SSEEventType = - | 'chat_id' - | 'request_id' - | 'title_updated' - | 'content' - | 'reasoning' // openai reasoning - render as thinking text - | 'tool_call' // tool call name - | 'tool_call_delta' // chunk of tool call - | 'tool_generating' // start a tool call - | 'tool_result' // tool call result - | 'tool_error' // tool call error - | 'resource_added' // add a resource to the chat - | 'resource_deleted' // delete a resource from the chat - | 'subagent_start' // start a subagent - | 'subagent_end' // end a subagent - | 'structured_result' // structured result from a tool call - | 'subagent_result' // result from a subagent - | 'done' // end of the chat - | 'context_compaction_start' // context compaction started - | 'context_compaction' // conversation context was compacted - | 'error' // error in the chat - | 'start' // start of the chat - /** * All tool names observed in the mothership SSE stream, grouped by phase. * * @example * ```json - * { "type": "tool_generating", "toolName": "glob" } - * { "type": "tool_call", "toolName": "function_execute", "ui": { "title": "Running code", "icon": "code" } } - * ``` - */ -export type MothershipToolName = - | 'glob' - | 'grep' - | 'read' - | 'search_online' - | 'scrape_page' - | 'get_page_contents' - | 'search_library_docs' - | 'manage_mcp_tool' - | 'manage_skill' - | 'manage_credential' - | 'manage_custom_tool' - | 'manage_job' - | 'user_memory' - | 'function_execute' - | 'superagent' - | 'user_table' - | 'workspace_file' - | 'create_workflow' - | 'delete_workflow' - | 'edit_workflow' - | 'rename_workflow' - | 'move_workflow' - | 'run_workflow' - | 'run_block' - | 'run_from_block' - | 'run_workflow_until_block' - | 'create_folder' - | 'delete_folder' - | 'move_folder' - | 'list_folders' - | 'list_user_workspaces' - | 'create_job' - | 'complete_job' - | 'update_job_history' - | 'job_respond' - | 'download_to_workspace_file' - | 'materialize_file' - | 'context_write' - | 'generate_image' - | 'generate_visualization' - | 'crawl_website' - | 'get_execution_summary' - | 'get_job_logs' - | 'get_deployment_version' - | 'revert_to_version' - | 'check_deployment_status' - | 'get_deployed_workflow_state' - | 'get_workflow_data' - | 'get_workflow_logs' - | 'get_block_outputs' - | 'get_block_upstream_references' - | 'set_global_workflow_variables' - | 'set_environment_variables' - | 'get_platform_actions' - | 'search_documentation' - | 'search_patterns' - | 'update_workspace_mcp_server' - | 'delete_workspace_mcp_server' - | 'create_workspace_mcp_server' - | 'list_workspace_mcp_servers' - | 'deploy_api' - | 'deploy_chat' - | 'deploy_mcp' - | 'redeploy' - | 'generate_api_key' - | 'oauth_get_auth_link' - | 'oauth_request_access' - | 'build' - | 'run' - | 'deploy' - | 'auth' - | 'knowledge' - | 'knowledge_base' - | 'table' - | 'job' - | 'agent' - | 'custom_tool' - | 'research' - | 'plan' - | 'debug' - | 'edit' - | 'fast_edit' - | 'open_resource' - | 'context_compaction' - -/** - * Subagent identifiers dispatched via `subagent_start` SSE events. - * - * @example - * ```json - * { "type": "subagent_start", "subagent": "build" } + * { "type": "tool", "phase": "call", "toolName": "glob" } + * { "type": "tool", "phase": "call", "toolName": "function_execute", "ui": { "title": "Running code", "icon": "code" } } * ``` + * Stream `type` is `MothershipStreamV1EventType.tool` (`mothership-stream-v1`) with `phase: 'call'`. */ -export type SubagentName = - | 'build' - | 'deploy' - | 'auth' - | 'research' - | 'knowledge' - | 'table' - | 'custom_tool' - | 'superagent' - | 'plan' - | 'debug' - | 'edit' - | 'fast_edit' - | 'run' - | 'agent' - | 'job' - | 'file_write' -export type ToolPhase = - | 'workspace' - | 'search' - | 'management' - | 'execution' - | 'resource' - | 'subagent' +export const ToolPhase = { + workspace: 'workspace', + search: 'search', + management: 'management', + execution: 'execution', + resource: 'resource', + subagent: 'subagent', +} as const +export type ToolPhase = (typeof ToolPhase)[keyof typeof ToolPhase] -export type ToolCallStatus = 'executing' | 'success' | 'error' | 'cancelled' +export const ToolCallStatus = { + executing: 'executing', + success: 'success', + error: 'error', + cancelled: 'cancelled', +} as const +export type ToolCallStatus = (typeof ToolCallStatus)[keyof typeof ToolCallStatus] export interface ToolCallResult { success: boolean @@ -191,7 +85,6 @@ export interface ToolCallResult { error?: string } -/** A single tool call result entry in the generic Results resource tab. */ export interface GenericResourceEntry { toolCallId: string toolName: string @@ -202,7 +95,6 @@ export interface GenericResourceEntry { result?: ToolCallResult } -/** Accumulated feed of tool call results shown in the generic Results tab. */ export interface GenericResourceData { entries: GenericResourceEntry[] } @@ -225,7 +117,7 @@ export interface ToolCallInfo { phaseLabel?: string params?: Record calledBy?: string - result?: { success: boolean; output?: unknown; error?: string } + result?: ToolCallResult streamingArgs?: string } @@ -234,14 +126,16 @@ export interface OptionItem { label: string } -export type ContentBlockType = - | 'text' - | 'tool_call' - | 'subagent' - | 'subagent_end' - | 'subagent_text' - | 'options' - | 'stopped' +export const ContentBlockType = { + text: 'text', + tool_call: 'tool_call', + subagent: 'subagent', + subagent_end: 'subagent_end', + subagent_text: 'subagent_text', + options: 'options', + stopped: 'stopped', +} as const +export type ContentBlockType = (typeof ContentBlockType)[keyof typeof ContentBlockType] export interface ContentBlock { type: ContentBlockType @@ -278,7 +172,7 @@ export interface ChatMessage { requestId?: string } -export const SUBAGENT_LABELS: Record = { +export const SUBAGENT_LABELS: Record = { build: 'Build agent', deploy: 'Deploy agent', auth: 'Integration agent', @@ -304,206 +198,130 @@ export interface ToolUIMetadata { } /** - * Primary UI metadata for tools observed in the SSE stream. - * Maps tool IDs to human-readable display names shown in the chat. - * This is the single source of truth — server-sent `ui.title` values are not used. + * Default UI metadata for tools observed in the SSE stream. + * The backend may send `ui` on some `MothershipStreamV1EventType.tool` payloads (`phase: 'call'`); + * this map provides fallback metadata when `ui` is absent. */ -export const TOOL_UI_METADATA: Record = { - // Workspace - glob: { title: 'Searching workspace', phaseLabel: 'Workspace', phase: 'workspace' }, - grep: { title: 'Searching workspace', phaseLabel: 'Workspace', phase: 'workspace' }, - read: { title: 'Reading file', phaseLabel: 'Workspace', phase: 'workspace' }, - // Search - search_online: { title: 'Searching online', phaseLabel: 'Search', phase: 'search' }, - scrape_page: { title: 'Reading webpage', phaseLabel: 'Search', phase: 'search' }, - get_page_contents: { title: 'Reading page', phaseLabel: 'Search', phase: 'search' }, - search_library_docs: { title: 'Searching docs', phaseLabel: 'Search', phase: 'search' }, - crawl_website: { title: 'Browsing website', phaseLabel: 'Search', phase: 'search' }, - // Execution - function_execute: { title: 'Running code', phaseLabel: 'Code', phase: 'execution' }, - superagent: { title: 'Taking action', phaseLabel: 'Action', phase: 'execution' }, - run_workflow: { title: 'Running workflow', phaseLabel: 'Execution', phase: 'execution' }, - run_block: { title: 'Running block', phaseLabel: 'Execution', phase: 'execution' }, - run_from_block: { title: 'Running from block', phaseLabel: 'Execution', phase: 'execution' }, - run_workflow_until_block: { - title: 'Running partial workflow', - phaseLabel: 'Execution', - phase: 'execution', +export const TOOL_UI_METADATA: Record = { + [Glob.id]: { + title: 'Searching files', + phaseLabel: 'Workspace', + phase: 'workspace', }, - complete_job: { title: 'Completing job', phaseLabel: 'Execution', phase: 'execution' }, - get_execution_summary: { title: 'Checking results', phaseLabel: 'Execution', phase: 'execution' }, - get_job_logs: { title: 'Checking logs', phaseLabel: 'Execution', phase: 'execution' }, - get_workflow_logs: { title: 'Checking logs', phaseLabel: 'Execution', phase: 'execution' }, - get_workflow_data: { title: 'Loading workflow', phaseLabel: 'Execution', phase: 'execution' }, - get_block_outputs: { - title: 'Checking block outputs', - phaseLabel: 'Execution', - phase: 'execution', + [Grep.id]: { + title: 'Searching code', + phaseLabel: 'Workspace', + phase: 'workspace', }, - get_block_upstream_references: { - title: 'Checking references', - phaseLabel: 'Execution', - phase: 'execution', + [ReadTool.id]: { title: 'Reading file', phaseLabel: 'Workspace', phase: 'workspace' }, + [SearchOnline.id]: { + title: 'Searching online', + phaseLabel: 'Search', + phase: 'search', + }, + [ScrapePage.id]: { + title: 'Scraping page', + phaseLabel: 'Search', + phase: 'search', + }, + [GetPageContents.id]: { + title: 'Getting page contents', + phaseLabel: 'Search', + phase: 'search', + }, + [SearchLibraryDocs.id]: { + title: 'Searching library docs', + phaseLabel: 'Search', + phase: 'search', + }, + [ManageMcpTool.id]: { + title: 'Managing MCP tool', + phaseLabel: 'Management', + phase: 'management', }, - get_deployed_workflow_state: { - title: 'Checking deployment', - phaseLabel: 'Execution', + [ManageSkill.id]: { + title: 'Managing skill', + phaseLabel: 'Management', + phase: 'management', + }, + [UserMemory.id]: { + title: 'Accessing memory', + phaseLabel: 'Management', + phase: 'management', + }, + [FunctionExecute.id]: { + title: 'Running code', + phaseLabel: 'Code', phase: 'execution', }, - check_deployment_status: { - title: 'Checking deployment', - phaseLabel: 'Execution', + [Superagent.id]: { + title: 'Executing action', + phaseLabel: 'Action', phase: 'execution', }, - // Workflows & folders - create_workflow: { title: 'Creating workflow', phaseLabel: 'Resource', phase: 'resource' }, - delete_workflow: { title: 'Deleting workflow', phaseLabel: 'Resource', phase: 'resource' }, - edit_workflow: { title: 'Editing workflow', phaseLabel: 'Resource', phase: 'resource' }, - rename_workflow: { title: 'Renaming workflow', phaseLabel: 'Resource', phase: 'resource' }, - move_workflow: { title: 'Moving workflow', phaseLabel: 'Resource', phase: 'resource' }, - create_folder: { title: 'Creating folder', phaseLabel: 'Resource', phase: 'resource' }, - delete_folder: { title: 'Deleting folder', phaseLabel: 'Resource', phase: 'resource' }, - move_folder: { title: 'Moving folder', phaseLabel: 'Resource', phase: 'resource' }, - list_folders: { title: 'Browsing folders', phaseLabel: 'Resource', phase: 'resource' }, - list_user_workspaces: { title: 'Browsing workspaces', phaseLabel: 'Resource', phase: 'resource' }, - revert_to_version: { title: 'Restoring version', phaseLabel: 'Resource', phase: 'resource' }, - get_deployment_version: { - title: 'Checking deployment', + [UserTable.id]: { + title: 'Managing table', phaseLabel: 'Resource', phase: 'resource', }, - open_resource: { title: 'Opening resource', phaseLabel: 'Resource', phase: 'resource' }, - // Files - workspace_file: { title: 'Working with files', phaseLabel: 'Resource', phase: 'resource' }, - download_to_workspace_file: { - title: 'Downloading file', + [WorkspaceFile.id]: { + title: 'Managing file', phaseLabel: 'Resource', phase: 'resource', }, - materialize_file: { title: 'Saving file', phaseLabel: 'Resource', phase: 'resource' }, - generate_image: { title: 'Generating image', phaseLabel: 'Resource', phase: 'resource' }, - generate_visualization: { - title: 'Generating visualization', + [CreateWorkflow.id]: { + title: 'Creating workflow', phaseLabel: 'Resource', phase: 'resource', }, - // Tables & knowledge - user_table: { title: 'Editing table', phaseLabel: 'Resource', phase: 'resource' }, - knowledge_base: { title: 'Updating knowledge base', phaseLabel: 'Resource', phase: 'resource' }, - // Jobs - create_job: { title: 'Creating job', phaseLabel: 'Resource', phase: 'resource' }, - manage_job: { title: 'Updating job', phaseLabel: 'Management', phase: 'management' }, - update_job_history: { title: 'Updating job', phaseLabel: 'Management', phase: 'management' }, - job_respond: { title: 'Explaining job scheduled', phaseLabel: 'Execution', phase: 'execution' }, - // Management - manage_mcp_tool: { title: 'Updating integration', phaseLabel: 'Management', phase: 'management' }, - manage_skill: { title: 'Updating skill', phaseLabel: 'Management', phase: 'management' }, - manage_credential: { title: 'Connecting account', phaseLabel: 'Management', phase: 'management' }, - manage_custom_tool: { title: 'Updating tool', phaseLabel: 'Management', phase: 'management' }, - update_workspace_mcp_server: { - title: 'Updating MCP server', - phaseLabel: 'Management', - phase: 'management', - }, - delete_workspace_mcp_server: { - title: 'Removing MCP server', - phaseLabel: 'Management', - phase: 'management', + [EditWorkflow.id]: { + title: 'Editing workflow', + phaseLabel: 'Resource', + phase: 'resource', }, - create_workspace_mcp_server: { - title: 'Creating MCP server', - phaseLabel: 'Management', - phase: 'management', + [Build.id]: { title: 'Building', phaseLabel: 'Build', phase: 'subagent' }, + [Run.id]: { title: 'Running', phaseLabel: 'Run', phase: 'subagent' }, + [Deploy.id]: { title: 'Deploying', phaseLabel: 'Deploy', phase: 'subagent' }, + [Auth.id]: { + title: 'Connecting credentials', + phaseLabel: 'Auth', + phase: 'subagent', }, - list_workspace_mcp_servers: { - title: 'Browsing MCP servers', - phaseLabel: 'Management', - phase: 'management', + [Knowledge.id]: { + title: 'Managing knowledge', + phaseLabel: 'Knowledge', + phase: 'subagent', }, - oauth_get_auth_link: { - title: 'Connecting account', - phaseLabel: 'Management', - phase: 'management', + [KnowledgeBase.id]: { + title: 'Managing knowledge base', + phaseLabel: 'Resource', + phase: 'resource', }, - oauth_request_access: { - title: 'Connecting account', - phaseLabel: 'Management', - phase: 'management', + [Table.id]: { title: 'Managing tables', phaseLabel: 'Table', phase: 'subagent' }, + [Job.id]: { title: 'Managing jobs', phaseLabel: 'Job', phase: 'subagent' }, + [Agent.id]: { title: 'Agent action', phaseLabel: 'Agent', phase: 'subagent' }, + custom_tool: { + title: 'Creating tool', + phaseLabel: 'Tool', + phase: 'subagent', }, - set_environment_variables: { - title: 'Updating environment', - phaseLabel: 'Management', - phase: 'management', + [Research.id]: { title: 'Researching', phaseLabel: 'Research', phase: 'subagent' }, + plan: { title: 'Planning', phaseLabel: 'Plan', phase: 'subagent' }, + [Debug.id]: { title: 'Debugging', phaseLabel: 'Debug', phase: 'subagent' }, + edit: { title: 'Editing workflow', phaseLabel: 'Edit', phase: 'subagent' }, + fast_edit: { + title: 'Editing workflow', + phaseLabel: 'Edit', + phase: 'subagent', }, - set_global_workflow_variables: { - title: 'Updating variables', - phaseLabel: 'Management', - phase: 'management', + [OpenResource.id]: { + title: 'Opening resource', + phaseLabel: 'Resource', + phase: 'resource', }, - get_platform_actions: { title: 'Loading actions', phaseLabel: 'Management', phase: 'management' }, - search_documentation: { title: 'Searching docs', phaseLabel: 'Search', phase: 'search' }, - search_patterns: { title: 'Searching patterns', phaseLabel: 'Search', phase: 'search' }, - deploy_api: { title: 'Deploying API', phaseLabel: 'Deploy', phase: 'management' }, - deploy_chat: { title: 'Deploying chat', phaseLabel: 'Deploy', phase: 'management' }, - deploy_mcp: { title: 'Deploying MCP', phaseLabel: 'Deploy', phase: 'management' }, - redeploy: { title: 'Redeploying', phaseLabel: 'Deploy', phase: 'management' }, - generate_api_key: { title: 'Generating API key', phaseLabel: 'Deploy', phase: 'management' }, - user_memory: { title: 'Updating memory', phaseLabel: 'Management', phase: 'management' }, - context_write: { title: 'Writing notes', phaseLabel: 'Management', phase: 'management' }, context_compaction: { - title: 'Optimizing context', - phaseLabel: 'Management', + title: 'Compacted context', + phaseLabel: 'Context', phase: 'management', }, - // Subagents - build: { title: 'Building', phaseLabel: 'Build', phase: 'subagent' }, - run: { title: 'Running', phaseLabel: 'Run', phase: 'subagent' }, - deploy: { title: 'Deploying', phaseLabel: 'Deploy', phase: 'subagent' }, - auth: { title: 'Connecting integration', phaseLabel: 'Auth', phase: 'subagent' }, - knowledge: { title: 'Working with knowledge', phaseLabel: 'Knowledge', phase: 'subagent' }, - table: { title: 'Working with tables', phaseLabel: 'Table', phase: 'subagent' }, - job: { title: 'Working with jobs', phaseLabel: 'Job', phase: 'subagent' }, - agent: { title: 'Taking action', phaseLabel: 'Agent', phase: 'subagent' }, - custom_tool: { title: 'Creating tool', phaseLabel: 'Tool', phase: 'subagent' }, - research: { title: 'Researching', phaseLabel: 'Research', phase: 'subagent' }, - plan: { title: 'Planning', phaseLabel: 'Plan', phase: 'subagent' }, - debug: { title: 'Debugging', phaseLabel: 'Debug', phase: 'subagent' }, - edit: { title: 'Editing workflow', phaseLabel: 'Edit', phase: 'subagent' }, - fast_edit: { title: 'Editing workflow', phaseLabel: 'Edit', phase: 'subagent' }, -} - -export interface SSEPayloadUI { - hidden?: boolean - title?: string - phaseLabel?: string - icon?: string - internal?: boolean - clientExecutable?: boolean -} - -export interface SSEPayloadData { - name?: string - ui?: SSEPayloadUI - id?: string - agent?: string - partial?: boolean - arguments?: Record - input?: Record - result?: unknown - error?: string -} - -export interface SSEPayload { - type: SSEEventType | (string & {}) - chatId?: string - data?: string | SSEPayloadData - content?: string - toolCallId?: string - toolName?: string - ui?: SSEPayloadUI - success?: boolean - result?: unknown - error?: string - subagent?: string - resource?: { type: MothershipResourceType; id: string; title: string } } diff --git a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx index 0f349d1b85f..50af6d6ea5f 100644 --- a/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx +++ b/apps/sim/app/workspace/[workspaceId]/w/[workflowId]/components/panel/panel.tsx @@ -218,7 +218,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel const [copilotChatId, setCopilotChatId] = useState(undefined) const [copilotChatTitle, setCopilotChatTitle] = useState(null) const [copilotChatList, setCopilotChatList] = useState< - { id: string; title: string | null; updatedAt: string; conversationId: string | null }[] + { id: string; title: string | null; updatedAt: string; activeStreamId: string | null }[] >([]) const [isCopilotHistoryOpen, setIsCopilotHistoryOpen] = useState(false) @@ -238,7 +238,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel id: string title: string | null updatedAt: string - conversationId: string | null + activeStreamId: string | null }> setCopilotChatList(filtered) @@ -784,7 +784,7 @@ export const Panel = memo(function Panel({ workspaceId: propWorkspaceId }: Panel > }> - status: string -} - export interface TaskChatHistory { id: string title: string | null - messages: TaskStoredMessage[] + messages: PersistedMessage[] activeStreamId: string | null resources: MothershipResource[] - streamSnapshot?: StreamSnapshot | null -} - -export interface TaskStoredToolCall { - id: string - name: string - status: string - params?: Record - result?: unknown - error?: string - durationMs?: number -} - -export interface TaskStoredFileAttachment { - id: string - key: string - filename: string - media_type: string - size: number -} - -export interface TaskStoredMessageContext { - kind: string - label: string - workflowId?: string - knowledgeId?: string - tableId?: string - fileId?: string -} - -export interface TaskStoredMessage { - id: string - role: 'user' | 'assistant' - content: string - requestId?: string - toolCalls?: TaskStoredToolCall[] - contentBlocks?: TaskStoredContentBlock[] - fileAttachments?: TaskStoredFileAttachment[] - contexts?: TaskStoredMessageContext[] -} - -export interface TaskStoredContentBlock { - type: string - content?: string - toolCall?: { - id?: string - name?: string - state?: string - params?: Record - result?: { success: boolean; output?: unknown; error?: string } - display?: { text?: string } - calledBy?: string - } | null + streamSnapshot?: { events: unknown[]; status: string } | null } export const taskKeys = { @@ -87,7 +32,7 @@ interface TaskResponse { id: string title: string | null updatedAt: string - conversationId: string | null + activeStreamId: string | null lastSeenAt: string | null } @@ -97,9 +42,9 @@ function mapTask(chat: TaskResponse): TaskMetadata { id: chat.id, name: chat.title ?? 'New task', updatedAt, - isActive: chat.conversationId !== null, + isActive: chat.activeStreamId !== null, isUnread: - chat.conversationId === null && + chat.activeStreamId === null && (chat.lastSeenAt === null || updatedAt > new Date(chat.lastSeenAt)), } } @@ -159,10 +104,11 @@ export async function fetchChatHistory( return { id: chat.id, title: chat.title, - messages: Array.isArray(chat.messages) ? chat.messages : [], - activeStreamId: chat.conversationId || null, + messages: Array.isArray(chat.messages) + ? chat.messages.map((m: Record) => normalizeMessage(m)) + : [], + activeStreamId: chat.activeStreamId || null, resources: Array.isArray(chat.resources) ? chat.resources : [], - streamSnapshot: chat.streamSnapshot || null, } } diff --git a/apps/sim/lib/copilot/async-runs/lifecycle.ts b/apps/sim/lib/copilot/async-runs/lifecycle.ts index 949ff773247..2656f7473aa 100644 --- a/apps/sim/lib/copilot/async-runs/lifecycle.ts +++ b/apps/sim/lib/copilot/async-runs/lifecycle.ts @@ -1,13 +1,7 @@ import type { CopilotAsyncToolStatus } from '@sim/db/schema' +import { MothershipStreamV1AsyncToolRecordStatus } from '@/lib/copilot/generated/mothership-stream-v1' -export const ASYNC_TOOL_STATUS = { - pending: 'pending', - running: 'running', - completed: 'completed', - failed: 'failed', - cancelled: 'cancelled', - delivered: 'delivered', -} as const +export const ASYNC_TOOL_STATUS = MothershipStreamV1AsyncToolRecordStatus export type AsyncLifecycleStatus = | typeof ASYNC_TOOL_STATUS.pending diff --git a/apps/sim/lib/copilot/chat-context.ts b/apps/sim/lib/copilot/chat-context.ts deleted file mode 100644 index f475b3b2802..00000000000 --- a/apps/sim/lib/copilot/chat-context.ts +++ /dev/null @@ -1,53 +0,0 @@ -import { createLogger } from '@sim/logger' -import { CopilotFiles } from '@/lib/uploads' -import { createFileContent } from '@/lib/uploads/utils/file-utils' - -const logger = createLogger('CopilotChatContext') - -export interface FileAttachmentInput { - id: string - key: string - name?: string - filename?: string - mimeType?: string - media_type?: string - size: number -} - -export interface FileContent { - type: string - [key: string]: unknown -} - -/** - * Process file attachments into content for the payload. - */ -export async function processFileAttachments( - fileAttachments: FileAttachmentInput[], - userId: string -): Promise { - if (!Array.isArray(fileAttachments) || fileAttachments.length === 0) return [] - - const processedFileContents: FileContent[] = [] - const requestId = `copilot-${userId}-${Date.now()}` - const processedAttachments = await CopilotFiles.processCopilotAttachments( - fileAttachments as Parameters[0], - requestId - ) - - for (const { buffer, attachment } of processedAttachments) { - const fileContent = createFileContent(buffer, attachment.media_type) - if (fileContent) { - const enriched: FileContent = { ...fileContent, filename: attachment.filename } - processedFileContents.push(enriched) - } - } - - logger.debug('Processed file attachments for payload', { - userId, - inputCount: fileAttachments.length, - outputCount: processedFileContents.length, - }) - - return processedFileContents -} diff --git a/apps/sim/lib/copilot/chat-streaming.test.ts b/apps/sim/lib/copilot/chat-streaming.test.ts deleted file mode 100644 index b0e05ed445a..00000000000 --- a/apps/sim/lib/copilot/chat-streaming.test.ts +++ /dev/null @@ -1,140 +0,0 @@ -/** - * @vitest-environment node - */ - -import { beforeEach, describe, expect, it, vi } from 'vitest' - -const { - orchestrateCopilotStream, - createRunSegment, - updateRunStatus, - resetStreamBuffer, - setStreamMeta, - createStreamEventWriter, -} = vi.hoisted(() => ({ - orchestrateCopilotStream: vi.fn(), - createRunSegment: vi.fn(), - updateRunStatus: vi.fn(), - resetStreamBuffer: vi.fn(), - setStreamMeta: vi.fn(), - createStreamEventWriter: vi.fn(), -})) - -vi.mock('@/lib/copilot/orchestrator', () => ({ - orchestrateCopilotStream, -})) - -vi.mock('@/lib/copilot/async-runs/repository', () => ({ - createRunSegment, - updateRunStatus, -})) - -vi.mock('@/lib/copilot/orchestrator/stream/buffer', () => ({ - createStreamEventWriter, - resetStreamBuffer, - setStreamMeta, -})) - -vi.mock('@sim/db', () => ({ - db: { - update: vi.fn(() => ({ - set: vi.fn(() => ({ - where: vi.fn(), - })), - })), - }, -})) - -vi.mock('@/lib/copilot/task-events', () => ({ - taskPubSub: null, -})) - -import { createSSEStream } from '@/lib/copilot/chat-streaming' - -async function drainStream(stream: ReadableStream) { - const reader = stream.getReader() - while (true) { - const { done } = await reader.read() - if (done) break - } -} - -describe('createSSEStream terminal error handling', () => { - const write = vi.fn().mockResolvedValue({ eventId: 1, streamId: 'stream-1', event: {} }) - const flush = vi.fn().mockResolvedValue(undefined) - const close = vi.fn().mockResolvedValue(undefined) - - beforeEach(() => { - vi.clearAllMocks() - write.mockResolvedValue({ eventId: 1, streamId: 'stream-1', event: {} }) - flush.mockResolvedValue(undefined) - close.mockResolvedValue(undefined) - createStreamEventWriter.mockReturnValue({ write, flush, close }) - resetStreamBuffer.mockResolvedValue(undefined) - setStreamMeta.mockResolvedValue(undefined) - createRunSegment.mockResolvedValue(null) - updateRunStatus.mockResolvedValue(null) - }) - - it('writes a terminal error event before close when orchestration returns success=false', async () => { - orchestrateCopilotStream.mockResolvedValue({ - success: false, - error: 'resume failed', - content: '', - contentBlocks: [], - toolCalls: [], - }) - - const stream = createSSEStream({ - requestPayload: { message: 'hello' }, - userId: 'user-1', - streamId: 'stream-1', - executionId: 'exec-1', - runId: 'run-1', - currentChat: null, - isNewChat: false, - message: 'hello', - titleModel: 'gpt-5.4', - requestId: 'req-1', - orchestrateOptions: {}, - }) - - await drainStream(stream) - - expect(write).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'error', - error: 'resume failed', - }) - ) - expect(write.mock.invocationCallOrder.at(-1)).toBeLessThan(close.mock.invocationCallOrder[0]) - }) - - it('writes the thrown terminal error event before close for replay durability', async () => { - orchestrateCopilotStream.mockRejectedValue(new Error('kaboom')) - - const stream = createSSEStream({ - requestPayload: { message: 'hello' }, - userId: 'user-1', - streamId: 'stream-1', - executionId: 'exec-1', - runId: 'run-1', - currentChat: null, - isNewChat: false, - message: 'hello', - titleModel: 'gpt-5.4', - requestId: 'req-1', - orchestrateOptions: {}, - }) - - await drainStream(stream) - - expect(write).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'error', - error: 'kaboom', - }) - ) - expect(write.mock.invocationCallOrder.at(-1)).toBeLessThan(close.mock.invocationCallOrder[0]) - }) -}) diff --git a/apps/sim/lib/copilot/chat-streaming.ts b/apps/sim/lib/copilot/chat-streaming.ts deleted file mode 100644 index 5779d20f65f..00000000000 --- a/apps/sim/lib/copilot/chat-streaming.ts +++ /dev/null @@ -1,579 +0,0 @@ -import { db } from '@sim/db' -import { copilotChats } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' -import { createRunSegment, updateRunStatus } from '@/lib/copilot/async-runs/repository' -import { SIM_AGENT_API_URL } from '@/lib/copilot/constants' -import type { OrchestrateStreamOptions } from '@/lib/copilot/orchestrator' -import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator' -import { - createStreamEventWriter, - getStreamMeta, - resetStreamBuffer, - setStreamMeta, -} from '@/lib/copilot/orchestrator/stream/buffer' -import { taskPubSub } from '@/lib/copilot/task-events' -import { env } from '@/lib/core/config/env' -import { acquireLock, getRedisClient, releaseLock } from '@/lib/core/config/redis' -import { SSE_HEADERS } from '@/lib/core/utils/sse' - -const logger = createLogger('CopilotChatStreaming') -const CHAT_STREAM_LOCK_TTL_SECONDS = 2 * 60 * 60 -const STREAM_ABORT_TTL_SECONDS = 10 * 60 -const STREAM_ABORT_POLL_MS = 1000 - -interface ActiveStreamEntry { - abortController: AbortController - userStopController: AbortController -} - -const activeStreams = new Map() - -// Tracks in-flight streams by chatId so that a subsequent request for the -// same chat can force-abort the previous stream and wait for it to settle -// before forwarding to Go. -const pendingChatStreams = new Map< - string, - { promise: Promise; resolve: () => void; streamId: string } ->() - -function registerPendingChatStream(chatId: string, streamId: string): void { - if (pendingChatStreams.has(chatId)) { - logger.warn(`registerPendingChatStream: overwriting existing entry for chatId ${chatId}`) - } - let resolve!: () => void - const promise = new Promise((r) => { - resolve = r - }) - pendingChatStreams.set(chatId, { promise, resolve, streamId }) -} - -function resolvePendingChatStream(chatId: string, streamId: string): void { - const entry = pendingChatStreams.get(chatId) - if (entry && entry.streamId === streamId) { - entry.resolve() - pendingChatStreams.delete(chatId) - } -} - -function getChatStreamLockKey(chatId: string): string { - return `copilot:chat-stream-lock:${chatId}` -} - -function getStreamAbortKey(streamId: string): string { - return `copilot:stream-abort:${streamId}` -} - -/** - * Wait for any in-flight stream on `chatId` to settle without force-aborting it. - * Returns true when no stream is active (or it settles in time), false on timeout. - */ -export async function waitForPendingChatStream( - chatId: string, - timeoutMs = 5_000, - expectedStreamId?: string -): Promise { - const redis = getRedisClient() - const deadline = Date.now() + timeoutMs - - for (;;) { - const entry = pendingChatStreams.get(chatId) - const localPending = !!entry && (!expectedStreamId || entry.streamId === expectedStreamId) - - if (redis) { - try { - const ownerStreamId = await redis.get(getChatStreamLockKey(chatId)) - const lockReleased = - !ownerStreamId || (expectedStreamId !== undefined && ownerStreamId !== expectedStreamId) - if (!localPending && lockReleased) { - return true - } - } catch (error) { - logger.warn('Failed to check distributed chat stream lock while waiting', { - chatId, - expectedStreamId, - error: error instanceof Error ? error.message : String(error), - }) - } - } else if (!localPending) { - return true - } - - if (Date.now() >= deadline) return false - await new Promise((resolve) => setTimeout(resolve, 200)) - } -} - -export async function releasePendingChatStream(chatId: string, streamId: string): Promise { - const redis = getRedisClient() - if (redis) { - await releaseLock(getChatStreamLockKey(chatId), streamId).catch(() => false) - } - resolvePendingChatStream(chatId, streamId) -} - -export async function acquirePendingChatStream( - chatId: string, - streamId: string, - timeoutMs = 5_000 -): Promise { - const redis = getRedisClient() - if (redis) { - const deadline = Date.now() + timeoutMs - for (;;) { - try { - const acquired = await acquireLock( - getChatStreamLockKey(chatId), - streamId, - CHAT_STREAM_LOCK_TTL_SECONDS - ) - if (acquired) { - registerPendingChatStream(chatId, streamId) - return true - } - if (!pendingChatStreams.has(chatId)) { - const ownerStreamId = await redis.get(getChatStreamLockKey(chatId)) - if (ownerStreamId) { - const ownerMeta = await getStreamMeta(ownerStreamId) - const ownerTerminal = - ownerMeta?.status === 'complete' || - ownerMeta?.status === 'error' || - ownerMeta?.status === 'cancelled' - if (ownerTerminal) { - await releaseLock(getChatStreamLockKey(chatId), ownerStreamId).catch(() => false) - continue - } - } - } - } catch (error) { - logger.warn('Distributed chat stream lock failed; retrying distributed coordination', { - chatId, - streamId, - error: error instanceof Error ? error.message : String(error), - }) - } - if (Date.now() >= deadline) return false - await new Promise((resolve) => setTimeout(resolve, 200)) - } - } - - for (;;) { - const existing = pendingChatStreams.get(chatId) - if (!existing) { - registerPendingChatStream(chatId, streamId) - return true - } - - const settled = await Promise.race([ - existing.promise.then(() => true), - new Promise((r) => setTimeout(() => r(false), timeoutMs)), - ]) - if (!settled) return false - } -} - -export async function abortActiveStream(streamId: string): Promise { - const redis = getRedisClient() - let published = false - if (redis) { - try { - await redis.set(getStreamAbortKey(streamId), '1', 'EX', STREAM_ABORT_TTL_SECONDS) - published = true - } catch (error) { - logger.warn('Failed to publish distributed stream abort', { - streamId, - error: error instanceof Error ? error.message : String(error), - }) - } - } - const entry = activeStreams.get(streamId) - if (!entry) return published - entry.userStopController.abort() - entry.abortController.abort() - activeStreams.delete(streamId) - return true -} - -const FLUSH_EVENT_TYPES = new Set([ - 'tool_call', - 'tool_result', - 'tool_error', - 'subagent_end', - 'structured_result', - 'subagent_result', - 'done', - 'error', -]) - -export async function requestChatTitle(params: { - message: string - model: string - provider?: string - messageId?: string -}): Promise { - const { message, model, provider, messageId } = params - if (!message || !model) return null - - const headers: Record = { 'Content-Type': 'application/json' } - if (env.COPILOT_API_KEY) { - headers['x-api-key'] = env.COPILOT_API_KEY - } - - try { - const response = await fetch(`${SIM_AGENT_API_URL}/api/generate-chat-title`, { - method: 'POST', - headers, - body: JSON.stringify({ message, model, ...(provider ? { provider } : {}) }), - }) - - const payload = await response.json().catch(() => ({})) - if (!response.ok) { - logger.withMetadata({ messageId }).warn('Failed to generate chat title via copilot backend', { - status: response.status, - error: payload, - }) - return null - } - - const title = typeof payload?.title === 'string' ? payload.title.trim() : '' - return title || null - } catch (error) { - logger.withMetadata({ messageId }).error('Error generating chat title', error) - return null - } -} - -export interface StreamingOrchestrationParams { - requestPayload: Record - userId: string - streamId: string - executionId: string - runId: string - chatId?: string - currentChat: any - isNewChat: boolean - message: string - titleModel: string - titleProvider?: string - requestId: string - workspaceId?: string - orchestrateOptions: Omit - pendingChatStreamAlreadyRegistered?: boolean -} - -export function createSSEStream(params: StreamingOrchestrationParams): ReadableStream { - const { - requestPayload, - userId, - streamId, - executionId, - runId, - chatId, - currentChat, - isNewChat, - message, - titleModel, - titleProvider, - requestId, - workspaceId, - orchestrateOptions, - pendingChatStreamAlreadyRegistered = false, - } = params - const messageId = - typeof requestPayload.messageId === 'string' ? requestPayload.messageId : streamId - const reqLogger = logger.withMetadata({ requestId, messageId }) - - let eventWriter: ReturnType | null = null - let clientDisconnected = false - const abortController = new AbortController() - const userStopController = new AbortController() - const clientDisconnectedController = new AbortController() - activeStreams.set(streamId, { abortController, userStopController }) - - if (chatId && !pendingChatStreamAlreadyRegistered) { - registerPendingChatStream(chatId, streamId) - } - - return new ReadableStream({ - async start(controller) { - const encoder = new TextEncoder() - const markClientDisconnected = (reason: string) => { - if (clientDisconnected) return - clientDisconnected = true - if (!clientDisconnectedController.signal.aborted) { - clientDisconnectedController.abort() - } - reqLogger.info('Client disconnected from live SSE stream', { - streamId, - runId, - reason, - }) - } - - await resetStreamBuffer(streamId) - await setStreamMeta(streamId, { status: 'active', userId, executionId, runId }) - if (chatId) { - await createRunSegment({ - id: runId, - executionId, - chatId, - userId, - workflowId: (requestPayload.workflowId as string | undefined) || null, - workspaceId, - streamId, - model: (requestPayload.model as string | undefined) || null, - provider: (requestPayload.provider as string | undefined) || null, - requestContext: { requestId }, - }).catch((error) => { - reqLogger.warn('Failed to create copilot run segment', { - error: error instanceof Error ? error.message : String(error), - }) - }) - } - eventWriter = createStreamEventWriter(streamId) - - let localSeq = 0 - let abortPoller: ReturnType | null = null - - const redis = getRedisClient() - if (redis) { - abortPoller = setInterval(() => { - void (async () => { - try { - const shouldAbort = await redis.get(getStreamAbortKey(streamId)) - if (shouldAbort && !abortController.signal.aborted) { - userStopController.abort() - abortController.abort() - await redis.del(getStreamAbortKey(streamId)) - } - } catch (error) { - reqLogger.warn('Failed to poll distributed stream abort', { - streamId, - error: error instanceof Error ? error.message : String(error), - }) - } - })() - }, STREAM_ABORT_POLL_MS) - } - - const pushEvent = async (event: Record) => { - if (!eventWriter) return - - const eventId = ++localSeq - - try { - await eventWriter.write(event) - if (FLUSH_EVENT_TYPES.has(event.type)) { - await eventWriter.flush() - } - } catch (error) { - reqLogger.error('Failed to persist stream event', { - eventType: event.type, - eventId, - error: error instanceof Error ? error.message : String(error), - }) - // Keep the live SSE stream going even if durable buffering hiccups. - } - - try { - if (!clientDisconnected) { - controller.enqueue( - encoder.encode(`data: ${JSON.stringify({ ...event, eventId, streamId })}\n\n`) - ) - } - } catch { - markClientDisconnected('enqueue_failed') - } - } - - const pushEventBestEffort = async (event: Record) => { - try { - await pushEvent(event) - } catch (error) { - reqLogger.error('Failed to push event', { - eventType: event.type, - error: error instanceof Error ? error.message : String(error), - }) - } - } - - if (chatId) { - await pushEvent({ type: 'chat_id', chatId }) - } - - if (chatId && !currentChat?.title && isNewChat) { - requestChatTitle({ message, model: titleModel, provider: titleProvider, messageId }) - .then(async (title) => { - if (title) { - await db.update(copilotChats).set({ title }).where(eq(copilotChats.id, chatId!)) - await pushEvent({ type: 'title_updated', title }) - if (workspaceId) { - taskPubSub?.publishStatusChanged({ workspaceId, chatId: chatId!, type: 'renamed' }) - } - } - }) - .catch((error) => { - reqLogger.error('Title generation failed', error) - }) - } - - const keepaliveInterval = setInterval(() => { - if (clientDisconnected) return - try { - controller.enqueue(encoder.encode(': keepalive\n\n')) - } catch { - markClientDisconnected('keepalive_failed') - } - }, 15_000) - - try { - const result = await orchestrateCopilotStream(requestPayload, { - ...orchestrateOptions, - executionId, - runId, - abortSignal: abortController.signal, - userStopSignal: userStopController.signal, - clientDisconnectedSignal: clientDisconnectedController.signal, - onEvent: async (event) => { - await pushEvent(event) - }, - }) - - if (abortController.signal.aborted) { - reqLogger.info('Stream aborted by explicit stop') - await eventWriter.close().catch(() => {}) - await setStreamMeta(streamId, { status: 'cancelled', userId, executionId, runId }) - await updateRunStatus(runId, 'cancelled', { completedAt: new Date() }).catch(() => {}) - return - } - - if (!result.success) { - const errorMessage = - result.error || - result.errors?.[0] || - 'An unexpected error occurred while processing the response.' - - if (clientDisconnected) { - reqLogger.info('Stream failed after client disconnect', { - error: errorMessage, - }) - } - - reqLogger.error('Orchestration returned failure', { - error: errorMessage, - }) - await pushEventBestEffort({ - type: 'error', - error: errorMessage, - data: { - displayMessage: errorMessage, - }, - }) - await eventWriter.close() - await setStreamMeta(streamId, { - status: 'error', - userId, - executionId, - runId, - error: errorMessage, - }) - await updateRunStatus(runId, 'error', { - completedAt: new Date(), - error: errorMessage, - }).catch(() => {}) - return - } - - await eventWriter.close() - await setStreamMeta(streamId, { status: 'complete', userId, executionId, runId }) - await updateRunStatus(runId, 'complete', { completedAt: new Date() }).catch(() => {}) - if (clientDisconnected) { - reqLogger.info('Orchestration completed after client disconnect', { - streamId, - runId, - }) - } - } catch (error) { - if (abortController.signal.aborted) { - reqLogger.info('Stream aborted by explicit stop') - await eventWriter.close().catch(() => {}) - await setStreamMeta(streamId, { status: 'cancelled', userId, executionId, runId }) - await updateRunStatus(runId, 'cancelled', { completedAt: new Date() }).catch(() => {}) - return - } - if (clientDisconnected) { - reqLogger.info('Stream errored after client disconnect', { - error: error instanceof Error ? error.message : 'Stream error', - }) - } - reqLogger.error('Orchestration error', error) - const errorMessage = error instanceof Error ? error.message : 'Stream error' - await pushEventBestEffort({ - type: 'error', - error: errorMessage, - data: { - displayMessage: 'An unexpected error occurred while processing the response.', - }, - }) - await eventWriter.close() - await setStreamMeta(streamId, { - status: 'error', - userId, - executionId, - runId, - error: errorMessage, - }) - await updateRunStatus(runId, 'error', { - completedAt: new Date(), - error: errorMessage, - }).catch(() => {}) - } finally { - reqLogger.info('Closing live SSE stream', { - streamId, - runId, - clientDisconnected, - aborted: abortController.signal.aborted, - }) - clearInterval(keepaliveInterval) - if (abortPoller) { - clearInterval(abortPoller) - } - activeStreams.delete(streamId) - if (chatId) { - if (redis) { - await releaseLock(getChatStreamLockKey(chatId), streamId).catch(() => false) - } - resolvePendingChatStream(chatId, streamId) - } - if (redis) { - await redis.del(getStreamAbortKey(streamId)).catch(() => {}) - } - try { - controller.close() - } catch { - // Controller already closed from cancel() — safe to ignore - } - } - }, - cancel() { - reqLogger.info('ReadableStream cancel received from client', { - streamId, - runId, - }) - if (!clientDisconnected) { - clientDisconnected = true - if (!clientDisconnectedController.signal.aborted) { - clientDisconnectedController.abort() - } - } - if (eventWriter) { - eventWriter.flush().catch(() => {}) - } - }, - }) -} - -export const SSE_RESPONSE_HEADERS = { - ...SSE_HEADERS, - 'Content-Encoding': 'none', -} as const diff --git a/apps/sim/lib/copilot/chat/display-message.test.ts b/apps/sim/lib/copilot/chat/display-message.test.ts new file mode 100644 index 00000000000..90648027a4a --- /dev/null +++ b/apps/sim/lib/copilot/chat/display-message.test.ts @@ -0,0 +1,63 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import { toDisplayMessage } from './display-message' + +describe('display-message', () => { + it('maps canonical tool, subagent text, and cancelled complete blocks to display blocks', () => { + const display = toDisplayMessage({ + id: 'msg-1', + role: 'assistant', + content: 'done', + timestamp: '2024-01-01T00:00:00.000Z', + requestId: 'req-1', + contentBlocks: [ + { + type: 'tool', + phase: 'call', + toolCall: { + id: 'tool-1', + name: 'read', + state: 'cancelled', + display: { title: 'Stopped by user' }, + }, + }, + { + type: 'text', + lane: 'subagent', + channel: 'assistant', + content: 'subagent output', + }, + { + type: 'complete', + status: 'cancelled', + }, + ], + }) + + expect(display.contentBlocks).toEqual([ + { + type: 'tool_call', + toolCall: { + id: 'tool-1', + name: 'read', + status: 'cancelled', + displayTitle: 'Stopped by user', + phaseLabel: undefined, + params: undefined, + calledBy: undefined, + result: undefined, + }, + }, + { + type: 'subagent_text', + content: 'subagent output', + }, + { + type: 'stopped', + }, + ]) + }) +}) diff --git a/apps/sim/lib/copilot/chat/display-message.ts b/apps/sim/lib/copilot/chat/display-message.ts new file mode 100644 index 00000000000..eec31d52ae0 --- /dev/null +++ b/apps/sim/lib/copilot/chat/display-message.ts @@ -0,0 +1,118 @@ +import { + MothershipStreamV1CompletionStatus, + MothershipStreamV1EventType, + MothershipStreamV1SpanLifecycleEvent, + MothershipStreamV1ToolOutcome, +} from '@/lib/copilot/generated/mothership-stream-v1' +import { + type ChatMessage, + type ChatMessageAttachment, + type ChatMessageContext, + type ContentBlock, + ContentBlockType, + type ToolCallInfo, + ToolCallStatus, +} from '@/app/workspace/[workspaceId]/home/types' +import type { PersistedContentBlock, PersistedMessage } from './persisted-message' + +const STATE_TO_STATUS: Record = { + [MothershipStreamV1ToolOutcome.success]: ToolCallStatus.success, + [MothershipStreamV1ToolOutcome.error]: ToolCallStatus.error, + [MothershipStreamV1ToolOutcome.cancelled]: ToolCallStatus.cancelled, + [MothershipStreamV1ToolOutcome.rejected]: ToolCallStatus.error, + [MothershipStreamV1ToolOutcome.skipped]: ToolCallStatus.success, + pending: ToolCallStatus.executing, + executing: ToolCallStatus.executing, +} + +function toToolCallInfo(block: PersistedContentBlock): ToolCallInfo | undefined { + const tc = block.toolCall + if (!tc) return undefined + const status: ToolCallStatus = STATE_TO_STATUS[tc.state] ?? ToolCallStatus.error + return { + id: tc.id, + name: tc.name, + status, + displayTitle: status === ToolCallStatus.cancelled ? 'Stopped by user' : tc.display?.title, + phaseLabel: tc.display?.phaseLabel, + params: tc.params, + calledBy: tc.calledBy, + result: tc.result, + } +} + +function toDisplayBlock(block: PersistedContentBlock): ContentBlock { + switch (block.type) { + case MothershipStreamV1EventType.text: + if (block.lane === 'subagent') { + return { type: ContentBlockType.subagent_text, content: block.content } + } + return { type: ContentBlockType.text, content: block.content } + case MothershipStreamV1EventType.tool: + return { type: ContentBlockType.tool_call, toolCall: toToolCallInfo(block) } + case MothershipStreamV1EventType.span: + if (block.lifecycle === MothershipStreamV1SpanLifecycleEvent.end) { + return { type: ContentBlockType.subagent_end } + } + return { type: ContentBlockType.subagent, content: block.content } + case MothershipStreamV1EventType.complete: + if (block.status === MothershipStreamV1CompletionStatus.cancelled) { + return { type: ContentBlockType.stopped } + } + return { type: ContentBlockType.text, content: block.content } + default: + return { type: ContentBlockType.text, content: block.content } + } +} + +function toDisplayAttachment(f: PersistedMessage['fileAttachments']): ChatMessageAttachment[] { + if (!f || f.length === 0) return [] + return f.map((a) => ({ + id: a.id, + filename: a.filename, + media_type: a.media_type, + size: a.size, + previewUrl: a.media_type.startsWith('image/') + ? `/api/files/serve/${encodeURIComponent(a.key)}?context=mothership` + : undefined, + })) +} + +function toDisplayContexts( + contexts: PersistedMessage['contexts'] +): ChatMessageContext[] | undefined { + if (!contexts || contexts.length === 0) return undefined + return contexts.map((c) => ({ + kind: c.kind, + label: c.label, + ...(c.workflowId ? { workflowId: c.workflowId } : {}), + ...(c.knowledgeId ? { knowledgeId: c.knowledgeId } : {}), + ...(c.tableId ? { tableId: c.tableId } : {}), + ...(c.fileId ? { fileId: c.fileId } : {}), + })) +} + +export function toDisplayMessage(msg: PersistedMessage): ChatMessage { + const display: ChatMessage = { + id: msg.id, + role: msg.role, + content: msg.content, + } + + if (msg.requestId) { + display.requestId = msg.requestId + } + + if (msg.contentBlocks && msg.contentBlocks.length > 0) { + display.contentBlocks = msg.contentBlocks.map(toDisplayBlock) + } + + const attachments = toDisplayAttachment(msg.fileAttachments) + if (attachments.length > 0) { + display.attachments = attachments + } + + display.contexts = toDisplayContexts(msg.contexts) + + return display +} diff --git a/apps/sim/lib/copilot/chat-lifecycle.ts b/apps/sim/lib/copilot/chat/lifecycle.ts similarity index 100% rename from apps/sim/lib/copilot/chat-lifecycle.ts rename to apps/sim/lib/copilot/chat/lifecycle.ts diff --git a/apps/sim/lib/copilot/chat-payload.test.ts b/apps/sim/lib/copilot/chat/payload.test.ts similarity index 80% rename from apps/sim/lib/copilot/chat-payload.test.ts rename to apps/sim/lib/copilot/chat/payload.test.ts index 0c7b187e7fd..521509fe48d 100644 --- a/apps/sim/lib/copilot/chat-payload.test.ts +++ b/apps/sim/lib/copilot/chat/payload.test.ts @@ -17,10 +17,6 @@ vi.mock('@/lib/billing/core/subscription', () => ({ getUserSubscriptionState: vi.fn(), })) -vi.mock('@/lib/copilot/chat-context', () => ({ - processFileAttachments: vi.fn(), -})) - vi.mock('@/lib/core/config/feature-flags', () => ({ isHosted: false, })) @@ -45,6 +41,12 @@ vi.mock('@/tools/registry', () => ({ name: 'Brandfetch Search', description: 'Search for brands by company name', }, + // Catalog marks run_workflow as client / clientExecutable; registry ToolConfig has no executor fields. + run_workflow: { + id: 'run_workflow', + name: 'Run Workflow', + description: 'Run a workflow from the client', + }, }, })) @@ -58,7 +60,7 @@ vi.mock('@/tools/params', () => ({ })) import { getUserSubscriptionState } from '@/lib/billing/core/subscription' -import { buildIntegrationToolSchemas } from '@/lib/copilot/chat-payload' +import { buildIntegrationToolSchemas } from './payload' const mockedGetUserSubscriptionState = getUserSubscriptionState as unknown as { mockResolvedValue: (value: unknown) => void @@ -102,4 +104,15 @@ describe('buildIntegrationToolSchemas', () => { expect(gmailTool?.description).toBe('Send emails using Gmail') expect(brandfetchTool?.description).toBe('Search for brands by company name') }) + + it('emits executeLocally for dynamic client tools only', async () => { + mockedGetUserSubscriptionState.mockResolvedValue({ isFree: false }) + + const toolSchemas = await buildIntegrationToolSchemas('user-client') + const gmailTool = toolSchemas.find((tool) => tool.name === 'gmail_send') + const runTool = toolSchemas.find((tool) => tool.name === 'run_workflow') + + expect(gmailTool?.executeLocally).toBe(false) + expect(runTool?.executeLocally).toBe(true) + }) }) diff --git a/apps/sim/lib/copilot/chat-payload.ts b/apps/sim/lib/copilot/chat/payload.ts similarity index 80% rename from apps/sim/lib/copilot/chat-payload.ts rename to apps/sim/lib/copilot/chat/payload.ts index 69b1d342f17..35dc2bc6637 100644 --- a/apps/sim/lib/copilot/chat-payload.ts +++ b/apps/sim/lib/copilot/chat/payload.ts @@ -1,6 +1,7 @@ import { createLogger } from '@sim/logger' import { getUserSubscriptionState } from '@/lib/billing/core/subscription' -import { getCopilotToolDescription } from '@/lib/copilot/tool-descriptions' +import { getToolEntry } from '@/lib/copilot/tool-executor/router' +import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions' import { isHosted } from '@/lib/core/config/feature-flags' import { createMcpToolId } from '@/lib/mcp/utils' import { trackChatUpload } from '@/lib/uploads/contexts/workspace/workspace-file-manager' @@ -10,7 +11,7 @@ import { getLatestVersionTools, stripVersionSuffix } from '@/tools/utils' const logger = createLogger('CopilotChatPayload') -export interface BuildPayloadParams { +interface BuildPayloadParams { message: string workflowId?: string workflowName?: string @@ -60,16 +61,22 @@ export async function buildIntegrationToolSchemas( const subscriptionState = await getUserSubscriptionState(userId) shouldAppendEmailTagline = subscriptionState.isFree } catch (error) { - reqLogger.warn('Failed to load subscription state for copilot tool descriptions', { - userId, - error: error instanceof Error ? error.message : String(error), - }) + logger.warn( + messageId + ? `Failed to load subscription state for copilot tool descriptions [messageId:${messageId}]` + : 'Failed to load subscription state for copilot tool descriptions', + { + userId, + error: error instanceof Error ? error.message : String(error), + } + ) } for (const [toolId, toolConfig] of Object.entries(latestTools)) { try { const userSchema = createUserToolSchema(toolConfig) const strippedName = stripVersionSuffix(toolId) + const catalogEntry = getToolEntry(strippedName) integrationTools.push({ name: strippedName, description: getCopilotToolDescription(toolConfig, { @@ -79,6 +86,8 @@ export async function buildIntegrationToolSchemas( }), input_schema: userSchema as unknown as Record, defer_loading: true, + executeLocally: + catalogEntry?.clientExecutable === true || catalogEntry?.executor === 'client', ...(toolConfig.oauth?.required && { oauth: { required: true, @@ -87,16 +96,26 @@ export async function buildIntegrationToolSchemas( }), }) } catch (toolError) { - reqLogger.warn('Failed to build schema for tool, skipping', { - toolId, - error: toolError instanceof Error ? toolError.message : String(toolError), - }) + logger.warn( + messageId + ? `Failed to build schema for tool, skipping [messageId:${messageId}]` + : 'Failed to build schema for tool, skipping', + { + toolId, + error: toolError instanceof Error ? toolError.message : String(toolError), + } + ) } } } catch (error) { - reqLogger.warn('Failed to build tool schemas', { - error: error instanceof Error ? error.message : String(error), - }) + logger.warn( + messageId + ? `Failed to build tool schemas [messageId:${messageId}]` + : 'Failed to build tool schemas', + { + error: error instanceof Error ? error.message : String(error), + } + ) } return integrationTools } @@ -192,16 +211,27 @@ export async function buildCopilotRequestPayload( description: mcpTool.description || `MCP tool: ${mcpTool.name} (${mcpTool.serverName})`, input_schema: mcpTool.inputSchema as unknown as Record, + executeLocally: false, }) } if (mcpTools.length > 0) { - payloadLogger.info('Added MCP tools to copilot payload', { count: mcpTools.length }) + logger.error( + userMessageId + ? `Added MCP tools to copilot payload [messageId:${userMessageId}]` + : 'Added MCP tools to copilot payload', + { count: mcpTools.length } + ) } } } catch (error) { - payloadLogger.warn('Failed to discover MCP tools for copilot', { - error: error instanceof Error ? error.message : String(error), - }) + logger.warn( + userMessageId + ? `Failed to discover MCP tools for copilot [messageId:${userMessageId}]` + : 'Failed to discover MCP tools for copilot', + { + error: error instanceof Error ? error.message : String(error), + } + ) } } } diff --git a/apps/sim/lib/copilot/chat/persisted-message.test.ts b/apps/sim/lib/copilot/chat/persisted-message.test.ts new file mode 100644 index 00000000000..cf2d9e35311 --- /dev/null +++ b/apps/sim/lib/copilot/chat/persisted-message.test.ts @@ -0,0 +1,122 @@ +/** + * @vitest-environment node + */ + +import { describe, expect, it } from 'vitest' +import type { OrchestratorResult } from '@/lib/copilot/request/types' +import { + buildPersistedAssistantMessage, + buildPersistedUserMessage, + normalizeMessage, +} from './persisted-message' + +describe('persisted-message', () => { + it('round-trips canonical tool blocks through normalizeMessage', () => { + const result: OrchestratorResult = { + success: true, + content: 'done', + requestId: 'req-1', + contentBlocks: [ + { + type: 'tool_call', + timestamp: Date.now(), + calledBy: 'build', + toolCall: { + id: 'tool-1', + name: 'read', + status: 'success', + params: { path: 'foo.txt' }, + result: { success: true, output: { ok: true } }, + }, + }, + ], + toolCalls: [], + } + + const persisted = buildPersistedAssistantMessage(result) + const normalized = normalizeMessage(persisted as unknown as Record) + + expect(normalized.contentBlocks).toEqual([ + { + type: 'tool', + phase: 'call', + toolCall: { + id: 'tool-1', + name: 'read', + state: 'success', + params: { path: 'foo.txt' }, + result: { success: true, output: { ok: true } }, + calledBy: 'build', + }, + }, + { + type: 'text', + channel: 'assistant', + content: 'done', + }, + ]) + }) + + it('normalizes legacy tool_call and top-level toolCalls shapes', () => { + const normalized = normalizeMessage({ + id: 'msg-1', + role: 'assistant', + content: 'hello', + timestamp: '2024-01-01T00:00:00.000Z', + contentBlocks: [ + { + type: 'tool_call', + toolCall: { + id: 'tool-1', + name: 'read', + state: 'cancelled', + display: { text: 'Stopped by user' }, + }, + }, + ], + toolCalls: [ + { + id: 'tool-2', + name: 'glob', + status: 'success', + result: { matches: [] }, + }, + ], + }) + + expect(normalized.contentBlocks).toEqual([ + { + type: 'tool', + phase: 'call', + toolCall: { + id: 'tool-1', + name: 'read', + state: 'cancelled', + display: { title: 'Stopped by user' }, + }, + }, + { + type: 'text', + channel: 'assistant', + content: 'hello', + }, + ]) + }) + + it('builds normalized user messages with stripped optional empties', () => { + const msg = buildPersistedUserMessage({ + id: 'user-1', + content: 'hello', + fileAttachments: [], + contexts: [], + }) + + expect(msg).toMatchObject({ + id: 'user-1', + role: 'user', + content: 'hello', + }) + expect(msg.fileAttachments).toBeUndefined() + expect(msg.contexts).toBeUndefined() + }) +}) diff --git a/apps/sim/lib/copilot/chat/persisted-message.ts b/apps/sim/lib/copilot/chat/persisted-message.ts new file mode 100644 index 00000000000..7caee7c1ef1 --- /dev/null +++ b/apps/sim/lib/copilot/chat/persisted-message.ts @@ -0,0 +1,469 @@ +import { + MothershipStreamV1CompletionStatus, + MothershipStreamV1EventType, + MothershipStreamV1SpanLifecycleEvent, + MothershipStreamV1SpanPayloadKind, + type MothershipStreamV1StreamScope, + MothershipStreamV1TextChannel, + MothershipStreamV1ToolOutcome, + MothershipStreamV1ToolPhase, +} from '@/lib/copilot/generated/mothership-stream-v1' +import type { + ContentBlock, + LocalToolCallStatus, + OrchestratorResult, +} from '@/lib/copilot/request/types' + +export type PersistedToolState = LocalToolCallStatus | MothershipStreamV1ToolOutcome + +export interface PersistedToolCall { + id: string + name: string + state: PersistedToolState + params?: Record + result?: { success: boolean; output?: unknown; error?: string } + error?: string + calledBy?: string + durationMs?: number + display?: { title?: string; phaseLabel?: string } +} + +export interface PersistedContentBlock { + type: MothershipStreamV1EventType + lane?: MothershipStreamV1StreamScope['lane'] + channel?: MothershipStreamV1TextChannel + phase?: MothershipStreamV1ToolPhase + kind?: MothershipStreamV1SpanPayloadKind + lifecycle?: MothershipStreamV1SpanLifecycleEvent + status?: MothershipStreamV1CompletionStatus + content?: string + toolCall?: PersistedToolCall +} + +export interface PersistedFileAttachment { + id: string + key: string + filename: string + media_type: string + size: number +} + +export interface PersistedMessageContext { + kind: string + label: string + workflowId?: string + knowledgeId?: string + tableId?: string + fileId?: string +} + +export interface PersistedMessage { + id: string + role: 'user' | 'assistant' + content: string + timestamp: string + requestId?: string + contentBlocks?: PersistedContentBlock[] + fileAttachments?: PersistedFileAttachment[] + contexts?: PersistedMessageContext[] +} + +// --------------------------------------------------------------------------- +// Write: OrchestratorResult → PersistedMessage +// --------------------------------------------------------------------------- + +function resolveToolState(block: ContentBlock): PersistedToolState { + const tc = block.toolCall + if (!tc) return 'pending' + if (tc.result?.success !== undefined) { + return tc.result.success + ? MothershipStreamV1ToolOutcome.success + : MothershipStreamV1ToolOutcome.error + } + return tc.status as PersistedToolState +} + +function mapContentBlock(block: ContentBlock): PersistedContentBlock { + switch (block.type) { + case 'text': + return { + type: MothershipStreamV1EventType.text, + channel: MothershipStreamV1TextChannel.assistant, + content: block.content, + } + case 'thinking': + return { + type: MothershipStreamV1EventType.text, + channel: MothershipStreamV1TextChannel.thinking, + content: block.content, + } + case 'subagent': + return { + type: MothershipStreamV1EventType.span, + kind: MothershipStreamV1SpanPayloadKind.subagent, + lifecycle: MothershipStreamV1SpanLifecycleEvent.start, + content: block.content, + } + case 'subagent_text': + return { + type: MothershipStreamV1EventType.text, + lane: 'subagent', + channel: MothershipStreamV1TextChannel.assistant, + content: block.content, + } + case 'tool_call': { + if (!block.toolCall) { + return { + type: MothershipStreamV1EventType.tool, + phase: MothershipStreamV1ToolPhase.call, + content: block.content, + } + } + const state = resolveToolState(block) + const isSubagentTool = !!block.calledBy + const isNonTerminal = + state === MothershipStreamV1ToolOutcome.cancelled || + state === 'pending' || + state === 'executing' + + const toolCall: PersistedToolCall = { + id: block.toolCall.id, + name: block.toolCall.name, + state, + ...(isSubagentTool && isNonTerminal ? {} : { result: block.toolCall.result }), + ...(isSubagentTool && isNonTerminal + ? {} + : block.toolCall.params + ? { params: block.toolCall.params } + : {}), + ...(block.calledBy ? { calledBy: block.calledBy } : {}), + } + + return { + type: MothershipStreamV1EventType.tool, + phase: MothershipStreamV1ToolPhase.call, + toolCall, + } + } + default: + return { type: MothershipStreamV1EventType.text, content: block.content } + } +} + +export function buildPersistedAssistantMessage( + result: OrchestratorResult, + requestId?: string +): PersistedMessage { + const message: PersistedMessage = { + id: crypto.randomUUID(), + role: 'assistant', + content: result.content, + timestamp: new Date().toISOString(), + } + + if (requestId || result.requestId) { + message.requestId = requestId || result.requestId + } + + if (result.contentBlocks.length > 0) { + message.contentBlocks = result.contentBlocks.map(mapContentBlock) + } + + return message +} + +export interface UserMessageParams { + id: string + content: string + fileAttachments?: PersistedFileAttachment[] + contexts?: PersistedMessageContext[] +} + +export function buildPersistedUserMessage(params: UserMessageParams): PersistedMessage { + const message: PersistedMessage = { + id: params.id, + role: 'user', + content: params.content, + timestamp: new Date().toISOString(), + } + + if (params.fileAttachments && params.fileAttachments.length > 0) { + message.fileAttachments = params.fileAttachments + } + + if (params.contexts && params.contexts.length > 0) { + message.contexts = params.contexts.map((c) => ({ + kind: c.kind, + label: c.label, + ...(c.workflowId ? { workflowId: c.workflowId } : {}), + ...(c.knowledgeId ? { knowledgeId: c.knowledgeId } : {}), + ...(c.tableId ? { tableId: c.tableId } : {}), + ...(c.fileId ? { fileId: c.fileId } : {}), + })) + } + + return message +} + +// --------------------------------------------------------------------------- +// Read: raw JSONB → PersistedMessage +// Handles both canonical (type: 'tool', 'text', 'span', 'complete') and +// legacy (type: 'tool_call', 'thinking', 'subagent', 'stopped') blocks. +// --------------------------------------------------------------------------- + +const CANONICAL_BLOCK_TYPES: Set = new Set(Object.values(MothershipStreamV1EventType)) + +interface RawBlock { + type: string + lane?: string + content?: string + channel?: string + phase?: string + kind?: string + lifecycle?: string + status?: string + toolCall?: { + id?: string + name?: string + state?: string + params?: Record + result?: { success: boolean; output?: unknown; error?: string } + display?: { text?: string; title?: string; phaseLabel?: string } + calledBy?: string + durationMs?: number + error?: string + } | null +} + +interface LegacyToolCall { + id: string + name: string + status: string + params?: Record + result?: unknown + error?: string + durationMs?: number +} + +const OUTCOME_NORMALIZATION: Record = { + [MothershipStreamV1ToolOutcome.success]: MothershipStreamV1ToolOutcome.success, + [MothershipStreamV1ToolOutcome.error]: MothershipStreamV1ToolOutcome.error, + [MothershipStreamV1ToolOutcome.cancelled]: MothershipStreamV1ToolOutcome.cancelled, + [MothershipStreamV1ToolOutcome.skipped]: MothershipStreamV1ToolOutcome.skipped, + [MothershipStreamV1ToolOutcome.rejected]: MothershipStreamV1ToolOutcome.rejected, + pending: 'pending', + executing: 'executing', +} + +function normalizeToolState(state: string | undefined): PersistedToolState { + if (!state) return 'pending' + return OUTCOME_NORMALIZATION[state] ?? MothershipStreamV1ToolOutcome.error +} + +function isCanonicalBlock(block: RawBlock): boolean { + return CANONICAL_BLOCK_TYPES.has(block.type) +} + +function normalizeCanonicalBlock(block: RawBlock): PersistedContentBlock { + const result: PersistedContentBlock = { + type: block.type as MothershipStreamV1EventType, + } + if (block.lane === 'main' || block.lane === 'subagent') { + result.lane = block.lane + } + if (block.content !== undefined) result.content = block.content + if (block.channel) result.channel = block.channel as MothershipStreamV1TextChannel + if (block.phase) result.phase = block.phase as MothershipStreamV1ToolPhase + if (block.kind) result.kind = block.kind as MothershipStreamV1SpanPayloadKind + if (block.lifecycle) result.lifecycle = block.lifecycle as MothershipStreamV1SpanLifecycleEvent + if (block.status) result.status = block.status as MothershipStreamV1CompletionStatus + if (block.toolCall) { + result.toolCall = { + id: block.toolCall.id ?? '', + name: block.toolCall.name ?? '', + state: normalizeToolState(block.toolCall.state), + ...(block.toolCall.params ? { params: block.toolCall.params } : {}), + ...(block.toolCall.result ? { result: block.toolCall.result } : {}), + ...(block.toolCall.calledBy ? { calledBy: block.toolCall.calledBy } : {}), + ...(block.toolCall.error ? { error: block.toolCall.error } : {}), + ...(block.toolCall.durationMs ? { durationMs: block.toolCall.durationMs } : {}), + ...(block.toolCall.display + ? { + display: { + title: block.toolCall.display.title ?? block.toolCall.display.text, + phaseLabel: block.toolCall.display.phaseLabel, + }, + } + : {}), + } + } + return result +} + +function normalizeLegacyBlock(block: RawBlock): PersistedContentBlock { + if (block.type === 'tool_call' && block.toolCall) { + return { + type: MothershipStreamV1EventType.tool, + phase: MothershipStreamV1ToolPhase.call, + toolCall: { + id: block.toolCall.id ?? '', + name: block.toolCall.name ?? '', + state: normalizeToolState(block.toolCall.state), + ...(block.toolCall.params ? { params: block.toolCall.params } : {}), + ...(block.toolCall.result ? { result: block.toolCall.result } : {}), + ...(block.toolCall.calledBy ? { calledBy: block.toolCall.calledBy } : {}), + ...(block.toolCall.display ? { display: { title: block.toolCall.display.text } } : {}), + }, + } + } + + if (block.type === 'thinking') { + return { + type: MothershipStreamV1EventType.text, + channel: MothershipStreamV1TextChannel.thinking, + content: block.content, + } + } + + if (block.type === 'subagent' || block.type === 'subagent_text') { + if (block.type === 'subagent_text') { + return { + type: MothershipStreamV1EventType.text, + lane: 'subagent', + channel: MothershipStreamV1TextChannel.assistant, + content: block.content, + } + } + return { + type: MothershipStreamV1EventType.span, + kind: MothershipStreamV1SpanPayloadKind.subagent, + lifecycle: MothershipStreamV1SpanLifecycleEvent.start, + content: block.content, + } + } + + if (block.type === 'subagent_end') { + return { + type: MothershipStreamV1EventType.span, + kind: MothershipStreamV1SpanPayloadKind.subagent, + lifecycle: MothershipStreamV1SpanLifecycleEvent.end, + } + } + + if (block.type === 'stopped') { + return { + type: MothershipStreamV1EventType.complete, + status: MothershipStreamV1CompletionStatus.cancelled, + } + } + + return { + type: MothershipStreamV1EventType.text, + channel: MothershipStreamV1TextChannel.assistant, + content: block.content, + } +} + +function normalizeBlock(block: RawBlock): PersistedContentBlock { + return isCanonicalBlock(block) ? normalizeCanonicalBlock(block) : normalizeLegacyBlock(block) +} + +function normalizeLegacyToolCall(tc: LegacyToolCall): PersistedContentBlock { + const state = normalizeToolState(tc.status) + return { + type: MothershipStreamV1EventType.tool, + phase: MothershipStreamV1ToolPhase.call, + toolCall: { + id: tc.id, + name: tc.name, + state, + ...(tc.params ? { params: tc.params } : {}), + ...(tc.result != null + ? { + result: { + success: tc.status === MothershipStreamV1ToolOutcome.success, + output: tc.result, + ...(tc.error ? { error: tc.error } : {}), + }, + } + : {}), + ...(tc.durationMs ? { durationMs: tc.durationMs } : {}), + }, + } +} + +function blocksContainTools(blocks: RawBlock[]): boolean { + return blocks.some((b) => b.type === 'tool_call' || b.type === MothershipStreamV1EventType.tool) +} + +function normalizeBlocks(rawBlocks: RawBlock[], messageContent: string): PersistedContentBlock[] { + const blocks = rawBlocks.map(normalizeBlock) + const hasAssistantText = blocks.some( + (b) => + b.type === MothershipStreamV1EventType.text && + b.channel !== MothershipStreamV1TextChannel.thinking && + b.content?.trim() + ) + if (!hasAssistantText && messageContent.trim()) { + blocks.push({ + type: MothershipStreamV1EventType.text, + channel: MothershipStreamV1TextChannel.assistant, + content: messageContent, + }) + } + return blocks +} + +export function normalizeMessage(raw: Record): PersistedMessage { + const msg: PersistedMessage = { + id: (raw.id as string) ?? crypto.randomUUID(), + role: (raw.role as 'user' | 'assistant') ?? 'assistant', + content: (raw.content as string) ?? '', + timestamp: (raw.timestamp as string) ?? new Date().toISOString(), + } + + if (raw.requestId && typeof raw.requestId === 'string') { + msg.requestId = raw.requestId + } + + const rawBlocks = raw.contentBlocks as RawBlock[] | undefined + const rawToolCalls = raw.toolCalls as LegacyToolCall[] | undefined + const hasBlocks = Array.isArray(rawBlocks) && rawBlocks.length > 0 + const hasToolCalls = Array.isArray(rawToolCalls) && rawToolCalls.length > 0 + + if (hasBlocks) { + msg.contentBlocks = normalizeBlocks(rawBlocks!, msg.content) + const contentBlocksAlreadyContainTools = blocksContainTools(rawBlocks!) + if (hasToolCalls && !contentBlocksAlreadyContainTools) { + msg.contentBlocks.push(...rawToolCalls!.map(normalizeLegacyToolCall)) + } + } else if (hasToolCalls) { + msg.contentBlocks = rawToolCalls!.map(normalizeLegacyToolCall) + if (msg.content.trim()) { + msg.contentBlocks.push({ + type: MothershipStreamV1EventType.text, + channel: MothershipStreamV1TextChannel.assistant, + content: msg.content, + }) + } + } + + const rawAttachments = raw.fileAttachments as PersistedFileAttachment[] | undefined + if (Array.isArray(rawAttachments) && rawAttachments.length > 0) { + msg.fileAttachments = rawAttachments + } + + const rawContexts = raw.contexts as PersistedMessageContext[] | undefined + if (Array.isArray(rawContexts) && rawContexts.length > 0) { + msg.contexts = rawContexts.map((c) => ({ + kind: c.kind, + label: c.label, + ...(c.workflowId ? { workflowId: c.workflowId } : {}), + ...(c.knowledgeId ? { knowledgeId: c.knowledgeId } : {}), + ...(c.tableId ? { tableId: c.tableId } : {}), + ...(c.fileId ? { fileId: c.fileId } : {}), + })) + } + + return msg +} diff --git a/apps/sim/lib/copilot/process-contents.ts b/apps/sim/lib/copilot/chat/process-contents.ts similarity index 91% rename from apps/sim/lib/copilot/process-contents.ts rename to apps/sim/lib/copilot/chat/process-contents.ts index 5378b678f5c..934e2f32bfe 100644 --- a/apps/sim/lib/copilot/process-contents.ts +++ b/apps/sim/lib/copilot/chat/process-contents.ts @@ -19,9 +19,9 @@ import { checkKnowledgeBaseAccess } from '@/app/api/knowledge/utils' import { isHiddenFromDisplay } from '@/blocks/types' import { getUserPermissionConfig } from '@/ee/access-control/utils/permission-check' import { escapeRegExp } from '@/executor/constants' -import type { ChatContext } from '@/stores/panel/copilot/types' +import type { ChatContext } from '@/stores/panel' -export type AgentContextType = +type AgentContextType = | 'past_chat' | 'workflow' | 'current_workflow' @@ -35,7 +35,7 @@ export type AgentContextType = | 'docs' | 'active_resource' -export interface AgentContext { +interface AgentContext { type: AgentContextType tag: string content: string @@ -43,62 +43,6 @@ export interface AgentContext { const logger = createLogger('ProcessContents') -export async function processContexts( - contexts: ChatContext[] | undefined -): Promise { - if (!Array.isArray(contexts) || contexts.length === 0) return [] - const tasks = contexts.map(async (ctx) => { - try { - if (ctx.kind === 'past_chat') { - return await processPastChatViaApi(ctx.chatId, ctx.label ? `@${ctx.label}` : '@') - } - if ((ctx.kind === 'workflow' || ctx.kind === 'current_workflow') && ctx.workflowId) { - return await processWorkflowFromDb( - ctx.workflowId, - undefined, - ctx.label ? `@${ctx.label}` : '@', - ctx.kind - ) - } - if (ctx.kind === 'knowledge' && ctx.knowledgeId) { - return await processKnowledgeFromDb( - ctx.knowledgeId, - undefined, - ctx.label ? `@${ctx.label}` : '@' - ) - } - if (ctx.kind === 'blocks' && ctx.blockIds?.length > 0) { - return await processBlockMetadata(ctx.blockIds[0], ctx.label ? `@${ctx.label}` : '@') - } - if (ctx.kind === 'templates' && ctx.templateId) { - return await processTemplateFromDb( - ctx.templateId, - undefined, - ctx.label ? `@${ctx.label}` : '@' - ) - } - if (ctx.kind === 'logs' && ctx.executionId) { - return await processExecutionLogFromDb( - ctx.executionId, - undefined, - ctx.label ? `@${ctx.label}` : '@' - ) - } - if (ctx.kind === 'workflow_block' && ctx.workflowId && ctx.blockId) { - return await processWorkflowBlockFromDb(ctx.workflowId, undefined, ctx.blockId, ctx.label) - } - // Other kinds can be added here: workflow, blocks, logs, knowledge, templates, docs - return null - } catch (error) { - logger.error('Failed processing context', { ctx, error }) - return null - } - }) - - const results = await Promise.all(tasks) - return results.filter((r): r is AgentContext => !!r) as AgentContext[] -} - // Server-side variant (recommended for use in API routes) export async function processContextsServer( contexts: ChatContext[] | undefined, @@ -265,7 +209,7 @@ async function processPastChatFromDb( currentWorkspaceId?: string ): Promise { try { - const { getAccessibleCopilotChat } = await import('@/lib/copilot/chat-lifecycle') + const { getAccessibleCopilotChat } = await import('./lifecycle') const chat = await getAccessibleCopilotChat(chatId, userId) if (!chat) { return null diff --git a/apps/sim/lib/copilot/workspace-context.ts b/apps/sim/lib/copilot/chat/workspace-context.ts similarity index 100% rename from apps/sim/lib/copilot/workspace-context.ts rename to apps/sim/lib/copilot/chat/workspace-context.ts diff --git a/apps/sim/lib/copilot/client-sse/types.ts b/apps/sim/lib/copilot/client-sse/types.ts deleted file mode 100644 index 128ec75d872..00000000000 --- a/apps/sim/lib/copilot/client-sse/types.ts +++ /dev/null @@ -1,48 +0,0 @@ -import type { - ChatContext, - CopilotToolCall, - SubAgentContentBlock, -} from '@/stores/panel/copilot/types' - -/** - * A content block used in copilot messages and during streaming. - * Uses a literal type union for `type` to stay compatible with CopilotMessage. - */ -export type ContentBlockType = 'text' | 'thinking' | 'tool_call' | 'contexts' - -export interface ClientContentBlock { - type: ContentBlockType - content?: string - timestamp: number - toolCall?: CopilotToolCall | null - startTime?: number - duration?: number - contexts?: ChatContext[] -} - -export interface StreamingContext { - messageId: string - requestId?: string - accumulatedContent: string - contentBlocks: ClientContentBlock[] - currentTextBlock: ClientContentBlock | null - isInThinkingBlock: boolean - currentThinkingBlock: ClientContentBlock | null - isInDesignWorkflowBlock: boolean - designWorkflowContent: string - pendingContent: string - newChatId?: string - doneEventCount: number - streamComplete?: boolean - wasAborted?: boolean - suppressContinueOption?: boolean - subAgentParentToolCallId?: string - subAgentParentStack: string[] - subAgentContent: Record - subAgentToolCalls: Record - subAgentBlocks: Record - suppressStreamingUpdates?: boolean - activeCompactionId?: string -} - -export type ClientStreamingContext = StreamingContext diff --git a/apps/sim/lib/copilot/constants.ts b/apps/sim/lib/copilot/constants.ts index fbf4e312957..8aba3b02561 100644 --- a/apps/sim/lib/copilot/constants.ts +++ b/apps/sim/lib/copilot/constants.ts @@ -10,13 +10,6 @@ export const SIM_AGENT_API_URL = ? rawAgentUrl : SIM_AGENT_API_URL_DEFAULT -// --------------------------------------------------------------------------- -// Redis key prefixes -// --------------------------------------------------------------------------- - -/** Redis key prefix for copilot SSE stream buffers. */ -export const REDIS_COPILOT_STREAM_PREFIX = 'copilot_stream:' - // --------------------------------------------------------------------------- // Timeouts // --------------------------------------------------------------------------- @@ -31,29 +24,9 @@ export const STREAM_TIMEOUT_MS = 3_600_000 // Stream resume // --------------------------------------------------------------------------- -/** Maximum number of resume attempts before giving up. */ -export const MAX_RESUME_ATTEMPTS = 3 - /** SessionStorage key for persisting active stream metadata across page reloads. */ export const STREAM_STORAGE_KEY = 'copilot_active_stream' -// --------------------------------------------------------------------------- -// Client-side streaming batching -// --------------------------------------------------------------------------- - -/** Delay (ms) before processing the next queued message after stream completion. */ -export const QUEUE_PROCESS_DELAY_MS = 100 - -/** Delay (ms) before invalidating subscription queries after stream completion. */ -export const SUBSCRIPTION_INVALIDATE_DELAY_MS = 1_000 - -// --------------------------------------------------------------------------- -// UI helpers -// --------------------------------------------------------------------------- - -/** Maximum character length for an optimistic chat title derived from a user message. */ -export const OPTIMISTIC_TITLE_MAX_LENGTH = 50 - // --------------------------------------------------------------------------- // Copilot API paths (client-side fetch targets) // --------------------------------------------------------------------------- @@ -64,39 +37,23 @@ export const COPILOT_CHAT_API_PATH = '/api/copilot/chat' /** POST — send a workspace-scoped chat message (mothership). */ export const MOTHERSHIP_CHAT_API_PATH = '/api/mothership/chat' -/** GET — resume/replay a copilot SSE stream. */ -export const COPILOT_CHAT_STREAM_API_PATH = '/api/copilot/chat/stream' - -/** POST — persist chat messages / plan artifact / config. */ -export const COPILOT_UPDATE_MESSAGES_API_PATH = '/api/copilot/chat/update-messages' - -/** DELETE — delete a copilot chat. */ -export const COPILOT_DELETE_CHAT_API_PATH = '/api/copilot/chat/delete' - /** POST — confirm or reject a tool call. */ export const COPILOT_CONFIRM_API_PATH = '/api/copilot/confirm' /** POST — forward diff-accepted/rejected stats to the copilot backend. */ export const COPILOT_STATS_API_PATH = '/api/copilot/stats' -/** GET — load checkpoints for a chat. */ -export const COPILOT_CHECKPOINTS_API_PATH = '/api/copilot/checkpoints' - -/** POST — revert to a checkpoint. */ -export const COPILOT_CHECKPOINTS_REVERT_API_PATH = '/api/copilot/checkpoints/revert' - -/** GET/POST/DELETE — manage auto-allowed tools. */ -export const COPILOT_AUTO_ALLOWED_TOOLS_API_PATH = '/api/copilot/auto-allowed-tools' - -/** GET — fetch dynamically available copilot models. */ -export const COPILOT_MODELS_API_PATH = '/api/copilot/models' - -/** GET — fetch user credentials for masking. */ -export const COPILOT_CREDENTIALS_API_PATH = '/api/copilot/credentials' - // --------------------------------------------------------------------------- // Dedup limits // --------------------------------------------------------------------------- /** Maximum entries in the in-memory SSE tool-event dedup cache. */ export const STREAM_BUFFER_MAX_DEDUP_ENTRIES = 1_000 + +// --------------------------------------------------------------------------- +// Copilot modes +// --------------------------------------------------------------------------- + +export const COPILOT_MODES = ['ask', 'build', 'plan'] as const + +export const COPILOT_REQUEST_MODES = ['ask', 'build', 'plan', 'agent'] as const diff --git a/apps/sim/lib/copilot/generated/mothership-stream-v1.ts b/apps/sim/lib/copilot/generated/mothership-stream-v1.ts new file mode 100644 index 00000000000..ded9a6b4c3c --- /dev/null +++ b/apps/sim/lib/copilot/generated/mothership-stream-v1.ts @@ -0,0 +1,295 @@ +// AUTO-GENERATED FILE. DO NOT EDIT. +// + +/** + * This interface was referenced by `MothershipStreamV1EventEnvelope`'s JSON-Schema + * via the `definition` "MothershipStreamV1EventType". + */ +export type MothershipStreamV1EventType = + | 'session' + | 'text' + | 'tool' + | 'span' + | 'resource' + | 'run' + | 'error' + | 'complete' +/** + * This interface was referenced by `MothershipStreamV1EventEnvelope`'s JSON-Schema + * via the `definition` "MothershipStreamV1AsyncToolRecordStatus". + */ +export type MothershipStreamV1AsyncToolRecordStatus = + | 'pending' + | 'running' + | 'completed' + | 'failed' + | 'cancelled' + | 'delivered' +/** + * This interface was referenced by `MothershipStreamV1EventEnvelope`'s JSON-Schema + * via the `definition` "MothershipStreamV1CompletionStatus". + */ +export type MothershipStreamV1CompletionStatus = 'complete' | 'error' | 'cancelled' +/** + * This interface was referenced by `MothershipStreamV1EventEnvelope`'s JSON-Schema + * via the `definition` "MothershipStreamV1ResourceOp". + */ +export type MothershipStreamV1ResourceOp = 'upsert' | 'remove' +/** + * This interface was referenced by `MothershipStreamV1EventEnvelope`'s JSON-Schema + * via the `definition` "MothershipStreamV1RunKind". + */ +export type MothershipStreamV1RunKind = + | 'checkpoint_pause' + | 'resumed' + | 'compaction_start' + | 'compaction_done' +/** + * This interface was referenced by `MothershipStreamV1EventEnvelope`'s JSON-Schema + * via the `definition` "MothershipStreamV1SessionKind". + */ +export type MothershipStreamV1SessionKind = 'trace' | 'chat' | 'title' | 'start' +/** + * This interface was referenced by `MothershipStreamV1EventEnvelope`'s JSON-Schema + * via the `definition` "MothershipStreamV1SpanKind". + */ +export type MothershipStreamV1SpanKind = 'subagent' +/** + * This interface was referenced by `MothershipStreamV1EventEnvelope`'s JSON-Schema + * via the `definition` "MothershipStreamV1SpanLifecycleEvent". + */ +export type MothershipStreamV1SpanLifecycleEvent = 'start' | 'end' +/** + * This interface was referenced by `MothershipStreamV1EventEnvelope`'s JSON-Schema + * via the `definition` "MothershipStreamV1SpanPayloadKind". + */ +export type MothershipStreamV1SpanPayloadKind = 'subagent' | 'structured_result' | 'subagent_result' +/** + * This interface was referenced by `MothershipStreamV1EventEnvelope`'s JSON-Schema + * via the `definition` "MothershipStreamV1TextChannel". + */ +export type MothershipStreamV1TextChannel = 'assistant' | 'thinking' +/** + * This interface was referenced by `MothershipStreamV1EventEnvelope`'s JSON-Schema + * via the `definition` "MothershipStreamV1ToolExecutor". + */ +export type MothershipStreamV1ToolExecutor = 'go' | 'sim' | 'client' +/** + * This interface was referenced by `MothershipStreamV1EventEnvelope`'s JSON-Schema + * via the `definition` "MothershipStreamV1ToolMode". + */ +export type MothershipStreamV1ToolMode = 'sync' | 'async' +/** + * This interface was referenced by `MothershipStreamV1EventEnvelope`'s JSON-Schema + * via the `definition` "MothershipStreamV1ToolPhase". + */ +export type MothershipStreamV1ToolPhase = 'call' | 'args_delta' | 'result' +/** + * This interface was referenced by `MothershipStreamV1EventEnvelope`'s JSON-Schema + * via the `definition` "MothershipStreamV1ToolOutcome". + */ +export type MothershipStreamV1ToolOutcome = + | 'success' + | 'error' + | 'cancelled' + | 'skipped' + | 'rejected' + +/** + * Shared execution-oriented mothership stream contract from Go to Sim. + */ +export interface MothershipStreamV1EventEnvelope { + payload: MothershipStreamV1AdditionalPropertiesMap + scope?: MothershipStreamV1StreamScope + seq: number + stream: MothershipStreamV1StreamRef + trace?: MothershipStreamV1Trace + ts: string + type: MothershipStreamV1EventType + v: number +} +/** + * This interface was referenced by `MothershipStreamV1EventEnvelope`'s JSON-Schema + * via the `definition` "MothershipStreamV1AdditionalPropertiesMap". + */ +export interface MothershipStreamV1AdditionalPropertiesMap { + [k: string]: unknown +} +/** + * This interface was referenced by `MothershipStreamV1EventEnvelope`'s JSON-Schema + * via the `definition` "MothershipStreamV1StreamScope". + */ +export interface MothershipStreamV1StreamScope { + agentId?: string + lane: 'main' | 'subagent' + parentToolCallId?: string +} +/** + * This interface was referenced by `MothershipStreamV1EventEnvelope`'s JSON-Schema + * via the `definition` "MothershipStreamV1StreamRef". + */ +export interface MothershipStreamV1StreamRef { + chatId?: string + cursor?: string + streamId: string +} +/** + * This interface was referenced by `MothershipStreamV1EventEnvelope`'s JSON-Schema + * via the `definition` "MothershipStreamV1Trace". + */ +export interface MothershipStreamV1Trace { + requestId: string + spanId?: string +} +/** + * This interface was referenced by `MothershipStreamV1EventEnvelope`'s JSON-Schema + * via the `definition` "MothershipStreamV1CheckpointPausePayload". + */ +export interface MothershipStreamV1CheckpointPausePayload { + checkpointId: string + executionId: string + pendingToolCallIds: string[] + runId: string +} +/** + * This interface was referenced by `MothershipStreamV1EventEnvelope`'s JSON-Schema + * via the `definition` "MothershipStreamV1ResumeRequest". + */ +export interface MothershipStreamV1ResumeRequest { + checkpointId: string + results: MothershipStreamV1ResumeToolResult[] + streamId: string +} +/** + * This interface was referenced by `MothershipStreamV1EventEnvelope`'s JSON-Schema + * via the `definition` "MothershipStreamV1ResumeToolResult". + */ +export interface MothershipStreamV1ResumeToolResult { + error?: string + output?: unknown + success: boolean + toolCallId: string +} +/** + * This interface was referenced by `MothershipStreamV1EventEnvelope`'s JSON-Schema + * via the `definition` "MothershipStreamV1StreamCursor". + */ +export interface MothershipStreamV1StreamCursor { + cursor: string + seq: number + streamId: string +} +/** + * This interface was referenced by `MothershipStreamV1EventEnvelope`'s JSON-Schema + * via the `definition` "MothershipStreamV1ToolCallDescriptor". + */ +export interface MothershipStreamV1ToolCallDescriptor { + arguments?: MothershipStreamV1AdditionalPropertiesMap + argumentsDelta?: string + executor: MothershipStreamV1ToolExecutor + mode: MothershipStreamV1ToolMode + partial?: boolean + phase: MothershipStreamV1ToolPhase + requiresConfirmation?: boolean + toolCallId: string + toolName: string +} +/** + * This interface was referenced by `MothershipStreamV1EventEnvelope`'s JSON-Schema + * via the `definition` "MothershipStreamV1ToolResultPayload". + */ +export interface MothershipStreamV1ToolResultPayload { + error?: string + output?: unknown + success: boolean +} + +export const MothershipStreamV1AsyncToolRecordStatus = { + pending: 'pending', + running: 'running', + completed: 'completed', + failed: 'failed', + cancelled: 'cancelled', + delivered: 'delivered', +} as const + +export const MothershipStreamV1CompletionStatus = { + complete: 'complete', + error: 'error', + cancelled: 'cancelled', +} as const + +export const MothershipStreamV1EventType = { + session: 'session', + text: 'text', + tool: 'tool', + span: 'span', + resource: 'resource', + run: 'run', + error: 'error', + complete: 'complete', +} as const + +export const MothershipStreamV1ResourceOp = { + upsert: 'upsert', + remove: 'remove', +} as const + +export const MothershipStreamV1RunKind = { + checkpoint_pause: 'checkpoint_pause', + resumed: 'resumed', + compaction_start: 'compaction_start', + compaction_done: 'compaction_done', +} as const + +export const MothershipStreamV1SessionKind = { + trace: 'trace', + chat: 'chat', + title: 'title', + start: 'start', +} as const + +export const MothershipStreamV1SpanKind = { + subagent: 'subagent', +} as const + +export const MothershipStreamV1SpanLifecycleEvent = { + start: 'start', + end: 'end', +} as const + +export const MothershipStreamV1SpanPayloadKind = { + subagent: 'subagent', + structured_result: 'structured_result', + subagent_result: 'subagent_result', +} as const + +export const MothershipStreamV1TextChannel = { + assistant: 'assistant', + thinking: 'thinking', +} as const + +export const MothershipStreamV1ToolExecutor = { + go: 'go', + sim: 'sim', + client: 'client', +} as const + +export const MothershipStreamV1ToolMode = { + sync: 'sync', + async: 'async', +} as const + +export const MothershipStreamV1ToolOutcome = { + success: 'success', + error: 'error', + cancelled: 'cancelled', + skipped: 'skipped', + rejected: 'rejected', +} as const + +export const MothershipStreamV1ToolPhase = { + call: 'call', + args_delta: 'args_delta', + result: 'result', +} as const diff --git a/apps/sim/lib/copilot/generated/request-trace-v1.ts b/apps/sim/lib/copilot/generated/request-trace-v1.ts new file mode 100644 index 00000000000..8213b11e226 --- /dev/null +++ b/apps/sim/lib/copilot/generated/request-trace-v1.ts @@ -0,0 +1,136 @@ +// AUTO-GENERATED FILE. DO NOT EDIT. +// + +/** + * This interface was referenced by `RequestTraceV1SimReport`'s JSON-Schema + * via the `definition` "RequestTraceV1Outcome". + */ +export type RequestTraceV1Outcome = 'success' | 'error' | 'cancelled' +/** + * This interface was referenced by `RequestTraceV1SimReport`'s JSON-Schema + * via the `definition` "RequestTraceV1SpanSource". + */ +export type RequestTraceV1SpanSource = 'sim' | 'go' +/** + * This interface was referenced by `RequestTraceV1SimReport`'s JSON-Schema + * via the `definition` "RequestTraceV1SpanStatus". + */ +export type RequestTraceV1SpanStatus = 'ok' | 'error' | 'cancelled' + +/** + * Trace report sent from Sim to Go after a request completes. + */ +export interface RequestTraceV1SimReport { + chatId?: string + cost?: RequestTraceV1CostSummary + durationMs: number + endMs: number + executionId?: string + goTraceId?: string + outcome: RequestTraceV1Outcome + runId?: string + simRequestId: string + spans: RequestTraceV1Span[] + startMs: number + streamId?: string + usage?: RequestTraceV1UsageSummary +} +/** + * This interface was referenced by `RequestTraceV1SimReport`'s JSON-Schema + * via the `definition` "RequestTraceV1CostSummary". + */ +export interface RequestTraceV1CostSummary { + billedTotalCost?: number + rawTotalCost?: number +} +/** + * This interface was referenced by `RequestTraceV1SimReport`'s JSON-Schema + * via the `definition` "RequestTraceV1Span". + */ +export interface RequestTraceV1Span { + attributes?: MothershipStreamV1AdditionalPropertiesMap + durationMs?: number + endMs?: number + kind?: string + name: string + parentName?: string + source?: RequestTraceV1SpanSource + startMs: number + status: RequestTraceV1SpanStatus +} +/** + * This interface was referenced by `RequestTraceV1SimReport`'s JSON-Schema + * via the `definition` "MothershipStreamV1AdditionalPropertiesMap". + */ +export interface MothershipStreamV1AdditionalPropertiesMap { + [k: string]: unknown +} +/** + * This interface was referenced by `RequestTraceV1SimReport`'s JSON-Schema + * via the `definition` "RequestTraceV1UsageSummary". + */ +export interface RequestTraceV1UsageSummary { + cacheReadTokens?: number + cacheWriteTokens?: number + inputTokens?: number + outputTokens?: number +} +/** + * This interface was referenced by `RequestTraceV1SimReport`'s JSON-Schema + * via the `definition` "RequestTraceV1MergedTrace". + */ +export interface RequestTraceV1MergedTrace { + chatId?: string + cost?: RequestTraceV1CostSummary + durationMs: number + endMs: number + executionId?: string + goTraceId: string + model?: string + outcome: RequestTraceV1Outcome + provider?: string + runId?: string + serviceCharges?: MothershipStreamV1AdditionalPropertiesMap + simRequestId?: string + spans: RequestTraceV1Span[] + startMs: number + streamId?: string + usage?: RequestTraceV1UsageSummary + userId?: string +} +/** + * This interface was referenced by `RequestTraceV1SimReport`'s JSON-Schema + * via the `definition` "RequestTraceV1SimReport". + */ +export interface RequestTraceV1SimReport1 { + chatId?: string + cost?: RequestTraceV1CostSummary + durationMs: number + endMs: number + executionId?: string + goTraceId?: string + outcome: RequestTraceV1Outcome + runId?: string + simRequestId: string + spans: RequestTraceV1Span[] + startMs: number + streamId?: string + usage?: RequestTraceV1UsageSummary +} + +export const RequestTraceV1Outcome = { + success: 'success', + error: 'error', + cancelled: 'cancelled', +} as const + +export const RequestTraceV1SpanSource = { + sim: 'sim', + go: 'go', +} as const + +export const RequestTraceV1SpanStatus = { + ok: 'ok', + error: 'error', + cancelled: 'cancelled', +} as const diff --git a/apps/sim/lib/copilot/generated/tool-catalog-v1.ts b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts new file mode 100644 index 00000000000..42bcd87a4d2 --- /dev/null +++ b/apps/sim/lib/copilot/generated/tool-catalog-v1.ts @@ -0,0 +1,1047 @@ +// AUTO-GENERATED FILE. DO NOT EDIT. +// Generated from copilot/contracts/tool-catalog-v1.json +// + +export interface ToolCatalogEntry { + clientExecutable?: boolean + executor: 'client' | 'go' | 'sim' | 'subagent' + hidden?: boolean + id: + | 'agent' + | 'agent_respond' + | 'auth' + | 'auth_respond' + | 'build' + | 'build_respond' + | 'check_deployment_status' + | 'complete_job' + | 'context_write' + | 'crawl_website' + | 'create_folder' + | 'create_job' + | 'create_workflow' + | 'create_workspace_mcp_server' + | 'debug' + | 'debug_respond' + | 'delete_folder' + | 'delete_workflow' + | 'delete_workspace_mcp_server' + | 'deploy' + | 'deploy_api' + | 'deploy_chat' + | 'deploy_mcp' + | 'deploy_respond' + | 'download_to_workspace_file' + | 'edit_respond' + | 'edit_workflow' + | 'fast_edit_respond' + | 'file_write' + | 'function_execute' + | 'generate_api_key' + | 'generate_image' + | 'generate_visualization' + | 'get_block_outputs' + | 'get_block_upstream_references' + | 'get_deployed_workflow_state' + | 'get_deployment_version' + | 'get_execution_summary' + | 'get_job_logs' + | 'get_page_contents' + | 'get_platform_actions' + | 'get_workflow_data' + | 'get_workflow_logs' + | 'glob' + | 'grep' + | 'job' + | 'job_respond' + | 'knowledge' + | 'knowledge_base' + | 'knowledge_respond' + | 'list_folders' + | 'list_user_workspaces' + | 'list_workspace_mcp_servers' + | 'manage_credential' + | 'manage_custom_tool' + | 'manage_job' + | 'manage_mcp_tool' + | 'manage_skill' + | 'materialize_file' + | 'oauth_get_auth_link' + | 'oauth_request_access' + | 'open_resource' + | 'plan_respond' + | 'read' + | 'redeploy' + | 'research' + | 'research_respond' + | 'revert_to_version' + | 'run' + | 'run_block' + | 'run_from_block' + | 'run_respond' + | 'run_workflow' + | 'run_workflow_until_block' + | 'scrape_page' + | 'search_documentation' + | 'search_library_docs' + | 'search_online' + | 'search_patterns' + | 'set_environment_variables' + | 'set_global_workflow_variables' + | 'superagent' + | 'superagent_respond' + | 'table' + | 'table_respond' + | 'tool_search_tool_regex' + | 'update_job_history' + | 'update_workspace_mcp_server' + | 'user_memory' + | 'user_table' + | 'workspace_file' + internal?: boolean + mode: 'async' | 'sync' + name: + | 'agent' + | 'agent_respond' + | 'auth' + | 'auth_respond' + | 'build' + | 'build_respond' + | 'check_deployment_status' + | 'complete_job' + | 'context_write' + | 'crawl_website' + | 'create_folder' + | 'create_job' + | 'create_workflow' + | 'create_workspace_mcp_server' + | 'debug' + | 'debug_respond' + | 'delete_folder' + | 'delete_workflow' + | 'delete_workspace_mcp_server' + | 'deploy' + | 'deploy_api' + | 'deploy_chat' + | 'deploy_mcp' + | 'deploy_respond' + | 'download_to_workspace_file' + | 'edit_respond' + | 'edit_workflow' + | 'fast_edit_respond' + | 'file_write' + | 'function_execute' + | 'generate_api_key' + | 'generate_image' + | 'generate_visualization' + | 'get_block_outputs' + | 'get_block_upstream_references' + | 'get_deployed_workflow_state' + | 'get_deployment_version' + | 'get_execution_summary' + | 'get_job_logs' + | 'get_page_contents' + | 'get_platform_actions' + | 'get_workflow_data' + | 'get_workflow_logs' + | 'glob' + | 'grep' + | 'job' + | 'job_respond' + | 'knowledge' + | 'knowledge_base' + | 'knowledge_respond' + | 'list_folders' + | 'list_user_workspaces' + | 'list_workspace_mcp_servers' + | 'manage_credential' + | 'manage_custom_tool' + | 'manage_job' + | 'manage_mcp_tool' + | 'manage_skill' + | 'materialize_file' + | 'oauth_get_auth_link' + | 'oauth_request_access' + | 'open_resource' + | 'plan_respond' + | 'read' + | 'redeploy' + | 'research' + | 'research_respond' + | 'revert_to_version' + | 'run' + | 'run_block' + | 'run_from_block' + | 'run_respond' + | 'run_workflow' + | 'run_workflow_until_block' + | 'scrape_page' + | 'search_documentation' + | 'search_library_docs' + | 'search_online' + | 'search_patterns' + | 'set_environment_variables' + | 'set_global_workflow_variables' + | 'superagent' + | 'superagent_respond' + | 'table' + | 'table_respond' + | 'tool_search_tool_regex' + | 'update_job_history' + | 'update_workspace_mcp_server' + | 'user_memory' + | 'user_table' + | 'workspace_file' + requiredPermission?: 'admin' | 'write' + requiresConfirmation?: boolean + subagentId?: + | 'agent' + | 'auth' + | 'build' + | 'debug' + | 'deploy' + | 'file_write' + | 'job' + | 'knowledge' + | 'research' + | 'run' + | 'superagent' + | 'table' +} + +export const Agent: ToolCatalogEntry = { + id: 'agent', + name: 'agent', + executor: 'subagent', + mode: 'async', + subagentId: 'agent', + internal: true, + requiredPermission: 'write', +} + +export const AgentRespond: ToolCatalogEntry = { + id: 'agent_respond', + name: 'agent_respond', + executor: 'sim', + mode: 'async', + internal: true, + hidden: true, +} + +export const Auth: ToolCatalogEntry = { + id: 'auth', + name: 'auth', + executor: 'subagent', + mode: 'async', + subagentId: 'auth', + internal: true, +} + +export const AuthRespond: ToolCatalogEntry = { + id: 'auth_respond', + name: 'auth_respond', + executor: 'sim', + mode: 'async', + internal: true, + hidden: true, +} + +export const Build: ToolCatalogEntry = { + id: 'build', + name: 'build', + executor: 'subagent', + mode: 'async', + subagentId: 'build', + internal: true, +} + +export const BuildRespond: ToolCatalogEntry = { + id: 'build_respond', + name: 'build_respond', + executor: 'sim', + mode: 'async', + internal: true, + hidden: true, +} + +export const CheckDeploymentStatus: ToolCatalogEntry = { + id: 'check_deployment_status', + name: 'check_deployment_status', + executor: 'sim', + mode: 'async', +} + +export const CompleteJob: ToolCatalogEntry = { + id: 'complete_job', + name: 'complete_job', + executor: 'sim', + mode: 'async', +} + +export const ContextWrite: ToolCatalogEntry = { + id: 'context_write', + name: 'context_write', + executor: 'go', + mode: 'sync', +} + +export const CrawlWebsite: ToolCatalogEntry = { + id: 'crawl_website', + name: 'crawl_website', + executor: 'go', + mode: 'sync', +} + +export const CreateFolder: ToolCatalogEntry = { + id: 'create_folder', + name: 'create_folder', + executor: 'sim', + mode: 'async', + requiredPermission: 'write', +} + +export const CreateJob: ToolCatalogEntry = { + id: 'create_job', + name: 'create_job', + executor: 'sim', + mode: 'async', +} + +export const CreateWorkflow: ToolCatalogEntry = { + id: 'create_workflow', + name: 'create_workflow', + executor: 'sim', + mode: 'async', + requiredPermission: 'write', +} + +export const CreateWorkspaceMcpServer: ToolCatalogEntry = { + id: 'create_workspace_mcp_server', + name: 'create_workspace_mcp_server', + executor: 'sim', + mode: 'async', + requiresConfirmation: true, + requiredPermission: 'admin', +} + +export const Debug: ToolCatalogEntry = { + id: 'debug', + name: 'debug', + executor: 'subagent', + mode: 'async', + subagentId: 'debug', + internal: true, +} + +export const DebugRespond: ToolCatalogEntry = { + id: 'debug_respond', + name: 'debug_respond', + executor: 'sim', + mode: 'async', + internal: true, + hidden: true, +} + +export const DeleteFolder: ToolCatalogEntry = { + id: 'delete_folder', + name: 'delete_folder', + executor: 'sim', + mode: 'async', + requiresConfirmation: true, + requiredPermission: 'write', +} + +export const DeleteWorkflow: ToolCatalogEntry = { + id: 'delete_workflow', + name: 'delete_workflow', + executor: 'sim', + mode: 'async', + requiresConfirmation: true, + requiredPermission: 'write', +} + +export const DeleteWorkspaceMcpServer: ToolCatalogEntry = { + id: 'delete_workspace_mcp_server', + name: 'delete_workspace_mcp_server', + executor: 'sim', + mode: 'async', + requiresConfirmation: true, + requiredPermission: 'admin', +} + +export const Deploy: ToolCatalogEntry = { + id: 'deploy', + name: 'deploy', + executor: 'subagent', + mode: 'async', + subagentId: 'deploy', + internal: true, +} + +export const DeployApi: ToolCatalogEntry = { + id: 'deploy_api', + name: 'deploy_api', + executor: 'sim', + mode: 'async', + requiresConfirmation: true, + requiredPermission: 'admin', +} + +export const DeployChat: ToolCatalogEntry = { + id: 'deploy_chat', + name: 'deploy_chat', + executor: 'sim', + mode: 'async', + requiresConfirmation: true, + requiredPermission: 'admin', +} + +export const DeployMcp: ToolCatalogEntry = { + id: 'deploy_mcp', + name: 'deploy_mcp', + executor: 'sim', + mode: 'async', + requiresConfirmation: true, + requiredPermission: 'admin', +} + +export const DeployRespond: ToolCatalogEntry = { + id: 'deploy_respond', + name: 'deploy_respond', + executor: 'sim', + mode: 'async', + internal: true, + hidden: true, +} + +export const DownloadToWorkspaceFile: ToolCatalogEntry = { + id: 'download_to_workspace_file', + name: 'download_to_workspace_file', + executor: 'sim', + mode: 'async', + requiredPermission: 'write', +} + +export const EditRespond: ToolCatalogEntry = { + id: 'edit_respond', + name: 'edit_respond', + executor: 'sim', + mode: 'async', + internal: true, + hidden: true, +} + +export const EditWorkflow: ToolCatalogEntry = { + id: 'edit_workflow', + name: 'edit_workflow', + executor: 'sim', + mode: 'async', + requiredPermission: 'write', +} + +export const FastEditRespond: ToolCatalogEntry = { + id: 'fast_edit_respond', + name: 'fast_edit_respond', + executor: 'sim', + mode: 'async', + internal: true, + hidden: true, +} + +export const FileWrite: ToolCatalogEntry = { + id: 'file_write', + name: 'file_write', + executor: 'subagent', + mode: 'async', + subagentId: 'file_write', + internal: true, +} + +export const FunctionExecute: ToolCatalogEntry = { + id: 'function_execute', + name: 'function_execute', + executor: 'sim', + mode: 'async', + requiredPermission: 'write', +} + +export const GenerateApiKey: ToolCatalogEntry = { + id: 'generate_api_key', + name: 'generate_api_key', + executor: 'sim', + mode: 'async', + requiresConfirmation: true, + requiredPermission: 'admin', +} + +export const GenerateImage: ToolCatalogEntry = { + id: 'generate_image', + name: 'generate_image', + executor: 'sim', + mode: 'async', + requiredPermission: 'write', +} + +export const GenerateVisualization: ToolCatalogEntry = { + id: 'generate_visualization', + name: 'generate_visualization', + executor: 'sim', + mode: 'async', + requiredPermission: 'write', +} + +export const GetBlockOutputs: ToolCatalogEntry = { + id: 'get_block_outputs', + name: 'get_block_outputs', + executor: 'sim', + mode: 'async', +} + +export const GetBlockUpstreamReferences: ToolCatalogEntry = { + id: 'get_block_upstream_references', + name: 'get_block_upstream_references', + executor: 'sim', + mode: 'async', +} + +export const GetDeployedWorkflowState: ToolCatalogEntry = { + id: 'get_deployed_workflow_state', + name: 'get_deployed_workflow_state', + executor: 'sim', + mode: 'async', +} + +export const GetDeploymentVersion: ToolCatalogEntry = { + id: 'get_deployment_version', + name: 'get_deployment_version', + executor: 'sim', + mode: 'async', +} + +export const GetExecutionSummary: ToolCatalogEntry = { + id: 'get_execution_summary', + name: 'get_execution_summary', + executor: 'sim', + mode: 'async', +} + +export const GetJobLogs: ToolCatalogEntry = { + id: 'get_job_logs', + name: 'get_job_logs', + executor: 'sim', + mode: 'async', +} + +export const GetPageContents: ToolCatalogEntry = { + id: 'get_page_contents', + name: 'get_page_contents', + executor: 'go', + mode: 'sync', +} + +export const GetPlatformActions: ToolCatalogEntry = { + id: 'get_platform_actions', + name: 'get_platform_actions', + executor: 'sim', + mode: 'async', +} + +export const GetWorkflowData: ToolCatalogEntry = { + id: 'get_workflow_data', + name: 'get_workflow_data', + executor: 'sim', + mode: 'async', +} + +export const GetWorkflowLogs: ToolCatalogEntry = { + id: 'get_workflow_logs', + name: 'get_workflow_logs', + executor: 'sim', + mode: 'async', +} + +export const Glob: ToolCatalogEntry = { + id: 'glob', + name: 'glob', + executor: 'sim', + mode: 'async', +} + +export const Grep: ToolCatalogEntry = { + id: 'grep', + name: 'grep', + executor: 'sim', + mode: 'async', +} + +export const Job: ToolCatalogEntry = { + id: 'job', + name: 'job', + executor: 'subagent', + mode: 'async', + subagentId: 'job', + internal: true, +} + +export const JobRespond: ToolCatalogEntry = { + id: 'job_respond', + name: 'job_respond', + executor: 'sim', + mode: 'async', + internal: true, + hidden: true, +} + +export const Knowledge: ToolCatalogEntry = { + id: 'knowledge', + name: 'knowledge', + executor: 'subagent', + mode: 'async', + subagentId: 'knowledge', + internal: true, +} + +export const KnowledgeBase: ToolCatalogEntry = { + id: 'knowledge_base', + name: 'knowledge_base', + executor: 'sim', + mode: 'async', + requiresConfirmation: true, +} + +export const KnowledgeRespond: ToolCatalogEntry = { + id: 'knowledge_respond', + name: 'knowledge_respond', + executor: 'sim', + mode: 'async', + internal: true, + hidden: true, +} + +export const ListFolders: ToolCatalogEntry = { + id: 'list_folders', + name: 'list_folders', + executor: 'sim', + mode: 'async', +} + +export const ListUserWorkspaces: ToolCatalogEntry = { + id: 'list_user_workspaces', + name: 'list_user_workspaces', + executor: 'sim', + mode: 'async', +} + +export const ListWorkspaceMcpServers: ToolCatalogEntry = { + id: 'list_workspace_mcp_servers', + name: 'list_workspace_mcp_servers', + executor: 'sim', + mode: 'async', +} + +export const ManageCredential: ToolCatalogEntry = { + id: 'manage_credential', + name: 'manage_credential', + executor: 'sim', + mode: 'async', + requiresConfirmation: true, + requiredPermission: 'admin', +} + +export const ManageCustomTool: ToolCatalogEntry = { + id: 'manage_custom_tool', + name: 'manage_custom_tool', + executor: 'sim', + mode: 'async', + requiresConfirmation: true, +} + +export const ManageJob: ToolCatalogEntry = { + id: 'manage_job', + name: 'manage_job', + executor: 'sim', + mode: 'async', +} + +export const ManageMcpTool: ToolCatalogEntry = { + id: 'manage_mcp_tool', + name: 'manage_mcp_tool', + executor: 'sim', + mode: 'async', + requiresConfirmation: true, + requiredPermission: 'write', +} + +export const ManageSkill: ToolCatalogEntry = { + id: 'manage_skill', + name: 'manage_skill', + executor: 'sim', + mode: 'async', + requiresConfirmation: true, + requiredPermission: 'write', +} + +export const MaterializeFile: ToolCatalogEntry = { + id: 'materialize_file', + name: 'materialize_file', + executor: 'sim', + mode: 'async', + requiredPermission: 'write', +} + +export const OauthGetAuthLink: ToolCatalogEntry = { + id: 'oauth_get_auth_link', + name: 'oauth_get_auth_link', + executor: 'sim', + mode: 'async', +} + +export const OauthRequestAccess: ToolCatalogEntry = { + id: 'oauth_request_access', + name: 'oauth_request_access', + executor: 'sim', + mode: 'async', + requiresConfirmation: true, +} + +export const OpenResource: ToolCatalogEntry = { + id: 'open_resource', + name: 'open_resource', + executor: 'sim', + mode: 'async', +} + +export const PlanRespond: ToolCatalogEntry = { + id: 'plan_respond', + name: 'plan_respond', + executor: 'sim', + mode: 'async', + internal: true, + hidden: true, +} + +export const Read: ToolCatalogEntry = { + id: 'read', + name: 'read', + executor: 'sim', + mode: 'async', +} + +export const Redeploy: ToolCatalogEntry = { + id: 'redeploy', + name: 'redeploy', + executor: 'sim', + mode: 'async', + requiresConfirmation: true, + requiredPermission: 'admin', +} + +export const Research: ToolCatalogEntry = { + id: 'research', + name: 'research', + executor: 'subagent', + mode: 'async', + subagentId: 'research', + internal: true, +} + +export const ResearchRespond: ToolCatalogEntry = { + id: 'research_respond', + name: 'research_respond', + executor: 'sim', + mode: 'async', + internal: true, + hidden: true, +} + +export const RevertToVersion: ToolCatalogEntry = { + id: 'revert_to_version', + name: 'revert_to_version', + executor: 'sim', + mode: 'async', + requiresConfirmation: true, + requiredPermission: 'admin', +} + +export const Run: ToolCatalogEntry = { + id: 'run', + name: 'run', + executor: 'subagent', + mode: 'async', + subagentId: 'run', + internal: true, +} + +export const RunBlock: ToolCatalogEntry = { + id: 'run_block', + name: 'run_block', + executor: 'client', + mode: 'async', + clientExecutable: true, + requiresConfirmation: true, +} + +export const RunFromBlock: ToolCatalogEntry = { + id: 'run_from_block', + name: 'run_from_block', + executor: 'client', + mode: 'async', + clientExecutable: true, + requiresConfirmation: true, +} + +export const RunRespond: ToolCatalogEntry = { + id: 'run_respond', + name: 'run_respond', + executor: 'sim', + mode: 'async', + internal: true, + hidden: true, +} + +export const RunWorkflow: ToolCatalogEntry = { + id: 'run_workflow', + name: 'run_workflow', + executor: 'client', + mode: 'async', + clientExecutable: true, + requiresConfirmation: true, +} + +export const RunWorkflowUntilBlock: ToolCatalogEntry = { + id: 'run_workflow_until_block', + name: 'run_workflow_until_block', + executor: 'client', + mode: 'async', + clientExecutable: true, + requiresConfirmation: true, +} + +export const ScrapePage: ToolCatalogEntry = { + id: 'scrape_page', + name: 'scrape_page', + executor: 'go', + mode: 'sync', +} + +export const SearchDocumentation: ToolCatalogEntry = { + id: 'search_documentation', + name: 'search_documentation', + executor: 'sim', + mode: 'async', +} + +export const SearchLibraryDocs: ToolCatalogEntry = { + id: 'search_library_docs', + name: 'search_library_docs', + executor: 'go', + mode: 'sync', +} + +export const SearchOnline: ToolCatalogEntry = { + id: 'search_online', + name: 'search_online', + executor: 'go', + mode: 'sync', +} + +export const SearchPatterns: ToolCatalogEntry = { + id: 'search_patterns', + name: 'search_patterns', + executor: 'go', + mode: 'sync', +} + +export const SetEnvironmentVariables: ToolCatalogEntry = { + id: 'set_environment_variables', + name: 'set_environment_variables', + executor: 'sim', + mode: 'async', + requiresConfirmation: true, + requiredPermission: 'write', +} + +export const SetGlobalWorkflowVariables: ToolCatalogEntry = { + id: 'set_global_workflow_variables', + name: 'set_global_workflow_variables', + executor: 'sim', + mode: 'async', + requiresConfirmation: true, + requiredPermission: 'write', +} + +export const Superagent: ToolCatalogEntry = { + id: 'superagent', + name: 'superagent', + executor: 'subagent', + mode: 'async', + subagentId: 'superagent', + internal: true, +} + +export const SuperagentRespond: ToolCatalogEntry = { + id: 'superagent_respond', + name: 'superagent_respond', + executor: 'sim', + mode: 'async', + internal: true, + hidden: true, +} + +export const Table: ToolCatalogEntry = { + id: 'table', + name: 'table', + executor: 'subagent', + mode: 'async', + subagentId: 'table', + internal: true, +} + +export const TableRespond: ToolCatalogEntry = { + id: 'table_respond', + name: 'table_respond', + executor: 'sim', + mode: 'async', + internal: true, + hidden: true, +} + +export const ToolSearchToolRegex: ToolCatalogEntry = { + id: 'tool_search_tool_regex', + name: 'tool_search_tool_regex', + executor: 'sim', + mode: 'async', +} + +export const UpdateJobHistory: ToolCatalogEntry = { + id: 'update_job_history', + name: 'update_job_history', + executor: 'sim', + mode: 'async', +} + +export const UpdateWorkspaceMcpServer: ToolCatalogEntry = { + id: 'update_workspace_mcp_server', + name: 'update_workspace_mcp_server', + executor: 'sim', + mode: 'async', + requiresConfirmation: true, + requiredPermission: 'admin', +} + +export const UserMemory: ToolCatalogEntry = { + id: 'user_memory', + name: 'user_memory', + executor: 'go', + mode: 'sync', +} + +export const UserTable: ToolCatalogEntry = { + id: 'user_table', + name: 'user_table', + executor: 'sim', + mode: 'async', + requiresConfirmation: true, +} + +export const WorkspaceFile: ToolCatalogEntry = { + id: 'workspace_file', + name: 'workspace_file', + executor: 'sim', + mode: 'async', + requiredPermission: 'write', +} + +export const TOOL_CATALOG: Record = { + [Agent.id]: Agent, + [AgentRespond.id]: AgentRespond, + [Auth.id]: Auth, + [AuthRespond.id]: AuthRespond, + [Build.id]: Build, + [BuildRespond.id]: BuildRespond, + [CheckDeploymentStatus.id]: CheckDeploymentStatus, + [CompleteJob.id]: CompleteJob, + [ContextWrite.id]: ContextWrite, + [CrawlWebsite.id]: CrawlWebsite, + [CreateFolder.id]: CreateFolder, + [CreateJob.id]: CreateJob, + [CreateWorkflow.id]: CreateWorkflow, + [CreateWorkspaceMcpServer.id]: CreateWorkspaceMcpServer, + [Debug.id]: Debug, + [DebugRespond.id]: DebugRespond, + [DeleteFolder.id]: DeleteFolder, + [DeleteWorkflow.id]: DeleteWorkflow, + [DeleteWorkspaceMcpServer.id]: DeleteWorkspaceMcpServer, + [Deploy.id]: Deploy, + [DeployApi.id]: DeployApi, + [DeployChat.id]: DeployChat, + [DeployMcp.id]: DeployMcp, + [DeployRespond.id]: DeployRespond, + [DownloadToWorkspaceFile.id]: DownloadToWorkspaceFile, + [EditRespond.id]: EditRespond, + [EditWorkflow.id]: EditWorkflow, + [FastEditRespond.id]: FastEditRespond, + [FileWrite.id]: FileWrite, + [FunctionExecute.id]: FunctionExecute, + [GenerateApiKey.id]: GenerateApiKey, + [GenerateImage.id]: GenerateImage, + [GenerateVisualization.id]: GenerateVisualization, + [GetBlockOutputs.id]: GetBlockOutputs, + [GetBlockUpstreamReferences.id]: GetBlockUpstreamReferences, + [GetDeployedWorkflowState.id]: GetDeployedWorkflowState, + [GetDeploymentVersion.id]: GetDeploymentVersion, + [GetExecutionSummary.id]: GetExecutionSummary, + [GetJobLogs.id]: GetJobLogs, + [GetPageContents.id]: GetPageContents, + [GetPlatformActions.id]: GetPlatformActions, + [GetWorkflowData.id]: GetWorkflowData, + [GetWorkflowLogs.id]: GetWorkflowLogs, + [Glob.id]: Glob, + [Grep.id]: Grep, + [Job.id]: Job, + [JobRespond.id]: JobRespond, + [Knowledge.id]: Knowledge, + [KnowledgeBase.id]: KnowledgeBase, + [KnowledgeRespond.id]: KnowledgeRespond, + [ListFolders.id]: ListFolders, + [ListUserWorkspaces.id]: ListUserWorkspaces, + [ListWorkspaceMcpServers.id]: ListWorkspaceMcpServers, + [ManageCredential.id]: ManageCredential, + [ManageCustomTool.id]: ManageCustomTool, + [ManageJob.id]: ManageJob, + [ManageMcpTool.id]: ManageMcpTool, + [ManageSkill.id]: ManageSkill, + [MaterializeFile.id]: MaterializeFile, + [OauthGetAuthLink.id]: OauthGetAuthLink, + [OauthRequestAccess.id]: OauthRequestAccess, + [OpenResource.id]: OpenResource, + [PlanRespond.id]: PlanRespond, + [Read.id]: Read, + [Redeploy.id]: Redeploy, + [Research.id]: Research, + [ResearchRespond.id]: ResearchRespond, + [RevertToVersion.id]: RevertToVersion, + [Run.id]: Run, + [RunBlock.id]: RunBlock, + [RunFromBlock.id]: RunFromBlock, + [RunRespond.id]: RunRespond, + [RunWorkflow.id]: RunWorkflow, + [RunWorkflowUntilBlock.id]: RunWorkflowUntilBlock, + [ScrapePage.id]: ScrapePage, + [SearchDocumentation.id]: SearchDocumentation, + [SearchLibraryDocs.id]: SearchLibraryDocs, + [SearchOnline.id]: SearchOnline, + [SearchPatterns.id]: SearchPatterns, + [SetEnvironmentVariables.id]: SetEnvironmentVariables, + [SetGlobalWorkflowVariables.id]: SetGlobalWorkflowVariables, + [Superagent.id]: Superagent, + [SuperagentRespond.id]: SuperagentRespond, + [Table.id]: Table, + [TableRespond.id]: TableRespond, + [ToolSearchToolRegex.id]: ToolSearchToolRegex, + [UpdateJobHistory.id]: UpdateJobHistory, + [UpdateWorkspaceMcpServer.id]: UpdateWorkspaceMcpServer, + [UserMemory.id]: UserMemory, + [UserTable.id]: UserTable, + [WorkspaceFile.id]: WorkspaceFile, +} diff --git a/apps/sim/lib/copilot/logging.ts b/apps/sim/lib/copilot/logging.ts deleted file mode 100644 index b1f0aa90435..00000000000 --- a/apps/sim/lib/copilot/logging.ts +++ /dev/null @@ -1,25 +0,0 @@ -export interface CopilotLogContext { - requestId?: string - messageId?: string -} - -/** - * Appends copilot request identifiers to a log message. - */ -export function appendCopilotLogContext(message: string, context: CopilotLogContext = {}): string { - const suffixParts: string[] = [] - - if (context.requestId) { - suffixParts.push(`requestId:${context.requestId}`) - } - - if (context.messageId) { - suffixParts.push(`messageId:${context.messageId}`) - } - - if (suffixParts.length === 0) { - return message - } - - return `${message} [${suffixParts.join(' ')}]` -} diff --git a/apps/sim/lib/copilot/models.ts b/apps/sim/lib/copilot/models.ts deleted file mode 100644 index f102de517f1..00000000000 --- a/apps/sim/lib/copilot/models.ts +++ /dev/null @@ -1,10 +0,0 @@ -export type CopilotModelId = string - -export const COPILOT_MODES = ['ask', 'build', 'plan'] as const -export type CopilotMode = (typeof COPILOT_MODES)[number] - -export const COPILOT_TRANSPORT_MODES = ['ask', 'agent', 'plan'] as const -export type CopilotTransportMode = (typeof COPILOT_TRANSPORT_MODES)[number] - -export const COPILOT_REQUEST_MODES = ['ask', 'build', 'plan', 'agent'] as const -export type CopilotRequestMode = (typeof COPILOT_REQUEST_MODES)[number] diff --git a/apps/sim/lib/copilot/orchestrator/index.ts b/apps/sim/lib/copilot/orchestrator/index.ts deleted file mode 100644 index 3b03aa8c982..00000000000 --- a/apps/sim/lib/copilot/orchestrator/index.ts +++ /dev/null @@ -1,570 +0,0 @@ -import { createLogger } from '@sim/logger' -import { - ASYNC_TOOL_STATUS, - inferDeliveredAsyncSuccess, - isDeliveredAsyncStatus, - isTerminalAsyncStatus, -} from '@/lib/copilot/async-runs/lifecycle' -import { - claimCompletedAsyncToolCall, - getAsyncToolCall, - getAsyncToolCalls, - markAsyncToolDelivered, - releaseCompletedAsyncToolClaim, - updateRunStatus, -} from '@/lib/copilot/async-runs/repository' -import { SIM_AGENT_API_URL, SIM_AGENT_VERSION } from '@/lib/copilot/constants' -import { - isToolAvailableOnSimSide, - prepareExecutionContext, -} from '@/lib/copilot/orchestrator/tool-executor' -import { - type ExecutionContext, - isTerminalToolCallStatus, - type OrchestratorOptions, - type OrchestratorResult, - type SSEEvent, - type ToolCallState, -} from '@/lib/copilot/orchestrator/types' -import { env } from '@/lib/core/config/env' -import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' -import { buildToolCallSummaries, createStreamingContext, runStreamLoop } from './stream/core' - -const logger = createLogger('CopilotOrchestrator') - -function didAsyncToolSucceed(input: { - durableStatus?: string | null - durableResult?: Record - durableError?: string | null - toolStateStatus?: string | undefined -}) { - const { durableStatus, durableResult, durableError, toolStateStatus } = input - - if (durableStatus === ASYNC_TOOL_STATUS.completed) { - return true - } - if (durableStatus === ASYNC_TOOL_STATUS.failed || durableStatus === ASYNC_TOOL_STATUS.cancelled) { - return false - } - - if (durableStatus === ASYNC_TOOL_STATUS.delivered) { - return inferDeliveredAsyncSuccess({ - result: durableResult, - error: durableError, - }) - } - - if (toolStateStatus === 'success') return true - if (toolStateStatus === 'error' || toolStateStatus === 'cancelled') return false - - return false -} - -interface ReadyContinuationTool { - toolCallId: string - toolState?: ToolCallState - durableRow?: Awaited> - needsDurableClaim: boolean - alreadyClaimedByWorker: boolean -} - -export interface OrchestrateStreamOptions extends OrchestratorOptions { - userId: string - workflowId?: string - workspaceId?: string - chatId?: string - executionId?: string - runId?: string - /** Go-side route to proxy to. Defaults to '/api/copilot'. */ - goRoute?: string -} - -export async function orchestrateCopilotStream( - requestPayload: Record, - options: OrchestrateStreamOptions -): Promise { - const { - userId, - workflowId, - workspaceId, - chatId, - executionId, - runId, - goRoute = '/api/copilot', - } = options - - const userTimezone = - typeof requestPayload?.userTimezone === 'string' ? requestPayload.userTimezone : undefined - - let execContext: ExecutionContext - if (workflowId) { - execContext = await prepareExecutionContext(userId, workflowId, chatId) - } else { - const decryptedEnvVars = await getEffectiveDecryptedEnv(userId, workspaceId) - execContext = { - userId, - workflowId: '', - workspaceId, - chatId, - decryptedEnvVars, - } - } - if (userTimezone) { - execContext.userTimezone = userTimezone - } - execContext.executionId = executionId - execContext.runId = runId - execContext.abortSignal = options.abortSignal - execContext.userStopSignal = options.userStopSignal - - const payloadMsgId = requestPayload?.messageId - const messageId = typeof payloadMsgId === 'string' ? payloadMsgId : crypto.randomUUID() - execContext.messageId = messageId - const context = createStreamingContext({ - chatId, - executionId, - runId, - messageId, - }) - const continuationWorkerId = `sim-resume:${crypto.randomUUID()}` - const reqLogger = logger.withMetadata({ requestId: context.requestId, messageId }) - let claimedToolCallIds: string[] = [] - let claimedByWorkerId: string | null = null - - reqLogger.info('Starting copilot orchestration', { - goRoute, - workflowId, - workspaceId, - chatId, - executionId, - runId, - hasUserTimezone: Boolean(userTimezone), - }) - - try { - let route = goRoute - let payload = requestPayload - - const callerOnEvent = options.onEvent - - for (;;) { - context.streamComplete = false - - reqLogger.info('Starting orchestration loop iteration', { - route, - hasPendingAsyncContinuation: Boolean(context.awaitingAsyncContinuation), - claimedToolCallCount: claimedToolCallIds.length, - }) - - const loopOptions = { - ...options, - onEvent: async (event: SSEEvent) => { - if (event.type === 'done') { - const d = (event.data ?? {}) as Record - const response = (d.response ?? {}) as Record - if (response.async_pause) { - reqLogger.info('Detected async pause from copilot backend', { - route, - checkpointId: - typeof (response.async_pause as Record)?.checkpointId === - 'string' - ? (response.async_pause as Record).checkpointId - : undefined, - }) - if (runId) { - await updateRunStatus(runId, 'paused_waiting_for_tool').catch(() => {}) - } - } - } - await callerOnEvent?.(event) - }, - } - - await runStreamLoop( - `${SIM_AGENT_API_URL}${route}`, - { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}), - 'X-Client-Version': SIM_AGENT_VERSION, - }, - body: JSON.stringify(payload), - }, - context, - execContext, - loopOptions - ) - - reqLogger.info('Completed orchestration loop iteration', { - route, - streamComplete: context.streamComplete, - wasAborted: context.wasAborted, - hasAsyncContinuation: Boolean(context.awaitingAsyncContinuation), - errorCount: context.errors.length, - }) - - if (claimedToolCallIds.length > 0) { - reqLogger.info('Marking async tool calls as delivered', { - toolCallIds: claimedToolCallIds, - }) - await Promise.all( - claimedToolCallIds.map((toolCallId) => - markAsyncToolDelivered(toolCallId).catch(() => null) - ) - ) - claimedToolCallIds = [] - claimedByWorkerId = null - } - - if (options.abortSignal?.aborted || context.wasAborted) { - reqLogger.info('Stopping orchestration because request was aborted', { - pendingToolCallCount: Array.from(context.toolCalls.values()).filter( - (toolCall) => toolCall.status === 'pending' || toolCall.status === 'executing' - ).length, - }) - for (const [toolCallId, toolCall] of context.toolCalls) { - if (toolCall.status === 'pending' || toolCall.status === 'executing') { - toolCall.status = 'cancelled' - toolCall.endTime = Date.now() - toolCall.error = 'Stopped by user' - } - } - context.awaitingAsyncContinuation = undefined - break - } - - const continuation = context.awaitingAsyncContinuation - if (!continuation) { - reqLogger.info('No async continuation pending; finishing orchestration') - break - } - - let resumeReady = false - let resumeRetries = 0 - reqLogger.info('Processing async continuation', { - checkpointId: continuation.checkpointId, - runId: continuation.runId, - pendingToolCallIds: continuation.pendingToolCallIds, - }) - for (;;) { - claimedToolCallIds = [] - claimedByWorkerId = null - const resumeWorkerId = continuationWorkerId - const readyTools: ReadyContinuationTool[] = [] - const localPendingPromises: Promise[] = [] - const missingToolCallIds: string[] = [] - - for (const toolCallId of continuation.pendingToolCallIds) { - const durableRow = await getAsyncToolCall(toolCallId).catch(() => null) - const localPendingPromise = context.pendingToolPromises.get(toolCallId) - const toolState = context.toolCalls.get(toolCallId) - - if (localPendingPromise) { - localPendingPromises.push(localPendingPromise) - reqLogger.info('Waiting for local async tool completion before retrying resume claim', { - toolCallId, - runId: continuation.runId, - workerId: resumeWorkerId, - }) - continue - } - - if (durableRow && isTerminalAsyncStatus(durableRow.status)) { - if (durableRow.claimedBy && durableRow.claimedBy !== resumeWorkerId) { - missingToolCallIds.push(toolCallId) - reqLogger.warn( - 'Async tool continuation is waiting on a claim held by another worker', - { - toolCallId, - runId: continuation.runId, - workerId: resumeWorkerId, - claimedBy: durableRow.claimedBy, - } - ) - continue - } - readyTools.push({ - toolCallId, - toolState, - durableRow, - needsDurableClaim: durableRow.claimedBy !== resumeWorkerId, - alreadyClaimedByWorker: durableRow.claimedBy === resumeWorkerId, - }) - continue - } - - if ( - !durableRow && - toolState && - isTerminalToolCallStatus(toolState.status) && - !isToolAvailableOnSimSide(toolState.name) - ) { - reqLogger.info('Including Go-handled tool in resume payload (no Sim-side row)', { - toolCallId, - toolName: toolState.name, - status: toolState.status, - runId: continuation.runId, - }) - readyTools.push({ - toolCallId, - toolState, - needsDurableClaim: false, - alreadyClaimedByWorker: false, - }) - continue - } - - reqLogger.warn('Skipping already-claimed or missing async tool resume', { - toolCallId, - runId: continuation.runId, - durableStatus: durableRow?.status, - toolStateStatus: toolState?.status, - }) - missingToolCallIds.push(toolCallId) - } - - if (localPendingPromises.length > 0) { - reqLogger.info('Waiting for local pending async tools before resuming continuation', { - checkpointId: continuation.checkpointId, - pendingPromiseCount: localPendingPromises.length, - }) - await Promise.allSettled(localPendingPromises) - continue - } - - if (missingToolCallIds.length > 0) { - if (resumeRetries < 3) { - resumeRetries++ - reqLogger.info('Retrying async resume after some tool calls were not yet ready', { - checkpointId: continuation.checkpointId, - runId: continuation.runId, - workerId: resumeWorkerId, - retry: resumeRetries, - missingToolCallIds, - }) - await new Promise((resolve) => setTimeout(resolve, 250 * resumeRetries)) - continue - } - reqLogger.error( - 'Async continuation failed because pending tool calls never became ready', - { - checkpointId: continuation.checkpointId, - runId: continuation.runId, - missingToolCallIds, - } - ) - throw new Error( - `Failed to resume async tool continuation: pending tool calls were not ready (${missingToolCallIds.join(', ')})` - ) - } - - if (readyTools.length === 0) { - if (resumeRetries < 3 && continuation.pendingToolCallIds.length > 0) { - resumeRetries++ - reqLogger.info('Retrying async resume because no tool calls were ready yet', { - checkpointId: continuation.checkpointId, - runId: continuation.runId, - workerId: resumeWorkerId, - retry: resumeRetries, - }) - await new Promise((resolve) => setTimeout(resolve, 250 * resumeRetries)) - continue - } - reqLogger.error('Async continuation failed because no tool calls were ready', { - checkpointId: continuation.checkpointId, - runId: continuation.runId, - requestedToolCallIds: continuation.pendingToolCallIds, - }) - throw new Error('Failed to resume async tool continuation: no tool calls were ready') - } - - const claimCandidates = readyTools.filter((tool) => tool.needsDurableClaim) - const newlyClaimedToolCallIds: string[] = [] - const claimFailures: string[] = [] - - for (const tool of claimCandidates) { - const claimed = await claimCompletedAsyncToolCall(tool.toolCallId, resumeWorkerId).catch( - () => null - ) - if (!claimed) { - claimFailures.push(tool.toolCallId) - continue - } - newlyClaimedToolCallIds.push(tool.toolCallId) - } - - if (claimFailures.length > 0) { - if (newlyClaimedToolCallIds.length > 0) { - reqLogger.info('Releasing async tool claims after claim contention during resume', { - checkpointId: continuation.checkpointId, - runId: continuation.runId, - workerId: resumeWorkerId, - newlyClaimedToolCallIds, - claimFailures, - }) - await Promise.all( - newlyClaimedToolCallIds.map((toolCallId) => - releaseCompletedAsyncToolClaim(toolCallId, resumeWorkerId).catch(() => null) - ) - ) - } - if (resumeRetries < 3) { - resumeRetries++ - reqLogger.info('Retrying async resume after claim contention', { - checkpointId: continuation.checkpointId, - runId: continuation.runId, - workerId: resumeWorkerId, - retry: resumeRetries, - claimFailures, - }) - await new Promise((resolve) => setTimeout(resolve, 250 * resumeRetries)) - continue - } - reqLogger.error('Async continuation failed because tool claims could not be acquired', { - checkpointId: continuation.checkpointId, - runId: continuation.runId, - claimFailures, - }) - throw new Error( - `Failed to resume async tool continuation: unable to claim tool calls (${claimFailures.join(', ')})` - ) - } - - claimedToolCallIds = [ - ...readyTools - .filter((tool) => tool.alreadyClaimedByWorker) - .map((tool) => tool.toolCallId), - ...newlyClaimedToolCallIds, - ] - claimedByWorkerId = claimedToolCallIds.length > 0 ? resumeWorkerId : null - - reqLogger.info('Resuming async tool continuation', { - checkpointId: continuation.checkpointId, - runId: continuation.runId, - workerId: resumeWorkerId, - toolCallIds: readyTools.map((tool) => tool.toolCallId), - }) - - const durableRows = await getAsyncToolCalls( - readyTools.map((tool) => tool.toolCallId) - ).catch(() => []) - const durableByToolCallId = new Map(durableRows.map((row) => [row.toolCallId, row])) - - const results = await Promise.all( - readyTools.map(async (tool) => { - const durable = durableByToolCallId.get(tool.toolCallId) || tool.durableRow - const durableStatus = durable?.status - const durableResult = - durable?.result && typeof durable.result === 'object' - ? (durable.result as Record) - : undefined - const success = didAsyncToolSucceed({ - durableStatus, - durableResult, - durableError: durable?.error, - toolStateStatus: tool.toolState?.status, - }) - const data = - durableResult || - (tool.toolState?.result?.output as Record | undefined) || - (success - ? { message: 'Tool completed' } - : { - error: durable?.error || tool.toolState?.error || 'Tool failed', - }) - - if ( - durableStatus && - !isTerminalAsyncStatus(durableStatus) && - !isDeliveredAsyncStatus(durableStatus) - ) { - reqLogger.warn( - 'Async tool row was claimed for resume without terminal durable state', - { - toolCallId: tool.toolCallId, - status: durableStatus, - } - ) - } - - return { - callId: tool.toolCallId, - name: durable?.toolName || tool.toolState?.name || '', - data, - success, - } - }) - ) - - context.awaitingAsyncContinuation = undefined - route = '/api/tools/resume' - payload = { - checkpointId: continuation.checkpointId, - results, - } - reqLogger.info('Prepared async continuation payload for resume endpoint', { - route, - checkpointId: continuation.checkpointId, - resultCount: results.length, - }) - resumeReady = true - break - } - - if (!resumeReady) { - reqLogger.warn('Async continuation loop exited without resume payload', { - checkpointId: continuation.checkpointId, - runId: continuation.runId, - }) - break - } - } - - const result: OrchestratorResult = { - success: context.errors.length === 0 && !context.wasAborted, - content: context.accumulatedContent, - contentBlocks: context.contentBlocks, - toolCalls: buildToolCallSummaries(context), - chatId: context.chatId, - requestId: context.requestId, - errors: context.errors.length ? context.errors : undefined, - usage: context.usage, - cost: context.cost, - } - reqLogger.info('Completing copilot orchestration', { - success: result.success, - chatId: result.chatId, - hasRequestId: Boolean(result.requestId), - errorCount: result.errors?.length || 0, - toolCallCount: result.toolCalls.length, - }) - await options.onComplete?.(result) - return result - } catch (error) { - const err = error instanceof Error ? error : new Error('Copilot orchestration failed') - if (claimedToolCallIds.length > 0 && claimedByWorkerId) { - reqLogger.warn('Releasing async tool claims after delivery failure', { - toolCallIds: claimedToolCallIds, - workerId: claimedByWorkerId, - }) - await Promise.all( - claimedToolCallIds.map((toolCallId) => - releaseCompletedAsyncToolClaim(toolCallId, claimedByWorkerId!).catch(() => null) - ) - ) - } - reqLogger.error('Copilot orchestration failed', { - error: err.message, - }) - await options.onError?.(err) - return { - success: false, - content: '', - contentBlocks: [], - toolCalls: [], - chatId: context.chatId, - error: err.message, - } - } -} diff --git a/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.test.ts b/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.test.ts deleted file mode 100644 index 84099340c83..00000000000 --- a/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.test.ts +++ /dev/null @@ -1,293 +0,0 @@ -/** - * @vitest-environment node - */ - -import { loggerMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@sim/logger', () => loggerMock) - -const { executeToolServerSide, markToolComplete, isToolAvailableOnSimSide } = vi.hoisted(() => ({ - executeToolServerSide: vi.fn(), - markToolComplete: vi.fn(), - isToolAvailableOnSimSide: vi.fn().mockReturnValue(true), -})) - -const { upsertAsyncToolCall } = vi.hoisted(() => ({ - upsertAsyncToolCall: vi.fn(), -})) - -vi.mock('@/lib/copilot/orchestrator/tool-executor', () => ({ - executeToolServerSide, - markToolComplete, - isToolAvailableOnSimSide, -})) - -vi.mock('@/lib/copilot/async-runs/repository', async () => { - const actual = await vi.importActual( - '@/lib/copilot/async-runs/repository' - ) - return { - ...actual, - upsertAsyncToolCall, - } -}) - -import { sseHandlers } from '@/lib/copilot/orchestrator/sse/handlers' -import type { ExecutionContext, StreamingContext } from '@/lib/copilot/orchestrator/types' - -describe('sse-handlers tool lifecycle', () => { - let context: StreamingContext - let execContext: ExecutionContext - - beforeEach(() => { - vi.clearAllMocks() - upsertAsyncToolCall.mockResolvedValue(null) - context = { - chatId: undefined, - messageId: 'msg-1', - accumulatedContent: '', - contentBlocks: [], - toolCalls: new Map(), - pendingToolPromises: new Map(), - currentThinkingBlock: null, - isInThinkingBlock: false, - subAgentParentToolCallId: undefined, - subAgentParentStack: [], - subAgentContent: {}, - subAgentToolCalls: {}, - pendingContent: '', - streamComplete: false, - wasAborted: false, - errors: [], - } - execContext = { - userId: 'user-1', - workflowId: 'workflow-1', - } - }) - - it('executes tool_call and emits tool_result + mark-complete', async () => { - executeToolServerSide.mockResolvedValueOnce({ success: true, output: { ok: true } }) - markToolComplete.mockResolvedValueOnce(true) - const onEvent = vi.fn() - - await sseHandlers.tool_call( - { - type: 'tool_call', - data: { id: 'tool-1', name: 'read', arguments: { workflowId: 'workflow-1' } }, - } as any, - context, - execContext, - { onEvent, interactive: false, timeout: 1000 } - ) - - // tool_call fires execution without awaiting (fire-and-forget for parallel execution), - // so we flush pending microtasks before asserting - await new Promise((resolve) => setTimeout(resolve, 0)) - - expect(executeToolServerSide).toHaveBeenCalledTimes(1) - expect(markToolComplete).toHaveBeenCalledTimes(1) - expect(onEvent).toHaveBeenCalledWith( - expect.objectContaining({ - type: 'tool_result', - toolCallId: 'tool-1', - success: true, - }) - ) - - const updated = context.toolCalls.get('tool-1') - expect(updated?.status).toBe('success') - expect(updated?.result?.output).toEqual({ ok: true }) - }) - - it('skips duplicate tool_call after result', async () => { - executeToolServerSide.mockResolvedValueOnce({ success: true, output: { ok: true } }) - markToolComplete.mockResolvedValueOnce(true) - - const event = { - type: 'tool_call', - data: { id: 'tool-dup', name: 'read', arguments: { workflowId: 'workflow-1' } }, - } - - await sseHandlers.tool_call(event as any, context, execContext, { interactive: false }) - await new Promise((resolve) => setTimeout(resolve, 0)) - await sseHandlers.tool_call(event as any, context, execContext, { interactive: false }) - - expect(executeToolServerSide).toHaveBeenCalledTimes(1) - expect(markToolComplete).toHaveBeenCalledTimes(1) - }) - - it('marks an in-flight tool as cancelled when aborted mid-execution', async () => { - const abortController = new AbortController() - const userStopController = new AbortController() - execContext.abortSignal = abortController.signal - execContext.userStopSignal = userStopController.signal - - executeToolServerSide.mockImplementationOnce( - () => - new Promise((resolve) => { - setTimeout(() => resolve({ success: true, output: { ok: true } }), 0) - }) - ) - markToolComplete.mockResolvedValue(true) - - await sseHandlers.tool_call( - { - type: 'tool_call', - data: { id: 'tool-cancel', name: 'read', arguments: { workflowId: 'workflow-1' } }, - } as any, - context, - execContext, - { - interactive: false, - timeout: 1000, - abortSignal: abortController.signal, - userStopSignal: userStopController.signal, - } - ) - - userStopController.abort() - abortController.abort() - await new Promise((resolve) => setTimeout(resolve, 10)) - - expect(markToolComplete).toHaveBeenCalledWith( - 'tool-cancel', - 'read', - 499, - 'Request aborted during tool execution', - { cancelled: true }, - 'msg-1' - ) - - const updated = context.toolCalls.get('tool-cancel') - expect(updated?.status).toBe('cancelled') - }) - - it('does not replace an in-flight pending promise on duplicate tool_call', async () => { - let resolveTool: ((value: { success: boolean; output: { ok: boolean } }) => void) | undefined - executeToolServerSide.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveTool = resolve - }) - ) - markToolComplete.mockResolvedValueOnce(true) - - const event = { - type: 'tool_call', - data: { id: 'tool-inflight', name: 'read', arguments: { workflowId: 'workflow-1' } }, - } - - await sseHandlers.tool_call(event as any, context, execContext, { interactive: false }) - await new Promise((resolve) => setTimeout(resolve, 0)) - - const firstPromise = context.pendingToolPromises.get('tool-inflight') - expect(firstPromise).toBeDefined() - - await sseHandlers.tool_call(event as any, context, execContext, { interactive: false }) - - expect(executeToolServerSide).toHaveBeenCalledTimes(1) - expect(context.pendingToolPromises.get('tool-inflight')).toBe(firstPromise) - - resolveTool?.({ success: true, output: { ok: true } }) - await new Promise((resolve) => setTimeout(resolve, 0)) - - expect(context.pendingToolPromises.has('tool-inflight')).toBe(false) - expect(markToolComplete).toHaveBeenCalledTimes(1) - }) - - it('still executes the tool when async row upsert fails', async () => { - upsertAsyncToolCall.mockRejectedValueOnce(new Error('db down')) - executeToolServerSide.mockResolvedValueOnce({ success: true, output: { ok: true } }) - markToolComplete.mockResolvedValueOnce(true) - - await sseHandlers.tool_call( - { - type: 'tool_call', - data: { id: 'tool-upsert-fail', name: 'read', arguments: { workflowId: 'workflow-1' } }, - } as any, - context, - execContext, - { onEvent: vi.fn(), interactive: false, timeout: 1000 } - ) - - await new Promise((resolve) => setTimeout(resolve, 0)) - - expect(executeToolServerSide).toHaveBeenCalledTimes(1) - expect(markToolComplete).toHaveBeenCalledTimes(1) - expect(context.toolCalls.get('tool-upsert-fail')?.status).toBe('success') - }) - - it('does not execute a tool if a terminal tool_result arrives before local execution starts', async () => { - let resolveUpsert: ((value: null) => void) | undefined - upsertAsyncToolCall.mockImplementationOnce( - () => - new Promise((resolve) => { - resolveUpsert = resolve - }) - ) - const onEvent = vi.fn() - - await sseHandlers.tool_call( - { - type: 'tool_call', - data: { id: 'tool-race', name: 'read', arguments: { workflowId: 'workflow-1' } }, - } as any, - context, - execContext, - { onEvent, interactive: false, timeout: 1000 } - ) - - await sseHandlers.tool_result( - { - type: 'tool_result', - toolCallId: 'tool-race', - data: { id: 'tool-race', success: true, result: { ok: true } }, - } as any, - context, - execContext, - { onEvent, interactive: false, timeout: 1000 } - ) - - resolveUpsert?.(null) - await new Promise((resolve) => setTimeout(resolve, 0)) - - expect(executeToolServerSide).not.toHaveBeenCalled() - expect(markToolComplete).not.toHaveBeenCalled() - expect(context.toolCalls.get('tool-race')?.status).toBe('success') - expect(context.toolCalls.get('tool-race')?.result?.output).toEqual({ ok: true }) - }) - - it('does not execute a tool if a tool_result arrives before the tool_call event', async () => { - const onEvent = vi.fn() - - await sseHandlers.tool_result( - { - type: 'tool_result', - toolCallId: 'tool-early-result', - toolName: 'read', - data: { id: 'tool-early-result', name: 'read', success: true, result: { ok: true } }, - } as any, - context, - execContext, - { onEvent, interactive: false, timeout: 1000 } - ) - - await sseHandlers.tool_call( - { - type: 'tool_call', - data: { id: 'tool-early-result', name: 'read', arguments: { workflowId: 'workflow-1' } }, - } as any, - context, - execContext, - { onEvent, interactive: false, timeout: 1000 } - ) - - await new Promise((resolve) => setTimeout(resolve, 0)) - - expect(executeToolServerSide).not.toHaveBeenCalled() - expect(markToolComplete).not.toHaveBeenCalled() - expect(context.toolCalls.get('tool-early-result')?.status).toBe('success') - }) -}) diff --git a/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.ts b/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.ts deleted file mode 100644 index e3f1cd829df..00000000000 --- a/apps/sim/lib/copilot/orchestrator/sse/handlers/handlers.ts +++ /dev/null @@ -1,852 +0,0 @@ -import { createLogger } from '@sim/logger' -import { upsertAsyncToolCall } from '@/lib/copilot/async-runs/repository' -import { STREAM_TIMEOUT_MS } from '@/lib/copilot/constants' -import { - asRecord, - getEventData, - markToolResultSeen, - wasToolResultSeen, -} from '@/lib/copilot/orchestrator/sse/utils' -import { - isToolAvailableOnSimSide, - markToolComplete, -} from '@/lib/copilot/orchestrator/tool-executor' -import type { - ContentBlock, - ExecutionContext, - OrchestratorOptions, - SSEEvent, - StreamingContext, - ToolCallState, -} from '@/lib/copilot/orchestrator/types' -import { isWorkflowToolName } from '@/lib/copilot/workflow-tools' -import { executeToolAndReport, waitForToolCompletion } from './tool-execution' - -const logger = createLogger('CopilotSseHandlers') - -/** - * Builds an AbortSignal that fires when either the main abort signal OR - * the client-disconnect signal fires. Used for client-executable tool waits - * so the orchestrator doesn't block for the full timeout when the browser dies. - */ -function buildClientToolAbortSignal(options: OrchestratorOptions): AbortSignal | undefined { - const { abortSignal, clientDisconnectedSignal } = options - if (!clientDisconnectedSignal || clientDisconnectedSignal.aborted) { - return clientDisconnectedSignal?.aborted ? AbortSignal.abort() : abortSignal - } - if (!abortSignal) return clientDisconnectedSignal - - const combined = new AbortController() - const fire = () => combined.abort() - abortSignal.addEventListener('abort', fire, { once: true }) - clientDisconnectedSignal.addEventListener('abort', fire, { once: true }) - return combined.signal -} - -function registerPendingToolPromise( - context: StreamingContext, - toolCallId: string, - pendingPromise: Promise<{ status: string; message?: string; data?: Record }> -) { - context.pendingToolPromises.set(toolCallId, pendingPromise) - pendingPromise.finally(() => { - if (context.pendingToolPromises.get(toolCallId) === pendingPromise) { - context.pendingToolPromises.delete(toolCallId) - } - }) -} - -/** - * When the Sim→Go stream is aborted, avoid starting server-side tool work and - * unblock the Go async waiter with a terminal 499 completion. - */ -function abortPendingToolIfStreamDead( - toolCall: ToolCallState, - toolCallId: string, - options: OrchestratorOptions, - context: StreamingContext -): boolean { - if (!options.abortSignal?.aborted && !context.wasAborted) { - return false - } - toolCall.status = 'cancelled' - toolCall.endTime = Date.now() - markToolResultSeen(toolCallId) - markToolComplete( - toolCall.id, - toolCall.name, - 499, - 'Request aborted before tool execution', - { - cancelled: true, - }, - context.messageId - ).catch((err) => { - logger - .withMetadata({ messageId: context.messageId }) - .error('markToolComplete fire-and-forget failed (stream aborted)', { - toolCallId: toolCall.id, - error: err instanceof Error ? err.message : String(err), - }) - }) - return true -} - -/** - * Extract the `ui` object from an SSE event. The server enriches - * tool_call events with `ui: { requiresConfirmation, clientExecutable, ... }`. - */ -function getEventUI(event: SSEEvent): { - requiresConfirmation: boolean - clientExecutable: boolean - internal: boolean - hidden: boolean -} { - const raw = asRecord((event as unknown as Record).ui) - return { - requiresConfirmation: raw.requiresConfirmation === true, - clientExecutable: raw.clientExecutable === true, - internal: raw.internal === true, - hidden: raw.hidden === true, - } -} - -/** - * Handle the completion signal from a client-executable tool. - * Shared by both the main and subagent tool_call handlers. - */ -function handleClientCompletion( - toolCall: ToolCallState, - toolCallId: string, - completion: { status: string; message?: string; data?: Record } | null, - context: StreamingContext -): void { - if (completion?.status === 'background') { - toolCall.status = 'skipped' - toolCall.endTime = Date.now() - markToolComplete( - toolCall.id, - toolCall.name, - 202, - completion.message || 'Tool execution moved to background', - { background: true }, - context.messageId - ).catch((err) => { - logger - .withMetadata({ messageId: context.messageId }) - .error('markToolComplete fire-and-forget failed (client background)', { - toolCallId: toolCall.id, - error: err instanceof Error ? err.message : String(err), - }) - }) - markToolResultSeen(toolCallId) - return - } - if (completion?.status === 'rejected') { - toolCall.status = 'rejected' - toolCall.endTime = Date.now() - markToolComplete( - toolCall.id, - toolCall.name, - 400, - completion.message || 'Tool execution rejected', - undefined, - context.messageId - ).catch((err) => { - logger - .withMetadata({ messageId: context.messageId }) - .error('markToolComplete fire-and-forget failed (client rejected)', { - toolCallId: toolCall.id, - error: err instanceof Error ? err.message : String(err), - }) - }) - markToolResultSeen(toolCallId) - return - } - if (completion?.status === 'cancelled') { - toolCall.status = 'cancelled' - toolCall.endTime = Date.now() - markToolComplete( - toolCall.id, - toolCall.name, - 499, - completion.message || 'Workflow execution was stopped manually by the user.', - completion.data, - context.messageId - ).catch((err) => { - logger - .withMetadata({ messageId: context.messageId }) - .error('markToolComplete fire-and-forget failed (client cancelled)', { - toolCallId: toolCall.id, - error: err instanceof Error ? err.message : String(err), - }) - }) - markToolResultSeen(toolCallId) - return - } - const success = completion?.status === 'success' - toolCall.status = success ? 'success' : 'error' - toolCall.endTime = Date.now() - const msg = completion?.message || (success ? 'Tool completed' : 'Tool failed or timed out') - markToolComplete( - toolCall.id, - toolCall.name, - success ? 200 : 500, - msg, - completion?.data, - context.messageId - ).catch((err) => { - logger - .withMetadata({ messageId: context.messageId }) - .error('markToolComplete fire-and-forget failed (client completion)', { - toolCallId: toolCall.id, - toolName: toolCall.name, - error: err instanceof Error ? err.message : String(err), - }) - }) - markToolResultSeen(toolCallId) -} - -/** - * Emit a synthetic tool_result SSE event to the client after a client-executable - * tool completes. The server's actual tool_result is skipped (markToolResultSeen), - * so the client would never learn the outcome without this. - */ -async function emitSyntheticToolResult( - toolCallId: string, - toolName: string, - completion: { status: string; message?: string; data?: Record } | null, - options: OrchestratorOptions, - context: StreamingContext -): Promise { - const success = completion?.status === 'success' - const isCancelled = completion?.status === 'cancelled' - - const resultPayload = isCancelled - ? { ...completion?.data, reason: 'user_cancelled', cancelledByUser: true } - : completion?.data - - try { - await options.onEvent?.({ - type: 'tool_result', - toolCallId, - toolName, - success, - result: resultPayload, - error: !success ? completion?.message : undefined, - } as SSEEvent) - } catch (error) { - logger - .withMetadata({ messageId: context.messageId }) - .warn('Failed to emit synthetic tool_result', { - toolCallId, - toolName, - error: error instanceof Error ? error.message : String(error), - }) - } -} - -// Normalization + dedupe helpers live in sse-utils to keep server/client in sync. - -function inferToolSuccess(data: Record | undefined): { - success: boolean - hasResultData: boolean - hasError: boolean -} { - const resultObj = asRecord(data?.result) - const hasExplicitSuccess = data?.success !== undefined || resultObj.success !== undefined - const explicitSuccess = data?.success ?? resultObj.success - const hasResultData = data?.result !== undefined || data?.data !== undefined - const hasError = !!data?.error || !!resultObj.error - const success = hasExplicitSuccess ? !!explicitSuccess : !hasError - return { success, hasResultData, hasError } -} - -function ensureTerminalToolCallState( - context: StreamingContext, - toolCallId: string, - toolName: string -): ToolCallState { - const existing = context.toolCalls.get(toolCallId) - if (existing) { - return existing - } - - const toolCall: ToolCallState = { - id: toolCallId, - name: toolName || 'unknown_tool', - status: 'pending', - startTime: Date.now(), - } - context.toolCalls.set(toolCallId, toolCall) - addContentBlock(context, { type: 'tool_call', toolCall }) - return toolCall -} - -export type SSEHandler = ( - event: SSEEvent, - context: StreamingContext, - execContext: ExecutionContext, - options: OrchestratorOptions -) => void | Promise - -function addContentBlock(context: StreamingContext, block: Omit): void { - context.contentBlocks.push({ - ...block, - timestamp: Date.now(), - }) -} - -export const sseHandlers: Record = { - chat_id: (event, context, execContext) => { - const chatId = asRecord(event.data).chatId as string | undefined - context.chatId = chatId - if (chatId) { - execContext.chatId = chatId - } - }, - request_id: (event, context) => { - const rid = typeof event.data === 'string' ? event.data : undefined - if (rid) { - context.requestId = rid - logger - .withMetadata({ messageId: context.messageId }) - .info('Mapped copilot message to Go trace ID', { - goTraceId: rid, - chatId: context.chatId, - executionId: context.executionId, - runId: context.runId, - }) - } - }, - title_updated: () => {}, - tool_result: (event, context) => { - const data = getEventData(event) - const toolCallId = event.toolCallId || (data?.id as string | undefined) - if (!toolCallId) return - const toolName = - event.toolName || - (data?.name as string | undefined) || - context.toolCalls.get(toolCallId)?.name || - '' - const current = ensureTerminalToolCallState(context, toolCallId, toolName) - - const { success, hasResultData, hasError } = inferToolSuccess(data) - - current.status = success ? 'success' : 'error' - current.endTime = Date.now() - if (hasResultData) { - current.result = { - success, - output: data?.result || data?.data, - } - } - if (hasError) { - const resultObj = asRecord(data?.result) - current.error = (data?.error || resultObj.error) as string | undefined - } - markToolResultSeen(toolCallId) - }, - tool_error: (event, context) => { - const data = getEventData(event) - const toolCallId = event.toolCallId || (data?.id as string | undefined) - if (!toolCallId) return - const toolName = - event.toolName || - (data?.name as string | undefined) || - context.toolCalls.get(toolCallId)?.name || - '' - const current = ensureTerminalToolCallState(context, toolCallId, toolName) - current.status = 'error' - current.error = (data?.error as string | undefined) || 'Tool execution failed' - current.endTime = Date.now() - markToolResultSeen(toolCallId) - }, - tool_call_delta: () => { - // Argument streaming delta — no action needed on orchestrator side - }, - tool_generating: (event, context) => { - const data = getEventData(event) - const toolCallId = - event.toolCallId || - (data?.toolCallId as string | undefined) || - (data?.id as string | undefined) - const toolName = - event.toolName || (data?.toolName as string | undefined) || (data?.name as string | undefined) - if (!toolCallId || !toolName) return - if (!context.toolCalls.has(toolCallId)) { - const toolCall = { - id: toolCallId, - name: toolName, - status: 'pending' as const, - startTime: Date.now(), - } - context.toolCalls.set(toolCallId, toolCall) - addContentBlock(context, { type: 'tool_call', toolCall }) - } - }, - tool_call: async (event, context, execContext, options) => { - const toolData = getEventData(event) || ({} as Record) - const toolCallId = (toolData.id as string | undefined) || event.toolCallId - const toolName = (toolData.name as string | undefined) || event.toolName - if (!toolCallId || !toolName) return - - const args = (toolData.arguments || toolData.input || asRecord(event.data).input) as - | Record - | undefined - const isPartial = toolData.partial === true - const existing = context.toolCalls.get(toolCallId) - - if ( - existing?.endTime || - (existing && existing.status !== 'pending' && existing.status !== 'executing') - ) { - if (!existing.name && toolName) { - existing.name = toolName - } - if (!existing.params && args) { - existing.params = args - } - return - } - - if (existing) { - if (args && !existing.params) existing.params = args - if ( - !context.contentBlocks.some((b) => b.type === 'tool_call' && b.toolCall?.id === toolCallId) - ) { - addContentBlock(context, { type: 'tool_call', toolCall: existing }) - } - } else { - const created = { - id: toolCallId, - name: toolName, - status: 'pending' as const, - params: args, - startTime: Date.now(), - } - context.toolCalls.set(toolCallId, created) - addContentBlock(context, { type: 'tool_call', toolCall: created }) - } - - if (isPartial) return - if (wasToolResultSeen(toolCallId)) return - if (context.pendingToolPromises.has(toolCallId) || existing?.status === 'executing') { - return - } - - const toolCall = context.toolCalls.get(toolCallId) - if (!toolCall) return - - const { clientExecutable, internal } = getEventUI(event) - - if (internal) { - return - } - - if (!isToolAvailableOnSimSide(toolName) && !clientExecutable) { - return - } - - /** - * Fire tool execution without awaiting so parallel tool calls from the - * same LLM turn execute concurrently. executeToolAndReport is self-contained: - * it updates tool state, calls markToolComplete, and emits result events. - */ - const fireToolExecution = () => { - const pendingPromise = (async () => { - try { - await upsertAsyncToolCall({ - runId: context.runId || crypto.randomUUID(), - toolCallId, - toolName, - args, - }) - } catch (err) { - logger - .withMetadata({ messageId: context.messageId }) - .warn('Failed to persist async tool row before execution', { - toolCallId, - toolName, - error: err instanceof Error ? err.message : String(err), - }) - } - return executeToolAndReport(toolCallId, context, execContext, options) - })().catch((err) => { - logger - .withMetadata({ messageId: context.messageId }) - .error('Parallel tool execution failed', { - toolCallId, - toolName, - error: err instanceof Error ? err.message : String(err), - }) - return { - status: 'error', - message: err instanceof Error ? err.message : String(err), - data: { error: err instanceof Error ? err.message : String(err) }, - } - }) - registerPendingToolPromise(context, toolCallId, pendingPromise) - } - - if (options.interactive === false) { - if (options.autoExecuteTools !== false) { - if (!abortPendingToolIfStreamDead(toolCall, toolCallId, options, context)) { - fireToolExecution() - } - } - return - } - - // Client-executable tool: execute server-side if available, otherwise - // delegate to the client (React UI) and wait for completion. - // Workflow run tools are implemented on Sim for MCP/server callers but must - // still run in the browser when clientExecutable so the workflow terminal - // receives SSE block logs (executeWorkflowWithFullLogging). - if (clientExecutable) { - const delegateWorkflowRunToClient = isWorkflowToolName(toolName) - if (isToolAvailableOnSimSide(toolName) && !delegateWorkflowRunToClient) { - if (!abortPendingToolIfStreamDead(toolCall, toolCallId, options, context)) { - fireToolExecution() - } - } else { - toolCall.status = 'executing' - await upsertAsyncToolCall({ - runId: context.runId || crypto.randomUUID(), - toolCallId, - toolName, - args, - status: 'running', - }).catch((err) => { - logger - .withMetadata({ messageId: context.messageId }) - .warn('Failed to persist async tool row for client-executable tool', { - toolCallId, - toolName, - error: err instanceof Error ? err.message : String(err), - }) - }) - const clientWaitSignal = buildClientToolAbortSignal(options) - const completion = await waitForToolCompletion( - toolCallId, - options.timeout || STREAM_TIMEOUT_MS, - clientWaitSignal - ) - handleClientCompletion(toolCall, toolCallId, completion, context) - await emitSyntheticToolResult(toolCallId, toolCall.name, completion, options, context) - } - return - } - - if (options.autoExecuteTools !== false) { - if (!abortPendingToolIfStreamDead(toolCall, toolCallId, options, context)) { - fireToolExecution() - } - } - }, - reasoning: (event, context) => { - const d = asRecord(event.data) - const phase = d.phase || asRecord(d.data).phase - if (phase === 'start') { - context.isInThinkingBlock = true - context.currentThinkingBlock = { - type: 'thinking', - content: '', - timestamp: Date.now(), - } - return - } - if (phase === 'end') { - if (context.currentThinkingBlock) { - context.contentBlocks.push(context.currentThinkingBlock) - } - context.isInThinkingBlock = false - context.currentThinkingBlock = null - return - } - const chunk = (d.data || d.content || event.content) as string | undefined - if (!chunk || !context.currentThinkingBlock) return - context.currentThinkingBlock.content = `${context.currentThinkingBlock.content || ''}${chunk}` - }, - content: (event, context) => { - // Server sends content as a plain string in event.data, not wrapped in an object. - let chunk: string | undefined - if (typeof event.data === 'string') { - chunk = event.data - } else { - const d = asRecord(event.data) - chunk = (d.content || d.data || event.content) as string | undefined - } - if (!chunk) return - context.accumulatedContent += chunk - addContentBlock(context, { type: 'text', content: chunk }) - }, - done: (event, context) => { - const d = asRecord(event.data) - const response = asRecord(d.response) - const asyncPause = asRecord(response.async_pause) - if (asyncPause.checkpointId) { - context.awaitingAsyncContinuation = { - checkpointId: String(asyncPause.checkpointId), - executionId: - typeof asyncPause.executionId === 'string' ? asyncPause.executionId : context.executionId, - runId: typeof asyncPause.runId === 'string' ? asyncPause.runId : context.runId, - pendingToolCallIds: Array.isArray(asyncPause.pendingToolCallIds) - ? asyncPause.pendingToolCallIds.map((id) => String(id)) - : [], - } - } - if (d.usage) { - const u = asRecord(d.usage) - context.usage = { - prompt: (u.input_tokens as number) || 0, - completion: (u.output_tokens as number) || 0, - } - } - if (d.cost) { - const c = asRecord(d.cost) - context.cost = { - input: (c.input as number) || 0, - output: (c.output as number) || 0, - total: (c.total as number) || 0, - } - } - context.streamComplete = true - }, - start: () => {}, - error: (event, context) => { - const d = asRecord(event.data) - const message = (d.message || d.error || event.error) as string | undefined - if (message) { - context.errors.push(message) - } - context.streamComplete = true - }, -} - -export const subAgentHandlers: Record = { - content: (event, context) => { - const parentToolCallId = context.subAgentParentToolCallId - if (!parentToolCallId || !event.data) return - // Server sends content as a plain string in event.data - let chunk: string | undefined - if (typeof event.data === 'string') { - chunk = event.data - } else { - const d = asRecord(event.data) - chunk = (d.content || d.data || event.content) as string | undefined - } - if (!chunk) return - context.subAgentContent[parentToolCallId] = - (context.subAgentContent[parentToolCallId] || '') + chunk - addContentBlock(context, { type: 'subagent_text', content: chunk }) - }, - tool_call: async (event, context, execContext, options) => { - const parentToolCallId = context.subAgentParentToolCallId - if (!parentToolCallId) return - const toolData = getEventData(event) || ({} as Record) - const toolCallId = (toolData.id as string | undefined) || event.toolCallId - const toolName = (toolData.name as string | undefined) || event.toolName - if (!toolCallId || !toolName) return - const isPartial = toolData.partial === true - const args = (toolData.arguments || toolData.input || asRecord(event.data).input) as - | Record - | undefined - - const existing = context.toolCalls.get(toolCallId) - // Ignore late/duplicate tool_call events once we already have a result. - if (wasToolResultSeen(toolCallId) || existing?.endTime) { - if (existing && !existing.name && toolName) { - existing.name = toolName - } - if (existing && !existing.params && args) { - existing.params = args - } - return - } - - const toolCall: ToolCallState = { - id: toolCallId, - name: toolName, - status: 'pending', - params: args, - startTime: Date.now(), - } - - // Store in both places - but do NOT overwrite existing tool call state for the same id. - if (!context.subAgentToolCalls[parentToolCallId]) { - context.subAgentToolCalls[parentToolCallId] = [] - } - if (!context.subAgentToolCalls[parentToolCallId].some((tc) => tc.id === toolCallId)) { - context.subAgentToolCalls[parentToolCallId].push(toolCall) - } - if (!context.toolCalls.has(toolCallId)) { - context.toolCalls.set(toolCallId, toolCall) - const parentToolCall = context.toolCalls.get(parentToolCallId) - addContentBlock(context, { - type: 'tool_call', - toolCall, - calledBy: parentToolCall?.name, - }) - } - - if (isPartial) return - if (context.pendingToolPromises.has(toolCallId) || existing?.status === 'executing') { - return - } - - const { clientExecutable, internal } = getEventUI(event) - - if (internal) { - return - } - - if (!isToolAvailableOnSimSide(toolName) && !clientExecutable) { - return - } - - const fireToolExecution = () => { - const pendingPromise = (async () => { - try { - await upsertAsyncToolCall({ - runId: context.runId || crypto.randomUUID(), - toolCallId, - toolName, - args, - }) - } catch (err) { - logger - .withMetadata({ messageId: context.messageId }) - .warn('Failed to persist async subagent tool row before execution', { - toolCallId, - toolName, - error: err instanceof Error ? err.message : String(err), - }) - } - return executeToolAndReport(toolCallId, context, execContext, options) - })().catch((err) => { - logger - .withMetadata({ messageId: context.messageId }) - .error('Parallel subagent tool execution failed', { - toolCallId, - toolName, - error: err instanceof Error ? err.message : String(err), - }) - return { - status: 'error', - message: err instanceof Error ? err.message : String(err), - data: { error: err instanceof Error ? err.message : String(err) }, - } - }) - registerPendingToolPromise(context, toolCallId, pendingPromise) - } - - if (options.interactive === false) { - if (options.autoExecuteTools !== false) { - if (!abortPendingToolIfStreamDead(toolCall, toolCallId, options, context)) { - fireToolExecution() - } - } - return - } - - if (clientExecutable) { - const delegateWorkflowRunToClient = isWorkflowToolName(toolName) - if (isToolAvailableOnSimSide(toolName) && !delegateWorkflowRunToClient) { - if (!abortPendingToolIfStreamDead(toolCall, toolCallId, options, context)) { - fireToolExecution() - } - } else { - toolCall.status = 'executing' - await upsertAsyncToolCall({ - runId: context.runId || crypto.randomUUID(), - toolCallId, - toolName, - args, - status: 'running', - }).catch((err) => { - logger - .withMetadata({ messageId: context.messageId }) - .warn('Failed to persist async tool row for client-executable subagent tool', { - toolCallId, - toolName, - error: err instanceof Error ? err.message : String(err), - }) - }) - const clientWaitSignal = buildClientToolAbortSignal(options) - const completion = await waitForToolCompletion( - toolCallId, - options.timeout || STREAM_TIMEOUT_MS, - clientWaitSignal - ) - handleClientCompletion(toolCall, toolCallId, completion, context) - await emitSyntheticToolResult(toolCallId, toolCall.name, completion, options, context) - } - return - } - - if (options.autoExecuteTools !== false) { - if (!abortPendingToolIfStreamDead(toolCall, toolCallId, options, context)) { - fireToolExecution() - } - } - }, - tool_result: (event, context) => { - const parentToolCallId = context.subAgentParentToolCallId - if (!parentToolCallId) return - const data = getEventData(event) - const toolCallId = event.toolCallId || (data?.id as string | undefined) - if (!toolCallId) return - const toolName = event.toolName || (data?.name as string | undefined) || '' - - // Update in subAgentToolCalls. - const toolCalls = context.subAgentToolCalls[parentToolCallId] || [] - const subAgentToolCall = toolCalls.find((tc) => tc.id === toolCallId) - - // Also update in main toolCalls (where we added it for execution). - const mainToolCall = ensureTerminalToolCallState(context, toolCallId, toolName) - - const { success, hasResultData, hasError } = inferToolSuccess(data) - - const status = success ? 'success' : 'error' - const endTime = Date.now() - const result = hasResultData ? { success, output: data?.result || data?.data } : undefined - - if (subAgentToolCall) { - subAgentToolCall.status = status - subAgentToolCall.endTime = endTime - if (result) subAgentToolCall.result = result - if (hasError) { - const resultObj = asRecord(data?.result) - subAgentToolCall.error = (data?.error || resultObj.error) as string | undefined - } - } - - if (mainToolCall) { - mainToolCall.status = status - mainToolCall.endTime = endTime - if (result) mainToolCall.result = result - if (hasError) { - const resultObj = asRecord(data?.result) - mainToolCall.error = (data?.error || resultObj.error) as string | undefined - } - } - if (subAgentToolCall || mainToolCall) { - markToolResultSeen(toolCallId) - } - }, -} - -export function handleSubagentRouting(event: SSEEvent, context: StreamingContext): boolean { - if (!event.subagent) return false - if (!context.subAgentParentToolCallId) { - logger - .withMetadata({ messageId: context.messageId }) - .warn('Subagent event missing parent tool call', { - type: event.type, - subagent: event.subagent, - }) - return false - } - return true -} diff --git a/apps/sim/lib/copilot/orchestrator/sse/handlers/index.ts b/apps/sim/lib/copilot/orchestrator/sse/handlers/index.ts deleted file mode 100644 index d0d6b14b5bf..00000000000 --- a/apps/sim/lib/copilot/orchestrator/sse/handlers/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export type { SSEHandler } from './handlers' -export { handleSubagentRouting, sseHandlers, subAgentHandlers } from './handlers' diff --git a/apps/sim/lib/copilot/orchestrator/sse/handlers/tool-execution.ts b/apps/sim/lib/copilot/orchestrator/sse/handlers/tool-execution.ts deleted file mode 100644 index e0296f8b525..00000000000 --- a/apps/sim/lib/copilot/orchestrator/sse/handlers/tool-execution.ts +++ /dev/null @@ -1,936 +0,0 @@ -import { db } from '@sim/db' -import { userTableRows } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' -import { completeAsyncToolCall, markAsyncToolRunning } from '@/lib/copilot/async-runs/repository' -import { waitForToolConfirmation } from '@/lib/copilot/orchestrator/persistence' -import { asRecord, markToolResultSeen } from '@/lib/copilot/orchestrator/sse/utils' -import { executeToolServerSide, markToolComplete } from '@/lib/copilot/orchestrator/tool-executor' -import { - type ExecutionContext, - isTerminalToolCallStatus, - type OrchestratorOptions, - type SSEEvent, - type StreamingContext, - type ToolCallResult, -} from '@/lib/copilot/orchestrator/types' -import { - extractDeletedResourcesFromToolResult, - extractResourcesFromToolResult, - hasDeleteCapability, - isResourceToolName, - persistChatResources, - removeChatResources, -} from '@/lib/copilot/resources' -import { getTableById } from '@/lib/table/service' -import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' - -const logger = createLogger('CopilotSseToolExecution') - -const OUTPUT_PATH_TOOLS = new Set(['function_execute', 'user_table']) - -/** - * Try to pull a flat array of row-objects out of the various shapes that - * `function_execute` and `user_table` can return. - */ -function extractTabularData(output: unknown): Record[] | null { - if (!output || typeof output !== 'object') return null - - if (Array.isArray(output)) { - if (output.length > 0 && typeof output[0] === 'object' && output[0] !== null) { - return output as Record[] - } - return null - } - - const obj = output as Record - - // function_execute shape: { result: [...], stdout: "..." } - if (Array.isArray(obj.result)) { - const rows = obj.result - if (rows.length > 0 && typeof rows[0] === 'object' && rows[0] !== null) { - return rows as Record[] - } - } - - // user_table query_rows shape: { data: { rows: [{ data: {...} }], totalCount } } - if (obj.data && typeof obj.data === 'object' && !Array.isArray(obj.data)) { - const data = obj.data as Record - if (Array.isArray(data.rows) && data.rows.length > 0) { - const rows = data.rows as Record[] - // user_table rows nest actual values inside .data - if (typeof rows[0].data === 'object' && rows[0].data !== null) { - return rows.map((r) => r.data as Record) - } - return rows - } - } - - return null -} - -function escapeCsvValue(value: unknown): string { - if (value === null || value === undefined) return '' - const str = typeof value === 'object' ? JSON.stringify(value) : String(value) - if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) { - return `"${str.replace(/"/g, '""')}"` - } - return str -} - -function convertRowsToCsv(rows: Record[]): string { - if (rows.length === 0) return '' - - const headerSet = new Set() - for (const row of rows) { - for (const key of Object.keys(row)) { - headerSet.add(key) - } - } - const headers = [...headerSet] - - const lines = [headers.map(escapeCsvValue).join(',')] - for (const row of rows) { - lines.push(headers.map((h) => escapeCsvValue(row[h])).join(',')) - } - return lines.join('\n') -} - -type OutputFormat = 'json' | 'csv' | 'txt' | 'md' | 'html' - -const EXT_TO_FORMAT: Record = { - '.json': 'json', - '.csv': 'csv', - '.txt': 'txt', - '.md': 'md', - '.html': 'html', -} - -const FORMAT_TO_CONTENT_TYPE: Record = { - json: 'application/json', - csv: 'text/csv', - txt: 'text/plain', - md: 'text/markdown', - html: 'text/html', -} - -function normalizeOutputWorkspaceFileName(outputPath: string): string { - const trimmed = outputPath.trim().replace(/^\/+/, '') - const withoutPrefix = trimmed.startsWith('files/') ? trimmed.slice('files/'.length) : trimmed - if (!withoutPrefix) { - throw new Error('outputPath must include a file name, e.g. "files/result.json"') - } - if (withoutPrefix.includes('/')) { - throw new Error( - 'outputPath must target a flat workspace file, e.g. "files/result.json". Nested paths like "files/reports/result.json" are not supported.' - ) - } - return withoutPrefix -} - -function resolveOutputFormat(fileName: string, explicit?: string): OutputFormat { - if (explicit && explicit in FORMAT_TO_CONTENT_TYPE) return explicit as OutputFormat - const ext = fileName.slice(fileName.lastIndexOf('.')).toLowerCase() - return EXT_TO_FORMAT[ext] ?? 'json' -} - -function serializeOutputForFile(output: unknown, format: OutputFormat): string { - if (typeof output === 'string') return output - - if (format === 'csv') { - const rows = extractTabularData(output) - if (rows && rows.length > 0) { - return convertRowsToCsv(rows) - } - } - - return JSON.stringify(output, null, 2) -} - -async function maybeWriteOutputToFile( - toolName: string, - params: Record | undefined, - result: ToolCallResult, - context: ExecutionContext -): Promise { - if (!result.success || !result.output) return result - if (!OUTPUT_PATH_TOOLS.has(toolName)) return result - if (!context.workspaceId || !context.userId) return result - - const args = params?.args as Record | undefined - const outputPath = - (params?.outputPath as string | undefined) ?? (args?.outputPath as string | undefined) - if (!outputPath) return result - - const explicitFormat = - (params?.outputFormat as string | undefined) ?? (args?.outputFormat as string | undefined) - - try { - const fileName = normalizeOutputWorkspaceFileName(outputPath) - const format = resolveOutputFormat(fileName, explicitFormat) - if (context.abortSignal?.aborted) { - throw new Error('Request aborted before tool mutation could be applied') - } - const content = serializeOutputForFile(result.output, format) - const contentType = FORMAT_TO_CONTENT_TYPE[format] - - const buffer = Buffer.from(content, 'utf-8') - if (context.abortSignal?.aborted) { - throw new Error('Request aborted before tool mutation could be applied') - } - const uploaded = await uploadWorkspaceFile( - context.workspaceId, - context.userId, - buffer, - fileName, - contentType - ) - - logger.withMetadata({ messageId: context.messageId }).info('Tool output written to file', { - toolName, - fileName, - size: buffer.length, - fileId: uploaded.id, - }) - - return { - success: true, - output: { - message: `Output written to files/${fileName} (${buffer.length} bytes)`, - fileId: uploaded.id, - fileName, - size: buffer.length, - downloadUrl: uploaded.url, - }, - } - } catch (err) { - const message = err instanceof Error ? err.message : String(err) - logger - .withMetadata({ messageId: context.messageId }) - .warn('Failed to write tool output to file', { - toolName, - outputPath, - error: message, - }) - return { - success: false, - error: `Failed to write output file: ${message}`, - } - } -} - -const MAX_OUTPUT_TABLE_ROWS = 10_000 -const BATCH_CHUNK_SIZE = 500 - -export interface AsyncToolCompletion { - status: string - message?: string - data?: Record -} - -function abortRequested( - context: StreamingContext, - execContext: ExecutionContext, - options?: OrchestratorOptions -): boolean { - if (options?.userStopSignal?.aborted || execContext.userStopSignal?.aborted) { - return true - } - if (context.wasAborted) { - return true - } - return false -} - -function cancelledCompletion(message: string): AsyncToolCompletion { - return { - status: 'cancelled', - message, - data: { cancelled: true }, - } -} - -function terminalCompletionFromToolCall(toolCall: { - status: string - error?: string - result?: { output?: unknown; error?: string } -}): AsyncToolCompletion { - if (toolCall.status === 'cancelled') { - return cancelledCompletion(toolCall.error || 'Tool execution cancelled') - } - - if (toolCall.status === 'success') { - return { - status: 'success', - message: 'Tool completed', - data: - toolCall.result?.output && - typeof toolCall.result.output === 'object' && - !Array.isArray(toolCall.result.output) - ? (toolCall.result.output as Record) - : undefined, - } - } - - if (toolCall.status === 'skipped') { - return { - status: 'success', - message: 'Tool skipped', - data: - toolCall.result?.output && - typeof toolCall.result.output === 'object' && - !Array.isArray(toolCall.result.output) - ? (toolCall.result.output as Record) - : undefined, - } - } - - return { - status: toolCall.status === 'rejected' ? 'rejected' : 'error', - message: toolCall.error || toolCall.result?.error || 'Tool failed', - data: { error: toolCall.error || toolCall.result?.error || 'Tool failed' }, - } -} - -function reportCancelledTool( - toolCall: { id: string; name: string }, - message: string, - messageId?: string, - data: Record = { cancelled: true } -): void { - markToolComplete(toolCall.id, toolCall.name, 499, message, data, messageId).catch((err) => { - logger.withMetadata({ messageId }).error('markToolComplete failed (cancelled)', { - toolCallId: toolCall.id, - toolName: toolCall.name, - error: err instanceof Error ? err.message : String(err), - }) - }) -} - -async function maybeWriteOutputToTable( - toolName: string, - params: Record | undefined, - result: ToolCallResult, - context: ExecutionContext -): Promise { - if (toolName !== 'function_execute') return result - if (!result.success || !result.output) return result - if (!context.workspaceId || !context.userId) return result - - const outputTable = params?.outputTable as string | undefined - if (!outputTable) return result - - try { - const table = await getTableById(outputTable) - if (!table) { - return { - success: false, - error: `Table "${outputTable}" not found`, - } - } - - const rawOutput = result.output - let rows: Array> - - if (rawOutput && typeof rawOutput === 'object' && 'result' in rawOutput) { - const inner = (rawOutput as Record).result - if (Array.isArray(inner)) { - rows = inner - } else { - return { - success: false, - error: 'outputTable requires the code to return an array of objects', - } - } - } else if (Array.isArray(rawOutput)) { - rows = rawOutput - } else { - return { - success: false, - error: 'outputTable requires the code to return an array of objects', - } - } - - if (rows.length > MAX_OUTPUT_TABLE_ROWS) { - return { - success: false, - error: `outputTable row limit exceeded: got ${rows.length}, max is ${MAX_OUTPUT_TABLE_ROWS}`, - } - } - - if (rows.length === 0) { - return { - success: false, - error: 'outputTable requires at least one row — code returned an empty array', - } - } - - if (context.abortSignal?.aborted) { - throw new Error('Request aborted before tool mutation could be applied') - } - await db.transaction(async (tx) => { - if (context.abortSignal?.aborted) { - throw new Error('Request aborted before tool mutation could be applied') - } - await tx.delete(userTableRows).where(eq(userTableRows.tableId, outputTable)) - - const now = new Date() - for (let i = 0; i < rows.length; i += BATCH_CHUNK_SIZE) { - if (context.abortSignal?.aborted) { - throw new Error('Request aborted before tool mutation could be applied') - } - const chunk = rows.slice(i, i + BATCH_CHUNK_SIZE) - const values = chunk.map((rowData, j) => ({ - id: `row_${crypto.randomUUID().replace(/-/g, '')}`, - tableId: outputTable, - workspaceId: context.workspaceId!, - data: rowData, - position: i + j, - createdAt: now, - updatedAt: now, - createdBy: context.userId, - })) - await tx.insert(userTableRows).values(values) - } - }) - - logger.withMetadata({ messageId: context.messageId }).info('Tool output written to table', { - toolName, - tableId: outputTable, - rowCount: rows.length, - }) - - return { - success: true, - output: { - message: `Wrote ${rows.length} rows to table ${outputTable}`, - tableId: outputTable, - rowCount: rows.length, - }, - } - } catch (err) { - logger - .withMetadata({ messageId: context.messageId }) - .warn('Failed to write tool output to table', { - toolName, - outputTable, - error: err instanceof Error ? err.message : String(err), - }) - return { - success: false, - error: `Failed to write to table: ${err instanceof Error ? err.message : String(err)}`, - } - } -} - -async function maybeWriteReadCsvToTable( - toolName: string, - params: Record | undefined, - result: ToolCallResult, - context: ExecutionContext -): Promise { - if (toolName !== 'read') return result - if (!result.success || !result.output) return result - if (!context.workspaceId || !context.userId) return result - - const outputTable = params?.outputTable as string | undefined - if (!outputTable) return result - - try { - const table = await getTableById(outputTable) - if (!table) { - return { success: false, error: `Table "${outputTable}" not found` } - } - - const output = result.output as Record - const content = (output.content as string) || '' - if (!content.trim()) { - return { success: false, error: 'File has no content to import into table' } - } - - const filePath = (params?.path as string) || '' - const ext = filePath.split('.').pop()?.toLowerCase() - - let rows: Record[] - - if (ext === 'json') { - const parsed = JSON.parse(content) - if (!Array.isArray(parsed)) { - return { - success: false, - error: 'JSON file must contain an array of objects for table import', - } - } - rows = parsed - } else { - const { parse } = await import('csv-parse/sync') - rows = parse(content, { - columns: true, - skip_empty_lines: true, - trim: true, - relax_column_count: true, - relax_quotes: true, - skip_records_with_error: true, - cast: false, - }) as Record[] - } - - if (rows.length === 0) { - return { success: false, error: 'File has no data rows to import' } - } - - if (rows.length > MAX_OUTPUT_TABLE_ROWS) { - return { - success: false, - error: `Row limit exceeded: got ${rows.length}, max is ${MAX_OUTPUT_TABLE_ROWS}`, - } - } - - if (context.abortSignal?.aborted) { - throw new Error('Request aborted before tool mutation could be applied') - } - await db.transaction(async (tx) => { - if (context.abortSignal?.aborted) { - throw new Error('Request aborted before tool mutation could be applied') - } - await tx.delete(userTableRows).where(eq(userTableRows.tableId, outputTable)) - - const now = new Date() - for (let i = 0; i < rows.length; i += BATCH_CHUNK_SIZE) { - if (context.abortSignal?.aborted) { - throw new Error('Request aborted before tool mutation could be applied') - } - const chunk = rows.slice(i, i + BATCH_CHUNK_SIZE) - const values = chunk.map((rowData, j) => ({ - id: `row_${crypto.randomUUID().replace(/-/g, '')}`, - tableId: outputTable, - workspaceId: context.workspaceId!, - data: rowData, - position: i + j, - createdAt: now, - updatedAt: now, - createdBy: context.userId, - })) - await tx.insert(userTableRows).values(values) - } - }) - - logger.withMetadata({ messageId: context.messageId }).info('Read output written to table', { - toolName, - tableId: outputTable, - tableName: table.name, - rowCount: rows.length, - filePath, - }) - - return { - success: true, - output: { - message: `Imported ${rows.length} rows from "${filePath}" into table "${table.name}"`, - tableId: outputTable, - tableName: table.name, - rowCount: rows.length, - }, - } - } catch (err) { - logger - .withMetadata({ messageId: context.messageId }) - .warn('Failed to write read output to table', { - toolName, - outputTable, - error: err instanceof Error ? err.message : String(err), - }) - return { - success: false, - error: `Failed to import into table: ${err instanceof Error ? err.message : String(err)}`, - } - } -} - -export async function executeToolAndReport( - toolCallId: string, - context: StreamingContext, - execContext: ExecutionContext, - options?: OrchestratorOptions -): Promise { - const toolCall = context.toolCalls.get(toolCallId) - if (!toolCall) return { status: 'error', message: 'Tool call not found' } - - if (toolCall.status === 'executing') { - return { status: 'running', message: 'Tool already executing' } - } - if (toolCall.endTime || isTerminalToolCallStatus(toolCall.status)) { - return terminalCompletionFromToolCall(toolCall) - } - - if (abortRequested(context, execContext, options)) { - toolCall.status = 'cancelled' - toolCall.endTime = Date.now() - markToolResultSeen(toolCall.id) - await completeAsyncToolCall({ - toolCallId: toolCall.id, - status: 'cancelled', - result: { cancelled: true }, - error: 'Request aborted before tool execution', - }).catch(() => {}) - reportCancelledTool(toolCall, 'Request aborted before tool execution', context.messageId) - return cancelledCompletion('Request aborted before tool execution') - } - - toolCall.status = 'executing' - await markAsyncToolRunning(toolCall.id, 'sim-stream').catch(() => {}) - - logger.withMetadata({ messageId: context.messageId }).info('Tool execution started', { - toolCallId: toolCall.id, - toolName: toolCall.name, - params: toolCall.params, - }) - - try { - let result = await executeToolServerSide(toolCall, execContext) - if (toolCall.endTime || isTerminalToolCallStatus(toolCall.status)) { - return terminalCompletionFromToolCall(toolCall) - } - if (abortRequested(context, execContext, options)) { - toolCall.status = 'cancelled' - toolCall.endTime = Date.now() - markToolResultSeen(toolCall.id) - await completeAsyncToolCall({ - toolCallId: toolCall.id, - status: 'cancelled', - result: { cancelled: true }, - error: 'Request aborted during tool execution', - }).catch(() => {}) - reportCancelledTool(toolCall, 'Request aborted during tool execution', context.messageId) - return cancelledCompletion('Request aborted during tool execution') - } - result = await maybeWriteOutputToFile(toolCall.name, toolCall.params, result, execContext) - if (abortRequested(context, execContext, options)) { - toolCall.status = 'cancelled' - toolCall.endTime = Date.now() - markToolResultSeen(toolCall.id) - await completeAsyncToolCall({ - toolCallId: toolCall.id, - status: 'cancelled', - result: { cancelled: true }, - error: 'Request aborted during tool post-processing', - }).catch(() => {}) - reportCancelledTool( - toolCall, - 'Request aborted during tool post-processing', - context.messageId - ) - return cancelledCompletion('Request aborted during tool post-processing') - } - result = await maybeWriteOutputToTable(toolCall.name, toolCall.params, result, execContext) - if (abortRequested(context, execContext, options)) { - toolCall.status = 'cancelled' - toolCall.endTime = Date.now() - markToolResultSeen(toolCall.id) - await completeAsyncToolCall({ - toolCallId: toolCall.id, - status: 'cancelled', - result: { cancelled: true }, - error: 'Request aborted during tool post-processing', - }).catch(() => {}) - reportCancelledTool( - toolCall, - 'Request aborted during tool post-processing', - context.messageId - ) - return cancelledCompletion('Request aborted during tool post-processing') - } - result = await maybeWriteReadCsvToTable(toolCall.name, toolCall.params, result, execContext) - if (abortRequested(context, execContext, options)) { - toolCall.status = 'cancelled' - toolCall.endTime = Date.now() - markToolResultSeen(toolCall.id) - await completeAsyncToolCall({ - toolCallId: toolCall.id, - status: 'cancelled', - result: { cancelled: true }, - error: 'Request aborted during tool post-processing', - }).catch(() => {}) - reportCancelledTool( - toolCall, - 'Request aborted during tool post-processing', - context.messageId - ) - return cancelledCompletion('Request aborted during tool post-processing') - } - toolCall.status = result.success ? 'success' : 'error' - toolCall.result = result - toolCall.error = result.error - toolCall.endTime = Date.now() - - if (result.success) { - const raw = result.output - const preview = - typeof raw === 'string' - ? raw.slice(0, 200) - : raw && typeof raw === 'object' - ? JSON.stringify(raw).slice(0, 200) - : undefined - logger.withMetadata({ messageId: context.messageId }).info('Tool execution succeeded', { - toolCallId: toolCall.id, - toolName: toolCall.name, - outputPreview: preview, - }) - } else { - logger.withMetadata({ messageId: context.messageId }).warn('Tool execution failed', { - toolCallId: toolCall.id, - toolName: toolCall.name, - error: result.error, - params: toolCall.params, - }) - } - - // If create_workflow was successful, update the execution context with the new workflowId. - // This ensures subsequent tools in the same stream have access to the workflowId. - const output = asRecord(result.output) - if ( - toolCall.name === 'create_workflow' && - result.success && - output.workflowId && - !execContext.workflowId - ) { - execContext.workflowId = output.workflowId as string - if (output.workspaceId) { - execContext.workspaceId = output.workspaceId as string - } - } - - markToolResultSeen(toolCall.id) - await completeAsyncToolCall({ - toolCallId: toolCall.id, - status: result.success ? 'completed' : 'failed', - result: result.success ? asRecord(result.output) : { error: result.error || 'Tool failed' }, - error: result.success ? null : result.error || 'Tool failed', - }).catch(() => {}) - - if (abortRequested(context, execContext, options)) { - toolCall.status = 'cancelled' - reportCancelledTool( - toolCall, - 'Request aborted before tool result delivery', - context.messageId - ) - return cancelledCompletion('Request aborted before tool result delivery') - } - - // Fire-and-forget: notify the copilot backend that the tool completed. - // IMPORTANT: We must NOT await this — the server may block on the - // mark-complete handler until it can write back on the SSE stream, but - // the SSE reader (our for-await loop) is paused while we're in this - // handler. Awaiting here would deadlock: sim waits for the server's response, - // the server waits for sim to drain the SSE stream. - markToolComplete( - toolCall.id, - toolCall.name, - result.success ? 200 : 500, - result.error || (result.success ? 'Tool completed' : 'Tool failed'), - result.output, - context.messageId - ).catch((err) => { - logger - .withMetadata({ messageId: context.messageId }) - .error('markToolComplete fire-and-forget failed', { - toolCallId: toolCall.id, - toolName: toolCall.name, - error: err instanceof Error ? err.message : String(err), - }) - }) - - const resultEvent: SSEEvent = { - type: 'tool_result', - toolCallId: toolCall.id, - toolName: toolCall.name, - success: result.success, - result: result.output, - data: { - id: toolCall.id, - name: toolCall.name, - success: result.success, - result: result.output, - }, - } - await options?.onEvent?.(resultEvent) - - if (abortRequested(context, execContext, options)) { - toolCall.status = 'cancelled' - return cancelledCompletion('Request aborted before resource persistence') - } - - if (result.success && execContext.chatId && !abortRequested(context, execContext, options)) { - let isDeleteOp = false - - if (hasDeleteCapability(toolCall.name)) { - const deleted = extractDeletedResourcesFromToolResult( - toolCall.name, - toolCall.params, - result.output - ) - if (deleted.length > 0) { - isDeleteOp = true - removeChatResources(execContext.chatId, deleted).catch((err) => { - logger - .withMetadata({ messageId: context.messageId }) - .warn('Failed to remove chat resources after deletion', { - chatId: execContext.chatId, - error: err instanceof Error ? err.message : String(err), - }) - }) - - for (const resource of deleted) { - if (abortRequested(context, execContext, options)) break - await options?.onEvent?.({ - type: 'resource_deleted', - resource: { type: resource.type, id: resource.id, title: resource.title }, - }) - } - } - } - - if (!isDeleteOp && !abortRequested(context, execContext, options)) { - const resources = - result.resources && result.resources.length > 0 - ? result.resources - : isResourceToolName(toolCall.name) - ? extractResourcesFromToolResult(toolCall.name, toolCall.params, result.output) - : [] - - if (resources.length > 0) { - persistChatResources(execContext.chatId, resources).catch((err) => { - logger - .withMetadata({ messageId: context.messageId }) - .warn('Failed to persist chat resources', { - chatId: execContext.chatId, - error: err instanceof Error ? err.message : String(err), - }) - }) - - for (const resource of resources) { - if (abortRequested(context, execContext, options)) break - await options?.onEvent?.({ - type: 'resource_added', - resource: { type: resource.type, id: resource.id, title: resource.title }, - }) - } - } - } - } - return { - status: result.success ? 'success' : 'error', - message: result.error || (result.success ? 'Tool completed' : 'Tool failed'), - data: asRecord(result.output), - } - } catch (error) { - if (abortRequested(context, execContext, options)) { - toolCall.status = 'cancelled' - toolCall.endTime = Date.now() - markToolResultSeen(toolCall.id) - await completeAsyncToolCall({ - toolCallId: toolCall.id, - status: 'cancelled', - result: { cancelled: true }, - error: 'Request aborted during tool execution', - }).catch(() => {}) - reportCancelledTool(toolCall, 'Request aborted during tool execution', context.messageId) - return cancelledCompletion('Request aborted during tool execution') - } - toolCall.status = 'error' - toolCall.error = error instanceof Error ? error.message : String(error) - toolCall.endTime = Date.now() - - logger.withMetadata({ messageId: context.messageId }).error('Tool execution threw', { - toolCallId: toolCall.id, - toolName: toolCall.name, - error: toolCall.error, - params: toolCall.params, - }) - - markToolResultSeen(toolCall.id) - await completeAsyncToolCall({ - toolCallId: toolCall.id, - status: 'failed', - result: { error: toolCall.error }, - error: toolCall.error, - }).catch(() => {}) - - // Fire-and-forget (same reasoning as above). - // Pass error as structured data so the Go side can surface it to the LLM. - markToolComplete( - toolCall.id, - toolCall.name, - 500, - toolCall.error, - { - error: toolCall.error, - }, - context.messageId - ).catch((err) => { - logger - .withMetadata({ messageId: context.messageId }) - .error('markToolComplete fire-and-forget failed', { - toolCallId: toolCall.id, - toolName: toolCall.name, - error: err instanceof Error ? err.message : String(err), - }) - }) - - const errorEvent: SSEEvent = { - type: 'tool_error', - state: 'error', - toolCallId: toolCall.id, - data: { - id: toolCall.id, - name: toolCall.name, - error: toolCall.error, - }, - } - await options?.onEvent?.(errorEvent) - return { - status: 'error', - message: toolCall.error, - data: { error: toolCall.error }, - } - } -} - -/** - * Wait for a tool completion signal (success/error/rejected) from the client. - * Ignores intermediate statuses like `accepted` and only returns terminal statuses: - * - success: client finished executing successfully - * - error: client execution failed - * - rejected: user clicked Skip (subagent run tools where user hasn't auto-allowed) - * - * Used for client-executable run tools: the client executes the workflow - * and posts success/error to /api/copilot/confirm when done. The server - * waits here until that completion signal arrives. - */ -export async function waitForToolCompletion( - toolCallId: string, - timeoutMs: number, - abortSignal?: AbortSignal -): Promise<{ status: string; message?: string; data?: Record } | null> { - const decision = await waitForToolConfirmation(toolCallId, timeoutMs, abortSignal, { - acceptStatus: (status) => - status === 'success' || - status === 'error' || - status === 'rejected' || - status === 'background' || - status === 'cancelled' || - status === 'delivered', - }) - if ( - decision?.status === 'success' || - decision?.status === 'error' || - decision?.status === 'rejected' || - decision?.status === 'background' || - decision?.status === 'cancelled' || - decision?.status === 'delivered' - ) { - return decision - } - return null -} diff --git a/apps/sim/lib/copilot/orchestrator/sse/utils.test.ts b/apps/sim/lib/copilot/orchestrator/sse/utils.test.ts deleted file mode 100644 index 56eaad33789..00000000000 --- a/apps/sim/lib/copilot/orchestrator/sse/utils.test.ts +++ /dev/null @@ -1,44 +0,0 @@ -/** - * @vitest-environment node - */ -import { describe, expect, it } from 'vitest' -import { - markToolResultSeen, - normalizeSseEvent, - shouldSkipToolCallEvent, - shouldSkipToolResultEvent, -} from '@/lib/copilot/orchestrator/sse/utils' - -describe('sse-utils', () => { - it.concurrent('normalizes tool fields from string data', () => { - const event = { - type: 'tool_result', - data: JSON.stringify({ - id: 'tool_1', - name: 'edit_workflow', - success: true, - result: { ok: true }, - }), - } - - const normalized = normalizeSseEvent(event as any) - - expect(normalized.toolCallId).toBe('tool_1') - expect(normalized.toolName).toBe('edit_workflow') - expect(normalized.success).toBe(true) - expect(normalized.result).toEqual({ ok: true }) - }) - - it.concurrent('dedupes tool_call events', () => { - const event = { type: 'tool_call', data: { id: 'tool_call_1', name: 'plan' } } - expect(shouldSkipToolCallEvent(event as any)).toBe(false) - expect(shouldSkipToolCallEvent(event as any)).toBe(true) - }) - - it.concurrent('dedupes tool_result events', () => { - const event = { type: 'tool_result', data: { id: 'tool_result_1', name: 'plan' } } - expect(shouldSkipToolResultEvent(event as any)).toBe(false) - markToolResultSeen('tool_result_1') - expect(shouldSkipToolResultEvent(event as any)).toBe(true) - }) -}) diff --git a/apps/sim/lib/copilot/orchestrator/sse/utils.ts b/apps/sim/lib/copilot/orchestrator/sse/utils.ts deleted file mode 100644 index 3619012d9f9..00000000000 --- a/apps/sim/lib/copilot/orchestrator/sse/utils.ts +++ /dev/null @@ -1,129 +0,0 @@ -import { STREAM_BUFFER_MAX_DEDUP_ENTRIES } from '@/lib/copilot/constants' -import type { SSEEvent } from '@/lib/copilot/orchestrator/types' - -type EventDataObject = Record | undefined - -/** Safely cast event.data to a record for property access. */ -export const asRecord = (data: unknown): Record => - (data && typeof data === 'object' && !Array.isArray(data) ? data : {}) as Record - -/** - * In-memory tool event dedupe with bounded size. - * - * NOTE: Process-local only. In a multi-instance setup (e.g., ECS), - * each task maintains its own dedupe cache. - */ -const seenToolCalls = new Set() -const seenToolResults = new Set() - -function addToSet(set: Set, id: string): void { - if (set.size >= STREAM_BUFFER_MAX_DEDUP_ENTRIES) { - const first = set.values().next().value - if (first) set.delete(first) - } - set.add(id) -} - -const parseEventData = (data: unknown): EventDataObject => { - if (!data) return undefined - if (typeof data !== 'string') { - if (typeof data === 'object' && !Array.isArray(data)) { - return data as EventDataObject - } - return undefined - } - try { - const parsed = JSON.parse(data) - if (typeof parsed === 'object' && parsed !== null && !Array.isArray(parsed)) { - return parsed as EventDataObject - } - return undefined - } catch { - return undefined - } -} - -const hasToolFields = (data: EventDataObject): boolean => { - if (!data) return false - return ( - data.id !== undefined || - data.toolCallId !== undefined || - data.name !== undefined || - data.success !== undefined || - data.result !== undefined || - data.arguments !== undefined - ) -} - -export const getEventData = (event: SSEEvent): EventDataObject => { - const topLevel = parseEventData(event.data) - if (!topLevel) return undefined - if (hasToolFields(topLevel)) return topLevel - const nested = parseEventData(topLevel.data) - return nested || topLevel -} - -function getToolCallIdFromEvent(event: SSEEvent): string | undefined { - const data = getEventData(event) - return ( - event.toolCallId || (data?.id as string | undefined) || (data?.toolCallId as string | undefined) - ) -} - -/** Normalizes SSE events so tool metadata is available at the top level. */ -export function normalizeSseEvent(event: SSEEvent): SSEEvent { - if (!event) return event - const data = getEventData(event) - if (!data) return event - const toolCallId = - event.toolCallId || (data.id as string | undefined) || (data.toolCallId as string | undefined) - const toolName = - event.toolName || (data.name as string | undefined) || (data.toolName as string | undefined) - const success = event.success ?? (data.success as boolean | undefined) - const result = event.result ?? data.result - const normalizedData = typeof event.data === 'string' ? data : event.data - return { - ...event, - data: normalizedData, - toolCallId, - toolName, - success, - result, - } -} - -function markToolCallSeen(toolCallId: string): void { - addToSet(seenToolCalls, toolCallId) -} - -function wasToolCallSeen(toolCallId: string): boolean { - return seenToolCalls.has(toolCallId) -} - -export function markToolResultSeen(toolCallId: string): void { - addToSet(seenToolResults, toolCallId) -} - -export function wasToolResultSeen(toolCallId: string): boolean { - return seenToolResults.has(toolCallId) -} - -export function shouldSkipToolCallEvent(event: SSEEvent): boolean { - if (event.type !== 'tool_call') return false - const toolCallId = getToolCallIdFromEvent(event) - if (!toolCallId) return false - const eventData = getEventData(event) - if (eventData?.partial === true) return false - if (wasToolResultSeen(toolCallId) || wasToolCallSeen(toolCallId)) { - return true - } - markToolCallSeen(toolCallId) - return false -} - -export function shouldSkipToolResultEvent(event: SSEEvent): boolean { - if (event.type !== 'tool_result') return false - const toolCallId = getToolCallIdFromEvent(event) - if (!toolCallId) return false - return wasToolResultSeen(toolCallId) -} diff --git a/apps/sim/lib/copilot/orchestrator/stream/buffer.test.ts b/apps/sim/lib/copilot/orchestrator/stream/buffer.test.ts deleted file mode 100644 index 1a87edbb366..00000000000 --- a/apps/sim/lib/copilot/orchestrator/stream/buffer.test.ts +++ /dev/null @@ -1,119 +0,0 @@ -/** - * @vitest-environment node - */ - -import { loggerMock } from '@sim/testing' -import { beforeEach, describe, expect, it, vi } from 'vitest' - -vi.mock('@sim/logger', () => loggerMock) - -type StoredEntry = { score: number; value: string } - -const createRedisStub = () => { - const events = new Map() - const counters = new Map() - - const readEntries = (key: string, min: number, max: number) => { - const list = events.get(key) || [] - return list - .filter((entry) => entry.score >= min && entry.score <= max) - .sort((a, b) => a.score - b.score) - .map((entry) => entry.value) - } - - return { - del: vi.fn().mockResolvedValue(1), - hset: vi.fn().mockResolvedValue(1), - hgetall: vi.fn().mockResolvedValue({}), - expire: vi.fn().mockResolvedValue(1), - eval: vi - .fn() - .mockImplementation( - ( - _lua: string, - _keysCount: number, - seqKey: string, - eventsKey: string, - _ttl: number, - _limit: number, - streamId: string, - eventJson: string - ) => { - const current = counters.get(seqKey) || 0 - const next = current + 1 - counters.set(seqKey, next) - const entry = JSON.stringify({ eventId: next, streamId, event: JSON.parse(eventJson) }) - const list = events.get(eventsKey) || [] - list.push({ score: next, value: entry }) - events.set(eventsKey, list) - return next - } - ), - incrby: vi.fn().mockImplementation((key: string, amount: number) => { - const current = counters.get(key) || 0 - const next = current + amount - counters.set(key, next) - return next - }), - zrangebyscore: vi.fn().mockImplementation((key: string, min: string, max: string) => { - const minVal = Number(min) - const maxVal = max === '+inf' ? Number.POSITIVE_INFINITY : Number(max) - return Promise.resolve(readEntries(key, minVal, maxVal)) - }), - pipeline: vi.fn().mockImplementation(() => { - const api: Record = {} - api.zadd = vi.fn().mockImplementation((key: string, ...args: Array) => { - const list = events.get(key) || [] - for (let i = 0; i < args.length; i += 2) { - list.push({ score: Number(args[i]), value: String(args[i + 1]) }) - } - events.set(key, list) - return api - }) - api.expire = vi.fn().mockReturnValue(api) - api.zremrangebyrank = vi.fn().mockReturnValue(api) - api.exec = vi.fn().mockResolvedValue([]) - return api - }), - } -} - -let mockRedis: ReturnType - -vi.mock('@/lib/core/config/redis', () => ({ - getRedisClient: () => mockRedis, -})) - -import { - appendStreamEvent, - createStreamEventWriter, - readStreamEvents, -} from '@/lib/copilot/orchestrator/stream/buffer' - -describe('stream-buffer', () => { - beforeEach(() => { - mockRedis = createRedisStub() - vi.clearAllMocks() - }) - - it.concurrent('replays events after a given event id', async () => { - await appendStreamEvent('stream-1', { type: 'content', data: 'hello' }) - await appendStreamEvent('stream-1', { type: 'content', data: 'world' }) - - const allEvents = await readStreamEvents('stream-1', 0) - expect(allEvents.map((entry) => entry.event.data)).toEqual(['hello', 'world']) - - const replayed = await readStreamEvents('stream-1', 1) - expect(replayed.map((entry) => entry.event.data)).toEqual(['world']) - }) - - it.concurrent('flushes buffered events for resume', async () => { - const writer = createStreamEventWriter('stream-2') - await writer.write({ type: 'content', data: 'a' }) - await writer.write({ type: 'content', data: 'b' }) - await writer.flush() - - const events = await readStreamEvents('stream-2', 0) - expect(events.map((entry) => entry.event.data)).toEqual(['a', 'b']) - }) -}) diff --git a/apps/sim/lib/copilot/orchestrator/stream/buffer.ts b/apps/sim/lib/copilot/orchestrator/stream/buffer.ts deleted file mode 100644 index 526488c9182..00000000000 --- a/apps/sim/lib/copilot/orchestrator/stream/buffer.ts +++ /dev/null @@ -1,322 +0,0 @@ -import { createLogger } from '@sim/logger' -import { REDIS_COPILOT_STREAM_PREFIX } from '@/lib/copilot/constants' -import { env } from '@/lib/core/config/env' -import { getRedisClient } from '@/lib/core/config/redis' - -const logger = createLogger('CopilotStreamBuffer') - -const STREAM_DEFAULTS = { - ttlSeconds: 60 * 60, - eventLimit: 5000, - reserveBatch: 200, - flushIntervalMs: 15, - flushMaxBatch: 200, -} - -export type StreamBufferConfig = { - ttlSeconds: number - eventLimit: number - reserveBatch: number - flushIntervalMs: number - flushMaxBatch: number -} - -const parseNumber = (value: number | string | undefined, fallback: number): number => { - if (typeof value === 'number' && Number.isFinite(value)) return value - const parsed = Number(value) - return Number.isFinite(parsed) ? parsed : fallback -} - -export function getStreamBufferConfig(): StreamBufferConfig { - return { - ttlSeconds: parseNumber(env.COPILOT_STREAM_TTL_SECONDS, STREAM_DEFAULTS.ttlSeconds), - eventLimit: parseNumber(env.COPILOT_STREAM_EVENT_LIMIT, STREAM_DEFAULTS.eventLimit), - reserveBatch: parseNumber(env.COPILOT_STREAM_RESERVE_BATCH, STREAM_DEFAULTS.reserveBatch), - flushIntervalMs: parseNumber( - env.COPILOT_STREAM_FLUSH_INTERVAL_MS, - STREAM_DEFAULTS.flushIntervalMs - ), - flushMaxBatch: parseNumber(env.COPILOT_STREAM_FLUSH_MAX_BATCH, STREAM_DEFAULTS.flushMaxBatch), - } -} - -const APPEND_STREAM_EVENT_LUA = ` -local seqKey = KEYS[1] -local eventsKey = KEYS[2] -local ttl = tonumber(ARGV[1]) -local limit = tonumber(ARGV[2]) -local streamId = ARGV[3] -local eventJson = ARGV[4] - -local id = redis.call('INCR', seqKey) -local entry = '{"eventId":' .. id .. ',"streamId":' .. cjson.encode(streamId) .. ',"event":' .. eventJson .. '}' -redis.call('ZADD', eventsKey, id, entry) -redis.call('EXPIRE', eventsKey, ttl) -redis.call('EXPIRE', seqKey, ttl) -if limit > 0 then - redis.call('ZREMRANGEBYRANK', eventsKey, 0, -limit-1) -end -return id -` - -function getStreamKeyPrefix(streamId: string) { - return `${REDIS_COPILOT_STREAM_PREFIX}${streamId}` -} - -function getEventsKey(streamId: string) { - return `${getStreamKeyPrefix(streamId)}:events` -} - -function getSeqKey(streamId: string) { - return `${getStreamKeyPrefix(streamId)}:seq` -} - -function getMetaKey(streamId: string) { - return `${getStreamKeyPrefix(streamId)}:meta` -} - -export type StreamStatus = 'active' | 'complete' | 'cancelled' | 'error' - -export type StreamMeta = { - status: StreamStatus - userId?: string - executionId?: string - runId?: string - updatedAt?: string - error?: string -} - -export type StreamEventEntry = { - eventId: number - streamId: string - event: Record -} - -export type StreamEventWriter = { - write: (event: Record) => Promise - flush: () => Promise - close: () => Promise -} - -export async function resetStreamBuffer(streamId: string): Promise { - const redis = getRedisClient() - if (!redis) return - try { - await redis.del(getEventsKey(streamId), getSeqKey(streamId), getMetaKey(streamId)) - } catch (error) { - logger.warn('Failed to reset stream buffer', { - streamId, - error: error instanceof Error ? error.message : String(error), - }) - } -} - -export async function setStreamMeta(streamId: string, meta: StreamMeta): Promise { - const redis = getRedisClient() - if (!redis) return - try { - const config = getStreamBufferConfig() - const payload: Record = { - status: meta.status, - updatedAt: meta.updatedAt || new Date().toISOString(), - } - if (meta.userId) payload.userId = meta.userId - if (meta.executionId) payload.executionId = meta.executionId - if (meta.runId) payload.runId = meta.runId - if (meta.error) payload.error = meta.error - await redis.hset(getMetaKey(streamId), payload) - await redis.expire(getMetaKey(streamId), config.ttlSeconds) - } catch (error) { - logger.warn('Failed to update stream meta', { - streamId, - error: error instanceof Error ? error.message : String(error), - }) - } -} - -export async function getStreamMeta(streamId: string): Promise { - const redis = getRedisClient() - if (!redis) return null - try { - const meta = await redis.hgetall(getMetaKey(streamId)) - if (!meta || Object.keys(meta).length === 0) return null - return meta as StreamMeta - } catch (error) { - logger.warn('Failed to read stream meta', { - streamId, - error: error instanceof Error ? error.message : String(error), - }) - return null - } -} - -export async function appendStreamEvent( - streamId: string, - event: Record -): Promise { - const redis = getRedisClient() - if (!redis) { - return { eventId: 0, streamId, event } - } - - try { - const config = getStreamBufferConfig() - const eventJson = JSON.stringify(event) - const nextId = await redis.eval( - APPEND_STREAM_EVENT_LUA, - 2, - getSeqKey(streamId), - getEventsKey(streamId), - config.ttlSeconds, - config.eventLimit, - streamId, - eventJson - ) - const eventId = typeof nextId === 'number' ? nextId : Number(nextId) - return { eventId, streamId, event } - } catch (error) { - logger.warn('Failed to append stream event', { - streamId, - error: error instanceof Error ? error.message : String(error), - }) - return { eventId: 0, streamId, event } - } -} - -export function createStreamEventWriter(streamId: string): StreamEventWriter { - const redis = getRedisClient() - if (!redis) { - return { - write: async (event) => ({ eventId: 0, streamId, event }), - flush: async () => {}, - close: async () => {}, - } - } - - const config = getStreamBufferConfig() - let pending: StreamEventEntry[] = [] - let nextEventId = 0 - let maxReservedId = 0 - let flushTimer: ReturnType | null = null - const scheduleFlush = () => { - if (flushTimer) return - flushTimer = setTimeout(() => { - flushTimer = null - void flush() - }, config.flushIntervalMs) - } - - const reserveIds = async (minCount: number) => { - const reserveCount = Math.max(config.reserveBatch, minCount) - const newMax = await redis.incrby(getSeqKey(streamId), reserveCount) - const startId = newMax - reserveCount + 1 - if (nextEventId === 0 || nextEventId > maxReservedId) { - nextEventId = startId - maxReservedId = newMax - } - } - - let flushPromise: Promise | null = null - let closed = false - - const doFlush = async () => { - if (pending.length === 0) return - const batch = pending - pending = [] - try { - const key = getEventsKey(streamId) - const zaddArgs: (string | number)[] = [] - for (const entry of batch) { - zaddArgs.push(entry.eventId, JSON.stringify(entry)) - } - const pipeline = redis.pipeline() - pipeline.zadd(key, ...(zaddArgs as [number, string])) - pipeline.expire(key, config.ttlSeconds) - pipeline.expire(getSeqKey(streamId), config.ttlSeconds) - pipeline.zremrangebyrank(key, 0, -config.eventLimit - 1) - await pipeline.exec() - } catch (error) { - logger.warn('Failed to flush stream events', { - streamId, - error: error instanceof Error ? error.message : String(error), - }) - pending = batch.concat(pending) - if (pending.length > config.eventLimit) { - const dropped = pending.length - config.eventLimit - pending = pending.slice(-config.eventLimit) - logger.warn('Dropped oldest pending stream events due to sustained Redis failure', { - streamId, - dropped, - remaining: pending.length, - }) - } - } - } - - const flush = async () => { - if (flushPromise) { - await flushPromise - return - } - flushPromise = doFlush() - try { - await flushPromise - } finally { - flushPromise = null - if (pending.length > 0) scheduleFlush() - } - } - - const write = async (event: Record) => { - if (closed) return { eventId: 0, streamId, event } - if (nextEventId === 0 || nextEventId > maxReservedId) { - await reserveIds(1) - } - const eventId = nextEventId++ - const entry: StreamEventEntry = { eventId, streamId, event } - pending.push(entry) - if (pending.length >= config.flushMaxBatch) { - await flush() - } else { - scheduleFlush() - } - return entry - } - - const close = async () => { - closed = true - if (flushTimer) { - clearTimeout(flushTimer) - flushTimer = null - } - await flush() - } - - return { write, flush, close } -} - -export async function readStreamEvents( - streamId: string, - afterEventId: number -): Promise { - const redis = getRedisClient() - if (!redis) return [] - try { - const raw = await redis.zrangebyscore(getEventsKey(streamId), afterEventId + 1, '+inf') - return raw - .map((entry) => { - try { - return JSON.parse(entry) as StreamEventEntry - } catch { - return null - } - }) - .filter((entry): entry is StreamEventEntry => Boolean(entry)) - } catch (error) { - logger.warn('Failed to read stream events', { - streamId, - error: error instanceof Error ? error.message : String(error), - }) - return [] - } -} diff --git a/apps/sim/lib/copilot/orchestrator/stream/core.ts b/apps/sim/lib/copilot/orchestrator/stream/core.ts deleted file mode 100644 index 1dccfa2700c..00000000000 --- a/apps/sim/lib/copilot/orchestrator/stream/core.ts +++ /dev/null @@ -1,264 +0,0 @@ -import { createLogger } from '@sim/logger' -import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' -import { isPaid } from '@/lib/billing/plan-helpers' -import { ORCHESTRATION_TIMEOUT_MS } from '@/lib/copilot/constants' -import { - handleSubagentRouting, - sseHandlers, - subAgentHandlers, -} from '@/lib/copilot/orchestrator/sse/handlers' -import { parseSSEStream } from '@/lib/copilot/orchestrator/sse/parser' -import { - normalizeSseEvent, - shouldSkipToolCallEvent, - shouldSkipToolResultEvent, -} from '@/lib/copilot/orchestrator/sse/utils' -import type { - ExecutionContext, - OrchestratorOptions, - SSEEvent, - StreamingContext, - ToolCallSummary, -} from '@/lib/copilot/orchestrator/types' - -const logger = createLogger('CopilotStreamCore') - -/** - * Options for the shared stream processing loop. - */ -export interface StreamLoopOptions extends OrchestratorOptions { - /** - * Called for each normalized event BEFORE standard handler dispatch. - * Return true to skip the default handler for this event. - */ - onBeforeDispatch?: (event: SSEEvent, context: StreamingContext) => boolean | undefined -} - -/** - * Create a fresh StreamingContext. - */ -export function createStreamingContext(overrides?: Partial): StreamingContext { - return { - chatId: undefined, - executionId: undefined, - runId: undefined, - messageId: crypto.randomUUID(), - accumulatedContent: '', - contentBlocks: [], - toolCalls: new Map(), - pendingToolPromises: new Map(), - currentThinkingBlock: null, - isInThinkingBlock: false, - subAgentParentToolCallId: undefined, - subAgentParentStack: [], - subAgentContent: {}, - subAgentToolCalls: {}, - pendingContent: '', - streamComplete: false, - wasAborted: false, - errors: [], - ...overrides, - } -} - -/** - * Run the SSE stream processing loop. - * - * Handles: fetch -> parse -> normalize -> dedupe -> subagent routing -> handler dispatch. - * Callers provide the fetch URL/options and can intercept events via onBeforeDispatch. - */ -export async function runStreamLoop( - fetchUrl: string, - fetchOptions: RequestInit, - context: StreamingContext, - execContext: ExecutionContext, - options: StreamLoopOptions -): Promise { - const { timeout = ORCHESTRATION_TIMEOUT_MS, abortSignal } = options - - const response = await fetch(fetchUrl, { - ...fetchOptions, - signal: abortSignal, - }) - - if (!response.ok) { - const errorText = await response.text().catch(() => '') - - if (response.status === 402) { - let action = 'upgrade_plan' - let message = "You've reached your usage limit. Please upgrade your plan to continue." - try { - const sub = await getHighestPrioritySubscription(execContext.userId) - if (sub && isPaid(sub.plan)) { - action = 'increase_limit' - message = - "You've reached your usage limit for this billing period. Please increase your usage limit to continue." - } - } catch { - // Fall back to upgrade_plan if we can't determine the plan - } - - const upgradePayload = JSON.stringify({ - reason: 'usage_limit', - action, - message, - }) - const syntheticContent = `${upgradePayload}` - - const syntheticEvents: SSEEvent[] = [ - { type: 'content', data: syntheticContent as unknown as Record }, - { type: 'done', data: {} }, - ] - for (const event of syntheticEvents) { - try { - await options.onEvent?.(event) - } catch { - // best-effort forwarding - } - - const handler = sseHandlers[event.type] - if (handler) { - await handler(event, context, execContext, options) - } - if (context.streamComplete) break - } - return - } - - throw new Error( - `Copilot backend error (${response.status}): ${errorText || response.statusText}` - ) - } - - if (!response.body) { - throw new Error('Copilot backend response missing body') - } - - const reader = response.body.getReader() - const decoder = new TextDecoder() - - const timeoutId = setTimeout(() => { - context.errors.push('Request timed out') - context.streamComplete = true - reader.cancel().catch(() => {}) - }, timeout) - - try { - for await (const event of parseSSEStream(reader, decoder, abortSignal)) { - if (abortSignal?.aborted) { - context.wasAborted = true - await reader.cancel().catch(() => {}) - break - } - - const normalizedEvent = normalizeSseEvent(event) - - // Skip duplicate tool events — both forwarding AND handler dispatch. - const shouldSkipToolCall = shouldSkipToolCallEvent(normalizedEvent) - const shouldSkipToolResult = shouldSkipToolResultEvent(normalizedEvent) - - if (shouldSkipToolCall || shouldSkipToolResult) { - continue - } - - try { - await options.onEvent?.(normalizedEvent) - } catch (error) { - logger.withMetadata({ messageId: context.messageId }).warn('Failed to forward SSE event', { - type: normalizedEvent.type, - error: error instanceof Error ? error.message : String(error), - }) - } - - // Let the caller intercept before standard dispatch. - if (options.onBeforeDispatch?.(normalizedEvent, context)) { - if (context.streamComplete) break - continue - } - - // Standard subagent start/end handling (stack-based for nested agents). - if (normalizedEvent.type === 'subagent_start') { - const eventData = normalizedEvent.data as Record | undefined - const toolCallId = eventData?.tool_call_id as string | undefined - const subagentName = normalizedEvent.subagent || (eventData?.agent as string | undefined) - if (toolCallId) { - context.subAgentParentStack.push(toolCallId) - context.subAgentParentToolCallId = toolCallId - context.subAgentContent[toolCallId] = '' - context.subAgentToolCalls[toolCallId] = [] - } - if (subagentName) { - context.contentBlocks.push({ - type: 'subagent', - content: subagentName, - timestamp: Date.now(), - }) - } - continue - } - - if (normalizedEvent.type === 'subagent_end') { - if (context.subAgentParentStack.length > 0) { - context.subAgentParentStack.pop() - } else { - logger - .withMetadata({ messageId: context.messageId }) - .warn('subagent_end without matching subagent_start') - } - context.subAgentParentToolCallId = - context.subAgentParentStack.length > 0 - ? context.subAgentParentStack[context.subAgentParentStack.length - 1] - : undefined - continue - } - - // Subagent event routing. - if (handleSubagentRouting(normalizedEvent, context)) { - const handler = subAgentHandlers[normalizedEvent.type] - if (handler) { - await handler(normalizedEvent, context, execContext, options) - } - if (context.streamComplete) break - continue - } - - // Main event handler dispatch. - const handler = sseHandlers[normalizedEvent.type] - if (handler) { - await handler(normalizedEvent, context, execContext, options) - } - if (context.streamComplete) break - } - } finally { - if (abortSignal?.aborted) { - context.wasAborted = true - await reader.cancel().catch(() => {}) - } - clearTimeout(timeoutId) - } -} - -/** - * Build a ToolCallSummary array from the streaming context. - */ -export function buildToolCallSummaries(context: StreamingContext): ToolCallSummary[] { - return Array.from(context.toolCalls.values()).map((toolCall) => { - let status = toolCall.status - if (toolCall.result && toolCall.result.success !== undefined) { - status = toolCall.result.success ? 'success' : 'error' - } else if ((status === 'pending' || status === 'executing') && toolCall.error) { - status = 'error' - } - - return { - id: toolCall.id, - name: toolCall.name, - status, - params: toolCall.params, - result: toolCall.result?.output, - error: toolCall.error, - durationMs: - toolCall.endTime && toolCall.startTime ? toolCall.endTime - toolCall.startTime : undefined, - } - }) -} diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/index.ts b/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/index.ts deleted file mode 100644 index 9e490922b12..00000000000 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './deploy' -export * from './manage' diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts b/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts deleted file mode 100644 index cf6b623717e..00000000000 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/index.ts +++ /dev/null @@ -1,1513 +0,0 @@ -import { db } from '@sim/db' -import { credential, mcpServers, pendingCredentialDraft, user } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { and, eq, isNull, lt } from 'drizzle-orm' -import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' -import { SIM_AGENT_API_URL } from '@/lib/copilot/constants' -import type { - ExecutionContext, - ToolCallResult, - ToolCallState, -} from '@/lib/copilot/orchestrator/types' -import { routeExecution } from '@/lib/copilot/tools/server/router' -import { env } from '@/lib/core/config/env' -import { getBaseUrl } from '@/lib/core/utils/urls' -import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' -import { getKnowledgeBaseById } from '@/lib/knowledge/service' -import { validateMcpDomain } from '@/lib/mcp/domain-check' -import { mcpService } from '@/lib/mcp/service' -import { generateMcpServerId } from '@/lib/mcp/utils' -import { getAllOAuthServices } from '@/lib/oauth/utils' -import { getTableById } from '@/lib/table/service' -import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { - deleteCustomTool, - getCustomToolById, - listCustomTools, - upsertCustomTools, -} from '@/lib/workflows/custom-tools/operations' -import { deleteSkill, listSkills, upsertSkills } from '@/lib/workflows/skills/operations' -import { getWorkflowById } from '@/lib/workflows/utils' -import { isMcpTool, isUuid } from '@/executor/constants' -import { executeTool } from '@/tools' -import { getTool, resolveToolId } from '@/tools/utils' -import { - executeCheckDeploymentStatus, - executeCreateWorkspaceMcpServer, - executeDeleteWorkspaceMcpServer, - executeDeployApi, - executeDeployChat, - executeDeployMcp, - executeGetDeploymentVersion, - executeListWorkspaceMcpServers, - executeRedeploy, - executeRevertToVersion, - executeUpdateWorkspaceMcpServer, -} from './deployment-tools' -import { executeIntegrationToolDirect } from './integration-tools' -import { - executeCompleteJob, - executeCreateJob, - executeManageJob, - executeUpdateJobHistory, -} from './job-tools' -import { executeMaterializeFile } from './materialize-file' -import type { - CheckDeploymentStatusParams, - CreateFolderParams, - CreateWorkflowParams, - CreateWorkspaceMcpServerParams, - DeleteFolderParams, - DeleteWorkflowParams, - DeleteWorkspaceMcpServerParams, - DeployApiParams, - DeployChatParams, - DeployMcpParams, - GenerateApiKeyParams, - GetBlockOutputsParams, - GetBlockUpstreamReferencesParams, - GetDeployedWorkflowStateParams, - GetWorkflowDataParams, - ListFoldersParams, - ListWorkspaceMcpServersParams, - MoveFolderParams, - MoveWorkflowParams, - OpenResourceParams, - OpenResourceType, - RenameFolderParams, - RenameWorkflowParams, - RunBlockParams, - RunFromBlockParams, - RunWorkflowParams, - RunWorkflowUntilBlockParams, - SetGlobalWorkflowVariablesParams, - UpdateWorkflowParams, - UpdateWorkspaceMcpServerParams, - ValidOpenResourceParams, -} from './param-types' -import { PLATFORM_ACTIONS_CONTENT } from './platform-actions' -import { executeVfsGlob, executeVfsGrep, executeVfsList, executeVfsRead } from './vfs-tools' -import { - executeCreateFolder, - executeCreateWorkflow, - executeDeleteFolder, - executeDeleteWorkflow, - executeGenerateApiKey, - executeGetBlockOutputs, - executeGetBlockUpstreamReferences, - executeGetDeployedWorkflowState, - executeGetWorkflowData, - executeListFolders, - executeListUserWorkspaces, - executeMoveFolder, - executeMoveWorkflow, - executeRenameFolder, - executeRenameWorkflow, - executeRunBlock, - executeRunFromBlock, - executeRunWorkflow, - executeRunWorkflowUntilBlock, - executeSetGlobalWorkflowVariables, - executeUpdateWorkflow, -} from './workflow-tools' - -const logger = createLogger('CopilotToolExecutor') -const VALID_OPEN_RESOURCE_TYPES = new Set([ - 'workflow', - 'table', - 'knowledgebase', - 'file', -]) - -function validateOpenResourceParams( - params: OpenResourceParams -): { success: true; params: ValidOpenResourceParams } | { success: false; error: string } { - if (!params.type) { - return { success: false, error: 'type is required' } - } - - if (!VALID_OPEN_RESOURCE_TYPES.has(params.type)) { - return { success: false, error: `Invalid resource type: ${params.type}` } - } - - if (!params.id) { - return { success: false, error: `${params.type} resources require \`id\`` } - } - - return { - success: true, - params: { - type: params.type, - id: params.id, - }, - } -} - -type ManageCustomToolOperation = 'add' | 'edit' | 'delete' | 'list' - -interface ManageCustomToolSchema { - type: 'function' - function: { - name: string - description?: string - parameters: Record - } -} - -interface ManageCustomToolParams { - operation?: string - toolId?: string - schema?: ManageCustomToolSchema - code?: string - title?: string - workspaceId?: string -} - -async function executeManageCustomTool( - rawParams: Record, - context: ExecutionContext -): Promise { - const params = rawParams as ManageCustomToolParams - const operation = String(params.operation || '').toLowerCase() as ManageCustomToolOperation - const workspaceId = params.workspaceId || context.workspaceId - - if (!operation) { - return { success: false, error: "Missing required 'operation' argument" } - } - - const writeOps: string[] = ['add', 'edit', 'delete'] - if ( - writeOps.includes(operation) && - context.userPermission && - context.userPermission !== 'write' && - context.userPermission !== 'admin' - ) { - return { - success: false, - error: `Permission denied: '${operation}' on manage_custom_tool requires write access. You have '${context.userPermission}' permission.`, - } - } - - try { - if (operation === 'list') { - const toolsForUser = await listCustomTools({ - userId: context.userId, - workspaceId, - }) - - return { - success: true, - output: { - success: true, - operation, - tools: toolsForUser, - count: toolsForUser.length, - }, - } - } - - if (operation === 'add') { - if (!workspaceId) { - return { - success: false, - error: "workspaceId is required for operation 'add'", - } - } - if (!params.schema || !params.code) { - return { - success: false, - error: "Both 'schema' and 'code' are required for operation 'add'", - } - } - - const title = params.title || params.schema.function?.name - if (!title) { - return { success: false, error: "Missing tool title or schema.function.name for 'add'" } - } - - const resultTools = await upsertCustomTools({ - tools: [{ title, schema: params.schema, code: params.code }], - workspaceId, - userId: context.userId, - }) - const created = resultTools.find((tool) => tool.title === title) - - recordAudit({ - workspaceId, - actorId: context.userId, - action: AuditAction.CUSTOM_TOOL_CREATED, - resourceType: AuditResourceType.CUSTOM_TOOL, - resourceId: created?.id, - resourceName: title, - description: `Created custom tool "${title}"`, - }) - - return { - success: true, - output: { - success: true, - operation, - toolId: created?.id, - title, - message: `Created custom tool "${title}"`, - }, - } - } - - if (operation === 'edit') { - if (!workspaceId) { - return { - success: false, - error: "workspaceId is required for operation 'edit'", - } - } - if (!params.toolId) { - return { success: false, error: "'toolId' is required for operation 'edit'" } - } - if (!params.schema && !params.code) { - return { - success: false, - error: "At least one of 'schema' or 'code' is required for operation 'edit'", - } - } - - const existing = await getCustomToolById({ - toolId: params.toolId, - userId: context.userId, - workspaceId, - }) - if (!existing) { - return { success: false, error: `Custom tool not found: ${params.toolId}` } - } - - const mergedSchema = params.schema || (existing.schema as ManageCustomToolSchema) - const mergedCode = params.code || existing.code - const title = params.title || mergedSchema.function?.name || existing.title - - await upsertCustomTools({ - tools: [{ id: params.toolId, title, schema: mergedSchema, code: mergedCode }], - workspaceId, - userId: context.userId, - }) - - recordAudit({ - workspaceId, - actorId: context.userId, - action: AuditAction.CUSTOM_TOOL_UPDATED, - resourceType: AuditResourceType.CUSTOM_TOOL, - resourceId: params.toolId, - resourceName: title, - description: `Updated custom tool "${title}"`, - }) - - return { - success: true, - output: { - success: true, - operation, - toolId: params.toolId, - title, - message: `Updated custom tool "${title}"`, - }, - } - } - - if (operation === 'delete') { - if (!params.toolId) { - return { success: false, error: "'toolId' is required for operation 'delete'" } - } - - const deleted = await deleteCustomTool({ - toolId: params.toolId, - userId: context.userId, - workspaceId, - }) - if (!deleted) { - return { success: false, error: `Custom tool not found: ${params.toolId}` } - } - - recordAudit({ - workspaceId, - actorId: context.userId, - action: AuditAction.CUSTOM_TOOL_DELETED, - resourceType: AuditResourceType.CUSTOM_TOOL, - resourceId: params.toolId, - description: 'Deleted custom tool', - }) - - return { - success: true, - output: { - success: true, - operation, - toolId: params.toolId, - message: 'Deleted custom tool', - }, - } - } - - return { - success: false, - error: `Unsupported operation for manage_custom_tool: ${operation}`, - } - } catch (error) { - logger - .withMetadata({ messageId: context.messageId }) - .error('manage_custom_tool execution failed', { - operation, - workspaceId, - userId: context.userId, - error: error instanceof Error ? error.message : String(error), - }) - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to manage custom tool', - } - } -} - -type ManageMcpToolOperation = 'add' | 'edit' | 'delete' | 'list' - -interface ManageMcpToolConfig { - name?: string - transport?: string - url?: string - headers?: Record - timeout?: number - enabled?: boolean -} - -interface ManageMcpToolParams { - operation?: string - serverId?: string - config?: ManageMcpToolConfig -} - -async function executeManageMcpTool( - rawParams: Record, - context: ExecutionContext -): Promise { - const params = rawParams as ManageMcpToolParams - const operation = String(params.operation || '').toLowerCase() as ManageMcpToolOperation - const workspaceId = context.workspaceId - - if (!operation) { - return { success: false, error: "Missing required 'operation' argument" } - } - - if (!workspaceId) { - return { success: false, error: 'workspaceId is required' } - } - - const writeOps: string[] = ['add', 'edit', 'delete'] - if ( - writeOps.includes(operation) && - context.userPermission && - context.userPermission !== 'write' && - context.userPermission !== 'admin' - ) { - return { - success: false, - error: `Permission denied: '${operation}' on manage_mcp_tool requires write access. You have '${context.userPermission}' permission.`, - } - } - - try { - if (operation === 'list') { - const servers = await db - .select() - .from(mcpServers) - .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))) - - return { - success: true, - output: { - success: true, - operation, - servers: servers.map((s) => ({ - id: s.id, - name: s.name, - url: s.url, - transport: s.transport, - enabled: s.enabled, - connectionStatus: s.connectionStatus, - })), - count: servers.length, - }, - } - } - - if (operation === 'add') { - const config = params.config - if (!config?.name || !config?.url) { - return { success: false, error: "config.name and config.url are required for 'add'" } - } - - validateMcpDomain(config.url) - - const serverId = generateMcpServerId(workspaceId, config.url) - - const [existing] = await db - .select({ id: mcpServers.id, deletedAt: mcpServers.deletedAt }) - .from(mcpServers) - .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId))) - .limit(1) - - if (existing) { - await db - .update(mcpServers) - .set({ - name: config.name, - transport: config.transport || 'streamable-http', - url: config.url, - headers: config.headers || {}, - timeout: config.timeout || 30000, - enabled: config.enabled !== false, - connectionStatus: 'connected', - lastConnected: new Date(), - updatedAt: new Date(), - deletedAt: null, - }) - .where(eq(mcpServers.id, serverId)) - } else { - await db.insert(mcpServers).values({ - id: serverId, - workspaceId, - createdBy: context.userId, - name: config.name, - description: '', - transport: config.transport || 'streamable-http', - url: config.url, - headers: config.headers || {}, - timeout: config.timeout || 30000, - retries: 3, - enabled: config.enabled !== false, - connectionStatus: 'connected', - lastConnected: new Date(), - createdAt: new Date(), - updatedAt: new Date(), - }) - } - - await mcpService.clearCache(workspaceId) - - recordAudit({ - workspaceId, - actorId: context.userId, - action: AuditAction.MCP_SERVER_ADDED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: serverId, - resourceName: config.name, - description: existing - ? `Updated existing MCP server "${config.name}"` - : `Added MCP server "${config.name}"`, - }) - - return { - success: true, - output: { - success: true, - operation, - serverId, - name: config.name, - message: existing - ? `Updated existing MCP server "${config.name}"` - : `Added MCP server "${config.name}"`, - }, - } - } - - if (operation === 'edit') { - if (!params.serverId) { - return { success: false, error: "'serverId' is required for 'edit'" } - } - const config = params.config - if (!config) { - return { success: false, error: "'config' is required for 'edit'" } - } - - if (config.url) { - validateMcpDomain(config.url) - } - - const updateData: Record = { updatedAt: new Date() } - if (config.name !== undefined) updateData.name = config.name - if (config.transport !== undefined) updateData.transport = config.transport - if (config.url !== undefined) updateData.url = config.url - if (config.headers !== undefined) updateData.headers = config.headers - if (config.timeout !== undefined) updateData.timeout = config.timeout - if (config.enabled !== undefined) updateData.enabled = config.enabled - - const [updated] = await db - .update(mcpServers) - .set(updateData) - .where( - and( - eq(mcpServers.id, params.serverId), - eq(mcpServers.workspaceId, workspaceId), - isNull(mcpServers.deletedAt) - ) - ) - .returning() - - if (!updated) { - return { success: false, error: `MCP server not found: ${params.serverId}` } - } - - await mcpService.clearCache(workspaceId) - - recordAudit({ - workspaceId, - actorId: context.userId, - action: AuditAction.MCP_SERVER_UPDATED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: params.serverId, - description: `Updated MCP server "${updated.name}"`, - }) - - return { - success: true, - output: { - success: true, - operation, - serverId: params.serverId, - name: updated.name, - message: `Updated MCP server "${updated.name}"`, - }, - } - } - - if (operation === 'delete') { - if (context.userPermission && context.userPermission !== 'admin') { - return { - success: false, - error: `Permission denied: 'delete' on manage_mcp_tool requires admin access. You have '${context.userPermission}' permission.`, - } - } - - if (!params.serverId) { - return { success: false, error: "'serverId' is required for 'delete'" } - } - - const [deleted] = await db - .delete(mcpServers) - .where(and(eq(mcpServers.id, params.serverId), eq(mcpServers.workspaceId, workspaceId))) - .returning() - - if (!deleted) { - return { success: false, error: `MCP server not found: ${params.serverId}` } - } - - await mcpService.clearCache(workspaceId) - - recordAudit({ - workspaceId, - actorId: context.userId, - action: AuditAction.MCP_SERVER_REMOVED, - resourceType: AuditResourceType.MCP_SERVER, - resourceId: params.serverId, - description: `Deleted MCP server "${deleted.name}"`, - }) - - return { - success: true, - output: { - success: true, - operation, - serverId: params.serverId, - message: `Deleted MCP server "${deleted.name}"`, - }, - } - } - - return { success: false, error: `Unsupported operation for manage_mcp_tool: ${operation}` } - } catch (error) { - logger - .withMetadata({ messageId: context.messageId }) - .error('manage_mcp_tool execution failed', { - operation, - workspaceId, - error: error instanceof Error ? error.message : String(error), - }) - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to manage MCP server', - } - } -} - -type ManageSkillOperation = 'add' | 'edit' | 'delete' | 'list' - -interface ManageSkillParams { - operation?: string - skillId?: string - name?: string - description?: string - content?: string -} - -async function executeManageSkill( - rawParams: Record, - context: ExecutionContext -): Promise { - const params = rawParams as ManageSkillParams - const operation = String(params.operation || '').toLowerCase() as ManageSkillOperation - const workspaceId = context.workspaceId - - if (!operation) { - return { success: false, error: "Missing required 'operation' argument" } - } - - if (!workspaceId) { - return { success: false, error: 'workspaceId is required' } - } - - const writeOps: string[] = ['add', 'edit', 'delete'] - if ( - writeOps.includes(operation) && - context.userPermission && - context.userPermission !== 'write' && - context.userPermission !== 'admin' - ) { - return { - success: false, - error: `Permission denied: '${operation}' on manage_skill requires write access. You have '${context.userPermission}' permission.`, - } - } - - try { - if (operation === 'list') { - const skills = await listSkills({ workspaceId }) - - return { - success: true, - output: { - success: true, - operation, - skills: skills.map((s) => ({ - id: s.id, - name: s.name, - description: s.description, - createdAt: s.createdAt, - })), - count: skills.length, - }, - } - } - - if (operation === 'add') { - if (!params.name || !params.description || !params.content) { - return { - success: false, - error: "'name', 'description', and 'content' are required for 'add'", - } - } - - const resultSkills = await upsertSkills({ - skills: [{ name: params.name, description: params.description, content: params.content }], - workspaceId, - userId: context.userId, - }) - const created = resultSkills.find((s) => s.name === params.name) - - recordAudit({ - workspaceId, - actorId: context.userId, - action: AuditAction.SKILL_CREATED, - resourceType: AuditResourceType.SKILL, - resourceId: created?.id, - resourceName: params.name, - description: `Created skill "${params.name}"`, - }) - - return { - success: true, - output: { - success: true, - operation, - skillId: created?.id, - name: params.name, - message: `Created skill "${params.name}"`, - }, - } - } - - if (operation === 'edit') { - if (!params.skillId) { - return { success: false, error: "'skillId' is required for 'edit'" } - } - if (!params.name && !params.description && !params.content) { - return { - success: false, - error: "At least one of 'name', 'description', or 'content' is required for 'edit'", - } - } - - const existing = await listSkills({ workspaceId }) - const found = existing.find((s) => s.id === params.skillId) - if (!found) { - return { success: false, error: `Skill not found: ${params.skillId}` } - } - - await upsertSkills({ - skills: [ - { - id: params.skillId, - name: params.name || found.name, - description: params.description || found.description, - content: params.content || found.content, - }, - ], - workspaceId, - userId: context.userId, - }) - - const updatedName = params.name || found.name - - recordAudit({ - workspaceId, - actorId: context.userId, - action: AuditAction.SKILL_UPDATED, - resourceType: AuditResourceType.SKILL, - resourceId: params.skillId, - resourceName: updatedName, - description: `Updated skill "${updatedName}"`, - }) - - return { - success: true, - output: { - success: true, - operation, - skillId: params.skillId, - name: updatedName, - message: `Updated skill "${updatedName}"`, - }, - } - } - - if (operation === 'delete') { - if (!params.skillId) { - return { success: false, error: "'skillId' is required for 'delete'" } - } - - const deleted = await deleteSkill({ skillId: params.skillId, workspaceId }) - if (!deleted) { - return { success: false, error: `Skill not found: ${params.skillId}` } - } - - recordAudit({ - workspaceId, - actorId: context.userId, - action: AuditAction.SKILL_DELETED, - resourceType: AuditResourceType.SKILL, - resourceId: params.skillId, - description: 'Deleted skill', - }) - - return { - success: true, - output: { - success: true, - operation, - skillId: params.skillId, - message: 'Deleted skill', - }, - } - } - - return { success: false, error: `Unsupported operation for manage_skill: ${operation}` } - } catch (error) { - logger.withMetadata({ messageId: context.messageId }).error('manage_skill execution failed', { - operation, - workspaceId, - error: error instanceof Error ? error.message : String(error), - }) - return { - success: false, - error: error instanceof Error ? error.message : 'Failed to manage skill', - } - } -} - -const SERVER_TOOLS = new Set([ - 'get_blocks_metadata', - 'get_trigger_blocks', - 'edit_workflow', - 'get_workflow_logs', - 'search_documentation', - 'set_environment_variables', - 'make_api_request', - 'knowledge_base', - 'user_table', - 'workspace_file', - 'download_to_workspace_file', - 'get_execution_summary', - 'get_job_logs', - 'generate_visualization', - 'generate_image', -]) - -/** - * Resolves a human-friendly provider name to a providerId and generates the - * actual OAuth authorization URL via Better Auth's server-side API. - * - * Steps: resolve provider → create credential draft → look up user session → - * call auth.api.oAuth2LinkAccount → return the real authorization URL. - */ -async function generateOAuthLink( - userId: string, - workspaceId: string | undefined, - workflowId: string | undefined, - chatId: string | undefined, - providerName: string, - baseUrl: string -): Promise<{ url: string; providerId: string; serviceName: string }> { - if (!workspaceId) { - throw new Error('workspaceId is required to generate an OAuth link') - } - - const allServices = getAllOAuthServices() - const normalizedInput = providerName.toLowerCase().trim() - - const matched = - allServices.find((s) => s.providerId === normalizedInput) || - allServices.find((s) => s.name.toLowerCase() === normalizedInput) || - allServices.find( - (s) => - s.name.toLowerCase().includes(normalizedInput) || - normalizedInput.includes(s.name.toLowerCase()) - ) || - allServices.find( - (s) => s.providerId.includes(normalizedInput) || normalizedInput.includes(s.providerId) - ) - - if (!matched) { - const available = allServices.map((s) => s.name).join(', ') - throw new Error(`Provider "${providerName}" not found. Available providers: ${available}`) - } - - const { providerId, name: serviceName } = matched - const callbackURL = - workflowId && workspaceId - ? `${baseUrl}/workspace/${workspaceId}/w/${workflowId}` - : chatId && workspaceId - ? `${baseUrl}/workspace/${workspaceId}/task/${chatId}` - : `${baseUrl}/workspace/${workspaceId}` - - // Trello and Shopify use custom auth routes, not genericOAuth - if (providerId === 'trello') { - return { url: `${baseUrl}/api/auth/trello/authorize`, providerId, serviceName } - } - if (providerId === 'shopify') { - const returnUrl = encodeURIComponent(callbackURL) - return { - url: `${baseUrl}/api/auth/shopify/authorize?returnUrl=${returnUrl}`, - providerId, - serviceName, - } - } - - // Build display name: "User Name's ServiceName" or just "ServiceName" - let displayName = serviceName - try { - const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId)) - if (row?.name) { - displayName = `${row.name}'s ${serviceName}` - } - } catch { - // Fall back to service name only - } - - // Create credential draft so the callback hook creates the credential - const now = new Date() - await db - .delete(pendingCredentialDraft) - .where( - and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now)) - ) - await db - .insert(pendingCredentialDraft) - .values({ - id: crypto.randomUUID(), - userId, - workspaceId, - providerId, - displayName, - expiresAt: new Date(now.getTime() + 15 * 60 * 1000), - createdAt: now, - }) - .onConflictDoUpdate({ - target: [ - pendingCredentialDraft.userId, - pendingCredentialDraft.providerId, - pendingCredentialDraft.workspaceId, - ], - set: { - displayName, - expiresAt: new Date(now.getTime() + 15 * 60 * 1000), - createdAt: now, - }, - }) - - const { auth } = await import('@/lib/auth/auth') - const { headers: getHeaders } = await import('next/headers') - const reqHeaders = await getHeaders() - - const data = await auth.api.oAuth2LinkAccount({ - body: { providerId, callbackURL }, - headers: reqHeaders, - }) - - if (!data?.url) { - throw new Error('oAuth2LinkAccount did not return an authorization URL') - } - - return { url: data.url, providerId, serviceName } -} - -const SIM_WORKFLOW_TOOL_HANDLERS: Record< - string, - (params: Record, context: ExecutionContext) => Promise -> = { - list_user_workspaces: (_p, c) => executeListUserWorkspaces(c), - list_folders: (p, c) => executeListFolders(p as ListFoldersParams, c), - create_workflow: (p, c) => executeCreateWorkflow(p as CreateWorkflowParams, c), - create_folder: (p, c) => executeCreateFolder(p as CreateFolderParams, c), - rename_workflow: (p, c) => executeRenameWorkflow(p as unknown as RenameWorkflowParams, c), - update_workflow: (p, c) => executeUpdateWorkflow(p as unknown as UpdateWorkflowParams, c), - delete_workflow: (p, c) => executeDeleteWorkflow(p as unknown as DeleteWorkflowParams, c), - move_workflow: (p, c) => executeMoveWorkflow(p as unknown as MoveWorkflowParams, c), - move_folder: (p, c) => executeMoveFolder(p as unknown as MoveFolderParams, c), - rename_folder: (p, c) => executeRenameFolder(p as unknown as RenameFolderParams, c), - delete_folder: (p, c) => executeDeleteFolder(p as unknown as DeleteFolderParams, c), - get_workflow_data: (p, c) => executeGetWorkflowData(p as GetWorkflowDataParams, c), - get_block_outputs: (p, c) => executeGetBlockOutputs(p as GetBlockOutputsParams, c), - get_block_upstream_references: (p, c) => - executeGetBlockUpstreamReferences(p as unknown as GetBlockUpstreamReferencesParams, c), - run_workflow: (p, c) => executeRunWorkflow(p as RunWorkflowParams, c), - run_workflow_until_block: (p, c) => - executeRunWorkflowUntilBlock(p as unknown as RunWorkflowUntilBlockParams, c), - run_from_block: (p, c) => executeRunFromBlock(p as unknown as RunFromBlockParams, c), - run_block: (p, c) => executeRunBlock(p as unknown as RunBlockParams, c), - get_deployed_workflow_state: (p, c) => - executeGetDeployedWorkflowState(p as GetDeployedWorkflowStateParams, c), - generate_api_key: (p, c) => executeGenerateApiKey(p as unknown as GenerateApiKeyParams, c), - get_platform_actions: () => - Promise.resolve({ - success: true, - output: { content: PLATFORM_ACTIONS_CONTENT }, - }), - set_global_workflow_variables: (p, c) => - executeSetGlobalWorkflowVariables(p as SetGlobalWorkflowVariablesParams, c), - deploy_api: (p, c) => executeDeployApi(p as DeployApiParams, c), - deploy_chat: (p, c) => executeDeployChat(p as DeployChatParams, c), - deploy_mcp: (p, c) => executeDeployMcp(p as DeployMcpParams, c), - redeploy: (p, c) => executeRedeploy(p as { workflowId?: string }, c), - check_deployment_status: (p, c) => - executeCheckDeploymentStatus(p as CheckDeploymentStatusParams, c), - list_workspace_mcp_servers: (p, c) => - executeListWorkspaceMcpServers(p as ListWorkspaceMcpServersParams, c), - create_workspace_mcp_server: (p, c) => - executeCreateWorkspaceMcpServer(p as CreateWorkspaceMcpServerParams, c), - update_workspace_mcp_server: (p, c) => - executeUpdateWorkspaceMcpServer(p as unknown as UpdateWorkspaceMcpServerParams, c), - delete_workspace_mcp_server: (p, c) => - executeDeleteWorkspaceMcpServer(p as unknown as DeleteWorkspaceMcpServerParams, c), - get_deployment_version: (p, c) => - executeGetDeploymentVersion(p as { workflowId?: string; version?: number }, c), - revert_to_version: (p, c) => - executeRevertToVersion(p as { workflowId?: string; version?: number }, c), - manage_credential: async (p, c) => { - const params = p as { operation: string; credentialId: string; displayName?: string } - const { operation, credentialId, displayName } = params - if (!credentialId) { - return { success: false, error: 'credentialId is required' } - } - try { - const [row] = await db - .select({ id: credential.id, type: credential.type, displayName: credential.displayName }) - .from(credential) - .where(eq(credential.id, credentialId)) - .limit(1) - if (!row) { - return { success: false, error: 'Credential not found' } - } - if (row.type !== 'oauth') { - return { - success: false, - error: - 'Only OAuth credentials can be managed with this tool. Use set_environment_variables for env vars.', - } - } - switch (operation) { - case 'rename': { - if (!displayName) { - return { success: false, error: 'displayName is required for rename' } - } - await db - .update(credential) - .set({ displayName, updatedAt: new Date() }) - .where(eq(credential.id, credentialId)) - recordAudit({ - actorId: c.userId, - action: AuditAction.CREDENTIAL_RENAMED, - resourceType: AuditResourceType.OAUTH, - resourceId: credentialId, - description: `Renamed credential to "${displayName}"`, - }) - return { success: true, output: { credentialId, displayName } } - } - case 'delete': { - await db.delete(credential).where(eq(credential.id, credentialId)) - recordAudit({ - actorId: c.userId, - action: AuditAction.CREDENTIAL_DELETED, - resourceType: AuditResourceType.OAUTH, - resourceId: credentialId, - description: `Deleted credential`, - }) - return { success: true, output: { credentialId, deleted: true } } - } - default: - return { - success: false, - error: `Unknown operation: ${operation}. Use "rename" or "delete".`, - } - } - } catch (error) { - return { success: false, error: error instanceof Error ? error.message : String(error) } - } - }, - create_job: (p, c) => executeCreateJob(p, c), - manage_job: (p, c) => executeManageJob(p, c), - complete_job: (p, c) => executeCompleteJob(p, c), - update_job_history: (p, c) => executeUpdateJobHistory(p, c), - oauth_get_auth_link: async (p, c) => { - const providerName = (p.providerName || p.provider_name || 'the provider') as string - const baseUrl = getBaseUrl() - - try { - const result = await generateOAuthLink( - c.userId, - c.workspaceId, - c.workflowId, - c.chatId, - providerName, - baseUrl - ) - return { - success: true, - output: { - message: `Authorization URL generated for ${result.serviceName}. The user must open this URL in a browser to authorize.`, - oauth_url: result.url, - instructions: `Open this URL in your browser to connect ${result.serviceName}: ${result.url}`, - provider: result.serviceName, - providerId: result.providerId, - }, - } - } catch (err) { - logger - .withMetadata({ messageId: c.messageId }) - .warn('Failed to generate OAuth link, falling back to generic URL', { - providerName, - error: err instanceof Error ? err.message : String(err), - }) - const workspaceUrl = c.workspaceId - ? `${baseUrl}/workspace/${c.workspaceId}` - : `${baseUrl}/workspace` - return { - success: false, - output: { - message: `Could not generate a direct OAuth link for ${providerName}. The user can connect manually from the workspace.`, - oauth_url: workspaceUrl, - instructions: `Open ${workspaceUrl} in a browser, go to Settings → Credentials, and connect ${providerName} from there.`, - provider: providerName, - error: err instanceof Error ? err.message : String(err), - }, - } - } - }, - oauth_request_access: async (p, _c) => { - const providerName = (p.providerName || p.provider_name || 'the provider') as string - return { - success: true, - output: { - success: true, - status: 'requested', - providerName, - message: `Requested ${providerName} OAuth connection. The user should complete the OAuth modal in the UI, then retry credential-dependent actions.`, - }, - } - }, - materialize_file: (p, c) => executeMaterializeFile(p, c), - manage_custom_tool: (p, c) => executeManageCustomTool(p, c), - manage_mcp_tool: (p, c) => executeManageMcpTool(p, c), - manage_skill: (p, c) => executeManageSkill(p, c), - // VFS tools - grep: (p, c) => executeVfsGrep(p, c), - glob: (p, c) => executeVfsGlob(p, c), - read: (p, c) => executeVfsRead(p, c), - list: (p, c) => executeVfsList(p, c), - - // Resource visibility - open_resource: async (p: OpenResourceParams, c: ExecutionContext) => { - const validated = validateOpenResourceParams(p) - if (!validated.success) { - return { success: false, error: validated.error } - } - - const params = validated.params - const resourceType = params.type - let resourceId = params.id - let title: string = resourceType - - if (resourceType === 'file') { - if (!c.workspaceId) { - return { - success: false, - error: - 'Opening a workspace file requires workspace context. Pass the canonical file UUID from files/by-id//meta.json.', - } - } - if (!isUuid(params.id)) { - return { - success: false, - error: - 'open_resource for files requires the canonical file UUID. Read files/by-id//meta.json or files//meta.json and pass the "id" field. Do not pass VFS paths or display names.', - } - } - const record = await getWorkspaceFile(c.workspaceId, params.id) - if (!record) { - return { - success: false, - error: `No workspace file with id "${params.id}". Confirm the UUID from files/by-id//meta.json.`, - } - } - resourceId = record.id - title = record.name - } - - if (resourceType === 'workflow') { - const workflow = await getWorkflowById(params.id) - if (!workflow) { - return { - success: false, - error: `No workflow with id "${params.id}". Confirm the workflow ID before opening it.`, - } - } - if (c.workspaceId && workflow.workspaceId !== c.workspaceId) { - return { - success: false, - error: `Workflow "${params.id}" was not found in the current workspace.`, - } - } - resourceId = workflow.id - title = workflow.name - } - - if (resourceType === 'table') { - const table = await getTableById(params.id) - if (!table) { - return { - success: false, - error: `No table with id "${params.id}". Confirm the table ID before opening it.`, - } - } - if (c.workspaceId && table.workspaceId !== c.workspaceId) { - return { - success: false, - error: `Table "${params.id}" was not found in the current workspace.`, - } - } - resourceId = table.id - title = table.name - } - - if (resourceType === 'knowledgebase') { - const knowledgeBase = await getKnowledgeBaseById(params.id) - if (!knowledgeBase) { - return { - success: false, - error: `No knowledge base with id "${params.id}". Confirm the knowledge base ID before opening it.`, - } - } - if (c.workspaceId && knowledgeBase.workspaceId !== c.workspaceId) { - return { - success: false, - error: `Knowledge base "${params.id}" was not found in the current workspace.`, - } - } - resourceId = knowledgeBase.id - title = knowledgeBase.name - } - - return { - success: true, - output: { message: `Opened ${resourceType} ${resourceId} for the user` }, - resources: [ - { - type: resourceType as 'workflow' | 'table' | 'knowledgebase' | 'file', - id: resourceId, - title, - }, - ], - } - }, -} - -/** - * Check whether a tool can be executed on the Sim (TypeScript) side. - * - * Tools that are only available server-side (e.g. search_patterns) - * will return false. The subagent tool_call - * handler uses this to decide whether to execute a tool locally or let the - * server's own tool_result SSE event handle it. - */ -export function isToolAvailableOnSimSide(toolName: string): boolean { - if (SERVER_TOOLS.has(toolName)) return true - if (toolName in SIM_WORKFLOW_TOOL_HANDLERS) return true - if (isMcpTool(toolName)) return true - const resolvedToolName = resolveToolId(toolName) - return !!getTool(resolvedToolName) -} - -/** - * Execute a tool server-side without calling internal routes. - */ -export async function executeToolServerSide( - toolCall: ToolCallState, - context: ExecutionContext -): Promise { - const toolName = toolCall.name - const resolvedToolName = resolveToolId(toolName) - - if (SERVER_TOOLS.has(toolName)) { - return executeServerToolDirect(toolName, toolCall.params || {}, context) - } - - if (toolName in SIM_WORKFLOW_TOOL_HANDLERS) { - return executeSimWorkflowTool(toolName, toolCall.params || {}, context) - } - - if (isMcpTool(toolName)) { - return executeMcpToolDirect(toolCall, context) - } - - const toolConfig = getTool(resolvedToolName) - if (!toolConfig) { - logger - .withMetadata({ messageId: context.messageId }) - .warn('Tool not found in registry', { toolName, resolvedToolName }) - return { - success: false, - error: `Tool not found: ${toolName}`, - } - } - - return executeIntegrationToolDirect(toolCall, toolConfig, context) -} - -/** - * Execute an MCP tool via the existing executeTool dispatcher which - * already handles the mcp- prefix and routes to /api/mcp/tools/execute. - */ -async function executeMcpToolDirect( - toolCall: ToolCallState, - context: ExecutionContext -): Promise { - const { userId, workflowId } = context - - let workspaceId = context.workspaceId - if (!workspaceId && workflowId) { - const wf = await getWorkflowById(workflowId) - workspaceId = wf?.workspaceId ?? undefined - } - - const params: Record = { - ...(toolCall.params || {}), - _context: { workflowId, userId, workspaceId }, - } - - const result = await executeTool(toolCall.name, params) - - return { - success: result.success, - output: result.output, - error: result.error, - } -} - -/** - * Execute a server tool directly via the server tool router. - */ -async function executeServerToolDirect( - toolName: string, - params: Record, - context: ExecutionContext -): Promise { - try { - const enrichedParams = { ...params } - if (!enrichedParams.workflowId && context.workflowId) { - enrichedParams.workflowId = context.workflowId - } - if (!enrichedParams.workspaceId && context.workspaceId) { - enrichedParams.workspaceId = context.workspaceId - } - - const result = await routeExecution(toolName, enrichedParams, { - userId: context.userId, - workspaceId: context.workspaceId, - userPermission: context.userPermission, - chatId: context.chatId, - messageId: context.messageId, - abortSignal: context.abortSignal, - userStopSignal: context.userStopSignal, - }) - - const resultRecord = - result && typeof result === 'object' && !Array.isArray(result) - ? (result as Record) - : null - - // Some server tools return an explicit { success, message, ... } envelope. - // Preserve tool-level failures instead of reporting them as transport success. - if (resultRecord?.success === false) { - const message = - (typeof resultRecord.error === 'string' && resultRecord.error) || - (typeof resultRecord.message === 'string' && resultRecord.message) || - `${toolName} failed` - - return { - success: false, - error: message, - output: result, - } - } - - return { success: true, output: result } - } catch (error) { - logger.withMetadata({ messageId: context.messageId }).error('Server tool execution failed', { - toolName, - error: error instanceof Error ? error.message : String(error), - }) - return { - success: false, - error: error instanceof Error ? error.message : 'Server tool execution failed', - } - } -} - -async function executeSimWorkflowTool( - toolName: string, - params: Record, - context: ExecutionContext -): Promise { - const handler = SIM_WORKFLOW_TOOL_HANDLERS[toolName] - if (!handler) return { success: false, error: `Unsupported workflow tool: ${toolName}` } - - if (context.workflowId) { - if (toolName === 'create_workflow') { - return { - success: false, - error: - 'Cannot create new workflows from the workflow copilot. You are scoped to the current workflow. Use the workspace chat to create new workflows.', - } - } - - if ( - toolName === 'edit_workflow' && - params.workflowId && - params.workflowId !== context.workflowId - ) { - return { - success: false, - error: `Cannot edit a different workflow. You are scoped to workflow ${context.workflowId}.`, - } - } - } - - return handler(params, context) -} - -/** Timeout for the mark-complete POST to the copilot backend (30 s). */ -const MARK_COMPLETE_TIMEOUT_MS = 30_000 - -/** - * Notify the copilot backend that a tool has completed. - */ -export async function markToolComplete( - toolCallId: string, - toolName: string, - status: number, - message?: unknown, - data?: unknown, - messageId?: string -): Promise { - try { - const controller = new AbortController() - const timeoutId = setTimeout(() => controller.abort(), MARK_COMPLETE_TIMEOUT_MS) - - try { - const response = await fetch(`${SIM_AGENT_API_URL}/api/tools/mark-complete`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}), - }, - body: JSON.stringify({ - id: toolCallId, - name: toolName, - status, - message, - data, - }), - signal: controller.signal, - }) - - if (!response.ok) { - logger.withMetadata({ messageId }).warn('Mark-complete call failed', { - toolCallId, - toolName, - status: response.status, - }) - return false - } - - return true - } finally { - clearTimeout(timeoutId) - } - } catch (error) { - const isTimeout = error instanceof DOMException && error.name === 'AbortError' - logger.withMetadata({ messageId }).error('Mark-complete call failed', { - toolCallId, - toolName, - timedOut: isTimeout, - error: error instanceof Error ? error.message : String(error), - }) - return false - } -} - -/** - * Prepare execution context with cached environment values. - */ -export async function prepareExecutionContext( - userId: string, - workflowId: string, - chatId?: string -): Promise { - const wf = await getWorkflowById(workflowId) - const workspaceId = wf?.workspaceId ?? undefined - - const decryptedEnvVars = await getEffectiveDecryptedEnv(userId, workspaceId) - - return { - userId, - workflowId, - workspaceId, - chatId, - decryptedEnvVars, - } -} diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/integration-tools.ts b/apps/sim/lib/copilot/orchestrator/tool-executor/integration-tools.ts deleted file mode 100644 index 7e7d74f4d29..00000000000 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/integration-tools.ts +++ /dev/null @@ -1,285 +0,0 @@ -import { db } from '@sim/db' -import { account } from '@sim/db/schema' -import { createLogger } from '@sim/logger' -import { eq } from 'drizzle-orm' -import type { - ExecutionContext, - ToolCallResult, - ToolCallState, -} from '@/lib/copilot/orchestrator/types' -import { isHosted } from '@/lib/core/config/feature-flags' -import { generateRequestId } from '@/lib/core/utils/request' -import { getCredentialActorContext } from '@/lib/credentials/access' -import { getAccessibleOAuthCredentials } from '@/lib/credentials/environment' -import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' -import { getServiceAccountProviderForProviderId } from '@/lib/oauth/utils' -import { getTableById, queryRows } from '@/lib/table/service' -import { - downloadWorkspaceFile, - findWorkspaceFileRecord, - getSandboxWorkspaceFilePath, - listWorkspaceFiles, -} from '@/lib/uploads/contexts/workspace/workspace-file-manager' -import { getWorkflowById } from '@/lib/workflows/utils' -import { refreshTokenIfNeeded } from '@/app/api/auth/oauth/utils' -import { resolveEnvVarReferences } from '@/executor/utils/reference-validation' -import { executeTool } from '@/tools' -import type { ToolConfig } from '@/tools/types' -import { resolveToolId } from '@/tools/utils' - -const logger = createLogger('CopilotIntegrationTools') - -function csvEscapeValue(value: unknown): string { - if (value === null || value === undefined) return '' - if (typeof value === 'number' || typeof value === 'boolean') return String(value) - const str = String(value) - if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) { - return `"${str.replace(/"/g, '""')}"` - } - return str -} - -export async function executeIntegrationToolDirect( - toolCall: ToolCallState, - toolConfig: ToolConfig, - context: ExecutionContext -): Promise { - const { userId, workflowId } = context - const toolName = resolveToolId(toolCall.name) - const toolArgs = toolCall.params || {} - - let workspaceId = context.workspaceId - if (!workspaceId && workflowId) { - const wf = await getWorkflowById(workflowId) - workspaceId = wf?.workspaceId ?? undefined - } - - const decryptedEnvVars = - context.decryptedEnvVars || (await getEffectiveDecryptedEnv(userId, workspaceId)) - - const executionParams = resolveEnvVarReferences(toolArgs, decryptedEnvVars, { - deep: true, - }) as Record - - // If the LLM passed a credential/oauthCredential ID directly, verify the user - // has active credential_member access before proceeding. This prevents - // unauthorized credential usage even if the agent hallucinated or received - // a credential ID the user doesn't have access to. - const suppliedCredentialId = (executionParams.credentialId || - executionParams.oauthCredential || - executionParams.credential) as string | undefined - if (suppliedCredentialId) { - const actorCtx = await getCredentialActorContext(suppliedCredentialId, userId) - if (!actorCtx.member) { - logger.warn('Blocked credential use: user lacks credential_member access', { - credentialId: suppliedCredentialId, - userId, - toolName, - }) - return { - success: false, - error: `You do not have access to credential "${suppliedCredentialId}". Ask the credential admin to add you as a member, or connect your own account.`, - } - } - } - - if (toolConfig.oauth?.required && toolConfig.oauth.provider) { - const provider = toolConfig.oauth.provider - - // Determine which credential to use: supplied by the LLM or auto-resolved - let resolvedCredentialId = suppliedCredentialId - - if (!resolvedCredentialId) { - if (!workspaceId) { - return { - success: false, - error: `Cannot resolve ${provider} credential without a workspace context.`, - } - } - - const accessibleCreds = await getAccessibleOAuthCredentials(workspaceId, userId) - const saProviderId = getServiceAccountProviderForProviderId(provider) - const match = - accessibleCreds.find((c) => c.providerId === provider) || - (saProviderId ? accessibleCreds.find((c) => c.providerId === saProviderId) : undefined) - - if (!match) { - return { - success: false, - error: `No accessible ${provider} account found. You either don't have a ${provider} account connected in this workspace, or you don't have access to the existing one. Please connect your own account.`, - } - } - - resolvedCredentialId = match.id - } - - const matchCtx = await getCredentialActorContext(resolvedCredentialId, userId) - - if (matchCtx.credential?.type === 'service_account') { - executionParams.oauthCredential = resolvedCredentialId - } else { - const accountId = matchCtx.credential?.accountId - if (!accountId) { - return { - success: false, - error: `OAuth account for ${provider} not found. Please reconnect your account.`, - } - } - - const [acc] = await db.select().from(account).where(eq(account.id, accountId)).limit(1) - - if (!acc) { - return { - success: false, - error: `OAuth account for ${provider} not found. Please reconnect your account.`, - } - } - - const requestId = generateRequestId() - const { accessToken } = await refreshTokenIfNeeded(requestId, acc, acc.id) - - if (!accessToken) { - return { - success: false, - error: `OAuth token not available for ${provider}. Please reconnect your account.`, - } - } - - executionParams.accessToken = accessToken - } - } - - const hasHostedKeySupport = isHosted && !!toolConfig.hosting - if (toolConfig.params?.apiKey?.required && !executionParams.apiKey && !hasHostedKeySupport) { - return { - success: false, - error: `API key not provided for ${toolName}. Use {{YOUR_API_KEY_ENV_VAR}} to reference your environment variable.`, - } - } - - executionParams._context = { - workflowId, - workspaceId, - userId, - enforceCredentialAccess: true, - } - - if (toolName === 'function_execute') { - executionParams.envVars = decryptedEnvVars - executionParams.workflowVariables = {} - executionParams.blockData = {} - executionParams.blockNameMapping = {} - executionParams.language = executionParams.language || 'javascript' - executionParams.timeout = executionParams.timeout || 30000 - - if (isHosted && workspaceId) { - const sandboxFiles: Array<{ path: string; content: string }> = [] - const MAX_FILE_SIZE = 10 * 1024 * 1024 - const MAX_TOTAL_SIZE = 50 * 1024 * 1024 - const TEXT_EXTENSIONS = new Set([ - 'csv', - 'json', - 'txt', - 'md', - 'html', - 'xml', - 'tsv', - 'yaml', - 'yml', - ]) - let totalSize = 0 - - const inputFileIds = executionParams.inputFiles as string[] | undefined - if (inputFileIds?.length) { - const allFiles = await listWorkspaceFiles(workspaceId) - for (const fileRef of inputFileIds) { - const record = findWorkspaceFileRecord(allFiles, fileRef) - if (!record) { - logger.warn('Sandbox input file not found', { fileRef }) - continue - } - const ext = record.name.split('.').pop()?.toLowerCase() ?? '' - if (!TEXT_EXTENSIONS.has(ext)) { - logger.warn('Skipping non-text sandbox input file', { - fileId: record.id, - fileName: record.name, - ext, - }) - continue - } - if (record.size > MAX_FILE_SIZE) { - logger.warn('Sandbox input file exceeds size limit', { - fileId: record.id, - fileName: record.name, - size: record.size, - }) - continue - } - if (totalSize + record.size > MAX_TOTAL_SIZE) { - logger.warn('Sandbox input total size limit reached, skipping remaining files') - break - } - const buffer = await downloadWorkspaceFile(record) - totalSize += buffer.length - const textContent = buffer.toString('utf-8') - sandboxFiles.push({ - path: getSandboxWorkspaceFilePath(record), - content: textContent, - }) - sandboxFiles.push({ - path: `/home/user/${record.name}`, - content: textContent, - }) - } - } - - const inputTableIds = executionParams.inputTables as string[] | undefined - if (inputTableIds?.length) { - for (const tableId of inputTableIds) { - const table = await getTableById(tableId) - if (!table) { - logger.warn('Sandbox input table not found', { tableId }) - continue - } - const { rows } = await queryRows(tableId, workspaceId, { limit: 10000 }, 'sandbox-input') - const schema = table.schema as { columns: Array<{ name: string; type?: string }> } - const cols = schema.columns.map((c) => c.name) - const typeComment = `# types: ${schema.columns.map((c) => `${c.name}=${c.type || 'string'}`).join(', ')}` - const csvLines = [typeComment, cols.join(',')] - for (const row of rows) { - csvLines.push( - cols.map((c) => csvEscapeValue((row.data as Record)[c])).join(',') - ) - } - const csvContent = csvLines.join('\n') - if (totalSize + csvContent.length > MAX_TOTAL_SIZE) { - logger.warn('Sandbox input total size limit reached, skipping remaining tables') - break - } - totalSize += csvContent.length - sandboxFiles.push({ path: `/home/user/tables/${tableId}.csv`, content: csvContent }) - } - } - - if (sandboxFiles.length > 0) { - executionParams._sandboxFiles = sandboxFiles - logger.info('Prepared sandbox input files', { - fileCount: sandboxFiles.length, - totalSize, - paths: sandboxFiles.map((f) => f.path), - }) - } - - executionParams.inputFiles = undefined - executionParams.inputTables = undefined - } - } - - const result = await executeTool(toolName, executionParams) - - return { - success: result.success, - output: result.output, - error: result.error, - } -} diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/workflow-tools/index.ts b/apps/sim/lib/copilot/orchestrator/tool-executor/workflow-tools/index.ts deleted file mode 100644 index b908b07108d..00000000000 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/workflow-tools/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -export * from './mutations' -export * from './queries' diff --git a/apps/sim/lib/copilot/orchestrator/persistence.ts b/apps/sim/lib/copilot/persistence/tool-confirm/index.ts similarity index 65% rename from apps/sim/lib/copilot/orchestrator/persistence.ts rename to apps/sim/lib/copilot/persistence/tool-confirm/index.ts index d0b47ecb8fc..0b18af68bee 100644 --- a/apps/sim/lib/copilot/orchestrator/persistence.ts +++ b/apps/sim/lib/copilot/persistence/tool-confirm/index.ts @@ -1,9 +1,13 @@ import { createLogger } from '@sim/logger' -import type { AsyncCompletionEnvelope } from '@/lib/copilot/async-runs/lifecycle' +import { ASYNC_TOOL_STATUS, type AsyncCompletionEnvelope } from '@/lib/copilot/async-runs/lifecycle' import { getAsyncToolCalls } from '@/lib/copilot/async-runs/repository' +import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1' +import { getRedisClient } from '@/lib/core/config/redis' import { createPubSubChannel } from '@/lib/events/pubsub' const logger = createLogger('CopilotOrchestratorPersistence') +const TOOL_CONFIRMATION_TTL_SECONDS = 60 * 10 +const toolConfirmationKey = (toolCallId: string) => `copilot:tool-confirmation:${toolCallId}` const toolConfirmationChannel = createPubSubChannel({ channel: 'copilot:tool-confirmation', @@ -19,16 +23,22 @@ export async function getToolConfirmation(toolCallId: string): Promise<{ timestamp?: string data?: Record } | null> { - const [row] = await getAsyncToolCalls([toolCallId]).catch(() => []) + const [row] = await getAsyncToolCalls([toolCallId]).catch((err) => { + logger.warn('Failed to fetch async tool calls', { + toolCallId, + error: err instanceof Error ? err.message : String(err), + }) + return [] + }) if (!row) return null return { status: - row.status === 'completed' - ? 'success' - : row.status === 'failed' - ? 'error' - : row.status === 'cancelled' - ? 'cancelled' + row.status === ASYNC_TOOL_STATUS.completed + ? MothershipStreamV1ToolOutcome.success + : row.status === ASYNC_TOOL_STATUS.failed + ? MothershipStreamV1ToolOutcome.error + : row.status === ASYNC_TOOL_STATUS.cancelled + ? MothershipStreamV1ToolOutcome.cancelled : row.status, message: row.error || undefined, data: (row.result as Record | null) || undefined, @@ -41,6 +51,34 @@ export function publishToolConfirmation(event: AsyncCompletionEnvelope): void { toolCallId: event.toolCallId, status: event.status, }) + const redis = getRedisClient() + if (redis) { + void redis + .set( + toolConfirmationKey(event.toolCallId), + JSON.stringify(event), + 'EX', + TOOL_CONFIRMATION_TTL_SECONDS + ) + .then(() => { + logger.info('Persisted tool confirmation in Redis', { + toolCallId: event.toolCallId, + status: event.status, + redisKey: toolConfirmationKey(event.toolCallId), + }) + }) + .catch((error) => { + logger.warn('Failed to persist tool confirmation in Redis', { + toolCallId: event.toolCallId, + error: error instanceof Error ? error.message : String(error), + }) + }) + } else { + logger.warn('Redis unavailable while publishing tool confirmation', { + toolCallId: event.toolCallId, + status: event.status, + }) + } toolConfirmationChannel.publish(event) } diff --git a/apps/sim/lib/copilot/orchestrator/persistence.test.ts b/apps/sim/lib/copilot/persistence/tool-confirm/tool-confirm.test.ts similarity index 98% rename from apps/sim/lib/copilot/orchestrator/persistence.test.ts rename to apps/sim/lib/copilot/persistence/tool-confirm/tool-confirm.test.ts index 0474fab9f80..8c8c4e9f253 100644 --- a/apps/sim/lib/copilot/orchestrator/persistence.test.ts +++ b/apps/sim/lib/copilot/persistence/tool-confirm/tool-confirm.test.ts @@ -33,7 +33,7 @@ import { getToolConfirmation, publishToolConfirmation, waitForToolConfirmation, -} from './persistence' +} from '@/lib/copilot/persistence/tool-confirm' describe('copilot orchestrator persistence', () => { let row: { diff --git a/apps/sim/lib/copilot/request/context/request-context.ts b/apps/sim/lib/copilot/request/context/request-context.ts new file mode 100644 index 00000000000..0f4fe41b1db --- /dev/null +++ b/apps/sim/lib/copilot/request/context/request-context.ts @@ -0,0 +1,30 @@ +import { TraceCollector } from '@/lib/copilot/request/trace' +import type { StreamingContext } from '@/lib/copilot/request/types' + +/** + * Create a fresh StreamingContext. + */ +export function createStreamingContext(overrides?: Partial): StreamingContext { + return { + chatId: undefined, + executionId: undefined, + runId: undefined, + messageId: crypto.randomUUID(), + accumulatedContent: '', + contentBlocks: [], + toolCalls: new Map(), + pendingToolPromises: new Map(), + currentThinkingBlock: null, + isInThinkingBlock: false, + subAgentParentToolCallId: undefined, + subAgentParentStack: [], + subAgentContent: {}, + subAgentToolCalls: {}, + pendingContent: '', + streamComplete: false, + wasAborted: false, + errors: [], + trace: new TraceCollector(), + ...overrides, + } +} diff --git a/apps/sim/lib/copilot/orchestrator/stream/core.test.ts b/apps/sim/lib/copilot/request/context/result.test.ts similarity index 81% rename from apps/sim/lib/copilot/orchestrator/stream/core.test.ts rename to apps/sim/lib/copilot/request/context/result.test.ts index 97e614caf6b..0e8f8cd97ca 100644 --- a/apps/sim/lib/copilot/orchestrator/stream/core.test.ts +++ b/apps/sim/lib/copilot/request/context/result.test.ts @@ -2,8 +2,10 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { buildToolCallSummaries } from '@/lib/copilot/orchestrator/stream/core' -import type { StreamingContext } from '@/lib/copilot/orchestrator/types' +import { FunctionExecute } from '@/lib/copilot/generated/tool-catalog-v1' +import { buildToolCallSummaries } from '@/lib/copilot/request/context/result' +import { TraceCollector } from '@/lib/copilot/request/trace' +import type { StreamingContext } from '@/lib/copilot/request/types' function makeContext(): StreamingContext { return { @@ -27,6 +29,7 @@ function makeContext(): StreamingContext { streamComplete: false, wasAborted: false, errors: [], + trace: new TraceCollector(), } } @@ -50,7 +53,7 @@ describe('buildToolCallSummaries', () => { const context = makeContext() context.toolCalls.set('tool-2', { id: 'tool-2', - name: 'function_execute', + name: FunctionExecute.id, status: 'executing', startTime: 1, }) diff --git a/apps/sim/lib/copilot/request/context/result.ts b/apps/sim/lib/copilot/request/context/result.ts new file mode 100644 index 00000000000..8884ac4b23f --- /dev/null +++ b/apps/sim/lib/copilot/request/context/result.ts @@ -0,0 +1,29 @@ +import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1' +import type { StreamingContext, ToolCallSummary } from '@/lib/copilot/request/types' + +/** + * Build a ToolCallSummary array from the streaming context. + */ +export function buildToolCallSummaries(context: StreamingContext): ToolCallSummary[] { + return Array.from(context.toolCalls.values()).map((toolCall) => { + let status = toolCall.status + if (toolCall.result && toolCall.result.success !== undefined) { + status = toolCall.result.success + ? MothershipStreamV1ToolOutcome.success + : MothershipStreamV1ToolOutcome.error + } else if ((status === 'pending' || status === 'executing') && toolCall.error) { + status = MothershipStreamV1ToolOutcome.error + } + + return { + id: toolCall.id, + name: toolCall.name, + status, + params: toolCall.params, + result: toolCall.result?.output, + error: toolCall.error, + durationMs: + toolCall.endTime && toolCall.startTime ? toolCall.endTime - toolCall.startTime : undefined, + } + }) +} diff --git a/apps/sim/lib/copilot/orchestrator/sse/parser.ts b/apps/sim/lib/copilot/request/go/parser.ts similarity index 69% rename from apps/sim/lib/copilot/orchestrator/sse/parser.ts rename to apps/sim/lib/copilot/request/go/parser.ts index d2fd0355c85..479276ab9db 100644 --- a/apps/sim/lib/copilot/orchestrator/sse/parser.ts +++ b/apps/sim/lib/copilot/request/go/parser.ts @@ -1,16 +1,23 @@ import { createLogger } from '@sim/logger' -import type { SSEEvent } from '@/lib/copilot/orchestrator/types' const logger = createLogger('CopilotSseParser') /** - * Parses SSE streams from the copilot backend into typed events. + * Processes an SSE stream by calling onEvent synchronously for each parsed event + * within a single reader.read() chunk. All events from one chunk are processed + * in the same microtask — no yield/next() boundaries between them. + * + * Replaces the async generator approach which incurred 2 microtask yields per + * event (one for yield, one for the consumer's next() resumption). + * + * @param onEvent Called synchronously per parsed event. Return true to stop processing. */ -export async function* parseSSEStream( +export async function processSSEStream( reader: ReadableStreamDefaultReader, decoder: TextDecoder, - abortSignal?: AbortSignal -): AsyncGenerator { + abortSignal: AbortSignal | undefined, + onEvent: (event: unknown) => boolean | undefined +): Promise { let buffer = '' try { @@ -28,6 +35,7 @@ export async function* parseSSEStream( const lines = buffer.split('\n') buffer = lines.pop() || '' + let stopped = false for (const line of lines) { if (abortSignal?.aborted) { logger.info('SSE stream aborted mid-chunk (between events)') @@ -40,9 +48,9 @@ export async function* parseSSEStream( if (jsonStr === '[DONE]') continue try { - const event = JSON.parse(jsonStr) as SSEEvent - if (event?.type) { - yield event + if (onEvent(JSON.parse(jsonStr))) { + stopped = true + break } } catch (error) { logger.warn('Failed to parse SSE event', { @@ -51,6 +59,7 @@ export async function* parseSSEStream( }) } } + if (stopped) break } } catch (error) { const aborted = @@ -64,10 +73,7 @@ export async function* parseSSEStream( if (buffer.trim() && buffer.startsWith('data: ')) { try { - const event = JSON.parse(buffer.slice(6)) as SSEEvent - if (event?.type) { - yield event - } + onEvent(JSON.parse(buffer.slice(6))) } catch (error) { logger.warn('Failed to parse final SSE buffer', { preview: buffer.slice(0, 200), diff --git a/apps/sim/lib/copilot/request/go/stream.ts b/apps/sim/lib/copilot/request/go/stream.ts new file mode 100644 index 00000000000..b7bc12bd6fc --- /dev/null +++ b/apps/sim/lib/copilot/request/go/stream.ts @@ -0,0 +1,282 @@ +import { createLogger } from '@sim/logger' +import { ORCHESTRATION_TIMEOUT_MS } from '@/lib/copilot/constants' +import { + MothershipStreamV1EventType, + MothershipStreamV1SpanLifecycleEvent, + MothershipStreamV1SpanPayloadKind, +} from '@/lib/copilot/generated/mothership-stream-v1' +import { processSSEStream } from '@/lib/copilot/request/go/parser' +import { + handleSubagentRouting, + sseHandlers, + subAgentHandlers, +} from '@/lib/copilot/request/handlers' +import { eventToStreamEvent, isEventRecord } from '@/lib/copilot/request/session' +import { shouldSkipToolCallEvent, shouldSkipToolResultEvent } from '@/lib/copilot/request/sse-utils' +import type { + ExecutionContext, + OrchestratorOptions, + StreamEvent, + StreamingContext, +} from '@/lib/copilot/request/types' + +const logger = createLogger('CopilotGoStream') + +export class CopilotBackendError extends Error { + status?: number + body?: string + + constructor(message: string, options?: { status?: number; body?: string }) { + super(message) + this.name = 'CopilotBackendError' + this.status = options?.status + this.body = options?.body + } +} + +export class BillingLimitError extends Error { + constructor(public readonly userId: string) { + super('Usage limit reached') + this.name = 'BillingLimitError' + } +} + +/** + * Options for the shared stream processing loop. + */ +export interface StreamLoopOptions extends OrchestratorOptions { + /** + * Called for each normalized event BEFORE standard handler dispatch. + * Return true to skip the default handler for this event. + */ + onBeforeDispatch?: (event: StreamEvent, context: StreamingContext) => boolean | undefined +} + +// Pre-resolve text handlers at module level to avoid map lookups in the hot path. +const textHandler = sseHandlers[MothershipStreamV1EventType.text] +const subagentTextHandler = subAgentHandlers[MothershipStreamV1EventType.text] + +/** + * Run the SSE stream processing loop against the Go backend. + * + * Handles: fetch -> parse -> normalize -> dedupe -> subagent routing -> handler dispatch. + * Callers provide the fetch URL/options and can intercept events via onBeforeDispatch. + * + * Optimised hot path: text events (the most frequent) bypass tool-call dedup + * checks and are dispatched synchronously without any await, eliminating ~4 + * microtask yields per text event vs the previous async-generator + await chain. + */ +export async function runStreamLoop( + fetchUrl: string, + fetchOptions: RequestInit, + context: StreamingContext, + execContext: ExecutionContext, + options: StreamLoopOptions +): Promise { + const { timeout = ORCHESTRATION_TIMEOUT_MS, abortSignal } = options + + const fetchSpan = context.trace.startSpan( + `HTTP Request → ${new URL(fetchUrl).pathname}`, + 'sim.http.fetch', + { url: fetchUrl } + ) + const response = await fetch(fetchUrl, { + ...fetchOptions, + signal: abortSignal, + }) + + if (!response.ok) { + context.trace.endSpan(fetchSpan, 'error') + const errorText = await response.text().catch(() => '') + + if (response.status === 402) { + throw new BillingLimitError(execContext.userId) + } + + throw new CopilotBackendError( + `Copilot backend error (${response.status}): ${errorText || response.statusText}`, + { status: response.status, body: errorText || response.statusText } + ) + } + + if (!response.body) { + context.trace.endSpan(fetchSpan, 'error') + throw new CopilotBackendError('Copilot backend response missing body') + } + + context.trace.endSpan(fetchSpan) + const reader = response.body.getReader() + const decoder = new TextDecoder() + + const timeoutId = setTimeout(() => { + context.errors.push('Request timed out') + context.streamComplete = true + reader.cancel().catch(() => {}) + }, timeout) + + try { + await processSSEStream(reader, decoder, abortSignal, (raw) => { + // --- Abort gate (sync check, no await) --- + if (abortSignal?.aborted) { + context.wasAborted = true + return true + } + + if (!isEventRecord(raw)) { + logger.warn('Received non-contract stream event on shared path; dropping event') + return + } + + const streamEvent = eventToStreamEvent(raw) + if (raw.trace?.requestId) { + context.requestId = raw.trace.requestId + context.trace.setGoTraceId(raw.trace.requestId) + } + + // --------------------------------------------------------------- + // FAST PATH — text events + // + // Text is the most frequent event type. We skip two things that + // can never match for text events: + // • shouldSkipToolCallEvent (early-exits for type !== 'tool') + // • shouldSkipToolResultEvent (early-exits for type !== 'tool') + // + // All calls in this path are synchronous: onEvent (publish) returns + // void, and both textHandler / subagentTextHandler return void. + // Eliminating the awaits saves 2 microtask yields per text event + // (on top of the 2 saved by replacing the async generator). + // --------------------------------------------------------------- + if (streamEvent.type === MothershipStreamV1EventType.text) { + try { + options.onEvent?.(streamEvent) + } catch (error) { + logger.warn('Failed to forward stream event', { + type: streamEvent.type, + error: error instanceof Error ? error.message : String(error), + }) + } + + if (options.onBeforeDispatch?.(streamEvent, context)) { + return context.streamComplete || undefined + } + + if (handleSubagentRouting(streamEvent, context)) { + subagentTextHandler(streamEvent, context, execContext, options) + } else { + textHandler(streamEvent, context, execContext, options) + } + return context.streamComplete || undefined + } + + // --------------------------------------------------------------- + // STANDARD PATH — all other event types + // --------------------------------------------------------------- + if (shouldSkipToolCallEvent(streamEvent) || shouldSkipToolResultEvent(streamEvent)) { + return + } + + // onEvent (publish) is synchronous — no await needed. + try { + options.onEvent?.(streamEvent) + } catch (error) { + logger.warn('Failed to forward stream event', { + type: streamEvent.type, + error: error instanceof Error ? error.message : String(error), + }) + } + + if (options.onBeforeDispatch?.(streamEvent, context)) { + return context.streamComplete || undefined + } + + // --- Subagent span lifecycle --- + if ( + streamEvent.type === MothershipStreamV1EventType.span && + streamEvent.payload.kind === MothershipStreamV1SpanPayloadKind.subagent + ) { + const spanData = + streamEvent.payload.data && + typeof streamEvent.payload.data === 'object' && + !Array.isArray(streamEvent.payload.data) + ? (streamEvent.payload.data as Record) + : undefined + const toolCallId = + (streamEvent.payload.parentToolCallId as string | undefined) || + (spanData?.tool_call_id as string | undefined) + const subagentName = streamEvent.payload.agent as string | undefined + const spanEvent = streamEvent.payload.event as string | undefined + const isPendingPause = spanData?.pending === true + if (spanEvent === MothershipStreamV1SpanLifecycleEvent.start) { + const lastParent = context.subAgentParentStack[context.subAgentParentStack.length - 1] + const lastBlock = context.contentBlocks[context.contentBlocks.length - 1] + if (toolCallId) { + if (lastParent !== toolCallId) { + context.subAgentParentStack.push(toolCallId) + } + context.subAgentParentToolCallId = toolCallId + context.subAgentContent[toolCallId] ??= '' + context.subAgentToolCalls[toolCallId] ??= [] + } + if ( + subagentName && + !( + lastParent === toolCallId && + lastBlock?.type === 'subagent' && + lastBlock.content === subagentName + ) + ) { + context.contentBlocks.push({ + type: 'subagent', + content: subagentName, + timestamp: Date.now(), + }) + } + return + } + if (spanEvent === MothershipStreamV1SpanLifecycleEvent.end) { + if (isPendingPause) { + return + } + if (context.subAgentParentStack.length > 0) { + context.subAgentParentStack.pop() + } else { + logger.warn('subagent end without matching start') + } + context.subAgentParentToolCallId = + context.subAgentParentStack.length > 0 + ? context.subAgentParentStack[context.subAgentParentStack.length - 1] + : undefined + return + } + } + + // --- Subagent-scoped event dispatch --- + if (handleSubagentRouting(streamEvent, context)) { + const handler = subAgentHandlers[streamEvent.type] + if (handler) { + // All current subagent handlers (text, tool, span) resolve + // synchronously or fire-and-forget their async work internally. + // Calling without await saves 1 microtask yield per event. + handler(streamEvent, context, execContext, options) + } + return context.streamComplete || undefined + } + + // --- Main handler dispatch --- + const handler = sseHandlers[streamEvent.type] + if (handler) { + // session, complete, error, run, span handlers are synchronous. + // tool handler is async but resolves immediately (fire-and-forget + // internal dispatch). Calling without await saves 1 microtask yield. + handler(streamEvent, context, execContext, options) + } + return context.streamComplete || undefined + }) + } finally { + if (abortSignal?.aborted) { + context.wasAborted = true + await reader.cancel().catch(() => {}) + } + clearTimeout(timeoutId) + } +} diff --git a/apps/sim/lib/copilot/request/handlers/complete.ts b/apps/sim/lib/copilot/request/handlers/complete.ts new file mode 100644 index 00000000000..ba7ebda6aa5 --- /dev/null +++ b/apps/sim/lib/copilot/request/handlers/complete.ts @@ -0,0 +1,29 @@ +import { asRecord, getEventData } from '@/lib/copilot/request/sse-utils' +import type { StreamHandler } from './types' + +export const handleCompleteEvent: StreamHandler = (event, context) => { + const d = getEventData(event) + if (!d) { + context.streamComplete = true + return + } + + if (d.usage) { + const u = asRecord(d.usage) + context.usage = { + prompt: (context.usage?.prompt || 0) + ((u.input_tokens as number) || 0), + completion: (context.usage?.completion || 0) + ((u.output_tokens as number) || 0), + } + } + + if (d.cost) { + const c = asRecord(d.cost) + context.cost = { + input: (context.cost?.input || 0) + ((c.input as number) || 0), + output: (context.cost?.output || 0) + ((c.output as number) || 0), + total: (context.cost?.total || 0) + ((c.total as number) || 0), + } + } + + context.streamComplete = true +} diff --git a/apps/sim/lib/copilot/request/handlers/error.ts b/apps/sim/lib/copilot/request/handlers/error.ts new file mode 100644 index 00000000000..225534d066f --- /dev/null +++ b/apps/sim/lib/copilot/request/handlers/error.ts @@ -0,0 +1,11 @@ +import { getEventData } from '@/lib/copilot/request/sse-utils' +import type { StreamHandler } from './types' + +export const handleErrorEvent: StreamHandler = (event, context) => { + const d = getEventData(event) + const message = (d?.message || d?.error) as string | undefined + if (message) { + context.errors.push(message) + } + context.streamComplete = true +} diff --git a/apps/sim/lib/copilot/request/handlers/handlers.test.ts b/apps/sim/lib/copilot/request/handlers/handlers.test.ts new file mode 100644 index 00000000000..0a225595370 --- /dev/null +++ b/apps/sim/lib/copilot/request/handlers/handlers.test.ts @@ -0,0 +1,509 @@ +/** + * @vitest-environment node + */ + +import { loggerMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { TraceCollector } from '@/lib/copilot/request/trace' + +vi.mock('@sim/logger', () => loggerMock) + +const { isSimExecuted, executeTool, ensureHandlersRegistered } = vi.hoisted(() => ({ + isSimExecuted: vi.fn().mockReturnValue(true), + executeTool: vi.fn().mockResolvedValue({ success: true, output: { ok: true } }), + ensureHandlersRegistered: vi.fn(), +})) + +const { upsertAsyncToolCall, markAsyncToolRunning, completeAsyncToolCall } = vi.hoisted(() => ({ + upsertAsyncToolCall: vi.fn(), + markAsyncToolRunning: vi.fn(), + completeAsyncToolCall: vi.fn(), +})) + +vi.mock('@/lib/copilot/tool-executor', () => ({ + isSimExecuted, + executeTool, + ensureHandlersRegistered, +})) + +vi.mock('@/lib/copilot/async-runs/repository', async () => { + const actual = await vi.importActual( + '@/lib/copilot/async-runs/repository' + ) + return { + ...actual, + upsertAsyncToolCall, + markAsyncToolRunning, + completeAsyncToolCall, + } +}) + +import { + MothershipStreamV1EventType, + MothershipStreamV1TextChannel, + MothershipStreamV1ToolExecutor, + MothershipStreamV1ToolMode, + MothershipStreamV1ToolOutcome, + MothershipStreamV1ToolPhase, +} from '@/lib/copilot/generated/mothership-stream-v1' +import { Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' +import { sseHandlers, subAgentHandlers } from '@/lib/copilot/request/handlers' +import type { ExecutionContext, StreamEvent, StreamingContext } from '@/lib/copilot/request/types' + +describe('sse-handlers tool lifecycle', () => { + let context: StreamingContext + let execContext: ExecutionContext + + beforeEach(() => { + vi.clearAllMocks() + upsertAsyncToolCall.mockResolvedValue(null) + markAsyncToolRunning.mockResolvedValue(null) + completeAsyncToolCall.mockResolvedValue(null) + context = { + chatId: undefined, + messageId: 'msg-1', + accumulatedContent: '', + trace: new TraceCollector(), + contentBlocks: [], + toolCalls: new Map(), + pendingToolPromises: new Map(), + currentThinkingBlock: null, + isInThinkingBlock: false, + subAgentParentToolCallId: undefined, + subAgentParentStack: [], + subAgentContent: {}, + subAgentToolCalls: {}, + pendingContent: '', + streamComplete: false, + wasAborted: false, + errors: [], + } + execContext = { + userId: 'user-1', + workflowId: 'workflow-1', + } + }) + + it('executes tool_call and emits tool_result', async () => { + executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } }) + const onEvent = vi.fn() + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-1', + toolName: ReadTool.id, + arguments: { workflowId: 'workflow-1' }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { onEvent, interactive: false, timeout: 1000 } + ) + + // tool_call fires execution without awaiting (fire-and-forget for parallel execution), + // so we flush pending microtasks before asserting + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(executeTool).toHaveBeenCalledTimes(1) + expect(onEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: MothershipStreamV1EventType.tool, + payload: expect.objectContaining({ + toolCallId: 'tool-1', + success: true, + phase: MothershipStreamV1ToolPhase.result, + }), + }) + ) + + const updated = context.toolCalls.get('tool-1') + expect(updated?.status).toBe(MothershipStreamV1ToolOutcome.success) + expect(updated?.result?.output).toEqual({ ok: true }) + }) + + it('updates stored params when a subagent generating event is followed by the final tool call', async () => { + executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } }) + context.subAgentParentToolCallId = 'parent-1' + context.subAgentParentStack = ['parent-1'] + context.toolCalls.set('parent-1', { + id: 'parent-1', + name: 'build', + status: 'pending', + startTime: Date.now(), + }) + + await subAgentHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + scope: { lane: 'subagent', parentToolCallId: 'parent-1', agentId: 'build' }, + payload: { + toolCallId: 'sub-tool-1', + toolName: 'create_workflow', + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + status: 'generating', + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + + await subAgentHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + scope: { lane: 'subagent', parentToolCallId: 'parent-1', agentId: 'build' }, + payload: { + toolCallId: 'sub-tool-1', + toolName: 'create_workflow', + arguments: { name: 'Example Workflow' }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + status: 'executing', + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(executeTool).toHaveBeenCalledWith( + 'create_workflow', + { name: 'Example Workflow' }, + expect.any(Object) + ) + expect(context.toolCalls.get('sub-tool-1')?.params).toEqual({ name: 'Example Workflow' }) + expect(context.subAgentToolCalls['parent-1']?.[0]?.params).toEqual({ + name: 'Example Workflow', + }) + }) + + it('routes subagent text using the event scope parent tool call id', async () => { + context.subAgentParentToolCallId = 'wrong-parent' + context.subAgentContent['parent-1'] = '' + + await subAgentHandlers.text( + { + type: MothershipStreamV1EventType.text, + scope: { lane: 'subagent', parentToolCallId: 'parent-1', agentId: 'deploy' }, + payload: { + channel: MothershipStreamV1TextChannel.assistant, + text: 'hello from deploy', + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + + expect(context.subAgentContent['parent-1']).toBe('hello from deploy') + expect(context.contentBlocks.at(-1)).toEqual( + expect.objectContaining({ + type: 'subagent_text', + content: 'hello from deploy', + }) + ) + }) + + it('routes subagent tool calls using the event scope parent tool call id', async () => { + executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } }) + context.subAgentParentToolCallId = 'wrong-parent' + context.toolCalls.set('parent-1', { + id: 'parent-1', + name: 'deploy', + status: 'pending', + startTime: Date.now(), + }) + + await subAgentHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + scope: { lane: 'subagent', parentToolCallId: 'parent-1', agentId: 'deploy' }, + payload: { + toolCallId: 'sub-tool-scope-1', + toolName: 'read', + arguments: { path: 'workflow.json' }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(context.subAgentToolCalls['parent-1']?.[0]?.id).toBe('sub-tool-scope-1') + }) + + it('skips duplicate tool_call after result', async () => { + executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } }) + + const event = { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-dup', + toolName: ReadTool.id, + arguments: { workflowId: 'workflow-1' }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } + + await sseHandlers.tool(event as StreamEvent, context, execContext, { interactive: false }) + await new Promise((resolve) => setTimeout(resolve, 0)) + await sseHandlers.tool(event as StreamEvent, context, execContext, { interactive: false }) + + expect(executeTool).toHaveBeenCalledTimes(1) + }) + + it('marks an in-flight tool as cancelled when aborted mid-execution', async () => { + const abortController = new AbortController() + const userStopController = new AbortController() + execContext.abortSignal = abortController.signal + execContext.userStopSignal = userStopController.signal + + executeTool.mockImplementationOnce( + () => + new Promise((resolve) => { + setTimeout(() => resolve({ success: true, output: { ok: true } }), 0) + }) + ) + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-cancel', + toolName: ReadTool.id, + arguments: { workflowId: 'workflow-1' }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { + interactive: false, + timeout: 1000, + abortSignal: abortController.signal, + userStopSignal: userStopController.signal, + } + ) + + userStopController.abort() + abortController.abort() + await new Promise((resolve) => setTimeout(resolve, 10)) + + const updated = context.toolCalls.get('tool-cancel') + expect(updated?.status).toBe(MothershipStreamV1ToolOutcome.cancelled) + }) + + it('does not replace an in-flight pending promise on duplicate tool_call', async () => { + let resolveTool: ((value: { success: boolean; output: { ok: boolean } }) => void) | undefined + executeTool.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveTool = resolve + }) + ) + + const event = { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-inflight', + toolName: ReadTool.id, + arguments: { workflowId: 'workflow-1' }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } + + await sseHandlers.tool(event as StreamEvent, context, execContext, { interactive: false }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + const firstPromise = context.pendingToolPromises.get('tool-inflight') + expect(firstPromise).toBeDefined() + + await sseHandlers.tool(event as StreamEvent, context, execContext, { interactive: false }) + + expect(executeTool).toHaveBeenCalledTimes(1) + expect(context.pendingToolPromises.get('tool-inflight')).toBe(firstPromise) + + resolveTool?.({ success: true, output: { ok: true } }) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(context.pendingToolPromises.has('tool-inflight')).toBe(false) + }) + + it('still executes the tool when async row upsert fails', async () => { + upsertAsyncToolCall.mockRejectedValueOnce(new Error('db down')) + executeTool.mockResolvedValueOnce({ success: true, output: { ok: true } }) + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-upsert-fail', + toolName: ReadTool.id, + arguments: { workflowId: 'workflow-1' }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { onEvent: vi.fn(), interactive: false, timeout: 1000 } + ) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(executeTool).toHaveBeenCalledTimes(1) + expect(context.toolCalls.get('tool-upsert-fail')?.status).toBe( + MothershipStreamV1ToolOutcome.success + ) + }) + + it('does not execute a tool if a terminal tool_result arrives before local execution starts', async () => { + let resolveUpsert: ((value: null) => void) | undefined + upsertAsyncToolCall.mockImplementationOnce( + () => + new Promise((resolve) => { + resolveUpsert = resolve + }) + ) + const onEvent = vi.fn() + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-race', + toolName: ReadTool.id, + arguments: { workflowId: 'workflow-1' }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { onEvent, interactive: false, timeout: 1000 } + ) + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-race', + toolName: ReadTool.id, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.result, + success: true, + result: { ok: true }, + }, + } satisfies StreamEvent, + context, + execContext, + { onEvent, interactive: false, timeout: 1000 } + ) + + resolveUpsert?.(null) + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(executeTool).not.toHaveBeenCalled() + expect(context.toolCalls.get('tool-race')?.status).toBe(MothershipStreamV1ToolOutcome.success) + expect(context.toolCalls.get('tool-race')?.result?.output).toEqual({ ok: true }) + }) + + it('does not execute a tool if a tool_result arrives before the tool_call event', async () => { + const onEvent = vi.fn() + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-early-result', + toolName: ReadTool.id, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.result, + success: true, + result: { ok: true }, + }, + } satisfies StreamEvent, + context, + execContext, + { onEvent, interactive: false, timeout: 1000 } + ) + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-early-result', + toolName: ReadTool.id, + arguments: { workflowId: 'workflow-1' }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { onEvent, interactive: false, timeout: 1000 } + ) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(executeTool).not.toHaveBeenCalled() + expect(context.toolCalls.get('tool-early-result')?.status).toBe( + MothershipStreamV1ToolOutcome.success + ) + }) + + it('executes dynamic sim tools based on payload executor', async () => { + isSimExecuted.mockReturnValueOnce(false) + executeTool.mockResolvedValueOnce({ success: true, output: { emails: [] } }) + + await sseHandlers.tool( + { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: 'tool-dynamic-sim', + toolName: 'gmail_read', + arguments: { maxResults: 10 }, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.call, + }, + } satisfies StreamEvent, + context, + execContext, + { interactive: false, timeout: 1000 } + ) + + await new Promise((resolve) => setTimeout(resolve, 0)) + + expect(executeTool).toHaveBeenCalledWith('gmail_read', { maxResults: 10 }, expect.any(Object)) + expect(context.toolCalls.get('tool-dynamic-sim')?.status).toBe( + MothershipStreamV1ToolOutcome.success + ) + }) +}) diff --git a/apps/sim/lib/copilot/request/handlers/index.ts b/apps/sim/lib/copilot/request/handlers/index.ts new file mode 100644 index 00000000000..d416b1ace34 --- /dev/null +++ b/apps/sim/lib/copilot/request/handlers/index.ts @@ -0,0 +1,50 @@ +import { createLogger } from '@sim/logger' +import { MothershipStreamV1EventType } from '@/lib/copilot/generated/mothership-stream-v1' +import type { StreamEvent, StreamingContext } from '@/lib/copilot/request/types' +import { handleCompleteEvent } from './complete' +import { handleErrorEvent } from './error' +import { handleRunEvent } from './run' +import { handleSessionEvent } from './session' +import { handleSpanEvent } from './span' +import { handleTextEvent } from './text' +import { handleToolEvent } from './tool' +import type { StreamHandler } from './types' + +export type { StreamHandler, ToolScope } from './types' + +const logger = createLogger('CopilotHandlerRouting') + +export const sseHandlers: Record = { + [MothershipStreamV1EventType.session]: handleSessionEvent, + [MothershipStreamV1EventType.tool]: (e, c, ec, o) => handleToolEvent(e, c, ec, o, 'main'), + [MothershipStreamV1EventType.text]: handleTextEvent('main'), + [MothershipStreamV1EventType.run]: handleRunEvent, + [MothershipStreamV1EventType.complete]: handleCompleteEvent, + [MothershipStreamV1EventType.error]: handleErrorEvent, + [MothershipStreamV1EventType.span]: handleSpanEvent, +} + +export const subAgentHandlers: Record = { + [MothershipStreamV1EventType.text]: handleTextEvent('subagent'), + [MothershipStreamV1EventType.tool]: (e, c, ec, o) => handleToolEvent(e, c, ec, o, 'subagent'), + [MothershipStreamV1EventType.span]: handleSpanEvent, +} + +export function handleSubagentRouting(event: StreamEvent, context: StreamingContext): boolean { + if (event.scope?.lane !== 'subagent') return false + + // Keep the latest scoped parent on hand for legacy callers, but subagent + // handlers should prefer the event-local scope for correctness. + if (event.scope?.parentToolCallId) { + context.subAgentParentToolCallId = event.scope.parentToolCallId + } + + if (!context.subAgentParentToolCallId) { + logger.warn('Subagent event missing parent tool call', { + type: event.type, + subagent: event.scope?.agentId, + }) + return false + } + return true +} diff --git a/apps/sim/lib/copilot/request/handlers/run.ts b/apps/sim/lib/copilot/request/handlers/run.ts new file mode 100644 index 00000000000..9c9d41b0c3a --- /dev/null +++ b/apps/sim/lib/copilot/request/handlers/run.ts @@ -0,0 +1,70 @@ +import { createLogger } from '@sim/logger' +import { + MothershipStreamV1RunKind, + MothershipStreamV1ToolOutcome, +} from '@/lib/copilot/generated/mothership-stream-v1' +import { getEventData } from '@/lib/copilot/request/sse-utils' +import type { StreamHandler } from './types' +import { addContentBlock } from './types' + +const logger = createLogger('CopilotRunHandler') + +export const handleRunEvent: StreamHandler = (event, context) => { + const d = getEventData(event) + if (!d) return + + const kind = d?.kind as string | undefined + + if (kind === MothershipStreamV1RunKind.checkpoint_pause) { + const rawFrames = Array.isArray(d?.frames) ? d.frames : [] + const frames = rawFrames.map((f: Record) => ({ + parentToolCallId: String(f.parentToolCallId), + parentToolName: String(f.parentToolName ?? ''), + pendingToolIds: Array.isArray(f.pendingToolIds) + ? f.pendingToolIds.map((id: unknown) => String(id)) + : [], + })) + + context.awaitingAsyncContinuation = { + checkpointId: String(d?.checkpointId), + executionId: typeof d?.executionId === 'string' ? d.executionId : context.executionId, + runId: typeof d?.runId === 'string' && d.runId ? d.runId : context.runId, + pendingToolCallIds: Array.isArray(d?.pendingToolCallIds) + ? d.pendingToolCallIds.map((id) => String(id)) + : [], + frames: frames.length > 0 ? frames : undefined, + } + logger.info('Received checkpoint pause', { + checkpointId: context.awaitingAsyncContinuation.checkpointId, + executionId: context.awaitingAsyncContinuation.executionId, + runId: context.awaitingAsyncContinuation.runId, + pendingToolCallIds: context.awaitingAsyncContinuation.pendingToolCallIds, + frameCount: frames.length, + }) + context.streamComplete = true + return + } + + if (kind === MothershipStreamV1RunKind.compaction_start) { + addContentBlock(context, { + type: 'tool_call', + toolCall: { + id: `compaction-${Date.now()}`, + name: 'context_compaction', + status: 'executing', + }, + }) + return + } + + if (kind === MothershipStreamV1RunKind.compaction_done) { + addContentBlock(context, { + type: 'tool_call', + toolCall: { + id: `compaction-${Date.now()}`, + name: 'context_compaction', + status: MothershipStreamV1ToolOutcome.success, + }, + }) + } +} diff --git a/apps/sim/lib/copilot/request/handlers/session.ts b/apps/sim/lib/copilot/request/handlers/session.ts new file mode 100644 index 00000000000..72ad666a85a --- /dev/null +++ b/apps/sim/lib/copilot/request/handlers/session.ts @@ -0,0 +1,14 @@ +import { MothershipStreamV1SessionKind } from '@/lib/copilot/generated/mothership-stream-v1' +import { getEventData } from '@/lib/copilot/request/sse-utils' +import type { StreamHandler } from './types' + +export const handleSessionEvent: StreamHandler = (event, context, execContext) => { + const data = getEventData(event) + if (data?.kind === MothershipStreamV1SessionKind.chat) { + const chatId = data.chatId as string | undefined + context.chatId = chatId + if (chatId) { + execContext.chatId = chatId + } + } +} diff --git a/apps/sim/lib/copilot/request/handlers/span.ts b/apps/sim/lib/copilot/request/handlers/span.ts new file mode 100644 index 00000000000..e684b232582 --- /dev/null +++ b/apps/sim/lib/copilot/request/handlers/span.ts @@ -0,0 +1,3 @@ +import type { StreamHandler } from './types' + +export const handleSpanEvent: StreamHandler = () => {} diff --git a/apps/sim/lib/copilot/request/handlers/text.ts b/apps/sim/lib/copilot/request/handlers/text.ts new file mode 100644 index 00000000000..7c42b68516e --- /dev/null +++ b/apps/sim/lib/copilot/request/handlers/text.ts @@ -0,0 +1,54 @@ +import { + MothershipStreamV1SpanLifecycleEvent, + MothershipStreamV1TextChannel, +} from '@/lib/copilot/generated/mothership-stream-v1' +import { getEventData } from '@/lib/copilot/request/sse-utils' +import type { StreamHandler, ToolScope } from './types' +import { addContentBlock, getScopedParentToolCallId } from './types' + +export function handleTextEvent(scope: ToolScope): StreamHandler { + return (event, context) => { + const d = getEventData(event) + + if (scope === 'subagent') { + const parentToolCallId = getScopedParentToolCallId(event, context) + if (!parentToolCallId || d?.channel !== MothershipStreamV1TextChannel.assistant) return + const chunk = d?.text as string | undefined + if (!chunk) return + context.subAgentContent[parentToolCallId] = + (context.subAgentContent[parentToolCallId] || '') + chunk + addContentBlock(context, { type: 'subagent_text', content: chunk }) + return + } + + if (d?.channel === MothershipStreamV1TextChannel.thinking) { + const phase = d.phase as string | undefined + if (phase === MothershipStreamV1SpanLifecycleEvent.start) { + context.isInThinkingBlock = true + context.currentThinkingBlock = { + type: 'thinking', + content: '', + timestamp: Date.now(), + } + return + } + if (phase === MothershipStreamV1SpanLifecycleEvent.end) { + if (context.currentThinkingBlock) { + context.contentBlocks.push(context.currentThinkingBlock) + } + context.isInThinkingBlock = false + context.currentThinkingBlock = null + return + } + const chunk = d?.text as string | undefined + if (!chunk || !context.currentThinkingBlock) return + context.currentThinkingBlock.content = `${context.currentThinkingBlock.content || ''}${chunk}` + return + } + + const chunk = d?.text as string | undefined + if (!chunk) return + context.accumulatedContent += chunk + addContentBlock(context, { type: 'text', content: chunk }) + } +} diff --git a/apps/sim/lib/copilot/request/handlers/tool.ts b/apps/sim/lib/copilot/request/handlers/tool.ts new file mode 100644 index 00000000000..46e0d298276 --- /dev/null +++ b/apps/sim/lib/copilot/request/handlers/tool.ts @@ -0,0 +1,393 @@ +import { createLogger } from '@sim/logger' +import { upsertAsyncToolCall } from '@/lib/copilot/async-runs/repository' +import { STREAM_TIMEOUT_MS } from '@/lib/copilot/constants' +import { + MothershipStreamV1AsyncToolRecordStatus, + MothershipStreamV1ToolOutcome, + MothershipStreamV1ToolPhase, +} from '@/lib/copilot/generated/mothership-stream-v1' +import { TOOL_CALL_STATUS } from '@/lib/copilot/request/session' +import { + asRecord, + getEventData, + markToolResultSeen, + wasToolResultSeen, +} from '@/lib/copilot/request/sse-utils' +import { executeToolAndReport, waitForToolCompletion } from '@/lib/copilot/request/tools/executor' +import type { + ExecutionContext, + OrchestratorOptions, + StreamEvent, + StreamingContext, + ToolCallState, +} from '@/lib/copilot/request/types' +import { isSimExecuted } from '@/lib/copilot/tool-executor' +import { isWorkflowToolName } from '@/lib/copilot/tools/workflow-tools' +import type { ToolScope } from './types' +import { + abortPendingToolIfStreamDead, + addContentBlock, + emitSyntheticToolResult, + ensureTerminalToolCallState, + getEventUI, + getScopedParentToolCallId, + handleClientCompletion, + inferToolSuccess, + registerPendingToolPromise, +} from './types' + +const logger = createLogger('CopilotToolHandler') + +/** + * Unified tool event handler for both main and subagent scopes. + * + * The main vs subagent differences are: + * - Subagent requires a parentToolCallId and tracks tool calls in subAgentToolCalls + * - Subagent result phase also updates the subAgentToolCalls record + * - Subagent call phase stores in both subAgentToolCalls and context.toolCalls + * - Main call phase only stores in context.toolCalls + */ +export async function handleToolEvent( + event: StreamEvent, + context: StreamingContext, + execContext: ExecutionContext, + options: OrchestratorOptions, + scope: ToolScope +): Promise { + const isSubagent = scope === 'subagent' + const parentToolCallId = isSubagent ? getScopedParentToolCallId(event, context) : undefined + + if (isSubagent && !parentToolCallId) return + + const data = getEventData(event) + const phase = data?.phase as string | undefined + const toolCallId = (data?.toolCallId as string | undefined) || (data?.id as string | undefined) + if (!toolCallId) return + const toolName = + (data?.toolName as string | undefined) || + (data?.name as string | undefined) || + context.toolCalls.get(toolCallId)?.name || + '' + + if (phase === MothershipStreamV1ToolPhase.args_delta) { + return + } + + if (phase === MothershipStreamV1ToolPhase.result) { + handleResultPhase(context, data, toolCallId, toolName, isSubagent, parentToolCallId) + return + } + + handleCallPhase( + event, + context, + execContext, + options, + data, + toolCallId, + toolName, + isSubagent, + parentToolCallId, + scope + ) +} + +function handleResultPhase( + context: StreamingContext, + data: Record | undefined, + toolCallId: string, + toolName: string, + isSubagent: boolean, + parentToolCallId: string | undefined +): void { + const mainToolCall = ensureTerminalToolCallState(context, toolCallId, toolName) + const { success, hasResultData, hasError } = inferToolSuccess(data) + const status = + data?.status === MothershipStreamV1ToolOutcome.cancelled + ? MothershipStreamV1ToolOutcome.cancelled + : success + ? MothershipStreamV1ToolOutcome.success + : MothershipStreamV1ToolOutcome.error + const endTime = Date.now() + const result = hasResultData ? { success, output: data?.result || data?.data } : undefined + + if (isSubagent && parentToolCallId) { + const toolCalls = context.subAgentToolCalls[parentToolCallId] || [] + const subAgentToolCall = toolCalls.find((tc) => tc.id === toolCallId) + if (subAgentToolCall) { + subAgentToolCall.status = status + subAgentToolCall.endTime = endTime + if (result) subAgentToolCall.result = result + if (hasError) { + const resultObj = asRecord(data?.result) + subAgentToolCall.error = (data?.error || resultObj.error) as string | undefined + } + } + } + + mainToolCall.status = status + mainToolCall.endTime = endTime + if (result) mainToolCall.result = result + if (hasError) { + const resultObj = asRecord(data?.result) + mainToolCall.error = (data?.error || resultObj.error) as string | undefined + } + markToolResultSeen(toolCallId) +} + +async function handleCallPhase( + event: StreamEvent, + context: StreamingContext, + execContext: ExecutionContext, + options: OrchestratorOptions, + data: Record | undefined, + toolCallId: string, + toolName: string, + isSubagent: boolean, + parentToolCallId: string | undefined, + scope: ToolScope +): Promise { + const args = (data?.arguments || data?.input) as Record | undefined + const isGenerating = data?.status === TOOL_CALL_STATUS.generating + const isPartial = data?.partial === true || isGenerating + const existing = context.toolCalls.get(toolCallId) + + if (isSubagent) { + if (wasToolResultSeen(toolCallId) || existing?.endTime) { + if (existing && !existing.name && toolName) existing.name = toolName + if (existing && !existing.params && args) existing.params = args + return + } + } else { + if ( + existing?.endTime || + (existing && existing.status !== 'pending' && existing.status !== 'executing') + ) { + if (!existing.name && toolName) existing.name = toolName + if (!existing.params && args) existing.params = args + return + } + } + + if (isSubagent) { + registerSubagentToolCall(context, toolCallId, toolName, args, parentToolCallId!) + } else { + registerMainToolCall(context, toolCallId, toolName, args, existing) + } + + if (isPartial) return + if (!isSubagent && wasToolResultSeen(toolCallId)) return + if (context.pendingToolPromises.has(toolCallId) || existing?.status === 'executing') { + return + } + + const toolCall = context.toolCalls.get(toolCallId) + if (!toolCall) return + + const isGoHandledInternalRead = + toolName === 'read' && + typeof args?.path === 'string' && + (args.path as string).startsWith('internal/') + if (isGoHandledInternalRead) return + + const { clientExecutable, simExecutable, internal } = getEventUI(event) + const staticSimExecuted = isSimExecuted(toolName) + const willDispatch = !internal && (staticSimExecuted || simExecutable || clientExecutable) + logger.info('Tool call routing decision', { + toolCallId, + toolName, + scope, + isSubagent, + parentToolCallId, + executor: data?.executor, + clientExecutable, + simExecutable, + staticSimExecuted, + internal, + hasPendingPromise: context.pendingToolPromises.has(toolCallId), + existingStatus: existing?.status, + willDispatch, + }) + if (internal) return + if (!willDispatch) return + + await dispatchToolExecution( + toolCall, + toolCallId, + toolName, + args, + context, + execContext, + options, + clientExecutable, + scope + ) +} + +function registerSubagentToolCall( + context: StreamingContext, + toolCallId: string, + toolName: string, + args: Record | undefined, + parentToolCallId: string +): void { + if (!context.subAgentToolCalls[parentToolCallId]) { + context.subAgentToolCalls[parentToolCallId] = [] + } + let toolCall = context.toolCalls.get(toolCallId) + if (toolCall) { + if (!toolCall.name && toolName) toolCall.name = toolName + if (args && !toolCall.params) toolCall.params = args + } else { + toolCall = { + id: toolCallId, + name: toolName, + status: 'pending', + params: args, + startTime: Date.now(), + } + context.toolCalls.set(toolCallId, toolCall) + const parentToolCall = context.toolCalls.get(parentToolCallId) + addContentBlock(context, { + type: 'tool_call', + toolCall, + calledBy: parentToolCall?.name, + }) + } + + const subagentToolCalls = context.subAgentToolCalls[parentToolCallId] + const existingSubagentToolCall = subagentToolCalls.find((tc) => tc.id === toolCallId) + if (existingSubagentToolCall) { + if (!existingSubagentToolCall.name && toolName) existingSubagentToolCall.name = toolName + if (args && !existingSubagentToolCall.params) existingSubagentToolCall.params = args + } else { + subagentToolCalls.push(toolCall) + } +} + +function registerMainToolCall( + context: StreamingContext, + toolCallId: string, + toolName: string, + args: Record | undefined, + existing: ToolCallState | undefined +): void { + if (existing) { + if (args && !existing.params) existing.params = args + if ( + !context.contentBlocks.some((b) => b.type === 'tool_call' && b.toolCall?.id === toolCallId) + ) { + addContentBlock(context, { type: 'tool_call', toolCall: existing }) + } + } else { + const created: ToolCallState = { + id: toolCallId, + name: toolName, + status: 'pending', + params: args, + startTime: Date.now(), + } + context.toolCalls.set(toolCallId, created) + addContentBlock(context, { type: 'tool_call', toolCall: created }) + } +} + +async function dispatchToolExecution( + toolCall: ToolCallState, + toolCallId: string, + toolName: string, + args: Record | undefined, + context: StreamingContext, + execContext: ExecutionContext, + options: OrchestratorOptions, + clientExecutable: boolean, + scope: ToolScope +): Promise { + const scopeLabel = scope === 'subagent' ? 'subagent ' : '' + + const fireToolExecution = () => { + const pendingPromise = (async () => { + return executeToolAndReport(toolCallId, context, execContext, options) + })().catch((err) => { + logger.error(`Parallel ${scopeLabel}tool execution failed`, { + toolCallId, + toolName, + error: err instanceof Error ? err.message : String(err), + }) + return { + status: MothershipStreamV1ToolOutcome.error, + message: 'Tool execution failed', + data: { error: 'Tool execution failed' }, + } + }) + registerPendingToolPromise(context, toolCallId, pendingPromise) + } + + if (options.interactive === false) { + if (options.autoExecuteTools !== false) { + if (!abortPendingToolIfStreamDead(toolCall, toolCallId, options, context)) { + fireToolExecution() + } + } + return + } + + if (clientExecutable) { + const delegateWorkflowRunToClient = isWorkflowToolName(toolName) + if (isSimExecuted(toolName) && !delegateWorkflowRunToClient) { + if (!abortPendingToolIfStreamDead(toolCall, toolCallId, options, context)) { + fireToolExecution() + } + } else { + toolCall.status = 'executing' + const pendingPromise = (async () => { + await upsertAsyncToolCall({ + runId: context.runId || crypto.randomUUID(), + toolCallId, + toolName, + args, + status: MothershipStreamV1AsyncToolRecordStatus.running, + }).catch((err) => { + logger.warn(`Failed to persist async tool row for client-executable ${scopeLabel}tool`, { + toolCallId, + toolName, + error: err instanceof Error ? err.message : String(err), + }) + }) + const completion = await waitForToolCompletion( + toolCallId, + options.timeout || STREAM_TIMEOUT_MS, + options.abortSignal + ) + handleClientCompletion(toolCall, toolCallId, completion) + await emitSyntheticToolResult(toolCallId, toolCall.name, completion, options) + return ( + completion ?? { + status: MothershipStreamV1ToolOutcome.error, + message: 'Tool completion missing', + data: { error: 'Tool completion missing' }, + } + ) + })().catch((err) => { + logger.error(`Client-executable ${scopeLabel}tool wait failed`, { + toolCallId, + toolName, + error: err instanceof Error ? err.message : String(err), + }) + return { + status: MothershipStreamV1ToolOutcome.error, + message: 'Tool wait failed', + data: { error: 'Tool wait failed' }, + } + }) + registerPendingToolPromise(context, toolCallId, pendingPromise) + } + return + } + + if (options.autoExecuteTools !== false) { + if (!abortPendingToolIfStreamDead(toolCall, toolCallId, options, context)) { + fireToolExecution() + } + } +} diff --git a/apps/sim/lib/copilot/request/handlers/types.ts b/apps/sim/lib/copilot/request/handlers/types.ts new file mode 100644 index 00000000000..53722f7548f --- /dev/null +++ b/apps/sim/lib/copilot/request/handlers/types.ts @@ -0,0 +1,229 @@ +import { createLogger } from '@sim/logger' +import { + MothershipStreamV1EventType, + type MothershipStreamV1StreamScope, + MothershipStreamV1ToolExecutor, + MothershipStreamV1ToolMode, + MothershipStreamV1ToolOutcome, + MothershipStreamV1ToolPhase, +} from '@/lib/copilot/generated/mothership-stream-v1' +import { asRecord, getEventData, markToolResultSeen } from '@/lib/copilot/request/sse-utils' +import type { + ContentBlock, + ExecutionContext, + OrchestratorOptions, + StreamEvent, + StreamingContext, + ToolCallState, +} from '@/lib/copilot/request/types' + +export type StreamHandler = ( + event: StreamEvent, + context: StreamingContext, + execContext: ExecutionContext, + options: OrchestratorOptions +) => void | Promise + +export type ToolScope = MothershipStreamV1StreamScope['lane'] + +const logger = createLogger('CopilotHandlerHelpers') + +export function addContentBlock( + context: StreamingContext, + block: Omit +): void { + context.contentBlocks.push({ + ...block, + timestamp: Date.now(), + }) +} + +export function getScopedParentToolCallId( + event: StreamEvent, + context: StreamingContext +): string | undefined { + return event.scope?.parentToolCallId || context.subAgentParentToolCallId +} + +export function registerPendingToolPromise( + context: StreamingContext, + toolCallId: string, + pendingPromise: Promise<{ status: string; message?: string; data?: Record }> +): void { + context.pendingToolPromises.set(toolCallId, pendingPromise) + pendingPromise.finally(() => { + if (context.pendingToolPromises.get(toolCallId) === pendingPromise) { + context.pendingToolPromises.delete(toolCallId) + } + }) +} + +/** + * When the Sim->Go stream is aborted, avoid starting server-side tool work and + * unblock the Go async waiter with a terminal 499 completion. + */ +export function abortPendingToolIfStreamDead( + toolCall: ToolCallState, + toolCallId: string, + options: OrchestratorOptions, + context: StreamingContext +): boolean { + if (!options.abortSignal?.aborted && !context.wasAborted) { + return false + } + toolCall.status = MothershipStreamV1ToolOutcome.cancelled + toolCall.endTime = Date.now() + markToolResultSeen(toolCallId) + return true +} + +/** + * Extract the `ui` object from a Go SSE event. The Go backend enriches + * tool_call events with `ui: { requiresConfirmation, clientExecutable, ... }`. + */ +export function getEventUI(event: StreamEvent): { + requiresConfirmation: boolean + clientExecutable: boolean + simExecutable: boolean + internal: boolean + hidden: boolean +} { + const data = getEventData(event) + const raw = asRecord(data?.ui) + return { + requiresConfirmation: raw.requiresConfirmation === true || data?.requiresConfirmation === true, + clientExecutable: + raw.clientExecutable === true || data?.executor === MothershipStreamV1ToolExecutor.client, + simExecutable: data?.executor === MothershipStreamV1ToolExecutor.sim, + internal: raw.internal === true, + hidden: raw.hidden === true, + } +} + +/** + * Handle the completion signal from a client-executable tool. + * Shared by both main and subagent scopes. + */ +export function handleClientCompletion( + toolCall: ToolCallState, + toolCallId: string, + completion: { status: string; message?: string; data?: Record } | null +): void { + if (completion?.status === 'background') { + toolCall.status = MothershipStreamV1ToolOutcome.skipped + toolCall.result = completion?.data ? { success: true, output: completion.data } : undefined + toolCall.endTime = Date.now() + markToolResultSeen(toolCallId) + return + } + if (completion?.status === MothershipStreamV1ToolOutcome.rejected) { + toolCall.status = MothershipStreamV1ToolOutcome.rejected + toolCall.error = completion?.message || 'Tool rejected' + toolCall.result = { + success: false, + output: completion?.data ?? { error: toolCall.error }, + } + toolCall.endTime = Date.now() + markToolResultSeen(toolCallId) + return + } + if (completion?.status === MothershipStreamV1ToolOutcome.cancelled) { + toolCall.status = MothershipStreamV1ToolOutcome.cancelled + toolCall.error = completion?.message || 'Tool cancelled' + toolCall.result = { + success: false, + output: completion?.data ?? { error: toolCall.error }, + } + toolCall.endTime = Date.now() + markToolResultSeen(toolCallId) + return + } + const success = completion?.status === MothershipStreamV1ToolOutcome.success + toolCall.status = success + ? MothershipStreamV1ToolOutcome.success + : MothershipStreamV1ToolOutcome.error + toolCall.result = { + success, + output: completion?.data ?? (success ? {} : { error: completion?.message || 'Tool failed' }), + } + toolCall.error = success ? undefined : completion?.message || 'Tool failed' + toolCall.endTime = Date.now() + markToolResultSeen(toolCallId) +} + +/** + * Emit a synthetic tool_result SSE event to the client after a client-executable + * tool completes. The Go backend's actual tool_result is skipped (markToolResultSeen), + * so the client would never learn the outcome without this. + */ +export async function emitSyntheticToolResult( + toolCallId: string, + toolName: string, + completion: { status: string; message?: string; data?: Record } | null, + options: OrchestratorOptions +): Promise { + const success = completion?.status === MothershipStreamV1ToolOutcome.success + const isCancelled = completion?.status === MothershipStreamV1ToolOutcome.cancelled + + const resultPayload = isCancelled + ? { ...completion?.data, reason: 'user_cancelled', cancelledByUser: true } + : completion?.data + + try { + await options.onEvent?.({ + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId, + toolName, + executor: MothershipStreamV1ToolExecutor.client, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.result, + success, + result: resultPayload, + ...(completion?.status ? { status: completion.status } : {}), + ...(!success && completion?.message ? { error: completion.message } : {}), + }, + }) + } catch (error) { + logger.warn('Failed to emit synthetic tool_result', { + toolCallId, + toolName, + error: error instanceof Error ? error.message : String(error), + }) + } +} + +export function inferToolSuccess(data: Record | undefined): { + success: boolean + hasResultData: boolean + hasError: boolean +} { + const resultObj = asRecord(data?.result) + const hasExplicitSuccess = data?.success !== undefined || resultObj.success !== undefined + const explicitSuccess = data?.success ?? resultObj.success + const hasResultData = data?.result !== undefined || data?.data !== undefined + const hasError = !!data?.error || !!resultObj.error + const success = hasExplicitSuccess ? !!explicitSuccess : !hasError + return { success, hasResultData, hasError } +} + +export function ensureTerminalToolCallState( + context: StreamingContext, + toolCallId: string, + toolName: string +): ToolCallState { + const existing = context.toolCalls.get(toolCallId) + if (existing) { + return existing + } + + const toolCall: ToolCallState = { + id: toolCallId, + name: toolName || 'unknown_tool', + status: 'pending', + startTime: Date.now(), + } + context.toolCalls.set(toolCallId, toolCall) + addContentBlock(context, { type: 'tool_call', toolCall }) + return toolCall +} diff --git a/apps/sim/lib/copilot/request-helpers.ts b/apps/sim/lib/copilot/request/http.ts similarity index 63% rename from apps/sim/lib/copilot/request-helpers.ts rename to apps/sim/lib/copilot/request/http.ts index 01b46e9f721..3b0ae6c2ec3 100644 --- a/apps/sim/lib/copilot/request-helpers.ts +++ b/apps/sim/lib/copilot/request/http.ts @@ -1,8 +1,20 @@ +import type { NextRequest } from 'next/server' import { NextResponse } from 'next/server' import { getSession } from '@/lib/auth' +import { env } from '@/lib/core/config/env' +import { safeCompare } from '@/lib/core/security/encryption' import { generateRequestId } from '@/lib/core/utils/request' -export type { NotificationStatus } from '@/lib/copilot/types' +export const NotificationStatus = { + pending: 'pending', + success: 'success', + error: 'error', + accepted: 'accepted', + rejected: 'rejected', + background: 'background', + cancelled: 'cancelled', +} as const +export type NotificationStatus = (typeof NotificationStatus)[keyof typeof NotificationStatus] export interface CopilotAuthResult { userId: string | null @@ -61,3 +73,22 @@ export async function authenticateCopilotRequestSessionOnly(): Promise { + if (aborted) { + return handleAborted(publisher, runId, requestId) + } + if (!result.success) { + return handleError(result, publisher, runId, requestId) + } + return handleSuccess(publisher, runId, requestId) +} + +async function handleAborted( + publisher: StreamWriter, + runId: string, + requestId: string +): Promise { + logger.info(`[${requestId}] Stream aborted by explicit stop`) + if (!publisher.sawComplete) { + await publisher.publish({ + type: MothershipStreamV1EventType.complete, + payload: { status: MothershipStreamV1CompletionStatus.cancelled }, + }) + } + await publisher.flush() + await loggedRunStatusUpdate(runId, MothershipStreamV1CompletionStatus.cancelled, requestId, { + completedAt: new Date(), + }) +} + +async function handleError( + result: OrchestratorResult, + publisher: StreamWriter, + runId: string, + requestId: string +): Promise { + const errorMessage = + result.error || + result.errors?.[0] || + 'An unexpected error occurred while processing the response.' + + if (publisher.clientDisconnected) { + logger.info(`[${requestId}] Stream failed after client disconnect`, { error: errorMessage }) + } + logger.error(`[${requestId}] Orchestration returned failure`, { error: errorMessage }) + + await publisher.publish({ + type: MothershipStreamV1EventType.error, + payload: { + message: errorMessage, + error: errorMessage, + data: { displayMessage: 'An unexpected error occurred while processing the response.' }, + }, + }) + if (!publisher.sawComplete) { + await publisher.publish({ + type: MothershipStreamV1EventType.complete, + payload: { status: MothershipStreamV1CompletionStatus.error }, + }) + } + await publisher.flush() + await loggedRunStatusUpdate(runId, MothershipStreamV1CompletionStatus.error, requestId, { + completedAt: new Date(), + error: errorMessage, + }) +} + +async function handleSuccess( + publisher: StreamWriter, + runId: string, + requestId: string +): Promise { + if (!publisher.sawComplete) { + await publisher.publish({ + type: MothershipStreamV1EventType.complete, + payload: { status: MothershipStreamV1CompletionStatus.complete }, + }) + } + await publisher.flush() + await loggedRunStatusUpdate(runId, MothershipStreamV1CompletionStatus.complete, requestId, { + completedAt: new Date(), + }) +} + +async function loggedRunStatusUpdate( + runId: string, + status: Parameters[1], + requestId: string, + updates: Parameters[2] = {} +): Promise { + try { + await updateRunStatus(runId, status, updates) + } catch (error) { + logger.warn(`[${requestId}] Failed to update run status to ${status}`, { + runId, + error: error instanceof Error ? error.message : String(error), + }) + } +} diff --git a/apps/sim/lib/copilot/request/lifecycle/run.ts b/apps/sim/lib/copilot/request/lifecycle/run.ts new file mode 100644 index 00000000000..f5d15cf7673 --- /dev/null +++ b/apps/sim/lib/copilot/request/lifecycle/run.ts @@ -0,0 +1,427 @@ +import { createLogger } from '@sim/logger' +import { updateRunStatus } from '@/lib/copilot/async-runs/repository' +import { SIM_AGENT_API_URL, SIM_AGENT_VERSION } from '@/lib/copilot/constants' +import { + MothershipStreamV1EventType, + MothershipStreamV1RunKind, + MothershipStreamV1ToolOutcome, +} from '@/lib/copilot/generated/mothership-stream-v1' +import { createStreamingContext } from '@/lib/copilot/request/context/request-context' +import { buildToolCallSummaries } from '@/lib/copilot/request/context/result' +import { + BillingLimitError, + CopilotBackendError, + runStreamLoop, +} from '@/lib/copilot/request/go/stream' +import { handleBillingLimitResponse } from '@/lib/copilot/request/tools/billing' +import { executeToolAndReport } from '@/lib/copilot/request/tools/executor' +import type { TraceCollector } from '@/lib/copilot/request/trace' +import { RequestTraceV1SpanStatus } from '@/lib/copilot/request/trace' +import type { + ExecutionContext, + OrchestratorOptions, + OrchestratorResult, + StreamEvent, + StreamingContext, +} from '@/lib/copilot/request/types' +import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context' +import { env } from '@/lib/core/config/env' +import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' + +const logger = createLogger('CopilotLifecycle') + +const MAX_RESUME_ATTEMPTS = 3 +const RESUME_BACKOFF_MS = [250, 500, 1000] as const + +export interface CopilotLifecycleOptions extends OrchestratorOptions { + userId: string + workflowId?: string + workspaceId?: string + chatId?: string + executionId?: string + runId?: string + goRoute?: string + trace?: TraceCollector + simRequestId?: string +} + +export async function runCopilotLifecycle( + requestPayload: Record, + options: CopilotLifecycleOptions +): Promise { + const { + userId, + workflowId, + workspaceId, + chatId, + executionId, + runId, + goRoute = '/api/copilot', + } = options + + const execContext = await buildExecutionContext(requestPayload, { + userId, + workflowId, + workspaceId, + chatId, + executionId, + runId, + abortSignal: options.abortSignal, + }) + + const payloadMsgId = requestPayload?.messageId + const context = createStreamingContext({ + chatId, + executionId, + runId, + messageId: typeof payloadMsgId === 'string' ? payloadMsgId : crypto.randomUUID(), + ...(options.trace ? { trace: options.trace } : {}), + }) + + try { + await runCheckpointLoop(requestPayload, context, execContext, options, goRoute) + + const result: OrchestratorResult = { + success: context.errors.length === 0 && !context.wasAborted, + content: context.accumulatedContent, + contentBlocks: context.contentBlocks, + toolCalls: buildToolCallSummaries(context), + chatId: context.chatId, + requestId: context.requestId, + errors: context.errors.length ? context.errors : undefined, + usage: context.usage, + cost: context.cost, + } + await options.onComplete?.(result) + return result + } catch (error) { + const err = error instanceof Error ? error : new Error('Copilot orchestration failed') + logger.error('Copilot orchestration failed', { error: err.message }) + await options.onError?.(err) + return { + success: false, + content: '', + contentBlocks: [], + toolCalls: [], + chatId: context.chatId, + error: err.message, + } + } +} + +// --------------------------------------------------------------------------- +// Checkpoint loop – the core state machine +// --------------------------------------------------------------------------- + +async function runCheckpointLoop( + initialPayload: Record, + context: StreamingContext, + execContext: ExecutionContext, + options: CopilotLifecycleOptions, + initialRoute: string +): Promise { + let route = initialRoute + let payload: Record = initialPayload + let resumeAttempt = 0 + const callerOnEvent = options.onEvent + + for (;;) { + context.streamComplete = false + const isResume = route === '/api/tools/resume' + + if (isResume && isAborted(options, context)) { + cancelPendingTools(context) + context.awaitingAsyncContinuation = undefined + break + } + + const loopOptions = { + ...options, + onEvent: async (event: StreamEvent) => { + if ( + event.type === MothershipStreamV1EventType.run && + event.payload.kind === MothershipStreamV1RunKind.checkpoint_pause && + options.runId + ) { + try { + await updateRunStatus(options.runId, 'paused_waiting_for_tool') + } catch (error) { + logger.warn('Failed to mark run as paused_waiting_for_tool', { + runId: options.runId, + error: error instanceof Error ? error.message : String(error), + }) + } + } + await callerOnEvent?.(event) + }, + } + + const streamSpan = context.trace.startSpan( + isResume ? 'Sim → Go (Resume)' : 'Sim → Go Stream', + isResume ? 'lifecycle.resume' : 'sim.stream', + { + route, + isResume, + ...(isResume ? { attempt: resumeAttempt } : {}), + } + ) + context.trace.setActiveSpan(streamSpan) + + logger.info('Starting stream loop', { + route, + isResume, + resumeAttempt, + pendingToolPromises: context.pendingToolPromises.size, + toolCallCount: context.toolCalls.size, + hasCheckpoint: !!context.awaitingAsyncContinuation, + }) + + try { + await runStreamLoop( + `${SIM_AGENT_API_URL}${route}`, + { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}), + 'X-Client-Version': SIM_AGENT_VERSION, + ...(options.simRequestId ? { 'X-Sim-Request-ID': options.simRequestId } : {}), + }, + body: JSON.stringify(payload), + }, + context, + execContext, + loopOptions + ) + context.trace.endSpan(streamSpan) + resumeAttempt = 0 + } catch (streamError) { + context.trace.endSpan(streamSpan, RequestTraceV1SpanStatus.error) + if (streamError instanceof BillingLimitError) { + await handleBillingLimitResponse(streamError.userId, context, execContext, options) + break + } + if ( + isResume && + isRetryableStreamError(streamError) && + resumeAttempt < MAX_RESUME_ATTEMPTS - 1 + ) { + resumeAttempt++ + const backoff = RESUME_BACKOFF_MS[resumeAttempt - 1] ?? 1000 + logger.warn('Resume stream failed, retrying', { + attempt: resumeAttempt + 1, + maxAttempts: MAX_RESUME_ATTEMPTS, + backoffMs: backoff, + error: streamError instanceof Error ? streamError.message : String(streamError), + }) + await sleepWithAbort(backoff, options.abortSignal) + continue + } + throw streamError + } + + logger.info('Stream loop completed', { + route, + isResume, + isAborted: isAborted(options, context), + hasCheckpoint: !!context.awaitingAsyncContinuation, + checkpointId: context.awaitingAsyncContinuation?.checkpointId, + pendingToolPromises: context.pendingToolPromises.size, + streamComplete: context.streamComplete, + toolCallCount: context.toolCalls.size, + }) + + if (isAborted(options, context)) { + cancelPendingTools(context) + context.awaitingAsyncContinuation = undefined + break + } + + const continuation = context.awaitingAsyncContinuation + if (!continuation) break + + if (context.pendingToolPromises.size > 0) { + const waitSpan = context.trace.startSpan('Wait for Tools', 'lifecycle.wait_tools', { + checkpointId: continuation.checkpointId, + pendingCount: context.pendingToolPromises.size, + }) + logger.info('Waiting for in-flight tool executions before resume', { + checkpointId: continuation.checkpointId, + pendingCount: context.pendingToolPromises.size, + }) + await Promise.allSettled(context.pendingToolPromises.values()) + context.trace.endSpan(waitSpan) + } + + if (isAborted(options, context)) { + cancelPendingTools(context) + context.awaitingAsyncContinuation = undefined + break + } + + const undispatchedToolIds = continuation.pendingToolCallIds.filter((toolCallId) => { + const tool = context.toolCalls.get(toolCallId) + return ( + !!tool && + !tool.result && + !tool.error && + !context.pendingToolPromises.has(toolCallId) && + tool.status !== 'executing' + ) + }) + + if (undispatchedToolIds.length > 0) { + logger.warn('Checkpointed tools were never dispatched; executing before resume', { + checkpointId: continuation.checkpointId, + toolCallIds: undispatchedToolIds, + }) + await Promise.allSettled( + undispatchedToolIds.map((toolCallId) => + executeToolAndReport(toolCallId, context, execContext, options) + ) + ) + } + + const results: Array<{ + callId: string + name: string + data: unknown + success: boolean + }> = [] + for (const toolCallId of continuation.pendingToolCallIds) { + const tool = context.toolCalls.get(toolCallId) + if (!tool || (!tool.result && !tool.error)) { + logger.error('Missing tool result for pending tool call', { + toolCallId, + checkpointId: continuation.checkpointId, + hasToolEntry: !!tool, + toolName: tool?.name, + toolStatus: tool?.status, + hasPendingPromise: context.pendingToolPromises.has(toolCallId), + }) + throw new Error(`Cannot resume: missing result for pending tool call ${toolCallId}`) + } + results.push({ + callId: toolCallId, + name: tool.name || '', + data: tool.result?.output ?? (tool.error ? { error: tool.error } : { error: 'unknown' }), + success: tool.result?.success ?? false, + }) + } + + logger.info('Resuming with tool results', { + checkpointId: continuation.checkpointId, + runId: continuation.runId, + toolCount: results.length, + pendingToolCallIds: continuation.pendingToolCallIds, + frameCount: continuation.frames?.length ?? 0, + }) + + context.awaitingAsyncContinuation = undefined + route = '/api/tools/resume' + payload = { + streamId: context.messageId, + checkpointId: continuation.checkpointId, + results, + } + logger.info('Prepared resume request payload', { + route, + streamId: context.messageId, + checkpointId: continuation.checkpointId, + resultCount: results.length, + }) + } +} + +// --------------------------------------------------------------------------- +// Execution context builder +// --------------------------------------------------------------------------- + +async function buildExecutionContext( + requestPayload: Record, + params: { + userId: string + workflowId?: string + workspaceId?: string + chatId?: string + executionId?: string + runId?: string + abortSignal?: AbortSignal + } +): Promise { + const { userId, workflowId, workspaceId, chatId, executionId, runId, abortSignal } = params + const userTimezone = + typeof requestPayload?.userTimezone === 'string' ? requestPayload.userTimezone : undefined + + let execContext: ExecutionContext + if (workflowId) { + execContext = await prepareExecutionContext(userId, workflowId, chatId) + } else { + const decryptedEnvVars = await getEffectiveDecryptedEnv(userId, workspaceId) + execContext = { + userId, + workflowId: '', + workspaceId, + chatId, + decryptedEnvVars, + } + } + + if (userTimezone) execContext.userTimezone = userTimezone + execContext.executionId = executionId + execContext.runId = runId + execContext.abortSignal = abortSignal + return execContext +} + +// --------------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------------- + +function isAborted(options: CopilotLifecycleOptions, context: StreamingContext): boolean { + return !!(options.abortSignal?.aborted || context.wasAborted) +} + +function cancelPendingTools(context: StreamingContext): void { + for (const [, toolCall] of context.toolCalls) { + if (toolCall.status === 'pending' || toolCall.status === 'executing') { + toolCall.status = MothershipStreamV1ToolOutcome.cancelled + toolCall.endTime = Date.now() + toolCall.error = 'Stopped by user' + } + } +} + +function isRetryableStreamError(error: unknown): boolean { + if (error instanceof DOMException && error.name === 'AbortError') { + return false + } + if (error instanceof CopilotBackendError) { + return error.status !== undefined && error.status >= 500 + } + if (error instanceof TypeError) { + return true + } + return false +} + +function sleepWithAbort(ms: number, abortSignal?: AbortSignal): Promise { + if (!abortSignal) { + return new Promise((resolve) => setTimeout(resolve, ms)) + } + if (abortSignal.aborted) { + return Promise.resolve() + } + return new Promise((resolve) => { + const timeoutId = setTimeout(() => { + abortSignal.removeEventListener('abort', onAbort) + resolve() + }, ms) + const onAbort = () => { + clearTimeout(timeoutId) + abortSignal.removeEventListener('abort', onAbort) + resolve() + } + abortSignal.addEventListener('abort', onAbort, { once: true }) + }) +} diff --git a/apps/sim/lib/copilot/request/lifecycle/start.test.ts b/apps/sim/lib/copilot/request/lifecycle/start.test.ts new file mode 100644 index 00000000000..de610ca6ab4 --- /dev/null +++ b/apps/sim/lib/copilot/request/lifecycle/start.test.ts @@ -0,0 +1,179 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { MothershipStreamV1EventType } from '@/lib/copilot/generated/mothership-stream-v1' + +const { + runCopilotLifecycle, + createRunSegment, + updateRunStatus, + resetBuffer, + allocateCursor, + appendEvent, + cleanupAbortMarker, + hasAbortMarker, + releasePendingChatStream, +} = vi.hoisted(() => ({ + runCopilotLifecycle: vi.fn(), + createRunSegment: vi.fn(), + updateRunStatus: vi.fn(), + resetBuffer: vi.fn(), + allocateCursor: vi.fn(), + appendEvent: vi.fn(), + cleanupAbortMarker: vi.fn(), + hasAbortMarker: vi.fn(), + releasePendingChatStream: vi.fn(), +})) + +vi.mock('@/lib/copilot/request/lifecycle/continue', () => ({ + runCopilotLifecycle, +})) + +vi.mock('@/lib/copilot/async-runs/repository', () => ({ + createRunSegment, + updateRunStatus, +})) + +let mockPublisherController: ReadableStreamDefaultController | null = null + +vi.mock('@/lib/copilot/request/session', () => ({ + resetBuffer, + allocateCursor, + appendEvent, + cleanupAbortMarker, + hasAbortMarker, + releasePendingChatStream, + registerActiveStream: vi.fn(), + unregisterActiveStream: vi.fn(), + startAbortPoller: vi.fn().mockReturnValue(setInterval(() => {}, 999999)), + SSE_RESPONSE_HEADERS: {}, + StreamWriter: vi.fn().mockImplementation(() => ({ + attach: vi.fn().mockImplementation((ctrl: ReadableStreamDefaultController) => { + mockPublisherController = ctrl + }), + startKeepalive: vi.fn(), + stopKeepalive: vi.fn(), + flush: vi.fn(), + close: vi.fn().mockImplementation(() => { + try { + mockPublisherController?.close() + } catch { + // already closed + } + }), + markDisconnected: vi.fn(), + publish: vi.fn().mockImplementation(async (event: Record) => { + appendEvent(event) + }), + get clientDisconnected() { + return false + }, + get sawComplete() { + return false + }, + })), +})) +vi.mock('@/lib/copilot/request/session/sse', () => ({ + SSE_RESPONSE_HEADERS: {}, +})) + +vi.mock('@sim/db', () => ({ + db: { + update: vi.fn(() => ({ + set: vi.fn(() => ({ + where: vi.fn(), + })), + })), + }, +})) + +vi.mock('@/lib/copilot/tasks', () => ({ + taskPubSub: null, +})) + +import { createSSEStream } from './start' + +async function drainStream(stream: ReadableStream) { + const reader = stream.getReader() + while (true) { + const { done } = await reader.read() + if (done) break + } +} + +describe('createSSEStream terminal error handling', () => { + beforeEach(() => { + vi.clearAllMocks() + resetBuffer.mockResolvedValue(undefined) + allocateCursor + .mockResolvedValueOnce({ seq: 1, cursor: '1' }) + .mockResolvedValueOnce({ seq: 2, cursor: '2' }) + .mockResolvedValueOnce({ seq: 3, cursor: '3' }) + appendEvent.mockImplementation(async (event: unknown) => event) + cleanupAbortMarker.mockResolvedValue(undefined) + hasAbortMarker.mockResolvedValue(false) + releasePendingChatStream.mockResolvedValue(undefined) + createRunSegment.mockResolvedValue(null) + updateRunStatus.mockResolvedValue(null) + }) + + it('writes a terminal error event before close when orchestration returns success=false', async () => { + runCopilotLifecycle.mockResolvedValue({ + success: false, + error: 'resume failed', + content: '', + contentBlocks: [], + toolCalls: [], + }) + + const stream = createSSEStream({ + requestPayload: { message: 'hello' }, + userId: 'user-1', + streamId: 'stream-1', + executionId: 'exec-1', + runId: 'run-1', + currentChat: null, + isNewChat: false, + message: 'hello', + titleModel: 'gpt-5.4', + requestId: 'req-1', + orchestrateOptions: {}, + }) + + await drainStream(stream) + + expect(appendEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: MothershipStreamV1EventType.error, + }) + ) + }) + + it('writes the thrown terminal error event before close for replay durability', async () => { + runCopilotLifecycle.mockRejectedValue(new Error('kaboom')) + + const stream = createSSEStream({ + requestPayload: { message: 'hello' }, + userId: 'user-1', + streamId: 'stream-1', + executionId: 'exec-1', + runId: 'run-1', + currentChat: null, + isNewChat: false, + message: 'hello', + titleModel: 'gpt-5.4', + requestId: 'req-1', + orchestrateOptions: {}, + }) + + await drainStream(stream) + + expect(appendEvent).toHaveBeenCalledWith( + expect.objectContaining({ + type: MothershipStreamV1EventType.error, + }) + ) + }) +}) diff --git a/apps/sim/lib/copilot/request/lifecycle/start.ts b/apps/sim/lib/copilot/request/lifecycle/start.ts new file mode 100644 index 00000000000..7654d031125 --- /dev/null +++ b/apps/sim/lib/copilot/request/lifecycle/start.ts @@ -0,0 +1,310 @@ +import { db } from '@sim/db' +import { copilotChats } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { eq } from 'drizzle-orm' +import { createRunSegment } from '@/lib/copilot/async-runs/repository' +import { SIM_AGENT_API_URL } from '@/lib/copilot/constants' +import { + MothershipStreamV1EventType, + MothershipStreamV1SessionKind, +} from '@/lib/copilot/generated/mothership-stream-v1' +import { RequestTraceV1Outcome } from '@/lib/copilot/generated/request-trace-v1' +import { finalizeStream } from '@/lib/copilot/request/lifecycle/finalize' +import type { CopilotLifecycleOptions } from '@/lib/copilot/request/lifecycle/run' +import { runCopilotLifecycle } from '@/lib/copilot/request/lifecycle/run' +import { + cleanupAbortMarker, + registerActiveStream, + releasePendingChatStream, + resetBuffer, + StreamWriter, + startAbortPoller, + unregisterActiveStream, +} from '@/lib/copilot/request/session' +import { SSE_RESPONSE_HEADERS } from '@/lib/copilot/request/session/sse' +import { reportTrace, TraceCollector } from '@/lib/copilot/request/trace' +import { taskPubSub } from '@/lib/copilot/tasks' +import { env } from '@/lib/core/config/env' + +export { SSE_RESPONSE_HEADERS } + +const logger = createLogger('CopilotChatStreaming') + +export interface StreamingOrchestrationParams { + requestPayload: Record + userId: string + streamId: string + executionId: string + runId: string + chatId?: string + currentChat: any + isNewChat: boolean + message: string + titleModel: string + titleProvider?: string + requestId: string + workspaceId?: string + orchestrateOptions: Omit +} + +export function createSSEStream(params: StreamingOrchestrationParams): ReadableStream { + const { + requestPayload, + userId, + streamId, + executionId, + runId, + chatId, + currentChat, + isNewChat, + message, + titleModel, + titleProvider, + requestId, + workspaceId, + orchestrateOptions, + } = params + + const abortController = new AbortController() + registerActiveStream(streamId, abortController) + + const publisher = new StreamWriter({ streamId, chatId, requestId }) + + const collector = new TraceCollector() + + return new ReadableStream({ + async start(controller) { + publisher.attach(controller) + + const requestSpan = collector.startSpan('Mothership Request', 'request', { + streamId, + chatId, + runId, + }) + let outcome: 'success' | 'error' | 'cancelled' = 'error' + let lifecycleResult: + | { + usage?: { prompt: number; completion: number } + cost?: { input: number; output: number; total: number } + } + | undefined + + await resetBuffer(streamId) + + if (chatId) { + createRunSegment({ + id: runId, + executionId, + chatId, + userId, + workflowId: (requestPayload.workflowId as string | undefined) || null, + workspaceId, + streamId, + model: (requestPayload.model as string | undefined) || null, + provider: (requestPayload.provider as string | undefined) || null, + requestContext: { requestId }, + }).catch((error) => { + logger.warn(`[${requestId}] Failed to create copilot run segment`, { + error: error instanceof Error ? error.message : String(error), + }) + }) + } + + const abortPoller = startAbortPoller(streamId, abortController, { requestId }) + publisher.startKeepalive() + + if (chatId) { + publisher.publish({ + type: MothershipStreamV1EventType.session, + payload: { + kind: MothershipStreamV1SessionKind.chat, + chatId, + }, + }) + } + + fireTitleGeneration({ + chatId, + currentChat, + isNewChat, + message, + titleModel, + titleProvider, + workspaceId, + requestId, + publisher, + }) + + try { + const result = await runCopilotLifecycle(requestPayload, { + ...orchestrateOptions, + executionId, + runId, + trace: collector, + simRequestId: requestId, + abortSignal: abortController.signal, + onEvent: async (event) => { + await publisher.publish(event) + }, + }) + + lifecycleResult = result + outcome = abortController.signal.aborted + ? RequestTraceV1Outcome.cancelled + : result.success + ? RequestTraceV1Outcome.success + : RequestTraceV1Outcome.error + await finalizeStream(result, publisher, runId, abortController.signal.aborted, requestId) + } catch (error) { + outcome = abortController.signal.aborted + ? RequestTraceV1Outcome.cancelled + : RequestTraceV1Outcome.error + if (publisher.clientDisconnected) { + logger.info(`[${requestId}] Stream errored after client disconnect`, { + error: error instanceof Error ? error.message : 'Stream error', + }) + } + logger.error(`[${requestId}] Unexpected orchestration error:`, error) + + const syntheticResult = { + success: false as const, + content: '', + contentBlocks: [], + toolCalls: [], + error: 'An unexpected error occurred while processing the response.', + } + await finalizeStream( + syntheticResult, + publisher, + runId, + abortController.signal.aborted, + requestId + ) + } finally { + collector.endSpan( + requestSpan, + outcome === RequestTraceV1Outcome.success + ? 'ok' + : outcome === RequestTraceV1Outcome.cancelled + ? 'cancelled' + : 'error' + ) + + clearInterval(abortPoller) + try { + await publisher.close() + } catch (error) { + logger.warn(`[${requestId}] Failed to flush stream persistence during close`, { + error: error instanceof Error ? error.message : String(error), + }) + } + unregisterActiveStream(streamId) + if (chatId) { + await releasePendingChatStream(chatId, streamId) + } + await cleanupAbortMarker(streamId) + + const trace = collector.build({ + outcome: outcome as 'success' | 'error' | 'cancelled', + simRequestId: requestId, + streamId, + chatId, + runId, + executionId, + usage: lifecycleResult?.usage, + cost: lifecycleResult?.cost, + }) + reportTrace(trace).catch(() => {}) + } + }, + cancel() { + publisher.markDisconnected() + }, + }) +} + +// --------------------------------------------------------------------------- +// Title generation (fire-and-forget side effect) +// --------------------------------------------------------------------------- + +function fireTitleGeneration(params: { + chatId?: string + currentChat: any + isNewChat: boolean + message: string + titleModel: string + titleProvider?: string + workspaceId?: string + requestId: string + publisher: StreamWriter +}): void { + const { + chatId, + currentChat, + isNewChat, + message, + titleModel, + titleProvider, + workspaceId, + requestId, + publisher, + } = params + if (!chatId || currentChat?.title || !isNewChat) return + + requestChatTitle({ message, model: titleModel, provider: titleProvider }) + .then(async (title) => { + if (!title) return + await db.update(copilotChats).set({ title }).where(eq(copilotChats.id, chatId)) + await publisher.publish({ + type: MothershipStreamV1EventType.session, + payload: { kind: MothershipStreamV1SessionKind.title, title }, + }) + if (workspaceId) { + taskPubSub?.publishStatusChanged({ workspaceId, chatId, type: 'renamed' }) + } + }) + .catch((error) => { + logger.error(`[${requestId}] Title generation failed:`, error) + }) +} + +// --------------------------------------------------------------------------- +// Chat title helper +// --------------------------------------------------------------------------- + +export async function requestChatTitle(params: { + message: string + model: string + provider?: string +}): Promise { + const { message, model, provider } = params + if (!message || !model) return null + + const headers: Record = { 'Content-Type': 'application/json' } + if (env.COPILOT_API_KEY) { + headers['x-api-key'] = env.COPILOT_API_KEY + } + + try { + const response = await fetch(`${SIM_AGENT_API_URL}/api/generate-chat-title`, { + method: 'POST', + headers, + body: JSON.stringify({ message, model, ...(provider ? { provider } : {}) }), + }) + + const payload = await response.json().catch(() => ({})) + if (!response.ok) { + logger.warn('Failed to generate chat title via copilot backend', { + status: response.status, + error: payload, + }) + return null + } + + const title = typeof payload?.title === 'string' ? payload.title.trim() : '' + return title || null + } catch (error) { + logger.error('Error generating chat title:', error) + return null + } +} diff --git a/apps/sim/lib/copilot/request/session/abort.ts b/apps/sim/lib/copilot/request/session/abort.ts new file mode 100644 index 00000000000..3bf9cbd7bb4 --- /dev/null +++ b/apps/sim/lib/copilot/request/session/abort.ts @@ -0,0 +1,234 @@ +import { createLogger } from '@sim/logger' +import { acquireLock, getRedisClient, releaseLock } from '@/lib/core/config/redis' +import { clearAbortMarker, hasAbortMarker, writeAbortMarker } from './buffer' + +const logger = createLogger('SessionAbort') + +const activeStreams = new Map() +const pendingChatStreams = new Map< + string, + { promise: Promise; resolve: () => void; streamId: string } +>() + +const DEFAULT_ABORT_POLL_MS = 1000 +const CHAT_STREAM_LOCK_TTL_SECONDS = 2 * 60 * 60 + +function registerPendingChatStream(chatId: string, streamId: string): void { + let resolve!: () => void + const promise = new Promise((r) => { + resolve = r + }) + pendingChatStreams.set(chatId, { promise, resolve, streamId }) +} + +function resolvePendingChatStream(chatId: string, streamId: string): void { + const entry = pendingChatStreams.get(chatId) + if (entry && entry.streamId === streamId) { + entry.resolve() + pendingChatStreams.delete(chatId) + } +} + +function getChatStreamLockKey(chatId: string): string { + return `copilot:chat-stream-lock:${chatId}` +} + +export function registerActiveStream(streamId: string, controller: AbortController): void { + activeStreams.set(streamId, controller) +} + +export function unregisterActiveStream(streamId: string): void { + activeStreams.delete(streamId) +} + +export async function waitForPendingChatStream( + chatId: string, + timeoutMs = 5_000, + expectedStreamId?: string +): Promise { + const redis = getRedisClient() + const deadline = Date.now() + timeoutMs + + for (;;) { + const entry = pendingChatStreams.get(chatId) + const localPending = !!entry && (!expectedStreamId || entry.streamId === expectedStreamId) + + if (redis) { + try { + const ownerStreamId = await redis.get(getChatStreamLockKey(chatId)) + const lockReleased = + !ownerStreamId || (expectedStreamId !== undefined && ownerStreamId !== expectedStreamId) + if (!localPending && lockReleased) { + return true + } + } catch (error) { + logger.warn('Failed to inspect chat stream lock while waiting', { + chatId, + expectedStreamId, + error: error instanceof Error ? error.message : String(error), + }) + } + } else if (!localPending) { + return true + } + + if (Date.now() >= deadline) { + return false + } + await new Promise((resolve) => setTimeout(resolve, 200)) + } +} + +export async function getPendingChatStreamId(chatId: string): Promise { + const localEntry = pendingChatStreams.get(chatId) + if (localEntry?.streamId) { + return localEntry.streamId + } + + const redis = getRedisClient() + if (!redis) { + return null + } + + try { + return (await redis.get(getChatStreamLockKey(chatId))) || null + } catch (error) { + logger.warn('Failed to load chat stream lock owner', { + chatId, + error: error instanceof Error ? error.message : String(error), + }) + return null + } +} + +export async function releasePendingChatStream(chatId: string, streamId: string): Promise { + try { + await releaseLock(getChatStreamLockKey(chatId), streamId) + } catch (error) { + logger.warn('Failed to release chat stream lock', { + chatId, + streamId, + error: error instanceof Error ? error.message : String(error), + }) + } finally { + resolvePendingChatStream(chatId, streamId) + } +} + +export async function acquirePendingChatStream( + chatId: string, + streamId: string, + timeoutMs = 5_000 +): Promise { + const redis = getRedisClient() + if (redis) { + const deadline = Date.now() + timeoutMs + for (;;) { + try { + const acquired = await acquireLock( + getChatStreamLockKey(chatId), + streamId, + CHAT_STREAM_LOCK_TTL_SECONDS + ) + if (acquired) { + registerPendingChatStream(chatId, streamId) + return true + } + if (!pendingChatStreams.has(chatId)) { + const ownerStreamId = await redis.get(getChatStreamLockKey(chatId)) + if (ownerStreamId) { + const settled = await waitForPendingChatStream(chatId, 0, ownerStreamId) + if (settled) { + continue + } + } + } + } catch (error) { + logger.warn('Failed to acquire chat stream lock', { + chatId, + streamId, + error: error instanceof Error ? error.message : String(error), + }) + } + + if (Date.now() >= deadline) { + return false + } + await new Promise((resolve) => setTimeout(resolve, 200)) + } + } + + for (;;) { + const existing = pendingChatStreams.get(chatId) + if (!existing) { + registerPendingChatStream(chatId, streamId) + return true + } + + const settled = await Promise.race([ + existing.promise.then(() => true), + new Promise((resolve) => setTimeout(() => resolve(false), timeoutMs)), + ]) + if (!settled) { + return false + } + } +} + +/** + * Returns `true` if it aborted an in-process controller, + * `false` if it only wrote the marker (no local controller found). + */ +export async function abortActiveStream(streamId: string): Promise { + await writeAbortMarker(streamId) + const controller = activeStreams.get(streamId) + if (!controller) return false + controller.abort() + activeStreams.delete(streamId) + return true +} + +const pollingStreams = new Set() + +export function startAbortPoller( + streamId: string, + abortController: AbortController, + options?: { pollMs?: number; requestId?: string } +): ReturnType { + const pollMs = options?.pollMs ?? DEFAULT_ABORT_POLL_MS + const requestId = options?.requestId + + return setInterval(() => { + if (pollingStreams.has(streamId)) return + pollingStreams.add(streamId) + + void (async () => { + try { + const shouldAbort = await hasAbortMarker(streamId) + if (shouldAbort && !abortController.signal.aborted) { + abortController.abort() + await clearAbortMarker(streamId) + } + } catch (error) { + logger.warn('Failed to poll stream abort marker', { + streamId, + ...(requestId ? { requestId } : {}), + error: error instanceof Error ? error.message : String(error), + }) + } finally { + pollingStreams.delete(streamId) + } + })() + }, pollMs) +} + +export async function cleanupAbortMarker(streamId: string): Promise { + try { + await clearAbortMarker(streamId) + } catch (error) { + logger.warn('Failed to clear stream abort marker during cleanup', { + streamId, + error: error instanceof Error ? error.message : String(error), + }) + } +} diff --git a/apps/sim/lib/copilot/request/session/buffer.test.ts b/apps/sim/lib/copilot/request/session/buffer.test.ts new file mode 100644 index 00000000000..9ce631bd16f --- /dev/null +++ b/apps/sim/lib/copilot/request/session/buffer.test.ts @@ -0,0 +1,148 @@ +/** + * @vitest-environment node + */ + +import { loggerMock } from '@sim/testing' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + MothershipStreamV1EventType, + MothershipStreamV1TextChannel, +} from '@/lib/copilot/generated/mothership-stream-v1' +import { createEvent } from '@/lib/copilot/request/session/event' + +vi.mock('@sim/logger', () => loggerMock) + +type StoredEnvelope = { + score: number + value: string +} + +const createRedisStub = () => { + const counters = new Map() + const values = new Map() + const sortedSets = new Map() + + const api = { + incr: vi.fn().mockImplementation((key: string) => { + const next = (counters.get(key) ?? 0) + 1 + counters.set(key, next) + return next + }), + expire: vi.fn().mockResolvedValue(1), + del: vi.fn().mockImplementation((...keys: string[]) => { + for (const key of keys) { + values.delete(key) + sortedSets.delete(key) + counters.delete(key) + } + return Promise.resolve(keys.length) + }), + zadd: vi.fn().mockImplementation((key: string, score: number, value: string) => { + const entries = sortedSets.get(key) ?? [] + entries.push({ score, value }) + sortedSets.set(key, entries) + return Promise.resolve(1) + }), + zremrangebyrank: vi.fn().mockImplementation((key: string, start: number, stop: number) => { + const entries = [...(sortedSets.get(key) ?? [])].sort((a, b) => a.score - b.score) + const normalizedStart = start < 0 ? Math.max(entries.length + start, 0) : start + const normalizedStop = stop < 0 ? entries.length + stop : stop + const next = entries.filter( + (_entry, index) => index < normalizedStart || index > normalizedStop + ) + sortedSets.set(key, next) + return Promise.resolve(1) + }), + zrangebyscore: vi.fn().mockImplementation((key: string, min: number, max: string) => { + const upperBound = max === '+inf' ? Number.POSITIVE_INFINITY : Number(max) + const entries = [...(sortedSets.get(key) ?? [])] + .filter((entry) => entry.score >= min && entry.score <= upperBound) + .sort((a, b) => a.score - b.score) + .map((entry) => entry.value) + return Promise.resolve(entries) + }), + set: vi.fn().mockImplementation((key: string, value: string) => { + values.set(key, value) + return Promise.resolve('OK') + }), + get: vi.fn().mockImplementation((key: string) => Promise.resolve(values.get(key) ?? null)), + pipeline: vi.fn().mockImplementation(() => { + const operations: Array<() => Promise> = [] + const pipeline = { + zadd: (...args: [string, number, string]) => { + operations.push(() => api.zadd(...args)) + return pipeline + }, + expire: (...args: [string, number]) => { + operations.push(() => api.expire(...args)) + return pipeline + }, + set: (...args: [string, string, 'EX', number]) => { + operations.push(() => api.set(args[0], args[1])) + return pipeline + }, + zremrangebyrank: (...args: [string, number, number]) => { + operations.push(() => api.zremrangebyrank(...args)) + return pipeline + }, + exec: vi.fn().mockImplementation(async () => { + const results: Array<[null, unknown]> = [] + for (const operation of operations) { + results.push([null, await operation()]) + } + return results + }), + } + return pipeline + }), + } + + return api +} + +let mockRedis: ReturnType + +vi.mock('@/lib/core/config/redis', () => ({ + getRedisClient: () => mockRedis, +})) + +import { allocateCursor, appendEvent, readEvents } from '@/lib/copilot/request/session/buffer' + +describe('mothership-stream-outbox', () => { + beforeEach(() => { + mockRedis = createRedisStub() + vi.clearAllMocks() + }) + + it.concurrent('replays envelopes after a given cursor', async () => { + const firstCursor = await allocateCursor('stream-1') + const secondCursor = await allocateCursor('stream-1') + + await appendEvent( + createEvent({ + streamId: 'stream-1', + cursor: firstCursor.cursor, + seq: firstCursor.seq, + requestId: 'req-1', + type: MothershipStreamV1EventType.text, + payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'hello' }, + }) + ) + await appendEvent( + createEvent({ + streamId: 'stream-1', + cursor: secondCursor.cursor, + seq: secondCursor.seq, + requestId: 'req-1', + type: MothershipStreamV1EventType.text, + payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'world' }, + }) + ) + + const allEvents = await readEvents('stream-1', '0') + expect(allEvents.map((entry) => entry.payload.text)).toEqual(['hello', 'world']) + + const replayed = await readEvents('stream-1', '1') + expect(replayed.map((entry) => entry.payload.text)).toEqual(['world']) + }) +}) diff --git a/apps/sim/lib/copilot/request/session/buffer.ts b/apps/sim/lib/copilot/request/session/buffer.ts new file mode 100644 index 00000000000..89e661366ce --- /dev/null +++ b/apps/sim/lib/copilot/request/session/buffer.ts @@ -0,0 +1,230 @@ +import { createLogger } from '@sim/logger' +import type { MothershipStreamV1EventEnvelope } from '@/lib/copilot/generated/mothership-stream-v1' +import { env } from '@/lib/core/config/env' +import { getRedisClient } from '@/lib/core/config/redis' + +const logger = createLogger('SessionBuffer') + +const STREAM_OUTBOX_PREFIX = 'mothership_stream:' +const DEFAULT_TTL_SECONDS = 60 * 60 +const DEFAULT_EVENT_LIMIT = 5_000 +const RETRY_DELAYS_MS = [0, 50, 150] as const + +type RedisOperationMetadata = { + operation: string + streamId: string +} + +function getEventsKey(streamId: string) { + return `${STREAM_OUTBOX_PREFIX}${streamId}:events` +} + +function getSeqKey(streamId: string) { + return `${STREAM_OUTBOX_PREFIX}${streamId}:seq` +} + +function getAbortKey(streamId: string) { + return `${STREAM_OUTBOX_PREFIX}${streamId}:abort` +} + +export type StreamConfig = { + ttlSeconds: number + eventLimit: number +} + +export function getStreamConfig(): StreamConfig { + return { + ttlSeconds: parsePositiveNumber(env.COPILOT_STREAM_TTL_SECONDS, DEFAULT_TTL_SECONDS), + eventLimit: parsePositiveNumber(env.COPILOT_STREAM_EVENT_LIMIT, DEFAULT_EVENT_LIMIT), + } +} + +function parsePositiveNumber(value: number | string | undefined, fallback: number) { + if (typeof value === 'number' && Number.isFinite(value) && value > 0) { + return value + } + const parsed = Number(value) + return Number.isFinite(parsed) && parsed > 0 ? parsed : fallback +} + +async function withRedisRetry( + metadata: RedisOperationMetadata, + operation: (redis: NonNullable>) => Promise +): Promise { + const redis = getRedisClient() + if (!redis) { + throw new Error('Redis is required for mothership stream durability') + } + + let lastError: unknown + + for (let attempt = 0; attempt < RETRY_DELAYS_MS.length; attempt++) { + const delay = RETRY_DELAYS_MS[attempt] + if (delay > 0) { + await new Promise((resolve) => setTimeout(resolve, delay)) + } + + try { + return await operation(redis) + } catch (error) { + lastError = error + logger.warn('Redis stream operation failed', { + operation: metadata.operation, + streamId: metadata.streamId, + attempt: attempt + 1, + error: error instanceof Error ? error.message : String(error), + }) + } + } + + throw lastError instanceof Error + ? lastError + : new Error(`${metadata.operation} failed for stream ${metadata.streamId}`) +} + +export async function allocateCursor(streamId: string): Promise<{ + seq: number + cursor: string +}> { + const config = getStreamConfig() + const seq = await withRedisRetry({ operation: 'allocate_cursor', streamId }, async (redis) => { + const nextValue = await redis.incr(getSeqKey(streamId)) + await redis.expire(getSeqKey(streamId), config.ttlSeconds) + return typeof nextValue === 'number' ? nextValue : Number(nextValue) + }) + + return { seq, cursor: String(seq) } +} + +export async function resetBuffer(streamId: string): Promise { + await withRedisRetry({ operation: 'reset_outbox', streamId }, async (redis) => { + await redis.del(getEventsKey(streamId), getSeqKey(streamId), getAbortKey(streamId)) + }) +} + +export async function appendEvents( + envelopes: MothershipStreamV1EventEnvelope[] +): Promise { + if (envelopes.length === 0) { + return envelopes + } + + const streamId = envelopes[0].stream.streamId + const config = getStreamConfig() + + await withRedisRetry({ operation: 'append_event', streamId }, async (redis) => { + const key = getEventsKey(streamId) + const seqKey = getSeqKey(streamId) + const pipeline = redis.pipeline() + const zaddArgs: Array = [] + for (const envelope of envelopes) { + zaddArgs.push(envelope.seq, JSON.stringify(envelope)) + } + pipeline.zadd(key, ...(zaddArgs as [number, string, ...Array])) + pipeline.expire(key, config.ttlSeconds) + pipeline.set(seqKey, String(envelopes[envelopes.length - 1].seq), 'EX', config.ttlSeconds) + if (config.eventLimit > 0) { + pipeline.zremrangebyrank(key, 0, -config.eventLimit - 1) + } + await pipeline.exec() + }) + + return envelopes +} + +export async function appendEvent( + envelope: MothershipStreamV1EventEnvelope +): Promise { + await appendEvents([envelope]) + return envelope +} + +export class InvalidCursorError extends Error { + constructor( + public readonly streamId: string, + public readonly cursor: string + ) { + super(`Invalid non-numeric cursor "${cursor}" for stream ${streamId}`) + this.name = 'InvalidCursorError' + } +} + +export async function readEvents( + streamId: string, + afterCursor: string +): Promise { + const afterSeq = Number(afterCursor || '0') + if (!Number.isFinite(afterSeq)) { + throw new InvalidCursorError(streamId, afterCursor) + } + const minScore = afterSeq + 1 + + const rawEntries = await withRedisRetry({ operation: 'read_events', streamId }, async (redis) => { + return redis.zrangebyscore(getEventsKey(streamId), minScore, '+inf') + }) + + const envelopes: MothershipStreamV1EventEnvelope[] = [] + for (const entry of rawEntries) { + try { + const parsed = JSON.parse(entry) as MothershipStreamV1EventEnvelope + if (!parsed?.stream?.streamId || typeof parsed.seq !== 'number') { + logger.warn('Skipping corrupt outbox entry: missing required fields', { streamId }) + continue + } + envelopes.push(parsed) + } catch (error) { + logger.warn('Skipping corrupt outbox entry: JSON parse failed', { + streamId, + error: error instanceof Error ? error.message : String(error), + }) + } + } + return envelopes +} + +export async function getOldestSeq(streamId: string): Promise { + return withRedisRetry({ operation: 'get_oldest_seq', streamId }, async (redis) => { + const entries = await redis.zrangebyscore(getEventsKey(streamId), '-inf', '+inf', 'LIMIT', 0, 1) + if (!entries || entries.length === 0) { + return null + } + try { + const parsed = JSON.parse(entries[0]) as { seq?: number } + return typeof parsed.seq === 'number' ? parsed.seq : null + } catch { + logger.warn('Failed to parse oldest outbox entry', { streamId }) + return null + } + }) +} + +export async function getLatestSeq(streamId: string): Promise { + return withRedisRetry({ operation: 'get_latest_seq', streamId }, async (redis) => { + const currentSeq = await redis.get(getSeqKey(streamId)) + if (currentSeq === null) { + return null + } + const parsed = Number(currentSeq) + return Number.isFinite(parsed) ? parsed : null + }) +} + +export async function writeAbortMarker(streamId: string): Promise { + const ttlSeconds = getStreamConfig().ttlSeconds + await withRedisRetry({ operation: 'write_abort_marker', streamId }, async (redis) => { + await redis.set(getAbortKey(streamId), '1', 'EX', ttlSeconds) + }) +} + +export async function hasAbortMarker(streamId: string): Promise { + return withRedisRetry({ operation: 'read_abort_marker', streamId }, async (redis) => { + const marker = await redis.get(getAbortKey(streamId)) + return marker === '1' + }) +} + +export async function clearAbortMarker(streamId: string): Promise { + await withRedisRetry({ operation: 'clear_abort_marker', streamId }, async (redis) => { + await redis.del(getAbortKey(streamId)) + }) +} diff --git a/apps/sim/lib/copilot/request/session/event.ts b/apps/sim/lib/copilot/request/session/event.ts new file mode 100644 index 00000000000..adfc00dc5dd --- /dev/null +++ b/apps/sim/lib/copilot/request/session/event.ts @@ -0,0 +1,85 @@ +import { createLogger } from '@sim/logger' +import type { + MothershipStreamV1EventEnvelope, + MothershipStreamV1EventType as MothershipStreamV1EventTypeUnion, + MothershipStreamV1StreamScope, +} from '@/lib/copilot/generated/mothership-stream-v1' +import { MothershipStreamV1EventType } from '@/lib/copilot/generated/mothership-stream-v1' +import type { StreamEvent } from './types' + +const logger = createLogger('SessionEvent') + +type JsonRecord = Record + +const VALID_EVENT_TYPES = new Set(Object.values(MothershipStreamV1EventType)) + +export const TOOL_CALL_STATUS = { + generating: 'generating', +} as const + +export function createEvent(input: { + streamId: string + chatId?: string + cursor: string + seq: number + requestId: string + type: MothershipStreamV1EventTypeUnion + payload: JsonRecord + scope?: MothershipStreamV1StreamScope + ts?: string +}): MothershipStreamV1EventEnvelope { + const { streamId, chatId, cursor, seq, requestId, type, payload, scope, ts } = input + + return { + v: 1, + type, + seq, + ts: ts ?? new Date().toISOString(), + stream: { + streamId, + ...(chatId ? { chatId } : {}), + cursor, + }, + trace: { + requestId, + }, + ...(scope ? { scope } : {}), + payload, + } +} + +export function isEventRecord(value: unknown): value is MothershipStreamV1EventEnvelope { + if (!value || typeof value !== 'object') { + return false + } + + const record = value as Record + return ( + record.v === 1 && + typeof record.type === 'string' && + VALID_EVENT_TYPES.has(record.type) && + typeof record.seq === 'number' && + typeof record.ts === 'string' && + !!record.stream && + typeof record.stream === 'object' && + typeof (record.stream as Record).streamId === 'string' && + !!record.payload && + typeof record.payload === 'object' + ) +} + +export function eventToStreamEvent(envelope: MothershipStreamV1EventEnvelope): StreamEvent { + return { + type: envelope.type, + payload: asJsonRecord(envelope.payload), + ...(envelope.scope ? { scope: envelope.scope } : {}), + } +} + +function asJsonRecord(value: unknown): JsonRecord { + if (value && typeof value === 'object' && !Array.isArray(value)) { + return value as JsonRecord + } + logger.warn('Envelope payload is not a valid JSON record, defaulting to empty object') + return {} +} diff --git a/apps/sim/lib/copilot/request/session/index.ts b/apps/sim/lib/copilot/request/session/index.ts new file mode 100644 index 00000000000..6a6eddd1ee7 --- /dev/null +++ b/apps/sim/lib/copilot/request/session/index.ts @@ -0,0 +1,29 @@ +export { + abortActiveStream, + acquirePendingChatStream, + cleanupAbortMarker, + getPendingChatStreamId, + registerActiveStream, + releasePendingChatStream, + startAbortPoller, + unregisterActiveStream, + waitForPendingChatStream, +} from './abort' +export { + allocateCursor, + appendEvent, + appendEvents, + clearAbortMarker, + getLatestSeq, + getOldestSeq, + hasAbortMarker, + InvalidCursorError, + readEvents, + resetBuffer, + writeAbortMarker, +} from './buffer' +export { createEvent, eventToStreamEvent, isEventRecord, TOOL_CALL_STATUS } from './event' +export { checkForReplayGap, type ReplayGapResult } from './recovery' +export { encodeSSEComment, encodeSSEEnvelope, SSE_RESPONSE_HEADERS } from './sse' +export type { StreamEvent } from './types' +export { StreamWriter, type StreamWriterOptions } from './writer' diff --git a/apps/sim/lib/copilot/request/session/recovery.ts b/apps/sim/lib/copilot/request/session/recovery.ts new file mode 100644 index 00000000000..38afa6ad377 --- /dev/null +++ b/apps/sim/lib/copilot/request/session/recovery.ts @@ -0,0 +1,74 @@ +import { createLogger } from '@sim/logger' +import { + MothershipStreamV1CompletionStatus, + MothershipStreamV1EventType, +} from '@/lib/copilot/generated/mothership-stream-v1' +import { getLatestSeq, getOldestSeq } from './buffer' +import { createEvent } from './event' + +const logger = createLogger('SessionRecovery') + +export interface ReplayGapResult { + gapDetected: true + envelopes: ReturnType[] +} + +export async function checkForReplayGap( + streamId: string, + afterCursor: string +): Promise { + const requestedAfterSeq = Number(afterCursor || '0') + if (requestedAfterSeq <= 0) { + return null + } + + const oldestSeq = await getOldestSeq(streamId) + const latestSeq = await getLatestSeq(streamId) + + if ( + latestSeq !== null && + latestSeq > 0 && + oldestSeq !== null && + requestedAfterSeq < oldestSeq - 1 + ) { + logger.warn('Replay gap detected: requested cursor is below oldest available event', { + streamId, + requestedAfterSeq, + oldestAvailableSeq: oldestSeq, + latestSeq, + }) + + const gapEnvelope = createEvent({ + streamId, + cursor: String(latestSeq + 1), + seq: latestSeq + 1, + requestId: '', + type: MothershipStreamV1EventType.error, + payload: { + message: 'Replay history is no longer available. Some events may have been lost.', + code: 'replay_gap', + oldestAvailableSeq: oldestSeq, + requestedAfterSeq, + }, + }) + + const terminalEnvelope = createEvent({ + streamId, + cursor: String(latestSeq + 2), + seq: latestSeq + 2, + requestId: '', + type: MothershipStreamV1EventType.complete, + payload: { + status: MothershipStreamV1CompletionStatus.error, + reason: 'replay_gap', + }, + }) + + return { + gapDetected: true, + envelopes: [gapEnvelope, terminalEnvelope], + } + } + + return null +} diff --git a/apps/sim/lib/copilot/request/session/sse.ts b/apps/sim/lib/copilot/request/session/sse.ts new file mode 100644 index 00000000000..74b11454047 --- /dev/null +++ b/apps/sim/lib/copilot/request/session/sse.ts @@ -0,0 +1,16 @@ +import { SSE_HEADERS } from '@/lib/core/utils/sse' + +const encoder = new TextEncoder() + +export function encodeSSEEnvelope(envelope: unknown): Uint8Array { + return encoder.encode(`data: ${JSON.stringify(envelope)}\n\n`) +} + +export function encodeSSEComment(comment: string): Uint8Array { + return encoder.encode(`: ${comment}\n\n`) +} + +export const SSE_RESPONSE_HEADERS = { + ...SSE_HEADERS, + 'Content-Encoding': 'none', +} as const diff --git a/apps/sim/lib/copilot/request/session/types.ts b/apps/sim/lib/copilot/request/session/types.ts new file mode 100644 index 00000000000..1cd8b7af5f3 --- /dev/null +++ b/apps/sim/lib/copilot/request/session/types.ts @@ -0,0 +1,10 @@ +import type { + MothershipStreamV1EventType, + MothershipStreamV1StreamScope, +} from '@/lib/copilot/generated/mothership-stream-v1' + +export interface StreamEvent { + type: MothershipStreamV1EventType + payload: Record + scope?: MothershipStreamV1StreamScope +} diff --git a/apps/sim/lib/copilot/request/session/writer.test.ts b/apps/sim/lib/copilot/request/session/writer.test.ts new file mode 100644 index 00000000000..b125cac82b3 --- /dev/null +++ b/apps/sim/lib/copilot/request/session/writer.test.ts @@ -0,0 +1,149 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { + MothershipStreamV1EventType, + MothershipStreamV1TextChannel, +} from '@/lib/copilot/generated/mothership-stream-v1' + +const { appendEvents } = vi.hoisted(() => ({ + appendEvents: vi.fn(), +})) + +vi.mock('@/lib/copilot/request/session/buffer', () => ({ + appendEvents, +})) + +import { StreamWriter } from '@/lib/copilot/request/session/writer' + +function decodeChunk(value: Uint8Array): string { + return new TextDecoder().decode(value) +} + +describe('StreamWriter', () => { + beforeEach(() => { + vi.clearAllMocks() + vi.useRealTimers() + }) + + it('enqueues before persistence completes and flushes pending writes on close', async () => { + let releasePersist: (() => void) | null = null + appendEvents.mockImplementation( + () => + new Promise((resolve) => { + releasePersist = resolve + }) + ) + + const writer = new StreamWriter({ + streamId: 'stream-1', + chatId: 'chat-1', + requestId: 'req-1', + }) + + const chunks: string[] = [] + let closeCount = 0 + const controller = { + enqueue: vi.fn((value: Uint8Array) => { + chunks.push(decodeChunk(value)) + }), + close: vi.fn(() => { + closeCount += 1 + }), + } as unknown as ReadableStreamDefaultController + + writer.attach(controller) + await writer.publish({ + type: MothershipStreamV1EventType.text, + payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'hello' }, + }) + + expect(controller.enqueue).toHaveBeenCalledOnce() + expect(appendEvents).not.toHaveBeenCalled() + expect(chunks[0]).toContain('"text":"hello"') + expect(closeCount).toBe(0) + + const closePromise = writer.close() + await Promise.resolve() + await Promise.resolve() + expect(appendEvents).toHaveBeenCalledOnce() + expect(closeCount).toBe(0) + + const resolvePersist = releasePersist + if (typeof resolvePersist === 'function') { + resolvePersist() + } + await closePromise + + expect(closeCount).toBe(1) + }) + + it('batches publishes on the flush timer and preserves sequence order', async () => { + vi.useFakeTimers() + const persistedSeqs: number[] = [] + appendEvents.mockImplementation(async (envelopes) => { + persistedSeqs.push(...envelopes.map((envelope) => envelope.seq)) + return envelopes + }) + + const writer = new StreamWriter({ + streamId: 'stream-1', + requestId: 'req-1', + }) + + const chunks: string[] = [] + const controller = { + enqueue: vi.fn((value: Uint8Array) => { + chunks.push(decodeChunk(value)) + }), + close: vi.fn(), + } as unknown as ReadableStreamDefaultController + + writer.attach(controller) + await Promise.all([ + writer.publish({ + type: MothershipStreamV1EventType.text, + payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'one' }, + }), + writer.publish({ + type: MothershipStreamV1EventType.text, + payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'two' }, + }), + ]) + expect(appendEvents).not.toHaveBeenCalled() + + await vi.advanceTimersByTimeAsync(15) + await writer.close() + + expect(persistedSeqs).toEqual([1, 2]) + expect(appendEvents).toHaveBeenCalledWith([ + expect.objectContaining({ seq: 1 }), + expect.objectContaining({ seq: 2 }), + ]) + expect(chunks[0]).toContain('"seq":1') + expect(chunks[1]).toContain('"seq":2') + }) + + it('flush waits for persistence and surfaces failures', async () => { + appendEvents.mockRejectedValueOnce(new Error('redis down')) + + const writer = new StreamWriter({ + streamId: 'stream-1', + requestId: 'req-1', + }) + + writer.attach({ + enqueue: vi.fn(), + close: vi.fn(), + } as unknown as ReadableStreamDefaultController) + + await writer.publish({ + type: MothershipStreamV1EventType.text, + payload: { channel: MothershipStreamV1TextChannel.assistant, text: 'boom' }, + }) + + await expect(writer.flush()).rejects.toThrow('redis down') + }) +}) diff --git a/apps/sim/lib/copilot/request/session/writer.ts b/apps/sim/lib/copilot/request/session/writer.ts new file mode 100644 index 00000000000..5e330ccbf16 --- /dev/null +++ b/apps/sim/lib/copilot/request/session/writer.ts @@ -0,0 +1,197 @@ +import { createLogger } from '@sim/logger' +import type { MothershipStreamV1EventEnvelope } from '@/lib/copilot/generated/mothership-stream-v1' +import { MothershipStreamV1EventType } from '@/lib/copilot/generated/mothership-stream-v1' +import { appendEvents } from './buffer' +import { createEvent } from './event' +import { encodeSSEComment, encodeSSEEnvelope } from './sse' +import type { StreamEvent } from './types' + +const logger = createLogger('StreamWriter') + +const DEFAULT_KEEPALIVE_MS = 15_000 +const DEFAULT_PERSIST_FLUSH_INTERVAL_MS = 15 +const DEFAULT_PERSIST_FLUSH_MAX_BATCH = 200 + +export interface StreamWriterOptions { + streamId: string + chatId?: string + requestId: string + keepaliveMs?: number +} + +export class StreamWriter { + private readonly streamId: string + private readonly chatId: string | undefined + private readonly requestId: string + private readonly keepaliveMs: number + private readonly flushIntervalMs: number + private readonly flushMaxBatch: number + private readonly encoder: TextEncoder + private controller: ReadableStreamDefaultController | null = null + private keepaliveInterval: ReturnType | null = null + private flushTimer: ReturnType | null = null + private _clientDisconnected = false + private _sawComplete = false + private nextSeq = 0 + private pendingEnvelopes: MothershipStreamV1EventEnvelope[] = [] + private persistenceTail: Promise = Promise.resolve() + private lastPersistenceError: Error | null = null + + constructor(options: StreamWriterOptions) { + this.streamId = options.streamId + this.chatId = options.chatId + this.requestId = options.requestId + this.keepaliveMs = options.keepaliveMs ?? DEFAULT_KEEPALIVE_MS + this.flushIntervalMs = DEFAULT_PERSIST_FLUSH_INTERVAL_MS + this.flushMaxBatch = DEFAULT_PERSIST_FLUSH_MAX_BATCH + this.encoder = new TextEncoder() + } + + get clientDisconnected(): boolean { + return this._clientDisconnected + } + + get sawComplete(): boolean { + return this._sawComplete + } + + attach(controller: ReadableStreamDefaultController): void { + this.controller = controller + } + + startKeepalive(): void { + this.keepaliveInterval = setInterval(() => { + if (this._clientDisconnected || !this.controller) return + try { + this.controller.enqueue(encodeSSEComment('keepalive')) + } catch (error) { + this._clientDisconnected = true + logger.warn('Keepalive enqueue failed, marking client disconnected', { + streamId: this.streamId, + requestId: this.requestId, + error: error instanceof Error ? error.message : String(error), + }) + } + }, this.keepaliveMs) + } + + stopKeepalive(): void { + if (this.keepaliveInterval) { + clearInterval(this.keepaliveInterval) + this.keepaliveInterval = null + } + } + + publish(event: StreamEvent): void { + const envelope = this.createEnvelope(event) + this.enqueue(envelope) + this.queuePersistence(envelope) + if (event.type === MothershipStreamV1EventType.complete) { + this._sawComplete = true + } + } + + markDisconnected(): void { + this._clientDisconnected = true + } + + async flush(): Promise { + this.flushPendingPersistence() + await this.persistenceTail + if (this.lastPersistenceError) { + const error = this.lastPersistenceError + this.lastPersistenceError = null + throw error + } + } + + async close(): Promise { + this.stopKeepalive() + this.clearFlushTimer() + await this.flush() + if (!this.controller) return + try { + this.controller.close() + } catch { + // Controller already closed + } + this.controller = null + } + + private enqueue(envelope: MothershipStreamV1EventEnvelope): void { + if (this._clientDisconnected || !this.controller) return + try { + this.controller.enqueue(encodeSSEEnvelope(envelope)) + } catch (error) { + this._clientDisconnected = true + logger.warn('Envelope enqueue failed, marking client disconnected', { + streamId: this.streamId, + requestId: this.requestId, + seq: envelope.seq, + error: error instanceof Error ? error.message : String(error), + }) + } + } + + private createEnvelope(event: StreamEvent): MothershipStreamV1EventEnvelope { + const seq = ++this.nextSeq + return createEvent({ + streamId: this.streamId, + chatId: this.chatId, + cursor: String(seq), + seq, + requestId: this.requestId, + type: event.type, + payload: event.payload, + scope: event.scope, + }) + } + + private queuePersistence(envelope: MothershipStreamV1EventEnvelope): void { + this.pendingEnvelopes.push(envelope) + if (this.pendingEnvelopes.length >= this.flushMaxBatch) { + this.flushPendingPersistence() + return + } + if (this.flushTimer || this.pendingEnvelopes.length === 0) { + return + } + this.flushTimer = setTimeout(() => { + this.flushTimer = null + this.flushPendingPersistence() + }, this.flushIntervalMs) + } + + private flushPendingPersistence(): void { + this.clearFlushTimer() + if (this.pendingEnvelopes.length === 0) { + return + } + const batch = this.pendingEnvelopes + this.pendingEnvelopes = [] + this.persistenceTail = this.persistenceTail + .catch(() => undefined) + .then(() => appendEvents(batch)) + .then(() => { + this.lastPersistenceError = null + }) + .catch((error) => { + this.lastPersistenceError = error instanceof Error ? error : new Error(String(error)) + logger.warn('Failed to persist stream envelope batch', { + streamId: this.streamId, + requestId: this.requestId, + batchSize: batch.length, + firstSeq: batch[0]?.seq, + lastSeq: batch[batch.length - 1]?.seq, + error: error instanceof Error ? error.message : String(error), + }) + }) + } + + private clearFlushTimer(): void { + if (this.flushTimer) { + clearTimeout(this.flushTimer) + this.flushTimer = null + } + } +} diff --git a/apps/sim/lib/copilot/request/sse-utils.ts b/apps/sim/lib/copilot/request/sse-utils.ts new file mode 100644 index 00000000000..f5e68db0dd7 --- /dev/null +++ b/apps/sim/lib/copilot/request/sse-utils.ts @@ -0,0 +1,80 @@ +import { STREAM_BUFFER_MAX_DEDUP_ENTRIES } from '@/lib/copilot/constants' +import { + MothershipStreamV1EventType, + MothershipStreamV1ToolPhase, +} from '@/lib/copilot/generated/mothership-stream-v1' +import { TOOL_CALL_STATUS } from '@/lib/copilot/request/session/event' +import type { StreamEvent } from '@/lib/copilot/request/types' + +type EventDataObject = Record | undefined + +/** Safely cast event.data to a record for property access. */ +export const asRecord = (data: unknown): Record => + (data && typeof data === 'object' && !Array.isArray(data) ? data : {}) as Record + +/** + * In-memory tool event dedupe with bounded size. + * + * NOTE: Process-local only. In a multi-instance setup (e.g., ECS), + * each task maintains its own dedupe cache. + */ +const seenToolCalls = new Set() +const seenToolResults = new Set() + +function addToSet(set: Set, id: string): void { + if (set.size >= STREAM_BUFFER_MAX_DEDUP_ENTRIES) { + const first = set.values().next().value + if (first) set.delete(first) + } + set.add(id) +} + +export const getEventData = (event: StreamEvent): EventDataObject => { + if (!event.payload || typeof event.payload !== 'object' || Array.isArray(event.payload)) { + return undefined + } + return event.payload +} + +function getToolCallIdFromEvent(event: StreamEvent): string | undefined { + const data = getEventData(event) + return (data?.toolCallId as string | undefined) || (data?.id as string | undefined) +} + +function markToolCallSeen(toolCallId: string): void { + addToSet(seenToolCalls, toolCallId) +} + +function wasToolCallSeen(toolCallId: string): boolean { + return seenToolCalls.has(toolCallId) +} + +export function markToolResultSeen(toolCallId: string): void { + addToSet(seenToolResults, toolCallId) +} + +export function wasToolResultSeen(toolCallId: string): boolean { + return seenToolResults.has(toolCallId) +} + +export function shouldSkipToolCallEvent(event: StreamEvent): boolean { + if (event.type !== MothershipStreamV1EventType.tool) return false + const eventData = getEventData(event) + if (eventData?.phase !== MothershipStreamV1ToolPhase.call) return false + if (eventData?.status === TOOL_CALL_STATUS.generating) return false + const toolCallId = getToolCallIdFromEvent(event) + if (!toolCallId) return false + if (eventData?.partial === true) return false + if (wasToolResultSeen(toolCallId) || wasToolCallSeen(toolCallId)) return true + markToolCallSeen(toolCallId) + return false +} + +export function shouldSkipToolResultEvent(event: StreamEvent): boolean { + if (event.type !== MothershipStreamV1EventType.tool) return false + const eventData = getEventData(event) + if (eventData?.phase !== MothershipStreamV1ToolPhase.result) return false + const toolCallId = getToolCallIdFromEvent(event) + if (!toolCallId) return false + return wasToolResultSeen(toolCallId) +} diff --git a/apps/sim/lib/copilot/orchestrator/subagent.ts b/apps/sim/lib/copilot/request/subagent.ts similarity index 77% rename from apps/sim/lib/copilot/orchestrator/subagent.ts rename to apps/sim/lib/copilot/request/subagent.ts index c2d40c044ac..686d46142a4 100644 --- a/apps/sim/lib/copilot/orchestrator/subagent.ts +++ b/apps/sim/lib/copilot/request/subagent.ts @@ -1,16 +1,22 @@ import { createLogger } from '@sim/logger' import { SIM_AGENT_API_URL } from '@/lib/copilot/constants' -import { prepareExecutionContext } from '@/lib/copilot/orchestrator/tool-executor' +import { + MothershipStreamV1EventType, + MothershipStreamV1SpanPayloadKind, +} from '@/lib/copilot/generated/mothership-stream-v1' +import { createStreamingContext } from '@/lib/copilot/request/context/request-context' +import { buildToolCallSummaries } from '@/lib/copilot/request/context/result' +import { runStreamLoop } from '@/lib/copilot/request/go/stream' import type { ExecutionContext, OrchestratorOptions, - SSEEvent, + StreamEvent, StreamingContext, ToolCallSummary, -} from '@/lib/copilot/orchestrator/types' +} from '@/lib/copilot/request/types' +import { prepareExecutionContext } from '@/lib/copilot/tools/handlers/context' import { env } from '@/lib/core/config/env' import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' -import { buildToolCallSummaries, createStreamingContext, runStreamLoop } from './stream/core' const logger = createLogger('CopilotSubagentOrchestrator') @@ -71,23 +77,22 @@ export async function orchestrateSubagentStream( execContext, { ...options, - onBeforeDispatch: (event: SSEEvent, ctx: StreamingContext) => { - // Handle structured_result / subagent_result - subagent-specific. - if (event.type === 'structured_result' || event.type === 'subagent_result') { - structuredResult = normalizeStructuredResult(event.data) + onBeforeDispatch: (event: StreamEvent, ctx: StreamingContext) => { + if ( + event.type === MothershipStreamV1EventType.span && + (event.payload.kind === MothershipStreamV1SpanPayloadKind.structured_result || + event.payload.kind === MothershipStreamV1SpanPayloadKind.subagent_result) + ) { + structuredResult = normalizeStructuredResult(event.payload.data) ctx.streamComplete = true - return true // skip default dispatch + return true } - // For direct subagent calls, events may have the subagent field set - // but no subagent_start because this IS the top-level agent. - // Skip subagent routing for events where the subagent field matches - // the current agentId - these are top-level events. - if (event.subagent === agentId && !ctx.subAgentParentToolCallId) { - return false // let default dispatch handle it + if (event.scope?.agentId === agentId && !ctx.subAgentParentToolCallId) { + return false } - return false // let default dispatch handle it + return false }, } ) diff --git a/apps/sim/lib/copilot/request/tools/billing.ts b/apps/sim/lib/copilot/request/tools/billing.ts new file mode 100644 index 00000000000..2f4b0e2d7ac --- /dev/null +++ b/apps/sim/lib/copilot/request/tools/billing.ts @@ -0,0 +1,84 @@ +import { createLogger } from '@sim/logger' +import { getHighestPrioritySubscription } from '@/lib/billing/core/plan' +import { isPaid } from '@/lib/billing/plan-helpers' +import { + MothershipStreamV1CompletionStatus, + MothershipStreamV1EventType, + MothershipStreamV1TextChannel, +} from '@/lib/copilot/generated/mothership-stream-v1' +import { sseHandlers } from '@/lib/copilot/request/handlers' +import type { + ExecutionContext, + OrchestratorOptions, + StreamEvent, + StreamingContext, +} from '@/lib/copilot/request/types' + +const logger = createLogger('CopilotBillingEffect') + +/** + * Handle a 402 billing-limit response from the Go backend. + * + * Determines whether the user needs a plan upgrade or a limit increase, + * then dispatches synthetic text + complete events through the handler chain + * so the client renders the upgrade prompt. + */ +export async function handleBillingLimitResponse( + userId: string, + context: StreamingContext, + execContext: ExecutionContext, + options: OrchestratorOptions +): Promise { + let action = 'upgrade_plan' + let message = "You've reached your usage limit. Please upgrade your plan to continue." + try { + const sub = await getHighestPrioritySubscription(userId) + if (sub && isPaid(sub.plan)) { + action = 'increase_limit' + message = + "You've reached your usage limit for this billing period. Please increase your usage limit to continue." + } + } catch { + logger.warn('Failed to determine subscription plan, defaulting to upgrade_plan') + } + + const upgradePayload = JSON.stringify({ + reason: 'usage_limit', + action, + message, + }) + const syntheticContent = `${upgradePayload}` + + const syntheticEvents: StreamEvent[] = [ + { + type: MothershipStreamV1EventType.text, + payload: { + channel: MothershipStreamV1TextChannel.assistant, + text: syntheticContent, + }, + }, + { + type: MothershipStreamV1EventType.complete, + payload: { + status: MothershipStreamV1CompletionStatus.complete, + }, + }, + ] + + for (const event of syntheticEvents) { + try { + await options.onEvent?.(event) + } catch { + logger.warn('Failed to forward synthetic billing event', { type: event.type }) + } + + // TODO: Handler dispatch should move out of this effect — effects should be + // pure side-effect producers; event dispatch belongs in the stream loop or + // a dedicated dispatcher. Keeping here for now to preserve behavior. + const handler = sseHandlers[event.type] + if (handler) { + await handler(event, context, execContext, options) + } + if (context.streamComplete) break + } +} diff --git a/apps/sim/lib/copilot/request/tools/client.ts b/apps/sim/lib/copilot/request/tools/client.ts new file mode 100644 index 00000000000..44f2341486f --- /dev/null +++ b/apps/sim/lib/copilot/request/tools/client.ts @@ -0,0 +1,43 @@ +import { + MothershipStreamV1AsyncToolRecordStatus, + MothershipStreamV1ToolOutcome, +} from '@/lib/copilot/generated/mothership-stream-v1' +import { waitForToolConfirmation } from '@/lib/copilot/persistence/tool-confirm' + +/** + * Wait for a tool completion signal (success/error/rejected) from the client. + * Ignores intermediate statuses like `accepted` and only returns terminal statuses: + * - success: client finished executing successfully + * - error: client execution failed + * - rejected: user clicked Skip (subagent run tools where user hasn't auto-allowed) + * + * Used for client-executable run tools: the client executes the workflow + * and posts success/error to /api/copilot/confirm when done. The server + * waits here until that completion signal arrives. + */ +export async function waitForToolCompletion( + toolCallId: string, + timeoutMs: number, + abortSignal?: AbortSignal +): Promise<{ status: string; message?: string; data?: Record } | null> { + const decision = await waitForToolConfirmation(toolCallId, timeoutMs, abortSignal, { + acceptStatus: (status) => + status === MothershipStreamV1ToolOutcome.success || + status === MothershipStreamV1ToolOutcome.error || + status === MothershipStreamV1ToolOutcome.rejected || + status === 'background' || + status === MothershipStreamV1ToolOutcome.cancelled || + status === MothershipStreamV1AsyncToolRecordStatus.delivered, + }) + if ( + decision?.status === MothershipStreamV1ToolOutcome.success || + decision?.status === MothershipStreamV1ToolOutcome.error || + decision?.status === MothershipStreamV1ToolOutcome.rejected || + decision?.status === 'background' || + decision?.status === MothershipStreamV1ToolOutcome.cancelled || + decision?.status === MothershipStreamV1AsyncToolRecordStatus.delivered + ) { + return decision + } + return null +} diff --git a/apps/sim/lib/copilot/request/tools/executor.ts b/apps/sim/lib/copilot/request/tools/executor.ts new file mode 100644 index 00000000000..1179ca48ce8 --- /dev/null +++ b/apps/sim/lib/copilot/request/tools/executor.ts @@ -0,0 +1,490 @@ +import { createLogger } from '@sim/logger' +import { + completeAsyncToolCall, + markAsyncToolRunning, + upsertAsyncToolCall, +} from '@/lib/copilot/async-runs/repository' +import { + MothershipStreamV1AsyncToolRecordStatus, + MothershipStreamV1EventType, + MothershipStreamV1ToolExecutor, + MothershipStreamV1ToolMode, + MothershipStreamV1ToolOutcome, + MothershipStreamV1ToolPhase, +} from '@/lib/copilot/generated/mothership-stream-v1' +import { CreateWorkflow } from '@/lib/copilot/generated/tool-catalog-v1' +import { publishToolConfirmation } from '@/lib/copilot/persistence/tool-confirm' +import { asRecord, markToolResultSeen } from '@/lib/copilot/request/sse-utils' +import { maybeWriteOutputToFile } from '@/lib/copilot/request/tools/files' +import { handleResourceSideEffects } from '@/lib/copilot/request/tools/resources' +import { + maybeWriteOutputToTable, + maybeWriteReadCsvToTable, +} from '@/lib/copilot/request/tools/tables' +import { + type ExecutionContext, + isTerminalToolCallStatus, + type OrchestratorOptions, + type StreamEvent, + type StreamingContext, +} from '@/lib/copilot/request/types' +import { ensureHandlersRegistered, executeTool } from '@/lib/copilot/tool-executor' + +export { waitForToolCompletion } from '@/lib/copilot/request/tools/client' + +const logger = createLogger('CopilotSseToolExecution') + +export interface AsyncToolCompletion { + status: string + message?: string + data?: Record +} + +function publishTerminalToolConfirmation(input: { + toolCallId: string + status: string + message?: string + data?: Record +}): void { + publishToolConfirmation({ + toolCallId: input.toolCallId, + status: input.status, + message: input.message, + data: input.data, + timestamp: new Date().toISOString(), + }) +} + +function abortRequested( + context: StreamingContext, + execContext: ExecutionContext, + options?: OrchestratorOptions +): boolean { + return Boolean( + options?.abortSignal?.aborted || execContext.abortSignal?.aborted || context.wasAborted + ) +} + +function cancelledCompletion(message: string): AsyncToolCompletion { + return { + status: MothershipStreamV1ToolOutcome.cancelled, + message, + data: { cancelled: true }, + } +} + +function terminalCompletionFromToolCall(toolCall: { + status: string + error?: string + result?: { output?: unknown; error?: string } +}): AsyncToolCompletion { + if (toolCall.status === MothershipStreamV1ToolOutcome.cancelled) { + return cancelledCompletion(toolCall.error || 'Tool execution cancelled') + } + + if (toolCall.status === MothershipStreamV1ToolOutcome.success) { + return { + status: MothershipStreamV1ToolOutcome.success, + message: 'Tool completed', + data: + toolCall.result?.output && + typeof toolCall.result.output === 'object' && + !Array.isArray(toolCall.result.output) + ? (toolCall.result.output as Record) + : undefined, + } + } + + if (toolCall.status === MothershipStreamV1ToolOutcome.skipped) { + return { + status: MothershipStreamV1ToolOutcome.success, + message: 'Tool skipped', + data: + toolCall.result?.output && + typeof toolCall.result.output === 'object' && + !Array.isArray(toolCall.result.output) + ? (toolCall.result.output as Record) + : undefined, + } + } + + return { + status: + toolCall.status === MothershipStreamV1ToolOutcome.rejected + ? MothershipStreamV1ToolOutcome.rejected + : MothershipStreamV1ToolOutcome.error, + message: toolCall.error || toolCall.result?.error || 'Tool failed', + data: { error: toolCall.error || toolCall.result?.error || 'Tool failed' }, + } +} + +export async function executeToolAndReport( + toolCallId: string, + context: StreamingContext, + execContext: ExecutionContext, + options?: OrchestratorOptions +): Promise { + const toolCall = context.toolCalls.get(toolCallId) + if (!toolCall) + return { status: MothershipStreamV1ToolOutcome.error, message: 'Tool call not found' } + + if (toolCall.status === 'executing') { + return { + status: MothershipStreamV1AsyncToolRecordStatus.running, + message: 'Tool already executing', + } + } + if (toolCall.endTime || isTerminalToolCallStatus(toolCall.status)) { + return terminalCompletionFromToolCall(toolCall) + } + + if (abortRequested(context, execContext, options)) { + toolCall.status = MothershipStreamV1ToolOutcome.cancelled + toolCall.endTime = Date.now() + markToolResultSeen(toolCall.id) + await completeAsyncToolCall({ + toolCallId: toolCall.id, + status: MothershipStreamV1AsyncToolRecordStatus.cancelled, + result: { cancelled: true }, + error: 'Request aborted before tool execution', + }).catch((err) => { + logger.warn('Failed to persist async tool status', { + toolCallId: toolCall.id, + error: err instanceof Error ? err.message : String(err), + }) + }) + publishTerminalToolConfirmation({ + toolCallId: toolCall.id, + status: MothershipStreamV1ToolOutcome.cancelled, + message: 'Request aborted before tool execution', + data: { cancelled: true }, + }) + return cancelledCompletion('Request aborted before tool execution') + } + + toolCall.status = 'executing' + await upsertAsyncToolCall({ + runId: context.runId || crypto.randomUUID(), + toolCallId: toolCall.id, + toolName: toolCall.name, + args: toolCall.params, + }).catch((err) => { + logger.warn('Failed to persist async tool row before execution', { + toolCallId: toolCall.id, + error: err instanceof Error ? err.message : String(err), + }) + }) + await markAsyncToolRunning(toolCall.id, 'sim-stream').catch((err) => { + logger.warn('Failed to mark async tool running', { + toolCallId: toolCall.id, + error: err instanceof Error ? err.message : String(err), + }) + }) + + if (toolCall.endTime || isTerminalToolCallStatus(toolCall.status)) { + return terminalCompletionFromToolCall(toolCall) + } + + const argsPreview = toolCall.params ? JSON.stringify(toolCall.params).slice(0, 200) : undefined + const toolSpan = context.trace.startSpan(toolCall.name, 'tool.execute', { + toolCallId: toolCall.id, + toolName: toolCall.name, + argsPreview, + }) + + logger.info('Tool execution started', { + toolCallId: toolCall.id, + toolName: toolCall.name, + }) + + try { + ensureHandlersRegistered() + let result = await executeTool(toolCall.name, toolCall.params || {}, execContext) + if (toolCall.endTime || isTerminalToolCallStatus(toolCall.status)) { + return terminalCompletionFromToolCall(toolCall) + } + if (abortRequested(context, execContext, options)) { + toolCall.status = MothershipStreamV1ToolOutcome.cancelled + toolCall.endTime = Date.now() + markToolResultSeen(toolCall.id) + await completeAsyncToolCall({ + toolCallId: toolCall.id, + status: MothershipStreamV1AsyncToolRecordStatus.cancelled, + result: { cancelled: true }, + error: 'Request aborted during tool execution', + }).catch((err) => { + logger.warn('Failed to persist async tool status', { + toolCallId: toolCall.id, + error: err instanceof Error ? err.message : String(err), + }) + }) + publishTerminalToolConfirmation({ + toolCallId: toolCall.id, + status: MothershipStreamV1ToolOutcome.cancelled, + message: 'Request aborted during tool execution', + data: { cancelled: true }, + }) + return cancelledCompletion('Request aborted during tool execution') + } + result = await maybeWriteOutputToFile(toolCall.name, toolCall.params, result, execContext) + if (abortRequested(context, execContext, options)) { + toolCall.status = MothershipStreamV1ToolOutcome.cancelled + toolCall.endTime = Date.now() + markToolResultSeen(toolCall.id) + await completeAsyncToolCall({ + toolCallId: toolCall.id, + status: MothershipStreamV1AsyncToolRecordStatus.cancelled, + result: { cancelled: true }, + error: 'Request aborted during tool post-processing', + }).catch((err) => { + logger.warn('Failed to persist async tool status', { + toolCallId: toolCall.id, + error: err instanceof Error ? err.message : String(err), + }) + }) + publishTerminalToolConfirmation({ + toolCallId: toolCall.id, + status: MothershipStreamV1ToolOutcome.cancelled, + message: 'Request aborted during tool post-processing', + data: { cancelled: true }, + }) + return cancelledCompletion('Request aborted during tool post-processing') + } + result = await maybeWriteOutputToTable(toolCall.name, toolCall.params, result, execContext) + if (abortRequested(context, execContext, options)) { + toolCall.status = MothershipStreamV1ToolOutcome.cancelled + toolCall.endTime = Date.now() + markToolResultSeen(toolCall.id) + await completeAsyncToolCall({ + toolCallId: toolCall.id, + status: MothershipStreamV1AsyncToolRecordStatus.cancelled, + result: { cancelled: true }, + error: 'Request aborted during tool post-processing', + }).catch((err) => { + logger.warn('Failed to persist async tool status', { + toolCallId: toolCall.id, + error: err instanceof Error ? err.message : String(err), + }) + }) + publishTerminalToolConfirmation({ + toolCallId: toolCall.id, + status: MothershipStreamV1ToolOutcome.cancelled, + message: 'Request aborted during tool post-processing', + data: { cancelled: true }, + }) + return cancelledCompletion('Request aborted during tool post-processing') + } + result = await maybeWriteReadCsvToTable(toolCall.name, toolCall.params, result, execContext) + if (abortRequested(context, execContext, options)) { + toolCall.status = MothershipStreamV1ToolOutcome.cancelled + toolCall.endTime = Date.now() + markToolResultSeen(toolCall.id) + await completeAsyncToolCall({ + toolCallId: toolCall.id, + status: MothershipStreamV1AsyncToolRecordStatus.cancelled, + result: { cancelled: true }, + error: 'Request aborted during tool post-processing', + }).catch((err) => { + logger.warn('Failed to persist async tool status', { + toolCallId: toolCall.id, + error: err instanceof Error ? err.message : String(err), + }) + }) + publishTerminalToolConfirmation({ + toolCallId: toolCall.id, + status: MothershipStreamV1ToolOutcome.cancelled, + message: 'Request aborted during tool post-processing', + data: { cancelled: true }, + }) + return cancelledCompletion('Request aborted during tool post-processing') + } + toolCall.status = result.success + ? MothershipStreamV1ToolOutcome.success + : MothershipStreamV1ToolOutcome.error + toolCall.result = result + toolCall.error = result.error + toolCall.endTime = Date.now() + + if (result.success) { + const raw = result.output + const preview = + typeof raw === 'string' + ? raw.slice(0, 200) + : raw && typeof raw === 'object' + ? JSON.stringify(raw).slice(0, 200) + : undefined + logger.info('Tool execution succeeded', { + toolCallId: toolCall.id, + toolName: toolCall.name, + outputPreview: preview, + }) + } else { + logger.warn('Tool execution failed', { + toolCallId: toolCall.id, + toolName: toolCall.name, + error: result.error, + params: toolCall.params, + }) + } + + // If create_workflow was successful, update the execution context with the new workflowId. + // This ensures subsequent tools in the same stream have access to the workflowId. + const output = asRecord(result.output) + if ( + toolCall.name === CreateWorkflow.id && + result.success && + output.workflowId && + !execContext.workflowId + ) { + execContext.workflowId = output.workflowId as string + if (output.workspaceId) { + execContext.workspaceId = output.workspaceId as string + } + } + + markToolResultSeen(toolCall.id) + await completeAsyncToolCall({ + toolCallId: toolCall.id, + status: result.success + ? MothershipStreamV1AsyncToolRecordStatus.completed + : MothershipStreamV1AsyncToolRecordStatus.failed, + result: result.success ? asRecord(result.output) : { error: result.error || 'Tool failed' }, + error: result.success ? null : result.error || 'Tool failed', + }).catch((err) => { + logger.warn('Failed to persist async tool completion', { + toolCallId: toolCall.id, + error: err instanceof Error ? err.message : String(err), + }) + }) + publishTerminalToolConfirmation({ + toolCallId: toolCall.id, + status: result.success + ? MothershipStreamV1ToolOutcome.success + : MothershipStreamV1ToolOutcome.error, + message: result.error || (result.success ? 'Tool completed' : 'Tool failed'), + data: asRecord(result.output), + }) + + if (abortRequested(context, execContext, options)) { + toolCall.status = MothershipStreamV1ToolOutcome.cancelled + return cancelledCompletion('Request aborted before tool result delivery') + } + + // Fire-and-forget: notify the copilot backend that the tool completed. + // IMPORTANT: We must NOT await this — the Go backend may block on the + const resultEvent: StreamEvent = { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: toolCall.id, + toolName: toolCall.name, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.result, + success: result.success, + result: result.output, + ...(result.success + ? { status: MothershipStreamV1ToolOutcome.success } + : { status: MothershipStreamV1ToolOutcome.error }), + }, + } + await options?.onEvent?.(resultEvent) + + if (abortRequested(context, execContext, options)) { + toolCall.status = MothershipStreamV1ToolOutcome.cancelled + return cancelledCompletion('Request aborted before resource persistence') + } + + if (result.success && execContext.chatId && !abortRequested(context, execContext, options)) { + await handleResourceSideEffects( + toolCall.name, + toolCall.params, + result, + execContext.chatId, + options?.onEvent, + () => abortRequested(context, execContext, options) + ) + } + context.trace.endSpan(toolSpan, result.success ? 'ok' : 'error') + return { + status: result.success + ? MothershipStreamV1ToolOutcome.success + : MothershipStreamV1ToolOutcome.error, + message: result.error || (result.success ? 'Tool completed' : 'Tool failed'), + data: asRecord(result.output), + } + } catch (error) { + context.trace.endSpan(toolSpan, 'error') + if (abortRequested(context, execContext, options)) { + toolCall.status = MothershipStreamV1ToolOutcome.cancelled + toolCall.endTime = Date.now() + markToolResultSeen(toolCall.id) + await completeAsyncToolCall({ + toolCallId: toolCall.id, + status: MothershipStreamV1AsyncToolRecordStatus.cancelled, + result: { cancelled: true }, + error: 'Request aborted during tool execution', + }).catch((err) => { + logger.warn('Failed to persist async tool status', { + toolCallId: toolCall.id, + error: err instanceof Error ? err.message : String(err), + }) + }) + publishTerminalToolConfirmation({ + toolCallId: toolCall.id, + status: MothershipStreamV1ToolOutcome.cancelled, + message: 'Request aborted during tool execution', + data: { cancelled: true }, + }) + return cancelledCompletion('Request aborted during tool execution') + } + toolCall.status = MothershipStreamV1ToolOutcome.error + toolCall.error = error instanceof Error ? error.message : String(error) + toolCall.endTime = Date.now() + + logger.error('Tool execution threw', { + toolCallId: toolCall.id, + toolName: toolCall.name, + error: toolCall.error, + params: toolCall.params, + }) + + markToolResultSeen(toolCall.id) + await completeAsyncToolCall({ + toolCallId: toolCall.id, + status: MothershipStreamV1AsyncToolRecordStatus.failed, + result: { error: toolCall.error }, + error: toolCall.error, + }).catch((err) => { + logger.warn('Failed to persist async tool error', { + toolCallId: toolCall.id, + error: err instanceof Error ? err.message : String(err), + }) + }) + publishTerminalToolConfirmation({ + toolCallId: toolCall.id, + status: MothershipStreamV1ToolOutcome.error, + message: toolCall.error, + data: { error: toolCall.error }, + }) + + const errorEvent: StreamEvent = { + type: MothershipStreamV1EventType.tool, + payload: { + toolCallId: toolCall.id, + toolName: toolCall.name, + executor: MothershipStreamV1ToolExecutor.sim, + mode: MothershipStreamV1ToolMode.async, + phase: MothershipStreamV1ToolPhase.result, + status: MothershipStreamV1ToolOutcome.error, + error: toolCall.error, + result: { error: toolCall.error }, + }, + } + await options?.onEvent?.(errorEvent) + return { + status: MothershipStreamV1ToolOutcome.error, + message: toolCall.error, + data: { error: toolCall.error }, + } + } +} diff --git a/apps/sim/lib/copilot/request/tools/files.ts b/apps/sim/lib/copilot/request/tools/files.ts new file mode 100644 index 00000000000..64bb4bf6a1d --- /dev/null +++ b/apps/sim/lib/copilot/request/tools/files.ts @@ -0,0 +1,200 @@ +import { createLogger } from '@sim/logger' +import { FunctionExecute, UserTable } from '@/lib/copilot/generated/tool-catalog-v1' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { uploadWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' + +const logger = createLogger('CopilotToolResultFiles') + +export const OUTPUT_PATH_TOOLS: Set = new Set([FunctionExecute.id, UserTable.id]) + +type OutputFormat = 'json' | 'csv' | 'txt' | 'md' | 'html' + +export const EXT_TO_FORMAT: Record = { + '.json': 'json', + '.csv': 'csv', + '.txt': 'txt', + '.md': 'md', + '.html': 'html', +} + +export const FORMAT_TO_CONTENT_TYPE: Record = { + json: 'application/json', + csv: 'text/csv', + txt: 'text/plain', + md: 'text/markdown', + html: 'text/html', +} + +/** + * Try to pull a flat array of row-objects out of the various shapes that + * `function_execute` and `user_table` can return. + */ +export function extractTabularData(output: unknown): Record[] | null { + if (!output || typeof output !== 'object') return null + + if (Array.isArray(output)) { + if (output.length > 0 && typeof output[0] === 'object' && output[0] !== null) { + return output as Record[] + } + return null + } + + const obj = output as Record + + // function_execute shape: { result: [...], stdout: "..." } + if (Array.isArray(obj.result)) { + const rows = obj.result + if (rows.length > 0 && typeof rows[0] === 'object' && rows[0] !== null) { + return rows as Record[] + } + } + + // user_table query_rows shape: { data: { rows: [{ data: {...} }], totalCount } } + if (obj.data && typeof obj.data === 'object' && !Array.isArray(obj.data)) { + const data = obj.data as Record + if (Array.isArray(data.rows) && data.rows.length > 0) { + const rows = data.rows as Record[] + if (typeof rows[0].data === 'object' && rows[0].data !== null) { + return rows.map((r) => r.data as Record) + } + return rows + } + } + + return null +} + +export function escapeCsvValue(value: unknown): string { + if (value === null || value === undefined) return '' + const str = typeof value === 'object' ? JSON.stringify(value) : String(value) + if (str.includes(',') || str.includes('"') || str.includes('\n') || str.includes('\r')) { + return `"${str.replace(/"/g, '""')}"` + } + return str +} + +export function convertRowsToCsv(rows: Record[]): string { + if (rows.length === 0) return '' + + const headerSet = new Set() + for (const row of rows) { + for (const key of Object.keys(row)) { + headerSet.add(key) + } + } + const headers = [...headerSet] + + const lines = [headers.map(escapeCsvValue).join(',')] + for (const row of rows) { + lines.push(headers.map((h) => escapeCsvValue(row[h])).join(',')) + } + return lines.join('\n') +} + +export function normalizeOutputWorkspaceFileName(outputPath: string): string { + const trimmed = outputPath.trim().replace(/^\/+/, '') + const withoutPrefix = trimmed.startsWith('files/') ? trimmed.slice('files/'.length) : trimmed + if (!withoutPrefix) { + throw new Error('outputPath must include a file name, e.g. "files/result.json"') + } + if (withoutPrefix.includes('/')) { + throw new Error( + 'outputPath must target a flat workspace file, e.g. "files/result.json". Nested paths like "files/reports/result.json" are not supported.' + ) + } + return withoutPrefix +} + +export function resolveOutputFormat(fileName: string, explicit?: string): OutputFormat { + if (explicit && explicit in FORMAT_TO_CONTENT_TYPE) return explicit as OutputFormat + const ext = fileName.slice(fileName.lastIndexOf('.')).toLowerCase() + return EXT_TO_FORMAT[ext] ?? 'json' +} + +export function serializeOutputForFile(output: unknown, format: OutputFormat): string { + if (typeof output === 'string') return output + + if (format === 'csv') { + const rows = extractTabularData(output) + if (rows && rows.length > 0) { + return convertRowsToCsv(rows) + } + } + + return JSON.stringify(output, null, 2) +} + +export async function maybeWriteOutputToFile( + toolName: string, + params: Record | undefined, + result: ToolCallResult, + context: ExecutionContext +): Promise { + if (!result.success || !result.output) return result + if (!OUTPUT_PATH_TOOLS.has(toolName)) return result + if (!context.workspaceId || !context.userId) return result + + const args = params?.args as Record | undefined + const outputPath = + (params?.outputPath as string | undefined) ?? (args?.outputPath as string | undefined) + if (!outputPath) return result + const outputSandboxPath = + (params?.outputSandboxPath as string | undefined) ?? + (args?.outputSandboxPath as string | undefined) + if (toolName === FunctionExecute.id && outputSandboxPath) return result + + const explicitFormat = + (params?.outputFormat as string | undefined) ?? (args?.outputFormat as string | undefined) + + try { + const fileName = normalizeOutputWorkspaceFileName(outputPath) + const format = resolveOutputFormat(fileName, explicitFormat) + if (context.abortSignal?.aborted) { + throw new Error('Request aborted before tool mutation could be applied') + } + const content = serializeOutputForFile(result.output, format) + const contentType = FORMAT_TO_CONTENT_TYPE[format] + + const buffer = Buffer.from(content, 'utf-8') + if (context.abortSignal?.aborted) { + throw new Error('Request aborted before tool mutation could be applied') + } + const uploaded = await uploadWorkspaceFile( + context.workspaceId, + context.userId, + buffer, + fileName, + contentType + ) + + logger.info('Tool output written to file', { + toolName, + fileName, + size: buffer.length, + fileId: uploaded.id, + }) + + return { + success: true, + output: { + message: `Output written to files/${fileName} (${buffer.length} bytes)`, + fileId: uploaded.id, + fileName, + size: buffer.length, + downloadUrl: uploaded.url, + }, + resources: [{ type: 'file', id: uploaded.id, title: fileName }], + } + } catch (err) { + const message = err instanceof Error ? err.message : String(err) + logger.warn('Failed to write tool output to file', { + toolName, + outputPath, + error: message, + }) + return { + success: false, + error: `Failed to write output file: ${message}`, + } + } +} diff --git a/apps/sim/lib/copilot/request/tools/resources.ts b/apps/sim/lib/copilot/request/tools/resources.ts new file mode 100644 index 00000000000..9cb062657f9 --- /dev/null +++ b/apps/sim/lib/copilot/request/tools/resources.ts @@ -0,0 +1,86 @@ +import { createLogger } from '@sim/logger' +import { + MothershipStreamV1EventType, + MothershipStreamV1ResourceOp, +} from '@/lib/copilot/generated/mothership-stream-v1' +import type { StreamEvent, ToolCallResult } from '@/lib/copilot/request/types' +import { + extractDeletedResourcesFromToolResult, + extractResourcesFromToolResult, + hasDeleteCapability, + isResourceToolName, + persistChatResources, + removeChatResources, +} from '@/lib/copilot/resources/persistence' + +const logger = createLogger('CopilotResourceEffects') + +/** + * Persist and emit resource events after a successful tool execution. + * + * Handles both creation/upsert and deletion of chat resources depending on + * the tool's capabilities and output shape. + */ +export async function handleResourceSideEffects( + toolName: string, + params: Record | undefined, + result: ToolCallResult, + chatId: string, + onEvent: ((event: StreamEvent) => void | Promise) | undefined, + isAborted: () => boolean +): Promise { + let isDeleteOp = false + + if (hasDeleteCapability(toolName)) { + const deleted = extractDeletedResourcesFromToolResult(toolName, params, result.output) + if (deleted.length > 0) { + isDeleteOp = true + removeChatResources(chatId, deleted).catch((err) => { + logger.warn('Failed to remove chat resources after deletion', { + chatId, + error: err instanceof Error ? err.message : String(err), + }) + }) + + for (const resource of deleted) { + if (isAborted()) break + await onEvent?.({ + type: MothershipStreamV1EventType.resource, + payload: { + op: MothershipStreamV1ResourceOp.remove, + resource: { type: resource.type, id: resource.id, title: resource.title }, + }, + }) + } + } + } + + if (!isDeleteOp && !isAborted()) { + const resources = + result.resources && result.resources.length > 0 + ? result.resources + : isResourceToolName(toolName) + ? extractResourcesFromToolResult(toolName, params, result.output) + : [] + + if (resources.length > 0) { + persistChatResources(chatId, resources).catch((err) => { + logger.warn('Failed to persist chat resources', { + chatId, + error: err instanceof Error ? err.message : String(err), + }) + }) + + for (const resource of resources) { + if (isAborted()) break + await onEvent?.({ + type: MothershipStreamV1EventType.resource, + payload: { + op: MothershipStreamV1ResourceOp.upsert, + resource: { type: resource.type, id: resource.id, title: resource.title }, + }, + }) + } + } + } +} diff --git a/apps/sim/lib/copilot/request/tools/tables.ts b/apps/sim/lib/copilot/request/tools/tables.ts new file mode 100644 index 00000000000..89e0a5c19f0 --- /dev/null +++ b/apps/sim/lib/copilot/request/tools/tables.ts @@ -0,0 +1,248 @@ +import { db } from '@sim/db' +import { userTableRows } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { parse as csvParse } from 'csv-parse/sync' +import { eq } from 'drizzle-orm' +import { FunctionExecute, Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { getTableById } from '@/lib/table/service' + +const logger = createLogger('CopilotToolResultTables') + +const MAX_OUTPUT_TABLE_ROWS = 10_000 +const BATCH_CHUNK_SIZE = 500 + +export async function maybeWriteOutputToTable( + toolName: string, + params: Record | undefined, + result: ToolCallResult, + context: ExecutionContext +): Promise { + if (toolName !== FunctionExecute.id) return result + if (!result.success || !result.output) return result + if (!context.workspaceId || !context.userId) return result + + const outputTable = params?.outputTable as string | undefined + if (!outputTable) return result + + try { + const table = await getTableById(outputTable) + if (!table) { + return { + success: false, + error: `Table "${outputTable}" not found`, + } + } + + const rawOutput = result.output + let rows: Array> + + if (rawOutput && typeof rawOutput === 'object' && 'result' in rawOutput) { + const inner = (rawOutput as Record).result + if (Array.isArray(inner)) { + rows = inner + } else { + return { + success: false, + error: 'outputTable requires the code to return an array of objects', + } + } + } else if (Array.isArray(rawOutput)) { + rows = rawOutput + } else { + return { + success: false, + error: 'outputTable requires the code to return an array of objects', + } + } + + if (rows.length > MAX_OUTPUT_TABLE_ROWS) { + return { + success: false, + error: `outputTable row limit exceeded: got ${rows.length}, max is ${MAX_OUTPUT_TABLE_ROWS}`, + } + } + + if (rows.length === 0) { + return { + success: false, + error: 'outputTable requires at least one row — code returned an empty array', + } + } + + if (context.abortSignal?.aborted) { + throw new Error('Request aborted before tool mutation could be applied') + } + await db.transaction(async (tx) => { + if (context.abortSignal?.aborted) { + throw new Error('Request aborted before tool mutation could be applied') + } + await tx.delete(userTableRows).where(eq(userTableRows.tableId, outputTable)) + + const now = new Date() + for (let i = 0; i < rows.length; i += BATCH_CHUNK_SIZE) { + if (context.abortSignal?.aborted) { + throw new Error('Request aborted before tool mutation could be applied') + } + const chunk = rows.slice(i, i + BATCH_CHUNK_SIZE) + const values = chunk.map((rowData, j) => ({ + id: `row_${crypto.randomUUID().replace(/-/g, '')}`, + tableId: outputTable, + workspaceId: context.workspaceId!, + data: rowData, + position: i + j, + createdAt: now, + updatedAt: now, + createdBy: context.userId, + })) + await tx.insert(userTableRows).values(values) + } + }) + + logger.info('Tool output written to table', { + toolName, + tableId: outputTable, + rowCount: rows.length, + }) + + return { + success: true, + output: { + message: `Wrote ${rows.length} rows to table ${outputTable}`, + tableId: outputTable, + rowCount: rows.length, + }, + } + } catch (err) { + logger.warn('Failed to write tool output to table', { + toolName, + outputTable, + error: err instanceof Error ? err.message : String(err), + }) + return { + success: false, + error: `Failed to write to table: ${err instanceof Error ? err.message : String(err)}`, + } + } +} + +export async function maybeWriteReadCsvToTable( + toolName: string, + params: Record | undefined, + result: ToolCallResult, + context: ExecutionContext +): Promise { + if (toolName !== ReadTool.id) return result + if (!result.success || !result.output) return result + if (!context.workspaceId || !context.userId) return result + + const outputTable = params?.outputTable as string | undefined + if (!outputTable) return result + + try { + const table = await getTableById(outputTable) + if (!table) { + return { success: false, error: `Table "${outputTable}" not found` } + } + + const output = result.output as Record + const content = (output.content as string) || '' + if (!content.trim()) { + return { success: false, error: 'File has no content to import into table' } + } + + const filePath = (params?.path as string) || '' + const ext = filePath.split('.').pop()?.toLowerCase() + + let rows: Record[] + + if (ext === 'json') { + const parsed = JSON.parse(content) + if (!Array.isArray(parsed)) { + return { + success: false, + error: 'JSON file must contain an array of objects for table import', + } + } + rows = parsed + } else { + rows = csvParse(content, { + columns: true, + skip_empty_lines: true, + trim: true, + relax_column_count: true, + relax_quotes: true, + skip_records_with_error: true, + cast: false, + }) as Record[] + } + + if (rows.length === 0) { + return { success: false, error: 'File has no data rows to import' } + } + + if (rows.length > MAX_OUTPUT_TABLE_ROWS) { + return { + success: false, + error: `Row limit exceeded: got ${rows.length}, max is ${MAX_OUTPUT_TABLE_ROWS}`, + } + } + + if (context.abortSignal?.aborted) { + throw new Error('Request aborted before tool mutation could be applied') + } + await db.transaction(async (tx) => { + if (context.abortSignal?.aborted) { + throw new Error('Request aborted before tool mutation could be applied') + } + await tx.delete(userTableRows).where(eq(userTableRows.tableId, outputTable)) + + const now = new Date() + for (let i = 0; i < rows.length; i += BATCH_CHUNK_SIZE) { + if (context.abortSignal?.aborted) { + throw new Error('Request aborted before tool mutation could be applied') + } + const chunk = rows.slice(i, i + BATCH_CHUNK_SIZE) + const values = chunk.map((rowData, j) => ({ + id: `row_${crypto.randomUUID().replace(/-/g, '')}`, + tableId: outputTable, + workspaceId: context.workspaceId!, + data: rowData, + position: i + j, + createdAt: now, + updatedAt: now, + createdBy: context.userId, + })) + await tx.insert(userTableRows).values(values) + } + }) + + logger.info('Read output written to table', { + toolName, + tableId: outputTable, + tableName: table.name, + rowCount: rows.length, + filePath, + }) + + return { + success: true, + output: { + message: `Imported ${rows.length} rows from "${filePath}" into table "${table.name}"`, + tableId: outputTable, + tableName: table.name, + rowCount: rows.length, + }, + } + } catch (err) { + logger.warn('Failed to write read output to table', { + toolName, + outputTable, + error: err instanceof Error ? err.message : String(err), + }) + return { + success: false, + error: `Failed to import into table: ${err instanceof Error ? err.message : String(err)}`, + } + } +} diff --git a/apps/sim/lib/copilot/request/trace.ts b/apps/sim/lib/copilot/request/trace.ts new file mode 100644 index 00000000000..72353f19845 --- /dev/null +++ b/apps/sim/lib/copilot/request/trace.ts @@ -0,0 +1,125 @@ +import { createLogger } from '@sim/logger' +import { SIM_AGENT_API_URL } from '@/lib/copilot/constants' +import { + type RequestTraceV1CostSummary, + RequestTraceV1Outcome, + type RequestTraceV1SimReport, + type RequestTraceV1Span, + RequestTraceV1SpanSource, + RequestTraceV1SpanStatus, + type RequestTraceV1UsageSummary, +} from '@/lib/copilot/generated/request-trace-v1' +import { env } from '@/lib/core/config/env' + +const logger = createLogger('RequestTrace') + +export class TraceCollector { + private readonly spans: RequestTraceV1Span[] = [] + private readonly startMs = Date.now() + private goTraceId?: string + private activeSpan?: RequestTraceV1Span + + startSpan( + name: string, + kind: string, + attributes?: Record, + parent?: RequestTraceV1Span + ): RequestTraceV1Span { + const span: RequestTraceV1Span = { + name, + kind, + startMs: Date.now(), + status: RequestTraceV1SpanStatus.ok, + source: RequestTraceV1SpanSource.sim, + ...(parent + ? { parentName: parent.name } + : this.activeSpan + ? { parentName: this.activeSpan.name } + : {}), + ...(attributes && Object.keys(attributes).length > 0 ? { attributes } : {}), + } + this.spans.push(span) + return span + } + + endSpan( + span: RequestTraceV1Span, + status: RequestTraceV1SpanStatus | string = RequestTraceV1SpanStatus.ok + ): void { + span.endMs = Date.now() + span.durationMs = span.endMs - span.startMs + span.status = status as RequestTraceV1SpanStatus + } + + setActiveSpan(span: RequestTraceV1Span | undefined): void { + this.activeSpan = span + } + + setGoTraceId(id: string): void { + if (!this.goTraceId && id) { + this.goTraceId = id + } + } + + build(params: { + outcome: RequestTraceV1Outcome + simRequestId: string + streamId?: string + chatId?: string + runId?: string + executionId?: string + usage?: { prompt: number; completion: number } + cost?: { input: number; output: number; total: number } + }): RequestTraceV1SimReport { + const endMs = Date.now() + const usage: RequestTraceV1UsageSummary | undefined = params.usage + ? { + inputTokens: params.usage.prompt, + outputTokens: params.usage.completion, + } + : undefined + + const cost: RequestTraceV1CostSummary | undefined = params.cost + ? { + rawTotalCost: params.cost.total, + billedTotalCost: params.cost.total, + } + : undefined + + return { + simRequestId: params.simRequestId, + goTraceId: this.goTraceId, + streamId: params.streamId, + chatId: params.chatId, + runId: params.runId, + executionId: params.executionId, + startMs: this.startMs, + endMs, + durationMs: endMs - this.startMs, + outcome: params.outcome, + usage, + cost, + spans: this.spans, + } + } +} + +export async function reportTrace(trace: RequestTraceV1SimReport): Promise { + const response = await fetch(`${SIM_AGENT_API_URL}/api/traces`, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + ...(env.COPILOT_API_KEY ? { 'x-api-key': env.COPILOT_API_KEY } : {}), + }, + body: JSON.stringify(trace), + }) + + if (!response.ok) { + logger.warn('Failed to report trace', { + status: response.status, + simRequestId: trace.simRequestId, + }) + } +} + +export { RequestTraceV1Outcome, RequestTraceV1SpanStatus } diff --git a/apps/sim/lib/copilot/orchestrator/types.ts b/apps/sim/lib/copilot/request/types.ts similarity index 52% rename from apps/sim/lib/copilot/orchestrator/types.ts rename to apps/sim/lib/copilot/request/types.ts index b5a9e412f8e..5114fa5e5ea 100644 --- a/apps/sim/lib/copilot/orchestrator/types.ts +++ b/apps/sim/lib/copilot/request/types.ts @@ -1,71 +1,16 @@ -import type { MothershipResource } from '@/lib/copilot/resource-types' +import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1' +import type { StreamEvent } from '@/lib/copilot/request/session' +import type { TraceCollector } from '@/lib/copilot/request/trace' +import type { ToolExecutionContext, ToolExecutionResult } from '@/lib/copilot/tool-executor/types' -export type SSEEventType = - | 'chat_id' - | 'request_id' - | 'title_updated' - | 'content' - | 'reasoning' - | 'tool_call' - | 'tool_call_delta' - | 'tool_generating' - | 'tool_result' - | 'tool_error' - | 'resource_added' - | 'resource_deleted' - | 'subagent_start' - | 'subagent_end' - | 'structured_result' - | 'subagent_result' - | 'done' - | 'error' - | 'start' +export type { StreamEvent } -export interface SSEEvent { - type: SSEEventType - /** Authoritative tool call state set by the server */ - state?: string - data?: Record - /** Parent agent that produced this event */ - agent?: string - /** Subagent identifier (e.g. "build", "fast_edit") */ - subagent?: string - toolCallId?: string - toolName?: string - success?: boolean - result?: unknown - /** Set on chat_id events */ - chatId?: string - /** Set on title_updated events */ - title?: string - /** Set on error events */ - error?: string - /** Set on content/reasoning events */ - content?: string - /** Set on reasoning events */ - phase?: string - /** UI metadata from copilot (title, icon, phaseLabel) */ - ui?: Record - /** Set on resource_added events */ - resource?: { type: string; id: string; title: string } -} - -export type ToolCallStatus = - | 'pending' - | 'executing' - | 'success' - | 'error' - | 'skipped' - | 'rejected' - | 'cancelled' +export type LocalToolCallStatus = 'pending' | 'executing' +export type ToolCallStatus = LocalToolCallStatus | MothershipStreamV1ToolOutcome -const TERMINAL_TOOL_STATUSES: ReadonlySet = new Set([ - 'success', - 'error', - 'cancelled', - 'skipped', - 'rejected', -]) +const TERMINAL_TOOL_STATUSES: ReadonlySet = new Set( + Object.values(MothershipStreamV1ToolOutcome) +) export function isTerminalToolCallStatus(status?: string): boolean { return TERMINAL_TOOL_STATUSES.has(status as ToolCallStatus) @@ -82,14 +27,18 @@ export interface ToolCallState { endTime?: number } -export interface ToolCallResult { - success: boolean +export interface ToolCallResult extends ToolExecutionResult { output?: T - error?: string - resources?: MothershipResource[] } -export type ContentBlockType = 'text' | 'thinking' | 'tool_call' | 'subagent_text' | 'subagent' +export const ContentBlockType = { + text: 'text', + thinking: 'thinking', + tool_call: 'tool_call', + subagent_text: 'subagent_text', + subagent: 'subagent', +} as const +export type ContentBlockType = (typeof ContentBlockType)[keyof typeof ContentBlockType] export interface ContentBlock { type: ContentBlockType @@ -117,6 +66,11 @@ export interface StreamingContext { executionId?: string runId?: string pendingToolCallIds: string[] + frames?: Array<{ + parentToolCallId: string + parentToolName: string + pendingToolIds: string[] + }> } currentThinkingBlock: ContentBlock | null isInThinkingBlock: boolean @@ -130,6 +84,7 @@ export interface StreamingContext { errors: string[] usage?: { prompt: number; completion: number } cost?: { input: number; output: number; total: number } + trace: TraceCollector } export interface FileAttachment { @@ -160,18 +115,10 @@ export interface OrchestratorRequest { export interface OrchestratorOptions { autoExecuteTools?: boolean timeout?: number - onEvent?: (event: SSEEvent) => void | Promise + onEvent?: (event: StreamEvent) => void | Promise onComplete?: (result: OrchestratorResult) => void | Promise onError?: (error: Error) => void | Promise abortSignal?: AbortSignal - /** Fires only on explicit user stop, never on passive transport disconnect. */ - userStopSignal?: AbortSignal - /** - * Fires when the SSE client disconnects (tab close, navigation, etc.). - * Used to short-circuit `waitForToolCompletion` for client-executable tools - * so the orchestrator doesn't block for the full 60-min timeout. - */ - clientDisconnectedSignal?: AbortSignal interactive?: boolean } @@ -198,18 +145,6 @@ export interface ToolCallSummary { durationMs?: number } -export interface ExecutionContext { - userId: string - workflowId: string - workspaceId?: string - chatId?: string +export interface ExecutionContext extends ToolExecutionContext { messageId?: string - executionId?: string - runId?: string - abortSignal?: AbortSignal - /** Fires only on explicit user stop, never on passive transport disconnect. */ - userStopSignal?: AbortSignal - userTimezone?: string - userPermission?: string - decryptedEnvVars?: Record } diff --git a/apps/sim/lib/copilot/resource-types.ts b/apps/sim/lib/copilot/resource-types.ts deleted file mode 100644 index a3b3f3f4bfb..00000000000 --- a/apps/sim/lib/copilot/resource-types.ts +++ /dev/null @@ -1,14 +0,0 @@ -export type MothershipResourceType = 'table' | 'file' | 'workflow' | 'knowledgebase' | 'generic' - -export interface MothershipResource { - type: MothershipResourceType - id: string - title: string -} - -export const VFS_DIR_TO_RESOURCE: Record = { - tables: 'table', - files: 'file', - workflows: 'workflow', - knowledgebases: 'knowledgebase', -} as const diff --git a/apps/sim/lib/copilot/resource-extraction.test.ts b/apps/sim/lib/copilot/resources/extraction.test.ts similarity index 96% rename from apps/sim/lib/copilot/resource-extraction.test.ts rename to apps/sim/lib/copilot/resources/extraction.test.ts index bb1b1677098..acf64688032 100644 --- a/apps/sim/lib/copilot/resource-extraction.test.ts +++ b/apps/sim/lib/copilot/resources/extraction.test.ts @@ -2,7 +2,7 @@ * @vitest-environment node */ import { describe, expect, it } from 'vitest' -import { extractResourcesFromToolResult } from './resource-extraction' +import { extractResourcesFromToolResult } from './extraction' describe('extractResourcesFromToolResult', () => { it('uses the knowledge base id for knowledge_base tag mutations', () => { diff --git a/apps/sim/lib/copilot/resource-extraction.ts b/apps/sim/lib/copilot/resources/extraction.ts similarity index 64% rename from apps/sim/lib/copilot/resource-extraction.ts rename to apps/sim/lib/copilot/resources/extraction.ts index 9887ea5ded3..ca16417fb83 100644 --- a/apps/sim/lib/copilot/resource-extraction.ts +++ b/apps/sim/lib/copilot/resources/extraction.ts @@ -1,96 +1,36 @@ -import type { MothershipResource, MothershipResourceType } from '@/lib/copilot/resource-types' +import { + CreateWorkflow, + DeleteWorkflow, + DownloadToWorkspaceFile, + EditWorkflow, + FunctionExecute, + GenerateImage, + GenerateVisualization, + Knowledge, + KnowledgeBase, + UserTable, + WorkspaceFile, +} from '@/lib/copilot/generated/tool-catalog-v1' +import type { MothershipResource, MothershipResourceType } from './types' type ChatResource = MothershipResource type ResourceType = MothershipResourceType -/** - * Defines how each tool's result is surfaced in the resource panel: - * - `dedicated` — opens its own resource tab (table, file, workflow, knowledgebase) - * - `deferred` — may open a dedicated tab; falls back to the Results tab if no resource is produced - * - `excluded` — hidden from the resource panel (internal tools, management, subagent wrappers) - * - * Any tool not listed here appears in the generic Results tab by default. - */ -const TOOL_PANEL_BEHAVIOR: Record = { - // Dedicated resource tab openers - user_table: 'dedicated', - workspace_file: 'dedicated', - download_to_workspace_file: 'dedicated', - create_workflow: 'dedicated', - edit_workflow: 'dedicated', - knowledge_base: 'dedicated', - knowledge: 'dedicated', - generate_visualization: 'dedicated', - generate_image: 'dedicated', - // Deferred: may produce a dedicated resource; falls back to Results tab otherwise - function_execute: 'deferred', - // Excluded: saves files without opening a resource tab - materialize_file: 'excluded', - // Excluded: internal / invisible - user_memory: 'excluded', - context_write: 'excluded', - context_compaction: 'excluded', - // Excluded: workflow and folder management - rename_workflow: 'excluded', - move_workflow: 'excluded', - delete_workflow: 'excluded', - create_folder: 'excluded', - delete_folder: 'excluded', - move_folder: 'excluded', - list_folders: 'excluded', - list_user_workspaces: 'excluded', - open_resource: 'excluded', - // Excluded: settings and credential management - set_environment_variables: 'excluded', - set_global_workflow_variables: 'excluded', - manage_mcp_tool: 'excluded', - manage_skill: 'excluded', - manage_credential: 'excluded', - manage_custom_tool: 'excluded', - oauth_get_auth_link: 'excluded', - oauth_request_access: 'excluded', - update_workspace_mcp_server: 'excluded', - delete_workspace_mcp_server: 'excluded', - create_workspace_mcp_server: 'excluded', - list_workspace_mcp_servers: 'excluded', - // Excluded: subagent wrappers — inner tools fire as individual events - build: 'excluded', - run: 'excluded', - deploy: 'excluded', - auth: 'excluded', - table: 'excluded', - job: 'excluded', - agent: 'excluded', - custom_tool: 'excluded', - research: 'excluded', - plan: 'excluded', - debug: 'excluded', - edit: 'excluded', - fast_edit: 'excluded', -} +const RESOURCE_TOOL_NAMES: Set = new Set([ + UserTable.id, + WorkspaceFile.id, + DownloadToWorkspaceFile.id, + CreateWorkflow.id, + EditWorkflow.id, + FunctionExecute.id, + KnowledgeBase.id, + Knowledge.id, + GenerateVisualization.id, + GenerateImage.id, +]) -/** - * Returns true for resources that are client-only and must never be persisted to the server. - * This covers the generic Results tab and the in-flight streaming-file preview. - */ -export function isEphemeralResource(resource: { id: string; type: string }): boolean { - return resource.type === 'generic' || resource.id === 'streaming-file' -} - -/** Returns true for tools that open a dedicated resource tab or may fall back to the Results tab. */ export function isResourceToolName(toolName: string): boolean { - const b = TOOL_PANEL_BEHAVIOR[toolName] - return b === 'dedicated' || b === 'deferred' -} - -/** Returns true if the tool's result should appear in the Results tab at call time. */ -export function shouldOpenGenericResource(toolName: string): boolean { - return !(toolName in TOOL_PANEL_BEHAVIOR) -} - -/** Returns true for tools that may fall back to the Results tab at completion time. */ -export function isDeferredResourceTool(toolName: string): boolean { - return TOOL_PANEL_BEHAVIOR[toolName] === 'deferred' + return RESOURCE_TOOL_NAMES.has(toolName) } function asRecord(value: unknown): Record { @@ -122,7 +62,7 @@ export function extractResourcesFromToolResult( const data = asRecord(result.data) switch (toolName) { - case 'user_table': { + case UserTable.id: { if (READ_ONLY_TABLE_OPS.has(getOperation(params) ?? '')) return [] if (result.tableId) { @@ -158,7 +98,7 @@ export function extractResourcesFromToolResult( return [] } - case 'workspace_file': { + case WorkspaceFile.id: { const file = asRecord(data.file) if (file.id) { return [{ type: 'file', id: file.id as string, title: (file.name as string) || 'File' }] @@ -171,7 +111,7 @@ export function extractResourcesFromToolResult( return [] } - case 'function_execute': { + case FunctionExecute.id: { if (result.tableId) { return [ { @@ -193,9 +133,9 @@ export function extractResourcesFromToolResult( return [] } - case 'download_to_workspace_file': - case 'generate_visualization': - case 'generate_image': { + case DownloadToWorkspaceFile.id: + case GenerateVisualization.id: + case GenerateImage.id: { if (result.fileId) { return [ { @@ -208,8 +148,8 @@ export function extractResourcesFromToolResult( return [] } - case 'create_workflow': - case 'edit_workflow': { + case CreateWorkflow.id: + case EditWorkflow.id: { const workflowId = (result.workflowId as string) ?? (data.workflowId as string) ?? @@ -225,7 +165,7 @@ export function extractResourcesFromToolResult( return [] } - case 'knowledge_base': { + case KnowledgeBase.id: { if (READ_ONLY_KB_OPS.has(getOperation(params) ?? '')) return [] const args = asRecord(params?.args) @@ -243,7 +183,7 @@ export function extractResourcesFromToolResult( return [] } - case 'knowledge': { + case Knowledge.id: { const action = data.action as string | undefined if (READ_ONLY_KNOWLEDGE_ACTIONS.has(action ?? '')) return [] @@ -269,10 +209,10 @@ export function extractResourcesFromToolResult( } const DELETE_CAPABLE_TOOL_RESOURCE_TYPE: Record = { - delete_workflow: 'workflow', - workspace_file: 'file', - user_table: 'table', - knowledge_base: 'knowledgebase', + [DeleteWorkflow.id]: 'workflow', + [WorkspaceFile.id]: 'file', + [UserTable.id]: 'table', + [KnowledgeBase.id]: 'knowledgebase', } export function hasDeleteCapability(toolName: string): boolean { @@ -298,7 +238,7 @@ export function extractDeletedResourcesFromToolResult( const operation = (args.operation ?? params?.operation) as string | undefined switch (toolName) { - case 'delete_workflow': { + case DeleteWorkflow.id: { const workflowId = (result.workflowId as string) ?? (params?.workflowId as string) if (workflowId && result.deleted) { return [ @@ -308,7 +248,7 @@ export function extractDeletedResourcesFromToolResult( return [] } - case 'workspace_file': { + case WorkspaceFile.id: { if (operation !== 'delete') return [] const fileId = (data.id as string) ?? (args.fileId as string) if (fileId) { @@ -317,7 +257,7 @@ export function extractDeletedResourcesFromToolResult( return [] } - case 'user_table': { + case UserTable.id: { if (operation !== 'delete') return [] const tableId = (args.tableId as string) ?? (params?.tableId as string) if (tableId) { @@ -326,7 +266,7 @@ export function extractDeletedResourcesFromToolResult( return [] } - case 'knowledge_base': { + case KnowledgeBase.id: { if (operation !== 'delete') return [] const kbId = (data.id as string) ?? (args.knowledgeBaseId as string) if (kbId) { diff --git a/apps/sim/lib/copilot/resources.ts b/apps/sim/lib/copilot/resources/persistence.ts similarity index 81% rename from apps/sim/lib/copilot/resources.ts rename to apps/sim/lib/copilot/resources/persistence.ts index 5efec3dd50b..08323369e1e 100644 --- a/apps/sim/lib/copilot/resources.ts +++ b/apps/sim/lib/copilot/resources/persistence.ts @@ -2,32 +2,32 @@ import { db } from '@sim/db' import { copilotChats } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq, sql } from 'drizzle-orm' -import { isEphemeralResource } from '@/lib/copilot/resource-extraction' -import type { MothershipResource } from '@/lib/copilot/resource-types' +import type { MothershipResource } from './types' export { extractDeletedResourcesFromToolResult, extractResourcesFromToolResult, hasDeleteCapability, - isEphemeralResource, isResourceToolName, -} from '@/lib/copilot/resource-extraction' +} from './extraction' export type { MothershipResource as ChatResource, MothershipResourceType as ResourceType, -} from '@/lib/copilot/resource-types' +} from './types' const logger = createLogger('CopilotResources') +type ChatResource = MothershipResource + /** * Appends resources to a chat's JSONB resources column, deduplicating by type+id. * Updates the title of existing resources if the new title is more specific. */ export async function persistChatResources( chatId: string, - newResources: MothershipResource[] + newResources: ChatResource[] ): Promise { - const toMerge = newResources.filter((r) => !isEphemeralResource(r)) + const toMerge = newResources.filter((r) => r.id !== 'streaming-file') if (toMerge.length === 0) return try { @@ -39,8 +39,8 @@ export async function persistChatResources( if (!chat) return - const existing = Array.isArray(chat.resources) ? (chat.resources as MothershipResource[]) : [] - const map = new Map() + const existing = Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : [] + const map = new Map() const GENERIC = new Set(['Table', 'File', 'Workflow', 'Knowledge Base']) for (const r of existing) { @@ -72,10 +72,7 @@ export async function persistChatResources( /** * Removes resources from a chat's JSONB resources column by type+id. */ -export async function removeChatResources( - chatId: string, - toRemove: MothershipResource[] -): Promise { +export async function removeChatResources(chatId: string, toRemove: ChatResource[]): Promise { if (toRemove.length === 0) return try { @@ -87,7 +84,7 @@ export async function removeChatResources( if (!chat) return - const existing = Array.isArray(chat.resources) ? (chat.resources as MothershipResource[]) : [] + const existing = Array.isArray(chat.resources) ? (chat.resources as ChatResource[]) : [] const removeKeys = new Set(toRemove.map((r) => `${r.type}:${r.id}`)) const filtered = existing.filter((r) => !removeKeys.has(`${r.type}:${r.id}`)) diff --git a/apps/sim/lib/copilot/resources/types.ts b/apps/sim/lib/copilot/resources/types.ts new file mode 100644 index 00000000000..98595895544 --- /dev/null +++ b/apps/sim/lib/copilot/resources/types.ts @@ -0,0 +1,26 @@ +export const MothershipResourceType = { + table: 'table', + file: 'file', + workflow: 'workflow', + knowledgebase: 'knowledgebase', + generic: 'generic', +} as const +export type MothershipResourceType = + (typeof MothershipResourceType)[keyof typeof MothershipResourceType] + +export interface MothershipResource { + type: MothershipResourceType + id: string + title: string +} + +export function isEphemeralResource(resource: MothershipResource): boolean { + return resource.type === 'generic' || resource.id === 'streaming-file' +} + +export const VFS_DIR_TO_RESOURCE: Record = { + tables: 'table', + files: 'file', + workflows: 'workflow', + knowledgebases: 'knowledgebase', +} as const diff --git a/apps/sim/lib/copilot/task-events.ts b/apps/sim/lib/copilot/tasks.ts similarity index 95% rename from apps/sim/lib/copilot/task-events.ts rename to apps/sim/lib/copilot/tasks.ts index 50653809bf4..5828a711cb4 100644 --- a/apps/sim/lib/copilot/task-events.ts +++ b/apps/sim/lib/copilot/tasks.ts @@ -9,7 +9,7 @@ import { createPubSubChannel } from '@/lib/events/pubsub' -export interface TaskStatusEvent { +interface TaskStatusEvent { workspaceId: string chatId: string type: 'started' | 'completed' | 'created' | 'deleted' | 'renamed' diff --git a/apps/sim/lib/copilot/tool-executor/executor.test.ts b/apps/sim/lib/copilot/tool-executor/executor.test.ts new file mode 100644 index 00000000000..4ae97dd45c7 --- /dev/null +++ b/apps/sim/lib/copilot/tool-executor/executor.test.ts @@ -0,0 +1,61 @@ +/** + * @vitest-environment node + */ + +import { beforeEach, describe, expect, it, vi } from 'vitest' + +const { isKnownTool, isSimExecuted } = vi.hoisted(() => ({ + isKnownTool: vi.fn(), + isSimExecuted: vi.fn(), +})) + +const { executeAppTool } = vi.hoisted(() => ({ + executeAppTool: vi.fn(), +})) + +vi.mock('./router', () => ({ + isKnownTool, + isSimExecuted, +})) + +vi.mock('@/tools', () => ({ + executeTool: executeAppTool, +})) + +import { executeTool } from './executor' + +describe('copilot tool executor fallback', () => { + beforeEach(() => { + vi.clearAllMocks() + }) + + it('falls back to app tool executor for dynamic sim tools', async () => { + isKnownTool.mockReturnValue(false) + isSimExecuted.mockReturnValue(false) + executeAppTool.mockResolvedValue({ success: true, output: { emails: [] } }) + + const result = await executeTool( + 'gmail_read', + { maxResults: 10, credentialId: 'cred-123' }, + { userId: 'user-1', workflowId: 'workflow-1', workspaceId: 'ws-1', chatId: 'chat-1' } + ) + + expect(executeAppTool).toHaveBeenCalledWith( + 'gmail_read', + expect.objectContaining({ + maxResults: 10, + credentialId: 'cred-123', + credential: 'cred-123', + _context: expect.objectContaining({ + userId: 'user-1', + workflowId: 'workflow-1', + workspaceId: 'ws-1', + chatId: 'chat-1', + enforceCredentialAccess: true, + }), + }), + false + ) + expect(result).toEqual({ success: true, output: { emails: [] } }) + }) +}) diff --git a/apps/sim/lib/copilot/tool-executor/executor.ts b/apps/sim/lib/copilot/tool-executor/executor.ts new file mode 100644 index 00000000000..734246e16fd --- /dev/null +++ b/apps/sim/lib/copilot/tool-executor/executor.ts @@ -0,0 +1,112 @@ +import { createLogger } from '@sim/logger' +import { executeTool as executeAppTool } from '@/tools' +import { isKnownTool, isSimExecuted } from './router' +import type { + ToolCallDescriptor, + ToolExecutionContext, + ToolExecutionResult, + ToolHandler, +} from './types' + +const logger = createLogger('ToolExecutor') + +const handlerRegistry = new Map() + +export function registerHandler(toolId: string, handler: ToolHandler): void { + handlerRegistry.set(toolId, handler) +} + +export function registerHandlers(entries: Record): void { + for (const [toolId, handler] of Object.entries(entries)) { + handlerRegistry.set(toolId, handler) + } +} + +export function getRegisteredToolIds(): string[] { + return Array.from(handlerRegistry.keys()) +} + +export function hasHandler(toolId: string): boolean { + return handlerRegistry.has(toolId) +} + +export async function executeTool( + toolId: string, + params: Record, + context: ToolExecutionContext +): Promise { + const canUseRegisteredHandler = isKnownTool(toolId) && isSimExecuted(toolId) + if (!canUseRegisteredHandler) { + const appParams = buildAppToolParams(params, context) + return executeAppTool(toolId, appParams, false) + } + + if (context.abortSignal?.aborted) { + return { success: false, error: 'Execution aborted' } + } + + const handler = handlerRegistry.get(toolId) + if (!handler) { + logger.warn('No handler registered for tool', { toolId }) + return { success: false, error: `No handler for tool: ${toolId}` } + } + + try { + return await handler(params, context) + } catch (error) { + const message = error instanceof Error ? error.message : String(error) + logger.error('Tool execution failed', { toolId, error: message }) + return { success: false, error: message } + } +} + +export async function executeToolBatch( + toolCalls: ToolCallDescriptor[], + context: ToolExecutionContext +): Promise> { + const results = new Map() + + const executions = toolCalls.map(async ({ toolCallId, toolId, params }) => { + const result = await executeTool(toolId, params, context) + results.set(toolCallId, result) + }) + + await Promise.allSettled(executions) + + for (const { toolCallId } of toolCalls) { + if (!results.has(toolCallId)) { + results.set(toolCallId, { + success: false, + error: 'Tool execution did not produce a result', + }) + } + } + + return results +} + +function buildAppToolParams( + params: Record, + context: ToolExecutionContext +): Record { + const result = { ...params } + + if (result.credentialId && !result.credential && !result.oauthCredential) { + result.credential = result.credentialId + } + + result._context = { + ...(typeof result._context === 'object' && result._context !== null + ? (result._context as object) + : {}), + userId: context.userId, + workflowId: context.workflowId, + workspaceId: context.workspaceId, + chatId: context.chatId, + executionId: context.executionId, + runId: context.runId, + enforceCredentialAccess: true, + } + + return result +} diff --git a/apps/sim/lib/copilot/tool-executor/index.ts b/apps/sim/lib/copilot/tool-executor/index.ts new file mode 100644 index 00000000000..a623fe73509 --- /dev/null +++ b/apps/sim/lib/copilot/tool-executor/index.ts @@ -0,0 +1,24 @@ +export { + executeTool, + executeToolBatch, + getRegisteredToolIds, + hasHandler, + registerHandler, + registerHandlers, +} from './executor' +export { ensureHandlersRegistered } from './register-handlers' +export { + isGoExecuted, + isKnownTool, + isSimExecuted, + type PartitionedBatch, + partitionToolBatch, + routeToolCall, + type ToolRoute, +} from './router' +export type { + ToolCallDescriptor, + ToolExecutionContext, + ToolExecutionResult, + ToolHandler, +} from './types' diff --git a/apps/sim/lib/copilot/tool-executor/register-handlers.ts b/apps/sim/lib/copilot/tool-executor/register-handlers.ts new file mode 100644 index 00000000000..3095b2d6dec --- /dev/null +++ b/apps/sim/lib/copilot/tool-executor/register-handlers.ts @@ -0,0 +1,185 @@ +import { createLogger } from '@sim/logger' +import { + CheckDeploymentStatus, + CompleteJob, + CreateFolder, + CreateJob, + CreateWorkflow, + CreateWorkspaceMcpServer, + DeleteFolder, + DeleteWorkflow, + DeleteWorkspaceMcpServer, + DeployApi, + DeployChat, + DeployMcp, + FunctionExecute, + GenerateApiKey, + GetBlockOutputs, + GetBlockUpstreamReferences, + GetDeployedWorkflowState, + GetDeploymentVersion, + GetPlatformActions, + GetWorkflowData, + Glob as GlobTool, + Grep as GrepTool, + ListFolders, + ListUserWorkspaces, + ListWorkspaceMcpServers, + ManageCredential, + ManageCustomTool, + ManageJob, + ManageMcpTool, + ManageSkill, + MaterializeFile, + OauthGetAuthLink, + OauthRequestAccess, + OpenResource, + Read as ReadTool, + Redeploy, + RevertToVersion, + RunBlock, + RunFromBlock, + RunWorkflow, + RunWorkflowUntilBlock, + SetGlobalWorkflowVariables, + UpdateJobHistory, + UpdateWorkspaceMcpServer, +} from '@/lib/copilot/generated/tool-catalog-v1' +import { createServerToolHandler } from '@/lib/copilot/tools/registry/server-tool-adapter' +import { getRegisteredServerToolNames } from '@/lib/copilot/tools/server/router' +import { + executeDeployApi, + executeDeployChat, + executeDeployMcp, + executeRedeploy, +} from '../tools/handlers/deployment/deploy' +import { + executeCheckDeploymentStatus, + executeCreateWorkspaceMcpServer, + executeDeleteWorkspaceMcpServer, + executeGetDeploymentVersion, + executeListWorkspaceMcpServers, + executeRevertToVersion, + executeUpdateWorkspaceMcpServer, +} from '../tools/handlers/deployment/manage' +import { executeFunctionExecute } from '../tools/handlers/function-execute' +import { + executeCompleteJob, + executeCreateJob, + executeManageJob, + executeUpdateJobHistory, +} from '../tools/handlers/jobs' +import { executeManageCredential } from '../tools/handlers/management/manage-credential' +import { executeManageCustomTool } from '../tools/handlers/management/manage-custom-tool' +import { executeManageMcpTool } from '../tools/handlers/management/manage-mcp-tool' +import { executeManageSkill } from '../tools/handlers/management/manage-skill' +import { executeMaterializeFile } from '../tools/handlers/materialize-file' +import { executeOAuthGetAuthLink, executeOAuthRequestAccess } from '../tools/handlers/oauth' +import { executeGetPlatformActions } from '../tools/handlers/platform' +import { executeOpenResource } from '../tools/handlers/resources' +import { executeVfsGlob, executeVfsGrep, executeVfsRead } from '../tools/handlers/vfs' +import { + executeCreateFolder, + executeCreateWorkflow, + executeDeleteFolder, + executeDeleteWorkflow, + executeGenerateApiKey, + executeRunBlock, + executeRunFromBlock, + executeRunWorkflow, + executeRunWorkflowUntilBlock, + executeSetGlobalWorkflowVariables, +} from '../tools/handlers/workflow/mutations' +import { + executeGetBlockOutputs, + executeGetBlockUpstreamReferences, + executeGetDeployedWorkflowState, + executeGetWorkflowData, + executeListFolders, + executeListUserWorkspaces, +} from '../tools/handlers/workflow/queries' +import { registerHandlers } from './executor' +import type { ToolHandler } from './types' + +const logger = createLogger('ToolHandlerRegistration') + +let registered = false + +export function ensureHandlersRegistered(): void { + if (registered) return + registered = true + registerHandlers(buildHandlerMap()) + logger.info('Tool handlers registered') +} + +// Bridge: handler implementations accept specific param types (e.g. CreateWorkflowParams) +// while ToolHandler accepts Record. The params are cast internally by +// each implementation. ExecutionContext extends ToolExecutionContext so context is compatible. +function h(fn: (params: any, context: any) => Promise): ToolHandler { + return fn as ToolHandler +} + +function buildHandlerMap(): Record { + return { + [ListUserWorkspaces.id]: h((_p, c) => executeListUserWorkspaces(c)), + [ListFolders.id]: h(executeListFolders), + [GetWorkflowData.id]: h(executeGetWorkflowData), + [GetBlockOutputs.id]: h(executeGetBlockOutputs), + [GetBlockUpstreamReferences.id]: h(executeGetBlockUpstreamReferences), + [GetDeployedWorkflowState.id]: h(executeGetDeployedWorkflowState), + + [CreateWorkflow.id]: h(executeCreateWorkflow), + [CreateFolder.id]: h(executeCreateFolder), + [DeleteWorkflow.id]: h(executeDeleteWorkflow), + [DeleteFolder.id]: h(executeDeleteFolder), + [RunWorkflow.id]: h(executeRunWorkflow), + [RunWorkflowUntilBlock.id]: h(executeRunWorkflowUntilBlock), + [RunFromBlock.id]: h(executeRunFromBlock), + [RunBlock.id]: h(executeRunBlock), + [GenerateApiKey.id]: h(executeGenerateApiKey), + [SetGlobalWorkflowVariables.id]: h(executeSetGlobalWorkflowVariables), + + [DeployApi.id]: h(executeDeployApi), + [DeployChat.id]: h(executeDeployChat), + [DeployMcp.id]: h(executeDeployMcp), + [Redeploy.id]: h(executeRedeploy), + [CheckDeploymentStatus.id]: h(executeCheckDeploymentStatus), + [ListWorkspaceMcpServers.id]: h(executeListWorkspaceMcpServers), + [CreateWorkspaceMcpServer.id]: h(executeCreateWorkspaceMcpServer), + [UpdateWorkspaceMcpServer.id]: h(executeUpdateWorkspaceMcpServer), + [DeleteWorkspaceMcpServer.id]: h(executeDeleteWorkspaceMcpServer), + [GetDeploymentVersion.id]: h(executeGetDeploymentVersion), + [RevertToVersion.id]: h(executeRevertToVersion), + + [CreateJob.id]: h(executeCreateJob), + [ManageJob.id]: h(executeManageJob), + [CompleteJob.id]: h(executeCompleteJob), + [UpdateJobHistory.id]: h(executeUpdateJobHistory), + + [GrepTool.id]: h(executeVfsGrep), + [GlobTool.id]: h(executeVfsGlob), + [ReadTool.id]: h(executeVfsRead), + + [ManageCustomTool.id]: h(executeManageCustomTool), + [ManageMcpTool.id]: h(executeManageMcpTool), + [ManageSkill.id]: h(executeManageSkill), + [ManageCredential.id]: h(executeManageCredential), + [OauthGetAuthLink.id]: h(executeOAuthGetAuthLink), + [OauthRequestAccess.id]: h(executeOAuthRequestAccess), + [OpenResource.id]: h(executeOpenResource), + [GetPlatformActions.id]: h(executeGetPlatformActions), + [MaterializeFile.id]: h(executeMaterializeFile), + [FunctionExecute.id]: h(executeFunctionExecute), + + ...buildServerToolHandlers(), + } +} + +function buildServerToolHandlers(): Record { + const toolNames = getRegisteredServerToolNames() + const handlers: Record = {} + for (const toolId of toolNames) { + handlers[toolId] = createServerToolHandler(toolId) + } + return handlers +} diff --git a/apps/sim/lib/copilot/tool-executor/router.ts b/apps/sim/lib/copilot/tool-executor/router.ts new file mode 100644 index 00000000000..18202762d66 --- /dev/null +++ b/apps/sim/lib/copilot/tool-executor/router.ts @@ -0,0 +1,59 @@ +import { TOOL_CATALOG, type ToolCatalogEntry } from '@/lib/copilot/generated/tool-catalog-v1' +import type { ToolCallDescriptor } from './types' + +export type ToolExecutor = ToolCatalogEntry['executor'] + +export function isToolInCatalog(toolId: string): boolean { + return toolId in TOOL_CATALOG +} + +export function getToolEntry(toolId: string): ToolCatalogEntry | undefined { + return TOOL_CATALOG[toolId] +} + +export type ToolRoute = { + executor: ToolExecutor + mode: ToolCatalogEntry['mode'] + subagentId?: string +} + +export function routeToolCall(toolId: string): ToolRoute | null { + const entry = getToolEntry(toolId) + if (!entry) return null + return { executor: entry.executor, mode: entry.mode, subagentId: entry.subagentId } +} + +export function isSimExecuted(toolId: string): boolean { + return getToolEntry(toolId)?.executor === 'sim' +} + +export function isGoExecuted(toolId: string): boolean { + return getToolEntry(toolId)?.executor === 'go' +} + +export function isKnownTool(toolId: string): boolean { + return isToolInCatalog(toolId) +} + +export interface PartitionedBatch { + sim: ToolCallDescriptor[] + go: ToolCallDescriptor[] + subagent: ToolCallDescriptor[] + client: ToolCallDescriptor[] + unknown: ToolCallDescriptor[] +} + +export function partitionToolBatch(toolCalls: ToolCallDescriptor[]): PartitionedBatch { + const result: PartitionedBatch = { sim: [], go: [], subagent: [], client: [], unknown: [] } + + for (const tc of toolCalls) { + const route = routeToolCall(tc.toolId) + if (!route) { + result.unknown.push(tc) + continue + } + result[route.executor].push(tc) + } + + return result +} diff --git a/apps/sim/lib/copilot/tool-executor/types.ts b/apps/sim/lib/copilot/tool-executor/types.ts new file mode 100644 index 00000000000..60ecda8db45 --- /dev/null +++ b/apps/sim/lib/copilot/tool-executor/types.ts @@ -0,0 +1,32 @@ +import type { MothershipResource } from '@/lib/copilot/resources/types' + +export interface ToolExecutionContext { + userId: string + workflowId: string + workspaceId?: string + chatId?: string + executionId?: string + runId?: string + abortSignal?: AbortSignal + userTimezone?: string + userPermission?: string + decryptedEnvVars?: Record +} + +export interface ToolExecutionResult { + success: boolean + output?: unknown + error?: string + resources?: MothershipResource[] +} + +export type ToolHandler = ( + params: Record, + context: ToolExecutionContext +) => Promise + +export interface ToolCallDescriptor { + toolCallId: string + toolId: string + params: Record +} diff --git a/apps/sim/lib/copilot/client-sse/run-tool-execution.test.ts b/apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts similarity index 100% rename from apps/sim/lib/copilot/client-sse/run-tool-execution.test.ts rename to apps/sim/lib/copilot/tools/client/run-tool-execution.test.ts diff --git a/apps/sim/lib/copilot/client-sse/run-tool-execution.ts b/apps/sim/lib/copilot/tools/client/run-tool-execution.ts similarity index 88% rename from apps/sim/lib/copilot/client-sse/run-tool-execution.ts rename to apps/sim/lib/copilot/tools/client/run-tool-execution.ts index c289202d505..bb0f97f75c3 100644 --- a/apps/sim/lib/copilot/client-sse/run-tool-execution.ts +++ b/apps/sim/lib/copilot/tools/client/run-tool-execution.ts @@ -1,6 +1,12 @@ import { createLogger } from '@sim/logger' import { v4 as uuidv4 } from 'uuid' import { COPILOT_CONFIRM_API_PATH } from '@/lib/copilot/constants' +import { MothershipStreamV1ToolOutcome } from '@/lib/copilot/generated/mothership-stream-v1' +import { + RunBlock, + RunFromBlock, + RunWorkflowUntilBlock, +} from '@/lib/copilot/generated/tool-catalog-v1' import { ClientToolCallState } from '@/lib/copilot/tools/client/tool-display-registry' import { executeWorkflowWithFullLogging } from '@/app/workspace/[workspaceId]/w/[workflowId]/utils/workflow-execution-utils' import { useExecutionStore } from '@/stores/execution/store' @@ -50,6 +56,13 @@ export function markRunToolManuallyStopped(workflowId: string): string | null { return toolCallId } +export function isRunToolActiveForId(toolCallId: string): boolean { + for (const activeId of activeRunToolByWorkflowId.values()) { + if (activeId === toolCallId) return true + } + return false +} + export function cancelRunToolExecution(workflowId: string): void { const controller = activeRunAbortByWorkflowId.get(workflowId) if (!controller) return @@ -76,7 +89,7 @@ export async function reportManualRunToolStop( await reportCompletion( toolCallId, - 'cancelled', + MothershipStreamV1ToolOutcome.cancelled, 'Workflow execution was stopped manually by the user.', { reason: 'user_cancelled', @@ -100,7 +113,11 @@ async function doExecuteRunTool( if (!targetWorkflowId) { logger.warn('[RunTool] Execution prevented: no active workflow', { toolCallId, toolName }) setToolState(toolCallId, ClientToolCallState.error) - await reportCompletion(toolCallId, 'error', 'No active workflow found') + await reportCompletion( + toolCallId, + MothershipStreamV1ToolOutcome.error, + 'No active workflow found' + ) return } @@ -113,7 +130,11 @@ async function doExecuteRunTool( if (isExecuting) { logger.warn('[RunTool] Execution prevented: already executing', { toolCallId, toolName }) setToolState(toolCallId, ClientToolCallState.error) - await reportCompletion(toolCallId, 'error', 'Workflow is already executing. Try again later') + await reportCompletion( + toolCallId, + MothershipStreamV1ToolOutcome.error, + 'Workflow is already executing. Try again later' + ) return } @@ -123,20 +144,19 @@ async function doExecuteRunTool( | undefined const stopAfterBlockId = (() => { - if (toolName === 'run_workflow_until_block') - return params.stopAfterBlockId as string | undefined - if (toolName === 'run_block') return params.blockId as string | undefined + if (toolName === RunWorkflowUntilBlock.id) return params.stopAfterBlockId as string | undefined + if (toolName === RunBlock.id) return params.blockId as string | undefined return undefined })() const runFromBlock = (() => { - if (toolName === 'run_from_block' && params.startBlockId) { + if (toolName === RunFromBlock.id && params.startBlockId) { return { startBlockId: params.startBlockId as string, executionId: (params.executionId as string | undefined) || 'latest', } } - if (toolName === 'run_block' && params.blockId) { + if (toolName === RunBlock.id && params.blockId) { return { startBlockId: params.blockId as string, executionId: (params.executionId as string | undefined) || 'latest', @@ -230,7 +250,7 @@ async function doExecuteRunTool( setToolState(toolCallId, ClientToolCallState.success) await reportCompletion( toolCallId, - 'success', + MothershipStreamV1ToolOutcome.success, `Workflow execution completed. Started at: ${executionStartTime}`, buildResultData(result) ) @@ -238,7 +258,12 @@ async function doExecuteRunTool( const msg = errorMessage || 'Workflow execution failed' logger.error('[RunTool] Workflow execution failed', { toolCallId, toolName, error: msg }) setToolState(toolCallId, ClientToolCallState.error) - await reportCompletion(toolCallId, 'error', msg, buildResultData(result)) + await reportCompletion( + toolCallId, + MothershipStreamV1ToolOutcome.error, + msg, + buildResultData(result) + ) } } catch (err) { if (manuallyStoppedToolCallIds.has(toolCallId)) { @@ -250,7 +275,7 @@ async function doExecuteRunTool( const msg = err instanceof Error ? err.message : String(err) logger.error('[RunTool] Workflow execution threw', { toolCallId, toolName, error: msg }) setToolState(toolCallId, ClientToolCallState.error) - await reportCompletion(toolCallId, 'error', msg) + await reportCompletion(toolCallId, MothershipStreamV1ToolOutcome.error, msg) } } finally { if (typeof window !== 'undefined') { @@ -315,7 +340,7 @@ function buildResultData(result: unknown): Record | undefined { */ async function reportCompletion( toolCallId: string, - status: 'success' | 'error' | 'cancelled', + status: MothershipStreamV1ToolOutcome, message?: string, data?: Record ): Promise { @@ -326,7 +351,9 @@ async function reportCompletion( body: JSON.stringify({ toolCallId, status, - message: message || (status === 'success' ? 'Tool completed' : 'Tool failed'), + message: + message || + (status === MothershipStreamV1ToolOutcome.success ? 'Tool completed' : 'Tool failed'), ...(data ? { data } : {}), }), }) diff --git a/apps/sim/lib/copilot/store-utils.ts b/apps/sim/lib/copilot/tools/client/store-utils.ts similarity index 89% rename from apps/sim/lib/copilot/store-utils.ts rename to apps/sim/lib/copilot/tools/client/store-utils.ts index 9447f9cbf11..a050b8cf235 100644 --- a/apps/sim/lib/copilot/store-utils.ts +++ b/apps/sim/lib/copilot/tools/client/store-utils.ts @@ -23,7 +23,8 @@ import { Wrench, Zap, } from 'lucide-react' -import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resource-types' +import { Read as ReadTool } from '@/lib/copilot/generated/tool-catalog-v1' +import { VFS_DIR_TO_RESOURCE } from '@/lib/copilot/resources/types' import { ClientToolCallState, type ClientToolDisplay, @@ -133,7 +134,7 @@ function specialToolDisplay( } } - if (toolName === 'read') { + if (toolName === ReadTool.id) { const target = describeReadTarget(readStringParam(params, 'path')) return { text: formatReadingLabel(target, state), @@ -227,7 +228,7 @@ function serverUIFallback(serverUI: ServerToolUI, state: ClientToolCallState): C } } -export function humanizedFallback( +function humanizedFallback( toolName: string, state: ClientToolCallState ): ClientToolDisplay | undefined { @@ -244,7 +245,7 @@ export function humanizedFallback( } export function isRejectedState(state: string): boolean { - return state === 'rejected' + return state === ClientToolCallState.rejected } export function isReviewState(state: string): boolean { @@ -254,24 +255,3 @@ export function isReviewState(state: string): boolean { export function isBackgroundState(state: string): boolean { return state === 'background' } - -export function isTerminalState(state: string): boolean { - return ( - state === ClientToolCallState.success || - state === ClientToolCallState.error || - state === ClientToolCallState.rejected || - state === ClientToolCallState.aborted || - isReviewState(state) || - isBackgroundState(state) - ) -} - -export function stripTodoTags(text: string): string { - if (!text) return text - return text - .replace(/[\s\S]*?<\/marktodo>/g, '') - .replace(/[\s\S]*?<\/checkofftodo>/g, '') - .replace(/[\s\S]*?<\/design_workflow>/g, '') - .replace(/[ \t]+\n/g, '\n') - .replace(/\n{2,}/g, '\n') -} diff --git a/apps/sim/lib/copilot/tool-descriptions.test.ts b/apps/sim/lib/copilot/tools/descriptions.test.ts similarity index 97% rename from apps/sim/lib/copilot/tool-descriptions.test.ts rename to apps/sim/lib/copilot/tools/descriptions.test.ts index df0a1183376..6bb7d11d037 100644 --- a/apps/sim/lib/copilot/tool-descriptions.test.ts +++ b/apps/sim/lib/copilot/tools/descriptions.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest' -import { getCopilotToolDescription } from '@/lib/copilot/tool-descriptions' +import { getCopilotToolDescription } from './descriptions' describe('getCopilotToolDescription', () => { it.concurrent('returns the base description when hosted keys are not active', () => { diff --git a/apps/sim/lib/copilot/tool-descriptions.ts b/apps/sim/lib/copilot/tools/descriptions.ts similarity index 100% rename from apps/sim/lib/copilot/tool-descriptions.ts rename to apps/sim/lib/copilot/tools/descriptions.ts diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/access.ts b/apps/sim/lib/copilot/tools/handlers/access.ts similarity index 100% rename from apps/sim/lib/copilot/orchestrator/tool-executor/access.ts rename to apps/sim/lib/copilot/tools/handlers/access.ts diff --git a/apps/sim/lib/copilot/tools/handlers/context.ts b/apps/sim/lib/copilot/tools/handlers/context.ts new file mode 100644 index 00000000000..5070da18479 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/context.ts @@ -0,0 +1,22 @@ +import type { ExecutionContext } from '@/lib/copilot/request/types' +import { getEffectiveDecryptedEnv } from '@/lib/environment/utils' +import { getWorkflowById } from '@/lib/workflows/utils' + +export async function prepareExecutionContext( + userId: string, + workflowId: string, + chatId?: string +): Promise { + const wf = await getWorkflowById(workflowId) + const workspaceId = wf?.workspaceId ?? undefined + + const decryptedEnvVars = await getEffectiveDecryptedEnv(userId, workspaceId) + + return { + userId, + workflowId, + workspaceId, + chatId, + decryptedEnvVars, + } +} diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/deploy.ts b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts similarity index 99% rename from apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/deploy.ts rename to apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts index 062bedaceb6..7bc36e2c0db 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/deploy.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/deploy.ts @@ -3,7 +3,7 @@ import { db } from '@sim/db' import { chat, workflowMcpTool } from '@sim/db/schema' import { and, eq, isNull } from 'drizzle-orm' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { getBaseUrl } from '@/lib/core/utils/urls' import { mcpPubSub } from '@/lib/mcp/pubsub' import { generateParameterSchemaForWorkflow } from '@/lib/mcp/workflow-mcp-sync' diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/manage.ts b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts similarity index 99% rename from apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/manage.ts rename to apps/sim/lib/copilot/tools/handlers/deployment/manage.ts index ecc2456d544..c641bef9088 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/deployment-tools/manage.ts +++ b/apps/sim/lib/copilot/tools/handlers/deployment/manage.ts @@ -9,7 +9,7 @@ import { } from '@sim/db/schema' import { and, eq, inArray, isNull } from 'drizzle-orm' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { mcpPubSub } from '@/lib/mcp/pubsub' import { generateParameterSchemaForWorkflow } from '@/lib/mcp/workflow-mcp-sync' import { sanitizeToolName } from '@/lib/mcp/workflow-tool-schema' diff --git a/apps/sim/lib/copilot/tools/handlers/function-execute.ts b/apps/sim/lib/copilot/tools/handlers/function-execute.ts new file mode 100644 index 00000000000..36ec20ee958 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/function-execute.ts @@ -0,0 +1,31 @@ +import { executeTool as executeAppTool } from '@/tools' +import type { ToolExecutionContext, ToolExecutionResult } from '../../tool-executor/types' + +export async function executeFunctionExecute( + params: Record, + context: ToolExecutionContext +): Promise { + const enrichedParams = { ...params } + + if (context.decryptedEnvVars && Object.keys(context.decryptedEnvVars).length > 0) { + enrichedParams.envVars = { + ...context.decryptedEnvVars, + ...((enrichedParams.envVars as Record) || {}), + } + } + + enrichedParams._context = { + ...(typeof enrichedParams._context === 'object' && enrichedParams._context !== null + ? (enrichedParams._context as object) + : {}), + userId: context.userId, + workflowId: context.workflowId, + workspaceId: context.workspaceId, + chatId: context.chatId, + executionId: context.executionId, + runId: context.runId, + enforceCredentialAccess: true, + } + + return executeAppTool('function_execute', enrichedParams, false) +} diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/job-tools.ts b/apps/sim/lib/copilot/tools/handlers/jobs.ts similarity index 99% rename from apps/sim/lib/copilot/orchestrator/tool-executor/job-tools.ts rename to apps/sim/lib/copilot/tools/handlers/jobs.ts index 6870bde0d50..a6dc199af3e 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/job-tools.ts +++ b/apps/sim/lib/copilot/tools/handlers/jobs.ts @@ -4,7 +4,7 @@ import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' import { v4 as uuidv4 } from 'uuid' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { parseCronToHumanReadable, validateCronExpression } from '@/lib/workflows/schedules/utils' const logger = createLogger('JobTools') diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts new file mode 100644 index 00000000000..ce6c234147b --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-credential.ts @@ -0,0 +1,44 @@ +import { db } from '@sim/db' +import { credential } from '@sim/db/schema' +import { eq } from 'drizzle-orm' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' + +export function executeManageCredential( + rawParams: Record, + _context: ExecutionContext +): Promise { + const params = rawParams as { operation: string; credentialId: string; displayName?: string } + const { operation, credentialId, displayName } = params + if (!credentialId) return Promise.resolve({ success: false, error: 'credentialId is required' }) + return (async () => { + try { + const [row] = await db + .select({ id: credential.id, type: credential.type, displayName: credential.displayName }) + .from(credential) + .where(eq(credential.id, credentialId)) + .limit(1) + if (!row) return { success: false, error: 'Credential not found' } + if (row.type !== 'oauth') + return { success: false, error: 'Only OAuth credentials can be managed with this tool.' } + switch (operation) { + case 'rename': + if (!displayName) return { success: false, error: 'displayName is required for rename' } + await db + .update(credential) + .set({ displayName, updatedAt: new Date() }) + .where(eq(credential.id, credentialId)) + return { success: true, output: { credentialId, displayName } } + case 'delete': + await db.delete(credential).where(eq(credential.id, credentialId)) + return { success: true, output: { credentialId, deleted: true } } + default: + return { + success: false, + error: `Unknown operation: ${operation}. Use "rename" or "delete".`, + } + } + } catch (error) { + return { success: false, error: error instanceof Error ? error.message : String(error) } + } + })() +} diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts new file mode 100644 index 00000000000..57b0efd433c --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-custom-tool.ts @@ -0,0 +1,207 @@ +import { createLogger } from '@sim/logger' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { + deleteCustomTool, + getCustomToolById, + listCustomTools, + upsertCustomTools, +} from '@/lib/workflows/custom-tools/operations' + +const logger = createLogger('CopilotToolExecutor') + +type ManageCustomToolOperation = 'add' | 'edit' | 'delete' | 'list' + +interface ManageCustomToolSchema { + type: 'function' + function: { + name: string + description?: string + parameters: Record + } +} + +interface ManageCustomToolParams { + operation?: string + toolId?: string + schema?: ManageCustomToolSchema + code?: string + title?: string + workspaceId?: string +} + +export async function executeManageCustomTool( + rawParams: Record, + context: ExecutionContext +): Promise { + const params = rawParams as ManageCustomToolParams + const operation = String(params.operation || '').toLowerCase() as ManageCustomToolOperation + const workspaceId = params.workspaceId || context.workspaceId + + if (!operation) { + return { success: false, error: "Missing required 'operation' argument" } + } + + const writeOps: string[] = ['add', 'edit', 'delete'] + if ( + writeOps.includes(operation) && + context.userPermission && + context.userPermission !== 'write' && + context.userPermission !== 'admin' + ) { + return { + success: false, + error: `Permission denied: '${operation}' on manage_custom_tool requires write access. You have '${context.userPermission}' permission.`, + } + } + + try { + if (operation === 'list') { + const toolsForUser = await listCustomTools({ + userId: context.userId, + workspaceId, + }) + + return { + success: true, + output: { + success: true, + operation, + tools: toolsForUser, + count: toolsForUser.length, + }, + } + } + + if (operation === 'add') { + if (!workspaceId) { + return { + success: false, + error: "workspaceId is required for operation 'add'", + } + } + if (!params.schema || !params.code) { + return { + success: false, + error: "Both 'schema' and 'code' are required for operation 'add'", + } + } + + const title = params.title || params.schema.function?.name + if (!title) { + return { success: false, error: "Missing tool title or schema.function.name for 'add'" } + } + + const resultTools = await upsertCustomTools({ + tools: [{ title, schema: params.schema, code: params.code }], + workspaceId, + userId: context.userId, + }) + const created = resultTools.find((tool) => tool.title === title) + + return { + success: true, + output: { + success: true, + operation, + toolId: created?.id, + title, + message: `Created custom tool "${title}"`, + }, + } + } + + if (operation === 'edit') { + if (!workspaceId) { + return { + success: false, + error: "workspaceId is required for operation 'edit'", + } + } + if (!params.toolId) { + return { success: false, error: "'toolId' is required for operation 'edit'" } + } + if (!params.schema && !params.code) { + return { + success: false, + error: "At least one of 'schema' or 'code' is required for operation 'edit'", + } + } + + const existing = await getCustomToolById({ + toolId: params.toolId, + userId: context.userId, + workspaceId, + }) + if (!existing) { + return { success: false, error: `Custom tool not found: ${params.toolId}` } + } + + const mergedSchema = params.schema || (existing.schema as ManageCustomToolSchema) + const mergedCode = params.code || existing.code + const title = params.title || mergedSchema.function?.name || existing.title + + await upsertCustomTools({ + tools: [{ id: params.toolId, title, schema: mergedSchema, code: mergedCode }], + workspaceId, + userId: context.userId, + }) + + return { + success: true, + output: { + success: true, + operation, + toolId: params.toolId, + title, + message: `Updated custom tool "${title}"`, + }, + } + } + + if (operation === 'delete') { + if (!params.toolId) { + return { success: false, error: "'toolId' is required for operation 'delete'" } + } + + const deleted = await deleteCustomTool({ + toolId: params.toolId, + userId: context.userId, + workspaceId, + }) + if (!deleted) { + return { success: false, error: `Custom tool not found: ${params.toolId}` } + } + + return { + success: true, + output: { + success: true, + operation, + toolId: params.toolId, + message: 'Deleted custom tool', + }, + } + } + + return { + success: false, + error: `Unsupported operation for manage_custom_tool: ${operation}`, + } + } catch (error) { + logger.error( + context.messageId + ? `manage_custom_tool execution failed [messageId:${context.messageId}]` + : 'manage_custom_tool execution failed', + { + operation, + workspaceId, + userId: context.userId, + error: error instanceof Error ? error.message : String(error), + } + ) + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to manage custom tool', + } + } +} diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts new file mode 100644 index 00000000000..be7b327bdbc --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-mcp-tool.ts @@ -0,0 +1,246 @@ +import { db } from '@sim/db' +import { mcpServers } from '@sim/db/schema' +import { createLogger } from '@sim/logger' +import { and, eq, isNull } from 'drizzle-orm' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { validateMcpDomain } from '@/lib/mcp/domain-check' +import { mcpService } from '@/lib/mcp/service' +import { generateMcpServerId } from '@/lib/mcp/utils' + +const logger = createLogger('CopilotToolExecutor') + +type ManageMcpToolOperation = 'add' | 'edit' | 'delete' | 'list' + +interface ManageMcpToolConfig { + name?: string + transport?: string + url?: string + headers?: Record + timeout?: number + enabled?: boolean +} + +interface ManageMcpToolParams { + operation?: string + serverId?: string + config?: ManageMcpToolConfig +} + +export async function executeManageMcpTool( + rawParams: Record, + context: ExecutionContext +): Promise { + const params = rawParams as ManageMcpToolParams + const operation = String(params.operation || '').toLowerCase() as ManageMcpToolOperation + const workspaceId = context.workspaceId + + if (!operation) { + return { success: false, error: "Missing required 'operation' argument" } + } + + if (!workspaceId) { + return { success: false, error: 'workspaceId is required' } + } + + const writeOps: string[] = ['add', 'edit', 'delete'] + if ( + writeOps.includes(operation) && + context.userPermission && + context.userPermission !== 'write' && + context.userPermission !== 'admin' + ) { + return { + success: false, + error: `Permission denied: '${operation}' on manage_mcp_tool requires write access. You have '${context.userPermission}' permission.`, + } + } + + try { + if (operation === 'list') { + const servers = await db + .select() + .from(mcpServers) + .where(and(eq(mcpServers.workspaceId, workspaceId), isNull(mcpServers.deletedAt))) + + return { + success: true, + output: { + success: true, + operation, + servers: servers.map((s) => ({ + id: s.id, + name: s.name, + url: s.url, + transport: s.transport, + enabled: s.enabled, + connectionStatus: s.connectionStatus, + })), + count: servers.length, + }, + } + } + + if (operation === 'add') { + const config = params.config + if (!config?.name || !config?.url) { + return { success: false, error: "config.name and config.url are required for 'add'" } + } + + validateMcpDomain(config.url) + + const serverId = generateMcpServerId(workspaceId, config.url) + + const [existing] = await db + .select({ id: mcpServers.id, deletedAt: mcpServers.deletedAt }) + .from(mcpServers) + .where(and(eq(mcpServers.id, serverId), eq(mcpServers.workspaceId, workspaceId))) + .limit(1) + + if (existing) { + await db + .update(mcpServers) + .set({ + name: config.name, + transport: config.transport || 'streamable-http', + url: config.url, + headers: config.headers || {}, + timeout: config.timeout || 30000, + enabled: config.enabled !== false, + connectionStatus: 'connected', + lastConnected: new Date(), + updatedAt: new Date(), + deletedAt: null, + }) + .where(eq(mcpServers.id, serverId)) + } else { + await db.insert(mcpServers).values({ + id: serverId, + workspaceId, + createdBy: context.userId, + name: config.name, + description: '', + transport: config.transport || 'streamable-http', + url: config.url, + headers: config.headers || {}, + timeout: config.timeout || 30000, + retries: 3, + enabled: config.enabled !== false, + connectionStatus: 'connected', + lastConnected: new Date(), + createdAt: new Date(), + updatedAt: new Date(), + }) + } + + await mcpService.clearCache(workspaceId) + + return { + success: true, + output: { + success: true, + operation, + serverId, + name: config.name, + message: existing + ? `Updated existing MCP server "${config.name}"` + : `Added MCP server "${config.name}"`, + }, + } + } + + if (operation === 'edit') { + if (!params.serverId) { + return { success: false, error: "'serverId' is required for 'edit'" } + } + const config = params.config + if (!config) { + return { success: false, error: "'config' is required for 'edit'" } + } + + if (config.url) { + validateMcpDomain(config.url) + } + + const updateData: Record = { updatedAt: new Date() } + if (config.name !== undefined) updateData.name = config.name + if (config.transport !== undefined) updateData.transport = config.transport + if (config.url !== undefined) updateData.url = config.url + if (config.headers !== undefined) updateData.headers = config.headers + if (config.timeout !== undefined) updateData.timeout = config.timeout + if (config.enabled !== undefined) updateData.enabled = config.enabled + + const [updated] = await db + .update(mcpServers) + .set(updateData) + .where( + and( + eq(mcpServers.id, params.serverId), + eq(mcpServers.workspaceId, workspaceId), + isNull(mcpServers.deletedAt) + ) + ) + .returning() + + if (!updated) { + return { success: false, error: `MCP server not found: ${params.serverId}` } + } + + await mcpService.clearCache(workspaceId) + + return { + success: true, + output: { + success: true, + operation, + serverId: params.serverId, + name: updated.name, + message: `Updated MCP server "${updated.name}"`, + }, + } + } + + if (operation === 'delete') { + if (!params.serverId) { + return { success: false, error: "'serverId' is required for 'delete'" } + } + + const [deleted] = await db + .delete(mcpServers) + .where(and(eq(mcpServers.id, params.serverId), eq(mcpServers.workspaceId, workspaceId))) + .returning() + + if (!deleted) { + return { success: false, error: `MCP server not found: ${params.serverId}` } + } + + await mcpService.clearCache(workspaceId) + + return { + success: true, + output: { + success: true, + operation, + serverId: params.serverId, + message: `Deleted MCP server "${deleted.name}"`, + }, + } + } + + return { success: false, error: `Unsupported operation for manage_mcp_tool: ${operation}` } + } catch (error) { + logger.error( + context.messageId + ? `manage_mcp_tool execution failed [messageId:${context.messageId}]` + : 'manage_mcp_tool execution failed', + { + operation, + workspaceId, + error: error instanceof Error ? error.message : String(error), + } + ) + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to manage MCP server', + } + } +} diff --git a/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts b/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts new file mode 100644 index 00000000000..a1e5ed7de9e --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/management/manage-skill.ts @@ -0,0 +1,173 @@ +import { createLogger } from '@sim/logger' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { deleteSkill, listSkills, upsertSkills } from '@/lib/workflows/skills/operations' + +const logger = createLogger('CopilotToolExecutor') + +type ManageSkillOperation = 'add' | 'edit' | 'delete' | 'list' + +interface ManageSkillParams { + operation?: string + skillId?: string + name?: string + description?: string + content?: string +} + +export async function executeManageSkill( + rawParams: Record, + context: ExecutionContext +): Promise { + const params = rawParams as ManageSkillParams + const operation = String(params.operation || '').toLowerCase() as ManageSkillOperation + const workspaceId = context.workspaceId + + if (!operation) { + return { success: false, error: "Missing required 'operation' argument" } + } + + if (!workspaceId) { + return { success: false, error: 'workspaceId is required' } + } + + const writeOps: string[] = ['add', 'edit', 'delete'] + if ( + writeOps.includes(operation) && + context.userPermission && + context.userPermission !== 'write' && + context.userPermission !== 'admin' + ) { + return { + success: false, + error: `Permission denied: '${operation}' on manage_skill requires write access. You have '${context.userPermission}' permission.`, + } + } + + try { + if (operation === 'list') { + const skills = await listSkills({ workspaceId }) + + return { + success: true, + output: { + success: true, + operation, + skills: skills.map((s) => ({ + id: s.id, + name: s.name, + description: s.description, + createdAt: s.createdAt, + })), + count: skills.length, + }, + } + } + + if (operation === 'add') { + if (!params.name || !params.description || !params.content) { + return { + success: false, + error: "'name', 'description', and 'content' are required for 'add'", + } + } + + const resultSkills = await upsertSkills({ + skills: [{ name: params.name, description: params.description, content: params.content }], + workspaceId, + userId: context.userId, + }) + const created = resultSkills.find((s) => s.name === params.name) + + return { + success: true, + output: { + success: true, + operation, + skillId: created?.id, + name: params.name, + message: `Created skill "${params.name}"`, + }, + } + } + + if (operation === 'edit') { + if (!params.skillId) { + return { success: false, error: "'skillId' is required for 'edit'" } + } + if (!params.name && !params.description && !params.content) { + return { + success: false, + error: "At least one of 'name', 'description', or 'content' is required for 'edit'", + } + } + + const existing = await listSkills({ workspaceId }) + const found = existing.find((s) => s.id === params.skillId) + if (!found) { + return { success: false, error: `Skill not found: ${params.skillId}` } + } + + await upsertSkills({ + skills: [ + { + id: params.skillId, + name: params.name || found.name, + description: params.description || found.description, + content: params.content || found.content, + }, + ], + workspaceId, + userId: context.userId, + }) + + return { + success: true, + output: { + success: true, + operation, + skillId: params.skillId, + name: params.name || found.name, + message: `Updated skill "${params.name || found.name}"`, + }, + } + } + + if (operation === 'delete') { + if (!params.skillId) { + return { success: false, error: "'skillId' is required for 'delete'" } + } + + const deleted = await deleteSkill({ skillId: params.skillId, workspaceId }) + if (!deleted) { + return { success: false, error: `Skill not found: ${params.skillId}` } + } + + return { + success: true, + output: { + success: true, + operation, + skillId: params.skillId, + message: 'Deleted skill', + }, + } + } + + return { success: false, error: `Unsupported operation for manage_skill: ${operation}` } + } catch (error) { + logger.error( + context.messageId + ? `manage_skill execution failed [messageId:${context.messageId}]` + : 'manage_skill execution failed', + { + operation, + workspaceId, + error: error instanceof Error ? error.message : String(error), + } + ) + return { + success: false, + error: error instanceof Error ? error.message : 'Failed to manage skill', + } + } +} diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/materialize-file.ts b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts similarity index 98% rename from apps/sim/lib/copilot/orchestrator/tool-executor/materialize-file.ts rename to apps/sim/lib/copilot/tools/handlers/materialize-file.ts index 2fce7e695ad..58852aae659 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/materialize-file.ts +++ b/apps/sim/lib/copilot/tools/handlers/materialize-file.ts @@ -3,8 +3,8 @@ import { workflow, workspaceFiles } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' -import { findMothershipUploadRowByChatAndName } from '@/lib/copilot/orchestrator/tool-executor/upload-file-reader' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { findMothershipUploadRowByChatAndName } from '@/lib/copilot/tools/handlers/upload-file-reader' import { getServePathPrefix } from '@/lib/uploads' import { downloadWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' import { parseWorkflowJson } from '@/lib/workflows/operations/import-export' diff --git a/apps/sim/lib/copilot/tools/handlers/oauth.ts b/apps/sim/lib/copilot/tools/handlers/oauth.ts new file mode 100644 index 00000000000..7ece3645fce --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/oauth.ts @@ -0,0 +1,177 @@ +import { db } from '@sim/db' +import { pendingCredentialDraft, user } from '@sim/db/schema' +import { and, eq, lt } from 'drizzle-orm' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { getBaseUrl } from '@/lib/core/utils/urls' +import { getAllOAuthServices } from '@/lib/oauth/utils' + +export async function executeOAuthGetAuthLink( + rawParams: Record, + context: ExecutionContext +): Promise { + const providerName = String(rawParams.providerName || rawParams.provider_name || '') + const baseUrl = getBaseUrl() + try { + const result = await generateOAuthLink( + context.userId, + context.workspaceId, + context.workflowId, + context.chatId, + providerName, + baseUrl + ) + return { + success: true, + output: { + message: `Authorization URL generated for ${result.serviceName}.`, + oauth_url: result.url, + instructions: `Open this URL in your browser to connect ${result.serviceName}: ${result.url}`, + provider: result.serviceName, + providerId: result.providerId, + }, + } + } catch (err) { + const workspaceUrl = context.workspaceId + ? `${baseUrl}/workspace/${context.workspaceId}` + : `${baseUrl}/workspace` + return { + success: false, + error: err instanceof Error ? err.message : String(err), + output: { + message: `Could not generate a direct OAuth link for ${providerName}. Connect manually from the workspace.`, + oauth_url: workspaceUrl, + error: err instanceof Error ? err.message : String(err), + }, + } + } +} + +export async function executeOAuthRequestAccess( + rawParams: Record, + _context: ExecutionContext +): Promise { + const providerName = String(rawParams.providerName || rawParams.provider_name || 'the provider') + return { + success: true, + output: { + status: 'requested', + providerName, + message: `Requested ${providerName} OAuth connection.`, + }, + } +} + +/** + * Resolves a human-friendly provider name to a providerId and generates the + * actual OAuth authorization URL via Better Auth's server-side API. + * + * Steps: resolve provider → create credential draft → look up user session → + * call auth.api.oAuth2LinkAccount → return the real authorization URL. + */ +export async function generateOAuthLink( + userId: string, + workspaceId: string | undefined, + workflowId: string | undefined, + chatId: string | undefined, + providerName: string, + baseUrl: string +): Promise<{ url: string; providerId: string; serviceName: string }> { + if (!workspaceId) { + throw new Error('workspaceId is required to generate an OAuth link') + } + + const allServices = getAllOAuthServices() + const normalizedInput = providerName.toLowerCase().trim() + + const matched = + allServices.find((s) => s.providerId === normalizedInput) || + allServices.find((s) => s.name.toLowerCase() === normalizedInput) || + allServices.find( + (s) => + s.name.toLowerCase().includes(normalizedInput) || + normalizedInput.includes(s.name.toLowerCase()) + ) || + allServices.find( + (s) => s.providerId.includes(normalizedInput) || normalizedInput.includes(s.providerId) + ) + + if (!matched) { + const available = allServices.map((s) => s.name).join(', ') + throw new Error(`Provider "${providerName}" not found. Available providers: ${available}`) + } + + const { providerId, name: serviceName } = matched + const callbackURL = + workflowId && workspaceId + ? `${baseUrl}/workspace/${workspaceId}/w/${workflowId}` + : chatId && workspaceId + ? `${baseUrl}/workspace/${workspaceId}/task/${chatId}` + : `${baseUrl}/workspace/${workspaceId}` + + if (providerId === 'trello') { + return { url: `${baseUrl}/api/auth/trello/authorize`, providerId, serviceName } + } + if (providerId === 'shopify') { + const returnUrl = encodeURIComponent(callbackURL) + return { + url: `${baseUrl}/api/auth/shopify/authorize?returnUrl=${returnUrl}`, + providerId, + serviceName, + } + } + + let displayName = serviceName + try { + const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId)) + if (row?.name) { + displayName = `${row.name}'s ${serviceName}` + } + } catch { + // Fall back to service name only + } + + const now = new Date() + await db + .delete(pendingCredentialDraft) + .where( + and(eq(pendingCredentialDraft.userId, userId), lt(pendingCredentialDraft.expiresAt, now)) + ) + await db + .insert(pendingCredentialDraft) + .values({ + id: crypto.randomUUID(), + userId, + workspaceId, + providerId, + displayName, + expiresAt: new Date(now.getTime() + 15 * 60 * 1000), + createdAt: now, + }) + .onConflictDoUpdate({ + target: [ + pendingCredentialDraft.userId, + pendingCredentialDraft.providerId, + pendingCredentialDraft.workspaceId, + ], + set: { + displayName, + expiresAt: new Date(now.getTime() + 15 * 60 * 1000), + createdAt: now, + }, + }) + + const { auth } = await import('@/lib/auth/auth') + const { headers: getHeaders } = await import('next/headers') + const reqHeaders = await getHeaders() + + const data = (await auth.api.oAuth2LinkAccount({ + body: { providerId, callbackURL }, + headers: reqHeaders, + })) as { url?: string; redirect?: boolean } + + if (!data?.url) { + throw new Error('oAuth2LinkAccount did not return an authorization URL') + } + + return { url: data.url, providerId, serviceName } +} diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/param-types.ts b/apps/sim/lib/copilot/tools/handlers/param-types.ts similarity index 97% rename from apps/sim/lib/copilot/orchestrator/tool-executor/param-types.ts rename to apps/sim/lib/copilot/tools/handlers/param-types.ts index 660e62d67b0..ccef1180903 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/param-types.ts +++ b/apps/sim/lib/copilot/tools/handlers/param-types.ts @@ -3,6 +3,8 @@ * Replaces Record with specific shapes based on actual property access. */ +import type { MothershipResourceType } from '@/lib/copilot/resources/types' + // === Workflow Query Params === export interface GetWorkflowDataParams { @@ -203,7 +205,7 @@ export interface DeleteWorkspaceMcpServerParams { serverId: string } -export type OpenResourceType = 'workflow' | 'table' | 'knowledgebase' | 'file' +export type OpenResourceType = MothershipResourceType export interface OpenResourceParams { type?: OpenResourceType diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/platform-actions.ts b/apps/sim/lib/copilot/tools/handlers/platform-actions.ts similarity index 100% rename from apps/sim/lib/copilot/orchestrator/tool-executor/platform-actions.ts rename to apps/sim/lib/copilot/tools/handlers/platform-actions.ts diff --git a/apps/sim/lib/copilot/tools/handlers/platform.ts b/apps/sim/lib/copilot/tools/handlers/platform.ts new file mode 100644 index 00000000000..f5cc43f910b --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/platform.ts @@ -0,0 +1,9 @@ +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { PLATFORM_ACTIONS_CONTENT } from './platform-actions' + +export async function executeGetPlatformActions( + _rawParams: Record, + _context: ExecutionContext +): Promise { + return { success: true, output: { content: PLATFORM_ACTIONS_CONTENT } } +} diff --git a/apps/sim/lib/copilot/tools/handlers/resources.ts b/apps/sim/lib/copilot/tools/handlers/resources.ts new file mode 100644 index 00000000000..43a68386095 --- /dev/null +++ b/apps/sim/lib/copilot/tools/handlers/resources.ts @@ -0,0 +1,95 @@ +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' +import { MothershipResourceType } from '@/lib/copilot/resources/types' +import { getKnowledgeBaseById } from '@/lib/knowledge/service' +import { getTableById } from '@/lib/table/service' +import { getWorkspaceFile } from '@/lib/uploads/contexts/workspace/workspace-file-manager' +import { getWorkflowById } from '@/lib/workflows/utils' +import { isUuid } from '@/executor/constants' +import type { OpenResourceParams, ValidOpenResourceParams } from './param-types' + +const VALID_OPEN_RESOURCE_TYPES = new Set(Object.values(MothershipResourceType)) + +export async function executeOpenResource( + rawParams: Record, + context: ExecutionContext +): Promise { + const params = rawParams as OpenResourceParams + const validated = validateOpenResourceParams(params) + if (!validated.success) return { success: false, error: validated.error } + + const resourceType = validated.params.type + let resourceId = validated.params.id + let title: string = resourceType + + if (resourceType === 'file') { + if (!context.workspaceId) + return { success: false, error: 'Opening a workspace file requires workspace context.' } + if (!isUuid(validated.params.id)) + return { success: false, error: 'open_resource for files requires the canonical file UUID.' } + const record = await getWorkspaceFile(context.workspaceId, validated.params.id) + if (!record) + return { success: false, error: `No workspace file with id "${validated.params.id}".` } + resourceId = record.id + title = record.name + } + if (resourceType === 'workflow') { + const wf = await getWorkflowById(validated.params.id) + if (!wf) return { success: false, error: `No workflow with id "${validated.params.id}".` } + if (context.workspaceId && wf.workspaceId !== context.workspaceId) + return { success: false, error: `Workflow not found in the current workspace.` } + resourceId = wf.id + title = wf.name + } + if (resourceType === 'table') { + const tbl = await getTableById(validated.params.id) + if (!tbl) return { success: false, error: `No table with id "${validated.params.id}".` } + if (context.workspaceId && tbl.workspaceId !== context.workspaceId) + return { success: false, error: `Table not found in the current workspace.` } + resourceId = tbl.id + title = tbl.name + } + if (resourceType === 'knowledgebase') { + const kb = await getKnowledgeBaseById(validated.params.id) + if (!kb) return { success: false, error: `No knowledge base with id "${validated.params.id}".` } + if (context.workspaceId && kb.workspaceId !== context.workspaceId) + return { success: false, error: `Knowledge base not found in the current workspace.` } + resourceId = kb.id + title = kb.name + } + + return { + success: true, + output: { message: `Opened ${resourceType} ${resourceId} for the user` }, + resources: [ + { + type: resourceType, + id: resourceId, + title, + }, + ], + } +} + +function validateOpenResourceParams( + params: OpenResourceParams +): { success: true; params: ValidOpenResourceParams } | { success: false; error: string } { + if (!params.type) { + return { success: false, error: 'type is required' } + } + + if (!VALID_OPEN_RESOURCE_TYPES.has(params.type)) { + return { success: false, error: `Invalid resource type: ${params.type}` } + } + + if (!params.id) { + return { success: false, error: `${params.type} resources require \`id\`` } + } + + return { + success: true, + params: { + type: params.type, + id: params.id, + }, + } +} diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/upload-file-reader.ts b/apps/sim/lib/copilot/tools/handlers/upload-file-reader.ts similarity index 100% rename from apps/sim/lib/copilot/orchestrator/tool-executor/upload-file-reader.ts rename to apps/sim/lib/copilot/tools/handlers/upload-file-reader.ts diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/vfs-tools.ts b/apps/sim/lib/copilot/tools/handlers/vfs.ts similarity index 99% rename from apps/sim/lib/copilot/orchestrator/tool-executor/vfs-tools.ts rename to apps/sim/lib/copilot/tools/handlers/vfs.ts index c2e37759436..4cd6f6844ff 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/vfs-tools.ts +++ b/apps/sim/lib/copilot/tools/handlers/vfs.ts @@ -1,5 +1,5 @@ import { createLogger } from '@sim/logger' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { getOrMaterializeVFS } from '@/lib/copilot/vfs' import { listChatUploads, readChatUpload } from './upload-file-reader' diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/workflow-tools/mutations.ts b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts similarity index 99% rename from apps/sim/lib/copilot/orchestrator/tool-executor/workflow-tools/mutations.ts rename to apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts index c30d6b3e578..1eec20d02ce 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/workflow-tools/mutations.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/mutations.ts @@ -2,7 +2,7 @@ import crypto from 'crypto' import { createLogger } from '@sim/logger' import { createWorkspaceApiKey } from '@/lib/api-key/auth' import { AuditAction, AuditResourceType, recordAudit } from '@/lib/audit/log' -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { generateRequestId } from '@/lib/core/utils/request' import { executeWorkflow } from '@/lib/workflows/executor/execute-workflow' import { diff --git a/apps/sim/lib/copilot/orchestrator/tool-executor/workflow-tools/queries.ts b/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts similarity index 99% rename from apps/sim/lib/copilot/orchestrator/tool-executor/workflow-tools/queries.ts rename to apps/sim/lib/copilot/tools/handlers/workflow/queries.ts index 2148a2c0cb1..75755a59ee1 100644 --- a/apps/sim/lib/copilot/orchestrator/tool-executor/workflow-tools/queries.ts +++ b/apps/sim/lib/copilot/tools/handlers/workflow/queries.ts @@ -1,4 +1,4 @@ -import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/orchestrator/types' +import type { ExecutionContext, ToolCallResult } from '@/lib/copilot/request/types' import { formatNormalizedWorkflowForCopilot } from '@/lib/copilot/tools/shared/workflow-utils' import { mcpService } from '@/lib/mcp/service' import { listWorkspaceFiles } from '@/lib/uploads/contexts/workspace' diff --git a/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts new file mode 100644 index 00000000000..8bf0992a0a0 --- /dev/null +++ b/apps/sim/lib/copilot/tools/registry/server-tool-adapter.ts @@ -0,0 +1,47 @@ +import { createLogger } from '@sim/logger' +import type { ToolExecutionResult, ToolHandler } from '@/lib/copilot/tool-executor/types' +import { routeExecution } from '@/lib/copilot/tools/server/router' + +const logger = createLogger('ServerToolAdapter') + +export function createServerToolHandler(toolId: string): ToolHandler { + return async (params, context): Promise => { + const enrichedParams = { ...params } + if (!enrichedParams.workflowId && context.workflowId) + enrichedParams.workflowId = context.workflowId + if (!enrichedParams.workspaceId && context.workspaceId) + enrichedParams.workspaceId = context.workspaceId + + try { + const result = await routeExecution(toolId, enrichedParams, { + userId: context.userId, + workspaceId: context.workspaceId, + userPermission: context.userPermission ?? undefined, + chatId: context.chatId, + abortSignal: context.abortSignal, + }) + + const rec = + result && typeof result === 'object' && !Array.isArray(result) + ? (result as Record) + : null + if (rec?.success === false) { + const message = + (typeof rec.error === 'string' && rec.error) || + (typeof rec.message === 'string' && rec.message) || + `${toolId} failed` + return { success: false, error: message, output: result } + } + return { success: true, output: result } + } catch (error) { + logger.error('Server tool execution failed', { + toolId, + error: error instanceof Error ? error.message : String(error), + }) + return { + success: false, + error: error instanceof Error ? error.message : 'Server tool execution failed', + } + } + } +} diff --git a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts index b15785f9fd4..3228e23e9d4 100644 --- a/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts +++ b/apps/sim/lib/copilot/tools/server/blocks/get-blocks-metadata-tool.ts @@ -1,7 +1,7 @@ import { existsSync, readFileSync } from 'fs' import { join } from 'path' import { createLogger } from '@sim/logger' -import { getCopilotToolDescription } from '@/lib/copilot/tool-descriptions' +import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' import { GetBlocksMetadataInput, GetBlocksMetadataResult } from '@/lib/copilot/tools/shared/schemas' import { getAllowedIntegrationsFromEnv, isHosted } from '@/lib/core/config/feature-flags' diff --git a/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts b/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts index bae14cfd80f..043802fbb99 100644 --- a/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts +++ b/apps/sim/lib/copilot/tools/server/docs/search-documentation.ts @@ -2,6 +2,7 @@ import { db } from '@sim/db' import { docsEmbeddings } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { sql } from 'drizzle-orm' +import { SearchDocumentation } from '@/lib/copilot/generated/tool-catalog-v1' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' import { generateSearchEmbedding } from '@/lib/knowledge/embeddings' @@ -14,7 +15,7 @@ interface DocsSearchParams { const DEFAULT_DOCS_SIMILARITY_THRESHOLD = 0.3 export const searchDocumentationServerTool: BaseServerTool = { - name: 'search_documentation', + name: SearchDocumentation.id, async execute(params: DocsSearchParams): Promise { const logger = createLogger('SearchDocumentationServerTool') const { query, topK = 10, threshold } = params diff --git a/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts index 845aac805b6..dc6b0b2daba 100644 --- a/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/download-to-workspace-file.ts @@ -1,5 +1,6 @@ import { createLogger } from '@sim/logger' import { z } from 'zod' +import { DownloadToWorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1' import { assertServerToolNotAborted, type BaseServerTool, @@ -116,7 +117,7 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool< DownloadToWorkspaceFileArgs, DownloadToWorkspaceFileResult > = { - name: 'download_to_workspace_file', + name: DownloadToWorkspaceFile.id, inputSchema: DownloadToWorkspaceFileArgsSchema, outputSchema: DownloadToWorkspaceFileResultSchema, @@ -124,7 +125,8 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool< params: DownloadToWorkspaceFileArgs, context?: ServerToolContext ): Promise { - const reqLogger = logger.withMetadata({ messageId: context?.messageId }) + const withMessageId = (message: string) => + context?.messageId ? `${message} [messageId:${context.messageId}]` : message if (!context?.userId) { throw new Error('Authentication required') @@ -176,7 +178,7 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool< mimeType ) - reqLogger.info('Downloaded remote file to workspace', { + logger.info('Downloaded remote file to workspace', { sourceUrl: params.url, fileId: uploaded.id, fileName: uploaded.name, @@ -193,7 +195,7 @@ export const downloadToWorkspaceFileServerTool: BaseServerTool< } } catch (error) { const msg = error instanceof Error ? error.message : 'Unknown error' - reqLogger.error('Failed to download file to workspace', { + logger.error('Failed to download file to workspace', { url: params.url, error: msg, }) diff --git a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts index 76a0d1fdafe..6ef346bc0ef 100644 --- a/apps/sim/lib/copilot/tools/server/files/workspace-file.ts +++ b/apps/sim/lib/copilot/tools/server/files/workspace-file.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { WorkspaceFile } from '@/lib/copilot/generated/tool-catalog-v1' import { assertServerToolNotAborted, type BaseServerTool, @@ -45,15 +46,16 @@ function validateFlatWorkspaceFileName(fileName: string): string | null { } export const workspaceFileServerTool: BaseServerTool = { - name: 'workspace_file', + name: WorkspaceFile.id, async execute( params: WorkspaceFileArgs, context?: ServerToolContext ): Promise { - const reqLogger = logger.withMetadata({ messageId: context?.messageId }) + const withMessageId = (message: string) => + context?.messageId ? `${message} [messageId:${context.messageId}]` : message if (!context?.userId) { - reqLogger.error('Unauthorized attempt to access workspace files') + logger.error('Unauthorized attempt to access workspace files') throw new Error('Authentication required') } @@ -92,7 +94,7 @@ export const workspaceFileServerTool: BaseServerTool = { - name: 'generate_image', + name: GenerateImage.id, async execute( params: GenerateImageArgs, context?: ServerToolContext ): Promise { - const reqLogger = logger.withMetadata({ messageId: context?.messageId }) + const withMessageId = (message: string) => + context?.messageId ? `${message} [messageId:${context.messageId}]` : message if (!context?.userId) { throw new Error('Authentication required') @@ -95,17 +97,17 @@ export const generateImageServerTool: BaseServerTool = { - name: 'get_job_logs', + name: GetJobLogs.id, async execute(rawArgs: GetJobLogsArgs, context?: ServerToolContext): Promise { - const reqLogger = logger.withMetadata({ messageId: context?.messageId }) + const withMessageId = (message: string) => + context?.messageId ? `${message} [messageId:${context.messageId}]` : message const { jobId, @@ -112,7 +114,7 @@ export const getJobLogsServerTool: BaseServerTool const clampedLimit = Math.min(Math.max(1, limit), 5) - reqLogger.info('Fetching job logs', { + logger.info('Fetching job logs', { jobId, executionId, limit: clampedLimit, @@ -171,7 +173,7 @@ export const getJobLogsServerTool: BaseServerTool return entry }) - reqLogger.info('Job logs prepared', { + logger.info('Job logs prepared', { jobId, count: entries.length, resultSizeKB: Math.round(JSON.stringify(entries).length / 1024), diff --git a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts index d0115015699..354eecaa4f4 100644 --- a/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts +++ b/apps/sim/lib/copilot/tools/server/knowledge/knowledge-base.ts @@ -3,6 +3,7 @@ import { knowledgeConnector } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, isNull } from 'drizzle-orm' import { generateInternalToken } from '@/lib/auth/internal' +import { KnowledgeBase } from '@/lib/copilot/generated/tool-catalog-v1' import { assertServerToolNotAborted, type BaseServerTool, @@ -42,17 +43,16 @@ const logger = createLogger('KnowledgeBaseServerTool') * Knowledge base tool for copilot to create, list, and get knowledge bases */ export const knowledgeBaseServerTool: BaseServerTool = { - name: 'knowledge_base', + name: KnowledgeBase.id, async execute( params: KnowledgeBaseArgs, context?: ServerToolContext ): Promise { - const reqLogger = logger.withMetadata({ messageId: context?.messageId }) + const withMessageId = (message: string) => + context?.messageId ? `${message} [messageId:${context.messageId}]` : message if (!context?.userId) { - reqLogger.error( - 'Unauthorized attempt to access knowledge base - no authenticated user context' - ) + logger.error('Unauthorized attempt to access knowledge base - no authenticated user context') throw new Error('Authentication required') } @@ -101,7 +101,7 @@ export const knowledgeBaseServerTool: BaseServerTool { - reqLogger.error('Background document processing failed', { + logger.error('Background document processing failed', { documentId: doc.id, error: err instanceof Error ? err.message : String(err), }) }) - reqLogger.info('Workspace file added to knowledge base via copilot', { + logger.info('Workspace file added to knowledge base via copilot', { knowledgeBaseId: args.knowledgeBaseId, documentId: doc.id, fileName: fileRecord.name, @@ -348,7 +348,7 @@ export const knowledgeBaseServerTool: BaseServerTool = { - name: 'search_online', + name: SearchOnline.id, async execute(params: OnlineSearchParams): Promise { const logger = createLogger('SearchOnlineServerTool') const { query, num = 10, type = 'search', gl, hl } = params diff --git a/apps/sim/lib/copilot/tools/server/router.ts b/apps/sim/lib/copilot/tools/server/router.ts index 18dfdc7deb0..87321de3a1b 100644 --- a/apps/sim/lib/copilot/tools/server/router.ts +++ b/apps/sim/lib/copilot/tools/server/router.ts @@ -1,4 +1,16 @@ import { createLogger } from '@sim/logger' +import { + DownloadToWorkspaceFile, + GenerateImage, + GenerateVisualization, + KnowledgeBase, + ManageCredential, + ManageCustomTool, + ManageMcpTool, + ManageSkill, + UserTable, + WorkspaceFile, +} from '@/lib/copilot/generated/tool-catalog-v1' import { assertServerToolNotAborted, type BaseServerTool, @@ -29,7 +41,7 @@ export type ExecuteResponseSuccess = (typeof ExecuteResponseSuccessSchema)['_typ const logger = createLogger('ServerToolRouter') const WRITE_ACTIONS: Record = { - knowledge_base: [ + [KnowledgeBase.id]: [ 'create', 'add_file', 'update', @@ -44,7 +56,7 @@ const WRITE_ACTIONS: Record = { 'delete_connector', 'sync_connector', ], - user_table: [ + [UserTable.id]: [ 'create', 'create_from_file', 'import_file', @@ -60,14 +72,14 @@ const WRITE_ACTIONS: Record = { 'delete_column', 'update_column', ], - manage_custom_tool: ['add', 'edit', 'delete'], - manage_mcp_tool: ['add', 'edit', 'delete'], - manage_skill: ['add', 'edit', 'delete'], - manage_credential: ['rename', 'delete'], - workspace_file: ['write', 'update', 'delete', 'rename', 'patch'], - download_to_workspace_file: ['*'], - generate_visualization: ['generate'], - generate_image: ['generate'], + [ManageCustomTool.id]: ['add', 'edit', 'delete'], + [ManageMcpTool.id]: ['add', 'edit', 'delete'], + [ManageSkill.id]: ['add', 'edit', 'delete'], + [ManageCredential.id]: ['rename', 'delete'], + [WorkspaceFile.id]: ['write', 'update', 'delete', 'rename', 'patch'], + [DownloadToWorkspaceFile.id]: ['*'], + [GenerateVisualization.id]: ['generate'], + [GenerateImage.id]: ['generate'], } function isWritePermission(userPermission: string): boolean { @@ -108,10 +120,10 @@ const serverToolRegistry: Record = { [generateImageServerTool.name]: generateImageServerTool, } -/** - * Route a tool execution request to the appropriate server tool. - * Validates input/output using the tool's declared Zod schemas if present. - */ +export function getRegisteredServerToolNames(): string[] { + return Object.keys(serverToolRegistry) +} + export async function routeExecution( toolName: string, payload: unknown, @@ -122,9 +134,10 @@ export async function routeExecution( throw new Error(`Unknown server tool: ${toolName}`) } - logger.withMetadata({ messageId: context?.messageId }).debug('Routing to tool', { - toolName, - }) + logger.debug( + context?.messageId ? `Routing to tool [messageId:${context.messageId}]` : 'Routing to tool', + { toolName } + ) // Action-level permission enforcement for mixed read/write tools if (context?.userPermission && WRITE_ACTIONS[toolName]) { diff --git a/apps/sim/lib/copilot/tools/server/table/user-table.ts b/apps/sim/lib/copilot/tools/server/table/user-table.ts index be64c89416e..01eb2acf790 100644 --- a/apps/sim/lib/copilot/tools/server/table/user-table.ts +++ b/apps/sim/lib/copilot/tools/server/table/user-table.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { UserTable } from '@/lib/copilot/generated/tool-catalog-v1' import { assertServerToolNotAborted, type BaseServerTool, @@ -239,9 +240,10 @@ async function batchInsertAll( } export const userTableServerTool: BaseServerTool = { - name: 'user_table', + name: UserTable.id, async execute(params: UserTableArgs, context?: ServerToolContext): Promise { - const reqLogger = logger.withMetadata({ messageId: context?.messageId }) + const withMessageId = (message: string) => + context?.messageId ? `${message} [messageId:${context.messageId}]` : message if (!context?.userId) { logger.error('Unauthorized attempt to access user table - no authenticated user context') @@ -725,7 +727,7 @@ export const userTableServerTool: BaseServerTool const coerced = coerceRows(rows, columns, columnMap) const inserted = await batchInsertAll(table.id, coerced, table, workspaceId, context) - reqLogger.info('Table created from file', { + logger.info('Table created from file', { tableId: table.id, fileName: file.name, columns: columns.length, @@ -801,7 +803,7 @@ export const userTableServerTool: BaseServerTool const coerced = coerceRows(rows, matchedColumns, columnMap) const inserted = await batchInsertAll(table.id, coerced, table, workspaceId, context) - reqLogger.info('Rows imported from file', { + logger.info('Rows imported from file', { tableId: table.id, fileName: file.name, matchedColumns: mappedHeaders.length, @@ -999,7 +1001,7 @@ export const userTableServerTool: BaseServerTool ? error.cause.message : String(error.cause) : undefined - reqLogger.error('Table operation failed', { + logger.error('Table operation failed', { operation, error: errorMessage, cause, diff --git a/apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts b/apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts index 30f032e1114..56f1de2043f 100644 --- a/apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts +++ b/apps/sim/lib/copilot/tools/server/user/set-environment-variables.ts @@ -3,6 +3,7 @@ import { credential } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, eq, inArray } from 'drizzle-orm' import { z } from 'zod' +import { SetEnvironmentVariables } from '@/lib/copilot/generated/tool-catalog-v1' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' import { upsertPersonalEnvVars, upsertWorkspaceEnvVars } from '@/lib/environment/utils' import { getWorkflowById } from '@/lib/workflows/utils' @@ -35,7 +36,7 @@ function normalizeVariables( export const setEnvironmentVariablesServerTool: BaseServerTool = { - name: 'set_environment_variables', + name: SetEnvironmentVariables.id, async execute( params: SetEnvironmentVariablesParams, context?: { userId: string } diff --git a/apps/sim/lib/copilot/tools/server/visualization/generate-visualization.ts b/apps/sim/lib/copilot/tools/server/visualization/generate-visualization.ts index f58c40c4a9f..b639f3f1fe4 100644 --- a/apps/sim/lib/copilot/tools/server/visualization/generate-visualization.ts +++ b/apps/sim/lib/copilot/tools/server/visualization/generate-visualization.ts @@ -1,4 +1,5 @@ import { createLogger } from '@sim/logger' +import { GenerateVisualization } from '@/lib/copilot/generated/tool-catalog-v1' import { assertServerToolNotAborted, type BaseServerTool, @@ -65,7 +66,8 @@ async function collectSandboxFiles( inputTables?: string[], messageId?: string ): Promise { - const reqLogger = logger.withMetadata({ messageId }) + const withMessageId = (message: string) => + messageId ? `${message} [messageId:${messageId}]` : message const sandboxFiles: SandboxFile[] = [] let totalSize = 0 @@ -74,12 +76,12 @@ async function collectSandboxFiles( for (const fileRef of inputFiles) { const record = findWorkspaceFileRecord(allFiles, fileRef) if (!record) { - reqLogger.warn('Sandbox input file not found', { fileRef }) + logger.warn('Sandbox input file not found', { fileRef }) continue } const ext = record.name.split('.').pop()?.toLowerCase() ?? '' if (!TEXT_EXTENSIONS.has(ext)) { - reqLogger.warn('Skipping non-text sandbox input file', { + logger.warn('Skipping non-text sandbox input file', { fileId: record.id, fileName: record.name, ext, @@ -87,7 +89,7 @@ async function collectSandboxFiles( continue } if (record.size > MAX_FILE_SIZE) { - reqLogger.warn('Sandbox input file exceeds size limit', { + logger.warn('Sandbox input file exceeds size limit', { fileId: record.id, fileName: record.name, size: record.size, @@ -116,7 +118,7 @@ async function collectSandboxFiles( for (const tableId of inputTables) { const table = await getTableById(tableId) if (!table) { - reqLogger.warn('Sandbox input table not found', { tableId }) + logger.warn('Sandbox input table not found', { tableId }) continue } const { rows } = await queryRows(tableId, workspaceId, { limit: 10000 }, 'sandbox-input') @@ -146,13 +148,14 @@ export const generateVisualizationServerTool: BaseServerTool< VisualizationArgs, VisualizationResult > = { - name: 'generate_visualization', + name: GenerateVisualization.id, async execute( params: VisualizationArgs, context?: ServerToolContext ): Promise { - const reqLogger = logger.withMetadata({ messageId: context?.messageId }) + const withMessageId = (message: string) => + context?.messageId ? `${message} [messageId:${context.messageId}]` : message if (!context?.userId) { throw new Error('Authentication required') @@ -237,7 +240,7 @@ export const generateVisualizationServerTool: BaseServerTool< imageBuffer, 'image/png' ) - reqLogger.info('Chart image overwritten', { + logger.info('Chart image overwritten', { fileId: updated.id, fileName: updated.name, size: imageBuffer.length, @@ -261,7 +264,7 @@ export const generateVisualizationServerTool: BaseServerTool< 'image/png' ) - reqLogger.info('Chart image saved', { + logger.info('Chart image saved', { fileId: uploaded.id, fileName: uploaded.name, size: imageBuffer.length, @@ -276,7 +279,7 @@ export const generateVisualizationServerTool: BaseServerTool< } } catch (error) { const msg = error instanceof Error ? error.message : 'Unknown error' - reqLogger.error('Visualization generation failed', { error: msg }) + logger.error('Visualization generation failed', { error: msg }) return { success: false, message: `Failed to generate visualization: ${msg}` } } }, diff --git a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts index eb0a0f23ed6..5aabab4c862 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/edit-workflow/index.ts @@ -2,6 +2,7 @@ import { db } from '@sim/db' import { workflow as workflowTable } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { eq } from 'drizzle-orm' +import { EditWorkflow } from '@/lib/copilot/generated/tool-catalog-v1' import { assertServerToolNotAborted, type BaseServerTool, @@ -65,7 +66,7 @@ async function getCurrentWorkflowStateFromDb( } export const editWorkflowServerTool: BaseServerTool = { - name: 'edit_workflow', + name: EditWorkflow.id, async execute(params: EditWorkflowParams, context?: ServerToolContext): Promise { const logger = createLogger('EditWorkflowServerTool') const { operations, workflowId, currentUserWorkflow } = params diff --git a/apps/sim/lib/copilot/tools/server/workflow/get-execution-summary.ts b/apps/sim/lib/copilot/tools/server/workflow/get-execution-summary.ts index d80681f68d2..f561d7b6190 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/get-execution-summary.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/get-execution-summary.ts @@ -2,6 +2,7 @@ import { db } from '@sim/db' import { workflow, workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, desc, eq, type SQL } from 'drizzle-orm' +import { GetExecutionSummary } from '@/lib/copilot/generated/tool-catalog-v1' import type { BaseServerTool, ServerToolContext } from '@/lib/copilot/tools/server/base-tool' import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils' @@ -41,7 +42,7 @@ export const getExecutionSummaryServerTool: BaseServerTool< GetExecutionSummaryArgs, ExecutionSummary[] > = { - name: 'get_execution_summary', + name: GetExecutionSummary.id, async execute( rawArgs: GetExecutionSummaryArgs, context?: ServerToolContext diff --git a/apps/sim/lib/copilot/tools/server/workflow/get-workflow-logs.ts b/apps/sim/lib/copilot/tools/server/workflow/get-workflow-logs.ts index 6d8a6fbc681..e64226ec497 100644 --- a/apps/sim/lib/copilot/tools/server/workflow/get-workflow-logs.ts +++ b/apps/sim/lib/copilot/tools/server/workflow/get-workflow-logs.ts @@ -2,6 +2,7 @@ import { db } from '@sim/db' import { workflowExecutionLogs } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { and, desc, eq } from 'drizzle-orm' +import { GetWorkflowLogs } from '@/lib/copilot/generated/tool-catalog-v1' import type { BaseServerTool } from '@/lib/copilot/tools/server/base-tool' import { authorizeWorkflowByWorkspacePermission } from '@/lib/workflows/utils' @@ -65,7 +66,7 @@ function extractBlockExecutionsFromTraceSpans(traceSpans: any[]): BlockExecution } export const getWorkflowLogsServerTool: BaseServerTool = { - name: 'get_workflow_logs', + name: GetWorkflowLogs.id, async execute(rawArgs: GetWorkflowLogsArgs, context?: { userId: string }): Promise { const { workflowId, diff --git a/apps/sim/lib/copilot/workflow-tools.ts b/apps/sim/lib/copilot/tools/workflow-tools.ts similarity index 87% rename from apps/sim/lib/copilot/workflow-tools.ts rename to apps/sim/lib/copilot/tools/workflow-tools.ts index ecfba90cebf..fc750614dfb 100644 --- a/apps/sim/lib/copilot/workflow-tools.ts +++ b/apps/sim/lib/copilot/tools/workflow-tools.ts @@ -1,4 +1,4 @@ -export const WORKFLOW_TOOL_NAMES = [ +const WORKFLOW_TOOL_NAMES = [ 'run_workflow', 'run_workflow_until_block', 'run_block', diff --git a/apps/sim/lib/copilot/types.ts b/apps/sim/lib/copilot/types.ts deleted file mode 100644 index 031e835318a..00000000000 --- a/apps/sim/lib/copilot/types.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { CopilotToolCall, ToolState } from '@/stores/panel' - -export type NotificationStatus = - | 'pending' - | 'success' - | 'error' - | 'accepted' - | 'rejected' - | 'background' - | 'cancelled' - -export type { CopilotToolCall, ToolState } - -export interface AvailableModel { - id: string - friendlyName: string - provider: string -} diff --git a/apps/sim/lib/copilot/utils.ts b/apps/sim/lib/copilot/utils.ts deleted file mode 100644 index cb6b25979d8..00000000000 --- a/apps/sim/lib/copilot/utils.ts +++ /dev/null @@ -1,22 +0,0 @@ -import type { NextRequest } from 'next/server' -import { env } from '@/lib/core/config/env' -import { safeCompare } from '@/lib/core/security/encryption' - -export function checkInternalApiKey(req: NextRequest) { - const apiKey = req.headers.get('x-api-key') - const expectedApiKey = env.INTERNAL_API_SECRET - - if (!expectedApiKey) { - return { success: false, error: 'Internal API key not configured' } - } - - if (!apiKey) { - return { success: false, error: 'API key required' } - } - - if (!safeCompare(apiKey, expectedApiKey)) { - return { success: false, error: 'Invalid API key' } - } - - return { success: true } -} diff --git a/apps/sim/lib/copilot/vfs/serializers.ts b/apps/sim/lib/copilot/vfs/serializers.ts index 524e8567e01..4e72ad1d183 100644 --- a/apps/sim/lib/copilot/vfs/serializers.ts +++ b/apps/sim/lib/copilot/vfs/serializers.ts @@ -1,4 +1,4 @@ -import { getCopilotToolDescription } from '@/lib/copilot/tool-descriptions' +import { getCopilotToolDescription } from '@/lib/copilot/tools/descriptions' import { isHosted } from '@/lib/core/config/feature-flags' import { isSubBlockHidden } from '@/lib/workflows/subblocks/visibility' import type { BlockConfig, SubBlockConfig } from '@/blocks/types' diff --git a/apps/sim/lib/copilot/vfs/workspace-vfs.ts b/apps/sim/lib/copilot/vfs/workspace-vfs.ts index e43e5b03ec6..662beaeeaa8 100644 --- a/apps/sim/lib/copilot/vfs/workspace-vfs.ts +++ b/apps/sim/lib/copilot/vfs/workspace-vfs.ts @@ -17,6 +17,7 @@ import { import { createLogger } from '@sim/logger' import { and, desc, eq, isNull, ne } from 'drizzle-orm' import { listApiKeys } from '@/lib/api-key/service' +import { buildWorkspaceMd, type WorkspaceMdData } from '@/lib/copilot/chat/workspace-context' import { type FileReadResult, readFileRecord } from '@/lib/copilot/vfs/file-reader' import { normalizeVfsSegment } from '@/lib/copilot/vfs/normalize-segment' import type { DirEntry, GrepMatch, GrepOptions, ReadResult } from '@/lib/copilot/vfs/operations' @@ -49,7 +50,6 @@ import { serializeVersions, serializeWorkflowMeta, } from '@/lib/copilot/vfs/serializers' -import { buildWorkspaceMd, type WorkspaceMdData } from '@/lib/copilot/workspace-context' import { getAccessibleEnvCredentials, getAccessibleOAuthCredentials, diff --git a/apps/sim/lib/copilot/workflow-read-hashes.ts b/apps/sim/lib/copilot/workflow-read-hashes.ts deleted file mode 100644 index adca180dc84..00000000000 --- a/apps/sim/lib/copilot/workflow-read-hashes.ts +++ /dev/null @@ -1,115 +0,0 @@ -import crypto from 'crypto' -import { db } from '@sim/db' -import { copilotWorkflowReadHashes } from '@sim/db/schema' -import { and, eq } from 'drizzle-orm' -import { sanitizeForCopilot } from '@/lib/workflows/sanitization/json-sanitizer' - -type WorkflowStateLike = { - blocks?: Record - edges?: unknown[] - loops?: Record - parallels?: Record -} - -export function canonicalizeForHash(value: unknown): unknown { - if (Array.isArray(value)) { - return value.map(canonicalizeForHash) - } - if (value && typeof value === 'object') { - return Object.fromEntries( - Object.entries(value as Record) - .sort(([a], [b]) => a.localeCompare(b)) - .map(([key, nested]) => [key, canonicalizeForHash(nested)]) - ) - } - return value -} - -export function hashCanonicalJson(value: unknown): string { - const canonical = JSON.stringify(canonicalizeForHash(value)) - return crypto.createHash('sha256').update(canonical).digest('hex') -} - -export function computeWorkflowReadHashFromSanitizedState(sanitizedState: unknown): string { - return hashCanonicalJson(sanitizedState) -} - -export function computeWorkflowReadHashFromWorkflowState(state: WorkflowStateLike): { - sanitizedState: ReturnType - hash: string -} { - const sanitizedState = sanitizeForCopilot({ - blocks: state.blocks || {}, - edges: state.edges || [], - loops: state.loops || {}, - parallels: state.parallels || {}, - } as Parameters[0]) - - return { - sanitizedState, - hash: computeWorkflowReadHashFromSanitizedState(sanitizedState), - } -} - -export async function getWorkflowReadHash( - chatId: string, - workflowId: string -): Promise { - const [row] = await db - .select({ hash: copilotWorkflowReadHashes.hash }) - .from(copilotWorkflowReadHashes) - .where( - and( - eq(copilotWorkflowReadHashes.chatId, chatId), - eq(copilotWorkflowReadHashes.workflowId, workflowId) - ) - ) - .limit(1) - - return row?.hash ?? null -} - -export async function upsertWorkflowReadHash( - chatId: string, - workflowId: string, - hash: string -): Promise { - await db - .insert(copilotWorkflowReadHashes) - .values({ - chatId, - workflowId, - hash, - updatedAt: new Date(), - }) - .onConflictDoUpdate({ - target: [copilotWorkflowReadHashes.chatId, copilotWorkflowReadHashes.workflowId], - set: { - hash, - updatedAt: new Date(), - }, - }) -} - -export async function upsertWorkflowReadHashForWorkflowState( - chatId: string, - workflowId: string, - state: WorkflowStateLike -): Promise<{ - sanitizedState: ReturnType - hash: string -}> { - const computed = computeWorkflowReadHashFromWorkflowState(state) - await upsertWorkflowReadHash(chatId, workflowId, computed.hash) - return computed -} - -export async function upsertWorkflowReadHashForSanitizedState( - chatId: string, - workflowId: string, - sanitizedState: unknown -): Promise { - const hash = computeWorkflowReadHashFromSanitizedState(sanitizedState) - await upsertWorkflowReadHash(chatId, workflowId, hash) - return hash -} diff --git a/apps/sim/lib/core/config/env.ts b/apps/sim/lib/core/config/env.ts index 366ed51cbf1..61452913bc7 100644 --- a/apps/sim/lib/core/config/env.ts +++ b/apps/sim/lib/core/config/env.ts @@ -38,9 +38,6 @@ export const env = createEnv({ AGENT_INDEXER_API_KEY: z.string().min(1).optional(), // API key for agent indexer authentication COPILOT_STREAM_TTL_SECONDS: z.number().optional(), // Redis TTL for copilot SSE buffer COPILOT_STREAM_EVENT_LIMIT: z.number().optional(), // Max events retained per stream - COPILOT_STREAM_RESERVE_BATCH: z.number().optional(), // Event ID reservation batch size - COPILOT_STREAM_FLUSH_INTERVAL_MS: z.number().optional(), // Buffer flush interval in ms - COPILOT_STREAM_FLUSH_MAX_BATCH: z.number().optional(), // Max events per flush batch // Database & Storage REDIS_URL: z.string().url().optional(), // Redis connection string for caching/sessions @@ -322,6 +319,7 @@ export const env = createEnv({ // E2B Remote Code Execution E2B_ENABLED: z.string().optional(), // Enable E2B remote code execution E2B_API_KEY: z.string().optional(), // E2B API key for sandbox creation + MOTHERSHIP_E2B_TEMPLATE_ID: z.string().optional(), // Custom E2B template with pre-installed CLI tools for shell execution // Credential Sets (Email Polling) - for self-hosted deployments CREDENTIAL_SETS_ENABLED: z.boolean().optional(), // Enable credential sets on self-hosted (bypasses plan requirements) diff --git a/apps/sim/lib/execution/e2b.ts b/apps/sim/lib/execution/e2b.ts index f312fc21e4a..cd5733d9747 100644 --- a/apps/sim/lib/execution/e2b.ts +++ b/apps/sim/lib/execution/e2b.ts @@ -13,6 +13,15 @@ export interface E2BExecutionRequest { language: CodeLanguage timeoutMs: number sandboxFiles?: SandboxFile[] + outputSandboxPath?: string +} + +export interface E2BShellExecutionRequest { + code: string + envs: Record + timeoutMs: number + sandboxFiles?: SandboxFile[] + outputSandboxPath?: string } export interface E2BExecutionResult { @@ -20,6 +29,7 @@ export interface E2BExecutionResult { stdout: string sandboxId?: string error?: string + exportedFileContent?: string /** Base64-encoded PNG images captured from rich outputs (e.g. matplotlib figures). */ images?: string[] } @@ -27,7 +37,7 @@ export interface E2BExecutionResult { const logger = createLogger('E2BExecution') export async function executeInE2B(req: E2BExecutionRequest): Promise { - const { code, language, timeoutMs } = req + const { code, language, timeoutMs, outputSandboxPath } = req const apiKey = env.E2B_API_KEY if (!apiKey) { @@ -115,7 +125,123 @@ export async function executeInE2B(req: E2BExecutionRequest): Promise { + const { code, envs, timeoutMs, outputSandboxPath } = req + + const apiKey = env.E2B_API_KEY + if (!apiKey) { + throw new Error('E2B_API_KEY is required when E2B is enabled') + } + + const templateName = env.MOTHERSHIP_E2B_TEMPLATE_ID + logger.info('Creating E2B shell sandbox', { + template: templateName || '(default)', + }) + const sandbox = templateName + ? await Sandbox.create(templateName, { apiKey }) + : await Sandbox.create({ apiKey }) + const sandboxId = sandbox.sandboxId + + if (req.sandboxFiles?.length) { + for (const file of req.sandboxFiles) { + await sandbox.files.write(file.path, file.content) + } + logger.info('Wrote sandbox input files', { + sandboxId, + fileCount: req.sandboxFiles.length, + paths: req.sandboxFiles.map((f) => f.path), + }) + } + + try { + let result: { stdout: string; stderr: string; exitCode: number } + try { + result = await sandbox.commands.run(code, { + envs: { + ...envs, + PATH: '/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/root/.local/bin', + }, + timeoutMs, + user: 'root', + }) + } catch (cmdError: any) { + const stderr = cmdError?.stderr || cmdError?.message || String(cmdError) + const stdout = cmdError?.stdout || '' + const exitCode = cmdError?.exitCode ?? 1 + logger.error('E2B shell command error', { + sandboxId, + exitCode, + error: stderr.slice(0, 500), + }) + return { + result: null, + stdout: [stdout, stderr].filter(Boolean).join('\n'), + error: stderr || `Command failed with exit code ${exitCode}`, + sandboxId, + } + } + + const stdout = [result.stdout, result.stderr].filter(Boolean).join('\n') + + if (result.exitCode !== 0) { + const errorMessage = result.stderr || `Process exited with code ${result.exitCode}` + logger.error('E2B shell execution error', { + sandboxId, + exitCode: result.exitCode, + stderr: result.stderr?.slice(0, 500), + }) + return { + result: null, + stdout, + error: errorMessage, + sandboxId, + } + } + + let parsed: unknown = null + const prefix = '__SIM_RESULT__=' + const lines = stdout.split('\n') + const marker = lines.find((l) => l.startsWith(prefix)) + let cleanedStdout = stdout + if (marker) { + const jsonPart = marker.slice(prefix.length) + try { + parsed = JSON.parse(jsonPart) + } catch { + parsed = jsonPart + } + const filteredLines = lines.filter((l) => !l.startsWith(prefix)) + if (filteredLines.length > 0 && filteredLines[filteredLines.length - 1] === '') { + filteredLines.pop() + } + cleanedStdout = filteredLines.join('\n') + } + + const exportedFileContent = outputSandboxPath + ? await sandbox.files.read(outputSandboxPath) + : undefined + + return { result: parsed, stdout: cleanedStdout, sandboxId, exportedFileContent } } finally { try { await sandbox.kill() diff --git a/apps/sim/lib/execution/languages.ts b/apps/sim/lib/execution/languages.ts index e25b991dd03..e664f23de96 100644 --- a/apps/sim/lib/execution/languages.ts +++ b/apps/sim/lib/execution/languages.ts @@ -4,6 +4,7 @@ export enum CodeLanguage { JavaScript = 'javascript', Python = 'python', + Shell = 'shell', } /** @@ -22,6 +23,8 @@ export function getLanguageDisplayName(language: CodeLanguage): string { return 'JavaScript' case CodeLanguage.Python: return 'Python' + case CodeLanguage.Shell: + return 'Shell' default: return language } diff --git a/apps/sim/lib/mothership/inbox/executor.ts b/apps/sim/lib/mothership/inbox/executor.ts index ae6ce93fbc9..af3b3296c29 100644 --- a/apps/sim/lib/mothership/inbox/executor.ts +++ b/apps/sim/lib/mothership/inbox/executor.ts @@ -1,14 +1,17 @@ import { copilotChats, db, mothershipInboxTask, permissions, user, workspace } from '@sim/db' import { createLogger } from '@sim/logger' import { and, eq, sql } from 'drizzle-orm' -import { createRunSegment } from '@/lib/copilot/async-runs/repository' -import { resolveOrCreateChat } from '@/lib/copilot/chat-lifecycle' -import { buildIntegrationToolSchemas } from '@/lib/copilot/chat-payload' -import { requestChatTitle } from '@/lib/copilot/chat-streaming' -import { orchestrateCopilotStream } from '@/lib/copilot/orchestrator' -import type { OrchestratorResult } from '@/lib/copilot/orchestrator/types' -import { taskPubSub } from '@/lib/copilot/task-events' -import { generateWorkspaceContext } from '@/lib/copilot/workspace-context' +import { resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle' +import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload' +import { + buildPersistedAssistantMessage, + buildPersistedUserMessage, +} from '@/lib/copilot/chat/persisted-message' +import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context' +import { runCopilotLifecycle } from '@/lib/copilot/request/lifecycle/run' +import { requestChatTitle } from '@/lib/copilot/request/lifecycle/start' +import type { OrchestratorResult } from '@/lib/copilot/request/types' +import { taskPubSub } from '@/lib/copilot/tasks' import { isHosted } from '@/lib/core/config/feature-flags' import * as agentmail from '@/lib/mothership/inbox/agentmail-client' import { formatEmailAsMessage } from '@/lib/mothership/inbox/format' @@ -188,27 +191,10 @@ export async function executeInboxTask(taskId: string): Promise { ...(fileAttachments.length > 0 ? { fileAttachments } : {}), } - const executionId = crypto.randomUUID() - const runId = crypto.randomUUID() - const runStreamId = crypto.randomUUID() - - if (chatId) { - await createRunSegment({ - id: runId, - executionId, - chatId, - userId, - workspaceId: ws.id, - streamId: runStreamId, - }).catch(() => {}) - } - - const result = await orchestrateCopilotStream(requestPayload, { + const result = await runCopilotLifecycle(requestPayload, { userId, workspaceId: ws.id, chatId: chatId ?? undefined, - executionId, - runId, goRoute: '/api/mothership/execute', autoExecuteTools: true, interactive: false, @@ -326,23 +312,13 @@ async function persistChatMessages( storedAttachments: StoredAttachment[] = [] ): Promise { try { - const now = new Date().toISOString() - - const userMessage = { + const userMessage = buildPersistedUserMessage({ id: userMessageId, - role: 'user' as const, content: userContent, - timestamp: now, - ...(storedAttachments.length > 0 ? { fileAttachments: storedAttachments } : {}), - } + fileAttachments: storedAttachments.length > 0 ? storedAttachments : undefined, + }) - const assistantMessage = { - id: crypto.randomUUID(), - role: 'assistant' as const, - content: result.content || '', - timestamp: now, - ...(result.error ? { errorType: 'internal' } : {}), - } + const assistantMessage = buildPersistedAssistantMessage(result) const newMessages = JSON.stringify([userMessage, assistantMessage]) await db diff --git a/apps/sim/stores/panel/copilot/types.ts b/apps/sim/stores/panel/copilot/types.ts deleted file mode 100644 index 796cc53087e..00000000000 --- a/apps/sim/stores/panel/copilot/types.ts +++ /dev/null @@ -1,47 +0,0 @@ -import type { ServerToolUI } from '@/lib/copilot/store-utils' -import type { - ClientToolCallState, - ClientToolDisplay, -} from '@/lib/copilot/tools/client/tool-display-registry' -import type { ChatContext as PanelChatContext } from '@/stores/panel/types' - -export type ChatContext = PanelChatContext - -export interface CopilotToolCall { - id: string - name: string - state: ClientToolCallState - display?: ClientToolDisplay - params?: Record - input?: Record - serverUI?: ServerToolUI - clientExecutable?: boolean - result?: { success: boolean; output?: unknown; error?: string } - error?: string - calledBy?: string - streamingArgs?: string -} - -export interface SubAgentContentBlock { - type: string - content?: string - toolCall?: CopilotToolCall | null -} - -export interface CopilotStreamInfo { - chatId?: string - streamId?: string - messageId?: string -} - -export interface CopilotStore { - messages: Array> - toolCallsById: Record - activeStream: CopilotStreamInfo | null - streamingPlanContent?: string - handleNewChatCreation: (chatId: string) => Promise - updatePlanTodoStatus: (todoId: string, status: string) => void - [key: string]: unknown -} - -export type { ClientToolCallState, ClientToolDisplay } diff --git a/apps/sim/stores/panel/index.ts b/apps/sim/stores/panel/index.ts index 13ec5f1eec3..01e387082b1 100644 --- a/apps/sim/stores/panel/index.ts +++ b/apps/sim/stores/panel/index.ts @@ -1,8 +1,6 @@ // Main panel store export { ClientToolCallState as ToolState } from '@/lib/copilot/tools/client/tool-display-registry' -// Copilot types -export type { CopilotToolCall } from './copilot/types' // Editor export { usePanelEditorStore } from './editor' export { usePanelStore } from './store' diff --git a/apps/sim/tools/function/execute.ts b/apps/sim/tools/function/execute.ts index d8e75ca55d3..7c8841e11ce 100644 --- a/apps/sim/tools/function/execute.ts +++ b/apps/sim/tools/function/execute.ts @@ -7,7 +7,7 @@ export const functionExecuteTool: ToolConfig workflowVariables?: Record blockData?: Record @@ -15,6 +19,7 @@ export interface CodeExecutionInput { _context?: { workflowId?: string userId?: string + workspaceId?: string } isCustomTool?: boolean _sandboxFiles?: Array<{ path: string; content: string }> diff --git a/apps/sim/tools/index.ts b/apps/sim/tools/index.ts index 737dfa47bbd..135acb9c613 100644 --- a/apps/sim/tools/index.ts +++ b/apps/sim/tools/index.ts @@ -695,7 +695,6 @@ export async function executeTool( if (contextParams.oauthCredential) { contextParams.credential = contextParams.oauthCredential } - if (contextParams.credential) { logger.info( `[${requestId}] Tool ${toolId} needs access token for credential: ${contextParams.credential}` diff --git a/apps/sim/tools/types.ts b/apps/sim/tools/types.ts index 48c44535f71..7758f6facc2 100644 --- a/apps/sim/tools/types.ts +++ b/apps/sim/tools/types.ts @@ -1,3 +1,4 @@ +import type { MothershipResource } from '@/lib/copilot/resources/types' import type { HostedKeyRateLimitConfig } from '@/lib/core/rate-limiter' import type { OAuthService } from '@/lib/oauth' @@ -62,6 +63,7 @@ export interface ToolResponse { success: boolean // Whether the tool execution was successful output: Record // The structured output from the tool error?: string // Error message if success is false + resources?: MothershipResource[] // Resources to auto-open/show in UI timing?: { startTime: string // ISO timestamp when the tool execution started endTime: string // ISO timestamp when the tool execution ended diff --git a/package.json b/package.json index 9cd7279b997..835dec20b7d 100644 --- a/package.json +++ b/package.json @@ -21,6 +21,14 @@ "lint:helm": "helm lint ./helm/sim --strict --values ./helm/sim/test/values-lint.yaml", "lint:all": "turbo run lint && bun run lint:helm", "check": "turbo run format:check", + "mship-contracts:generate": "bun run scripts/sync-mothership-stream-contract.ts", + "mship-contracts:check": "bun run scripts/sync-mothership-stream-contract.ts --check", + "mship-tools:generate": "bun run scripts/sync-tool-catalog.ts", + "mship-tools:check": "bun run scripts/sync-tool-catalog.ts --check", + "trace-contracts:generate": "bun run scripts/sync-request-trace-contract.ts", + "trace-contracts:check": "bun run scripts/sync-request-trace-contract.ts --check", + "mship:generate": "bun run mship-contracts:generate && bun run mship-tools:generate && bun run trace-contracts:generate", + "mship:check": "bun run mship-contracts:check && bun run mship-tools:check && bun run trace-contracts:check", "prepare": "bun husky", "type-check": "turbo run type-check", "release": "bun run scripts/create-single-release.ts" diff --git a/scripts/sync-mothership-stream-contract.ts b/scripts/sync-mothership-stream-contract.ts new file mode 100644 index 00000000000..0c0282b240d --- /dev/null +++ b/scripts/sync-mothership-stream-contract.ts @@ -0,0 +1,68 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { compile } from 'json-schema-to-typescript' + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(SCRIPT_DIR, '..') +const DEFAULT_CONTRACT_PATH = resolve( + ROOT, + '../copilot/copilot/contracts/mothership-stream-v1.schema.json' +) +const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/copilot/generated/mothership-stream-v1.ts') + +function generateRuntimeConstants(schema: Record): string { + const defs = (schema.$defs ?? schema.definitions ?? {}) as Record + const lines: string[] = [] + + for (const [name, def] of Object.entries(defs)) { + if (!def || typeof def !== 'object') continue + const defObj = def as Record + const enumValues = defObj.enum + if (!Array.isArray(enumValues) || enumValues.length === 0) continue + if (!enumValues.every((v) => typeof v === 'string')) continue + + const entries = (enumValues as string[]) + .map((v) => ` ${JSON.stringify(v)}: ${JSON.stringify(v)}`) + .join(',\n') + + lines.push( + `export const ${name} = {\n${entries},\n} as const;\n` + ) + } + + return lines.join('\n') +} + +async function main() { + const checkOnly = process.argv.includes('--check') + const inputPathArg = process.argv.find((arg) => arg.startsWith('--input=')) + const inputPath = inputPathArg ? resolve(ROOT, inputPathArg.slice('--input='.length)) : DEFAULT_CONTRACT_PATH + + const raw = await readFile(inputPath, 'utf8') + const schema = JSON.parse(raw) + const types = await compile(schema, 'MothershipStreamV1EventEnvelope', { + bannerComment: + '// AUTO-GENERATED FILE. DO NOT EDIT.\n//', + unreachableDefinitions: true, + additionalProperties: false + }) + + const constants = generateRuntimeConstants(schema) + const rendered = constants ? `${types}\n${constants}\n` : types + + if (checkOnly) { + const existing = await readFile(OUTPUT_PATH, 'utf8').catch(() => null) + if (existing !== rendered) { + throw new Error( + `Generated mothership stream contract is stale. Run: bun run mship-contracts:generate` + ) + } + return + } + + await mkdir(dirname(OUTPUT_PATH), { recursive: true }) + await writeFile(OUTPUT_PATH, rendered, 'utf8') +} + +await main() diff --git a/scripts/sync-request-trace-contract.ts b/scripts/sync-request-trace-contract.ts new file mode 100644 index 00000000000..2cf7f5c3f05 --- /dev/null +++ b/scripts/sync-request-trace-contract.ts @@ -0,0 +1,73 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' +import { compile } from 'json-schema-to-typescript' + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(SCRIPT_DIR, '..') +const DEFAULT_CONTRACT_PATH = resolve( + ROOT, + '../copilot/copilot/contracts/request-trace-v1.schema.json' +) +const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/copilot/generated/request-trace-v1.ts') + +function generateRuntimeConstants(schema: Record): string { + const defs = (schema.$defs ?? schema.definitions ?? {}) as Record + const lines: string[] = [] + + for (const [name, def] of Object.entries(defs)) { + if (!def || typeof def !== 'object') continue + const defObj = def as Record + const enumValues = defObj.enum + if (!Array.isArray(enumValues) || enumValues.length === 0) continue + if (!enumValues.every((v) => typeof v === 'string')) continue + + const entries = (enumValues as string[]) + .map((v) => ` ${JSON.stringify(v)}: ${JSON.stringify(v)}`) + .join(',\n') + + lines.push( + `export const ${name} = {\n${entries},\n} as const;\n` + ) + } + + return lines.join('\n') +} + +async function main() { + const checkOnly = process.argv.includes('--check') + const inputPathArg = process.argv.find((arg) => arg.startsWith('--input=')) + const inputPath = inputPathArg ? resolve(ROOT, inputPathArg.slice('--input='.length)) : DEFAULT_CONTRACT_PATH + + const raw = await readFile(inputPath, 'utf8') + const schema = JSON.parse(raw) + const types = await compile(schema, 'RequestTraceV1SimReport', { + bannerComment: + '// AUTO-GENERATED FILE. DO NOT EDIT.\n//', + unreachableDefinitions: true, + additionalProperties: false + }) + + const constants = generateRuntimeConstants(schema) + const rendered = constants ? `${types}\n${constants}\n` : types + + if (checkOnly) { + const existing = await readFile(OUTPUT_PATH, 'utf8').catch(() => null) + if (existing !== rendered) { + throw new Error( + `Generated request trace contract is stale. Run: bun run trace-contracts:generate` + ) + } + console.log('Request trace contract is up to date.') + return + } + + await mkdir(dirname(OUTPUT_PATH), { recursive: true }) + await writeFile(OUTPUT_PATH, rendered, 'utf8') + console.log(`Generated request trace types -> ${OUTPUT_PATH}`) +} + +main().catch((err) => { + console.error(err) + process.exit(1) +}) diff --git a/scripts/sync-tool-catalog.ts b/scripts/sync-tool-catalog.ts new file mode 100644 index 00000000000..78893a6a67f --- /dev/null +++ b/scripts/sync-tool-catalog.ts @@ -0,0 +1,113 @@ +import { mkdir, readFile, writeFile } from 'node:fs/promises' +import { dirname, resolve } from 'node:path' +import { fileURLToPath } from 'node:url' + +const SCRIPT_DIR = dirname(fileURLToPath(import.meta.url)) +const ROOT = resolve(SCRIPT_DIR, '..') +const DEFAULT_CATALOG_PATH = resolve( + ROOT, + '../copilot/copilot/contracts/tool-catalog-v1.json' +) +const OUTPUT_PATH = resolve(ROOT, 'apps/sim/lib/copilot/generated/tool-catalog-v1.ts') + +function snakeToPascal(s: string): string { + return s.split('_').map((w) => w.charAt(0).toUpperCase() + w.slice(1)).join('') +} + +function inferTSType(values: unknown[]): string { + const unique = [...new Set(values.filter((v) => v !== undefined && v !== null))] + if (unique.length === 0) return 'string' + if (unique.every((v) => typeof v === 'string')) { + return unique.map((v) => JSON.stringify(v)).sort().join(' | ') + } + if (unique.every((v) => typeof v === 'boolean')) return 'boolean' + if (unique.every((v) => typeof v === 'number')) return 'number' + return 'string' +} + +function generateInterface(tools: Record[]): string { + if (tools.length === 0) return 'export interface ToolCatalogEntry {}\n' + + const allKeys = new Set() + for (const tool of tools) { + for (const key of Object.keys(tool)) { + allKeys.add(key) + } + } + + const requiredKeys = new Set() + for (const key of allKeys) { + if (tools.every((t) => key in t)) { + requiredKeys.add(key) + } + } + + const lines: string[] = ['export interface ToolCatalogEntry {'] + for (const key of [...allKeys].sort()) { + const values = tools.map((t) => t[key]) + const tsType = inferTSType(values) + const optional = requiredKeys.has(key) ? '' : '?' + lines.push(` ${key}${optional}: ${tsType};`) + } + lines.push('}') + return lines.join('\n') +} + +async function main() { + const checkOnly = process.argv.includes('--check') + const inputPathArg = process.argv.find((arg) => arg.startsWith('--input=')) + const inputPath = inputPathArg ? resolve(ROOT, inputPathArg.slice('--input='.length)) : DEFAULT_CATALOG_PATH + + const raw = await readFile(inputPath, 'utf8') + const catalog = JSON.parse(raw) as { version: string; tools: Record[] } + + const iface = generateInterface(catalog.tools) + + const lines: string[] = [ + '// AUTO-GENERATED FILE. DO NOT EDIT.', + '// Generated from copilot/contracts/tool-catalog-v1.json', + '//', + '', + iface, + '', + ] + + const constNames: string[] = [] + + for (const tool of catalog.tools) { + const constName = snakeToPascal(tool.id as string) + constNames.push(constName) + const fields: string[] = [] + for (const [key, value] of Object.entries(tool)) { + fields.push(` ${key}: ${JSON.stringify(value)}`) + } + lines.push(`export const ${constName}: ToolCatalogEntry = {`) + lines.push(fields.join(',\n') + ',') + lines.push('};') + lines.push('') + } + + lines.push(`export const TOOL_CATALOG: Record = {`) + for (let i = 0; i < catalog.tools.length; i++) { + lines.push(` [${constNames[i]}.id]: ${constNames[i]},`) + } + lines.push('};') + lines.push('') + + const rendered = lines.join('\n') + + if (checkOnly) { + const existing = await readFile(OUTPUT_PATH, 'utf8').catch(() => null) + if (existing !== rendered) { + throw new Error( + `Generated tool catalog is stale. Run: bun run mship-tools:generate` + ) + } + return + } + + await mkdir(dirname(OUTPUT_PATH), { recursive: true }) + await writeFile(OUTPUT_PATH, rendered, 'utf8') +} + +await main()