Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 16 additions & 1 deletion apps/sim/app/api/mothership/execute/route.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ const {
mockRequestExplicitStreamAbort,
mockRequireBillingAttributionHeader,
mockRunHeadlessCopilotLifecycle,
mockGetAccessibleWorkspacesForCopilot,
} = vi.hoisted(() => ({
mockAssertActiveWorkspaceAccess: vi.fn(),
mockBuildIntegrationToolSchemas: vi.fn(),
Expand All @@ -32,6 +33,11 @@ const {
mockRequestExplicitStreamAbort: vi.fn(),
mockRequireBillingAttributionHeader: vi.fn(),
mockRunHeadlessCopilotLifecycle: vi.fn(),
mockGetAccessibleWorkspacesForCopilot: vi.fn(),
}))

vi.mock('@/lib/copilot/chat/accessible-workspaces', () => ({
getAccessibleWorkspacesForCopilot: mockGetAccessibleWorkspacesForCopilot,
}))

vi.mock('@/lib/core/security/encryption', () => ({
Expand Down Expand Up @@ -170,6 +176,10 @@ describe('mothership private trace provenance transport', () => {
decryptionFailures: [],
})
mockGenerateWorkspaceContext.mockResolvedValue({})
mockGetAccessibleWorkspacesForCopilot.mockResolvedValue([
{ id: 'workspace-1', name: 'Production', permission: 'write' },
{ id: 'workspace-2', name: 'Marketing', permission: 'read' },
])
mockBuildIntegrationToolSchemas.mockResolvedValue([])
mockBuildSelectedMcpToolSchemas.mockResolvedValue([])
mockBuildTaggedMcpToolSchemas.mockResolvedValue([])
Expand Down Expand Up @@ -219,7 +229,12 @@ describe('mothership private trace provenance transport', () => {
expect(body).not.toHaveProperty('__resolvedSecretTraceProvenance')
expect(mockGetPersonalAndWorkspaceEnv).not.toHaveBeenCalled()
expect(mockRunHeadlessCopilotLifecycle).toHaveBeenCalledWith(
expect.any(Object),
expect.objectContaining({
accessibleWorkspaces: [
{ id: 'workspace-1', name: 'Production', permission: 'write' },
{ id: 'workspace-2', name: 'Marketing', permission: 'read' },
],
}),
expect.objectContaining({ environmentContext: undefined })
)
})
Expand Down
47 changes: 28 additions & 19 deletions apps/sim/app/api/mothership/execute/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { mothershipExecuteContract } from '@/lib/api/contracts/mothership-chats'
import { parseRequest } from '@/lib/api/server'
import { checkInternalAuth } from '@/lib/auth/hybrid'
import { requireBillingAttributionHeader } from '@/lib/billing/core/billing-attribution'
import { getAccessibleWorkspacesForCopilot } from '@/lib/copilot/chat/accessible-workspaces'
import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload'
import { processContextsServer } from '@/lib/copilot/chat/process-contents'
import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context'
Expand Down Expand Up @@ -266,25 +267,32 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
const byName = new Map(groups.flat().map((tool) => [tool.name, tool]))
return [...byName.values()]
})
const [workspaceContext, integrationTools, mothershipTools, entitlements, agentContexts] =
await Promise.all([
generateWorkspaceContext(workspaceId, userId, { workspaceAccess }),
buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId),
mothershipToolsPromise,
computeWorkspaceEntitlements(workspaceId, userId),
processContextsServer(
nonMcpAgentMentions,
userId,
lastUserMessage,
workspaceId,
effectiveChatId
).catch((error) => {
reqLogger.warn('Failed to resolve agent contexts for execution', {
error: toError(error).message,
})
return []
}),
])
const [
workspaceContext,
accessibleWorkspaces,
integrationTools,
mothershipTools,
entitlements,
agentContexts,
] = await Promise.all([
generateWorkspaceContext(workspaceId, userId, { workspaceAccess }),
getAccessibleWorkspacesForCopilot(userId),
buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId),
mothershipToolsPromise,
computeWorkspaceEntitlements(workspaceId, userId),
processContextsServer(
nonMcpAgentMentions,
userId,
lastUserMessage,
workspaceId,
effectiveChatId
).catch((error) => {
reqLogger.warn('Failed to resolve agent contexts for execution', {
error: toError(error).message,
})
return []
}),
])
const requestPayload: Record<string, unknown> = {
messages,
responseFormat,
Expand All @@ -300,6 +308,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
messageId,
isHosted: true,
workspaceContext,
...(accessibleWorkspaces.length > 0 ? { accessibleWorkspaces } : {}),
...(isDocSandboxEnabled ? { docCompiler: 'python' } : {}),
...(userMetadata ? { userMetadata } : {}),
...(fileAttachments && fileAttachments.length > 0 ? { fileAttachments } : {}),
Expand Down
39 changes: 39 additions & 0 deletions apps/sim/lib/copilot/chat/accessible-workspaces.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
/**
* @vitest-environment node
*/
import { beforeEach, describe, expect, it, vi } from 'vitest'

const { mockListAccessibleWorkspaceRowsForUser } = vi.hoisted(() => ({
mockListAccessibleWorkspaceRowsForUser: vi.fn(),
}))

vi.mock('@/lib/workspaces/utils', () => ({
listAccessibleWorkspaceRowsForUser: mockListAccessibleWorkspaceRowsForUser,
}))

import { getAccessibleWorkspacesForCopilot } from '@/lib/copilot/chat/accessible-workspaces'

describe('getAccessibleWorkspacesForCopilot', () => {
beforeEach(() => {
vi.clearAllMocks()
})

it('returns active workspace identity and effective permission in stable order', async () => {
mockListAccessibleWorkspaceRowsForUser.mockResolvedValue([
{ workspace: { id: 'ws-2', name: 'Production' }, permissionType: 'admin' },
{ workspace: { id: 'ws-1', name: 'Marketing' }, permissionType: 'write' },
])

await expect(getAccessibleWorkspacesForCopilot('user-1')).resolves.toEqual([
{ id: 'ws-1', name: 'Marketing', permission: 'write' },
{ id: 'ws-2', name: 'Production', permission: 'admin' },
])
expect(mockListAccessibleWorkspaceRowsForUser).toHaveBeenCalledWith('user-1')
})

it('degrades to no context when the informational lookup fails', async () => {
mockListAccessibleWorkspaceRowsForUser.mockRejectedValue(new Error('database unavailable'))

await expect(getAccessibleWorkspacesForCopilot('user-1')).resolves.toEqual([])
})
})
37 changes: 37 additions & 0 deletions apps/sim/lib/copilot/chat/accessible-workspaces.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { createLogger } from '@sim/logger'
import type { PermissionType } from '@sim/platform-authz/workspace'
import { getErrorMessage } from '@sim/utils/errors'
import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils'

const logger = createLogger('CopilotAccessibleWorkspaces')

export interface AccessibleWorkspace {
id: string
name: string
permission: PermissionType
}

/**
* Returns active workspaces visible to the current user for informational
* agent context. This data never replaces workspace authorization checks.
*/
export async function getAccessibleWorkspacesForCopilot(
userId: string
): Promise<AccessibleWorkspace[]> {
try {
const rows = await listAccessibleWorkspaceRowsForUser(userId)
return rows
.map(({ workspace, permissionType }) => ({
id: workspace.id,
name: workspace.name,
permission: permissionType,
}))
.sort((a, b) => a.name.localeCompare(b.name, 'en') || a.id.localeCompare(b.id, 'en'))
} catch (error) {
logger.warn('Failed to load accessible workspaces for copilot context', {
userId,
error: getErrorMessage(error),
})
return []
}
}
8 changes: 8 additions & 0 deletions apps/sim/lib/copilot/chat/payload.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,10 @@ describe('buildCopilotRequestPayload', () => {
model: 'claude-opus-4-8',
workspaceId: 'ws-1',
workspaceContext: 'workspace inventory',
accessibleWorkspaces: [
{ id: 'ws-1', name: 'Production', permission: 'admin' },
{ id: 'ws-2', name: 'Marketing', permission: 'read' },
],
},
{ selectedModel: 'claude-opus-4-8' }
)
Expand All @@ -282,6 +286,10 @@ describe('buildCopilotRequestPayload', () => {
expect.objectContaining({
workspaceId: 'ws-1',
workspaceContext: 'workspace inventory',
accessibleWorkspaces: [
{ id: 'ws-1', name: 'Production', permission: 'admin' },
{ id: 'ws-2', name: 'Marketing', permission: 'read' },
],
})
)
})
Expand Down
5 changes: 5 additions & 0 deletions apps/sim/lib/copilot/chat/payload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { LRUCache } from 'lru-cache'
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
import { isPaid } from '@/lib/billing/plan-helpers'
import { getBlockVisibilityForCopilot, visibilitySignature } from '@/lib/copilot/block-visibility'
import type { AccessibleWorkspace } from '@/lib/copilot/chat/accessible-workspaces'
import type { VfsSnapshotV1 } from '@/lib/copilot/generated/vfs-snapshot-v1'
import {
filterExposedIntegrationTools,
Expand Down Expand Up @@ -47,6 +48,7 @@ interface BuildPayloadParams {
prefetch?: boolean
implicitFeedback?: string
workspaceContext?: string
accessibleWorkspaces?: AccessibleWorkspace[]
vfs?: VfsSnapshotV1
userPermission?: string
/** Plan/flag-gated org capabilities (e.g. "custom-blocks") the mothership gates tools/prompts on. */
Expand Down Expand Up @@ -452,6 +454,9 @@ export async function buildCopilotRequestPayload(
...(mothershipTools.length > 0 ? { mothershipTools } : {}),
...(commands && commands.length > 0 ? { commands } : {}),
...(params.workspaceContext ? { workspaceContext: params.workspaceContext } : {}),
...(params.accessibleWorkspaces?.length
? { accessibleWorkspaces: params.accessibleWorkspaces }
: {}),
...(params.vfs ? { vfs: params.vfs } : {}),
...(params.userPermission ? { userPermission: params.userPermission } : {}),
...(params.entitlements?.length ? { entitlements: params.entitlements } : {}),
Expand Down
18 changes: 18 additions & 0 deletions apps/sim/lib/copilot/chat/post.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ const {
finalizeAssistantTurn,
appendCopilotChatMessages,
mockPublishStatusChanged,
getAccessibleWorkspacesForCopilot,
} = vi.hoisted(() => ({
generateWorkspaceSnapshot: vi.fn(),
processContextsServer: vi.fn(),
Expand All @@ -49,6 +50,7 @@ const {
finalizeAssistantTurn: vi.fn(),
appendCopilotChatMessages: vi.fn(),
mockPublishStatusChanged: vi.fn(),
getAccessibleWorkspacesForCopilot: vi.fn(),
}))

const getSession = authMockFns.mockGetSession
Expand Down Expand Up @@ -77,6 +79,10 @@ vi.mock('@/lib/copilot/chat/workspace-context', () => ({
generateWorkspaceSnapshot,
}))

vi.mock('@/lib/copilot/chat/accessible-workspaces', () => ({
getAccessibleWorkspacesForCopilot,
}))

vi.mock('@/lib/copilot/chat/process-contents', () => ({
processContextsServer,
resolveActiveResourceContext,
Expand Down Expand Up @@ -147,6 +153,10 @@ describe('handleUnifiedChatPost', () => {
markdown: 'workspace context',
snapshot: { workflows: [{ id: 'wf-1', name: 'Alpha', path: 'workflows/Alpha' }] },
})
getAccessibleWorkspacesForCopilot.mockResolvedValue([
{ id: 'ws-1', name: 'Production', permission: 'write' },
{ id: 'ws-2', name: 'Marketing', permission: 'read' },
])
processContextsServer.mockResolvedValue([])
resolveActiveResourceContext.mockResolvedValue(null)
buildCopilotRequestPayload.mockImplementation(async (params: Record<string, unknown>) => params)
Expand Down Expand Up @@ -187,6 +197,10 @@ describe('handleUnifiedChatPost', () => {
expect.objectContaining({
model: 'claude-opus-4-8',
workspaceContext: 'workspace context',
accessibleWorkspaces: [
{ id: 'ws-1', name: 'Production', permission: 'write' },
{ id: 'ws-2', name: 'Marketing', permission: 'read' },
],
// Regression guard: the branch must forward the typed snapshot, not drop it.
vfs: expect.objectContaining({ workflows: expect.any(Array) }),
}),
Expand Down Expand Up @@ -229,6 +243,10 @@ describe('handleUnifiedChatPost', () => {
expect.objectContaining({
workspaceId: 'ws-1',
workspaceContext: 'workspace context',
accessibleWorkspaces: [
{ id: 'ws-1', name: 'Production', permission: 'write' },
{ id: 'ws-2', name: 'Marketing', permission: 'read' },
],
// Regression guard: the branch must forward the typed snapshot, not drop it.
vfs: expect.objectContaining({ workflows: expect.any(Array) }),
}),
Expand Down
37 changes: 28 additions & 9 deletions apps/sim/lib/copilot/chat/post.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@ import { z } from 'zod'
import { isZodError, validationErrorResponse } from '@/lib/api/server'
import { getSession } from '@/lib/auth'
import { resolveBillingAttribution } from '@/lib/billing/core/billing-attribution'
import {
type AccessibleWorkspace,
getAccessibleWorkspacesForCopilot,
} from '@/lib/copilot/chat/accessible-workspaces'
import { type ChatLoadResult, resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle'
import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store'
import { buildCopilotRequestPayload } from '@/lib/copilot/chat/payload'
Expand Down Expand Up @@ -278,6 +282,7 @@ type UnifiedChatBranch =
prefetch?: boolean
implicitFeedback?: string
workspaceContext?: string
accessibleWorkspaces?: AccessibleWorkspace[]
vfs?: VfsSnapshotV1
desktopLocalFilesystem?: boolean
browserCapable?: boolean
Expand Down Expand Up @@ -314,6 +319,7 @@ type UnifiedChatBranch =
userTimezone?: string
userMetadata?: { name?: string; email?: string; timezone?: string }
workspaceContext?: string
accessibleWorkspaces?: AccessibleWorkspace[]
vfs?: VfsSnapshotV1
desktopLocalFilesystem?: boolean
browserCapable?: boolean
Expand Down Expand Up @@ -787,6 +793,7 @@ async function resolveBranch(params: {
prefetch: payloadParams.prefetch,
implicitFeedback: payloadParams.implicitFeedback,
workspaceContext: payloadParams.workspaceContext,
accessibleWorkspaces: payloadParams.accessibleWorkspaces,
vfs: payloadParams.vfs,
userPermission: payloadParams.userPermission,
entitlements: payloadParams.entitlements,
Expand Down Expand Up @@ -849,6 +856,7 @@ async function resolveBranch(params: {
fileAttachments: payloadParams.fileAttachments,
chatId: payloadParams.chatId,
workspaceContext: payloadParams.workspaceContext,
accessibleWorkspaces: payloadParams.accessibleWorkspaces,
vfs: payloadParams.vfs,
userPermission: payloadParams.userPermission,
entitlements: payloadParams.entitlements,
Expand Down Expand Up @@ -1082,6 +1090,7 @@ export async function handleUnifiedChatPost(req: NextRequest) {
const entitlementsPromise = workspaceId
? computeWorkspaceEntitlements(workspaceId, authenticatedUserId)
: Promise.resolve([])
const accessibleWorkspacesPromise = getAccessibleWorkspacesForCopilot(authenticatedUserId)
// Wrap the pre-LLM prep work in spans so the trace waterfall shows
// where time is going between "request received" and "llm.stream
// opens". Previously these ran bare under the root and inflated the
Expand Down Expand Up @@ -1136,15 +1145,23 @@ export async function handleUnifiedChatPost(req: NextRequest) {
activeOtelRoot.context
)

const [agentContexts, userPermission, entitlements, workspaceSnapshot, , executionContext] =
await Promise.all([
agentContextsPromise,
userPermissionPromise,
entitlementsPromise,
workspaceContextPromise,
persistUserMessagePromise,
executionContextPromise,
])
const [
agentContexts,
userPermission,
entitlements,
accessibleWorkspaces,
workspaceSnapshot,
,
executionContext,
] = await Promise.all([
agentContextsPromise,
userPermissionPromise,
entitlementsPromise,
accessibleWorkspacesPromise,
workspaceContextPromise,
persistUserMessagePromise,
executionContextPromise,
])
// Both halves come from one primary-db fetch (workspace-context.ts):
// `workspaceContext` is the markdown transition fallback, `vfs` is the
// typed snapshot Go diffs into baseline+delta messages.
Expand Down Expand Up @@ -1189,6 +1206,7 @@ export async function handleUnifiedChatPost(req: NextRequest) {
prefetch: body.prefetch,
implicitFeedback: body.implicitFeedback,
workspaceContext,
accessibleWorkspaces,
vfs,
desktopLocalFilesystem: body.desktopCapabilities?.localFilesystem === true,
browserCapable:
Expand All @@ -1210,6 +1228,7 @@ export async function handleUnifiedChatPost(req: NextRequest) {
userTimezone: body.userTimezone,
userMetadata,
workspaceContext,
accessibleWorkspaces,
vfs,
desktopLocalFilesystem: body.desktopCapabilities?.localFilesystem === true,
browserCapable:
Expand Down
Loading
Loading