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/function/execute/route.test.ts b/apps/sim/app/api/function/execute/route.test.ts
index f14f6a1bfa8..b73c6c64fb8 100644
--- a/apps/sim/app/api/function/execute/route.test.ts
+++ b/apps/sim/app/api/function/execute/route.test.ts
@@ -15,7 +15,9 @@ import { beforeEach, describe, expect, it, vi } from 'vitest'
const {
mockExecuteInE2B,
mockExecuteInIsolatedVM,
+ mockFetchWorkspaceFileBuffer,
mockGetWorkspaceFile,
+ mockResolveWorkspaceFileReference,
mockUpdateWorkspaceFileContent,
mockUploadFile,
mockValidateWorkspaceFileWriteTarget,
@@ -23,7 +25,9 @@ const {
} = vi.hoisted(() => ({
mockExecuteInE2B: vi.fn(),
mockExecuteInIsolatedVM: vi.fn(),
+ mockFetchWorkspaceFileBuffer: vi.fn(),
mockGetWorkspaceFile: vi.fn(),
+ mockResolveWorkspaceFileReference: vi.fn(),
mockUpdateWorkspaceFileContent: vi.fn(),
mockUploadFile: vi.fn(),
mockValidateWorkspaceFileWriteTarget: vi.fn(),
@@ -37,6 +41,7 @@ vi.mock('@/lib/execution/isolated-vm', () => ({
vi.mock('@/lib/execution/e2b', () => ({
executeInE2B: mockExecuteInE2B,
executeShellInE2B: vi.fn(),
+ SIM_RESULT_PREFIX: '__SIM_RESULT__=',
}))
vi.mock('@/lib/copilot/request/tools/files', () => ({
@@ -81,7 +86,9 @@ vi.mock('@/lib/copilot/vfs/resource-writer', () => ({
}))
vi.mock('@/lib/uploads/contexts/workspace/workspace-file-manager', () => ({
+ fetchWorkspaceFileBuffer: mockFetchWorkspaceFileBuffer,
getWorkspaceFile: mockGetWorkspaceFile,
+ resolveWorkspaceFileReference: mockResolveWorkspaceFileReference,
updateWorkspaceFileContent: mockUpdateWorkspaceFileContent,
uploadWorkspaceFile: vi.fn(),
}))
@@ -138,6 +145,8 @@ describe('Function Execute API Route', () => {
url: '/api/files/view/existing',
key: 'workspace/existing.png',
})
+ mockResolveWorkspaceFileReference.mockResolvedValue(null)
+ mockFetchWorkspaceFileBuffer.mockResolvedValue(Buffer.alloc(0))
mockValidateWorkspaceFileWriteTarget.mockImplementation(async ({ target }) => ({
mode: target.mode,
vfsPath: target.path,
@@ -530,6 +539,152 @@ describe('Function Execute API Route', () => {
expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled()
})
+ it('rejects sandboxPath outputs when the call would run in isolated-vm (E2B enabled, JS without imports)', async () => {
+ envFlagsMock.isE2bEnabled = true
+
+ const req = createMockRequest('POST', {
+ code: 'return "content"',
+ language: 'javascript',
+ workspaceId: 'workspace-1',
+ outputs: {
+ files: [
+ {
+ path: 'files/doc.md',
+ mode: 'overwrite',
+ sandboxPath: '/home/user/doc.md',
+ },
+ ],
+ },
+ })
+
+ const response = await POST(req)
+ const data = await response.json()
+
+ expect(response.status).toBe(422)
+ expect(data.success).toBe(false)
+ expect(data.error).toContain('no sandbox filesystem')
+ expect(mockExecuteInIsolatedVM).not.toHaveBeenCalled()
+ expect(mockExecuteInE2B).not.toHaveBeenCalled()
+ expect(mockWriteWorkspaceFileByPath).not.toHaveBeenCalled()
+ })
+
+ it('rejects sandbox file mounts when the call would run in isolated-vm', async () => {
+ const req = createMockRequest('POST', {
+ code: 'return 1',
+ language: 'javascript',
+ workspaceId: 'workspace-1',
+ _sandboxFiles: [{ path: '/home/user/files/data.csv', content: 'a,b\n1,2' }],
+ })
+
+ const response = await POST(req)
+ const data = await response.json()
+
+ expect(response.status).toBe(422)
+ expect(data.success).toBe(false)
+ // E2B is disabled in this test, so the remediation must name that cause
+ // instead of suggesting python (which would also fail without E2B).
+ expect(data.error).toContain('E2B is not enabled')
+ expect(mockExecuteInIsolatedVM).not.toHaveBeenCalled()
+ })
+
+ it('flags an overwrite export whose bytes are identical to the current file content as unchanged', async () => {
+ envFlagsMock.isE2bEnabled = true
+ const staleContent = '# doc\nunchanged mounted content\n'
+ mockExecuteInE2B.mockResolvedValueOnce({
+ result: 'done',
+ stdout: 'ok',
+ sandboxId: 'sandbox-123',
+ exportedFiles: { '/home/user/doc.md': staleContent },
+ })
+ mockResolveWorkspaceFileReference.mockResolvedValue({
+ id: 'wf_doc',
+ name: 'doc.md',
+ size: Buffer.byteLength(staleContent, 'utf-8'),
+ key: 'workspace/doc.md',
+ })
+ mockFetchWorkspaceFileBuffer.mockResolvedValue(Buffer.from(staleContent, 'utf-8'))
+
+ const req = createMockRequest('POST', {
+ code: 'print("done")',
+ language: 'python',
+ workspaceId: 'workspace-1',
+ outputs: {
+ files: [
+ {
+ path: 'files/doc.md',
+ mode: 'overwrite',
+ sandboxPath: '/home/user/doc.md',
+ mimeType: 'text/markdown',
+ },
+ ],
+ },
+ })
+
+ const response = await POST(req)
+ const data = await response.json()
+
+ // Idempotent overwrites (retries, unchanged regenerations) must not fail;
+ // the write proceeds and the receipt carries the loud unchanged signal so
+ // the model can tell its "new content" never reached the sandbox file.
+ expect(response.status).toBe(200)
+ expect(data.success).toBe(true)
+ expect(mockWriteWorkspaceFileByPath).toHaveBeenCalledTimes(1)
+ expect(data.output.result.unchanged).toBe(true)
+ expect(data.output.result.message).toContain('byte-identical to the previous version')
+ expect(data.output.result.message).toContain('/home/user/doc.md')
+ })
+
+ it('reports size, previousSize, and sha256 receipts on a successful overwrite export', async () => {
+ envFlagsMock.isE2bEnabled = true
+ const newContent = '# doc\nnew content\n'
+ mockExecuteInE2B.mockResolvedValueOnce({
+ result: 'done',
+ stdout: 'ok',
+ sandboxId: 'sandbox-123',
+ exportedFiles: { '/home/user/doc.md': newContent },
+ })
+ mockResolveWorkspaceFileReference.mockResolvedValue({
+ id: 'wf_doc',
+ name: 'doc.md',
+ size: 36728,
+ key: 'workspace/doc.md',
+ })
+
+ const req = createMockRequest('POST', {
+ code: 'print("done")',
+ language: 'python',
+ workspaceId: 'workspace-1',
+ outputs: {
+ files: [
+ {
+ path: 'files/doc.md',
+ mode: 'overwrite',
+ sandboxPath: '/home/user/doc.md',
+ mimeType: 'text/markdown',
+ },
+ ],
+ },
+ })
+
+ const response = await POST(req)
+ const data = await response.json()
+
+ expect(response.status).toBe(200)
+ expect(data.success).toBe(true)
+ // Sizes differ, so the current content is never downloaded for comparison.
+ expect(mockFetchWorkspaceFileBuffer).not.toHaveBeenCalled()
+ expect(data.output.result.size).toBe(Buffer.byteLength(newContent, 'utf-8'))
+ expect(data.output.result.previousSize).toBe(36728)
+ expect(data.output.result.sha256).toMatch(/^[0-9a-f]{64}$/)
+ expect(data.output.result.unchanged).toBe(false)
+ expect(data.output.result.message).toContain('replaced 36728 bytes')
+ expect(data.output.result.message).toContain('sha256:')
+ // The python wrapper prints the marker with a leading \n so it always
+ // starts a fresh line even after non-newline-terminated user output.
+ const e2bCode = mockExecuteInE2B.mock.calls[0][0].code as string
+ expect(e2bCode).toContain("print('\\n__SIM_RESULT__=' + json.dumps(__sim_result__))")
+ })
+
it('should return computed result for multi-line code', async () => {
mockExecuteInIsolatedVM.mockResolvedValueOnce({ result: 10, stdout: '' })
diff --git a/apps/sim/app/api/function/execute/route.ts b/apps/sim/app/api/function/execute/route.ts
index 33ab418dac5..20568fd2010 100644
--- a/apps/sim/app/api/function/execute/route.ts
+++ b/apps/sim/app/api/function/execute/route.ts
@@ -1,4 +1,5 @@
import { createLogger } from '@sim/logger'
+import { sha256Hex } from '@sim/security/hash'
import { getErrorMessage } from '@sim/utils/errors'
import { type NextRequest, NextResponse } from 'next/server'
import { functionExecuteContract } from '@/lib/api/contracts'
@@ -18,7 +19,7 @@ import {
import { isE2bEnabled } from '@/lib/core/config/env-flags'
import { generateRequestId } from '@/lib/core/utils/request'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { executeInE2B, executeShellInE2B } from '@/lib/execution/e2b'
+import { executeInE2B, executeShellInE2B, SIM_RESULT_PREFIX } from '@/lib/execution/e2b'
import { executeInIsolatedVM, type IsolatedVMBrokerHandler } from '@/lib/execution/isolated-vm'
import { CodeLanguage, DEFAULT_CODE_LANGUAGE, isValidCodeLanguage } from '@/lib/execution/languages'
import { recordMaterializedAccessKeys } from '@/lib/execution/payloads/access-keys'
@@ -36,6 +37,10 @@ import {
import { compactExecutionPayload } from '@/lib/execution/payloads/serializer'
import { materializeLargeValueRef } from '@/lib/execution/payloads/store'
import { isExecutionResourceLimitError } from '@/lib/execution/resource-errors'
+import {
+ fetchWorkspaceFileBuffer,
+ resolveWorkspaceFileReference,
+} from '@/lib/uploads/contexts/workspace/workspace-file-manager'
import { getWorkflowById } from '@/lib/workflows/utils'
import { escapeRegExp, normalizeName, REFERENCE } from '@/executor/constants'
import { type OutputSchema, resolveBlockReference } from '@/executor/utils/block-reference'
@@ -899,6 +904,49 @@ async function functionJsonResponse(
)
}
+/**
+ * Compares an about-to-be-exported buffer against the overwrite target's
+ * current content. `identical: true` means the export is a byte-for-byte no-op:
+ * either a legitimately idempotent regeneration, or the incident signature of
+ * code that never wrote to the declared sandboxPath (the file still holds the
+ * mounted input). Only the model can tell those apart, so callers surface the
+ * fact loudly in the receipt instead of failing the write. Comparison failures
+ * never block the write; the current content is only downloaded when the sizes
+ * already match.
+ */
+async function checkOverwriteTarget(
+ workspaceId: string,
+ targetPath: string,
+ buffer: Buffer
+): Promise<{ previousSize?: number; identical: boolean }> {
+ try {
+ const existing = await resolveWorkspaceFileReference(workspaceId, targetPath)
+ if (!existing) return { identical: false }
+ if (existing.size !== buffer.length) {
+ return { previousSize: existing.size, identical: false }
+ }
+ const current = await fetchWorkspaceFileBuffer(existing)
+ return { previousSize: existing.size, identical: current.equals(buffer) }
+ } catch {
+ return { identical: false }
+ }
+}
+
+function formatExportReceipt(bytes: number, previousSize: number | undefined, sha256: string) {
+ return `(${bytes} bytes${
+ previousSize !== undefined ? `, replaced ${previousSize} bytes` : ''
+ }, sha256:${sha256.slice(0, 16)})`
+}
+
+function exportUnchangedNote(sandboxPath?: string): string {
+ return (
+ 'WARNING: content is byte-identical to the previous version — nothing changed.' +
+ (sandboxPath
+ ? ` If you expected new content, your code did not modify the sandbox file at "${sandboxPath}" (it still holds the mounted input); write the new content to exactly that path and export again.`
+ : ' If you expected new content, the code returned the same bytes as before.')
+ )
+}
+
async function maybeExportSandboxFileToWorkspace(args: {
authUserId: string
workflowId?: string
@@ -982,7 +1030,16 @@ async function maybeExportSandboxFileToWorkspace(args: {
const targetPath = overwriteFileId || outputPath
const mode = outputMode ?? (overwriteFileId ? 'overwrite' : 'create')
+ let previousSize: number | undefined
+ let unchanged = false
+ if (mode === 'overwrite') {
+ const check = await checkOverwriteTarget(resolvedWorkspaceId, targetPath, fileBuffer)
+ previousSize = check.previousSize
+ unchanged = check.identical
+ }
+
try {
+ const sha256 = sha256Hex(fileBuffer)
const written = await writeWorkspaceFileByPath({
workspaceId: resolvedWorkspaceId,
userId: authUserId,
@@ -1001,17 +1058,28 @@ async function maybeExportSandboxFileToWorkspace(args: {
mode,
mimeType: resolvedMimeType,
size: fileBuffer.length,
+ previousSize,
+ sha256,
+ unchanged,
})
return NextResponse.json({
success: true,
output: {
result: {
- message: `Sandbox file exported to ${written.vfsPath}`,
+ message: `Sandbox file exported to ${written.vfsPath} ${formatExportReceipt(
+ fileBuffer.length,
+ previousSize,
+ sha256
+ )}${unchanged ? ` — ${exportUnchangedNote(outputSandboxPath)}` : ''}`,
fileId: written.id,
fileName: written.name,
vfsPath: written.vfsPath,
downloadUrl: written.downloadUrl,
sandboxPath: outputSandboxPath,
+ size: fileBuffer.length,
+ previousSize,
+ sha256,
+ unchanged,
},
stdout: cleanStdout(stdout),
executionTime,
@@ -1200,6 +1268,14 @@ async function maybeExportSandboxFilesToWorkspace(args: {
const buffer = prepared.isBinary
? Buffer.from(prepared.content, 'base64')
: Buffer.from(prepared.content, 'utf-8')
+ let previousSize: number | undefined
+ let unchanged = false
+ if (prepared.target.mode === 'overwrite') {
+ const check = await checkOverwriteTarget(resolvedWorkspaceId, prepared.target.path, buffer)
+ previousSize = check.previousSize
+ unchanged = check.identical
+ }
+ const sha256 = sha256Hex(buffer)
const written = await writeWorkspaceFileByPath({
workspaceId: resolvedWorkspaceId,
userId: args.authUserId,
@@ -1214,8 +1290,18 @@ async function maybeExportSandboxFilesToWorkspace(args: {
mode: prepared.file.mode ?? 'create',
mimeType: prepared.resolvedMimeType,
size: prepared.size,
+ previousSize,
+ sha256,
+ unchanged,
+ })
+ writtenFiles.push({
+ ...written,
+ sandboxPath: prepared.sandboxPath,
+ exportedBytes: buffer.length,
+ previousSize,
+ sha256,
+ unchanged,
})
- writtenFiles.push({ ...written, sandboxPath: prepared.sandboxPath })
}
} catch (error) {
return NextResponse.json(
@@ -1232,11 +1318,27 @@ async function maybeExportSandboxFilesToWorkspace(args: {
)
}
+ const unchangedFiles = writtenFiles.filter((file) => file.unchanged)
return NextResponse.json({
success: true,
output: {
result: {
- message: `Exported ${writtenFiles.length} sandbox files`,
+ message: `Exported ${writtenFiles.length} sandbox files: ${writtenFiles
+ .map(
+ (file) =>
+ `${file.vfsPath} ${formatExportReceipt(
+ file.exportedBytes,
+ file.previousSize,
+ file.sha256
+ )}${file.unchanged ? ' [UNCHANGED]' : ''}`
+ )
+ .join('; ')}${
+ unchangedFiles.length > 0
+ ? ` — WARNING: ${unchangedFiles.map((file) => file.vfsPath).join(', ')} ${
+ unchangedFiles.length === 1 ? 'is' : 'are'
+ } byte-identical to the previous version (nothing changed). If you expected new content there, your code did not modify the corresponding sandbox file.`
+ : ''
+ }`,
files: writtenFiles.map((file) => ({
fileId: file.id,
fileName: file.name,
@@ -1244,6 +1346,10 @@ async function maybeExportSandboxFilesToWorkspace(args: {
backingVfsPath: file.backingVfsPath,
downloadUrl: file.downloadUrl,
sandboxPath: file.sandboxPath,
+ size: file.exportedBytes,
+ previousSize: file.previousSize,
+ sha256: file.sha256,
+ unchanged: file.unchanged,
})),
},
stdout: cleanStdout(args.stdout),
@@ -1506,6 +1612,29 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
)
}
+ // Sandbox file mounts and sandboxPath exports only exist in the E2B
+ // runtime; isolated-vm has no filesystem. Silently dropping a declared
+ // sandbox input/output here produced "export succeeded" responses with
+ // zero bytes written, so refuse the call instead. The remediation depends
+ // on WHY this call runs in isolated-vm — "switch to python" is a dead end
+ // when E2B is disabled or the call is a custom tool.
+ if (!useE2B && (outputSandboxPaths.length > 0 || outputSandboxPath || _sandboxFiles?.length)) {
+ const remediation = !isE2bEnabled
+ ? "E2B is not enabled on this deployment, so there is no sandbox filesystem for any language. Pass input data via params and return output as the code's return value with outputs.files[].path (no sandboxPath)."
+ : isCustomTool
+ ? "custom tools always run in the isolated JavaScript VM, which has no sandbox filesystem. Pass input data via params and return output as the code's return value."
+ : 'plain JavaScript runs in the isolated VM, which has no sandbox filesystem. Use language "python" so the code runs in the E2B sandbox, or drop sandboxPath and return the file content as the code\'s return value with outputs.files[].path.'
+ return functionJsonResponse(
+ {
+ success: false,
+ error: `Sandbox file inputs/outputs are unavailable for this call: ${remediation}`,
+ output: { result: null, stdout: '', executionTime: Date.now() - startTime },
+ },
+ routeContext,
+ { status: 422 }
+ )
+ }
+
if (useE2B) {
logger.info(`[${requestId}] E2B status`, {
enabled: isE2bEnabled,
@@ -1543,7 +1672,11 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
' const __sim_result = await (async () => {',
` ${codeBody.split('\n').join('\n ')}`,
' })();',
- " console.log('__SIM_RESULT__=' + JSON.stringify(__sim_result));",
+ // Leading \n guarantees the marker starts a fresh line even when user
+ // code's last stdout write was not newline-terminated (chunks are
+ // concatenated verbatim on the parse side, so a glued marker would
+ // otherwise be missed silently).
+ ` console.log('\\n${SIM_RESULT_PREFIX}' + JSON.stringify(__sim_result));`,
' } catch (error) {',
' console.log(String((error && (error.stack || error.message)) || error));',
' throw error;',
@@ -1635,7 +1768,8 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
'def __sim_main__():',
...resolvedCode.split('\n').map((l) => ` ${l}`),
'__sim_result__ = __sim_main__()',
- "print('__SIM_RESULT__=' + json.dumps(__sim_result__))",
+ // Leading \n: same fresh-line guarantee as the JS wrapper's marker.
+ `print('\\n${SIM_RESULT_PREFIX}' + json.dumps(__sim_result__))`,
].join('\n')
const codeForE2B = prologue + wrapped
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/api/mothership/execute/route.ts b/apps/sim/app/api/mothership/execute/route.ts
index 37236aa160f..b37b3407147 100644
--- a/apps/sim/app/api/mothership/execute/route.ts
+++ b/apps/sim/app/api/mothership/execute/route.ts
@@ -17,7 +17,6 @@ import { requestExplicitStreamAbort } from '@/lib/copilot/request/session/explic
import type { StreamEvent } from '@/lib/copilot/request/types'
import { isE2BDocEnabled } from '@/lib/core/config/env-flags'
import { withRouteHandler } from '@/lib/core/utils/with-route-handler'
-import { buildUserSkillTool } from '@/lib/mothership/skills'
import {
assertActiveWorkspaceAccess,
getUserEntityPermissions,
@@ -141,25 +140,23 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
const lastUserMessage = messages.filter((m) => m.role === 'user').at(-1)?.content
// double-cast-allowed: the contract validates contexts as open kind/label objects; processContextsServer narrows on `kind` at runtime
const agentMentions = contexts as unknown as ChatContext[] | undefined
- const [workspaceContext, integrationTools, userSkillTool, userPermission, agentContexts] =
- await Promise.all([
- generateWorkspaceContext(workspaceId, userId),
- buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId),
- buildUserSkillTool(workspaceId),
- getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null),
- processContextsServer(
- agentMentions,
- userId,
- lastUserMessage,
- workspaceId,
- effectiveChatId
- ).catch((error) => {
- reqLogger.warn('Failed to resolve agent contexts for execution', {
- error: toError(error).message,
- })
- return []
- }),
- ])
+ const [workspaceContext, integrationTools, userPermission, agentContexts] = await Promise.all([
+ generateWorkspaceContext(workspaceId, userId),
+ buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId),
+ getUserEntityPermissions(userId, 'workspace', workspaceId).catch(() => null),
+ processContextsServer(
+ agentMentions,
+ userId,
+ lastUserMessage,
+ workspaceId,
+ effectiveChatId
+ ).catch((error) => {
+ reqLogger.warn('Failed to resolve agent contexts for execution', {
+ error: toError(error).message,
+ })
+ return []
+ }),
+ ])
const requestPayload: Record = {
messages,
responseFormat,
@@ -180,7 +177,6 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
...(fileAttachments && fileAttachments.length > 0 ? { fileAttachments } : {}),
...(agentContexts.length > 0 ? { contexts: agentContexts } : {}),
...(integrationTools.length > 0 ? { integrationTools } : {}),
- ...(userSkillTool ? { mothershipTools: [userSkillTool] } : {}),
...(userPermission ? { userPermission } : {}),
}
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..91698fdc02b 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
@@ -99,7 +99,8 @@ function endsInlineWord(value: string): boolean {
function nextInlineSegmentLabel(segment?: ContentSegment): string {
if (!segment) return ''
- if (segment.type === 'text' || segment.type === 'thinking') return segment.content
+ // Thinking segments are never rendered, so they contribute no following text.
+ if (segment.type === 'text') return segment.content
if (segment.type === 'workspace_resource') return segment.data.title || segment.data.id || ''
return ''
}
@@ -304,6 +305,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 +315,7 @@ interface ChatContentProps {
function ChatContentInner({
content,
isStreaming = false,
+ questionAnswers,
onOptionSelect,
onWorkspaceResourceSelect,
onRevealStateChange,
@@ -468,7 +472,11 @@ function ChatContentInner({
`[${label}](<#wsres-${s.data.type}-${ref}>)`,
nextSegment
)
- } else if (s.type === 'text' || s.type === 'thinking') {
+ } else if (s.type === 'thinking') {
+ // Model-emitted tag bodies are reasoning, not answer text —
+ // never rendered (matches the block-level thinking omission in
+ // message-content and the tag stripping in the inbox executor).
+ } else if (s.type === 'text') {
pendingMarkdown += s.content
} else {
flushMarkdown()
@@ -513,6 +521,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.
+