diff --git a/apps/docs/components/workflow-preview/format-references.tsx b/apps/docs/components/workflow-preview/format-references.tsx
index 6a3c01ae21e..15bfeb5c049 100644
--- a/apps/docs/components/workflow-preview/format-references.tsx
+++ b/apps/docs/components/workflow-preview/format-references.tsx
@@ -15,12 +15,10 @@ export function formatReferences(text: string): ReactNode[] {
const isReference =
(part.startsWith('<') && part.endsWith('>')) || (part.startsWith('{{') && part.endsWith('}}'))
return isReference ? (
- // biome-ignore lint/suspicious/noArrayIndexKey: static, never reordered
{part}
) : (
- // biome-ignore lint/suspicious/noArrayIndexKey: static, never reordered
{part}
)
})
diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.test.ts b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts
new file mode 100644
index 00000000000..894e543c3b2
--- /dev/null
+++ b/apps/sim/app/api/auth/oauth2/authorize/route.test.ts
@@ -0,0 +1,323 @@
+/**
+ * @vitest-environment node
+ */
+import { createMockRequest, dbChainMock, dbChainMockFns, resetDbChainMock } from '@sim/testing'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockGetSession,
+ mockOAuth2LinkAccount,
+ mockCheckWorkspaceAccess,
+ mockGetCredentialActorContext,
+} = vi.hoisted(() => ({
+ mockGetSession: vi.fn(),
+ mockOAuth2LinkAccount: vi.fn(),
+ mockCheckWorkspaceAccess: vi.fn(),
+ mockGetCredentialActorContext: vi.fn(),
+}))
+
+vi.mock('@sim/db', () => dbChainMock)
+
+vi.mock('@/lib/auth/auth', () => ({
+ auth: { api: { oAuth2LinkAccount: mockOAuth2LinkAccount } },
+ getSession: mockGetSession,
+}))
+
+vi.mock('@/lib/workspaces/permissions/utils', () => ({
+ checkWorkspaceAccess: mockCheckWorkspaceAccess,
+}))
+
+vi.mock('@/lib/credentials/access', () => ({
+ getCredentialActorContext: mockGetCredentialActorContext,
+}))
+
+vi.mock('@/lib/oauth/utils', () => ({
+ getAllOAuthServices: vi.fn(() => [{ providerId: 'google-email', name: 'Gmail' }]),
+}))
+
+import { GET } from '@/app/api/auth/oauth2/authorize/route'
+
+const BASE_URL = 'https://sim.test'
+const WORKSPACE_ID = 'ws-1'
+const USER_ID = 'user-1'
+const CREDENTIAL_ID = 'cred-1'
+const LINK_URL = 'https://provider.example/authorize?state=abc'
+
+function authorizeRequest(query: Record) {
+ const url = new URL(`${BASE_URL}/api/auth/oauth2/authorize`)
+ for (const [key, value] of Object.entries(query)) {
+ url.searchParams.set(key, value)
+ }
+ return createMockRequest('GET', undefined, {}, url.toString())
+}
+
+function oauthCredentialActor(overrides: Record = {}) {
+ return {
+ credential: {
+ id: CREDENTIAL_ID,
+ workspaceId: WORKSPACE_ID,
+ type: 'oauth',
+ providerId: 'google-email',
+ displayName: 'Work Gmail',
+ ...((overrides.credential as Record) ?? {}),
+ },
+ member: null,
+ hasWorkspaceAccess: true,
+ canWriteWorkspace: true,
+ isAdmin: true,
+ ...Object.fromEntries(Object.entries(overrides).filter(([key]) => key !== 'credential')),
+ }
+}
+
+describe('OAuth2 authorize route', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ resetDbChainMock()
+ process.env.NEXT_PUBLIC_APP_URL = BASE_URL
+ mockGetSession.mockResolvedValue({ user: { id: USER_ID } })
+ mockCheckWorkspaceAccess.mockResolvedValue({
+ hasAccess: true,
+ canWrite: true,
+ canAdmin: false,
+ workspace: { id: WORKSPACE_ID },
+ })
+ mockOAuth2LinkAccount.mockResolvedValue({
+ ok: true,
+ status: 200,
+ json: async () => ({ url: LINK_URL }),
+ headers: { getSetCookie: () => ['better-auth.state=xyz; Path=/'] },
+ })
+ })
+
+ describe('plain connect (no credentialId)', () => {
+ it('creates a draft with credentialId null and redirects to the provider', async () => {
+ const response = await GET(
+ authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })
+ )
+
+ expect(response.headers.get('location')).toBe(LINK_URL)
+ expect(mockGetCredentialActorContext).not.toHaveBeenCalled()
+ expect(dbChainMockFns.values).toHaveBeenCalledWith(
+ expect.objectContaining({
+ userId: USER_ID,
+ workspaceId: WORKSPACE_ID,
+ providerId: 'google-email',
+ credentialId: null,
+ })
+ )
+ expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ set: expect.objectContaining({ credentialId: null }),
+ })
+ )
+ })
+
+ it('numbers the draft display name when the default collides with an existing credential', async () => {
+ dbChainMockFns.where
+ .mockImplementationOnce(() => Promise.resolve([{ name: 'Justin' }]))
+ .mockImplementationOnce(() => Promise.resolve([{ displayName: "Justin's Gmail" }]))
+
+ await GET(authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }))
+
+ expect(dbChainMockFns.values).toHaveBeenCalledWith(
+ expect.objectContaining({ displayName: "Justin's Gmail 2" })
+ )
+ })
+
+ it('nulls out credentialId in the upsert set so a stale reconnect draft cannot leak into a plain connect', async () => {
+ await GET(authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID }))
+
+ const [{ set }] = dbChainMockFns.onConflictDoUpdate.mock.calls[0]
+ expect(set).toHaveProperty('credentialId', null)
+ })
+
+ it('redirects to login when unauthenticated', async () => {
+ mockGetSession.mockResolvedValue(null)
+
+ const response = await GET(
+ authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })
+ )
+
+ expect(response.headers.get('location')).toContain('/login')
+ expect(dbChainMockFns.values).not.toHaveBeenCalled()
+ })
+
+ it('rejects without workspace write access', async () => {
+ mockCheckWorkspaceAccess.mockResolvedValue({
+ hasAccess: true,
+ canWrite: false,
+ canAdmin: false,
+ workspace: { id: WORKSPACE_ID },
+ })
+
+ const response = await GET(
+ authorizeRequest({ providerId: 'google-email', workspaceId: WORKSPACE_ID })
+ )
+
+ expect(response.headers.get('location')).toBe(
+ `${BASE_URL}/workspace?error=workspace_access_denied`
+ )
+ expect(dbChainMockFns.values).not.toHaveBeenCalled()
+ expect(mockOAuth2LinkAccount).not.toHaveBeenCalled()
+ })
+ })
+
+ describe('reconnect (credentialId present)', () => {
+ it('creates a reconnect draft carrying credentialId in values and upsert set', async () => {
+ mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor())
+
+ const response = await GET(
+ authorizeRequest({
+ providerId: 'google-email',
+ workspaceId: WORKSPACE_ID,
+ credentialId: CREDENTIAL_ID,
+ })
+ )
+
+ expect(response.headers.get('location')).toBe(LINK_URL)
+ expect(mockGetCredentialActorContext).toHaveBeenCalledWith(
+ CREDENTIAL_ID,
+ USER_ID,
+ expect.objectContaining({ workspaceAccess: expect.anything() })
+ )
+ expect(dbChainMockFns.values).toHaveBeenCalledWith(
+ expect.objectContaining({ credentialId: CREDENTIAL_ID })
+ )
+ expect(dbChainMockFns.onConflictDoUpdate).toHaveBeenCalledWith(
+ expect.objectContaining({
+ set: expect.objectContaining({ credentialId: CREDENTIAL_ID }),
+ })
+ )
+ })
+
+ it("uses the credential's actual display name for the reconnect draft (audit accuracy)", async () => {
+ mockGetCredentialActorContext.mockResolvedValue(
+ oauthCredentialActor({ credential: { displayName: 'Renamed By User' } })
+ )
+
+ await GET(
+ authorizeRequest({
+ providerId: 'google-email',
+ workspaceId: WORKSPACE_ID,
+ credentialId: CREDENTIAL_ID,
+ })
+ )
+
+ expect(dbChainMockFns.values).toHaveBeenCalledWith(
+ expect.objectContaining({ displayName: 'Renamed By User' })
+ )
+ })
+
+ it('rejects reconnect for custom-flow providers (trello/shopify) and writes no draft', async () => {
+ for (const providerId of ['trello', 'shopify']) {
+ const response = await GET(
+ authorizeRequest({ providerId, workspaceId: WORKSPACE_ID, credentialId: CREDENTIAL_ID })
+ )
+
+ expect(response.headers.get('location')).toBe(
+ `${BASE_URL}/workspace?error=credential_reconnect_unsupported`
+ )
+ }
+ expect(mockGetCredentialActorContext).not.toHaveBeenCalled()
+ expect(dbChainMockFns.values).not.toHaveBeenCalled()
+ expect(mockOAuth2LinkAccount).not.toHaveBeenCalled()
+ })
+
+ it('rejects when the caller is not a credential admin and writes no draft', async () => {
+ mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor({ isAdmin: false }))
+
+ const response = await GET(
+ authorizeRequest({
+ providerId: 'google-email',
+ workspaceId: WORKSPACE_ID,
+ credentialId: CREDENTIAL_ID,
+ })
+ )
+
+ expect(response.headers.get('location')).toBe(
+ `${BASE_URL}/workspace?error=credential_access_denied`
+ )
+ expect(dbChainMockFns.values).not.toHaveBeenCalled()
+ expect(mockOAuth2LinkAccount).not.toHaveBeenCalled()
+ })
+
+ it('rejects when the credential belongs to a different workspace', async () => {
+ mockGetCredentialActorContext.mockResolvedValue(
+ oauthCredentialActor({ credential: { workspaceId: 'ws-other' } })
+ )
+
+ const response = await GET(
+ authorizeRequest({
+ providerId: 'google-email',
+ workspaceId: WORKSPACE_ID,
+ credentialId: CREDENTIAL_ID,
+ })
+ )
+
+ expect(response.headers.get('location')).toBe(
+ `${BASE_URL}/workspace?error=credential_access_denied`
+ )
+ expect(dbChainMockFns.values).not.toHaveBeenCalled()
+ })
+
+ it('rejects when the credential does not exist', async () => {
+ mockGetCredentialActorContext.mockResolvedValue({
+ credential: null,
+ member: null,
+ hasWorkspaceAccess: false,
+ canWriteWorkspace: false,
+ isAdmin: false,
+ })
+
+ const response = await GET(
+ authorizeRequest({
+ providerId: 'google-email',
+ workspaceId: WORKSPACE_ID,
+ credentialId: 'cred-missing',
+ })
+ )
+
+ expect(response.headers.get('location')).toBe(
+ `${BASE_URL}/workspace?error=credential_access_denied`
+ )
+ expect(dbChainMockFns.values).not.toHaveBeenCalled()
+ })
+
+ it('rejects a non-oauth credential', async () => {
+ mockGetCredentialActorContext.mockResolvedValue(
+ oauthCredentialActor({ credential: { type: 'env_workspace' } })
+ )
+
+ const response = await GET(
+ authorizeRequest({
+ providerId: 'google-email',
+ workspaceId: WORKSPACE_ID,
+ credentialId: CREDENTIAL_ID,
+ })
+ )
+
+ expect(response.headers.get('location')).toBe(
+ `${BASE_URL}/workspace?error=credential_access_denied`
+ )
+ expect(dbChainMockFns.values).not.toHaveBeenCalled()
+ })
+
+ it('rejects when the query providerId does not match the credential provider', async () => {
+ mockGetCredentialActorContext.mockResolvedValue(oauthCredentialActor())
+
+ const response = await GET(
+ authorizeRequest({
+ providerId: 'slack',
+ workspaceId: WORKSPACE_ID,
+ credentialId: CREDENTIAL_ID,
+ })
+ )
+
+ expect(response.headers.get('location')).toBe(
+ `${BASE_URL}/workspace?error=credential_provider_mismatch`
+ )
+ expect(dbChainMockFns.values).not.toHaveBeenCalled()
+ expect(mockOAuth2LinkAccount).not.toHaveBeenCalled()
+ })
+ })
+})
diff --git a/apps/sim/app/api/auth/oauth2/authorize/route.ts b/apps/sim/app/api/auth/oauth2/authorize/route.ts
index 65bb6a773b9..884762d3afd 100644
--- a/apps/sim/app/api/auth/oauth2/authorize/route.ts
+++ b/apps/sim/app/api/auth/oauth2/authorize/route.ts
@@ -1,5 +1,5 @@
import { db } from '@sim/db'
-import { pendingCredentialDraft, user } from '@sim/db/schema'
+import { credential, pendingCredentialDraft, user } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
import { and, eq, lt } from 'drizzle-orm'
@@ -9,6 +9,8 @@ import { parseRequest } from '@/lib/api/server'
import { auth, getSession } from '@/lib/auth/auth'
import { getBaseUrl } from '@/lib/core/utils/urls'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
+import { getCredentialActorContext } from '@/lib/credentials/access'
+import { defaultCredentialDisplayName } from '@/lib/credentials/display-name'
import { getAllOAuthServices } from '@/lib/oauth/utils'
import { checkWorkspaceAccess } from '@/lib/workspaces/permissions/utils'
@@ -30,20 +32,53 @@ async function createConnectDraft(params: {
userId: string
workspaceId: string
providerId: string
+ credentialId?: string
+ /** Reconnect only: the credential's actual name, so audit records stay accurate. */
+ displayName?: string
}): Promise {
- const { userId, workspaceId, providerId } = params
-
- const service = getAllOAuthServices().find((s) => s.providerId === providerId)
- const serviceName = service?.name ?? providerId
+ const { userId, workspaceId, providerId, credentialId } = params
+
+ let displayName = params.displayName
+ if (!displayName) {
+ const service = getAllOAuthServices().find((s) => s.providerId === providerId)
+ const serviceName = service?.name ?? providerId
+
+ let userName: string | null = null
+ try {
+ const [row] = await db.select({ name: user.name }).from(user).where(eq(user.id, userId))
+ userName = row?.name ?? null
+ } catch (error) {
+ // Cosmetic only — fall back to the "My {Service}" default
+ logger.warn('User name lookup failed for connect draft display name', {
+ userId,
+ workspaceId,
+ providerId,
+ error,
+ })
+ }
- 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}`
+ // Auto-number against existing workspace credentials so repeat connects for
+ // the same provider stay distinguishable — same behavior as the connect
+ // modal, which computes this client-side. Best effort: on failure the name
+ // simply skips deduplication.
+ let takenNames: ReadonlySet = new Set()
+ try {
+ const rows = await db
+ .select({ displayName: credential.displayName })
+ .from(credential)
+ .where(and(eq(credential.workspaceId, workspaceId), eq(credential.type, 'oauth')))
+ takenNames = new Set(rows.map((row) => row.displayName.toLowerCase()))
+ } catch (error) {
+ // Cosmetic only — proceed without collision numbering
+ logger.warn('Credential name lookup failed for connect draft deduplication', {
+ userId,
+ workspaceId,
+ providerId,
+ error,
+ })
}
- } catch {
- // Fall back to service name only
+
+ displayName = defaultCredentialDisplayName(userName, serviceName, takenNames)
}
const now = new Date()
@@ -61,6 +96,7 @@ async function createConnectDraft(params: {
workspaceId,
providerId,
displayName,
+ credentialId: credentialId ?? null,
expiresAt,
createdAt: now,
})
@@ -70,10 +106,18 @@ async function createConnectDraft(params: {
pendingCredentialDraft.providerId,
pendingCredentialDraft.workspaceId,
],
- set: { displayName, expiresAt, createdAt: now },
+ // credentialId must be written on BOTH paths: a plain connect that reuses a
+ // stale reconnect draft row would otherwise silently rebind the old
+ // credential instead of creating a new one.
+ set: { displayName, credentialId: credentialId ?? null, expiresAt, createdAt: now },
})
- logger.info('Created OAuth connect credential draft', { userId, workspaceId, providerId })
+ logger.info('Created OAuth connect credential draft', {
+ userId,
+ workspaceId,
+ providerId,
+ credentialId: credentialId ?? null,
+ })
}
/**
@@ -92,7 +136,12 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
const parsed = await parseRequest(authorizeOAuth2Contract, request, {})
if (!parsed.success) return parsed.response
- const { providerId, workspaceId, callbackURL: requestedCallback } = parsed.data.query
+ const {
+ providerId,
+ workspaceId,
+ callbackURL: requestedCallback,
+ credentialId,
+ } = parsed.data.query
const callbackURL = requestedCallback?.startsWith(`${baseUrl}/`)
? requestedCallback
@@ -109,10 +158,65 @@ export const GET = withRouteHandler(async (request: NextRequest) => {
return NextResponse.redirect(`${baseUrl}/workspace?error=workspace_access_denied`)
}
+ let reconnectDisplayName: string | undefined
+ if (credentialId) {
+ // Trello and Shopify authorize through their own custom flows that bypass
+ // this endpoint, so a reconnect draft written here would linger unconsumed
+ // and could later be picked up by their token-store callbacks, silently
+ // rebinding the credential. Mirror the copilot tool and reject reconnect.
+ if (providerId === 'trello' || providerId === 'shopify') {
+ logger.warn('Reconnect not supported for custom-flow provider', {
+ userId,
+ workspaceId,
+ providerId,
+ credentialId,
+ })
+ return NextResponse.redirect(`${baseUrl}/workspace?error=credential_reconnect_unsupported`)
+ }
+
+ // Reconnect: the OAuth callback will rebind this credential to the fresh
+ // account, so require the same credential-admin access as the draft POST
+ // route — workspace write alone must not be enough to swap someone's tokens.
+ const actor = await getCredentialActorContext(credentialId, userId, {
+ workspaceAccess: access,
+ })
+ if (
+ !actor.credential ||
+ actor.credential.workspaceId !== workspaceId ||
+ actor.credential.type !== 'oauth' ||
+ !actor.isAdmin
+ ) {
+ logger.warn('Credential admin access denied for OAuth2 reconnect', {
+ userId,
+ workspaceId,
+ providerId,
+ credentialId,
+ })
+ return NextResponse.redirect(`${baseUrl}/workspace?error=credential_access_denied`)
+ }
+ if (actor.credential.providerId !== providerId) {
+ logger.warn('Provider mismatch for OAuth2 reconnect', {
+ userId,
+ workspaceId,
+ providerId,
+ credentialId,
+ credentialProviderId: actor.credential.providerId,
+ })
+ return NextResponse.redirect(`${baseUrl}/workspace?error=credential_provider_mismatch`)
+ }
+ reconnectDisplayName = actor.credential.displayName
+ }
+
// Create the draft before initiating the link so it is guaranteed to exist
// (and freshly clocked) when the OAuth callback's `account.create.after`
// hook runs. If this throws, we never start the OAuth flow.
- await createConnectDraft({ userId, workspaceId, providerId })
+ await createConnectDraft({
+ userId,
+ workspaceId,
+ providerId,
+ credentialId,
+ displayName: reconnectDisplayName,
+ })
const linkResponse = await auth.api.oAuth2LinkAccount({
body: { providerId, callbackURL },
diff --git a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts
new file mode 100644
index 00000000000..c863c50b0d6
--- /dev/null
+++ b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.test.ts
@@ -0,0 +1,533 @@
+/**
+ * @vitest-environment node
+ */
+import { copilotHttpMock, copilotHttpMockFns } from '@sim/testing'
+import { NextRequest } from 'next/server'
+import { beforeEach, describe, expect, it, vi } from 'vitest'
+
+const {
+ mockTransaction,
+ mockSelectRows,
+ mockCheckStorageQuota,
+ mockFilterForkableChatFiles,
+ mockListForkableChatFiles,
+ mockPlanChatFileCopies,
+ mockExecuteChatFileBlobCopies,
+ mockLoadCopilotChatMessages,
+ mockAppendCopilotChatMessages,
+ mockAssertActiveWorkspaceAccess,
+ mockFetchGo,
+ mockPublishStatusChanged,
+ mockCaptureServerEvent,
+ mockDeleteWhere,
+ mockRemoveChatResources,
+} = vi.hoisted(() => ({
+ mockTransaction: vi.fn(),
+ mockSelectRows: vi.fn(),
+ mockCheckStorageQuota: vi.fn(),
+ // Real (pure) cut semantics so tests drive selection through row.messageId:
+ // rows with a NULL/undefined messageId are kept in every fork.
+ mockFilterForkableChatFiles: vi.fn(
+ (rows: Array<{ messageId?: string | null }>, kept: ReadonlySet) =>
+ rows.filter((row) => !row.messageId || kept.has(row.messageId))
+ ),
+ mockListForkableChatFiles: vi.fn(),
+ mockPlanChatFileCopies: vi.fn(),
+ mockExecuteChatFileBlobCopies: vi.fn(),
+ mockLoadCopilotChatMessages: vi.fn(),
+ mockAppendCopilotChatMessages: vi.fn(),
+ mockAssertActiveWorkspaceAccess: vi.fn(),
+ mockFetchGo: vi.fn(),
+ mockPublishStatusChanged: vi.fn(),
+ mockCaptureServerEvent: vi.fn(),
+ mockDeleteWhere: vi.fn(),
+ mockRemoveChatResources: vi.fn(),
+}))
+
+vi.mock('@sim/db', () => ({
+ db: {
+ select: () => ({
+ from: () => ({
+ where: () => ({
+ limit: () => mockSelectRows(),
+ }),
+ }),
+ }),
+ delete: () => ({
+ where: mockDeleteWhere,
+ }),
+ transaction: mockTransaction,
+ },
+}))
+
+vi.mock('@sim/db/schema', () => ({
+ copilotChats: {
+ id: 'copilotChats.id',
+ userId: 'copilotChats.userId',
+ type: 'copilotChats.type',
+ workspaceId: 'copilotChats.workspaceId',
+ title: 'copilotChats.title',
+ model: 'copilotChats.model',
+ resources: 'copilotChats.resources',
+ previewYaml: 'copilotChats.previewYaml',
+ planArtifact: 'copilotChats.planArtifact',
+ config: 'copilotChats.config',
+ },
+ workspaceFiles: {
+ id: 'workspaceFiles.id',
+ },
+}))
+
+vi.mock('drizzle-orm', () => ({
+ eq: vi.fn((field: unknown, value: unknown) => ({ type: 'eq', field, value })),
+ inArray: vi.fn((field: unknown, values: unknown) => ({ type: 'inArray', field, values })),
+}))
+
+vi.mock('@/lib/copilot/resources/persistence', () => ({
+ removeChatResources: mockRemoveChatResources,
+}))
+
+vi.mock('@/lib/copilot/request/http', () => copilotHttpMock)
+
+vi.mock('@/lib/billing/storage', () => ({
+ checkStorageQuota: mockCheckStorageQuota,
+}))
+
+vi.mock('@/lib/copilot/chat/fork-chat-files', () => ({
+ filterForkableChatFiles: mockFilterForkableChatFiles,
+ listForkableChatFiles: mockListForkableChatFiles,
+ planChatFileCopies: mockPlanChatFileCopies,
+ executeChatFileBlobCopies: mockExecuteChatFileBlobCopies,
+}))
+
+vi.mock('@/lib/copilot/chat/lifecycle', () => ({
+ loadCopilotChatMessages: mockLoadCopilotChatMessages,
+}))
+
+vi.mock('@/lib/copilot/chat/messages-store', () => ({
+ appendCopilotChatMessages: mockAppendCopilotChatMessages,
+}))
+
+vi.mock('@/lib/copilot/chat-status', () => ({
+ chatPubSub: { publishStatusChanged: mockPublishStatusChanged },
+}))
+
+vi.mock('@/lib/copilot/request/go/fetch', () => ({
+ fetchGo: mockFetchGo,
+}))
+
+vi.mock('@/lib/copilot/server/agent-url', () => ({
+ getMothershipBaseURL: vi.fn().mockResolvedValue('http://mothership.test'),
+ getMothershipSourceEnvHeaders: vi.fn().mockReturnValue({}),
+}))
+
+vi.mock('@/lib/core/config/env', () => ({ env: {} }))
+
+vi.mock('@/lib/posthog/server', () => ({
+ captureServerEvent: mockCaptureServerEvent,
+}))
+
+vi.mock('@/lib/workspaces/permissions/utils', () => ({
+ assertActiveWorkspaceAccess: mockAssertActiveWorkspaceAccess,
+ isWorkspaceAccessDeniedError: () => false,
+}))
+
+import { POST } from '@/app/api/mothership/chats/[chatId]/fork/route'
+
+const OLD_FILE_ID = 'wf_oldfile'
+const NEW_FILE_ID = 'wf_newfile'
+
+const parentRow = {
+ id: 'chat-1',
+ userId: 'user-1',
+ type: 'mothership',
+ workspaceId: 'ws-1',
+ title: 'Generate Logs',
+ model: 'claude-opus-4-8',
+ resources: [{ type: 'file', id: OLD_FILE_ID, title: 'cat.png' }],
+ previewYaml: null,
+ planArtifact: null,
+ config: null,
+}
+
+const threeMessages = [
+ {
+ id: 'msg-1',
+ role: 'user',
+ content: `See `,
+ timestamp: '2026-07-01T00:00:00.000Z',
+ },
+ {
+ id: 'msg-2',
+ role: 'assistant',
+ content: 'Nice cat.',
+ timestamp: '2026-07-01T00:00:01.000Z',
+ },
+ {
+ id: 'msg-3',
+ role: 'user',
+ content: 'A later message the fork must not keep.',
+ timestamp: '2026-07-01T00:00:02.000Z',
+ },
+]
+
+/** Chat rows inserted through the mock transaction, captured for title assertions. */
+let insertedChatRows: Array> = []
+/** tx.update(...).set(...) payloads, captured for resource-rewrite assertions. */
+let updatedChatRows: Array> = []
+
+function makeTx() {
+ return {
+ insert: () => ({
+ values: (v: Record) => {
+ insertedChatRows.push(v)
+ return {
+ returning: async () => [{ id: 'row-id', workspaceId: 'ws-1' }],
+ }
+ },
+ }),
+ update: () => ({
+ set: (v: Record) => {
+ updatedChatRows.push(v)
+ return { where: async () => undefined }
+ },
+ }),
+ }
+}
+
+function createRequest(chatId: string, body?: unknown) {
+ return new NextRequest(`http://localhost:3000/api/mothership/chats/${chatId}/fork`, {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(body ?? { upToMessageId: 'msg-2' }),
+ })
+}
+
+function makeContext(chatId: string) {
+ return { params: Promise.resolve({ chatId }) }
+}
+
+describe('POST /api/mothership/chats/[chatId]/fork', () => {
+ beforeEach(() => {
+ vi.clearAllMocks()
+ insertedChatRows = []
+ updatedChatRows = []
+ copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({
+ userId: 'user-1',
+ isAuthenticated: true,
+ })
+ mockSelectRows.mockResolvedValue([parentRow])
+ mockListForkableChatFiles.mockResolvedValue([])
+ mockCheckStorageQuota.mockResolvedValue({ allowed: true })
+ mockLoadCopilotChatMessages.mockResolvedValue(threeMessages)
+ mockPlanChatFileCopies.mockResolvedValue({
+ idMap: new Map(),
+ keyMap: new Map(),
+ blobTasks: [],
+ })
+ mockExecuteChatFileBlobCopies.mockResolvedValue({ copied: 0, failed: 0, failedCopyIds: [] })
+ mockAppendCopilotChatMessages.mockResolvedValue(undefined)
+ mockDeleteWhere.mockResolvedValue(undefined)
+ mockRemoveChatResources.mockResolvedValue(undefined)
+ mockAssertActiveWorkspaceAccess.mockResolvedValue(undefined)
+ mockFetchGo.mockResolvedValue({ ok: true })
+ mockTransaction.mockImplementation(async (cb: (tx: unknown) => Promise) =>
+ cb(makeTx())
+ )
+ })
+
+ it('rejects unauthenticated callers', async () => {
+ copilotHttpMockFns.mockAuthenticateCopilotRequestSessionOnly.mockResolvedValue({
+ userId: null,
+ isAuthenticated: false,
+ })
+ const res = await POST(createRequest('chat-1'), makeContext('chat-1'))
+ expect(res.status).toBe(401)
+ })
+
+ it('404s when the chat belongs to another user', async () => {
+ mockSelectRows.mockResolvedValue([{ ...parentRow, userId: 'someone-else' }])
+ const res = await POST(createRequest('chat-1'), makeContext('chat-1'))
+ expect(res.status).toBe(404)
+ expect(mockTransaction).not.toHaveBeenCalled()
+ })
+
+ it('404s for non-mothership chats', async () => {
+ mockSelectRows.mockResolvedValue([{ ...parentRow, type: 'copilot' }])
+ const res = await POST(createRequest('chat-1'), makeContext('chat-1'))
+ expect(res.status).toBe(404)
+ })
+
+ it('400s when upToMessageId is missing', async () => {
+ const res = await POST(createRequest('chat-1', {}), makeContext('chat-1'))
+ expect(res.status).toBe(400)
+ expect(mockTransaction).not.toHaveBeenCalled()
+ })
+
+ it('400s when upToMessageId is an empty string', async () => {
+ const res = await POST(createRequest('chat-1', { upToMessageId: '' }), makeContext('chat-1'))
+ expect(res.status).toBe(400)
+ expect(mockTransaction).not.toHaveBeenCalled()
+ })
+
+ it('400s when the message is not in the chat', async () => {
+ const res = await POST(
+ createRequest('chat-1', { upToMessageId: 'msg-unknown' }),
+ makeContext('chat-1')
+ )
+ expect(res.status).toBe(400)
+ expect(mockTransaction).not.toHaveBeenCalled()
+ })
+
+ it('applies the timeline cut: kept message ids drive the file selection', async () => {
+ // Two uploads born pre-cut, one born post-cut, and one legacy row with no
+ // birth message. The single chat-owned read is cut in memory: everything
+ // but the post-cut row.
+ const preCutUpload = { id: 'wf_up', size: 1, context: 'mothership', messageId: 'msg-1' }
+ const secondPreCut = { id: 'wf_up2', size: 1, context: 'mothership', messageId: 'msg-2' }
+ const postCutUpload = { id: 'wf_late', size: 1, context: 'mothership', messageId: 'msg-3' }
+ const legacyRow = { id: 'wf_legacy', size: 1, context: 'mothership', messageId: null }
+ mockListForkableChatFiles.mockResolvedValue([
+ preCutUpload,
+ secondPreCut,
+ postCutUpload,
+ legacyRow,
+ ])
+
+ const res = await POST(createRequest('chat-1'), makeContext('chat-1'))
+ expect(res.status).toBe(200)
+
+ // The cut runs over the single read with the kept slice (inclusive of
+ // msg-2, excluding msg-3).
+ expect(mockListForkableChatFiles).toHaveBeenCalledTimes(1)
+ const filterCall = mockFilterForkableChatFiles.mock.calls[0]
+ expect(filterCall[1]).toEqual(new Set(['msg-1', 'msg-2']))
+
+ // The copy plan receives the cut set — the pre-cut uploads plus the
+ // legacy no-birthdate row; the post-cut upload stays behind.
+ expect(mockPlanChatFileCopies.mock.calls[0][0].rows).toEqual([
+ preCutUpload,
+ secondPreCut,
+ legacyRow,
+ ])
+
+ // The appended transcript is the same inclusive slice.
+ const appended = mockAppendCopilotChatMessages.mock.calls[0]
+ expect(appended[1].map((m: { id: string }) => m.id)).toEqual(['msg-1', 'msg-2'])
+ })
+
+ it('fails up front with the quota error when copied bytes would exceed the limit', async () => {
+ mockListForkableChatFiles.mockResolvedValue([
+ { size: 600, workspaceId: 'ws-1' },
+ { size: 400, workspaceId: 'ws-1' },
+ ])
+ mockCheckStorageQuota.mockResolvedValue({ allowed: false, error: 'Storage limit exceeded' })
+
+ const res = await POST(createRequest('chat-1'), makeContext('chat-1'))
+
+ expect(res.status).toBe(400)
+ expect(mockCheckStorageQuota).toHaveBeenCalledWith('user-1', 1000)
+ expect(mockTransaction).not.toHaveBeenCalled()
+ expect(mockExecuteChatFileBlobCopies).not.toHaveBeenCalled()
+ })
+
+ it('excludes uncopyable rows (no workspaceId) from the quota sum', async () => {
+ // planChatFileCopies skips workspaceId-less legacy rows, so their bytes
+ // must not count against the gate.
+ mockListForkableChatFiles.mockResolvedValue([{ size: 600, workspaceId: 'ws-1' }, { size: 400 }])
+
+ const res = await POST(createRequest('chat-1'), makeContext('chat-1'))
+
+ expect(res.status).toBe(200)
+ expect(mockCheckStorageQuota).toHaveBeenCalledWith('user-1', 600)
+ })
+
+ it('skips the quota check entirely when no chat-owned rows are in the cut', async () => {
+ // The chat owns one file, but it was born after the fork point.
+ mockListForkableChatFiles.mockResolvedValue([
+ { id: 'wf_late', size: 500, context: 'mothership', messageId: 'msg-3' },
+ ])
+ const res = await POST(createRequest('chat-1'), makeContext('chat-1'))
+ expect(res.status).toBe(200)
+ expect(mockCheckStorageQuota).not.toHaveBeenCalled()
+ })
+
+ it('forks the chat: copies kept uploads, rewrites references, clones agent state', async () => {
+ const blobTasks = [
+ {
+ sourceKey: 'workspace/ws-1/old-cat.png',
+ targetKey: 'workspace/ws-1/new-cat.png',
+ context: 'mothership',
+ fileName: 'cat.png',
+ contentType: 'image/png',
+ },
+ ]
+ mockListForkableChatFiles.mockResolvedValue([
+ { size: 100, messageId: 'msg-1', workspaceId: 'ws-1' },
+ ])
+ mockPlanChatFileCopies.mockResolvedValue({
+ idMap: new Map([[OLD_FILE_ID, NEW_FILE_ID]]),
+ keyMap: new Map([['workspace/ws-1/old-cat.png', 'workspace/ws-1/new-cat.png']]),
+ blobTasks,
+ })
+
+ const res = await POST(createRequest('chat-1'), makeContext('chat-1'))
+ const body = await res.json()
+
+ expect(res.status).toBe(200)
+ expect(body.success).toBe(true)
+ expect(typeof body.id).toBe('string')
+
+ expect(mockCheckStorageQuota).toHaveBeenCalledWith('user-1', 100)
+
+ // The real rewriter runs: the kept message's view-URL points at the copy.
+ const appended = mockAppendCopilotChatMessages.mock.calls[0]
+ expect(appended[0]).toBe(body.id)
+ expect(appended[1][0].content).toBe(`See `)
+
+ expect(mockExecuteChatFileBlobCopies).toHaveBeenCalledWith(blobTasks, {
+ userId: 'user-1',
+ workspaceId: 'ws-1',
+ })
+
+ const goCall = mockFetchGo.mock.calls[0]
+ expect(goCall[0]).toBe('http://mothership.test/api/chats/fork')
+ const goBody = JSON.parse(goCall[1].body)
+ // The copilot service only knows USER message ids, so the clone cut is the
+ // kept slice's last user message (msg-1), not the clicked assistant (msg-2).
+ expect(goBody).toEqual({
+ sourceChatId: 'chat-1',
+ newChatId: body.id,
+ upToMessageId: 'msg-1',
+ userId: 'user-1',
+ })
+
+ expect(mockPublishStatusChanged).toHaveBeenCalledWith({
+ workspaceId: 'ws-1',
+ chatId: body.id,
+ type: 'created',
+ })
+ expect(mockCaptureServerEvent).toHaveBeenCalledWith(
+ 'user-1',
+ 'task_forked',
+ { workspace_id: 'ws-1', source_chat_id: 'chat-1' },
+ { groups: { workspace: 'ws-1' } }
+ )
+
+ // Forks are titled "Fork | ".
+ expect(insertedChatRows[0].title).toBe('Fork | Generate Logs')
+ })
+
+ it('still succeeds when the copilot-service clone fails (best-effort)', async () => {
+ mockFetchGo.mockRejectedValue(new Error('mothership unreachable'))
+ const res = await POST(createRequest('chat-1'), makeContext('chat-1'))
+ expect(res.status).toBe(200)
+ })
+
+ it('surfaces failed blob copies and cleans up their dead rows + resource chips', async () => {
+ mockExecuteChatFileBlobCopies.mockResolvedValue({
+ copied: 1,
+ failed: 2,
+ failedCopyIds: ['wf_dead1', 'wf_dead2'],
+ })
+
+ const failedRes = await POST(createRequest('chat-1'), makeContext('chat-1'))
+ const body = await failedRes.json()
+
+ expect(body.failedFileCopies).toBe(2)
+
+ // The dead rows (committed, but no bytes behind them) are hard-deleted so
+ // they vanish from VFS listings and name resolution…
+ expect(mockDeleteWhere).toHaveBeenCalledWith({
+ type: 'inArray',
+ field: 'workspaceFiles.id',
+ values: ['wf_dead1', 'wf_dead2'],
+ })
+ // …and their resource chips are dropped from the new chat.
+ expect(mockRemoveChatResources).toHaveBeenCalledWith(body.id, [
+ { type: 'file', id: 'wf_dead1', title: '' },
+ { type: 'file', id: 'wf_dead2', title: '' },
+ ])
+ })
+
+ it('omits failedFileCopies and skips cleanup when every blob copies', async () => {
+ mockExecuteChatFileBlobCopies.mockResolvedValue({ copied: 3, failed: 0, failedCopyIds: [] })
+
+ const cleanRes = await POST(createRequest('chat-1'), makeContext('chat-1'))
+
+ expect('failedFileCopies' in (await cleanRes.json())).toBe(false)
+ expect(mockDeleteWhere).not.toHaveBeenCalled()
+ expect(mockRemoveChatResources).not.toHaveBeenCalled()
+ })
+
+ it('copies pre-cut uploads and drops only post-cut ghosts', async () => {
+ // The source chat owns two more uploads (apple pre-cut, banana post-cut)
+ // beside the kept one, plus one shared workspace-file resource. The fork
+ // copies the kept upload AND the pre-cut apple; only the post-cut banana
+ // stays behind, so only its resource is dropped — not left pointing at
+ // the source chat.
+ mockSelectRows.mockResolvedValue([
+ {
+ ...parentRow,
+ resources: [
+ { type: 'file', id: OLD_FILE_ID, title: 'cat.png' },
+ { type: 'file', id: 'wf_apple', title: 'apple.png' },
+ { type: 'file', id: 'wf_banana', title: 'banana.png' },
+ { type: 'file', id: 'wf_shared', title: 'shared.pdf' },
+ { type: 'workflow', id: 'wflow-1', title: 'My flow' },
+ ],
+ },
+ ])
+ // Every chat-owned file of the source chat in the single read; messageId
+ // drives the in-memory cut.
+ mockListForkableChatFiles.mockResolvedValue([
+ { id: OLD_FILE_ID, size: 100, context: 'mothership', messageId: 'msg-1' },
+ { id: 'wf_apple', size: 50, context: 'mothership', messageId: 'msg-1' },
+ { id: 'wf_banana', size: 50, context: 'mothership', messageId: 'msg-3' },
+ ])
+ mockPlanChatFileCopies.mockResolvedValue({
+ idMap: new Map([
+ [OLD_FILE_ID, NEW_FILE_ID],
+ ['wf_apple', 'wf_apple_copy'],
+ ]),
+ keyMap: new Map(),
+ blobTasks: [],
+ })
+
+ const res = await POST(createRequest('chat-1'), makeContext('chat-1'))
+
+ expect(res.status).toBe(200)
+ // The plan received the cut set: the kept upload + pre-cut apple only.
+ expect(mockPlanChatFileCopies.mock.calls[0][0].rows.map((r: { id: string }) => r.id)).toEqual([
+ OLD_FILE_ID,
+ 'wf_apple',
+ ])
+ expect(updatedChatRows).toHaveLength(1)
+ expect(updatedChatRows[0].resources).toEqual([
+ { type: 'file', id: NEW_FILE_ID, title: 'cat.png' },
+ { type: 'file', id: 'wf_apple_copy', title: 'apple.png' },
+ { type: 'file', id: 'wf_shared', title: 'shared.pdf' },
+ { type: 'workflow', id: 'wflow-1', title: 'My flow' },
+ ])
+ })
+
+ it('drops ghosts even when the fork copies no files at all', async () => {
+ // Fork cut before the chat's only upload arrived: a guard that skips the
+ // resources update when idMap is empty would leave the ghost in place.
+ mockSelectRows.mockResolvedValue([
+ {
+ ...parentRow,
+ resources: [{ type: 'file', id: 'wf_banana', title: 'banana.png' }],
+ },
+ ])
+ mockListForkableChatFiles.mockResolvedValue([
+ { id: 'wf_banana', size: 50, context: 'mothership', messageId: 'msg-3' },
+ ])
+
+ const res = await POST(createRequest('chat-1'), makeContext('chat-1'))
+
+ expect(res.status).toBe(200)
+ expect(updatedChatRows).toHaveLength(1)
+ expect(updatedChatRows[0].resources).toEqual([])
+ })
+})
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 44b63c7f163..e2e361f2222 100644
--- a/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts
+++ b/apps/sim/app/api/mothership/chats/[chatId]/fork/route.ts
@@ -1,13 +1,24 @@
import { db } from '@sim/db'
-import { copilotChats } from '@sim/db/schema'
+import { copilotChats, workspaceFiles } from '@sim/db/schema'
import { createLogger } from '@sim/logger'
import { generateId } from '@sim/utils/id'
-import { eq } from 'drizzle-orm'
+import { eq, inArray } from 'drizzle-orm'
import { type NextRequest, NextResponse } from 'next/server'
import { forkMothershipChatContract } from '@/lib/api/contracts/mothership-chats'
import { parseRequest } from '@/lib/api/server'
+import { checkStorageQuota } from '@/lib/billing/storage'
+import {
+ executeChatFileBlobCopies,
+ filterForkableChatFiles,
+ listForkableChatFiles,
+ planChatFileCopies,
+} from '@/lib/copilot/chat/fork-chat-files'
import { loadCopilotChatMessages } from '@/lib/copilot/chat/lifecycle'
import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store'
+import {
+ rewriteMessageFileRefs,
+ rewriteResourceFileRefs,
+} from '@/lib/copilot/chat/rewrite-file-references'
import { chatPubSub } from '@/lib/copilot/chat-status'
import { fetchGo } from '@/lib/copilot/request/go/fetch'
import {
@@ -18,6 +29,7 @@ import {
createNotFoundResponse,
createUnauthorizedResponse,
} from '@/lib/copilot/request/http'
+import { removeChatResources } from '@/lib/copilot/resources/persistence'
import type { MothershipResource } from '@/lib/copilot/resources/types'
import { getMothershipBaseURL, getMothershipSourceEnvHeaders } from '@/lib/copilot/server/agent-url'
import { env } from '@/lib/core/config/env'
@@ -33,7 +45,16 @@ 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.
+ * including the specified message, along with the chat's uploads born
+ * at-or-before the fork point (a file travels iff the user message that
+ * carried it is kept). Resources and copilot-side state are copied.
+ *
+ * Every copied file gets a fresh row id and storage key, bytes are physically
+ * copied and counted against the storage quota, and every in-transcript file
+ * reference is re-pointed at the copies so the new chat survives deletion of
+ * the source chat. File resources whose chat-owned file was NOT copied
+ * (uploads born after the cut) are dropped from the new chat's resources
+ * rather than left as ghosts pointing at the source chat's files.
*/
export const POST = withRouteHandler(
async (request: NextRequest, context: { params: Promise<{ chatId: string }> }) => {
@@ -82,17 +103,49 @@ export const POST = withRouteHandler(
}
const forkedMessages = messages.slice(0, forkIdx + 1)
- // Resources are stored as a jsonb array on the chat row — copy them directly.
+ // Single workspace_files read per fork: every chat-owned upload. The
+ // copied set is timeline-cut to the kept message slice in memory (files
+ // born after the fork point stay behind).
+ const chatOwnedFiles = await listForkableChatFiles(db, chatId)
+ const sourceFiles = filterForkableChatFiles(
+ chatOwnedFiles,
+ new Set(forkedMessages.map((m) => m.id))
+ )
+ // Sum only rows the plan will actually copy — planChatFileCopies skips
+ // rows with no workspaceId, so counting their bytes could reject a fork
+ // whose real copies fit within quota.
+ const totalFileBytes = sourceFiles.reduce(
+ (sum, row) => (row.workspaceId ? sum + row.size : sum),
+ 0
+ )
+ if (totalFileBytes > 0) {
+ const quotaCheck = await checkStorageQuota(userId, totalFileBytes)
+ if (!quotaCheck.allowed) {
+ return createBadRequestResponse(quotaCheck.error || 'Storage limit exceeded')
+ }
+ }
+
+ // Resources are stored as a jsonb array on the chat row. They carry no
+ // timestamps, so they can't be timeline-cut like messages — instead,
+ // file resources whose chat-owned file is NOT copied (uploads born
+ // after the cut) are dropped in the rewrite below; everything else is
+ // copied.
const parentResources = Array.isArray(parent.resources)
? (parent.resources as MothershipResource[])
: []
+ // The source chat's chat-owned file ids (no cut) — the "is this
+ // resource a ghost?" test set for the rewrite.
+ const chatOwnedFileIds = new Set(chatOwnedFiles.map((row) => row.id))
+
const newId = generateId()
+ // Strip a leading "Fork | " so titles don't stack prefixes when forking
+ // a forked chat.
const baseTitle = (parent.title ?? 'New chat').replace(/^Fork \| /, '')
const title = `Fork | ${baseTitle}`
const now = new Date()
- const newChat = await db.transaction(async (tx) => {
+ const result = await db.transaction(async (tx) => {
const [row] = await tx
.insert(copilotChats)
.values({
@@ -114,16 +167,85 @@ export const POST = withRouteHandler(
if (!row) return null
- await appendCopilotChatMessages(newId, forkedMessages, { chatModel: parent.model }, tx)
- return row
+ // File rows FK the new chat row, so the plan runs after the insert.
+ const { idMap, keyMap, blobTasks } = await planChatFileCopies({
+ tx,
+ rows: sourceFiles,
+ newChatId: newId,
+ userId,
+ now,
+ })
+
+ const maps = { fileIds: idMap, fileKeys: keyMap }
+ const newChatResources = rewriteResourceFileRefs(parentResources, maps, chatOwnedFileIds)
+ // Skip the redundant update only when the rewrite changed nothing:
+ // no ids re-pointed AND no ghost resources dropped. (idMap and keyMap
+ // are populated in lockstep, so idMap alone decides the first half.)
+ if (idMap.size > 0 || newChatResources.length !== parentResources.length) {
+ await tx
+ .update(copilotChats)
+ .set({ resources: newChatResources })
+ .where(eq(copilotChats.id, newId))
+ }
+
+ await appendCopilotChatMessages(
+ newId,
+ rewriteMessageFileRefs(forkedMessages, maps),
+ { chatModel: parent.model },
+ tx
+ )
+ return { row, blobTasks }
})
- if (!newChat) {
+ if (!result) {
return createInternalServerErrorResponse('Failed to create forked chat')
}
+ const newChat = result.row
+
+ const { copied, failed, failedCopyIds } = await executeChatFileBlobCopies(result.blobTasks, {
+ userId,
+ workspaceId: parent.workspaceId ?? undefined,
+ })
+ if (failed > 0) {
+ // A failed blob copy leaves a committed row with no bytes behind it.
+ // Cleanly absent beats present-but-broken: hard-delete the dead rows
+ // (they vanish from the VFS listings and name resolution) and drop
+ // their resource chips from the new chat. Inline transcript embeds
+ // can't be healed — those 404 — which is what `failedFileCopies` in
+ // the response warns the user about.
+ try {
+ await db.delete(workspaceFiles).where(inArray(workspaceFiles.id, failedCopyIds))
+ await removeChatResources(
+ newId,
+ failedCopyIds.map((id) => ({ type: 'file' as const, id, title: '' }))
+ )
+ } catch (cleanupError) {
+ logger.error('Failed to clean up dead file rows after blob-copy failure', {
+ newChatId: newId,
+ failedCopyIds,
+ error: cleanupError,
+ })
+ }
+ logger.warn('Some chat file blobs failed to copy during fork', {
+ chatId,
+ newChatId: newId,
+ copied,
+ failed,
+ })
+ }
// 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.
+ // The service stamps MessageID only on USER messages (assistant rows carry
+ // Sim-local ids it has never seen), so hand it the kept slice's last user
+ // message — it clones through the end of that turn, matching this route's cut.
+ let goCutMessageId = upToMessageId
+ for (let i = forkedMessages.length - 1; i >= 0; i--) {
+ if (forkedMessages[i].role === 'user') {
+ goCutMessageId = forkedMessages[i].id
+ break
+ }
+ }
try {
const copilotHeaders: Record = { 'Content-Type': 'application/json' }
if (env.COPILOT_API_KEY) {
@@ -137,7 +259,7 @@ export const POST = withRouteHandler(
body: JSON.stringify({
sourceChatId: chatId,
newChatId: newId,
- upToMessageId,
+ upToMessageId: goCutMessageId,
userId,
}),
spanName: 'sim → go /api/chats/fork',
@@ -168,7 +290,11 @@ export const POST = withRouteHandler(
{ groups: { workspace: parent.workspaceId ?? '' } }
)
- return NextResponse.json({ success: true, id: newId })
+ return NextResponse.json({
+ success: true,
+ id: newId,
+ ...(failed > 0 ? { failedFileCopies: failed } : {}),
+ })
} catch (error) {
if (isWorkspaceAccessDeniedError(error)) {
return createForbiddenResponse('Workspace access denied')
diff --git a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx
index fa55168db32..bb9f2618984 100644
--- a/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/components/connect-oauth-modal/connect-oauth-modal.tsx
@@ -18,6 +18,7 @@ import { getErrorMessage } from '@sim/utils/errors'
import { useSession } from '@/lib/auth/auth-client'
import type { OAuthReturnContext } from '@/lib/credentials/client-state'
import { ADD_CONNECTOR_SEARCH_PARAM, writeOAuthReturnContext } from '@/lib/credentials/client-state'
+import { defaultCredentialDisplayName } from '@/lib/credentials/display-name'
import {
getProviderIdFromServiceId,
OAUTH_PROVIDERS,
@@ -30,18 +31,6 @@ import { useConnectOAuthService } from '@/hooks/queries/oauth/oauth-connections'
const logger = createLogger('ConnectOAuthModal')
-/** Server-enforced max for `WorkspaceCredential.displayName` — see `lib/api/contracts/credentials.ts`. */
-const DISPLAY_NAME_MAX_LENGTH = 255
-
-/**
- * Reserved tail budget when truncating the username so the auto-numbering
- * disambiguator (e.g. `" 9999"`) always fits within {@link DISPLAY_NAME_MAX_LENGTH}.
- */
-const COLLISION_SUFFIX_RESERVATION = 5
-
-/** Upper bound for the auto-numbering search — pathological if ever reached. */
-const MAX_COLLISION_INDEX = 10000
-
const EMPTY_SCOPES: readonly string[] = []
type ServiceIcon = ComponentType<{ className?: string }>
@@ -51,44 +40,6 @@ function isHiddenScope(scope: string): boolean {
return scope.includes('userinfo.email') || scope.includes('userinfo.profile')
}
-/**
- * Default credential display name. Produces `"{Name}'s {Service}"` when the
- * user's name is known, falling back to `"My {Service}"` otherwise. The
- * username is truncated so the full string (including any auto-numbering
- * disambiguator) stays within {@link DISPLAY_NAME_MAX_LENGTH}.
- *
- * When the base name collides with an existing credential in `takenNames`,
- * `" 2"`, `" 3"`, ... are appended until an unused name is found. Comparison
- * is case-insensitive to match the duplicate-detection used elsewhere in the
- * modal.
- */
-function defaultDisplayName(
- userName: string | null | undefined,
- serviceName: string,
- takenNames: ReadonlySet
-): string {
- const trimmed = userName?.trim()
- let base: string
- if (trimmed) {
- const suffix = `'s ${serviceName}`
- const nameBudget = Math.max(
- 0,
- DISPLAY_NAME_MAX_LENGTH - suffix.length - COLLISION_SUFFIX_RESERVATION
- )
- const safeName = trimmed.length > nameBudget ? trimmed.slice(0, nameBudget) : trimmed
- base = `${safeName}${suffix}`
- } else {
- base = `My ${serviceName}`
- }
-
- if (!takenNames.has(base.toLowerCase())) return base
- for (let n = 2; n < MAX_COLLISION_INDEX; n++) {
- const candidate = `${base} ${n}`
- if (!takenNames.has(candidate.toLowerCase())) return candidate
- }
- return base
-}
-
/**
* Resolves the display name + icon for an OAuth `provider`/`serviceId` pair,
* preferring the most specific service entry and falling back to the base
@@ -255,7 +206,7 @@ export function ConnectOAuthModal(props: ConnectOAuthModalProps) {
}
if (!isConnect || prefilled.current || credentialsLoading) return
prefilled.current = true
- setDisplayName(defaultDisplayName(userName, providerName, takenNames))
+ setDisplayName(defaultCredentialDisplayName(userName, providerName, takenNames))
setDescription('')
setValidationError(null)
setSubmitError(null)
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 91ebc37ea42..b2b4e22d1c6 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
@@ -10,19 +10,20 @@ import {
ChipModalHeader,
cn,
Duplicate,
+ Split,
ThumbsDown,
ThumbsUp,
Tooltip,
toast,
} from '@sim/emcn'
-import { GitBranch } from 'lucide-react'
import { useParams, useRouter } from 'next/navigation'
+import { isLiveAssistantMessageId } from '@/lib/copilot/chat/effective-transcript'
import { useChatSurface } from '@/app/workspace/[workspaceId]/home/components/chat-surface-context'
import { useSubmitCopilotFeedback } from '@/hooks/queries/copilot-feedback'
import { useForkMothershipChat } from '@/hooks/queries/mothership-chats'
import { useFolderStore } from '@/stores/folders/store'
-const SPECIAL_TAGS = 'thinking|options|usage_upgrade|credential|mothership-error|file'
+const SPECIAL_TAGS = 'thinking|options|usage_upgrade|credential|mothership-error|file|question'
function toPlainText(raw: string): string {
return (
@@ -153,6 +154,11 @@ export const MessageActions = memo(function MessageActions({
if (!chatId || !messageId || forkChat.isPending) return
try {
const result = await forkChat.mutateAsync({ chatId, upToMessageId: messageId })
+ if (result.failedFileCopies) {
+ toast.warning(
+ `${result.failedFileCopies} file${result.failedFileCopies === 1 ? '' : 's'} could not be copied to the fork`
+ )
+ }
useFolderStore.getState().clearChatSelection()
router.push(`/workspace/${params.workspaceId}/chat/${result.id}`)
} catch {
@@ -162,7 +168,10 @@ export const MessageActions = memo(function MessageActions({
const hasContent = Boolean(content)
const canSubmitFeedback = Boolean(chatId && userQuery)
- const canFork = false
+ // A live (just-streamed) assistant message carries a synthetic id that the
+ // persisted transcript doesn't know — forking it would 400. The button
+ // appears once the transcript refetch swaps in the persisted message id.
+ const canFork = Boolean(chatId && messageId && !isLiveAssistantMessageId(messageId))
if (!hasContent && !canSubmitFeedback && !canFork) return null
return (
@@ -220,15 +229,15 @@ export const MessageActions = memo(function MessageActions({
- Fork from here
+ Branch in new chat
)}
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
index 1283dc8617e..bb898bd9920 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/chat-content/chat-content.tsx
@@ -304,6 +304,8 @@ const MARKDOWN_COMPONENTS = {
interface ChatContentProps {
content: string
isStreaming?: boolean
+ /** Transcript-derived answers for this message's question card (renders the recap). */
+ questionAnswers?: string[]
onOptionSelect?: (id: string) => void
onWorkspaceResourceSelect?: (resource: MothershipResource) => void
onRevealStateChange?: (isRevealing: boolean) => void
@@ -312,6 +314,7 @@ interface ChatContentProps {
function ChatContentInner({
content,
isStreaming = false,
+ questionAnswers,
onOptionSelect,
onWorkspaceResourceSelect,
onRevealStateChange,
@@ -513,6 +516,7 @@ function ChatContentInner({
)
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts
index f211a5f42e5..8151e32f11c 100644
--- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/index.ts
@@ -2,4 +2,5 @@ export type { AgentGroupItem, NestedAgentGroup } from './agent-group'
export { AgentGroup, CircleStop, isAgentGroupResolved } from './agent-group'
export { ChatContent } from './chat-content'
export { Options } from './options'
+export { QuestionDisplay } from './question'
export { PendingTagIndicator, parseSpecialTags, SpecialTags } from './special-tags'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/index.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/index.ts
new file mode 100644
index 00000000000..5272577cfda
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/index.ts
@@ -0,0 +1,5 @@
+export {
+ formatQuestionAnswerMessage,
+ parseQuestionAnswerMessage,
+ QuestionDisplay,
+} from './question'
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts
new file mode 100644
index 00000000000..f3d7b9528ea
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.test.ts
@@ -0,0 +1,86 @@
+/**
+ * @vitest-environment node
+ */
+import { describe, expect, it } from 'vitest'
+import {
+ formatQuestionAnswerMessage,
+ parseQuestionAnswerMessage,
+} from '@/app/workspace/[workspaceId]/home/components/message-content/components/question/question'
+import type { QuestionItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags/special-tags'
+
+const QUESTIONS: QuestionItem[] = [
+ {
+ type: 'single_select',
+ prompt: 'How should I handle the duplicates?',
+ options: [{ id: 'keep_newest', label: 'Keep the newest entry' }],
+ },
+ {
+ type: 'single_select',
+ prompt: 'Delete 4 archived workflows?',
+ options: [
+ { id: 'yes', label: 'Delete them' },
+ { id: 'no', label: 'Cancel' },
+ ],
+ },
+ {
+ type: 'multi_select',
+ prompt: 'What time zone should the daily report run in?',
+ options: [
+ { id: 'est', label: 'EST' },
+ { id: 'pst', label: 'PST' },
+ ],
+ },
+]
+
+describe('formatQuestionAnswerMessage', () => {
+ it('sends a prompt-answer line for a single question', () => {
+ expect(formatQuestionAnswerMessage([QUESTIONS[0]], ['Keep the newest entry'])).toBe(
+ 'How should I handle the duplicates? — Keep the newest entry'
+ )
+ })
+
+ it('sends one prompt-answer line per question for multi-step batches', () => {
+ expect(formatQuestionAnswerMessage(QUESTIONS, ['Keep the newest entry', 'Cancel', 'EST'])).toBe(
+ 'How should I handle the duplicates? — Keep the newest entry\n' +
+ 'Delete 4 archived workflows? — Cancel\n' +
+ 'What time zone should the daily report run in? — EST'
+ )
+ })
+})
+
+describe('parseQuestionAnswerMessage', () => {
+ it('round-trips what formatQuestionAnswerMessage produces', () => {
+ const answers = ['Keep the newest entry', 'Cancel', 'EST, PST']
+ const message = formatQuestionAnswerMessage(QUESTIONS, answers)
+ expect(parseQuestionAnswerMessage(QUESTIONS, message)).toEqual(answers)
+ })
+
+ it('round-trips a single question', () => {
+ const message = formatQuestionAnswerMessage([QUESTIONS[0]], ['Merge them'])
+ expect(parseQuestionAnswerMessage([QUESTIONS[0]], message)).toEqual(['Merge them'])
+ })
+
+ it('rejects an unrelated user message (dismissed card, typed something else)', () => {
+ expect(parseQuestionAnswerMessage([QUESTIONS[0]], 'actually, show me the logs')).toBeNull()
+ })
+
+ it('rejects when the line count does not match the question count', () => {
+ const partial = formatQuestionAnswerMessage(QUESTIONS.slice(0, 2), ['A', 'B'])
+ expect(parseQuestionAnswerMessage(QUESTIONS, partial)).toBeNull()
+ })
+
+ it('rejects when a line pairs with the wrong prompt', () => {
+ const swapped =
+ 'Delete 4 archived workflows? — Cancel\n' +
+ 'How should I handle the duplicates? — Keep the newest entry\n' +
+ 'What time zone should the daily report run in? — EST'
+ expect(parseQuestionAnswerMessage(QUESTIONS, swapped)).toBeNull()
+ })
+
+ it('preserves em-dashes inside the answer text', () => {
+ const message = formatQuestionAnswerMessage([QUESTIONS[0]], ['newest — but keep backups'])
+ expect(parseQuestionAnswerMessage([QUESTIONS[0]], message)).toEqual([
+ 'newest — but keep backups',
+ ])
+ })
+})
diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx
new file mode 100644
index 00000000000..2381993aa05
--- /dev/null
+++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/question/question.tsx
@@ -0,0 +1,477 @@
+'use client'
+
+import { useState } from 'react'
+import {
+ ArrowRight,
+ Button,
+ Check,
+ ChevronLeft,
+ ChevronRight,
+ checkboxIconVariants,
+ checkboxVariants,
+ cn,
+ X,
+} from '@sim/emcn'
+import type { QuestionItem } from '@/app/workspace/[workspaceId]/home/components/message-content/components/special-tags'
+
+/**
+ * Builds the single user message sent after the final question is answered:
+ * one `Prompt — Answer` line per question, for lone questions too. The uniform
+ * shape is what lets the chat pair this message back to its question card
+ * (see parseQuestionAnswerMessage) and render the card as the user turn
+ * instead of echoing a duplicate bubble.
+ */
+export function formatQuestionAnswerMessage(questions: QuestionItem[], answers: string[]): string {
+ return questions.map((q, i) => `${q.prompt} — ${answers[i] ?? ''}`).join('\n')
+}
+
+/**
+ * Strictly matches a user message against a question batch's answer format:
+ * exactly one `Prompt — Answer` line per question, in order. Returns the
+ * answers, or null when the message is not this batch's answer — a dismissed
+ * card followed by an unrelated typed message must not match.
+ */
+export function parseQuestionAnswerMessage(
+ questions: QuestionItem[],
+ content: string
+): string[] | null {
+ const lines = content.split('\n')
+ if (lines.length !== questions.length) return null
+ const answers: string[] = []
+ for (const [i, question] of questions.entries()) {
+ const prefix = `${question.prompt} — `
+ if (!lines[i].startsWith(prefix)) return null
+ answers.push(lines[i].slice(prefix.length))
+ }
+ return answers
+}
+
+const OPTION_ROW_CLASSES =
+ 'flex items-center gap-2 border-[var(--divider)] px-2 py-2 text-left transition-colors'
+
+/** Ghost icon-button chrome shared by the stepper chevrons and the dismiss X. */
+const ICON_BUTTON_CLASSES = 'relative size-[14px] flex-shrink-0 p-0'
+
+/** Leading number slot matching the suggested follow-ups rows. */
+function RowNumber({ value }: { value: number }) {
+ return (
+
+ {value}
+
+ )
+}
+
+/**
+ * Leading checkbox slot for multi_select rows. Purely presentational — it
+ * reuses the emcn Checkbox chrome via its exported variants, but the row
+ * button (or the free-text input) owns the interaction, so nesting a real
+ * Radix checkbox button inside the row button is avoided.
+ */
+function RowCheckbox({ checked, disabled }: { checked: boolean; disabled?: boolean }) {
+ return (
+
+
+ {checked && (
+
+ )}
+
+
+ )
+}
+
+type QuestionPhase = 'active' | 'answered' | 'dismissed'
+
+interface QuestionDisplayProps {
+ data: QuestionItem[]
+ /**
+ * Answers resolved from the transcript (the paired user message that
+ * answered this card). When present the card renders as the answered recap
+ * — it IS the user turn; the paired message bubble is hidden by the chat.
+ */
+ answers?: string[]
+ /** Sends the combined answer as a user message; undefined renders the div inert. */
+ onSelect?: (message: string) => void
+}
+
+/**
+ * Inline renderer for the `` special tag: a chat-inline div with the
+ * user input's chrome, the current question's prompt at the top left, dismiss
+ * (and a `‹ N of M ›` stepper for multi-step batches) at the top right, and
+ * suggested-action option rows beneath, always followed by a "Something else"
+ * row that reads as a plain option until clicked and then becomes the focused
+ * text box. `single_select` answers and advances on click (or on submitting
+ * typed text); `multi_select` rows toggle checkboxes and an option-styled
+ * Submit row confirms the step. Answering the last question sends one
+ * combined user message and collapses the div to a question/answer recap.
+ */
+export function QuestionDisplay({
+ data,
+ answers: transcriptAnswers,
+ onSelect,
+}: QuestionDisplayProps) {
+ const disabled = !onSelect
+ const [phase, setPhase] = useState('active')
+ const [step, setStep] = useState(0)
+ const [selectedByStep, setSelectedByStep] = useState(() => data.map(() => []))
+ const [customByStep, setCustomByStep] = useState(() => data.map(() => ''))
+ const [freeText, setFreeText] = useState('')
+ // The "Something else" row reads as a plain option until clicked, then
+ // becomes the focused text box (and reverts when left empty).
+ const [freeTextEditing, setFreeTextEditing] = useState(false)
+ // multi_select only: whether the typed "Something else" text is included in
+ // the answer. Unchecking keeps the text; it just stops counting.
+ const [customCheckedByStep, setCustomCheckedByStep] = useState(() =>
+ data.map(() => false)
+ )
+
+ // The typed text that actually joins a step's answer: multi_select customs
+ // only count while checked; single_select customs always count.
+ const customFor = (i: number, customs: string[]): string =>
+ data[i].type === 'multi_select' && !(customCheckedByStep[i] ?? false) ? '' : (customs[i] ?? '')
+
+ const containerClasses =
+ 'rounded-2xl border border-[var(--border-1)] bg-[var(--white)] px-2.5 py-2 dark:bg-[var(--surface-4)]'
+
+ // Transcript answers win over local state: they survive reloads (local
+ // phase does not) and keep live + rehydrated renders identical.
+ const localAnswers =
+ phase === 'answered'
+ ? data.map((question, i) =>
+ answerFor(question, selectedByStep[i] ?? [], customFor(i, customByStep))
+ )
+ : null
+ const recapAnswers = transcriptAnswers ?? localAnswers
+ if (data.length > 0 && recapAnswers) {
+ return (
+
0 && 'border-t')}>
+ {isMulti ? (
+ // Checked from the moment the row is clicked into; blur with
+ // nothing typed reverts to the plain option row. A real button
+ // (the editing row is a div, so no nesting hazard) so the box
+ // can be toggled even after typing — unchecking keeps the text,
+ // it just stops counting toward the answer.
+