From 04968a38c7100e85bbad8e12ebb9845d24d91564 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 1 May 2026 10:24:46 -0700 Subject: [PATCH 1/5] =?UTF-8?q?fix(fork):=20resolve=20P0/P1=20bugs=20?= =?UTF-8?q?=E2=80=94=20protocol,=20atomicity,=20streaming=20guard,=20butto?= =?UTF-8?q?n?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Send keepCount instead of upToMessageId to Go; the Sim persisted-message array index is used directly so there is no ID-matching between Sim and Go. - Add streaming guard: reject fork if parent.conversationId is set (active stream in progress), matching the /chat/stop pattern. - Add workflowId to INSERT so forked chats inherit the parent workflow link. - Make Go failure a hard error with compensating delete: if the copilot call returns non-OK or throws, the orphaned Sim row is deleted before returning 500 instead of silently succeeding. - Move taskPubSub and captureServerEvent into the success path so analytics and sidebar updates only fire when both services confirmed the fork. - Enable the fork button: canFork = Boolean(messageId && !isStreamActive). - Add isStreamActive prop to MessageActions; mothership-chat passes it down so the button is suppressed during active generations. --- .../mothership/chats/[chatId]/fork/route.ts | 38 ++++++++++++++----- .../message-actions/message-actions.tsx | 4 +- .../mothership-chat/mothership-chat.tsx | 1 + 3 files changed, 32 insertions(+), 11 deletions(-) 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..995e4ad3582 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts @@ -60,6 +60,10 @@ export const POST = withRouteHandler( await assertActiveWorkspaceAccess(parent.workspaceId, userId) } + if (parent.conversationId) { + return createBadRequestResponse('Cannot fork a chat with an active stream') + } + // 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) @@ -84,6 +88,7 @@ export const POST = withRouteHandler( id: newId, userId, workspaceId: parent.workspaceId, + workflowId: parent.workflowId, type: parent.type, title, model: parent.model, @@ -103,19 +108,19 @@ export const POST = withRouteHandler( } // 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. + const copilotHeaders: Record = { 'Content-Type': 'application/json' } + if (env.COPILOT_API_KEY) { + copilotHeaders['x-api-key'] = env.COPILOT_API_KEY + } + let copilotFailed = false 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, + keepCount: forkedMessages.length, userId, }), spanName: 'sim → go /api/chats/fork', @@ -123,12 +128,25 @@ export const POST = withRouteHandler( }) if (!copilotRes.ok) { const text = await copilotRes.text().catch(() => '') - logger.warn('Copilot fork returned non-OK', { status: copilotRes.status, body: text }) + logger.error('Copilot fork returned non-OK', { status: copilotRes.status, body: text }) + copilotFailed = true } } 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 }) + logger.error('Failed to call copilot fork endpoint', { err }) + copilotFailed = true + } + + if (copilotFailed) { + // Compensating delete — remove the orphaned Sim row. + await db + .delete(copilotChats) + .where(eq(copilotChats.id, newId)) + .catch((e: unknown) => { + logger.error('Failed to delete orphaned forked chat after copilot failure', { + error: e, + }) + }) + return createInternalServerErrorResponse('Failed to fork chat') } if (newChat.workspaceId) { 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..a5ac9fcd0e7 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(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} /> )} From 526673ac3406888004ffb60d09550b6e94b045e5 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 1 May 2026 11:44:13 -0700 Subject: [PATCH 2/5] fix(fork): call Go before Sim INSERT, update route baseline --- .../mothership/chats/[chatId]/fork/route.ts | 77 +++++++++---------- scripts/check-api-validation-contracts.ts | 4 +- 2 files changed, 39 insertions(+), 42 deletions(-) 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 995e4ad3582..709a1a2d0c8 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts @@ -82,37 +82,15 @@ export const POST = withRouteHandler( const title = `Fork | ${baseTitle}` const now = new Date() - const [newChat] = await db - .insert(copilotChats) - .values({ - id: newId, - userId, - workspaceId: parent.workspaceId, - workflowId: parent.workflowId, - 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') - } - - // Clone copilot-service conversation state (messages, active_messages, memory files). + // 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. + // (The inverse order — Sim INSERT first — required a compensating delete + // and still left a brief window where the row was visible but Go state + // wasn't ready.) const copilotHeaders: Record = { 'Content-Type': 'application/json' } if (env.COPILOT_API_KEY) { copilotHeaders['x-api-key'] = env.COPILOT_API_KEY } - let copilotFailed = false try { const copilotRes = await fetchGo(`${SIM_AGENT_API_URL}/api/chats/fork`, { method: 'POST', @@ -129,24 +107,43 @@ export const POST = withRouteHandler( if (!copilotRes.ok) { const text = await copilotRes.text().catch(() => '') logger.error('Copilot fork returned non-OK', { status: copilotRes.status, body: text }) - copilotFailed = true + return createInternalServerErrorResponse('Failed to fork chat') } } catch (err) { logger.error('Failed to call copilot fork endpoint', { err }) - copilotFailed = true + return createInternalServerErrorResponse('Failed to fork chat') } - if (copilotFailed) { - // Compensating delete — remove the orphaned Sim row. - await db - .delete(copilotChats) - .where(eq(copilotChats.id, newId)) - .catch((e: unknown) => { - logger.error('Failed to delete orphaned forked chat after copilot failure', { - error: e, - }) - }) - return createInternalServerErrorResponse('Failed to fork chat') + // 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, + workspaceId: parent.workspaceId, + workflowId: parent.workflowId, + 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) { + logger.error('Failed to insert forked chat row after successful Go fork', { + newId, + chatId, + }) + return createInternalServerErrorResponse('Failed to create forked chat') } if (newChat.workspaceId) { 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 From 4228b6ed67bd4b49aec82ef98b67b845c16fe40c Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 1 May 2026 11:48:11 -0700 Subject: [PATCH 3/5] fix(fork): add chatId to canFork guard --- .../components/message-actions/message-actions.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 a5ac9fcd0e7..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 @@ -166,7 +166,7 @@ export const MessageActions = memo(function MessageActions({ const hasContent = Boolean(content) const canSubmitFeedback = Boolean(chatId && userQuery) - const canFork = Boolean(messageId && !isStreamActive) + const canFork = Boolean(chatId && messageId && !isStreamActive) if (!hasContent && !canSubmitFeedback && !canFork) return null return ( From 9681beeb478280a6b7b98562167ee5ab7feb0290 Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 1 May 2026 12:09:37 -0700 Subject: [PATCH 4/5] refactor(fork): remove redundant outer try/catch, clean indentation --- .../mothership/chats/[chatId]/fork/route.ts | 233 ++++++++---------- 1 file changed, 109 insertions(+), 124 deletions(-) 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 709a1a2d0c8..c74352b9216 100644 --- a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts +++ b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts @@ -32,139 +32,124 @@ const logger = createLogger('ForkChatAPI') */ 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 + + 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') + } - if (parent.workspaceId) { - await assertActiveWorkspaceAccess(parent.workspaceId, userId) - } + if (parent.workspaceId) { + await assertActiveWorkspaceAccess(parent.workspaceId, userId) + } - if (parent.conversationId) { - return createBadRequestResponse('Cannot fork a chat with an active stream') - } + if (parent.conversationId) { + return createBadRequestResponse('Cannot fork a chat with an active stream') + } - // 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() - - // 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. - // (The inverse order — Sim INSERT first — required a compensating delete - // and still left a brief window where the row was visible but Go state - // wasn't ready.) - 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') - } + 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) - // 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, - workspaceId: parent.workspaceId, - workflowId: parent.workflowId, - 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) { - logger.error('Failed to insert forked chat row after successful Go fork', { - newId, - chatId, - }) - return createInternalServerErrorResponse('Failed to create forked chat') - } + const parentResources = Array.isArray(parent.resources) + ? (parent.resources as MothershipResource[]) + : [] - if (newChat.workspaceId) { - taskPubSub?.publishStatusChanged({ - workspaceId: newChat.workspaceId, - chatId: newId, - type: 'created', - }) + 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 }) } ) From c90c2fc6c96c4431a6835cfb68f05e0d8afdefcc Mon Sep 17 00:00:00 2001 From: waleed Date: Fri, 1 May 2026 12:12:18 -0700 Subject: [PATCH 5/5] refactor(fork): remove self-explanatory comments --- apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts | 5 ----- 1 file changed, 5 deletions(-) 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 c74352b9216..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,11 +25,6 @@ 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 }> }) => { const { userId, isAuthenticated } = await authenticateCopilotRequestSessionOnly()