diff --git a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts index 7c6b7eef9e2..50c14350193 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts @@ -25,131 +25,126 @@ import { assertActiveWorkspaceAccess } from '@/lib/workspaces/permissions/utils' const logger = createLogger('ForkChatAPI') -/** - * POST /api/mothership/chats/[chatId]/fork - * Creates a new chat branched from the given chat, keeping messages up to and - * including the specified message. Resources and copilot-side state are copied. - */ export const POST = withRouteHandler( async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => { - try { - const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() - if (!isAuthenticated || !userId) { - return createUnauthorizedResponse() - } + const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly() + if (!isAuthenticated || !userId) { + return createUnauthorizedResponse() + } - const parsed = await parseRequest(forkMothershipChatContract, request, context, { - validationErrorResponse: () => createBadRequestResponse('upToMessageId is required'), - }) - if (!parsed.success) return parsed.response - const { chatId } = parsed.data.params - const { upToMessageId } = parsed.data.body - - // Load parent chat and verify ownership. - const [parent] = await db - .select() - .from(copilotChats) - .where(eq(copilotChats.id, chatId)) - .limit(1) - - if (!parent || parent.userId !== userId || parent.type !== 'mothership') { - return createNotFoundResponse('Chat not found') - } + const parsed = await parseRequest(forkMothershipChatContract, request, context, { + validationErrorResponse: () => createBadRequestResponse('upToMessageId is required'), + }) + if (!parsed.success) return parsed.response + const { chatId } = parsed.data.params + const { upToMessageId } = parsed.data.body - if (parent.workspaceId) { - await assertActiveWorkspaceAccess(parent.workspaceId, userId) - } + const [parent] = await db + .select() + .from(copilotChats) + .where(eq(copilotChats.id, chatId)) + .limit(1) - // Find the fork point in the Sim-side messages array. - const messages = Array.isArray(parent.messages) ? (parent.messages as PersistedMessage[]) : [] - const forkIdx = messages.findIndex((m) => m.id === upToMessageId) - if (forkIdx < 0) { - return createBadRequestResponse('Message not found in chat') - } - const forkedMessages = messages.slice(0, forkIdx + 1) - - // Resources are stored as a jsonb array on the chat row — copy them directly. - const parentResources = Array.isArray(parent.resources) - ? (parent.resources as MothershipResource[]) - : [] - - const newId = generateId() - const baseTitle = (parent.title ?? 'New task').replace(/^Fork \| /, '') - const title = `Fork | ${baseTitle}` - const now = new Date() - - const [newChat] = await db - .insert(copilotChats) - .values({ - id: newId, - userId, - workspaceId: parent.workspaceId, - type: parent.type, - title, - model: parent.model, - messages: forkedMessages, - resources: parentResources, - previewYaml: parent.previewYaml, - planArtifact: parent.planArtifact, - config: parent.config, - conversationId: null, - updatedAt: now, - lastSeenAt: now, - }) - .returning({ id: copilotChats.id, workspaceId: copilotChats.workspaceId }) - - if (!newChat) { - return createInternalServerErrorResponse('Failed to create forked chat') - } + if (!parent || parent.userId !== userId || parent.type !== 'mothership') { + return createNotFoundResponse('Chat not found') + } - // Clone copilot-service conversation state (messages, active_messages, memory files). - // Best-effort: if the copilot service doesn't have a row for the source chat yet, skip. - try { - const copilotHeaders: Record = { 'Content-Type': 'application/json' } - if (env.COPILOT_API_KEY) { - copilotHeaders['x-api-key'] = env.COPILOT_API_KEY - } - const copilotRes = await fetchGo(`${SIM_AGENT_API_URL}/api/chats/fork`, { - method: 'POST', - headers: copilotHeaders, - body: JSON.stringify({ - sourceChatId: chatId, - newChatId: newId, - upToMessageId, - userId, - }), - spanName: 'sim → go /api/chats/fork', - operation: 'fork_chat', - }) - if (!copilotRes.ok) { - const text = await copilotRes.text().catch(() => '') - logger.warn('Copilot fork returned non-OK', { status: copilotRes.status, body: text }) - } - } catch (err) { - // The copilot service may not have a row for this chat if no messages - // have been sent yet, or if it's unreachable. Log and continue. - logger.warn('Failed to fork copilot-service conversation, skipping', { err }) - } + if (parent.workspaceId) { + await assertActiveWorkspaceAccess(parent.workspaceId, userId) + } + + if (parent.conversationId) { + return createBadRequestResponse('Cannot fork a chat with an active stream') + } + + const messages = Array.isArray(parent.messages) ? (parent.messages as PersistedMessage[]) : [] + const forkIdx = messages.findIndex((m) => m.id === upToMessageId) + if (forkIdx < 0) { + return createBadRequestResponse('Message not found in chat') + } + const forkedMessages = messages.slice(0, forkIdx + 1) - if (newChat.workspaceId) { - taskPubSub?.publishStatusChanged({ - workspaceId: newChat.workspaceId, - chatId: newId, - type: 'created', - }) + const parentResources = Array.isArray(parent.resources) + ? (parent.resources as MothershipResource[]) + : [] + + const newId = generateId() + const baseTitle = (parent.title ?? 'New task').replace(/^Fork \| /, '') + const now = new Date() + + // Clone copilot-service conversation state first. If this fails we never + // insert the Sim row, so there is no orphaned UI entry to clean up. + const copilotHeaders: Record = { 'Content-Type': 'application/json' } + if (env.COPILOT_API_KEY) { + copilotHeaders['x-api-key'] = env.COPILOT_API_KEY + } + try { + const copilotRes = await fetchGo(`${SIM_AGENT_API_URL}/api/chats/fork`, { + method: 'POST', + headers: copilotHeaders, + body: JSON.stringify({ + sourceChatId: chatId, + newChatId: newId, + keepCount: forkedMessages.length, + userId, + }), + spanName: 'sim → go /api/chats/fork', + operation: 'fork_chat', + }) + if (!copilotRes.ok) { + const text = await copilotRes.text().catch(() => '') + logger.error('Copilot fork returned non-OK', { status: copilotRes.status, body: text }) + return createInternalServerErrorResponse('Failed to fork chat') } + } catch (err) { + logger.error('Failed to call copilot fork endpoint', { err }) + return createInternalServerErrorResponse('Failed to fork chat') + } - captureServerEvent( + // Go state is ready — now persist the Sim metadata row. If this insert + // fails the Go conversation is orphaned but permanently inaccessible + // (no Sim row = no UI entry), which is harmless. + const [newChat] = await db + .insert(copilotChats) + .values({ + id: newId, userId, - 'task_forked', - { workspace_id: parent.workspaceId ?? '', source_chat_id: chatId }, - { groups: { workspace: parent.workspaceId ?? '' } } - ) - - return NextResponse.json({ success: true, id: newId }) - } catch (error) { - logger.error('Error forking chat:', error) - return createInternalServerErrorResponse('Failed to fork chat') + workspaceId: parent.workspaceId, + workflowId: parent.workflowId, + type: parent.type, + title: `Fork | ${baseTitle}`, + model: parent.model, + messages: forkedMessages, + resources: parentResources, + previewYaml: parent.previewYaml, + planArtifact: parent.planArtifact, + config: parent.config, + conversationId: null, + updatedAt: now, + lastSeenAt: now, + }) + .returning({ id: copilotChats.id, workspaceId: copilotChats.workspaceId }) + + if (!newChat) { + logger.error('Failed to insert forked chat row after successful Go fork', { newId, chatId }) + return createInternalServerErrorResponse('Failed to create forked chat') + } + + if (newChat.workspaceId) { + taskPubSub?.publishStatusChanged({ + workspaceId: newChat.workspaceId, + chatId: newId, + type: 'created', + }) } + + captureServerEvent( + userId, + 'task_forked', + { workspace_id: parent.workspaceId ?? '', source_chat_id: chatId }, + { groups: { workspace: parent.workspaceId ?? '' } } + ) + + return NextResponse.json({ success: true, id: newId }) } ) diff --git a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx index 25eb175ceff..ecc63633d4e 100644 --- a/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx +++ b/apps/sim/app/workspace/[workspaceId]/components/message-actions/message-actions.tsx @@ -55,6 +55,7 @@ interface MessageActionsProps { userQuery?: string requestId?: string messageId?: string + isStreamActive?: boolean } export const MessageActions = memo(function MessageActions({ @@ -63,6 +64,7 @@ export const MessageActions = memo(function MessageActions({ userQuery, requestId, messageId, + isStreamActive, }: MessageActionsProps) { const router = useRouter() const params = useParams<{ workspaceId: string }>() @@ -164,7 +166,7 @@ export const MessageActions = memo(function MessageActions({ const hasContent = Boolean(content) const canSubmitFeedback = Boolean(chatId && userQuery) - const canFork = false + const canFork = Boolean(chatId && messageId && !isStreamActive) if (!hasContent && !canSubmitFeedback && !canFork) return null return ( diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index c4b8910b127..d90a74ff193 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -220,6 +220,7 @@ export function MothershipChat({ userQuery={precedingUserContent} requestId={msg.requestId} messageId={msg.id} + isStreamActive={isStreamActive} /> )} diff --git a/scripts/check-api-validation-contracts.ts b/scripts/check-api-validation-contracts.ts index 7bcf0ebcb0c..751c1919f54 100644 --- a/scripts/check-api-validation-contracts.ts +++ b/scripts/check-api-validation-contracts.ts @@ -9,8 +9,8 @@ const QUERY_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/queries') const SELECTOR_HOOKS_DIR = path.join(ROOT, 'apps/sim/hooks/selectors') const BASELINE = { - totalRoutes: 716, - zodRoutes: 716, + totalRoutes: 717, + zodRoutes: 717, nonZodRoutes: 0, } as const