Skip to content

Commit ac5dc14

Browse files
committed
feat(assistant): connect and use personal organization integrations
1 parent 623c710 commit ac5dc14

21 files changed

Lines changed: 848 additions & 27 deletions

‎apps/sim/app/o/[organizationId]/search/search.test.tsx‎

Lines changed: 32 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ const mocks = vi.hoisted(() => ({
1313
assistant: vi.fn(),
1414
urlUpdate: vi.fn(),
1515
push: vi.fn(),
16+
pathname: vi.fn(),
1617
speech: vi.fn<typeof useSpeechToText>(),
1718
toggleListening: vi.fn(),
1819
}))
@@ -23,7 +24,7 @@ vi.mock('@/app/o/[organizationId]/home/organization-home', () => ({
2324
vi.mock('@/hooks/use-speech-to-text', () => ({ useSpeechToText: mocks.speech }))
2425
vi.mock('next/navigation', () => ({
2526
useRouter: () => ({ push: mocks.push }),
26-
usePathname: () => '/o/organization-a/search',
27+
usePathname: mocks.pathname,
2728
}))
2829
vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => ({
2930
useOrganizationContext: () => ({
@@ -64,6 +65,7 @@ let container: HTMLDivElement
6465

6566
beforeEach(() => {
6667
vi.clearAllMocks()
68+
mocks.pathname.mockReturnValue('/o/organization-a/search')
6769
mocks.assistant.mockReturnValue(<div>Search Assistant composer</div>)
6870
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
6971
vi.stubGlobal(
@@ -227,6 +229,35 @@ describe('organization Search query navigation', () => {
227229
})
228230

229231
describe('organization Search intent toggle', () => {
232+
it('keeps the live Assistant mounted when the first message replaces the URL with a chat route', async () => {
233+
await render('?view=assistant')
234+
const composer = Array.from(container.querySelectorAll('div')).find(
235+
(node) => node.textContent === 'Search Assistant composer' && node.childElementCount === 0
236+
)
237+
expect(composer).toBeDefined()
238+
mocks.pathname.mockReturnValue('/o/organization-a/chat/new-chat')
239+
await render('')
240+
expect(container.contains(composer!)).toBe(true)
241+
expect(container.querySelector('input[aria-label="Search your sources"]')).toBeNull()
242+
expect(mocks.assistant).toHaveBeenLastCalledWith(
243+
expect.objectContaining({ requestMode: 'assistant', chatId: undefined }),
244+
undefined
245+
)
246+
expect(
247+
container.querySelector('[role="radio"][value="assistant"]')?.getAttribute('aria-checked')
248+
).toBe('true')
249+
await act(async () =>
250+
container.querySelector<HTMLButtonElement>('[role="radio"][value="results"]')!.click()
251+
)
252+
expect(mocks.push).toHaveBeenCalledWith('/o/organization-a/search?')
253+
})
254+
255+
it('does not treat another organization chat path as this organization Assistant', async () => {
256+
mocks.pathname.mockReturnValue('/o/organization-other/chat/chat')
257+
await render('')
258+
expect(searchInput()).toBeDefined()
259+
expect(mocks.assistant).not.toHaveBeenCalled()
260+
})
230261
it('preserves committed query and filters without submitting an Assistant turn', async () => {
231262
await render('?q=Orion&source=slack&updated=7d')
232263
await act(async () =>

‎apps/sim/app/o/[organizationId]/search/search.tsx‎

Lines changed: 7 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ import {
1111
useScrollEdges,
1212
} from '@sim/emcn'
1313
import { ArrowUp, Search } from '@sim/emcn/icons'
14-
import { useRouter } from 'next/navigation'
14+
import { usePathname, useRouter } from 'next/navigation'
1515
import { useQueryStates } from 'nuqs'
1616
import { HEADER_ACTION_CLUSTER, PAGE_HEADER_BAR } from '@/components/page-header-bar'
1717
import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge'
@@ -155,6 +155,7 @@ export function OrganizationSearch(props: OrganizationSearchProps) {
155155
function OrganizationSearchContent({ chatId, userName }: OrganizationSearchProps) {
156156
const { organization } = useOrganizationContext()
157157
const router = useRouter()
158+
const pathname = usePathname()
158159
const [{ q, view }, setParams] = useQueryStates(
159160
organizationSearchParsers,
160161
organizationSearchUrlKeys
@@ -182,7 +183,10 @@ function OrganizationSearchContent({ chatId, userName }: OrganizationSearchProps
182183
}
183184

184185
const searching = query.length > 0
185-
const activeView = chatId ? 'assistant' : view
186+
/** First-message admission replaces browser history without replacing this page's server props. */
187+
const isChatRoute =
188+
Boolean(chatId) || Boolean(pathname?.startsWith(organizationRoutes(organization.id).chat('')))
189+
const activeView = isChatRoute ? 'assistant' : view
186190

187191
return (
188192
<div className='flex h-full min-h-0 flex-col bg-[var(--bg)]'>
@@ -194,7 +198,7 @@ function OrganizationSearchContent({ chatId, userName }: OrganizationSearchProps
194198
aria-label='Search view'
195199
onValueChange={(next) => {
196200
if (next !== 'results' && next !== 'assistant') return
197-
if (chatId) {
201+
if (isChatRoute) {
198202
router.push(
199203
`${organizationRoutes(organization.id).search}?${new URLSearchParams({ ...(q ? { q } : {}), ...(filters.source ? { source: filters.source } : {}), ...(filters.updated !== 'any' ? { updated: filters.updated } : {}), ...(next === 'assistant' ? { view: next } : {}) })}`
200204
)

‎apps/sim/executor/utils/credential-token.test.ts‎

Lines changed: 39 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -9,11 +9,17 @@ const {
99
mockCreateCopilotManagedOAuthPrincipal,
1010
mockResolveCredentialAccessToken,
1111
mockPersonalCredentialUseCase,
12+
mockOrganizationPersonalToken,
1213
} = vi.hoisted(() => ({
1314
mockBindExecutorManagedOAuthDelegation: vi.fn(),
1415
mockCreateCopilotManagedOAuthPrincipal: vi.fn(),
1516
mockResolveCredentialAccessToken: vi.fn(),
1617
mockPersonalCredentialUseCase: vi.fn(),
18+
mockOrganizationPersonalToken: vi.fn(),
19+
}))
20+
21+
vi.mock('@/lib/mothership/application/resolve-organization-personal-token', () => ({
22+
resolveCopilotOrganizationPersonalToken: mockOrganizationPersonalToken,
1723
}))
1824

1925
vi.mock('@/lib/credentials/application/copilot-managed-oauth-delegation', () => ({
@@ -25,7 +31,7 @@ vi.mock('@/lib/mothership/application/execute-credential-use-case', () => ({
2531
}))
2632
vi.mock('@/tools/metadata', () => ({
2733
getToolMetadata: (id: string) =>
28-
id === 'gmail_read' ? { oauth: { required: true, provider: 'google-email' } } : undefined,
34+
id === 'gmail_read' ? { id, oauth: { required: true, provider: 'google-email' } } : undefined,
2935
}))
3036

3137
vi.mock('@/lib/oauth/token-resolution', () => ({
@@ -54,6 +60,38 @@ describe('resolveExecutorCredentialToken', () => {
5460
})
5561
})
5662

63+
it('uses organization-native personal authority without reaching workspace token resolution', async () => {
64+
const context = {
65+
userId: 'user-1',
66+
organizationId: 'org',
67+
chatId: 'chat',
68+
toolCallId: 'call',
69+
copilotToolExecution: true,
70+
requestMode: 'assistant',
71+
}
72+
mockOrganizationPersonalToken.mockResolvedValue({
73+
accessToken: 'own-token',
74+
credentialType: 'managed_oauth',
75+
})
76+
const token = await resolveExecutorCredentialToken({
77+
requestId: 'req',
78+
credentialId: 'own',
79+
userId: 'user-1',
80+
toolId: 'gmail_read',
81+
scopes: ['read'],
82+
copilotExecutionContext: context,
83+
})
84+
expect(token.accessToken).toBe('own-token')
85+
expect(mockOrganizationPersonalToken).toHaveBeenCalledWith(context, {
86+
credentialId: 'own',
87+
expectedProviderId: 'google-email',
88+
requiredScopes: ['read'],
89+
toolId: 'gmail_read',
90+
})
91+
expect(mockPersonalCredentialUseCase).not.toHaveBeenCalled()
92+
expect(mockResolveCredentialAccessToken).not.toHaveBeenCalled()
93+
})
94+
5795
it('dispatches with an internal-JWT auth result for the executing user', async () => {
5896
await resolveExecutorCredentialToken({
5997
requestId: 'req-1',

‎apps/sim/executor/utils/credential-token.ts‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { createCopilotManagedOAuthPrincipal } from '@/lib/credentials/applicatio
44
import { bindExecutorManagedOAuthDelegation } from '@/lib/credentials/application/managed-oauth-delegation'
55
import { authorizePersonalCredential } from '@/lib/credentials/application/personal-credentials'
66
import { executeCopilotCredentialUseCase } from '@/lib/mothership/application/execute-credential-use-case'
7+
import { resolveCopilotOrganizationPersonalToken } from '@/lib/mothership/application/resolve-organization-personal-token'
78
import type { CopilotExecutionContext } from '@/lib/mothership/auth/application-delegation'
89
import {
910
type CredentialTokenPayload,
@@ -61,11 +62,25 @@ export async function resolveExecutorCredentialToken(
6162
throw new Error('Assistant credential use requires the authenticated person for this turn.')
6263
}
6364
const tool = toolId ? getToolMetadata(toolId) : undefined
64-
if (!tool?.oauth?.required || !copilotExecutionContext.workspaceId || params.impersonateEmail) {
65+
if (
66+
!tool?.oauth?.required ||
67+
(!copilotExecutionContext.workspaceId && !copilotExecutionContext.organizationId) ||
68+
params.impersonateEmail
69+
) {
6570
throw new Error(
6671
'Assistant requires your own connected account and cannot impersonate another user.'
6772
)
6873
}
74+
if (copilotExecutionContext.organizationId) {
75+
return resolveCopilotOrganizationPersonalToken(copilotExecutionContext, {
76+
credentialId,
77+
expectedProviderId: tool.oauth.provider,
78+
requiredScopes: params.scopes ?? [],
79+
toolId: tool.id,
80+
})
81+
}
82+
if (!copilotExecutionContext.workspaceId)
83+
throw new Error('Workspace credential scope is required')
6984
await executeCopilotCredentialUseCase(copilotExecutionContext, authorizePersonalCredential, {
7085
workspaceId: copilotExecutionContext.workspaceId,
7186
credentialId,

0 commit comments

Comments
 (0)