Skip to content

Commit d25f790

Browse files
committed
feat(copilot): send accessible workspaces to agents
1 parent 83988c1 commit d25f790

10 files changed

Lines changed: 215 additions & 38 deletions

File tree

apps/sim/app/api/mothership/execute/route.test.ts

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ const {
1818
mockRequestExplicitStreamAbort,
1919
mockRequireBillingAttributionHeader,
2020
mockRunHeadlessCopilotLifecycle,
21+
mockGetAccessibleWorkspacesForCopilot,
2122
} = vi.hoisted(() => ({
2223
mockAssertActiveWorkspaceAccess: vi.fn(),
2324
mockBuildIntegrationToolSchemas: vi.fn(),
@@ -32,6 +33,11 @@ const {
3233
mockRequestExplicitStreamAbort: vi.fn(),
3334
mockRequireBillingAttributionHeader: vi.fn(),
3435
mockRunHeadlessCopilotLifecycle: vi.fn(),
36+
mockGetAccessibleWorkspacesForCopilot: vi.fn(),
37+
}))
38+
39+
vi.mock('@/lib/copilot/chat/accessible-workspaces', () => ({
40+
getAccessibleWorkspacesForCopilot: mockGetAccessibleWorkspacesForCopilot,
3541
}))
3642

3743
vi.mock('@/lib/core/security/encryption', () => ({
@@ -170,6 +176,10 @@ describe('mothership private trace provenance transport', () => {
170176
decryptionFailures: [],
171177
})
172178
mockGenerateWorkspaceContext.mockResolvedValue({})
179+
mockGetAccessibleWorkspacesForCopilot.mockResolvedValue([
180+
{ id: 'workspace-1', name: 'Production', permission: 'write' },
181+
{ id: 'workspace-2', name: 'Marketing', permission: 'read' },
182+
])
173183
mockBuildIntegrationToolSchemas.mockResolvedValue([])
174184
mockBuildSelectedMcpToolSchemas.mockResolvedValue([])
175185
mockBuildTaggedMcpToolSchemas.mockResolvedValue([])
@@ -219,7 +229,12 @@ describe('mothership private trace provenance transport', () => {
219229
expect(body).not.toHaveProperty('__resolvedSecretTraceProvenance')
220230
expect(mockGetPersonalAndWorkspaceEnv).not.toHaveBeenCalled()
221231
expect(mockRunHeadlessCopilotLifecycle).toHaveBeenCalledWith(
222-
expect.any(Object),
232+
expect.objectContaining({
233+
accessibleWorkspaces: [
234+
{ id: 'workspace-1', name: 'Production', permission: 'write' },
235+
{ id: 'workspace-2', name: 'Marketing', permission: 'read' },
236+
],
237+
}),
223238
expect.objectContaining({ environmentContext: undefined })
224239
)
225240
})

apps/sim/app/api/mothership/execute/route.ts

Lines changed: 28 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { mothershipExecuteContract } from '@/lib/api/contracts/mothership-chats'
66
import { parseRequest } from '@/lib/api/server'
77
import { checkInternalAuth } from '@/lib/auth/hybrid'
88
import { requireBillingAttributionHeader } from '@/lib/billing/core/billing-attribution'
9+
import { getAccessibleWorkspacesForCopilot } from '@/lib/copilot/chat/accessible-workspaces'
910
import { buildIntegrationToolSchemas } from '@/lib/copilot/chat/payload'
1011
import { processContextsServer } from '@/lib/copilot/chat/process-contents'
1112
import { generateWorkspaceContext } from '@/lib/copilot/chat/workspace-context'
@@ -266,25 +267,32 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
266267
const byName = new Map(groups.flat().map((tool) => [tool.name, tool]))
267268
return [...byName.values()]
268269
})
269-
const [workspaceContext, integrationTools, mothershipTools, entitlements, agentContexts] =
270-
await Promise.all([
271-
generateWorkspaceContext(workspaceId, userId, { workspaceAccess }),
272-
buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId),
273-
mothershipToolsPromise,
274-
computeWorkspaceEntitlements(workspaceId, userId),
275-
processContextsServer(
276-
nonMcpAgentMentions,
277-
userId,
278-
lastUserMessage,
279-
workspaceId,
280-
effectiveChatId
281-
).catch((error) => {
282-
reqLogger.warn('Failed to resolve agent contexts for execution', {
283-
error: toError(error).message,
284-
})
285-
return []
286-
}),
287-
])
270+
const [
271+
workspaceContext,
272+
accessibleWorkspaces,
273+
integrationTools,
274+
mothershipTools,
275+
entitlements,
276+
agentContexts,
277+
] = await Promise.all([
278+
generateWorkspaceContext(workspaceId, userId, { workspaceAccess }),
279+
getAccessibleWorkspacesForCopilot(userId),
280+
buildIntegrationToolSchemas(userId, messageId, undefined, workspaceId),
281+
mothershipToolsPromise,
282+
computeWorkspaceEntitlements(workspaceId, userId),
283+
processContextsServer(
284+
nonMcpAgentMentions,
285+
userId,
286+
lastUserMessage,
287+
workspaceId,
288+
effectiveChatId
289+
).catch((error) => {
290+
reqLogger.warn('Failed to resolve agent contexts for execution', {
291+
error: toError(error).message,
292+
})
293+
return []
294+
}),
295+
])
288296
const requestPayload: Record<string, unknown> = {
289297
messages,
290298
responseFormat,
@@ -300,6 +308,7 @@ export const POST = withRouteHandler(async (req: NextRequest) => {
300308
messageId,
301309
isHosted: true,
302310
workspaceContext,
311+
...(accessibleWorkspaces.length > 0 ? { accessibleWorkspaces } : {}),
303312
...(isDocSandboxEnabled ? { docCompiler: 'python' } : {}),
304313
...(userMetadata ? { userMetadata } : {}),
305314
...(fileAttachments && fileAttachments.length > 0 ? { fileAttachments } : {}),
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
/**
2+
* @vitest-environment node
3+
*/
4+
import { beforeEach, describe, expect, it, vi } from 'vitest'
5+
6+
const { mockListAccessibleWorkspaceRowsForUser } = vi.hoisted(() => ({
7+
mockListAccessibleWorkspaceRowsForUser: vi.fn(),
8+
}))
9+
10+
vi.mock('@/lib/workspaces/utils', () => ({
11+
listAccessibleWorkspaceRowsForUser: mockListAccessibleWorkspaceRowsForUser,
12+
}))
13+
14+
import { getAccessibleWorkspacesForCopilot } from '@/lib/copilot/chat/accessible-workspaces'
15+
16+
describe('getAccessibleWorkspacesForCopilot', () => {
17+
beforeEach(() => {
18+
vi.clearAllMocks()
19+
})
20+
21+
it('returns active workspace identity and effective permission in stable order', async () => {
22+
mockListAccessibleWorkspaceRowsForUser.mockResolvedValue([
23+
{ workspace: { id: 'ws-2', name: 'Production' }, permissionType: 'admin' },
24+
{ workspace: { id: 'ws-1', name: 'Marketing' }, permissionType: 'write' },
25+
])
26+
27+
await expect(getAccessibleWorkspacesForCopilot('user-1')).resolves.toEqual([
28+
{ id: 'ws-1', name: 'Marketing', permission: 'write' },
29+
{ id: 'ws-2', name: 'Production', permission: 'admin' },
30+
])
31+
expect(mockListAccessibleWorkspaceRowsForUser).toHaveBeenCalledWith('user-1')
32+
})
33+
34+
it('degrades to no context when the informational lookup fails', async () => {
35+
mockListAccessibleWorkspaceRowsForUser.mockRejectedValue(new Error('database unavailable'))
36+
37+
await expect(getAccessibleWorkspacesForCopilot('user-1')).resolves.toEqual([])
38+
})
39+
})
Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
import { createLogger } from '@sim/logger'
2+
import type { PermissionType } from '@sim/platform-authz/workspace'
3+
import { getErrorMessage } from '@sim/utils/errors'
4+
import { listAccessibleWorkspaceRowsForUser } from '@/lib/workspaces/utils'
5+
6+
const logger = createLogger('CopilotAccessibleWorkspaces')
7+
8+
export interface AccessibleWorkspace {
9+
id: string
10+
name: string
11+
permission: PermissionType
12+
}
13+
14+
/**
15+
* Returns active workspaces visible to the current user for informational
16+
* agent context. This data never replaces workspace authorization checks.
17+
*/
18+
export async function getAccessibleWorkspacesForCopilot(
19+
userId: string
20+
): Promise<AccessibleWorkspace[]> {
21+
try {
22+
const rows = await listAccessibleWorkspaceRowsForUser(userId)
23+
return rows
24+
.map(({ workspace, permissionType }) => ({
25+
id: workspace.id,
26+
name: workspace.name,
27+
permission: permissionType,
28+
}))
29+
.sort((a, b) => a.name.localeCompare(b.name, 'en') || a.id.localeCompare(b.id, 'en'))
30+
} catch (error) {
31+
logger.warn('Failed to load accessible workspaces for copilot context', {
32+
userId,
33+
error: getErrorMessage(error),
34+
})
35+
return []
36+
}
37+
}

apps/sim/lib/copilot/chat/payload.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -274,6 +274,10 @@ describe('buildCopilotRequestPayload', () => {
274274
model: 'claude-opus-4-8',
275275
workspaceId: 'ws-1',
276276
workspaceContext: 'workspace inventory',
277+
accessibleWorkspaces: [
278+
{ id: 'ws-1', name: 'Production', permission: 'admin' },
279+
{ id: 'ws-2', name: 'Marketing', permission: 'read' },
280+
],
277281
},
278282
{ selectedModel: 'claude-opus-4-8' }
279283
)
@@ -282,6 +286,10 @@ describe('buildCopilotRequestPayload', () => {
282286
expect.objectContaining({
283287
workspaceId: 'ws-1',
284288
workspaceContext: 'workspace inventory',
289+
accessibleWorkspaces: [
290+
{ id: 'ws-1', name: 'Production', permission: 'admin' },
291+
{ id: 'ws-2', name: 'Marketing', permission: 'read' },
292+
],
285293
})
286294
)
287295
})

apps/sim/lib/copilot/chat/payload.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { LRUCache } from 'lru-cache'
66
import { getHighestPrioritySubscription } from '@/lib/billing/core/subscription'
77
import { isPaid } from '@/lib/billing/plan-helpers'
88
import { getBlockVisibilityForCopilot, visibilitySignature } from '@/lib/copilot/block-visibility'
9+
import type { AccessibleWorkspace } from '@/lib/copilot/chat/accessible-workspaces'
910
import type { VfsSnapshotV1 } from '@/lib/copilot/generated/vfs-snapshot-v1'
1011
import {
1112
filterExposedIntegrationTools,
@@ -47,6 +48,7 @@ interface BuildPayloadParams {
4748
prefetch?: boolean
4849
implicitFeedback?: string
4950
workspaceContext?: string
51+
accessibleWorkspaces?: AccessibleWorkspace[]
5052
vfs?: VfsSnapshotV1
5153
userPermission?: string
5254
/** Plan/flag-gated org capabilities (e.g. "custom-blocks") the mothership gates tools/prompts on. */
@@ -452,6 +454,9 @@ export async function buildCopilotRequestPayload(
452454
...(mothershipTools.length > 0 ? { mothershipTools } : {}),
453455
...(commands && commands.length > 0 ? { commands } : {}),
454456
...(params.workspaceContext ? { workspaceContext: params.workspaceContext } : {}),
457+
...(params.accessibleWorkspaces?.length
458+
? { accessibleWorkspaces: params.accessibleWorkspaces }
459+
: {}),
455460
...(params.vfs ? { vfs: params.vfs } : {}),
456461
...(params.userPermission ? { userPermission: params.userPermission } : {}),
457462
...(params.entitlements?.length ? { entitlements: params.entitlements } : {}),

apps/sim/lib/copilot/chat/post.test.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ const {
3535
finalizeAssistantTurn,
3636
appendCopilotChatMessages,
3737
mockPublishStatusChanged,
38+
getAccessibleWorkspacesForCopilot,
3839
} = vi.hoisted(() => ({
3940
generateWorkspaceSnapshot: vi.fn(),
4041
processContextsServer: vi.fn(),
@@ -49,6 +50,7 @@ const {
4950
finalizeAssistantTurn: vi.fn(),
5051
appendCopilotChatMessages: vi.fn(),
5152
mockPublishStatusChanged: vi.fn(),
53+
getAccessibleWorkspacesForCopilot: vi.fn(),
5254
}))
5355

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

82+
vi.mock('@/lib/copilot/chat/accessible-workspaces', () => ({
83+
getAccessibleWorkspacesForCopilot,
84+
}))
85+
8086
vi.mock('@/lib/copilot/chat/process-contents', () => ({
8187
processContextsServer,
8288
resolveActiveResourceContext,
@@ -147,6 +153,10 @@ describe('handleUnifiedChatPost', () => {
147153
markdown: 'workspace context',
148154
snapshot: { workflows: [{ id: 'wf-1', name: 'Alpha', path: 'workflows/Alpha' }] },
149155
})
156+
getAccessibleWorkspacesForCopilot.mockResolvedValue([
157+
{ id: 'ws-1', name: 'Production', permission: 'write' },
158+
{ id: 'ws-2', name: 'Marketing', permission: 'read' },
159+
])
150160
processContextsServer.mockResolvedValue([])
151161
resolveActiveResourceContext.mockResolvedValue(null)
152162
buildCopilotRequestPayload.mockImplementation(async (params: Record<string, unknown>) => params)
@@ -187,6 +197,10 @@ describe('handleUnifiedChatPost', () => {
187197
expect.objectContaining({
188198
model: 'claude-opus-4-8',
189199
workspaceContext: 'workspace context',
200+
accessibleWorkspaces: [
201+
{ id: 'ws-1', name: 'Production', permission: 'write' },
202+
{ id: 'ws-2', name: 'Marketing', permission: 'read' },
203+
],
190204
// Regression guard: the branch must forward the typed snapshot, not drop it.
191205
vfs: expect.objectContaining({ workflows: expect.any(Array) }),
192206
}),
@@ -229,6 +243,10 @@ describe('handleUnifiedChatPost', () => {
229243
expect.objectContaining({
230244
workspaceId: 'ws-1',
231245
workspaceContext: 'workspace context',
246+
accessibleWorkspaces: [
247+
{ id: 'ws-1', name: 'Production', permission: 'write' },
248+
{ id: 'ws-2', name: 'Marketing', permission: 'read' },
249+
],
232250
// Regression guard: the branch must forward the typed snapshot, not drop it.
233251
vfs: expect.objectContaining({ workflows: expect.any(Array) }),
234252
}),

apps/sim/lib/copilot/chat/post.ts

Lines changed: 28 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,10 @@ import { z } from 'zod'
1010
import { isZodError, validationErrorResponse } from '@/lib/api/server'
1111
import { getSession } from '@/lib/auth'
1212
import { resolveBillingAttribution } from '@/lib/billing/core/billing-attribution'
13+
import {
14+
type AccessibleWorkspace,
15+
getAccessibleWorkspacesForCopilot,
16+
} from '@/lib/copilot/chat/accessible-workspaces'
1317
import { type ChatLoadResult, resolveOrCreateChat } from '@/lib/copilot/chat/lifecycle'
1418
import { appendCopilotChatMessages } from '@/lib/copilot/chat/messages-store'
1519
import { buildCopilotRequestPayload } from '@/lib/copilot/chat/payload'
@@ -278,6 +282,7 @@ type UnifiedChatBranch =
278282
prefetch?: boolean
279283
implicitFeedback?: string
280284
workspaceContext?: string
285+
accessibleWorkspaces?: AccessibleWorkspace[]
281286
vfs?: VfsSnapshotV1
282287
desktopLocalFilesystem?: boolean
283288
browserCapable?: boolean
@@ -314,6 +319,7 @@ type UnifiedChatBranch =
314319
userTimezone?: string
315320
userMetadata?: { name?: string; email?: string; timezone?: string }
316321
workspaceContext?: string
322+
accessibleWorkspaces?: AccessibleWorkspace[]
317323
vfs?: VfsSnapshotV1
318324
desktopLocalFilesystem?: boolean
319325
browserCapable?: boolean
@@ -787,6 +793,7 @@ async function resolveBranch(params: {
787793
prefetch: payloadParams.prefetch,
788794
implicitFeedback: payloadParams.implicitFeedback,
789795
workspaceContext: payloadParams.workspaceContext,
796+
accessibleWorkspaces: payloadParams.accessibleWorkspaces,
790797
vfs: payloadParams.vfs,
791798
userPermission: payloadParams.userPermission,
792799
entitlements: payloadParams.entitlements,
@@ -849,6 +856,7 @@ async function resolveBranch(params: {
849856
fileAttachments: payloadParams.fileAttachments,
850857
chatId: payloadParams.chatId,
851858
workspaceContext: payloadParams.workspaceContext,
859+
accessibleWorkspaces: payloadParams.accessibleWorkspaces,
852860
vfs: payloadParams.vfs,
853861
userPermission: payloadParams.userPermission,
854862
entitlements: payloadParams.entitlements,
@@ -1082,6 +1090,7 @@ export async function handleUnifiedChatPost(req: NextRequest) {
10821090
const entitlementsPromise = workspaceId
10831091
? computeWorkspaceEntitlements(workspaceId, authenticatedUserId)
10841092
: Promise.resolve([])
1093+
const accessibleWorkspacesPromise = getAccessibleWorkspacesForCopilot(authenticatedUserId)
10851094
// Wrap the pre-LLM prep work in spans so the trace waterfall shows
10861095
// where time is going between "request received" and "llm.stream
10871096
// opens". Previously these ran bare under the root and inflated the
@@ -1136,15 +1145,23 @@ export async function handleUnifiedChatPost(req: NextRequest) {
11361145
activeOtelRoot.context
11371146
)
11381147

1139-
const [agentContexts, userPermission, entitlements, workspaceSnapshot, , executionContext] =
1140-
await Promise.all([
1141-
agentContextsPromise,
1142-
userPermissionPromise,
1143-
entitlementsPromise,
1144-
workspaceContextPromise,
1145-
persistUserMessagePromise,
1146-
executionContextPromise,
1147-
])
1148+
const [
1149+
agentContexts,
1150+
userPermission,
1151+
entitlements,
1152+
accessibleWorkspaces,
1153+
workspaceSnapshot,
1154+
,
1155+
executionContext,
1156+
] = await Promise.all([
1157+
agentContextsPromise,
1158+
userPermissionPromise,
1159+
entitlementsPromise,
1160+
accessibleWorkspacesPromise,
1161+
workspaceContextPromise,
1162+
persistUserMessagePromise,
1163+
executionContextPromise,
1164+
])
11481165
// Both halves come from one primary-db fetch (workspace-context.ts):
11491166
// `workspaceContext` is the markdown transition fallback, `vfs` is the
11501167
// typed snapshot Go diffs into baseline+delta messages.
@@ -1189,6 +1206,7 @@ export async function handleUnifiedChatPost(req: NextRequest) {
11891206
prefetch: body.prefetch,
11901207
implicitFeedback: body.implicitFeedback,
11911208
workspaceContext,
1209+
accessibleWorkspaces,
11921210
vfs,
11931211
desktopLocalFilesystem: body.desktopCapabilities?.localFilesystem === true,
11941212
browserCapable:
@@ -1210,6 +1228,7 @@ export async function handleUnifiedChatPost(req: NextRequest) {
12101228
userTimezone: body.userTimezone,
12111229
userMetadata,
12121230
workspaceContext,
1231+
accessibleWorkspaces,
12131232
vfs,
12141233
desktopLocalFilesystem: body.desktopCapabilities?.localFilesystem === true,
12151234
browserCapable:

0 commit comments

Comments
 (0)