diff --git a/apps/sim/app/o/[organizationId]/settings/components/organization-search-slack.test.tsx b/apps/sim/app/o/[organizationId]/settings/components/organization-search-slack.test.tsx
index 3bd128195e7..4e4b229507e 100644
--- a/apps/sim/app/o/[organizationId]/settings/components/organization-search-slack.test.tsx
+++ b/apps/sim/app/o/[organizationId]/settings/components/organization-search-slack.test.tsx
@@ -12,6 +12,7 @@ const mocks = vi.hoisted(() => ({
remove: vi.fn(),
install: vi.fn(),
refetch: vi.fn(),
+ copy: vi.fn(),
removeError: null as Error | null,
}))
vi.mock('nuqs', () => ({ useQueryState: () => [null, vi.fn()] }))
@@ -53,6 +54,8 @@ let container: HTMLDivElement
beforeEach(() => {
vi.clearAllMocks()
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
+ vi.stubGlobal('navigator', { clipboard: { writeText: mocks.copy } })
+ mocks.copy.mockReset().mockResolvedValue(undefined)
mocks.context.mockReturnValue({ organization: { id: 'org-1' }, viewer: { isAdmin: true } })
mocks.list.mockReturnValue({ data: { installations: [], bots: [] } })
mocks.manifest.mockReturnValue({
@@ -104,11 +107,14 @@ async function action(label: string) {
}
describe('Slack Search settings and shared wizard', () => {
- it('starts with one setup action and the prefilled manifest, with no name or token form', async () => {
+ it('starts with one setup action and a Slack app link, with no manifest preview or form', async () => {
await render()
expect(container.querySelectorAll('button')).toHaveLength(1)
await click('Set up')
- expect(document.querySelector('[role="dialog"]')).toHaveTextContent('App manifest')
+ expect(document.querySelector('[role="dialog"]')).not.toHaveTextContent('App manifest')
+ expect(document.querySelector('a[href="https://api.slack.com/apps"]')).toHaveTextContent(
+ 'Create app in Slack'
+ )
expect(document.querySelectorAll('input')).toHaveLength(0)
expect(mocks.manifest).toHaveBeenCalledWith('org-1', 'Sim Search')
expect(mocks.install).not.toHaveBeenCalled()
@@ -135,7 +141,11 @@ describe('Slack Search settings and shared wizard', () => {
expect(container.textContent).toContain('Enabled')
await action('Reconnect')
expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Reconnect Slack Search')
+ await click('Copy app configuration')
+ expect(mocks.copy).toHaveBeenCalledExactlyOnceWith('{}')
expect(document.querySelector('a[href="https://api.slack.com/apps/A1"]')).not.toBeNull()
+ expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Configuration copied')
+ expect(document.querySelector('pre')).toBeNull()
await click('Continue')
expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Leave fields blank')
await click('Continue')
@@ -151,6 +161,40 @@ describe('Slack Search settings and shared wizard', () => {
expect(mocks.install.mock.calls[0][0]).not.toHaveProperty('clientSecret')
})
+ it('keeps the update action available when clipboard access fails', async () => {
+ mocks.copy.mockRejectedValueOnce(new Error('Clipboard access denied'))
+ await render(true)
+ await action('Reconnect')
+ await click('Copy app configuration')
+ expect(document.querySelector('[role="alert"]')).toHaveTextContent('Allow clipboard access')
+ expect(document.querySelector('a[href="https://api.slack.com/apps/A1"]')).toBeNull()
+ expect(button('Copy app configuration')).toBeDefined()
+ await click('Copy app configuration')
+ expect(document.querySelector('[role="alert"]')).toBeNull()
+ expect(document.querySelector('a[href="https://api.slack.com/apps/A1"]')).not.toBeNull()
+ })
+
+ it('offers the same configuration update for an app shared with Slack sources', async () => {
+ mocks.manifest.mockReturnValue({
+ data: {
+ manifest: '{"display_information":{"name":"Shared Slack app"}}',
+ existingApp: { appId: 'A2' },
+ createAppUrl: 'https://api.slack.com/apps',
+ },
+ isPending: false,
+ refetch: mocks.refetch,
+ })
+ await render()
+ await click('Set up')
+ expect(document.querySelector('[role="dialog"]')).toHaveTextContent('Update your Slack app')
+ await click('Copy app configuration')
+ expect(mocks.copy).toHaveBeenCalledExactlyOnceWith(
+ '{"display_information":{"name":"Shared Slack app"}}'
+ )
+ expect(document.querySelector('a[href="https://api.slack.com/apps/A2"]')).not.toBeNull()
+ expect(document.querySelector('pre')).toBeNull()
+ })
+
it('disables the selected connection from the actions menu', async () => {
await render(true)
await action('Disable')
diff --git a/apps/sim/components/integrations/slack-search-setup-wizard.tsx b/apps/sim/components/integrations/slack-search-setup-wizard.tsx
index 1d345840fed..c584cb7d30d 100644
--- a/apps/sim/components/integrations/slack-search-setup-wizard.tsx
+++ b/apps/sim/components/integrations/slack-search-setup-wizard.tsx
@@ -10,9 +10,9 @@ import {
ChipModalField,
ChipModalFooter,
ChipModalHeader,
+ writeTextToClipboard,
} from '@sim/emcn'
import { SlackIcon } from '@/components/icons'
-import { SlackAppManifest } from '@/components/integrations/slack-app-manifest'
import {
SLACK_SEARCH_DEFAULT_DESCRIPTION,
SLACK_SEARCH_DEFAULT_NAME,
@@ -43,11 +43,26 @@ export function SlackSearchSetupWizard({
const [clientId, setClientId] = useState('')
const [clientSecret, setClientSecret] = useState('')
const [signingSecret, setSigningSecret] = useState('')
- const error = prepare.error ?? oauth.error
+ const [configurationCopied, setConfigurationCopied] = useState(false)
+ const [copyError, setCopyError] = useState(null)
+ const error = prepare.error ?? oauth.error ?? copyError
const busy = oauth.isPending
const stepNumber = step === 'manifest' ? 1 : step === 'credentials' ? 2 : 3
const configuredAppId = appId ?? prepare.data?.existingApp?.appId
+ async function copyConfiguration() {
+ if (!prepare.data) throw new Error('Slack app configuration is not ready')
+ setCopyError(null)
+ try {
+ await writeTextToClipboard(prepare.data.manifest)
+ setConfigurationCopied(true)
+ } catch {
+ setCopyError(
+ new Error('Could not copy the app configuration. Allow clipboard access and try again.')
+ )
+ }
+ }
+
function advance() {
if (step === 'manifest') {
setStep('credentials')
@@ -101,11 +116,20 @@ export function SlackSearchSetupWizard({
)}
{step === 'manifest' && prepare.data && (
- <>
-
+
+ {configuredAppId && !configurationCopied ? (
+ void copyConfiguration()}>Copy app configuration
+ ) : (
{configuredAppId ? 'Open Slack app settings' : 'Create app in Slack'}
-
- {configuredAppId
- ? 'Open App Manifest in your existing app and apply the updated configuration.'
- : 'Choose your Slack workspace, review the prepared manifest, then create the app.'}
-
-
-
-
-
- >
+ )}
+
)}
{step === 'credentials' && (
<>
diff --git a/apps/sim/ee/credential-groups/components/slack-managed-users-access.test.tsx b/apps/sim/ee/credential-groups/components/slack-managed-users-access.test.tsx
index f9171915ed7..2314c7d445c 100644
--- a/apps/sim/ee/credential-groups/components/slack-managed-users-access.test.tsx
+++ b/apps/sim/ee/credential-groups/components/slack-managed-users-access.test.tsx
@@ -1,5 +1,6 @@
/** @vitest-environment jsdom */
import { act } from 'react'
+import { toast } from '@sim/emcn'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import { createRoot, type Root } from 'react-dom/client'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
@@ -44,6 +45,8 @@ describe('Slack member access selection', () => {
let root: Root
let container: HTMLDivElement
let client: QueryClient
+ let channels: Array<{ onmessage: ((event: MessageEvent) => void) | null }>
+ let popup: { location: { href: string }; closed: boolean; close: ReturnType }
const bot: WorkspaceCredential = {
id: '11111111-1111-4111-8111-111111111111',
workspaceId: 'workspace-1',
@@ -62,6 +65,8 @@ describe('Slack member access selection', () => {
beforeEach(() => {
vi.clearAllMocks()
+ vi.spyOn(toast, 'error').mockReturnValue('toast')
+ vi.spyOn(toast, 'success').mockReturnValue('toast')
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true)
mocks.create.mockResolvedValue(undefined)
mocks.apps.mockReturnValue({
@@ -81,17 +86,23 @@ describe('Slack member access selection', () => {
state: 'state',
authorizationUrl: 'https://slack.com/oauth/v2/authorize',
})
+ channels = []
vi.stubGlobal(
'BroadcastChannel',
class {
+ onmessage: ((event: MessageEvent) => void) | null = null
+ constructor() {
+ channels.push(this)
+ }
close() {}
}
)
- vi.spyOn(window, 'open').mockReturnValue({
+ popup = {
location: { href: '' },
closed: false,
close: vi.fn(),
- } as unknown as Window)
+ }
+ vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window)
container = document.createElement('div')
document.body.appendChild(container)
root = createRoot(container)
@@ -103,6 +114,7 @@ describe('Slack member access selection', () => {
client.clear()
vi.restoreAllMocks()
vi.unstubAllGlobals()
+ vi.useRealTimers()
})
async function render(
@@ -166,6 +178,145 @@ describe('Slack member access selection', () => {
)
}
+ async function completeAuthorization(state = 'state') {
+ await act(async () => {
+ for (const channel of channels) {
+ channel.onmessage?.(
+ new MessageEvent('message', {
+ data: {
+ type: 'slack-managed-users',
+ ok: true,
+ state,
+ credentialGroupId: 'group-1',
+ slackBotCredentialId: bot.id,
+ },
+ })
+ )
+ }
+ })
+ }
+
+ it('accepts authorization after browser isolation reports a live popup as closed', async () => {
+ vi.useFakeTimers()
+ await render()
+ await submit()
+ popup.closed = true
+ await act(async () => vi.advanceTimersByTimeAsync(1_000))
+
+ expect(toast.error).not.toHaveBeenCalled()
+ expect(document.body.textContent).toContain('Waiting for Slack...')
+ await completeAuthorization('unrelated-state')
+ expect(toast.success).not.toHaveBeenCalled()
+ await completeAuthorization()
+ expect(toast.success).toHaveBeenCalledWith('Slack configured')
+ expect(mocks.onOpenChange).toHaveBeenCalledWith(false)
+ await act(async () => vi.advanceTimersByTimeAsync(10 * 60 * 1_000))
+ expect(toast.error).not.toHaveBeenCalled()
+ })
+
+ it('expires only after the authorization deadline and ignores a late callback', async () => {
+ vi.useFakeTimers()
+ await render()
+ await submit()
+ await act(async () => vi.advanceTimersByTimeAsync(10 * 60 * 1_000 - 1))
+ expect(toast.error).not.toHaveBeenCalled()
+ await act(async () => vi.advanceTimersByTimeAsync(1))
+ expect(toast.error).toHaveBeenCalledExactlyOnceWith(
+ 'Slack authorization expired. Please try again.'
+ )
+ expect(popup.close).toHaveBeenCalledOnce()
+ await completeAuthorization()
+ expect(toast.success).not.toHaveBeenCalled()
+ })
+
+ it('lets the user cancel an abandoned popup without reporting expiry', async () => {
+ vi.useFakeTimers()
+ await render()
+ await submit()
+ await clickButton('Cancel')
+ expect(popup.close).toHaveBeenCalledOnce()
+ expect(mocks.onOpenChange).toHaveBeenCalledWith(false)
+ await completeAuthorization()
+ await act(async () => vi.advanceTimersByTimeAsync(10 * 60 * 1_000))
+ expect(toast.error).not.toHaveBeenCalled()
+ expect(toast.success).not.toHaveBeenCalled()
+ })
+
+ it('keeps a new authorization intact if an old deadline callback runs', async () => {
+ vi.useFakeTimers()
+ const timeouts = vi.spyOn(window, 'setTimeout')
+ await render()
+ await submit()
+ const oldDeadline = timeouts.mock.calls.find(([, delay]) => delay === 10 * 60 * 1_000)?.[0]
+ if (typeof oldDeadline !== 'function') throw new Error('Authorization deadline was not set')
+ await clickButton('Cancel')
+
+ const nextPopup = { location: { href: '' }, closed: false, close: vi.fn() }
+ vi.mocked(window.open).mockReturnValueOnce(nextPopup as unknown as Window)
+ mocks.start.mockResolvedValueOnce({
+ state: 'new-state',
+ authorizationUrl: 'https://slack.com/oauth/v2/authorize',
+ })
+ await submit()
+ await act(async () => oldDeadline())
+
+ expect(toast.error).not.toHaveBeenCalled()
+ expect(nextPopup.close).not.toHaveBeenCalled()
+ expect(document.body.textContent).toContain('Waiting for Slack...')
+ await completeAuthorization('new-state')
+ expect(toast.success).toHaveBeenCalledExactlyOnceWith('Slack configured')
+ await act(async () => vi.advanceTimersByTimeAsync(10 * 60 * 1_000))
+ expect(toast.error).not.toHaveBeenCalled()
+ })
+
+ it('does not navigate or start a timeout when authorization startup finishes after cancel', async () => {
+ vi.useFakeTimers()
+ let finishStartup!: (value: { state: string; authorizationUrl: string }) => void
+ mocks.start.mockReturnValueOnce(
+ new Promise((resolve) => {
+ finishStartup = resolve
+ })
+ )
+ await render()
+ await submit()
+ await clickButton('Cancel')
+ await act(async () => {
+ finishStartup({ state: 'state', authorizationUrl: 'https://slack.com/oauth/v2/authorize' })
+ })
+ expect(popup.location.href).toBe('')
+ await act(async () => vi.advanceTimersByTimeAsync(10 * 60 * 1_000))
+ expect(toast.error).not.toHaveBeenCalled()
+ })
+
+ it.each(['resolve', 'reject'] as const)(
+ 'ignores authorization startup that completes with %s after unmount',
+ async (outcome) => {
+ vi.useFakeTimers()
+ let finishStartup!: () => void
+ mocks.start.mockReturnValueOnce(
+ new Promise((resolve, reject) => {
+ finishStartup = () =>
+ outcome === 'resolve'
+ ? resolve({
+ state: 'state',
+ authorizationUrl: 'https://slack.com/oauth/v2/authorize',
+ })
+ : reject(new Error('Authorization startup failed'))
+ })
+ )
+ await render()
+ await submit()
+ await act(async () => root.render(null))
+ await act(async () => finishStartup())
+
+ expect(popup.close).toHaveBeenCalledOnce()
+ expect(popup.location.href).toBe('')
+ await act(async () => vi.advanceTimersByTimeAsync(10 * 60 * 1_000))
+ expect(toast.error).not.toHaveBeenCalled()
+ expect(toast.success).not.toHaveBeenCalled()
+ }
+ )
+
it('opens Slack app setup inline and returns to member setup when canceled', async () => {
await render(undefined, [])
expect(document.querySelector('a')).toBeNull()
@@ -199,7 +350,8 @@ describe('Slack member access selection', () => {
const dialog = appSetupDialog(true)
expect(dialog).toBeDefined()
expect(dialog?.textContent).toContain('Step 1 of 3')
- expect(dialog?.textContent).toContain('App manifest')
+ expect(dialog?.textContent).not.toContain('App manifest')
+ expect(dialog?.textContent).toContain('Create app in Slack')
expect(mocks.manifest).toHaveBeenCalledWith('org-1', 'Sim Search')
expect(mocks.start).not.toHaveBeenCalled()
expect(mocks.create).not.toHaveBeenCalled()
diff --git a/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx b/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx
index ae88f11bf09..aed7460ef51 100644
--- a/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx
+++ b/apps/sim/ee/credential-groups/components/slack-managed-users-modal.tsx
@@ -108,7 +108,7 @@ export function SlackManagedUsersModal({
const expectedState = useRef(null)
const expectedCredentialId = useRef(null)
const popup = useRef(null)
- const popupWatcher = useRef(null)
+ const authorizationTimeout = useRef(null)
const defaultCredentialId = initialCredentialId
? bots.some((bot) => bot.id === initialCredentialId)
@@ -141,8 +141,8 @@ export function SlackManagedUsersModal({
const reset = () => {
popup.current?.close()
popup.current = null
- if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current)
- popupWatcher.current = null
+ if (authorizationTimeout.current !== null) window.clearTimeout(authorizationTimeout.current)
+ authorizationTimeout.current = null
expectedState.current = null
expectedCredentialId.current = null
setAppSetupOpen(false)
@@ -160,8 +160,8 @@ export function SlackManagedUsersModal({
const verifiedCredentialId = expectedCredentialId.current
expectedState.current = null
expectedCredentialId.current = null
- if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current)
- popupWatcher.current = null
+ if (authorizationTimeout.current !== null) window.clearTimeout(authorizationTimeout.current)
+ authorizationTimeout.current = null
popup.current?.close()
popup.current = null
setPending(false)
@@ -222,14 +222,17 @@ export function SlackManagedUsersModal({
useEffect(
() => () => {
- if (popupWatcher.current !== null) window.clearInterval(popupWatcher.current)
+ if (authorizationTimeout.current !== null) window.clearTimeout(authorizationTimeout.current)
popup.current?.close()
+ popup.current = null
+ authorizationTimeout.current = null
+ expectedState.current = null
+ expectedCredentialId.current = null
},
[]
)
const handleOpenChange = (nextOpen: boolean) => {
- if (pending && !nextOpen) return
onOpenChange(nextOpen)
if (!nextOpen) reset()
}
@@ -273,22 +276,23 @@ export function SlackManagedUsersModal({
requiredScopes,
},
})
+ if (popup.current !== opened) return
expectedState.current = result.state
expectedCredentialId.current = selectedBot?.id ?? null
opened.location.href = result.authorizationUrl
- const startedAt = Date.now()
- popupWatcher.current = window.setInterval(() => {
- if (!opened.closed && Date.now() - startedAt < AUTHORIZATION_TIMEOUT_MS) return
- window.clearInterval(popupWatcher.current ?? undefined)
- popupWatcher.current = null
+ /** COOP can report a live OAuth popup as closed; only the deadline expires its state. */
+ authorizationTimeout.current = window.setTimeout(() => {
+ if (popup.current !== opened) return
+ authorizationTimeout.current = null
opened.close()
popup.current = null
expectedState.current = null
expectedCredentialId.current = null
setPending(false)
toast.error('Slack authorization expired. Please try again.')
- }, 500)
+ }, AUTHORIZATION_TIMEOUT_MS)
} catch (authorizationError) {
+ if (popup.current !== opened) return
opened.close()
popup.current = null
setPending(false)
@@ -318,15 +322,10 @@ export function SlackManagedUsersModal({
- handleOpenChange(false)}
- closeDisabled={pending}
- >
+ handleOpenChange(false)}>
{title}
@@ -467,7 +466,6 @@ export function SlackManagedUsersModal({
handleOpenChange(false)}
- cancelDisabled={pending}
{...(needsApp
? {
primaryAction: {
diff --git a/apps/sim/lib/knowledge/application/slack-search/onboarding.test.ts b/apps/sim/lib/knowledge/application/slack-search/onboarding.test.ts
index 3c46da18d63..7f85d50b285 100644
--- a/apps/sim/lib/knowledge/application/slack-search/onboarding.test.ts
+++ b/apps/sim/lib/knowledge/application/slack-search/onboarding.test.ts
@@ -72,6 +72,7 @@ import {
slackSearchConversation,
slackSearchConversationKey,
} from '@/lib/slack-search/conversation'
+import type { SlackSearchMessage } from '@/lib/slack-search/types'
const principal = { kind: 'session', userId: 'user1', sessionId: 'session1' } as const
const job = {
@@ -275,14 +276,18 @@ describe('Slack onboarding control delivery', () => {
eventId: 'Ev1',
receivedAt: new Date(),
} as const
- const send = () =>
+ const send = (
+ reason: 'account' | 'sources' = 'account',
+ message: SlackSearchMessage = job.message,
+ signal = new AbortController().signal
+ ) =>
sendSlackSearchOnboarding(slackPrincipal, {
- job,
+ job: { ...job, message },
turnId: 'turn1',
leaseId: 'lease1',
email: state.email,
- reason: 'account',
- signal: new AbortController().signal,
+ reason,
+ signal,
})
it('posts a thread-scoped signup link without bot secrets or email in its URL', async () => {
const result = await send()
@@ -301,12 +306,126 @@ describe('Slack onboarding control delivery', () => {
expect.objectContaining({ turnId: 'turn1', email: state.email })
)
})
- it('rechecks the binding and lease immediately before delivery', async () => {
- m.authorize.mockResolvedValueOnce(context).mockResolvedValueOnce(null)
- await expect(send()).rejects.toThrow('disabled')
- expect(m.post).not.toHaveBeenCalled()
- expect(m.lease).toHaveBeenCalledWith('turn1', 'lease1')
+ it('sends Connect sources only to the requesting Slack user without requiring an active thread', async () => {
+ await send('sources')
+ expect(m.api).toHaveBeenLastCalledWith({
+ accessToken: 'bot-secret',
+ method: 'chat.postEphemeral',
+ body: {
+ channel: 'D1',
+ user: job.message.userId,
+ text: 'Connect your sources',
+ blocks: expect.arrayContaining([
+ expect.objectContaining({
+ type: 'actions',
+ elements: [
+ expect.objectContaining({
+ text: { type: 'plain_text', text: 'Connect sources' },
+ url: 'https://sim.test/slack-search/connect/opaque-token',
+ }),
+ ],
+ }),
+ ]),
+ },
+ signal: expect.any(AbortSignal),
+ })
+ expect(m.outcome).toHaveBeenCalledWith(context.installation, 'sources_required')
})
+ it.each([job.message.threadTs, undefined])(
+ 'acknowledges missing sources in the question thread after private setup delivery: %s',
+ async (threadTs) => {
+ await send('sources', { ...job.message, threadTs })
+ expect(m.post).toHaveBeenCalledExactlyOnceWith(
+ 'bot-secret',
+ {
+ channel: 'D1',
+ thread_ts: threadTs ?? job.message.messageTs,
+ text: 'I don’t have any sources I can search for you yet. Check the “Connect sources” message in our DM to get set up, then retry this question.',
+ unfurl_links: false,
+ unfurl_media: false,
+ },
+ expect.any(AbortSignal)
+ )
+ expect(m.api.mock.invocationCallOrder[1]).toBeLessThan(m.post.mock.invocationCallOrder[0])
+ expect(m.post.mock.invocationCallOrder[0]).toBeLessThan(m.outcome.mock.invocationCallOrder[0])
+ }
+ )
+ it.each(['rejected', 'ambiguous'] as const)(
+ 'does not replace a %s ephemeral delivery with a persistent message',
+ async (outcome) => {
+ m.api.mockResolvedValueOnce({
+ status: 200,
+ data: { ok: true, permalink: state.slackUrl },
+ })
+ if (outcome === 'rejected') {
+ m.api.mockResolvedValueOnce({
+ status: 200,
+ data: { ok: false, error: 'user_not_in_channel' },
+ })
+ } else {
+ m.api.mockRejectedValueOnce(new Error('connection lost after send'))
+ }
+ await expect(send('sources')).rejects.toThrow(
+ outcome === 'rejected' ? 'Could not deliver Slack onboarding' : 'connection lost after send'
+ )
+ expect(m.api).toHaveBeenCalledTimes(2)
+ expect(m.post).not.toHaveBeenCalled()
+ expect(m.outcome).not.toHaveBeenCalled()
+ }
+ )
+ it.each(['account', 'sources'] as const)(
+ 'rechecks the binding and lease immediately before %s delivery',
+ async (reason) => {
+ m.authorize.mockResolvedValueOnce(context).mockResolvedValueOnce(null)
+ await expect(send(reason)).rejects.toThrow('disabled')
+ expect(m.post).not.toHaveBeenCalled()
+ expect(m.api).toHaveBeenCalledTimes(1)
+ expect(m.lease).toHaveBeenCalledWith('turn1', 'lease1')
+ }
+ )
+ it.each(['binding', 'lease', 'cancellation'] as const)(
+ 'stops before the thread notice if %s changes after the ephemeral prompt',
+ async (change) => {
+ const controller = new AbortController()
+ m.api.mockResolvedValueOnce({
+ status: 200,
+ data: { ok: true, permalink: state.slackUrl },
+ })
+ m.api.mockImplementationOnce(async () => {
+ if (change === 'binding') m.authorize.mockResolvedValueOnce(null)
+ if (change === 'lease') m.lease.mockRejectedValueOnce(new Error('lease lost'))
+ if (change === 'cancellation') controller.abort(new Error('cancelled'))
+ return { status: 200, data: { ok: true } }
+ })
+ await expect(send('sources', job.message, controller.signal)).rejects.toThrow(
+ change === 'binding' ? 'disabled' : change === 'lease' ? 'lease lost' : 'cancelled'
+ )
+ expect(m.api).toHaveBeenCalledTimes(2)
+ expect(m.post).not.toHaveBeenCalled()
+ expect(m.outcome).not.toHaveBeenCalled()
+ }
+ )
+ it.each(['rejected', 'ambiguous'] as const)(
+ 'fails without replaying either message when the sources notice delivery is %s',
+ async (outcome) => {
+ if (outcome === 'rejected') {
+ m.post.mockResolvedValueOnce({
+ status: 200,
+ data: { ok: false, error: 'channel_not_found' },
+ })
+ } else {
+ m.post.mockRejectedValueOnce(new Error('connection lost after send'))
+ }
+ await expect(send('sources')).rejects.toThrow(
+ outcome === 'rejected'
+ ? 'Could not deliver the Slack sources notice'
+ : 'connection lost after send'
+ )
+ expect(m.api).toHaveBeenCalledTimes(2)
+ expect(m.post).toHaveBeenCalledOnce()
+ expect(m.outcome).not.toHaveBeenCalled()
+ }
+ )
it('does not retry an ambiguous post or fall back to another transport', async () => {
m.post.mockRejectedValueOnce(new Error('connection lost after send'))
await expect(send()).rejects.toThrow('connection lost after send')
diff --git a/apps/sim/lib/knowledge/application/slack-search/onboarding.ts b/apps/sim/lib/knowledge/application/slack-search/onboarding.ts
index b6301d7f216..4cd4c7d79b1 100644
--- a/apps/sim/lib/knowledge/application/slack-search/onboarding.ts
+++ b/apps/sim/lib/knowledge/application/slack-search/onboarding.ts
@@ -113,36 +113,67 @@ export async function sendSlackSearchOnboarding(
await requireSlackSearchTurnLease(turnId, leaseId)
if (!(await authorizeSlackSearchInstallation(principal, job)))
throw new OrchestrationError('forbidden', 'Slack Search is disabled')
- const response = await postSlackMessage(
- context.secret.botToken,
- {
- channel: job.message.channelId,
- thread_ts: job.message.threadTs ?? job.message.messageTs,
- text,
- unfurl_links: false,
- unfurl_media: false,
- blocks: [
- { type: 'section', text: { type: 'plain_text', text } },
- {
- type: 'actions',
- elements: [
- {
- type: 'button',
- text: {
- type: 'plain_text',
- text: reason === 'account' ? 'Get started with Sim' : 'Connect sources',
- },
- url,
- action_id: 'slack_search_onboarding',
+ const message = {
+ channel: job.message.channelId,
+ text,
+ blocks: [
+ { type: 'section', text: { type: 'plain_text', text } },
+ {
+ type: 'actions',
+ elements: [
+ {
+ type: 'button',
+ text: {
+ type: 'plain_text',
+ text: reason === 'account' ? 'Get started with Sim' : 'Connect sources',
},
- ],
- },
- ],
- },
- signal
- )
+ url,
+ action_id: 'slack_search_onboarding',
+ },
+ ],
+ },
+ ],
+ }
+ /** Channel-level ephemeral prompts also display before the first persistent thread reply exists. */
+ const response =
+ reason === 'sources'
+ ? await requestSlackApi({
+ accessToken: context.secret.botToken,
+ method: 'chat.postEphemeral',
+ body: { ...message, user: job.message.userId },
+ signal,
+ })
+ : await postSlackMessage(
+ context.secret.botToken,
+ {
+ ...message,
+ thread_ts: job.message.threadTs ?? job.message.messageTs,
+ unfurl_links: false,
+ unfurl_media: false,
+ },
+ signal
+ )
if (response.status !== 200 || response.data.ok !== true)
throw new Error('Could not deliver Slack onboarding')
+ if (reason === 'sources') {
+ await requireSlackSearchTurnLease(turnId, leaseId)
+ if (!(await authorizeSlackSearchInstallation(principal, job)))
+ throw new OrchestrationError('forbidden', 'Slack Search is disabled')
+ signal.throwIfAborted()
+ const reply = await postSlackMessage(
+ context.secret.botToken,
+ {
+ channel: job.message.channelId,
+ thread_ts: job.message.threadTs ?? job.message.messageTs,
+ text: 'I don’t have any sources I can search for you yet. Check the “Connect sources” message in our DM to get set up, then retry this question.',
+ unfurl_links: false,
+ unfurl_media: false,
+ },
+ signal
+ )
+ if (reply.status !== 200 || reply.data.ok !== true)
+ throw new Error('Could not deliver the Slack sources notice')
+ }
await recordSlackSearchOutcome(
context.installation,
reason === 'account' ? 'account_required' : 'sources_required'
diff --git a/apps/sim/lib/slack-search/README.md b/apps/sim/lib/slack-search/README.md
deleted file mode 100644
index eba002f4b5e..00000000000
--- a/apps/sim/lib/slack-search/README.md
+++ /dev/null
@@ -1,278 +0,0 @@
-# Enterprise Search in Slack
-
-Organization admins configure this under **Settings → Sim Search in Slack**.
-Members DM the bot to use the existing organization Enterprise Search Assistant,
-including its search/read tools, model policy, source permissions, and billing.
-The orchestration is TypeScript application code; no saved workflow graph or
-separate agent framework is involved.
-
-## Setup
-
-1. The wizard generates the **Sim Search** app manifest with the default
- description, “Ask questions about your organization’s knowledge and get
- answers with sources.” Create the app in your Slack workspace using the
- prepared link or copyable JSON preview.
-2. Copy **Client ID**, **Client Secret**, and **Signing Secret** from Slack’s
- **Basic Information** page into the wizard.
-3. Choose **Install in Slack** and complete Slack OAuth. The bot token comes from
- OAuth; it is never entered manually.
-4. The callback rechecks the initiating admin’s current access, validates the
- app/workspace/bot identity and granted scopes, and atomically enables Search.
-
-Slack source setup and Settings → Sim Search in Slack open the same
-`components/integrations/slack-search-setup-wizard.tsx`. Source setup prompts
-**Install Sim Search first** when the organization has no registered app. Once
-installed, source setup only verifies member authorization against that app;
-it never asks for a second client ID or client secret. Members then connect
-their individual accounts through the existing source connection flow.
-
-Every installation uses one code-defined app configuration. The wizard has no
-feature switches and stores no per-app capabilities. The bot grants
-`assistant:write`, `chat:write`, `im:history`, `im:write`, `app_mentions:read`,
-`users:read`, and `users:read.email`. Agent View is enabled, with `message.im`,
-`app_mention`, and `agent_session_stopped` subscriptions.
-
-The same app supplies separate member OAuth grants for channel and DM indexing:
-`users:read`, `users:read.email`, and the read/history scopes for channels,
-private channels, DMs, and group DMs. Installing the bot does not authorize a
-member's personal history. Members connect through the existing source flow;
-connector settings choose which conversations to index. DM indexing is opt-in.
-Existing member scopes are preserved when regenerating the app manifest.
-Enterprise Grid deployment and token rotation remain disabled.
-
-Organization credential groups reference the registered `slack_app` instead of
-storing another copy of its client credentials. Setting up Search adopts an
-existing member app only when its app and workspace identities match. Reconnect
-reuses saved app credentials unless the admin supplies replacements.
-
-`NEXT_PUBLIC_APP_URL` must be the public HTTPS origin used consistently by both:
-
-- Events and interactivity: `/api/webhooks/slack`
-- Bot OAuth callback: `/api/knowledge/slack/oauth/callback`
-- Member app validation: `/api/credential-groups/slack-managed-users/callback`
-- Member enrollment: `/api/credential-groups/oauth/slack/callback`
-
-Redis is required for ten-minute, single-use OAuth state. The encrypted setup
-attempt is bound to the initiating user, browser session, organization, and
-expected installation revision. A different session cannot consume it. App
-secrets are never included in setup read responses.
-
-## Storage and deployment
-
-Apply `0332_slack_search` after the existing staging migration history. Local development can use `bun run
-db:push` from `packages/db`. Current staging schema requires PostgreSQL 15 or
-newer; PostgreSQL 17 with pgvector is suitable.
-
-The credential-to-app foreign key uses `NOT VALID` for the expand deployment,
-following the repository's migration-safety playbook. It enforces new writes;
-the added nullable column has no pre-existing non-null values.
-**contract-pending(after #7644 is fully deployed):** validate
-`credential_slack_app_id_slack_app_id_fk` in a separate migration.
-
-| Table | Purpose |
-| --- | --- |
-| `slack_app` | Slack app ID, custom/shared ownership, client ID, encrypted client and signing secrets, and configuration revision. |
-| `credential` | Organization-owned bot token with `workspace_id = NULL` and `slack_app_id` referencing the app configuration. |
-| `slack_search_installation` | Verified app/workspace/bot binding to an organization and credential, enabled state, and revisions. |
-| `copilot_chats` | Ordinary private Mothership chat with a unique external conversation key and Slack sender/root/Stop metadata. |
-| `slack_search_turn` | External conversation key, deduplicated event, FIFO ordinal, execution lease, status, and outcome. |
-| `outbox_event` | Durable wake-up delivery for pending Slack turns. |
-
-Slack threads use the existing `copilot_chats.id` UUID in history URLs:
-`/o/[organizationId]/chat/[chatId]`. `external_conversation_key` is a namespaced
-JSON tuple of installation ID, DM channel ID, and root Slack timestamp, with a
-unique database index. It is an external identity, not an authorization token.
-`external_conversation_metadata` stores the stable Slack user ID and Stop
-watermark; `user_id` owns the private chat. Email is resolved and checked against
-current verified membership, never used as the conversation key. No separate
-Slack thread table is needed. Forking a chat does not copy its external binding.
-
-The first durable event binds the Slack sender even before they have a Sim
-account; a chat is created only after member authorization. A changed Sim
-identity or deleted chat cannot silently rebind the same conversation. Event
-IDs are unique per installation, independently of the conversation key.
-Installation locks and database uniqueness prevent concurrent deliveries from
-creating duplicate chats or executing one turn twice.
-
-Slack chat titles use the same `requestChatTitle` backend and organization
-billing protocol as normal chats, based on the thread's first question, with
-` (Slack)` appended. Naming runs alongside the response, including source-setup
-replies, with a fifteen-second request deadline. Existing `Slack Search`
-placeholders are eligible on the next Slack message; generated and manually
-chosen titles are preserved. Saving rechecks current access and compares the
-original title so an in-flight request cannot overwrite a manual rename.
-Naming failures are logged separately from answer delivery and do not create a
-substitute title.
-
-The turn table remains execution bookkeeping, not a second transcript. Generic
-`async_jobs` currently does not provide the required per-conversation FIFO,
-installation concurrency cap, and durable execution lease. All chat messages
-continue to use the existing Mothership persistence and history paths.
-
-Only one enabled Search installation can own a Slack workspace. Reconnecting
-preserves the installation and credential IDs and requires the same Slack app
-and workspace. Disable, removal, and credential/configuration rotation invalidate
-pending authority. Removing Search leaves the reusable credential intact.
-
-**Register the existing platform Slack app before deploying unified ingress.**
-Use its verified app ID and existing deployment credentials:
-
-```sh
-cd apps/sim
-bun --env-file=.env scripts/register-platform-slack-app.ts A_VERIFIED_APP_ID
-```
-
-The script reads `SLACK_CLIENT_ID`, `SLACK_CLIENT_SECRET`, and
-`SLACK_SIGNING_SECRET`, encrypts the secrets, and registers the app as shared.
-It refuses to overwrite a custom app. Unknown app IDs fail authentication;
-ingress does not silently substitute environment signing secrets. Resolve any
-existing conflicting active workspace bindings before applying the unique index.
-
-## Request lifecycle
-
-1. Unified ingress uses the untrusted `api_app_id` only to select a candidate
- signing key. It verifies the raw-body Slack signature before resolving the
- app/workspace installation through an authorized application operation.
- URL verification is bounded and challenge-only; it cannot dispatch work.
-2. `dispatcher.ts` accepts human DMs, channel mentions, and native Stop events. Existing platform
- workflow dispatch remains in place. The old
- `/api/webhooks/slack/custom/[credentialId]` URLs are compatibility adapters
- using the same Search dispatcher and provider primitives.
-3. Intake atomically persists a deduplicated turn and outbox event. PostgreSQL
- installation locks coordinate workers: two active threads per installation,
- one active turn per thread, FIFO follow-ups, and at most twenty pending turns
- per thread. The three-minute execution deadline starts after a claim.
- Expired executions are marked failed and never replayed after an ambiguous
- external send. The shared outbox processor repairs missed wake-ups.
-4. `knowledge/application/slack-search/assistant.ts` resolves the sender’s email
- through that installation’s bot credential. It requires exactly one current,
- verified Sim member of the bound organization. Email never selects the
- organization. Existing managed identity conflicts are rejected.
-5. A short-lived member delegation authorizes the ordinary private organization
- Assistant conversation. The original DM timestamp becomes the thread root;
- replies reuse that conversation. Execution holds the existing chat stream
- lock and uses `buildCopilotRequestPayload` and
- `runHeadlessCopilotLifecycle` with the normal Assistant tools, environment
- projection, model configuration, permissions, and organization billing.
-6. Installation revision, credential version, current member authorization,
- execution lease, and chat lock are rechecked during execution and before
- delivery. Sim history uses the existing message persistence/finalization path
- and remains private to the acting member.
-
-A bot mention in a public or private channel starts a private DM thread for the
-sender. Each mention starts its own thread; subsequent DM replies use the same
-FIFO queue and private Sim conversation. The original channel, thread, and
-message timestamps are retained in the chat's Slack origin metadata. The bot
-posts no search results in the source channel. A failed or ambiguous DM creation
-is terminal and does not replay the Assistant run.
-
-Questions longer than 2,000 characters receive a private length notice without
-starting an Assistant run. DMs receive the notice in their original thread;
-channel mentions receive it as the root of the new private DM thread.
-
-## Member onboarding
-
-A sender without a matching verified Sim organization membership receives a
-**Get started with Sim** button in the original DM thread. The destination
-offers the existing signup and login flows and preserves the return path through
-authentication. Creating an account does not grant organization membership:
-existing invitations, SSO, email verification, and permission policies still
-apply. A wrong account or conflicting identity cannot see the original question
-or organization details on the return page.
-
-For an authorized member with no accessible indexed documents, the bot sends a
-**Connect sources** button without starting an Assistant run. The return page
-opens the organization's existing Integrations page directly, using the same
-source list and connection dialogs as normal visits. Slack adds only indexing,
-retry, and thread-return actions to its header. Shared documents that the member can already search do not
-require an additional personal connection. Readiness uses the normal document
-access predicate, including source approval and current source ACL evidence.
-
-Once indexing makes documents searchable, **Retry question in Slack** explicitly
-queues the original question in the same Slack thread. Opening the page never
-runs a question. Repeated clicks reuse one durable retry event; the transaction
-binds the current Sim user to the thread before a worker starts. Execution and
-delivery recheck current membership, installation revision, and credential
-version through the ordinary Assistant lifecycle. Successful member setup
-notices are also saved in that member's private Sim conversation.
-
-`slack-search/onboarding-state.ts` stores opaque navigation context in Redis for
-24 hours, keyed by a hash of a random token. It references the original durable
-turn, sender email, and Slack-provided thread permalink. The Slack button's link carries no
-email, bot credential, or organization ID and grants no authority. Every read
-and retry requires a session and re-resolves the current Slack identity. Expired
-links, disabled installations, identity changes, and ambiguous failed deliveries
-cannot replay the original run. No additional database tables are required.
-
-The application behavior lives in
-`knowledge/application/slack-search/onboarding.ts` and `source-status.ts`.
-`/slack-search/connect/[token]` handles account setup and redirects authorized
-members to `/o/[organizationId]/integrations?slack=[token]`;
-`/api/knowledge/slack/onboarding` and its `/retry` endpoint use the shared route
-builders and contracts.
-
-## Streaming and Stop
-
-`assistant-stream.ts` calls the same `chat.startStream`, `chat.appendStream`,
-and `chat.stopStream` provider primitives as Slack blocks. It batches public
-Assistant text, excludes reasoning and tool-scoped text, and projects secrets
-before delivery. Sim-only UI payloads such as suggested follow-up options are
-withheld even when their tags span multiple deltas. Text before and after a tool
-call is separated into paragraphs. Answers with active secret literals are held until complete
-so a secret split across deltas cannot leak.
-
-The shared citation evidence parser accepts successful retrieval results.
-Up to five source buttons use verified retrieval URLs; model-generated URLs and
-incomplete citation markup are not sent. Stream failures abort execution and
-record a failed outcome without switching delivery methods or replaying the run.
-A failed turn closes an established stream once with a generic error and saves
-that error in private Sim history. Cleanup has its own five-second signal and
-rechecks installation, membership, lease, and chat ownership after execution
-aborts. It never retries an ambiguous stop or guesses a stream ID after a failed
-start. Cleanup failures remain part of the recorded turn failure. Only a persisted
-native Stop event marks the response as stopped by the user.
-
-Slack’s native Stop cancels the active turn and pending follow-ups for the
-authenticated sender’s thread. A persisted timestamp watermark also rejects
-late deliveries from before Stop; a replayed Stop cannot cancel later messages.
-[Slack stops active streaming messages when the user clicks Stop](https://docs.slack.dev/ai/agent-sessions/#stopping-a-session).
-The handler cancels Sim work and clears Slack’s processing state; failure cleanup
-does not send an additional stop or error message for this native cancellation.
-
-## Verification
-
-Focused Vitest suites cover OAuth identity/replay/authorization, signature
-rejection and routing, reconnects, public streaming and citations, cancellation,
-and delivery failures. `knowledge/__integration__/slack-search-turns.integration.ts`
-exercises real PostgreSQL deduplication, competing claims, FIFO, concurrency and
-queue bounds, expired leases, and private conversation ownership. It creates and
-cleans only its own fixture rows; normal integration runs use the repository’s
-disposable-database harness.
-
-Live validation requires a reachable Assistant backend implementing
-`SIM_AGENT_API_URL` + `/api/mothership`, an HTTPS tunnel to this checkout, and an
-installed Slack app. Verify real streamed answers and source links, contextual
-and queued follow-ups, native Stop, matching private Sim history, and
-disable/reconnect behavior before declaring the live flow complete.
-
-### Manual validation
-
-Use a dedicated local server and one public HTTPS origin for all Slack callbacks.
-Install the generated app manifest through the shared wizard, then verify:
-
-- A bot DM streams an answer with source links and appears in the sender's
- private Sim history; a thread follow-up keeps the same conversation.
-- Channel mentions start private DM threads without posting knowledge in the
- source channel.
-- Queued follow-ups run FIFO, duplicate events do not execute twice, and Slack's
- native Stop control cancels the active turn and queued follow-ups.
-- Account onboarding and source setup return to the original Slack question.
-- Member OAuth uses the installed app; opt-in DM indexing respects each
- connected member's access.
-- Disable, reconnect, removed membership, and ambiguous delivery failures stop
- stale work without replaying a response.
-
-Live validation of the expanded member OAuth, mentions, DM indexing, and native
-Stop UI is still pending. Automated coverage does not replace those checks.
-Shared-app installation UX, channel-visible answers, attachment ingestion,
-interactive pagination/modals, and workflow branching remain future work.
diff --git a/apps/sim/lib/slack-search/manifest.test.ts b/apps/sim/lib/slack-search/manifest.test.ts
index c89305e5e65..6008d09d0e2 100644
--- a/apps/sim/lib/slack-search/manifest.test.ts
+++ b/apps/sim/lib/slack-search/manifest.test.ts
@@ -29,7 +29,12 @@ describe('Search app manifest', () => {
])
)
expect(manifest.settings.event_subscriptions.bot_events).toEqual(
- expect.arrayContaining(['message.im', 'app_mention', 'agent_session_stopped'])
+ expect.arrayContaining([
+ 'app_home_opened',
+ 'message.im',
+ 'app_mention',
+ 'agent_session_stopped',
+ ])
)
expect(manifest.settings.event_subscriptions.bot_events).not.toContain('message.channels')
expect(manifest.features.app_home.messages_tab_read_only_enabled).toBe(false)
@@ -63,6 +68,7 @@ describe('Search app manifest', () => {
'https://search-test.ngrok.app/api/webhooks/slack'
)
expect(manifest.settings.event_subscriptions.bot_events).toEqual([
+ 'app_home_opened',
'message.im',
'app_mention',
'agent_session_stopped',
diff --git a/apps/sim/lib/slack-search/manifest.ts b/apps/sim/lib/slack-search/manifest.ts
index 7d021bd33eb..2407534840c 100644
--- a/apps/sim/lib/slack-search/manifest.ts
+++ b/apps/sim/lib/slack-search/manifest.ts
@@ -52,7 +52,7 @@ export function createSlackSearchManifest(
settings: {
event_subscriptions: {
request_url: webhookUrl,
- bot_events: ['message.im', 'app_mention', 'agent_session_stopped'],
+ bot_events: ['app_home_opened', 'message.im', 'app_mention', 'agent_session_stopped'],
},
interactivity: { is_enabled: true, request_url: webhookUrl },
org_deploy_enabled: false,
diff --git a/apps/sim/lib/slack-search/queue.test.ts b/apps/sim/lib/slack-search/queue.test.ts
index 7d674b360d6..124b3c5ac52 100644
--- a/apps/sim/lib/slack-search/queue.test.ts
+++ b/apps/sim/lib/slack-search/queue.test.ts
@@ -1,8 +1,11 @@
/** @vitest-environment node */
import { beforeEach, describe, expect, it, vi } from 'vitest'
-const mocks = vi.hoisted(() => ({ enqueue: vi.fn(), run: vi.fn() }))
-vi.mock('@/lib/core/async-jobs', () => ({ getJobQueue: async () => ({ enqueue: mocks.enqueue }) }))
+const mocks = vi.hoisted(() => ({ enqueue: vi.fn(), run: vi.fn(), externalEnqueue: vi.fn() }))
+vi.mock('@/lib/core/async-jobs', () => ({
+ getInlineJobQueue: async () => ({ enqueue: mocks.enqueue }),
+ getJobQueue: async () => ({ enqueue: mocks.externalEnqueue }),
+}))
vi.mock('@/lib/slack-search/handlers/search-message', () => ({
handleSlackSearchMessage: mocks.run,
}))
@@ -32,11 +35,12 @@ describe('Slack Search queue', () => {
])
}
})
- it('supplies the same handler to the database runner', async () => {
+ it('runs in the app process even when the default queue has an external worker', async () => {
const signal = new AbortController().signal
await enqueueSlackSearch(job)
await mocks.enqueue.mock.calls[0][2].runner(job, signal)
expect(mocks.run).toHaveBeenCalledWith(job, signal)
+ expect(mocks.externalEnqueue).not.toHaveBeenCalled()
})
it('propagates enqueue failures so ingress can request a retry', async () => {
mocks.enqueue.mockRejectedValueOnce(new Error('unavailable'))
diff --git a/apps/sim/lib/slack-search/queue.ts b/apps/sim/lib/slack-search/queue.ts
index cafb95d408d..d50e42b1f0b 100644
--- a/apps/sim/lib/slack-search/queue.ts
+++ b/apps/sim/lib/slack-search/queue.ts
@@ -1,13 +1,13 @@
import { generateId } from '@sim/utils/id'
-import { getJobQueue } from '@/lib/core/async-jobs'
+import { getInlineJobQueue } from '@/lib/core/async-jobs'
import {
SLACK_SEARCH_CONCURRENCY,
SLACK_SEARCH_MAX_DURATION_SECONDS,
} from '@/lib/slack-search/constants'
-/** Wakes a durable turn; a repeated wake cannot repeat a claimed execution. */
+/** Runs durable turns in the app process; repeated wakes cannot repeat a claimed execution. */
export async function enqueueSlackSearch(input: { turnId: string; installationId: string }) {
- return (await getJobQueue()).enqueue(
+ return (await getInlineJobQueue()).enqueue(
'slack-search',
{ turnId: input.turnId },
{
diff --git a/apps/sim/lib/slack-search/types.test.ts b/apps/sim/lib/slack-search/types.test.ts
index 019bd541144..d222a32165a 100644
--- a/apps/sim/lib/slack-search/types.test.ts
+++ b/apps/sim/lib/slack-search/types.test.ts
@@ -67,6 +67,7 @@ describe('Slack Search message dispatch', () => {
})
})
it.each([
+ { type: 'app_home_opened', tab: 'messages' },
{ channel_type: 'channel' },
{ channel_type: 'mpim' },
{ bot_id: 'B1' },