diff --git a/apps/docs/components/ui/heading.tsx b/apps/docs/components/ui/heading.tsx index cd259daa121..5de3630ee35 100644 --- a/apps/docs/components/ui/heading.tsx +++ b/apps/docs/components/ui/heading.tsx @@ -38,7 +38,13 @@ export function Heading({ as, className, ...props }: HeadingProps) { } return ( - + {props.children} diff --git a/apps/docs/content/docs/search/ask-questions.mdx b/apps/docs/content/docs/search/ask-questions.mdx index 8091eff3fb6..2bbb9f35e5f 100644 --- a/apps/docs/content/docs/search/ask-questions.mdx +++ b/apps/docs/content/docs/search/ask-questions.mdx @@ -127,19 +127,19 @@ Copy the reviewed draft into another app, or ask Assistant to create it using yo When integration actions are enabled, Assistant can answer questions and take supported actions in the same conversation. For example, ask it to draft an email in Gmail or create a Jira issue using your own connected account. -Reads run without a prompt by default. Writes wait for review by default. Open **Review** to see the app, account, destination, and proposed content. Edit the supported fields, then select **Allow** or **Cancel** for each action. For several proposals, **Allow N shown** approves the actions currently displayed in the review. +Reads run without a prompt by default. Writes wait for review by default. Each proposal appears inline in the conversation with its app, account, destination, and proposed content. Edit the supported fields, then select **Allow** or **Cancel** for each action. After several proposals, **Allow N shown** or **Cancel N shown** applies your choice to that displayed batch. You can approve individual actions first, then cancel the remaining batch. Assistant action review showing the connected Jira account, exact site and project, editable issue summary and description, and Allow or Cancel controls Each decision applies to that proposal. Changing its account or destination requires a new proposal. An action can still fail if your account is disconnected or an administrator changes access before execution. If the provider outcome is unknown, check the original app before asking Assistant to repeat a write. -Configure persistent **Allow**, **Ask**, or **Block** preferences under **Integrations → Tool permissions** for your account. See [tool permissions](/search/connect-your-account#tool-permissions). +Configure persistent **Allow**, **Ask**, or **Block** preferences under **Integrations → ⋯ → Tool permissions** for your account. See [tool permissions](/search/connect-your-account#tool-permissions). ## Start from a document diff --git a/apps/docs/content/docs/search/coda.mdx b/apps/docs/content/docs/search/coda.mdx index 4cd10b17d14..1241479201a 100644 --- a/apps/docs/content/docs/search/coda.mdx +++ b/apps/docs/content/docs/search/coda.mdx @@ -8,7 +8,7 @@ Connect Coda to search page and table-row content. **Member accounts** and **Ser ## Connect 1. An admin adds **Coda** in **Settings → Sources** and selects **Member accounts**, or configures the service source below. -2. Each teammate opens **Integrations → Coda → Connect** and authorizes their account. +2. Each teammate opens **Integrations → Coda → ⋯ → Connect** and authorizes their account. 3. Search text in a page or row that account can access, then read a result. The managed Coda MCP provider must be configured before **Connect** is available. Members authorize that provider rather than choosing an arbitrary server URL. Coda may use [Superhuman sign-in](https://help.coda.io/en/articles/5473507-reset-your-password). diff --git a/apps/docs/content/docs/search/confluence.mdx b/apps/docs/content/docs/search/confluence.mdx index 0ffad5250f8..b9293a08dea 100644 --- a/apps/docs/content/docs/search/confluence.mdx +++ b/apps/docs/content/docs/search/confluence.mdx @@ -8,7 +8,7 @@ Search Confluence Cloud with your own account. **Member accounts** uses your pro ## Connect member accounts 1. An admin adds **Confluence** in **Settings → Sources** and selects **Member accounts**, or configures the service source below. -2. Each teammate opens **Integrations → Confluence → Connect** and authorizes their Atlassian account. +2. Each teammate opens **Integrations → Confluence → ⋯ → Connect** and authorizes their Atlassian account. 3. Search a page or blog post that account can access, then read a result. Member mode has no admin space or label filters. The default search covers pages and blog posts; ask Sim to use Confluence Query Language (CQL) to narrow a query by content type or space. Site access comes from your connected account. diff --git a/apps/docs/content/docs/search/connect-your-account.mdx b/apps/docs/content/docs/search/connect-your-account.mdx index 2a7f250922a..181194b1972 100644 --- a/apps/docs/content/docs/search/connect-your-account.mdx +++ b/apps/docs/content/docs/search/connect-your-account.mdx @@ -10,20 +10,19 @@ After your admin adds a source in **Settings → Sources**, connect your own acc ## Connect and verify 1. Join the Sim organization through its invitation or SSO. Use the verified email required by the provider connection. -2. Open **Integrations**, find the provider, and select **Connect**. +2. Open **Integrations**, find the provider, and choose **Connect** from its **⋯** menu. 3. Complete authorization in the new tab. If the popup is blocked, allow it and select **Connect** again. 4. Return to **Search** or **Search → Assistant** and search for content your account can open. Read a result and follow its source link. Confluence in Integrations with the Connect button before an account has been authorized -Once an account is connected, **Add account** lets you authorize another account for that provider. +If your organization makes another connection available, choose **Add account** from the same menu. The connection is usable when authorization completes; no initial indexing run is required. If a provider is missing, ask an admin to add it in **Sources**. `Not configured` means its app or deployment setup needs an admin's attention. @@ -47,7 +46,7 @@ Creating Jira sprints requires the Jira sprint permission. If your account was c ## Tool permissions -When Assistant integration actions are enabled, open **Integrations** and select **Tool permissions** beside your connected account. Set a preference for each operation or use the **Read-only tools** and **Write tools** group controls: +When Assistant integration actions are enabled, open the app’s **⋯** menu in **Integrations** and select **Tool permissions** for your connected account. This option appears when the app has supported Assistant tools available to you. Set a preference for each operation or use the **Read-only tools** and **Write tools** group controls: | Preference | Behavior | | --- | --- | @@ -64,16 +63,16 @@ These preferences belong to your account. They cannot grant access to another pe alt="Connected account settings with Read-only tools and Write tools groups and per-tool Allow, Ask, and Block preferences" width={1440} height={1000} - variant="result" + variant="overview" /> Apps used only for actions appear under **Assistant apps**. An admin makes the app available in organization source settings, then each member connects their own account in Integrations. ## Reconnect or disconnect -Use **Reconnect** when a grant expires, is revoked, or needs more scopes. A temporary provider outage does not necessarily require reconnecting. +Choose **Reconnect** from the app’s **⋯** menu when a grant expires, is revoked, or needs more scopes. A temporary provider outage does not necessarily require reconnecting. -Disconnecting stops future use of the grant and can affect workflows sharing the account. Review the confirmation before removing it. Disconnecting does not remove your Sim membership. +Choose **Disconnect** from the same menu to stop future use of the grant. This can affect workflows sharing the account. Review the confirmation before removing it. Disconnecting does not remove your Sim membership. For an account-connection request, sign in as the verified Sim user associated with the requested email, then authorize the provider. The request does not invite you into the organization; membership and Search access are still required. diff --git a/apps/docs/content/docs/search/fireflies.mdx b/apps/docs/content/docs/search/fireflies.mdx index 8b3bc83b0c5..0b3229b89d3 100644 --- a/apps/docs/content/docs/search/fireflies.mdx +++ b/apps/docs/content/docs/search/fireflies.mdx @@ -8,7 +8,7 @@ Fireflies Search uses **Member accounts** and the official managed MCP connectio ## Connect 1. An admin adds **Fireflies** in **Settings → Sources**, using **Member accounts**. -2. Each teammate opens **Integrations → Fireflies → Connect** and authorizes their account. +2. Each teammate opens **Integrations → Fireflies → ⋯ → Connect** and authorizes their account. 3. Search a phrase such as `security approval` or `customer onboarding`, then read a result. The managed provider must be configured before **Connect** is available. It uses `https://api.fireflies.ai/mcp`. The API key for the Fireflies knowledge-base connector is a separate ingestion connection and cannot authorize member Search. diff --git a/apps/docs/content/docs/search/gmail.mdx b/apps/docs/content/docs/search/gmail.mdx index b65b3b4dd33..c4a6bbc2e49 100644 --- a/apps/docs/content/docs/search/gmail.mdx +++ b/apps/docs/content/docs/search/gmail.mdx @@ -7,7 +7,7 @@ Gmail Search finds matching messages in your connected mailbox. **Member account ## Connect member accounts -An admin adds **Gmail** in **Settings → Sources**, chooses **Member accounts**, and saves. Members select **Integrations → Gmail → Connect**, then search for a known message and read it. +An admin adds **Gmail** in **Settings → Sources**, chooses **Member accounts**, and saves. Members select **Integrations → Gmail → ⋯ → Connect**, then search for a known message and read it. Member mode has no admin label, date, category, or mailbox filters. Search and document-read requests do not send or modify messages. When integration actions are enabled, **Search → Assistant** can create drafts or send email using your connected account, subject to tool permissions and organization policy. Writes ask for [review](/search/ask-questions#review-actions) by default. diff --git a/apps/docs/content/docs/search/google-drive.mdx b/apps/docs/content/docs/search/google-drive.mdx index 7ee7c62deed..0ae24194476 100644 --- a/apps/docs/content/docs/search/google-drive.mdx +++ b/apps/docs/content/docs/search/google-drive.mdx @@ -8,7 +8,7 @@ Google Drive Search finds files your account can access, including Google Docs, ## Connect and search 1. An admin adds **Google Drive** in **Settings → Sources** and chooses **Member accounts**, or configures the service source below. -2. Each teammate selects **Integrations → Google Drive → Connect** and authorizes their account. +2. Each teammate selects **Integrations → Google Drive → ⋯ → Connect** and authorizes their account. 3. Search a known file, read the result, and follow its source link. Member mode has no organization folder, file-type, or user selection. Native Drive queries can narrow a search. For example, `name contains 'Roadmap' and 'person@example.com' in owners` finds candidate files by name and owner. diff --git a/apps/docs/content/docs/search/google-meet.mdx b/apps/docs/content/docs/search/google-meet.mdx index 190c8bb9355..7ac481e6750 100644 --- a/apps/docs/content/docs/search/google-meet.mdx +++ b/apps/docs/content/docs/search/google-meet.mdx @@ -8,7 +8,7 @@ Google Meet Search finds recent finalized transcripts and generated-note links. ## Connect 1. An admin adds **Google Meet → Member accounts** in **Settings → Sources**. -2. In **Integrations**, select **Connect** beside Google Meet and authorize your Google account. Your Workspace administrator may need to approve the app. +2. In **Integrations**, open the Google Meet **⋯** menu and select **Connect** and authorize your Google account. Your Workspace administrator may need to approve the app. 3. Return to Sim, confirm the account appears, then search a phrase from a recent meeting and read the result. No service account is required. Search only reads conference records and artifacts, using the connection's `meetings.space.readonly` scope. Its `meetings.space.created` scope supports workflow actions. diff --git a/apps/docs/content/docs/search/granola.mdx b/apps/docs/content/docs/search/granola.mdx index af22d0515bf..302fbcbda49 100644 --- a/apps/docs/content/docs/search/granola.mdx +++ b/apps/docs/content/docs/search/granola.mdx @@ -8,7 +8,7 @@ Granola Search uses **Member accounts** through the official MCP service. It sea ## Connect 1. An admin adds **Granola → Member accounts** in **Settings → Sources**. -2. Each teammate opens **Integrations → Granola → Connect** and authorizes their account. +2. Each teammate opens **Integrations → Granola → ⋯ → Connect** and authorizes their account. 3. Ask a focused question such as `What did we decide about the enterprise launch?`, then read a result. Sim uses `https://mcp.granola.ai/mcp` with each member's managed OAuth grant. A shared API key cannot replace this connection. If meetings are missing, check the account and switch to the intended workspace in Granola; MCP follows the active workspace. diff --git a/apps/docs/content/docs/search/hubspot.mdx b/apps/docs/content/docs/search/hubspot.mdx index 26377ca6aec..8a897c06b58 100644 --- a/apps/docs/content/docs/search/hubspot.mdx +++ b/apps/docs/content/docs/search/hubspot.mdx @@ -7,7 +7,7 @@ HubSpot Search finds contacts, companies, deals, and tickets through the officia ## Connect -An admin adds **HubSpot** in **Settings → Sources**. Each person then selects **Integrations → HubSpot → Connect** and signs in. Grant read access to contacts, companies, deals, and tickets; write access is not needed. Search a known name, email, or domain, then read the record. +An admin adds **HubSpot** in **Settings → Sources**. Each person then selects **Integrations → HubSpot → ⋯ → Connect** and signs in. Grant read access to contacts, companies, deals, and tickets; write access is not needed. Search a known name, email, or domain, then read the record. ## Search and read records diff --git a/apps/docs/content/docs/search/jira.mdx b/apps/docs/content/docs/search/jira.mdx index e94bcf31d48..4444935e02e 100644 --- a/apps/docs/content/docs/search/jira.mdx +++ b/apps/docs/content/docs/search/jira.mdx @@ -8,7 +8,7 @@ Jira Search finds Cloud issues with your personal OAuth grant. It uses **Member ## Connect 1. An admin adds **Jira** in **Settings → Sources → Add source**. -2. Each teammate selects **Integrations → Jira → Connect** and authorizes their Atlassian account. +2. Each teammate selects **Integrations → Jira → ⋯ → Connect** and authorizes their Atlassian account. 3. Search a known issue, or ask Sim about it. Read the result and follow its Jira link. No project setup or indexing is needed after authorization. Sim Cloud supplies the OAuth app where available; self-hosted deployments configure their app below. diff --git a/apps/docs/content/docs/search/quickstart.mdx b/apps/docs/content/docs/search/quickstart.mdx index de861a79397..d5803ab06f9 100644 --- a/apps/docs/content/docs/search/quickstart.mdx +++ b/apps/docs/content/docs/search/quickstart.mdx @@ -7,7 +7,7 @@ Use this walkthrough to go from a question to a useful answer. Choose a topic yo ## 1. Connect your apps -Open your organization's Sim home and select **Integrations** in the sidebar. Find an app you use and select **Connect**. Complete authorization with the work account that can open the content you need. +Open your organization's Sim home and select **Integrations** in the sidebar. Find an app you use and choose **Connect** from its **⋯** menu. Complete authorization with the work account that can open the content you need. Your admin must enable the source first. If you don't see it, ask them to check **Settings → Sources**. Some organization-managed sources have a different connection process; see [Connect your account](/search/connect-your-account). diff --git a/apps/docs/content/docs/search/slack.mdx b/apps/docs/content/docs/search/slack.mdx index 2edb75ca9e7..d8776c0b0b7 100644 --- a/apps/docs/content/docs/search/slack.mdx +++ b/apps/docs/content/docs/search/slack.mdx @@ -11,7 +11,7 @@ Slack Search uses **Member accounts** and the member's real-time user token. The 1. An admin adds **Slack** in **Settings → Sources**. 2. Complete the organization's app setup if needed. Use the official Sim Search app when available, or the deployment's custom app. -3. Each member selects **Integrations → Slack → Connect** and authorizes their account. +3. Each member selects **Integrations → Slack → ⋯ → Connect** and authorizes their account. 4. Search a known message, read it, and follow its Slack link. No channel setup or initial sync is needed. Members need a Sim invitation or SSO membership separately. Provider app/workspace approval policies still apply. @@ -30,7 +30,7 @@ An admin opens **Settings → Sim Search in Slack** and installs or connects the - DM **Sim Search**, or mention it in a channel it has joined. - Where the installed app exposes slash commands, use `/query [question]` to start a private DM thread. Channel invocations keep personalized answers and account details in DMs. -- Use `/connect [provider]` where available, or **Home → Connect sources**, to open personal Integrations. Select **Connect** there to start OAuth. +- Use `/connect [provider]` where available, or **Home → Connect sources**, to open personal Integrations. Choose **Connect** from the app’s **⋯** menu to start OAuth. - Follow source links. Slack's **Stop** control cancels an active answer and queued follow-ups. Removing the bot binding is separate from disconnecting your Slack source. diff --git a/apps/docs/content/docs/search/zoom.mdx b/apps/docs/content/docs/search/zoom.mdx index 19611511531..bde4fc30578 100644 --- a/apps/docs/content/docs/search/zoom.mdx +++ b/apps/docs/content/docs/search/zoom.mdx @@ -10,7 +10,7 @@ When Zoom Search is enabled for your organization, connect your own account to f Zoom Search requires deployment enablement and organization access before setup or retrieval is available. Ask your admin about availability if Zoom is missing. 1. An admin adds **Zoom → Member accounts** in **Settings → Sources**. -2. Each person selects **Integrations → Zoom → Connect**, signs in, and authorizes the read permissions. Zoom account-admin approval may be needed. +2. Each person selects **Integrations → Zoom → ⋯ → Connect**, signs in, and authorizes the read permissions. Zoom account-admin approval may be needed. 3. Return to Sim, confirm the account appears, and search a known meeting. Read the result and follow its source link. A workflow Zoom connection does not authorize Search. Its dedicated app registration prevents Search reauthorization from replacing or narrowing a workflow app's grant. diff --git a/apps/docs/public/static/search/assistant-action-review.png b/apps/docs/public/static/search/assistant-action-review.png index 70924474a8a..6dbbddc6454 100644 Binary files a/apps/docs/public/static/search/assistant-action-review.png and b/apps/docs/public/static/search/assistant-action-review.png differ diff --git a/apps/docs/public/static/search/assistant-tool-permissions.png b/apps/docs/public/static/search/assistant-tool-permissions.png index 8b823a51fd1..5d8aac853ba 100644 Binary files a/apps/docs/public/static/search/assistant-tool-permissions.png and b/apps/docs/public/static/search/assistant-tool-permissions.png differ diff --git a/apps/docs/public/static/search/connect-account-menu.png b/apps/docs/public/static/search/connect-account-menu.png new file mode 100644 index 00000000000..4147656f006 Binary files /dev/null and b/apps/docs/public/static/search/connect-account-menu.png differ diff --git a/apps/sim/app/o/[organizationId]/components/organization-conversation.tsx b/apps/sim/app/o/[organizationId]/components/organization-conversation.tsx index 77f1b51cad9..50673e47748 100644 --- a/apps/sim/app/o/[organizationId]/components/organization-conversation.tsx +++ b/apps/sim/app/o/[organizationId]/components/organization-conversation.tsx @@ -5,10 +5,7 @@ import { toast } from '@sim/emcn' import { toRecord } from '@sim/utils/object' import { useQueryClient } from '@tanstack/react-query' import { useQueryStates } from 'nuqs' -import { - AssistantActionReviewPanel, - AssistantActionReviewProvider, -} from '@/components/assistant-actions' +import { AssistantActionReviewProvider } from '@/components/assistant-actions' import { requestJson } from '@/lib/api/client/request' import type { WorkspaceSearchFilters } from '@/lib/api/contracts/knowledge' import { getWorkspaceHostContextContract } from '@/lib/api/contracts/workspaces' @@ -368,6 +365,7 @@ function OrganizationConversationContent({ @@ -398,12 +396,7 @@ function OrganizationConversationContent({ onCancelQueueEdit={chat.cancelQueueEdit} userId={session?.user?.id} chatId={chat.resolvedChatId} - composer={ - <> - - {composer} - - } + composer={composer} onWorkspaceResourceSelect={requestMode !== 'assistant' ? selectResource : undefined} initialScrollBlocked={panel.initialScrollBlocked} /> diff --git a/apps/sim/app/o/[organizationId]/integrations/account-actions-menu.tsx b/apps/sim/app/o/[organizationId]/integrations/account-actions-menu.tsx new file mode 100644 index 00000000000..7defbf567e0 --- /dev/null +++ b/apps/sim/app/o/[organizationId]/integrations/account-actions-menu.tsx @@ -0,0 +1,129 @@ +'use client' + +import { useState } from 'react' +import { + Chip, + ChipConfirmModal, + ChipModalError, + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuItemLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@sim/emcn' +import { MoreHorizontal } from '@sim/emcn/icons' +import Link from 'next/link' +import type { ViewerSearchSourceAccount } from '@/lib/api/contracts/knowledge/connectors' +import { organizationRoutes } from '@/lib/navigation/paths' +import { useDisconnectPersonalOrganizationAccount } from '@/hooks/queries/organization-accounts' + +interface AccountActionsMenuProps { + organizationId: string + integrationName: string + accounts: ViewerSearchSourceAccount[] + onConnect?: () => void + onReconnect: (credentialId: string) => void + connectionPending: boolean + connecting: boolean + connectionReady: boolean + permissionAccountIds: readonly string[] + reconnectAccountIds: readonly string[] +} + +export function AccountActionsMenu({ + organizationId, + integrationName, + accounts, + onConnect, + onReconnect, + connectionPending, + connecting, + connectionReady, + permissionAccountIds, + reconnectAccountIds, +}: AccountActionsMenuProps) { + const disconnect = useDisconnectPersonalOrganizationAccount(organizationId) + const [selectedId, setSelectedId] = useState(null) + const selected = accounts.find((account) => account.credentialId === selectedId) + const permissionAccounts = accounts.filter((account) => + permissionAccountIds.includes(account.credentialId) + ) + const hasAccountActions = accounts.length > 0 + const pending = connectionPending || disconnect.isPending + if (!accounts.length && !onConnect) return null + + return ( + <> + + + + + + {permissionAccounts.map((account) => ( + + + 1 ? ` · ${account.displayName}` : ''}`} + /> + + + ))} + {accounts.map((account) => ( + onReconnect(account.credentialId)} + > + Reconnect{accounts.length > 1 ? ` · ${account.displayName}` : ''} + + ))} + {hasAccountActions && onConnect && } + {onConnect && ( + + {connecting ? 'Connecting' : accounts.length ? 'Add account' : 'Connect'} + + )} + {hasAccountActions && } + {accounts.map((account) => ( + { + disconnect.reset() + setSelectedId(account.credentialId) + }} + > + Disconnect{accounts.length > 1 ? ` · ${account.displayName}` : ''} + + ))} + + + { + if (!open && !disconnect.isPending) setSelectedId(null) + }} + title={`Disconnect ${integrationName}`} + text={`Disconnect ${selected?.displayName ?? integrationName} from all ${integrationName} connections in this organization. Workflows using this account will also lose access. You can reconnect later.`} + confirm={{ + label: 'Disconnect', + pendingLabel: 'Disconnecting', + pending: disconnect.isPending, + disabled: disconnect.isPending, + onClick: () => { + if (!selected || disconnect.isPending) return + disconnect.mutate(selected.credentialId, { onSuccess: () => setSelectedId(null) }) + }, + }} + > + {disconnect.error?.message} + + + ) +} diff --git a/apps/sim/app/o/[organizationId]/integrations/account-permissions-link.tsx b/apps/sim/app/o/[organizationId]/integrations/account-permissions-link.tsx deleted file mode 100644 index 97adc578b69..00000000000 --- a/apps/sim/app/o/[organizationId]/integrations/account-permissions-link.tsx +++ /dev/null @@ -1,44 +0,0 @@ -import { - Chip, - ChipLink, - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuTrigger, -} from '@sim/emcn' -import { ChevronDown } from '@sim/emcn/icons' -import Link from 'next/link' -import { organizationRoutes } from '@/lib/navigation/paths' - -interface AccountPermissionsLinkProps { - organizationId: string - accounts: readonly { credentialId: string; displayName: string }[] -} - -export function AccountPermissionsLink({ organizationId, accounts }: AccountPermissionsLinkProps) { - const routes = organizationRoutes(organizationId) - const [account] = accounts - if (!account) return null - if (accounts.length === 1) - return ( - - Tool permissions - - ) - return ( - - - Tool permissions - - - {accounts.map((entry) => ( - - - {entry.displayName} - - - ))} - - - ) -} diff --git a/apps/sim/app/o/[organizationId]/integrations/accounts/[credentialId]/tool-permissions.tsx b/apps/sim/app/o/[organizationId]/integrations/accounts/[credentialId]/tool-permissions.tsx index 014ec46f66a..792bc279324 100644 --- a/apps/sim/app/o/[organizationId]/integrations/accounts/[credentialId]/tool-permissions.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/accounts/[credentialId]/tool-permissions.tsx @@ -1,23 +1,23 @@ 'use client' -import { Chip, ChipLink, ChipSelect, ChipSwitch, Tooltip, toast } from '@sim/emcn' -import { ArrowLeft, Ban, Check, Hand } from '@sim/emcn/icons' +import { Chip, ChipInput, ChipLink, ChipSelect, ChipSwitch, Tooltip, toast } from '@sim/emcn' +import { ArrowLeft, Search } from '@sim/emcn/icons' import type { AssistantToolPermissionsResponse, UpdateAssistantToolPermissionsBody, } from '@/lib/api/contracts/assistant-actions' import { resolveCredentialDisplay } from '@/lib/integrations/credential-display' import { organizationRoutes } from '@/lib/navigation/paths' +import { useOrganizationPageFilters } from '@/app/o/[organizationId]/components/organization-page' import { - OrganizationPage, - useOrganizationPageFilters, -} from '@/app/o/[organizationId]/components/organization-page' + CredentialDetailHeading, + CredentialDetailLayout, +} from '@/app/workspace/[workspaceId]/components/credential-detail' import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' import { SettingsEmptyState, SettingsQueryErrorState, } from '@/app/workspace/[workspaceId]/settings/components/settings-empty-state' -import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' import { SettingsSection } from '@/app/workspace/[workspaceId]/settings/components/settings-section/settings-section' import { useAssistantToolPermissions, @@ -25,9 +25,9 @@ import { } from '@/hooks/queries/assistant-actions' const POLICY_OPTIONS = [ - { value: 'allow', label: 'Allow', icon: Check }, - { value: 'ask', label: 'Ask', icon: Hand }, - { value: 'block', label: 'Block', icon: Ban }, + { value: 'allow', label: 'Allow' }, + { value: 'ask', label: 'Ask' }, + { value: 'block', label: 'Block' }, ] as const const TOOL_GROUPS = [ { effect: 'read', label: 'Read-only tools' }, @@ -45,7 +45,7 @@ export function AccountToolPermissions({ }: AccountToolPermissionsProps) { const query = useAssistantToolPermissions(organizationId, credentialId) const update = useUpdateAssistantToolPermissions(organizationId, credentialId) - const { search } = useOrganizationPageFilters() + const { search, setSearch } = useOrganizationPageFilters() const data = query.data const display = data ? resolveCredentialDisplay({ ...data.account, type: 'managed_oauth' }) @@ -59,16 +59,9 @@ export function AccountToolPermissions({ } return ( - + Integrations } @@ -86,20 +79,15 @@ export function AccountToolPermissions({ ) : (
- ) : undefined } /> -

- Allow runs automatically. Ask pauses for your review. Block prevents use. -

{!data.canAutoApprove && (

Your organization requires approval before tools run. @@ -112,6 +100,15 @@ export function AccountToolPermissions({

)}
+ {data.tools.length > 0 && ( + setSearch(event.target.value)} + placeholder='Search tools' + aria-label='Search tools' + /> + )} {TOOL_GROUPS.map((group) => ( )} - + ) } diff --git a/apps/sim/app/o/[organizationId]/integrations/assistant-account-integrations.tsx b/apps/sim/app/o/[organizationId]/integrations/assistant-account-integrations.tsx index a1185a7afed..eb976ef4196 100644 --- a/apps/sim/app/o/[organizationId]/integrations/assistant-account-integrations.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/assistant-account-integrations.tsx @@ -1,6 +1,6 @@ 'use client' -import { Chip, ChipSelect, toast } from '@sim/emcn' +import { ChipSelect, toast } from '@sim/emcn' import type { SearchIntegrationStatus } from '@/lib/api/contracts/knowledge/search-integrations' import type { OrganizationAccountsSettings } from '@/lib/api/contracts/organization-accounts' import { assistantSearchSourceForProvider } from '@/lib/assistant-actions/providers' @@ -8,8 +8,7 @@ import { getCredentialGroupProviderId, getCredentialGroupProviderService, } from '@/lib/credential-groups/providers' -import { AccountPermissionsLink } from '@/app/o/[organizationId]/integrations/account-permissions-link' -import { DisconnectAccountMenu } from '@/app/o/[organizationId]/integrations/disconnect-account-menu' +import { AccountActionsMenu } from '@/app/o/[organizationId]/integrations/account-actions-menu' import { assistantAccountOptions } from '@/app/o/[organizationId]/integrations/utils' import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' import { SettingsResourceRow } from '@/app/workspace/[workspaceId]/settings/components/settings-resource-row' @@ -36,9 +35,9 @@ export function AssistantAccountIntegrations({ policies, admin = false, }: AssistantAccountIntegrationsProps) { - const ensure = useEnsureOrganizationAccounts() const connect = useConnectOrganizationAccount() const reconnect = useReconnectPersonalOrganizationAccount() + const ensure = useEnsureOrganizationAccounts() const onError = (error: Error) => toast.error(error.message) const group = data.credentialGroup const { providers, visible } = assistantAccountOptions(data, search) @@ -83,10 +82,6 @@ export function AssistantAccountIntegrations({ ) : undefined } > -

- Connect your own account to use these apps in Assistant. Write actions ask for review by - default. -

{visible.map((option) => { const service = getCredentialGroupProviderService(option.provider) @@ -120,33 +115,30 @@ export function AssistantAccountIntegrations({ : 'Connections unavailable' } trailing={ -
- - - {accounts - .filter((account) => account.status === 'needs_reauth') - .map((account) => ( - reconnect.mutate(account.credentialId, { onError })} - > - Reconnect{accounts.length > 1 ? ` ${account.displayName}` : ''} - - ))} - - connect.mutate({ organizationId, optionId: option.id }, { onError }) - } - > - {accounts.length ? 'Add account' : 'Connect'} - -
+ connect.mutate({ organizationId, optionId: option.id }, { onError }) + : undefined + } + onReconnect={(credentialId) => reconnect.mutate(credentialId, { onError })} + connectionPending={connect.isPending || reconnect.isPending} + connecting={ + connect.isPending && + 'optionId' in connect.variables && + connect.variables.optionId === option.id + } + connectionReady={ready} + reconnectAccountIds={ready ? accounts.map((account) => account.credentialId) : []} + permissionAccountIds={ + data.assistantProviders.includes(option.provider) + ? accounts.map((account) => account.credentialId) + : [] + } + /> } /> ) diff --git a/apps/sim/app/o/[organizationId]/integrations/disconnect-account-menu.tsx b/apps/sim/app/o/[organizationId]/integrations/disconnect-account-menu.tsx deleted file mode 100644 index 09ab845a6fb..00000000000 --- a/apps/sim/app/o/[organizationId]/integrations/disconnect-account-menu.tsx +++ /dev/null @@ -1,75 +0,0 @@ -'use client' - -import { useState } from 'react' -import { ChipConfirmModal, ChipModalError } from '@sim/emcn' -import type { ViewerSearchSourceAccount } from '@/lib/api/contracts/knowledge/connectors' -import { - type RowAction, - RowActionsMenu, -} from '@/app/workspace/[workspaceId]/settings/components/row-actions-menu' -import { useDisconnectPersonalOrganizationAccount } from '@/hooks/queries/organization-accounts' - -interface DisconnectAccountMenuProps { - organizationId: string - integrationName: string - accounts: ViewerSearchSourceAccount[] - accountLabels?: ReadonlyMap - actions?: RowAction[] -} - -/** Disconnect is independent of provider availability, reconnect state, and indexing activity. */ -export function DisconnectAccountMenu({ - organizationId, - integrationName, - accounts, - accountLabels, - actions = [], -}: DisconnectAccountMenuProps) { - const disconnect = useDisconnectPersonalOrganizationAccount(organizationId) - const [selectedId, setSelectedId] = useState(null) - const selected = accounts.find((account) => account.credentialId === selectedId) - const accountLabel = (account: ViewerSearchSourceAccount) => - accountLabels?.get(account.credentialId) ?? account.displayName - if (!accounts.length && !actions.length) return null - - return ( - <> - ({ - label: accounts.length === 1 ? 'Disconnect' : `Disconnect ${accountLabel(account)}`, - tooltip: accounts.length > 1 ? `Disconnect ${accountLabel(account)}` : undefined, - destructive: true, - disabled: disconnect.isPending, - onSelect: () => { - disconnect.reset() - setSelectedId(account.credentialId) - }, - })), - ]} - /> - { - if (!open && !disconnect.isPending) setSelectedId(null) - }} - title={`Disconnect ${integrationName}`} - text={`Disconnect ${selected ? accountLabel(selected) : integrationName} from all ${integrationName} connections in this organization. Workflows using this account will also lose access. You can reconnect later.`} - confirm={{ - label: 'Disconnect', - pendingLabel: 'Disconnecting', - pending: disconnect.isPending, - disabled: disconnect.isPending, - onClick: () => { - if (!selected || disconnect.isPending) return - disconnect.mutate(selected.credentialId, { onSuccess: () => setSelectedId(null) }) - }, - }} - > - {disconnect.error?.message} - - - ) -} diff --git a/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.test.tsx b/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.test.tsx index 33f5cd5f74f..89f76f6ebaa 100644 --- a/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.test.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.test.tsx @@ -25,11 +25,6 @@ vi.mock('@/hooks/queries/search-integrations', () => ({ }), })) vi.mock('@/app/o/[organizationId]/providers/organization-provider', () => organizationProviderMock) -vi.mock('@/app/o/[organizationId]/integrations/disconnect-account-menu', () => ({ - DisconnectAccountMenu: ({ accounts }: { accounts: { displayName: string }[] }) => ( - {accounts.map((account) => account.displayName).join(', ')} - ), -})) vi.mock('@/app/workspace/[workspaceId]/integrations/components/integrations-showcase', () => ({ IntegrationTile: () => null, })) @@ -95,8 +90,13 @@ it('never presents another MCP provider as a Coda connection', async () => { }) await act(async () => root.render()) expect(container.textContent).not.toContain('Other private account') - const connect = [...container.querySelectorAll('button')].find( - (button) => button.textContent === 'Connect' + const menu = container.querySelector('[aria-label="Coda integration actions"]') + if (!menu) throw new Error('Missing Coda actions menu') + await act(async () => + menu.dispatchEvent(new MouseEvent('pointerdown', { bubbles: true, button: 0 })) + ) + const connect = [...document.querySelectorAll('[role="menuitem"]')].find( + (item) => item.textContent === 'Connect' ) if (!connect) throw new Error('Missing Coda connection control') await act(async () => connect.click()) diff --git a/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx b/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx index 4da8866cd13..6297deb3a6c 100644 --- a/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx +++ b/apps/sim/app/o/[organizationId]/integrations/live-member-integrations.tsx @@ -1,16 +1,16 @@ 'use client' -import { Chip, toast } from '@sim/emcn' +import { toast } from '@sim/emcn' +import { findCredentialGroupProviderFromProviderId } from '@/lib/credential-groups/providers' import { hasSlackSearchUserScopes } from '@/lib/credential-groups/slack-managed-user-scopes' import { LIVE_SEARCH_SCOPE_FIELDS } from '@/lib/sim-search/live/policy-schema' -import { liveSearchProviderForCredential } from '@/lib/sim-search/live/provider-catalog' import { LIVE_SEARCH_SOURCE_TYPES, liveSearchMcpConnector, + liveSearchMemberAccountProvider, } from '@/lib/sim-search/live/source-catalog' -import { AccountPermissionsLink } from '@/app/o/[organizationId]/integrations/account-permissions-link' +import { AccountActionsMenu } from '@/app/o/[organizationId]/integrations/account-actions-menu' import { AssistantAccountIntegrations } from '@/app/o/[organizationId]/integrations/assistant-account-integrations' -import { DisconnectAccountMenu } from '@/app/o/[organizationId]/integrations/disconnect-account-menu' import { assistantAccountOptions } from '@/app/o/[organizationId]/integrations/utils' import { GenericSecretSourceRow } from '@/app/o/[organizationId]/settings/components/integrations/generic-secret-source' import { IntegrationTile } from '@/app/workspace/[workspaceId]/integrations/components/integrations-showcase' @@ -34,12 +34,12 @@ interface LiveMemberIntegrationsProps { /** Personal OAuth apps and administrator-managed GitLab have distinct connection states. */ export function LiveMemberIntegrations({ organizationId, search }: LiveMemberIntegrationsProps) { - const inventory = useOrganizationAccounts(organizationId) - const policies = useSearchIntegrations(organizationId) - const secrets = useOrganizationSecretSource(organizationId) const connect = useConnectOrganizationAccount() const reconnect = useReconnectPersonalOrganizationAccount() const onError = (error: Error) => toast.error(error.message) + const inventory = useOrganizationAccounts(organizationId) + const policies = useSearchIntegrations(organizationId) + const secrets = useOrganizationSecretSource(organizationId) if (inventory.error) return ( [policy.connectorType, policy])) + const permissionAccountIds = (data.viewerAccounts ?? []) + .filter((account) => { + const provider = findCredentialGroupProviderFromProviderId(account.providerId) + return provider !== null && data.assistantProviders.includes(provider) + }) + .map((account) => account.credentialId) const group = data.credentialGroup const mcpProviders = new Map( group?.mcpServers.map((server) => [server.id, server.managedConnectorId]) @@ -63,12 +69,14 @@ export function LiveMemberIntegrations({ organizationId, search }: LiveMemberInt (data.viewerMcpAccounts ?? []).filter( (account) => mcpProviders.get(account.mcpServerId) === provider ) - const accountsForProvider = (provider: string) => - liveSearchMcpConnector(provider) - ? mcpAccounts(provider) - : (data.viewerAccounts ?? []).filter( - (account) => liveSearchProviderForCredential(account.providerId) === provider - ) + const accountsForProvider = (provider: string) => { + if (liveSearchMcpConnector(provider)) return mcpAccounts(provider) + const memberProvider = + provider === 'slack' ? 'slack' : liveSearchMemberAccountProvider(provider) + return (data.viewerAccounts ?? []).filter( + (account) => findCredentialGroupProviderFromProviderId(account.providerId) === memberProvider + ) + } const available = LIVE_SEARCH_SOURCE_TYPES.filter( ([provider]) => (approvals.get(provider)?.available !== false || accountsForProvider(provider).length > 0) && @@ -76,11 +84,7 @@ export function LiveMemberIntegrations({ organizationId, search }: LiveMemberInt ((provider !== 'hubspot' && provider !== 'zoom') || data.availableMcpConnectors.includes(provider) || mcpAccounts(provider).length > 0) && - (approvals.get(provider)?.approved || - data.viewerAccounts?.some( - (account) => liveSearchProviderForCredential(account.providerId) === provider - ) || - mcpAccounts(provider).length) + (approvals.get(provider)?.approved || accountsForProvider(provider).length > 0) ) const query = search.trim().toLowerCase() const visible = available.filter(([provider, meta]) => @@ -90,7 +94,6 @@ export function LiveMemberIntegrations({ organizationId, search }: LiveMemberInt .toLowerCase() .includes(query) ) - const pending = connect.isPending || reconnect.isPending const secretSource = secrets.data?.source const showSecrets = secretSource && 'generic secrets'.includes(query) return ( @@ -140,32 +143,54 @@ export function LiveMemberIntegrations({ organizationId, search }: LiveMemberInt } /> ) - const option = group?.options.find( + const memberProvider = + provider === 'slack' ? 'slack' : liveSearchMemberAccountProvider(provider) + const accounts = accountsForProvider(provider) + const options = + group?.options.filter( + (option) => + option.provider === memberProvider && + option.status === 'active' && + option.configurationStatus === 'ready' && + (provider !== 'slack' || + (option.provider === 'slack' && hasSlackSearchUserScopes(option.requiredScopes))) + ) ?? [] + const servers = liveSearchMcpConnector(provider) + ? (group?.mcpServers.filter( + (server) => server.managedConnectorId === provider && server.enabled + ) ?? []) + : [] + const option = options.find( (option) => - liveSearchProviderForCredential(option.provider) === provider && - option.status === 'active' + !accounts.some((account) => 'optionId' in account && account.optionId === option.id) ) - const server = liveSearchMcpConnector(provider) - ? group?.mcpServers.find( - (server) => server.managedConnectorId === provider && server.enabled + const server = servers.find( + (server) => + !accounts.some( + (account) => 'mcpServerId' in account && account.mcpServerId === server.id ) - : undefined + ) const connectionTarget = server ? { mcpServerId: server.id } : option ? { optionId: option.id } : undefined - const accounts = accountsForProvider(provider) const ready = approval?.available !== false && group?.status === 'active' && - Boolean(option || server) && + (options.length > 0 || servers.length > 0) && approved && - (!option || option.configurationStatus === 'ready') && - (provider !== 'slack' || - (option?.provider === 'slack' && hasSlackSearchUserScopes(option.requiredScopes))) && ((provider !== 'hubspot' && provider !== 'zoom') || data.availableMcpConnectors.includes(provider)) + const reconnectAccountIds = ready + ? accounts + .filter((account) => + 'optionId' in account + ? options.some((option) => option.id === account.optionId) + : servers.some((server) => server.id === account.mcpServerId) + ) + .map((account) => account.credentialId) + : [] const scope = approval?.policy?.accessMode === 'service_account' ? 'Selected resources you can access' @@ -202,54 +227,28 @@ export function LiveMemberIntegrations({ organizationId, search }: LiveMemberInt title={name} description={description} trailing={ -
- {!liveSearchMcpConnector(provider) && ( - - )} - - {accounts - .filter((account) => account.status === 'needs_reauth') - .map((account) => ( - reconnect.mutate(account.credentialId, { onError })} - > - Reconnect{accounts.length > 1 ? ` ${account.displayName}` : ''} - - ))} - {approved && (option || server) && ( - { - if (!connectionTarget) return - connect.mutate( - { - organizationId, - ...connectionTarget, - }, - { onError } - ) - }} - > - {pending && - connect.variables?.organizationId === organizationId && - (('optionId' in connect.variables && - connect.variables.optionId === option?.id) || - ('mcpServerId' in connect.variables && - connect.variables.mcpServerId === server?.id)) - ? 'Connecting' - : accounts.length - ? 'Add account' - : 'Connect'} - - )} -
+ connect.mutate({ organizationId, ...connectionTarget }, { onError }) + : undefined + } + onReconnect={(credentialId) => reconnect.mutate(credentialId, { onError })} + connectionPending={connect.isPending || reconnect.isPending} + connecting={ + connect.isPending && + (('optionId' in connect.variables && + connect.variables.optionId === option?.id) || + ('mcpServerId' in connect.variables && + connect.variables.mcpServerId === server?.id)) + } + connectionReady={ready} + reconnectAccountIds={reconnectAccountIds} + permissionAccountIds={permissionAccountIds} + /> } /> ) diff --git a/apps/sim/app/o/[organizationId]/integrations/utils.ts b/apps/sim/app/o/[organizationId]/integrations/utils.ts index 395027fac2c..061bf684808 100644 --- a/apps/sim/app/o/[organizationId]/integrations/utils.ts +++ b/apps/sim/app/o/[organizationId]/integrations/utils.ts @@ -1,26 +1,27 @@ import type { OrganizationAccountsSettings } from '@/lib/api/contracts/organization-accounts' +import { getCredentialGroupProviderService } from '@/lib/credential-groups/providers' import { - findCredentialGroupProviderFromProviderId, - getCredentialGroupProviderService, -} from '@/lib/credential-groups/providers' -import { SEARCH_CONNECTORS } from '@/lib/sim-search/connectors' + LIVE_SEARCH_SOURCE_TYPES, + liveSearchMemberAccountProvider, +} from '@/lib/sim-search/live/source-catalog' + +const SEARCH_ACCOUNT_PROVIDERS = new Set([ + 'slack', + ...LIVE_SEARCH_SOURCE_TYPES.flatMap(([source]) => { + const provider = liveSearchMemberAccountProvider(source) + return provider ? [provider] : [] + }), +]) export function assistantAccountOptions(data: OrganizationAccountsSettings, search: string) { const providers = data.assistantProviders.filter( - (provider) => - provider !== 'slack' && - !SEARCH_CONNECTORS.some( - (connector) => findCredentialGroupProviderFromProviderId(connector.providerId) === provider - ) + (provider) => !SEARCH_ACCOUNT_PROVIDERS.has(provider) ) const ownedOptions = new Set(data.viewerAccounts?.map((account) => account.optionId)) const configured = data.credentialGroup?.options.filter( (option) => - !SEARCH_CONNECTORS.some( - (connector) => - findCredentialGroupProviderFromProviderId(connector.providerId) === option.provider - ) && + !SEARCH_ACCOUNT_PROVIDERS.has(option.provider) && ((option.status === 'active' && providers.includes(option.provider)) || ownedOptions.has(option.id)) ) ?? [] diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx index 9e3f9bb2b30..2790d36178f 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/components/agent-group/tool-call-item.tsx @@ -147,7 +147,7 @@ export function ToolCallItem({ /> ) return getToolMetadata(toolName)?.assistant ? ( - + ) : ( fallback ) diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx index fbe9df3b9be..5be65281a6b 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/message-content/message-content.tsx @@ -953,6 +953,8 @@ interface MessageContentProps { * place without ever changing height. */ isLast?: boolean + /** Pending review controls own the tail until the user decides. */ + awaitingInteraction?: boolean /** Transcript-derived answers for this message's question card (renders the recap). */ questionAnswers?: string[] /** Transcript-derived status payload for this message's credential card. */ @@ -980,6 +982,7 @@ function MessageContentInner({ requestMode, isStreaming = false, isLast = false, + awaitingInteraction = false, questionAnswers, credentialSubmission, credentialAbandoned, @@ -1082,7 +1085,8 @@ function MessageContentInner({ // trailing text keeps visually revealing on a timer after the network stream // closes, and collapsing under a still-growing reveal reads as the blob // winking out early while everything shifts. - const thinkingExpanded = phase !== 'settled' && lastSegment?.type !== 'stopped' + const thinkingExpanded = + !awaitingInteraction && phase !== 'settled' && lastSegment?.type !== 'stopped' if (segments.length === 0 && !isLast) return null diff --git a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx index 35b20311bfb..e382f6ae427 100644 --- a/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx +++ b/apps/sim/app/workspace/[workspaceId]/home/components/mothership-chat/mothership-chat.tsx @@ -22,6 +22,10 @@ import { } from '@sim/emcn' import { useQueryClient } from '@tanstack/react-query' import { defaultRangeExtractor, type Range, useVirtualizer } from '@tanstack/react-virtual' +import { + AssistantActionReviewMessage, + useHasAssistantActionReview, +} from '@/components/assistant-actions' import { ComputerUseActivity } from '@/components/computer-use/activity' import { SMOOTH_CHASE_RATE } from '@/lib/core/utils/smooth-bottom-chase' import type { TurnOrigin } from '@/lib/mothership/generated/protocol' @@ -281,6 +285,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ onOptionSelect, onAnimatingChange, }: AssistantMessageRowProps) { + const hasActionReview = useHasAssistantActionReview() const permissions = useOptionalWorkspacePermissionsContext() const canEdit = permissions?.userPermissions.canEdit ?? false const blocks = message.contentBlocks ?? EMPTY_BLOCKS @@ -324,15 +329,18 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ // Settle timing lives in MessageContent (the actions take the thinking // slot's place in the same render), so eligibility here is phase-free: // `phase: 'settled'` asks the helper "would a settled turn show them?". - const actionsEligible = shouldShowAssistantMessageActions({ - phase: 'settled', - hasContent: Boolean(message.content) || hasAnyBlocks, - endsWithInteraction: endsWithQuestion || showsCredentialCard, - questionDismissed, - }) + const actionsEligible = + !hasActionReview && + shouldShowAssistantMessageActions({ + phase: 'settled', + hasContent: Boolean(message.content) || hasAnyBlocks, + endsWithInteraction: endsWithQuestion || showsCredentialCard, + questionDismissed, + }) /** Match the 16px prose/card gap when the card is the message's last visible content. */ - const showsInteractionCard = (endsWithQuestion && !questionDismissed) || showsCredentialCard + const showsInteractionCard = + (endsWithQuestion && !questionDismissed) || showsCredentialCard || hasActionReview return (
@@ -344,6 +352,7 @@ const AssistantMessageRow = memo(function AssistantMessageRow({ fallbackContent={message.content} isStreaming={isStreaming} isLast={isLast} + awaitingInteraction={hasActionReview} questionAnswers={questionAnswers} credentialSubmission={credentialSubmission} credentialAbandoned={credentialAbandoned} @@ -937,39 +946,43 @@ export function MothershipChat({ className='absolute left-0 w-full' style={{ top: virtualItem.start }} > - {msg.role === 'user' ? ( - interactionPairing.hiddenUserByIndex[index] ? null : msg.origin ? ( - + + {msg.role === 'user' ? ( + interactionPairing.hiddenUserByIndex[index] ? null : msg.origin ? ( + + ) : ( + + ) ) : ( - - ) - ) : ( - - )} + )} +
) })} diff --git a/apps/sim/components/assistant-actions/index.ts b/apps/sim/components/assistant-actions/index.ts index 3023c6d186c..f173ee786cc 100644 --- a/apps/sim/components/assistant-actions/index.ts +++ b/apps/sim/components/assistant-actions/index.ts @@ -1,5 +1,6 @@ export { - AssistantActionReviewPanel, + AssistantActionReviewMessage, AssistantActionReviewProvider, AssistantActionReviewStatus, + useHasAssistantActionReview, } from './review' diff --git a/apps/sim/components/assistant-actions/review.tsx b/apps/sim/components/assistant-actions/review.tsx index a6676140602..e91372b3a7a 100644 --- a/apps/sim/components/assistant-actions/review.tsx +++ b/apps/sim/components/assistant-actions/review.tsx @@ -1,37 +1,80 @@ 'use client' -import { createContext, type ReactNode, useContext, useState } from 'react' import { - Chip, - ChipModal, - ChipModalBody, - ChipModalField, - ChipModalFooter, - ChipModalHeader, - OverflowText, - toast, -} from '@sim/emcn' -import { Hand } from '@sim/emcn/icons' + createContext, + type ReactNode, + useContext, + useEffect, + useMemo, + useRef, + useState, +} from 'react' +import { Chip, ChipInput, ChipTextarea, cn } from '@sim/emcn' +import { ShieldCheck } from '@sim/emcn/icons' import { getErrorMessage } from '@sim/utils/errors' import { omit } from '@sim/utils/object' +import { ActivityStatus } from '@/components/ui/activity-status' import type { AssistantActionsResponse } from '@/lib/api/contracts/assistant-actions' -import { - ASSISTANT_ACTION_MAX_BYTES, - ASSISTANT_REVIEW_FIELD_MAX_CHARS, -} from '@/lib/assistant-actions/limits' +import { ASSISTANT_REVIEW_FIELD_MAX_CHARS } from '@/lib/assistant-actions/limits' +import { resolveCredentialDisplay } from '@/lib/integrations/credential-display' +import { getLiveAssistantMessageId } from '@/lib/mothership/chat/live-message-id' +import { InteractionCard } from '@/app/workspace/[workspaceId]/home/components/message-content/components/interaction-card' +import { type ChatMessage, ToolCallStatus } from '@/app/workspace/[workspaceId]/home/types' +import { BrandIcon } from '@/blocks/brand-icon' import { useAssistantActionReview, useDecideAssistantAction, } from '@/hooks/queries/assistant-actions' +import { getToolMetadata } from '@/tools/metadata' type ReviewAction = AssistantActionsResponse['actions'][number] type ReviewEdits = Record }> +type ReviewErrors = Record + +interface ReviewDrafts { + runId: string | null + edits: ReviewEdits + errors: ReviewErrors +} + +interface ReviewContextValue { + query: ReturnType + actions: ReviewAction[] + byId: ReadonlyMap + assistantMessageId: string | null + recoveryMessageId: string | null + recoveryActions: ReviewAction[] + hasApprovalRows: boolean + drafts: ReviewDrafts + submitting: boolean + pendingDecision?: { toolCallId: string; decision: 'allow' | 'skip' } + edit: (action: ReviewAction, name: string, value: string) => void + submit: ( + actions: ReviewAction[], + decision: 'allow' | 'skip', + onStart: () => void + ) => Promise +} + +interface ReviewMessageContextValue { + messageId: string + submit: (actions: ReviewAction[], decision: 'allow' | 'skip', source: HTMLButtonElement) => void +} -const reviewContext = createContext<{ open: () => void; panel: ReactNode } | null>(null) +const reviewContext = createContext(null) +const reviewMessageContext = createContext(null) +const pendingReviewContext = createContext(false) +const EMPTY_ACTIONS: ReviewAction[] = [] + +/** Lets the current turn yield its activity footer to pending review controls. */ +export function useHasAssistantActionReview() { + return useContext(pendingReviewContext) +} interface AssistantActionReviewProviderProps { organizationId: string chatId?: string + messages: ChatMessage[] active: boolean enabled: boolean children: ReactNode @@ -40,215 +83,477 @@ interface AssistantActionReviewProviderProps { export function AssistantActionReviewProvider({ organizationId, chatId, + messages, active, enabled, children, }: AssistantActionReviewProviderProps) { + const inFlight = useRef(null) const query = useAssistantActionReview(organizationId, chatId ?? '', active, enabled) const mutation = useDecideAssistantAction(organizationId, chatId ?? '') - const [open, setOpen] = useState(false) const [submitting, setSubmitting] = useState(false) - const [edits, setEdits] = useState({}) - const actions = query.data?.actions ?? [] - const submit = async (selected: ReviewAction[], decision: 'allow' | 'skip') => { - if (submitting) return + const [drafts, setDrafts] = useState({ runId: null, edits: {}, errors: {} }) + const actions = query.data?.actions ?? EMPTY_ACTIONS + const runId = query.data?.runId ?? null + const streamId = query.data?.streamId ?? null + const byId = useMemo( + () => new Map(actions.map((action) => [action.toolCallId, action])), + [actions] + ) + const { assistantMessageId, recoveryMessageId, recoveryActions, hasApprovalRows } = + useMemo(() => { + if (!streamId) { + const hasApprovalRows = messages.some((message) => + message.contentBlocks?.some( + ({ toolCall }) => + toolCall?.status === ToolCallStatus.awaiting_approval && + getToolMetadata(toolCall.name)?.assistant + ) + ) + return { + assistantMessageId: null, + recoveryMessageId: hasApprovalRows ? null : (messages.at(-1)?.id ?? null), + recoveryActions: EMPTY_ACTIONS, + hasApprovalRows, + } + } + const messageId = getLiveAssistantMessageId(streamId) + const assistant = messages.find((message) => message.id === messageId) + const representedIds = new Set( + assistant?.contentBlocks?.flatMap(({ toolCall }) => + toolCall?.status === ToolCallStatus.awaiting_approval && + getToolMetadata(toolCall.name)?.assistant + ? [toolCall.id] + : [] + ) + ) + return { + assistantMessageId: messageId, + recoveryMessageId: assistant + ? messageId + : (messages.find((message) => message.role === 'user' && message.id === streamId)?.id ?? + null), + recoveryActions: actions.filter((action) => !representedIds.has(action.toolCallId)), + hasApprovalRows: representedIds.size > 0, + } + }, [actions, messages, streamId]) + + useEffect(() => () => inFlight.current?.abort(), [runId]) + + const edit = (action: ReviewAction, name: string, value: string) => { + setDrafts((current) => { + const edits = current.runId === action.runId ? current.edits : {} + const draft = edits[action.toolCallId] + return { + runId: action.runId, + errors: current.runId === action.runId ? omit(current.errors, [action.toolCallId]) : {}, + edits: { + ...edits, + [action.toolCallId]: { + proposalVersion: action.proposalVersion, + fields: { + ...(draft?.proposalVersion === action.proposalVersion ? draft.fields : {}), + [name]: value, + }, + }, + }, + } + }) + } + const submit = async ( + selected: ReviewAction[], + decision: 'allow' | 'skip', + onStart: () => void + ) => { + if (inFlight.current || query.isError || !selected.length) return + const controller = new AbortController() + inFlight.current = controller + onStart() setSubmitting(true) + const snapshot = selected.map((action) => { + const draft = drafts.runId === action.runId ? drafts.edits[action.toolCallId] : undefined + return { + action, + fields: draft?.proposalVersion === action.proposalVersion ? { ...draft.fields } : {}, + } + }) + setDrafts((current) => ({ + runId, + edits: current.runId === runId ? current.edits : {}, + errors: + current.runId === runId + ? omit( + current.errors, + selected.map((action) => action.toolCallId) + ) + : {}, + })) try { - for (const action of selected) { - const draft = edits[action.toolCallId] + for (const { action, fields } of snapshot) { + if (controller.signal.aborted) break try { await mutation.mutateAsync({ toolCallId: action.toolCallId, runId: action.runId, proposalVersion: action.proposalVersion, decision, - ...(decision === 'allow' && draft?.proposalVersion === action.proposalVersion - ? { edits: draft.fields } - : {}), + signal: controller.signal, + ...(decision === 'allow' ? { edits: fields } : {}), }) - setEdits((current) => omit(current, [action.toolCallId])) + setDrafts((current) => + current.runId === action.runId + ? { ...current, edits: omit(current.edits, [action.toolCallId]) } + : current + ) } catch (error) { - toast.error(getErrorMessage(error, 'Could not save this review')) + if (controller.signal.aborted) break + setDrafts((current) => + current.runId === action.runId + ? { + ...current, + errors: { + ...current.errors, + [action.toolCallId]: { + proposalVersion: action.proposalVersion, + message: getErrorMessage(error, 'Could not save this review'), + }, + }, + } + : current + ) } } } finally { - setSubmitting(false) + if (inFlight.current === controller) { + inFlight.current = null + setSubmitting(false) + } } } - const panel = - !enabled || (!actions.length && !query.error) ? null : ( -
-
- - - {actions.length - ? `${actions.length} ${actions.length === 1 ? 'action' : 'actions'} to review` - : 'Action review unavailable'} - - (actions.length ? setOpen(true) : void query.refetch())}> - {actions.length ? 'Review' : 'Retry'} - -
- {actions.slice(0, 3).map((action) => ( - field.target && field.value)?.value ?? - action.account?.displayName ?? - 'Account unavailable' - }`} - className='text-[var(--text-muted)] text-small' - /> - ))} - {actions.length > 3 && ( -

And {actions.length - 3} more

- )} -
- ) + return ( - setOpen(true), panel } : null}> + {children} - + ) +} + +interface AssistantActionReviewMessageProps { + messageId: string + children: ReactNode +} + +export function AssistantActionReviewMessage({ + messageId, + children, +}: AssistantActionReviewMessageProps) { + const turn = useRef(null) + const focusOrigin = useRef(null) + const review = useContext(reviewContext) + const submitting = review?.submitting ?? false + + useEffect(() => { + const origin = focusOrigin.current + if (!origin) return + if (submitting) { + const cancelRestore = () => { + focusOrigin.current = null + } + const handleFocus = (event: FocusEvent) => { + if (event.target !== origin && event.target !== document.body) cancelRestore() + } + document.addEventListener('focusin', handleFocus, true) + document.addEventListener('pointerdown', cancelRestore, true) + return () => { + document.removeEventListener('focusin', handleFocus, true) + document.removeEventListener('pointerdown', cancelRestore, true) + } + } + focusOrigin.current = null + if ( + document.hasFocus() && + (document.activeElement === document.body || document.activeElement === origin) + ) { + const target = origin.isConnected && !origin.disabled ? origin : turn.current + target?.focus({ preventScroll: true }) + } + }, [submitting]) + + if (!review) + return {children} + const submit: ReviewMessageContextValue['submit'] = (actions, decision, source) => { + void review.submit(actions, decision, () => { + focusOrigin.current = document.activeElement === source ? source : null + }) + } + const ownsReview = review.recoveryMessageId === messageId + return ( + + 0 || review.submitting)} > - setOpen(false)}> - Review actions - - -

- Review each action before it runs. Allow and Cancel apply only to these proposals. Each - action has a {ASSISTANT_ACTION_MAX_BYTES / 1024} KB total limit across all fields. -

- {actions.map((action) => ( - - setEdits((current) => ({ - ...current, - [action.toolCallId]: { - proposalVersion: action.proposalVersion, - fields: { - ...(current[action.toolCallId]?.proposalVersion === action.proposalVersion - ? current[action.toolCallId].fields - : {}), - [name]: value, - }, - }, - })) - } - onDecide={(decision) => void submit([action], decision)} - /> - ))} - {!actions.length && ( -

- {query.error - ? 'Could not load the current actions. Close this review and retry.' - : 'No actions are waiting for review.'} -

+
+ {children} + {ownsReview && + !review.hasApprovalRows && + review.recoveryActions.length === 0 && + review.query.isError && ( +
+ +
+ )} + {ownsReview && review.recoveryActions.length > 0 && ( +
+ {review.recoveryActions.map((action) => ( + + ))} +
)} - {query.data?.nextCursor !== null && query.data?.nextCursor !== undefined && ( -

- More actions are waiting. Review these first. -

- )} - - setOpen(false)} - cancelLabel='Close' - defaultAction='none' - primaryAction={{ - label: actions.length > 1 ? `Allow ${actions.length} shown` : 'Allow', - onClick: () => void submit(actions, 'allow'), - disabled: submitting || !actions.length || actions.some((action) => !action.account), - }} - /> - - + {ownsReview && } +
+
+
+ ) +} + +interface AssistantActionReviewStatusProps { + toolCallId: string + title: string + fallback: ReactNode +} + +export function AssistantActionReviewStatus({ + toolCallId, + title, + fallback, +}: AssistantActionReviewStatusProps) { + const review = useContext(reviewContext) + const message = useContext(reviewMessageContext) + if (!review) return fallback + if (review.query.isPending) return + if (message?.messageId === review.assistantMessageId) { + const action = review.byId.get(toolCallId) + if (action) return + if (!review.query.isError && review.query.data?.nextCursor != null) + return + } + if (review.query.isError) + return + return +} + +interface ReviewAvailabilityProps { + label: string + review?: ReviewContextValue +} + +function ReviewAvailability({ label, review }: ReviewAvailabilityProps) { + return ( +
+ } /> + {review && ( + void review.query.refetch()}> + Retry + + )} +
) } interface ActionReviewProps { action: ReviewAction - edits: Record - disabled: boolean - onEdit: (name: string, value: string) => void - onDecide: (decision: 'allow' | 'skip') => void + review: ReviewContextValue } -function ActionReview({ action, edits, disabled, onEdit, onDecide }: ActionReviewProps) { +function ActionReview({ action, review }: ActionReviewProps) { + const message = useContext(reviewMessageContext) + const draft = + review.drafts.runId === action.runId ? review.drafts.edits[action.toolCallId] : undefined + const edits = draft?.proposalVersion === action.proposalVersion ? draft.fields : {} + const error = + review.drafts.runId === action.runId ? review.drafts.errors[action.toolCallId] : undefined + const disabled = review.submitting || review.query.isError + const pendingDecision = + review.pendingDecision?.toolCallId === action.toolCallId + ? review.pendingDecision.decision + : null + const display = action.account + ? resolveCredentialDisplay({ ...action.account, type: 'managed_oauth' }) + : undefined return ( -
- + + {display?.icon ? ( + + ) : ( + + )} + {action.name} + + } > -
- onDecide('allow')}> - Allow - - onDecide('skip')}> - Cancel - -
-
- {action.fields - .filter((field) => field.value || field.editable) - .map((field) => - field.editable ? ( - +

+ {action.account?.displayName ?? 'Account unavailable'} +

+
+ {action.fields + .filter((field) => field.value || field.editable) + .map((field) => ( + review.edit(action, field.name, value)} + /> + ))} +
+ {!action.account?.active && ( +

+ This account is no longer available. +

+ )} + {review.query.isError && ( + + )} + {error?.proposalVersion === action.proposalVersion && ( +

+ {error.message} +

+ )} +
+ message?.submit([action], 'allow', event.currentTarget)} + > + {pendingDecision === 'allow' ? 'Saving…' : 'Allow'} + + onEdit(field.name, value)} - rows={field.format === 'multiline' ? 4 : 2} - /> - ) : ( - - ) - )} + onClick={(event) => message?.submit([action], 'skip', event.currentTarget)} + > + {pendingDecision === 'skip' ? 'Saving…' : 'Cancel'} + +
+
+ ) } -export function AssistantActionReviewPanel() { - return useContext(reviewContext)?.panel ?? null +interface ReviewBatchActionsProps { + review: ReviewContextValue } -interface AssistantActionReviewStatusProps { - title: string - fallback: ReactNode +function ReviewBatchActions({ review }: ReviewBatchActionsProps) { + const message = useContext(reviewMessageContext) + const disabled = review.submitting || review.query.isError + const hasMore = review.query.data?.nextCursor != null + if (review.actions.length < 2 && !review.submitting && !hasMore) return null + return ( +
+ {review.actions.length > 1 && ( +
+ !action.account?.active)} + onClick={(event) => message?.submit(review.actions, 'allow', event.currentTarget)} + > + Allow {review.actions.length} shown + + message?.submit(review.actions, 'skip', event.currentTarget)} + > + Cancel {review.actions.length} shown + +
+ )} + {review.submitting ? ( + + ) : ( + hasMore && ( +

More actions are waiting.

+ ) + )} +
+ ) } -export function AssistantActionReviewStatus({ title, fallback }: AssistantActionReviewStatusProps) { - const review = useContext(reviewContext) - return review ? ( - - {title} · Review - +interface ActionReviewFieldProps { + field: ReviewAction['fields'][number] + actionId: string + value: string + disabled: boolean + onEdit: (value: string) => void +} + +function ActionReviewField({ field, actionId, value, disabled, onEdit }: ActionReviewFieldProps) { + const id = `assistant-action-${actionId}-${field.name}` + return field.editable ? ( +
+ + {field.format === 'text' ? ( + onEdit(event.target.value)} + /> + ) : ( + onEdit(event.target.value)} + /> + )} +
) : ( - fallback +
+
{field.label}
+
+ {value} +
+
) } diff --git a/apps/sim/hooks/queries/assistant-actions.ts b/apps/sim/hooks/queries/assistant-actions.ts index f74fb4b7836..72054f3accb 100644 --- a/apps/sim/hooks/queries/assistant-actions.ts +++ b/apps/sim/hooks/queries/assistant-actions.ts @@ -59,10 +59,15 @@ export function useAssistantActionReview( export function useDecideAssistantAction(organizationId: string, chatId: string) { const client = useQueryClient() return useMutation({ - mutationFn: ({ toolCallId, ...body }: AssistantActionDecisionBody & { toolCallId: string }) => + mutationFn: ({ + toolCallId, + signal, + ...body + }: AssistantActionDecisionBody & { toolCallId: string; signal?: AbortSignal }) => requestJson(decideAssistantActionContract, { params: { id: organizationId, chatId, toolCallId }, body, + signal, }), onSettled: () => client.invalidateQueries({ queryKey: assistantActionKeys.review(organizationId, chatId) }), diff --git a/apps/sim/lib/api/contracts/assistant-actions.ts b/apps/sim/lib/api/contracts/assistant-actions.ts index 4a0a368d71c..112a7518a75 100644 --- a/apps/sim/lib/api/contracts/assistant-actions.ts +++ b/apps/sim/lib/api/contracts/assistant-actions.ts @@ -90,6 +90,8 @@ export const listAssistantActionsContract = defineRouteContract({ response: { mode: 'json', schema: z.object({ + runId: z.uuid().nullable(), + streamId: z.string().min(1).nullable(), actions: z .array( z.object({ @@ -104,6 +106,7 @@ export const listAssistantActionsContract = defineRouteContract({ credentialId: z.string().max(128), displayName: z.string().max(512), providerId: z.string().max(128), + active: z.boolean(), }) .nullable(), fields: z diff --git a/apps/sim/lib/assistant-actions/application/context.ts b/apps/sim/lib/assistant-actions/application/context.ts index e4d19d21dfd..c25ec4b2958 100644 --- a/apps/sim/lib/assistant-actions/application/context.ts +++ b/apps/sim/lib/assistant-actions/application/context.ts @@ -45,6 +45,7 @@ export async function loadAssistantAccountPolicyContext( return { account, tools, canAutoApprove } } +/** A transaction retains the chat lock until its decision or claim commits, before locking the run. */ export async function requireAssistantRun( principal: Principal, input: { organizationId: string; runId: string; chatId?: string }, @@ -61,6 +62,9 @@ export async function requireAssistantRun( throw new OrchestrationError('forbidden', 'Assistant actions require a private conversation.') const [run] = await executor .select({ + runId: copilotRuns.id, + streamId: copilotRuns.streamId, + currentStreamId: copilotChats.conversationId, config: copilotChats.config, version: copilotRuns.toolExecutionVersion, closedAt: copilotRuns.toolAdmissionClosedAt, @@ -80,14 +84,20 @@ export async function requireAssistantRun( ) ) .limit(1) + .for('share', { of: copilotChats }) if ( !run || toRecord(run.config).conversationMode !== 'assistant' || run.version !== SIM_TOOL_EXECUTION_VERSION ) throw new OrchestrationError('not_found', 'Assistant conversation is unavailable.') - if (run.closedAt || TERMINAL_RUN_STATUSES.includes(run.status)) + if ( + run.currentStreamId !== run.streamId || + run.closedAt || + TERMINAL_RUN_STATUSES.includes(run.status) + ) throw new OrchestrationError('conflict', 'This conversation is no longer accepting actions.') + return { runId: run.runId, streamId: run.streamId } } export async function resolveAssistantActionPolicy( diff --git a/apps/sim/lib/assistant-actions/application/review.ts b/apps/sim/lib/assistant-actions/application/review.ts index 9f572ba4d82..97775bfaa65 100644 --- a/apps/sim/lib/assistant-actions/application/review.ts +++ b/apps/sim/lib/assistant-actions/application/review.ts @@ -1,9 +1,9 @@ import { isDeepStrictEqual } from 'node:util' import { AuditAction, AuditResourceType } from '@sim/audit' import { db } from '@sim/db' -import { copilotAsyncToolCalls, copilotRuns } from '@sim/db/schema' +import { copilotAsyncToolCalls, copilotChats, copilotRuns } from '@sim/db/schema' import { toRecord } from '@sim/utils/object' -import { and, asc, desc, eq, gt, isNotNull, isNull, notInArray } from 'drizzle-orm' +import { and, asc, desc, eq, gt, gte, isNotNull, isNull, notInArray, or } from 'drizzle-orm' import { lockAssistantActionScope, requireAssistantRun, @@ -23,6 +23,7 @@ import { } from '@/lib/core/application/authorized-organization-use-case' import { authorizeOrganizationOperation } from '@/lib/core/application/organization-authorization' import { OrchestrationError } from '@/lib/core/orchestration/types' +import { SIM_TOOL_MIN_START_BUDGET_MS } from '@/lib/mothership/async-runs/execution-lease' import { recordToolPermissionDecision, TERMINAL_RUN_STATUSES, @@ -43,26 +44,29 @@ export const listOrganizationAssistantActions = defineAuthorizedOrganizationUseC principal, input, context, - }: OrganizationUseCaseContext & { runId?: string; cursor?: number }>) { - const [latest] = input.runId - ? [{ id: input.runId }] - : await db - .select({ id: copilotRuns.id }) - .from(copilotRuns) - .where( - and( - eq(copilotRuns.chatId, input.chatId), - eq(copilotRuns.organizationId, context.organizationId), - eq(copilotRuns.userId, context.userId), - isNull(copilotRuns.toolAdmissionClosedAt), - notInArray(copilotRuns.status, TERMINAL_RUN_STATUSES) - ) - ) - .orderBy(desc(copilotRuns.startedAt), desc(copilotRuns.id)) - .limit(1) - if (!latest) return { actions: [], nextCursor: null } + }: OrganizationUseCaseContext & { cursor?: number }>) { + const [latest] = await db + .select({ id: copilotRuns.id }) + .from(copilotRuns) + .innerJoin(copilotChats, eq(copilotChats.id, copilotRuns.chatId)) + .where( + and( + eq(copilotRuns.chatId, input.chatId), + eq(copilotRuns.organizationId, context.organizationId), + eq(copilotRuns.userId, context.userId), + eq(copilotChats.userId, context.userId), + eq(copilotChats.organizationId, context.organizationId), + eq(copilotChats.conversationId, copilotRuns.streamId), + isNull(copilotChats.deletedAt), + isNull(copilotRuns.toolAdmissionClosedAt), + notInArray(copilotRuns.status, TERMINAL_RUN_STATUSES) + ) + ) + .orderBy(desc(copilotRuns.startedAt), desc(copilotRuns.id)) + .limit(1) + if (!latest) return { runId: null, streamId: null, actions: [], nextCursor: null } const runId = latest.id - await requireAssistantRun(principal, { ...input, runId }, context.userId) + const run = await requireAssistantRun(principal, { ...input, runId }, context.userId) const records = await db .select() .from(copilotAsyncToolCalls) @@ -73,6 +77,13 @@ export const listOrganizationAssistantActions = defineAuthorizedOrganizationUseC isNotNull(copilotAsyncToolCalls.assistantAction), isNotNull(copilotAsyncToolCalls.permissionRequestedAt), isNull(copilotAsyncToolCalls.permissionDecision), + or( + isNull(copilotAsyncToolCalls.executionDeadlineAt), + gte( + copilotAsyncToolCalls.executionDeadlineAt, + new Date(Date.now() + SIM_TOOL_MIN_START_BUDGET_MS) + ) + ), input.cursor === undefined ? undefined : gt(copilotAsyncToolCalls.persistSeq, input.cursor) @@ -101,6 +112,7 @@ export const listOrganizationAssistantActions = defineAuthorizedOrganizationUseC credentialId: account.credentialId, displayName: account.displayName, providerId: account.providerId, + active: account.active, } : null, proposalVersion: assistantProposalVersion(record), @@ -108,7 +120,11 @@ export const listOrganizationAssistantActions = defineAuthorizedOrganizationUseC } }) ) - return { actions, nextCursor: records.length > 32 ? (page.at(-1)?.persistSeq ?? null) : null } + return { + ...run, + actions, + nextCursor: records.length > 32 ? (page.at(-1)?.persistSeq ?? null) : null, + } }, }) @@ -147,6 +163,7 @@ export const decideOrganizationAssistantAction = defineAuthorizedOrganizationUse executor: tx, forUpdate: true, }) + await requireAssistantRun(principal, input, context.userId, tx) await tx .select({ id: copilotRuns.id }) .from(copilotRuns) diff --git a/apps/sim/lib/assistant-actions/proposal.ts b/apps/sim/lib/assistant-actions/proposal.ts index 4bcf7748dd1..abd006ab5aa 100644 --- a/apps/sim/lib/assistant-actions/proposal.ts +++ b/apps/sim/lib/assistant-actions/proposal.ts @@ -86,7 +86,11 @@ export function assistantReviewFields(record: ActionRecord) { field.target !== true && tool?.params[name]?.type === 'string', format: - field?.format ?? (typeof value === 'string' ? ('text' as const) : ('json' as const)), + field?.format ?? + (typeof value === 'string' || + (value === undefined && tool?.params[name]?.type === 'string') + ? ('text' as const) + : ('json' as const)), value: value === undefined ? '' : typeof value === 'string' ? value : JSON.stringify(value), } }) diff --git a/apps/sim/lib/assistant-actions/tool-permissions.integration.ts b/apps/sim/lib/assistant-actions/tool-permissions.integration.ts index 4e3f89b6aa0..a5e0cad86cf 100644 --- a/apps/sim/lib/assistant-actions/tool-permissions.integration.ts +++ b/apps/sim/lib/assistant-actions/tool-permissions.integration.ts @@ -51,6 +51,7 @@ import { rejectManagedOAuthToken, } from '@/lib/credentials/managed-oauth' import { + claimAssistantToolExecution, claimToolExecution, recordToolPermissionDecision, upsertAsyncToolCall, @@ -98,6 +99,7 @@ const enrollmentId = generateId() const otherEnrollmentId = generateId() const chatId = generateId() const runId = generateId() +const streamId = generateId() const principal: SessionPrincipal = { kind: 'session', userId: ownerId, sessionId: generateId() } const input = { organizationId, credentialId } const delegated: OrganizationDelegatedPrincipal = { @@ -138,7 +140,7 @@ const prepare = async ( const claim = (call: Awaited>) => claimOrganizationAssistantAction.execute({ principal: delegated, input: call }) const review = () => - listOrganizationAssistantActions.execute({ principal, input: { organizationId, chatId, runId } }) + listOrganizationAssistantActions.execute({ principal, input: { organizationId, chatId } }) const decide = ( toolCallId: string, proposalVersion: string, @@ -355,6 +357,7 @@ beforeAll(async () => { organizationId, type: 'mothership', config: { conversationMode: 'assistant' }, + conversationId: streamId, }) await db.insert(copilotRuns).values({ id: runId, @@ -362,12 +365,13 @@ beforeAll(async () => { userId: ownerId, organizationId, executionId: generateId(), - streamId: generateId(), + streamId, toolExecutionVersion: 3, }) }) beforeEach(async () => { + await db.update(copilotChats).set({ conversationId: streamId }).where(eq(copilotChats.id, chatId)) const jiraPolicy = await getCredentialGroupProviderAdapter('jira').getPolicy(undefined, {}) await db .update(credential) @@ -427,6 +431,120 @@ describe('Assistant discovery for legacy account setup', () => { }) describe('organization Assistant tool permissions', () => { + it.each(['list', 'decide', 'prepare', 'claim', 'owner_claim'] as const)( + 'rejects an orphaned earlier turn at the %s boundary', + async (boundary) => { + await change(0, 'ask') + const call = await prepare() + const proposal = (await review()).actions[0] + if (!proposal) throw new Error('Pending action not available for review') + if (boundary === 'claim' || boundary === 'owner_claim') + await decide(call.toolCallId, proposal.proposalVersion, 'allow') + const successorId = generateId() + const successorStream = generateId() + await db.transaction(async (tx) => { + await tx + .update(copilotChats) + .set({ conversationId: successorStream }) + .where(eq(copilotChats.id, chatId)) + await tx.insert(copilotRuns).values({ + id: successorId, + chatId, + userId: ownerId, + organizationId, + executionId: generateId(), + streamId: successorStream, + status: 'complete', + toolExecutionVersion: 3, + }) + }) + try { + if (boundary === 'list') { + expect( + await listOrganizationAssistantActions.execute({ + principal, + input: { organizationId, chatId }, + }) + ).toEqual({ + runId: null, + streamId: null, + actions: [], + nextCursor: null, + }) + } else if (boundary === 'decide') { + await expect(decide(call.toolCallId, proposal.proposalVersion, 'allow')).rejects.toThrow( + 'no longer accepting actions' + ) + } else if (boundary === 'prepare') { + await expect(prepare()).rejects.toThrow('no longer accepting actions') + } else if (boundary === 'claim') { + await expect(claim(call)).rejects.toThrow('no longer accepting actions') + } else { + expect( + await db.transaction((tx) => + claimAssistantToolExecution({ ...call, userId: ownerId }, tx) + ) + ).toEqual({ outcome: 'closed' }) + } + const [record] = await db + .select({ started: copilotAsyncToolCalls.executionStartedAt }) + .from(copilotAsyncToolCalls) + .where(eq(copilotAsyncToolCalls.toolCallId, call.toolCallId)) + expect(record?.started).toBeNull() + } finally { + await db.delete(copilotRuns).where(eq(copilotRuns.id, successorId)) + } + } + ) + + it('pages eligible proposals past expired rows and keeps canonical turn metadata', async () => { + await change(0, 'ask') + const expired = [] + for (let index = 0; index < 33; index++) expired.push(await prepare()) + await db + .update(copilotAsyncToolCalls) + .set({ executionDeadlineAt: new Date(Date.now() + 30_000) }) + .where( + inArray( + copilotAsyncToolCalls.toolCallId, + expired.map((call) => call.toolCallId) + ) + ) + const eligible = await prepare() + const result = await listOrganizationAssistantActions.execute({ + principal, + input: { organizationId, chatId }, + }) + expect(result).toMatchObject({ runId, streamId, nextCursor: null }) + expect(result.actions.map((action) => action.toolCallId)).toEqual([eligible.toolCallId]) + const [expiredProposal] = expired + if (!expiredProposal) throw new Error('Missing expired proposal') + const [record] = await db + .select() + .from(copilotAsyncToolCalls) + .where(eq(copilotAsyncToolCalls.toolCallId, expiredProposal.toolCallId)) + expect(record?.executionStartedAt).toBeNull() + }) + + it('keeps a revoked account identifiable and cancellable without offering active access', async () => { + await change(0, 'ask') + const call = await prepare() + await db + .update(credential) + .set({ managedOauthStatus: 'revoked' }) + .where(eq(credential.id, credentialId)) + const action = (await review()).actions[0] + expect(action?.account).toMatchObject({ credentialId, active: false }) + if (!action) throw new Error('Pending action not available for cancellation') + await expect(decide(call.toolCallId, action.proposalVersion, 'allow')).rejects.toThrow( + 'active connected account' + ) + expect(await decide(call.toolCallId, action.proposalVersion, 'skip')).toMatchObject({ + decision: 'skip', + applied: true, + }) + }) + it('reads safe defaults without persisting a grant', async () => { const result = await read() expect(result).toMatchObject({ revision: 0, readPolicy: 'allow', writePolicy: 'ask' }) diff --git a/apps/sim/lib/mothership/async-runs/repository.ts b/apps/sim/lib/mothership/async-runs/repository.ts index e932023ef52..6383705b7b1 100644 --- a/apps/sim/lib/mothership/async-runs/repository.ts +++ b/apps/sim/lib/mothership/async-runs/repository.ts @@ -1142,10 +1142,26 @@ export function claimToolExecution(owner: SimToolExecutionOwner): Promise { + const [currentTurn] = await transaction + .select({ id: copilotRuns.id }) + .from(copilotRuns) + .innerJoin(copilotChats, eq(copilotChats.id, copilotRuns.chatId)) + .where( + and( + eq(copilotRuns.id, owner.runId), + eq(copilotRuns.userId, owner.userId), + eq(copilotChats.userId, owner.userId), + eq(copilotChats.conversationId, copilotRuns.streamId), + isNull(copilotChats.deletedAt) + ) + ) + .for('share', { of: copilotChats }) + .limit(1) + if (!currentTurn) return { outcome: 'closed' } return claimSimToolExecution(owner, true, transaction) } diff --git a/apps/sim/lib/mothership/async-runs/tool-identity.integration.ts b/apps/sim/lib/mothership/async-runs/tool-identity.integration.ts index 0081d2d1f2b..cab21f5bf1c 100644 --- a/apps/sim/lib/mothership/async-runs/tool-identity.integration.ts +++ b/apps/sim/lib/mothership/async-runs/tool-identity.integration.ts @@ -4,6 +4,8 @@ * Set TEST_DATABASE_URL to a local PostgreSQL database and * TEST_REDIS_URL to an isolated local Redis service. */ + +import { sleep } from '@sim/utils/helpers' import { generateId } from '@sim/utils/id' import { drizzle, type PostgresJsDatabase } from 'drizzle-orm/postgres-js' import Redis from 'ioredis' @@ -476,6 +478,50 @@ describe('Copilot tool identity with PostgreSQL', () => { }) describe.skipIf(!redisUrl)('with actual Redis confirmation and permission channels', () => { + it.each(['abort', 'timeout'] as const)( + 'stops durable permission reads after %s', + async (ending) => { + await createCurrentCalls() + const durableReads = vi.spyOn(asyncRepository, 'getAsyncToolCall') + const abort = new AbortController() + const waiting = waitForToolPermissionDecision( + firstId, + ending === 'timeout' ? 100 : 5_000, + abort.signal + ) + try { + await vi.waitFor(() => expect(durableReads).toHaveBeenCalledTimes(1)) + await Promise.all(durableReads.mock.results.map((result) => result.value)) + if (ending === 'abort') abort.abort() + expect(await waiting).toBeNull() + const readsAtSettlement = durableReads.mock.calls.length + await recordToolPermissionDecision(firstId, 'allow') + await sleep(1_200) + expect(durableReads).toHaveBeenCalledTimes(readsAtSettlement) + } finally { + abort.abort() + await waiting + } + } + ) + + it('recovers a committed permission decision when its notification is lost', async () => { + await createCurrentCalls() + const durableReads = vi.spyOn(asyncRepository, 'getAsyncToolCall') + const abort = new AbortController() + const waiting = waitForToolPermissionDecision(firstId, 3_000, abort.signal) + try { + await vi.waitFor(() => expect(durableReads).toHaveBeenCalledTimes(1)) + await Promise.all(durableReads.mock.results.map((result) => result.value)) + await recordToolPermissionDecision(firstId, 'allow') + expect(await waiting).toMatchObject({ toolCallId: firstId, decision: 'allow' }) + expect((await getAsyncToolCall(secondId))?.permissionDecision).toBeNull() + } finally { + abort.abort() + await waiting + } + }) + it('wakes only the matching waiter and reads its durable terminal result', async () => { await createCurrentCalls() const durableReads = vi.spyOn(asyncRepository, 'getAsyncToolCalls') diff --git a/apps/sim/lib/mothership/persistence/tool-permission/index.ts b/apps/sim/lib/mothership/persistence/tool-permission/index.ts index 79f9e51896d..962099b8d77 100644 --- a/apps/sim/lib/mothership/persistence/tool-permission/index.ts +++ b/apps/sim/lib/mothership/persistence/tool-permission/index.ts @@ -1,11 +1,13 @@ import type { CopilotToolPermissionDecision } from '@sim/db/schema' import { createLogger } from '@sim/logger' import { toError } from '@sim/utils/errors' +import { backoffWithJitter } from '@sim/utils/retry' import { createPubSubChannel, type PubSubChannel } from '@/lib/events/pubsub' import { isExecutableToolPermissionDecision } from '@/lib/mothership/async-runs/lifecycle' import { getAsyncToolCall } from '@/lib/mothership/async-runs/repository' const logger = createLogger('CopilotToolPermission') +const DECISION_RECHECK_BACKOFF = { baseMs: 1_000, maxMs: 10_000 } as const export const TOOL_PERMISSION_DECISION = { allow: 'allow', @@ -100,8 +102,8 @@ export async function getToolPermissionDecision( * Mirrors `waitForToolConfirmation`: subscribe first, then read the durable * row, so a decision that lands between those two steps — or that was made * against a different server instance, or before this waiter existed at all — - * is still picked up. Resolves `null` on timeout or abort; callers treat that - * as "no decision" and fail the tool rather than running it. + * is still picked up. Bounded durable rechecks also recover a missed notification. + * Resolves `null` on timeout or abort; callers treat that as "no decision". */ export async function waitForToolPermissionDecision( toolCallId: string, @@ -111,10 +113,13 @@ export async function waitForToolPermissionDecision( return new Promise((resolve) => { let settled = false let timeoutId: ReturnType | null = null + let recheckId: ReturnType | null = null + let recheckAttempt = 0 let unsubscribe: (() => void) | null = null const cleanup = () => { if (timeoutId) clearTimeout(timeoutId) + if (recheckId) clearTimeout(recheckId) if (unsubscribe) unsubscribe() abortSignal?.removeEventListener('abort', onAbort) } @@ -145,13 +150,24 @@ export async function waitForToolPermissionDecision( } abortSignal?.addEventListener('abort', onAbort, { once: true }) - void getToolPermissionDecision(toolCallId).then((existing) => { - if (!existing) return - logger.info('Resolved tool permission from durable row', { - toolCallId, - decision: existing.decision, - }) - settle(existing) - }) + const readDecision = async () => { + if (settled) return + const existing = await getToolPermissionDecision(toolCallId) + if (settled) return + if (existing) { + logger.info('Resolved tool permission from durable row', { + toolCallId, + decision: existing.decision, + }) + settle(existing) + return + } + recheckAttempt += 1 + recheckId = setTimeout( + () => void readDecision(), + backoffWithJitter(recheckAttempt, null, DECISION_RECHECK_BACKOFF) + ) + } + void readDecision() }) } diff --git a/apps/sim/scripts/test-assistant-actions-e2e.ts b/apps/sim/scripts/test-assistant-actions-e2e.ts index 40aa1fa8acd..3d8dde08afc 100644 --- a/apps/sim/scripts/test-assistant-actions-e2e.ts +++ b/apps/sim/scripts/test-assistant-actions-e2e.ts @@ -4,16 +4,30 @@ import { dirname, resolve } from 'node:path' import { chromium, expect } from '@playwright/test' import { assertDisposableTestDatabaseUrl } from '@sim/db/testing/test-infrastructure' import { createLogger } from '@sim/logger' +import { createDeferred } from '@sim/testing/helpers/deferred' import { getErrorMessage } from '@sim/utils/errors' import { generateId, generateShortId } from '@sim/utils/id' import { toRecord } from '@sim/utils/object' import { makeSignature } from 'better-auth/crypto' import postgres from 'postgres' +import { + listAssistantActionsContract, + readAssistantToolPermissionsContract, +} from '@/lib/api/contracts/assistant-actions' +import { closeRedisConnection } from '@/lib/core/config/redis' +import { + acquirePendingChatStream, + releasePendingChatStream, + startAbortPoller, +} from '@/lib/mothership/request/session/abort' +import { allocateCursor, appendEvents, clearBuffer } from '@/lib/mothership/request/session/buffer' +import type { SessionStreamEvent } from '@/lib/mothership/request/session/contract' +import { createEvent } from '@/lib/mothership/request/session/event' /** * Runs against a local app and disposable PostgreSQL with member Search, Connected Accounts, and Access Control enabled. * Requires ASSISTANT_ACTIONS_E2E_BASE_URL, ASSISTANT_ACTIONS_E2E_DATABASE_URL, - * ASSISTANT_ACTIONS_E2E_AUTH_SECRET, and ASSISTANT_ACTIONS_E2E_REPORT_PATH. + * ASSISTANT_ACTIONS_E2E_AUTH_SECRET, ASSISTANT_ACTIONS_E2E_REPORT_PATH, and the app's REDIS_URL. * Verifies account policy persistence and action review over real HTTP without executing provider operations. */ const logger = createLogger('AssistantActionsE2E') @@ -31,6 +45,10 @@ assert(!baseUrl.username && !baseUrl.password, 'The app URL must not contain cre const databaseUrl = assertDisposableTestDatabaseUrl( requiredEnvironment('ASSISTANT_ACTIONS_E2E_DATABASE_URL') ) +const redisUrl = new URL(requiredEnvironment('REDIS_URL')) +assert(['redis:', 'rediss:'].includes(redisUrl.protocol), 'Use a Redis URL') +assert(['localhost', '127.0.0.1', '[::1]'].includes(redisUrl.hostname), 'Use local Redis') +assert(!redisUrl.username && !redisUrl.password && !redisUrl.search, 'Use credential-free Redis') const authSecret = requiredEnvironment('ASSISTANT_ACTIONS_E2E_AUTH_SECRET') assert(authSecret.length >= 32, 'Use a local auth secret of at least 32 characters') const reportPath = resolve(requiredEnvironment('ASSISTANT_ACTIONS_E2E_REPORT_PATH')) @@ -45,22 +63,78 @@ const nativeCredentialId = generateId() const groupId = generateId() const enrollmentId = generateId() const runId = generateId() +const streamId = generateId() +const previousRunId = generateId() +const previousStreamId = generateId() +const previousCallId = generateId() +const lagChatId = generateId() +const lagRunId = generateId() +const lagStreamId = generateId() +const lagFirstCallId = generateId() +const lagSecondCallId = generateId() +const lateCallId = generateId() +const reviewChatId = generateId() +const reviewRunId = generateId() +const reviewStreamId = generateId() +const keyboardCallId = generateId() +const otherFocusCallId = generateId() +const navigationCallId = generateId() +const selectiveCallId = generateId() +const batchFirstCallId = generateId() +const batchSecondCallId = generateId() +const batchLateCallId = generateId() +const requestId = generateId() const firstCallId = generateId() const secondCallId = generateId() +const fixtureStreams = [ + { chatId: assistantId, streamId }, + { chatId: lagChatId, streamId: lagStreamId }, + { chatId: reviewChatId, streamId: reviewStreamId }, +] +const streamPollers: ReturnType[] = [] +const catalogAccounts = [ + { option: 'gmail', provider: 'google-email', name: 'Gmail', supported: true }, + { + option: 'google-calendar', + provider: 'google-calendar', + name: 'Google Calendar', + supported: true, + }, + { option: 'google-drive', provider: 'google-drive', name: 'Google Drive', supported: true }, + { option: 'confluence', provider: 'confluence', name: 'Confluence', supported: true }, + { + option: 'github-repositories', + provider: 'github-repositories', + name: 'GitHub', + supported: true, + }, + { option: 'google-docs', provider: 'google-docs', name: 'Google Docs', supported: true }, + { option: 'google-sheets', provider: 'google-sheets', name: 'Google Sheets', supported: true }, + { option: 'google-meet', provider: 'google-meet', name: 'Google Meet', supported: false }, +].map((account) => ({ ...account, credentialId: generateId() })) const token = generateShortId() const root = `/o/${organizationId}` const question = 'Create the two launch follow-up tickets in Jira.' const answer = 'Review the two Jira issues before I create them.' +const reviewAnswer = 'Review these keyboard and batch proposals.' const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] = [] const browserErrors: string[] = [] const serverErrors: { path: string; status: number }[] = [] +const httpResponses: { method: string; path: string; status: number }[] = [] const browser = await chromium.launch() const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } }) const page = await context.newPage() +const reviewCards = page.getByRole('region', { name: 'Review Jira Write', exact: true }) page.on('pageerror', (error) => browserErrors.push(error.message)) page.on('response', (response) => { const url = new URL(response.url()) + if (url.origin === baseUrl.origin && url.pathname.startsWith('/api/')) + httpResponses.push({ + method: response.request().method(), + path: url.pathname, + status: response.status(), + }) if (url.origin === baseUrl.origin && response.status() >= 500) serverErrors.push({ path: url.pathname, status: response.status() }) }) @@ -99,6 +173,52 @@ async function capture(name: string) { await page.screenshot({ path: resolve(artifactDirectory, `${name}.png`), animations: 'disabled' }) } +function proposalArguments(summary: string) { + return { + credential: credentialId, + domain: 'acme.atlassian.net', + projectId: 'LAUNCH', + issueType: 'Task', + summary, + description: 'Confirm the final owner and target date before the customer announcement.', + } +} + +async function insertProposal(targetRunId: string, toolCallId: string, summary: string) { + await sql`insert into copilot_async_tool_calls (run_id, tool_call_id, tool_name, executable_name, args, assistant_action, execution_retryable, permission_requested_at) + values (${targetRunId}, ${toolCallId}, 'jira_write', 'jira_write', ${sql.json(proposalArguments(summary))}, ${sql.json({ credentialId, operationVersion: 1, effect: 'write' })}, false, now())` +} + +async function appendStreamEvents( + chatId: string, + targetStreamId: string, + events: SessionStreamEvent[] +) { + for (const event of events) { + const position = await allocateCursor(targetStreamId) + const result = await appendEvents( + [createEvent({ ...event, ...position, streamId: targetStreamId, chatId, requestId })], + { streamId: targetStreamId, userId } + ) + assert.equal(result.persisted, true, 'Fixture stream exceeded its replay budget') + } +} + +function pendingToolEvent(toolCallId: string, summary: string): SessionStreamEvent { + return { + type: 'tool', + payload: { + phase: 'call', + executor: 'sim', + mode: 'async', + toolCallId, + toolName: 'jira_write', + status: 'awaiting_approval', + arguments: proposalArguments(summary), + }, + } +} + try { await mkdir(artifactDirectory, { recursive: true }) await sql.begin(async (tx) => { @@ -113,7 +233,7 @@ try { await tx`insert into session (id, token, user_id, expires_at, created_at, updated_at) values (${generateId()}, ${token}, ${userId}, now() + interval '1 day', now(), now())` await tx`insert into credential_group (id, organization_id, public_id, name, options) - values (${groupId}, ${organizationId}, ${generateId()}, 'Personal Jira', ${tx.json(['jira', 'salesforce'].map((provider) => ({ id: provider, provider, label: provider, authorizationAppId: 'fixture', requiredScopes: [], scopeVersion: 1, required: false, status: 'active' })))})` + values (${groupId}, ${organizationId}, ${generateId()}, 'Personal Jira', ${tx.json(['jira', 'salesforce', ...catalogAccounts.map((account) => account.option)].map((provider) => ({ id: provider, provider, label: provider, authorizationAppId: 'fixture', requiredScopes: [], scopeVersion: 1, required: false, status: 'active' })))})` await tx`insert into resource_policy (id, organization_id, resource_type, resource_id, document, created_by, updated_by) values (${generateId()}, ${organizationId}, 'credential_group', ${groupId}, ${tx.json({ version: 2, resource: { type: 'credential_group', id: groupId }, statements: [] })}, ${userId}, ${userId})` await tx`insert into credential_group_enrollment (id, credential_group_id, user_id, email, status, invitation_token_hash, invitation_expires_at, invited_at) @@ -122,34 +242,84 @@ try { values (${credentialId}, ${organizationId}, 'managed_oauth', ${userId}, 'taylor@acme.example', 'jira', ${enrollmentId}, 'jira', 'active', 1, '{}'::text[], ${userId}, 'fixture', 'fixture-not-a-provider-token', now())` await tx`insert into credential (id, organization_id, type, created_by, display_name, provider_id, credential_group_enrollment_id, credential_group_option_id, managed_oauth_status, managed_oauth_scope_version, granted_scopes, provider_subject_id, authorization_app_id, encrypted_oauth_token_set, granted_at) values (${nativeCredentialId}, ${organizationId}, 'managed_oauth', ${userId}, 'taylor@acme.example', 'salesforce', ${enrollmentId}, 'salesforce', 'active', 1, '{}'::text[], ${userId}, 'fixture', 'fixture-not-a-provider-token', now())` - await tx`insert into copilot_chats (id, user_id, organization_id, type, title, config) - values (${assistantId}, ${userId}, ${organizationId}, 'mothership', 'Launch follow-up', ${tx.json({ conversationMode: 'assistant' })})` - for (const [seq, role, content] of [ - [1, 'user', question], - [2, 'assistant', answer], - ] as const) { - const messageId = generateId() - await tx`insert into copilot_messages (id, chat_id, message_id, role, content, seq) - values (${generateId()}, ${assistantId}, ${messageId}, ${role}, ${tx.json({ id: messageId, role, content, timestamp: new Date().toISOString() })}, ${seq})` + for (const account of catalogAccounts) { + await tx`insert into credential (id, organization_id, type, created_by, display_name, provider_id, credential_group_enrollment_id, credential_group_option_id, managed_oauth_status, managed_oauth_scope_version, granted_scopes, provider_subject_id, authorization_app_id, encrypted_oauth_token_set, granted_at) + values (${account.credentialId}, ${organizationId}, 'managed_oauth', ${userId}, 'taylor@acme.example', ${account.provider}, ${enrollmentId}, ${account.option}, 'active', 1, '{}'::text[], ${userId}, 'fixture', 'fixture-not-a-provider-token', now())` } - await tx`insert into copilot_runs (id, chat_id, user_id, organization_id, execution_id, stream_id, tool_execution_version, status) - values (${runId}, ${assistantId}, ${userId}, ${organizationId}, ${generateId()}, ${generateId()}, 3, 'paused_waiting_for_tool')` - for (const [id, summary] of [ - [firstCallId, 'Publish the launch checklist'], - [secondCallId, 'Confirm the rollout owners'], - ] as const) { - const args = { - credential: credentialId, - domain: 'acme.atlassian.net', - projectId: 'LAUNCH', - issueType: 'Task', - summary, - description: 'Confirm the final owner and target date before the customer announcement.', - } - await tx`insert into copilot_async_tool_calls (run_id, tool_call_id, tool_name, executable_name, args, assistant_action, execution_retryable, permission_requested_at) - values (${runId}, ${id}, 'jira_write', 'jira_write', ${tx.json(args)}, ${tx.json({ credentialId, operationVersion: 1, effect: 'write' })}, false, now())` + await tx`insert into copilot_chats (id, user_id, organization_id, type, title, conversation_id, config) + values (${assistantId}, ${userId}, ${organizationId}, 'mothership', 'Launch follow-up', ${streamId}, ${tx.json({ conversationMode: 'assistant' })}), + (${lagChatId}, ${userId}, ${organizationId}, 'mothership', 'Delayed action transcript', ${lagStreamId}, ${tx.json({ conversationMode: 'assistant' })}), + (${reviewChatId}, ${userId}, ${organizationId}, 'mothership', 'Keyboard and batch review', ${reviewStreamId}, ${tx.json({ conversationMode: 'assistant' })})` + const oldResponseId = generateId() + for (const message of [ + { + id: previousStreamId, + role: 'user', + content: 'Prepare an earlier Jira issue.', + stream: previousStreamId, + }, + { + id: oldResponseId, + role: 'assistant', + content: 'This earlier request has ended.', + stream: previousStreamId, + contentBlocks: [ + { type: 'text', content: 'This earlier request has ended.' }, + { + type: 'tool', + phase: 'call', + toolCall: { + id: previousCallId, + name: 'jira_write', + state: 'awaiting_approval', + display: { title: 'Earlier Jira proposal' }, + params: proposalArguments('An old request that must not gain new actions'), + }, + }, + ], + }, + { id: streamId, role: 'user', content: question, stream: streamId }, + ].entries()) { + const [index, data] = message + const { stream, ...content } = data + await tx`insert into copilot_messages (id, chat_id, message_id, role, stream_id, content, seq) + values (${generateId()}, ${assistantId}, ${data.id}, ${data.role}, ${stream}, ${tx.json({ ...content, requestId, timestamp: new Date().toISOString() })}, ${index + 1})` } + await tx`insert into copilot_messages (id, chat_id, message_id, role, stream_id, content, seq) + values (${generateId()}, ${lagChatId}, ${lagStreamId}, 'user', ${lagStreamId}, ${tx.json({ id: lagStreamId, role: 'user', content: 'Create two more Jira issues.', requestId, timestamp: new Date().toISOString() })}, 1), + (${generateId()}, ${reviewChatId}, ${reviewStreamId}, 'user', ${reviewStreamId}, ${tx.json({ id: reviewStreamId, role: 'user', content: 'Prepare follow-up actions for review.', requestId, timestamp: new Date().toISOString() })}, 1)` + await tx`insert into copilot_runs (id, chat_id, user_id, organization_id, execution_id, stream_id, tool_execution_version, status, started_at, completed_at, tool_admission_closed_at) + values (${previousRunId}, ${assistantId}, ${userId}, ${organizationId}, ${generateId()}, ${previousStreamId}, 3, 'complete', now() - interval '1 hour', now() - interval '59 minutes', now() - interval '59 minutes')` + await tx`insert into copilot_runs (id, chat_id, user_id, organization_id, execution_id, stream_id, tool_execution_version, status) + values (${runId}, ${assistantId}, ${userId}, ${organizationId}, ${generateId()}, ${streamId}, 3, 'paused_waiting_for_tool'), + (${lagRunId}, ${lagChatId}, ${userId}, ${organizationId}, ${generateId()}, ${lagStreamId}, 3, 'paused_waiting_for_tool'), + (${reviewRunId}, ${reviewChatId}, ${userId}, ${organizationId}, ${generateId()}, ${reviewStreamId}, 3, 'paused_waiting_for_tool')` }) + await insertProposal(runId, firstCallId, 'Publish the launch checklist') + await insertProposal(runId, secondCallId, 'Confirm the rollout owners') + for (const stream of fixtureStreams) { + assert(await acquirePendingChatStream(stream.chatId, stream.streamId, 0)) + streamPollers.push( + startAbortPoller(stream.streamId, new AbortController(), { chatId: stream.chatId }) + ) + } + await appendStreamEvents(assistantId, streamId, [ + { type: 'session', payload: { kind: 'start' } }, + { type: 'text', payload: { channel: 'assistant', text: answer } }, + pendingToolEvent(firstCallId, 'Publish the launch checklist'), + pendingToolEvent(secondCallId, 'Confirm the rollout owners'), + ]) + await appendStreamEvents(lagChatId, lagStreamId, [ + { type: 'session', payload: { kind: 'start' } }, + { + type: 'text', + payload: { channel: 'assistant', text: 'I have prepared two more issues for review.' }, + }, + ]) + await appendStreamEvents(reviewChatId, reviewStreamId, [ + { type: 'session', payload: { kind: 'start' } }, + { type: 'text', payload: { channel: 'assistant', text: reviewAnswer } }, + ]) await context.addCookies([ { name: 'better-auth.session_token', @@ -164,6 +334,9 @@ try { waitUntil: 'domcontentloaded', timeout: 180_000, }) + await page.getByRole('button', { name: 'Salesforce integration actions', exact: true }).click() + await expect(page.getByRole('menuitem', { name: 'Add account', exact: true })).toHaveCount(0) + await expect(page.getByRole('menuitem', { name: 'Reconnect', exact: true })).toBeVisible() const permissions = page.locator( `a[href="${root}/integrations/accounts/${nativeCredentialId}"]` ) @@ -173,6 +346,36 @@ try { await expect(policy).toBeVisible({ timeout: 180_000 }) await expect(policy.getByRole('radio', { name: 'Ask', exact: true })).toBeChecked() }) + await check('Connected app menus agree with the real Assistant tool catalog', async () => { + await page.goto(`${baseUrl.origin}${root}/integrations`, { waitUntil: 'domcontentloaded' }) + for (const account of catalogAccounts) { + const response = await context.request.get( + `${baseUrl.origin}/api/organizations/${organizationId}/connected-accounts/personal/${account.credentialId}/tool-permissions` + ) + assert.equal(response.status(), 200, await response.text()) + const data = readAssistantToolPermissionsContract.response.schema.parse(await response.json()) + assert.equal(data.account.credentialId, account.credentialId) + assert.equal(data.tools.length > 0, account.supported, account.name) + const menu = page.getByRole('button', { + name: `${account.name} integration actions`, + exact: true, + }) + await expect(menu).toHaveCount(1) + await menu.click() + const permissions = page.getByRole('menu').locator('a[href*="/integrations/accounts/"]') + await expect(permissions).toHaveCount(account.supported ? 1 : 0) + if (account.supported) { + await expect(permissions).toHaveAttribute( + 'href', + `${root}/integrations/accounts/${account.credentialId}` + ) + } else { + await expect(permissions).toHaveCount(0) + } + await page.keyboard.press('Escape') + } + await capture('integrations-desktop') + }) await check('Personal permissions persist through the real settings route', async () => { await page.goto(`${baseUrl.origin}${root}/integrations/accounts/${credentialId}`, { waitUntil: 'domcontentloaded', @@ -195,38 +398,113 @@ try { 'Mobile permissions overflow horizontally' ) }) - await check('The Assistant groups exact proposed actions in one review', async () => { - await page.setViewportSize({ width: 1440, height: 1000 }) - await page.goto(`${baseUrl.origin}${root}/search/chat/${assistantId}`, { - waitUntil: 'domcontentloaded', - timeout: 180_000, - }) - await expect(page.getByText('2 actions to review')).toBeVisible({ timeout: 180_000 }) - await capture('assistant-actions-desktop') - await page.getByRole('button', { name: 'Review', exact: true }).click() - const modal = page.getByRole('dialog', { name: 'Review Assistant actions' }) - await expect(modal).toBeVisible() - await expect(modal.getByRole('textbox', { name: 'Project', exact: true }).first()).toHaveValue( - 'LAUNCH' - ) - await expect( - modal.getByRole('textbox', { name: 'Jira site', exact: true }).first() - ).toHaveValue('acme.atlassian.net') - await capture('action-review-desktop') - await page.setViewportSize({ width: 390, height: 844 }) - await capture('action-review-mobile') - assert( - await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), - 'Mobile review overflows horizontally' - ) - }) + await check( + 'A live Assistant turn restores exact inline actions without duplicating older rows', + async () => { + await page.setViewportSize({ width: 1440, height: 1000 }) + const streamResponse = page.waitForResponse( + (response) => + new URL(response.url()).pathname === '/api/mothership/chat/stream' && + new URL(response.url()).searchParams.get('streamId') === streamId, + { timeout: 180_000 } + ) + await page.goto(`${baseUrl.origin}${root}/search/chat/${assistantId}`, { + waitUntil: 'domcontentloaded', + timeout: 180_000, + }) + assert.equal((await streamResponse).status(), 200) + await expect(reviewCards).toHaveCount(2, { timeout: 180_000 }) + await expect(page.getByText('Earlier Jira proposal', { exact: false })).toBeAttached() + await expect(page.getByRole('button', { name: 'Allow', exact: true })).toHaveCount(2) + await expect( + reviewCards.nth(0).getByRole('textbox', { name: 'Summary', exact: true }) + ).toHaveValue('Publish the launch checklist') + await expect( + reviewCards.nth(1).getByRole('textbox', { name: 'Summary', exact: true }) + ).toHaveValue('Confirm the rollout owners') + const prompt = await page.getByText(question, { exact: true }).elementHandle() + assert(prompt, 'The current user turn must be restored') + assert( + await reviewCards.evaluateAll( + (cards, userPrompt) => + cards.every((card) => + Boolean(userPrompt.compareDocumentPosition(card) & Node.DOCUMENT_POSITION_FOLLOWING) + ), + prompt + ), + 'Current actions rendered inside an earlier turn' + ) + await prompt.dispose() + await expect(reviewCards.nth(0).getByText('LAUNCH', { exact: true })).toBeVisible() + await expect( + reviewCards.nth(0).getByText('acme.atlassian.net', { exact: true }) + ).toBeVisible() + await expect(page.getByRole('dialog', { name: 'Review Assistant actions' })).toHaveCount(0) + await expect(page.getByRole('button', { name: 'Review', exact: true })).toHaveCount(0) + const response = await context.request.get( + `${baseUrl.origin}/api/organizations/${organizationId}/chats/${assistantId}/actions` + ) + assert.equal(response.status(), 200, await response.text()) + const review = listAssistantActionsContract.response.schema.parse(await response.json()) + assert.equal(review.runId, runId) + assert.equal(review.streamId, streamId) + assert.notEqual(review.runId, requestId) + assert.deepEqual( + review.actions.map((action) => action.toolCallId), + [firstCallId, secondCallId] + ) + await page.reload() + await expect(reviewCards).toHaveCount(2) + await expect( + reviewCards.nth(0).getByRole('textbox', { name: 'Summary', exact: true }) + ).toHaveValue('Publish the launch checklist') + await page.getByText(question, { exact: true }).scrollIntoViewIfNeeded() + await capture('assistant-actions-desktop') + await reviewCards.nth(0).scrollIntoViewIfNeeded() + await capture('action-review-desktop') + await page.setViewportSize({ width: 390, height: 844 }) + await reviewCards.nth(0).scrollIntoViewIfNeeded() + await capture('action-review-mobile') + assert( + await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), + 'Mobile review overflows horizontally' + ) + } + ) + await check( + 'An inactive account prevents approval but keeps cancellation reachable', + async () => { + await sql`update credential set managed_oauth_status = 'needs_reauth' where id = ${credentialId}` + try { + await page.reload() + await expect(reviewCards).toHaveCount(2) + await expect( + reviewCards.nth(0).getByRole('button', { name: 'Allow', exact: true }) + ).toBeDisabled() + await expect( + reviewCards.nth(1).getByRole('button', { name: 'Allow', exact: true }) + ).toBeDisabled() + await expect( + reviewCards.nth(0).getByRole('button', { name: 'Cancel', exact: true }) + ).toBeEnabled() + await expect( + page.getByRole('button', { name: 'Allow 2 shown', exact: true }) + ).toBeDisabled() + } finally { + await sql`update credential set managed_oauth_status = 'active' where id = ${credentialId}` + } + await page.reload() + await expect( + reviewCards.nth(0).getByRole('button', { name: 'Allow', exact: true }) + ).toBeEnabled() + } + ) await check( 'An edited approval and a cancellation affect only their saved proposals', async () => { - const modal = page.getByRole('dialog', { name: 'Review Assistant actions' }) - await modal + await reviewCards + .nth(0) .getByRole('textbox', { name: 'Summary', exact: true }) - .first() .fill('Publish the reviewed launch checklist') const approval = page.waitForResponse( (response) => @@ -234,20 +512,26 @@ try { response.url().endsWith(`/actions/${firstCallId}`), { timeout: 180_000 } ) - await modal.getByRole('button', { name: 'Allow', exact: true }).first().click() + await reviewCards.nth(0).getByRole('button', { name: 'Allow', exact: true }).click() const approvalResponse = await approval assert.equal(approvalResponse.status(), 200, await approvalResponse.text()) - await expect(modal.getByRole('textbox', { name: 'Summary', exact: true })).toHaveCount(1) + await expect(reviewCards).toHaveCount(1) + await expect(reviewCards.getByRole('textbox', { name: 'Summary', exact: true })).toHaveValue( + 'Confirm the rollout owners' + ) + await expect( + page.getByText('No longer waiting for review', { exact: false }).first() + ).toBeVisible() const cancellation = page.waitForResponse( (response) => response.request().method() === 'PATCH' && response.url().endsWith(`/actions/${secondCallId}`), { timeout: 180_000 } ) - await modal.getByRole('button', { name: 'Cancel', exact: true }).click() + await reviewCards.getByRole('button', { name: 'Cancel', exact: true }).click() const cancellationResponse = await cancellation assert.equal(cancellationResponse.status(), 200, await cancellationResponse.text()) - await expect(modal.getByText('No actions are waiting for review.')).toBeVisible() + await expect(reviewCards).toHaveCount(0) const rows = await sql`select tool_call_id, permission_decision, args, approved_args, execution_started_at from copilot_async_tool_calls where run_id = ${runId} order by persist_seq` assert.equal(rows[0]?.permission_decision, 'allow') @@ -263,6 +547,360 @@ try { ) } ) + await check( + 'Review recovers when an empty result becomes unavailable before the tool transcript arrives', + async () => { + const reviewUrl = `${baseUrl.origin}/api/organizations/${organizationId}/chats/${lagChatId}/actions` + const initialReview = page.waitForResponse( + (response) => response.request().method() === 'GET' && response.url() === reviewUrl + ) + await page.goto(`${baseUrl.origin}${root}/search/chat/${lagChatId}`, { + waitUntil: 'domcontentloaded', + timeout: 180_000, + }) + const initialResponse = await initialReview + assert.equal(initialResponse.status(), 200) + const initialData = listAssistantActionsContract.response.schema.parse( + await initialResponse.json() + ) + assert.equal(initialData.runId, lagRunId) + assert.equal(initialData.streamId, lagStreamId) + assert.deepEqual(initialData.actions, []) + await insertProposal(lagRunId, lagFirstCallId, 'Review the support handoff') + await insertProposal(lagRunId, lagSecondCallId, 'Prepare the launch announcement') + await page.route(`${reviewUrl}*`, (route) => + route.request().method() === 'GET' ? route.abort('failed') : route.continue() + ) + try { + await expect(page.getByRole('button', { name: 'Retry', exact: true })).toBeVisible({ + timeout: 15_000, + }) + await expect(reviewCards).toHaveCount(0) + } finally { + await page.unroute(`${reviewUrl}*`) + } + await page.getByRole('button', { name: 'Retry', exact: true }).click() + await expect(reviewCards).toHaveCount(2) + await expect( + reviewCards.nth(0).getByRole('textbox', { name: 'Summary', exact: true }) + ).toHaveValue('Review the support handoff') + await expect( + reviewCards.nth(1).getByRole('textbox', { name: 'Summary', exact: true }) + ).toHaveValue('Prepare the launch announcement') + await appendStreamEvents(lagChatId, lagStreamId, [ + pendingToolEvent(lagFirstCallId, 'Review the support handoff'), + pendingToolEvent(lagSecondCallId, 'Prepare the launch announcement'), + { + type: 'text', + payload: { channel: 'assistant', text: '\nThe action transcript has caught up.' }, + }, + ]) + await expect( + page.getByText('The action transcript has caught up.', { exact: false }) + ).toBeVisible() + await expect(reviewCards).toHaveCount(2) + await page.reload() + await expect(reviewCards).toHaveCount(2) + } + ) + await check( + 'A bulk approval keeps its clicked snapshot when another action arrives', + async () => { + const releaseFirstDecision = createDeferred() + let firstDecisionRequested = false + const firstActionUrl = `${baseUrl.origin}/api/organizations/${organizationId}/chats/${lagChatId}/actions/${lagFirstCallId}` + await page.route(firstActionUrl, async (route) => { + firstDecisionRequested = true + await releaseFirstDecision.promise + await route.continue() + }) + try { + await page.getByRole('button', { name: 'Allow 2 shown', exact: true }).click() + await expect.poll(() => firstDecisionRequested).toBe(true) + await insertProposal(lagRunId, lateCallId, 'A later proposal that was never approved') + await appendStreamEvents(lagChatId, lagStreamId, [ + pendingToolEvent(lateCallId, 'A later proposal that was never approved'), + ]) + await expect(reviewCards).toHaveCount(3) + releaseFirstDecision.resolve() + await expect + .poll(async () => { + const rows = + await sql`select tool_call_id, permission_decision from copilot_async_tool_calls where run_id = ${lagRunId} order by persist_seq` + return rows.map((row) => [row.tool_call_id, row.permission_decision]) + }) + .toEqual([ + [lagFirstCallId, 'allow'], + [lagSecondCallId, 'allow'], + [lateCallId, null], + ]) + await expect(reviewCards).toHaveCount(1) + await expect( + reviewCards.getByRole('textbox', { name: 'Summary', exact: true }) + ).toHaveValue('A later proposal that was never approved') + const cancellation = page.waitForResponse( + (response) => + response.request().method() === 'PATCH' && + response.url().endsWith(`/actions/${lateCallId}`) + ) + await reviewCards.getByRole('button', { name: 'Cancel', exact: true }).click() + assert.equal((await cancellation).status(), 200) + await expect(reviewCards).toHaveCount(0) + } finally { + releaseFirstDecision.resolve() + await page.unroute(firstActionUrl) + } + const rows = + await sql`select execution_started_at from copilot_async_tool_calls where run_id in (${runId}, ${lagRunId})` + assert( + rows.every((row) => row.execution_started_at === null), + 'A fixture action executed' + ) + } + ) + await check( + 'A failed keyboard decision keeps focus and leaves the proposal pending', + async () => { + for (const [id, summary] of [ + [keyboardCallId, 'Approve with the keyboard'], + [otherFocusCallId, 'Keep focus in another field'], + [navigationCallId, 'Leave this conversation during a decision'], + ] as const) { + await insertProposal(reviewRunId, id, summary) + await appendStreamEvents(reviewChatId, reviewStreamId, [pendingToolEvent(id, summary)]) + } + await page.goto(`${baseUrl.origin}${root}/search/chat/${reviewChatId}`) + await expect(reviewCards).toHaveCount(3) + const actionUrl = `${baseUrl.origin}/api/organizations/${organizationId}/chats/${reviewChatId}/actions/${keyboardCallId}` + await page.route( + actionUrl, + (route) => + route.fulfill({ + status: 409, + contentType: 'application/json', + body: JSON.stringify({ error: 'The review could not be saved. Try again.' }), + }), + { times: 1 } + ) + const response = page.waitForResponse( + (response) => response.url() === actionUrl && response.request().method() === 'PATCH' + ) + const allow = reviewCards.nth(0).getByRole('button', { name: 'Allow', exact: true }) + await allow.focus() + await page.keyboard.press('Enter') + assert.equal((await response).status(), 409) + await expect(reviewCards.nth(0).getByRole('alert')).toBeVisible() + await expect(allow).toBeEnabled() + await expect(allow).toBeFocused() + const [row] = + await sql`select permission_decision, execution_started_at from copilot_async_tool_calls where tool_call_id = ${keyboardCallId}` + assert.equal(row?.permission_decision, null) + assert.equal(row?.execution_started_at, null) + } + ) + await check( + 'A completed keyboard decision restores focus without taking it from another field', + async () => { + const response = page.waitForResponse( + (response) => + response.url().endsWith(`/actions/${keyboardCallId}`) && + response.request().method() === 'PATCH' + ) + await reviewCards.nth(0).getByRole('button', { name: 'Allow', exact: true }).focus() + await page.keyboard.press('Enter') + assert.equal((await response).status(), 200) + await expect(reviewCards).toHaveCount(2) + await expect( + page + .getByRole('group', { name: 'Conversation turn', exact: true }) + .filter({ has: page.getByText(reviewAnswer, { exact: true }) }) + ).toBeFocused() + + const releaseDecision = createDeferred() + let decisionRequested = false + const actionUrl = `${baseUrl.origin}/api/organizations/${organizationId}/chats/${reviewChatId}/actions/${otherFocusCallId}` + await page.route(actionUrl, async (route) => { + decisionRequested = true + await releaseDecision.promise + await route.continue() + }) + try { + const cancellation = page.waitForResponse( + (response) => response.url() === actionUrl && response.request().method() === 'PATCH' + ) + await reviewCards.nth(0).getByRole('button', { name: 'Cancel', exact: true }).focus() + await page.keyboard.press('Enter') + await expect.poll(() => decisionRequested).toBe(true) + const composer = page.getByRole('textbox', { name: 'Ask Sim', exact: true }) + await composer.fill('Keep my focus here') + releaseDecision.resolve() + assert.equal((await cancellation).status(), 200) + await expect(reviewCards).toHaveCount(1) + await expect(composer).toBeFocused() + } finally { + releaseDecision.resolve() + await page.unroute(actionUrl) + } + } + ) + for (const intent of ['focus', 'pointer'] as const) { + await check( + `A completed review respects ${intent} movement away from its control`, + async () => { + const toolCallId = generateId() + const summary = `Preserve the user's ${intent} intent` + await insertProposal(reviewRunId, toolCallId, summary) + await appendStreamEvents(reviewChatId, reviewStreamId, [ + pendingToolEvent(toolCallId, summary), + ]) + await expect(reviewCards).toHaveCount(2) + const releaseDecision = createDeferred() + let decisionRequested = false + const actionUrl = `${baseUrl.origin}/api/organizations/${organizationId}/chats/${reviewChatId}/actions/${toolCallId}` + await page.route(actionUrl, async (route) => { + decisionRequested = true + await releaseDecision.promise + await route.continue() + }) + try { + await reviewCards.last().getByRole('button', { name: 'Cancel', exact: true }).focus() + await page.keyboard.press('Enter') + await expect.poll(() => decisionRequested).toBe(true) + if (intent === 'focus') { + const composer = page.getByRole('textbox', { name: 'Ask Sim', exact: true }) + await composer.fill('Continue elsewhere') + await expect(composer).toBeFocused() + await composer.evaluate((element) => { + if (element instanceof HTMLElement) element.blur() + }) + } else { + await page.locator('body').click({ position: { x: 1, y: 1 } }) + } + await expect(page.locator('body')).toBeFocused() + const response = page.waitForResponse( + (response) => response.url() === actionUrl && response.request().method() === 'PATCH' + ) + releaseDecision.resolve() + assert.equal((await response).status(), 200) + await expect(reviewCards).toHaveCount(1) + await expect(page.locator('body')).toBeFocused() + const [row] = + await sql`select permission_decision, execution_started_at from copilot_async_tool_calls where tool_call_id = ${toolCallId}` + assert.equal(row?.permission_decision, 'skip') + assert.equal(row?.execution_started_at, null) + } finally { + releaseDecision.resolve() + await page.unroute(actionUrl) + } + } + ) + } + await check( + 'A decision completing after navigation does not take focus from Search', + async () => { + let decisionPersisted = false + const releaseResponse = createDeferred() + let responseReleased = false + const actionUrl = `${baseUrl.origin}/api/organizations/${organizationId}/chats/${reviewChatId}/actions/${navigationCallId}` + await page.route(actionUrl, async (route) => { + const response = await route.fetch() + assert.equal(response.status(), 200) + decisionPersisted = true + await releaseResponse.promise + try { + await route.fulfill({ response }) + } finally { + responseReleased = true + } + }) + try { + await reviewCards.getByRole('button', { name: 'Cancel', exact: true }).focus() + await page.keyboard.press('Enter') + await expect.poll(() => decisionPersisted).toBe(true) + await page.goto(`${baseUrl.origin}${root}/search`) + const search = page.getByRole('textbox', { name: 'Search your sources', exact: true }) + await search.fill('Continue searching') + releaseResponse.resolve() + await expect.poll(() => responseReleased).toBe(true) + await expect(search).toBeFocused() + const [row] = + await sql`select permission_decision, execution_started_at from copilot_async_tool_calls where tool_call_id = ${navigationCallId}` + assert.equal(row?.permission_decision, 'skip') + assert.equal(row?.execution_started_at, null) + } finally { + releaseResponse.resolve() + await page.unroute(actionUrl) + } + } + ) + await check('Cancel all skips its displayed snapshot after a selective approval', async () => { + for (const [id, summary] of [ + [selectiveCallId, 'Allow this proposal separately'], + [batchFirstCallId, 'Cancel the first pending proposal'], + [batchSecondCallId, 'Cancel the second pending proposal'], + ] as const) { + await insertProposal(reviewRunId, id, summary) + await appendStreamEvents(reviewChatId, reviewStreamId, [pendingToolEvent(id, summary)]) + } + await page.goto(`${baseUrl.origin}${root}/search/chat/${reviewChatId}`) + await expect(reviewCards).toHaveCount(3) + const approval = page.waitForResponse( + (response) => + response.url().endsWith(`/actions/${selectiveCallId}`) && + response.request().method() === 'PATCH' + ) + await reviewCards.nth(0).getByRole('button', { name: 'Allow', exact: true }).click() + assert.equal((await approval).status(), 200) + await expect(reviewCards).toHaveCount(2) + const releaseFirstDecision = createDeferred() + let firstDecisionRequested = false + const firstActionUrl = `${baseUrl.origin}/api/organizations/${organizationId}/chats/${reviewChatId}/actions/${batchFirstCallId}` + await page.route(firstActionUrl, async (route) => { + firstDecisionRequested = true + await releaseFirstDecision.promise + await route.continue() + }) + try { + await page.getByRole('button', { name: 'Cancel 2 shown', exact: true }).click() + await expect.poll(() => firstDecisionRequested).toBe(true) + await insertProposal(reviewRunId, batchLateCallId, 'This later proposal was not canceled') + await appendStreamEvents(reviewChatId, reviewStreamId, [ + pendingToolEvent(batchLateCallId, 'This later proposal was not canceled'), + ]) + await expect(reviewCards).toHaveCount(3) + releaseFirstDecision.resolve() + await expect + .poll(async () => { + const rows = + await sql`select tool_call_id, permission_decision from copilot_async_tool_calls where tool_call_id in (${selectiveCallId}, ${batchFirstCallId}, ${batchSecondCallId}, ${batchLateCallId}) order by persist_seq` + return rows.map((row) => [row.tool_call_id, row.permission_decision]) + }) + .toEqual([ + [selectiveCallId, 'allow'], + [batchFirstCallId, 'skip'], + [batchSecondCallId, 'skip'], + [batchLateCallId, null], + ]) + await expect(reviewCards).toHaveCount(1) + const cancellation = page.waitForResponse( + (response) => + response.url().endsWith(`/actions/${batchLateCallId}`) && + response.request().method() === 'PATCH' + ) + await reviewCards.getByRole('button', { name: 'Cancel', exact: true }).click() + assert.equal((await cancellation).status(), 200) + await expect(reviewCards).toHaveCount(0) + } finally { + releaseFirstDecision.resolve() + await page.unroute(firstActionUrl) + } + const rows = + await sql`select execution_started_at from copilot_async_tool_calls where run_id = ${reviewRunId}` + assert( + rows.every((row) => row.execution_started_at === null), + 'A fixture action executed' + ) + }) await check('An organization URL cannot expose a different organization’s review', async () => { const response = await context.request.get( `${baseUrl.origin}/api/organizations/${otherOrganizationId}/chats/${assistantId}/actions` @@ -279,11 +917,15 @@ try { process.exitCode = 1 } finally { await browser.close() + for (const poller of streamPollers) clearInterval(poller) try { + for (const stream of fixtureStreams) + await releasePendingChatStream(stream.chatId, stream.streamId) await sql`delete from workspace where owner_id = ${userId}` await sql`delete from project where owner_id = ${userId}` await sql`delete from organization where id in (${organizationId},${otherOrganizationId})` await sql`delete from "user" where id = ${userId}` + for (const stream of fixtureStreams) await clearBuffer(stream.streamId) } catch (error) { checks.push({ name: 'Fixture cleanup', @@ -294,10 +936,15 @@ try { process.exitCode = 1 } finally { await sql.end() + await closeRedisConnection() } await mkdir(dirname(reportPath), { recursive: true }) await writeFile( reportPath, - JSON.stringify({ checks, browserErrors, serverErrors, artifactDirectory }, null, 2) + JSON.stringify( + { checks, browserErrors, serverErrors, httpResponses, artifactDirectory }, + null, + 2 + ) ) } diff --git a/apps/sim/scripts/test-organization-search-e2e.ts b/apps/sim/scripts/test-organization-search-e2e.ts index 8fb719c13f6..48b456d5e99 100644 --- a/apps/sim/scripts/test-organization-search-e2e.ts +++ b/apps/sim/scripts/test-organization-search-e2e.ts @@ -1,12 +1,14 @@ import assert from 'node:assert/strict' import { mkdir, writeFile } from 'node:fs/promises' import { dirname, resolve } from 'node:path' -import { chromium, expect } from '@playwright/test' +import { type BrowserContext, chromium, expect } from '@playwright/test' import { assertDisposableTestDatabaseUrl } from '@sim/db/testing/test-infrastructure' import { createLogger } from '@sim/logger' +import { toStringOrNull } from '@sim/utils/coerce' import { getErrorMessage } from '@sim/utils/errors' import { generateId, generateShortId } from '@sim/utils/id' import { toRecord } from '@sim/utils/object' +import { truncate } from '@sim/utils/string' import { makeSignature } from 'better-auth/crypto' import postgres from 'postgres' @@ -48,13 +50,147 @@ const question = 'What is the latest on the product launch?' const answer = 'The launch checklist is complete and ready for approval.' const checks: { name: string; status: 'passed' | 'failed'; durationMs: number; error?: string }[] = [] +const DIAGNOSTIC_LIMITS = { samples: 40, text: 2000, stack: 8000, templates: 8 } as const +interface BrowserErrorSample { + stage: string + url: string + message: string + stack: string + digest: string | null + templates: { digest: string | null; message: string | null }[] + count: number +} const browserErrors: string[] = [] +const browserErrorSamples: BrowserErrorSample[] = [] +const browserErrorEvents: BrowserErrorSample[] = [] +const documentResponses: { + stage: string + url: string + status: number + headers: Record +}[] = [] +let browserErrorCount = 0 +let browserErrorEventCount = 0 +let currentCheck = 'Setup' + +function recordErrorSample( + samples: BrowserErrorSample[], + sample: Omit +) { + const previous = samples.find( + (entry) => + entry.stage === sample.stage && + entry.url === sample.url && + entry.message === sample.message && + entry.stack === sample.stack && + entry.digest === sample.digest + ) + if (previous) previous.count += 1 + else if (samples.length < DIAGNOSTIC_LIMITS.samples) samples.push({ ...sample, count: 1 }) +} + +async function observeBrowserContext(observedContext: BrowserContext) { + await observedContext.exposeBinding( + 'recordOrganizationSearchError', + (_source, payload: unknown) => { + const event = toRecord(payload) + browserErrorEventCount += 1 + recordErrorSample(browserErrorEvents, { + stage: currentCheck, + url: truncate(toStringOrNull(event.url) ?? '', DIAGNOSTIC_LIMITS.text), + message: truncate(toStringOrNull(event.message) ?? '', DIAGNOSTIC_LIMITS.text), + stack: truncate(toStringOrNull(event.stack) ?? '', DIAGNOSTIC_LIMITS.stack), + digest: toStringOrNull(event.digest)?.slice(0, DIAGNOSTIC_LIMITS.text) ?? null, + templates: Array.isArray(event.templates) + ? event.templates.slice(0, DIAGNOSTIC_LIMITS.templates).map((value: unknown) => { + const template = toRecord(value) + return { + digest: toStringOrNull(template.digest)?.slice(0, DIAGNOSTIC_LIMITS.text) ?? null, + message: toStringOrNull(template.message)?.slice(0, DIAGNOSTIC_LIMITS.text) ?? null, + } + }) + : [], + }) + } + ) + await observedContext.addInitScript((limits) => { + const report = ( + window as typeof window & { + recordOrganizationSearchError: (sample: unknown) => Promise + } + ).recordOrganizationSearchError + window.addEventListener('error', (event) => { + const message: unknown = event.error?.message + const stack: unknown = event.error?.stack + const digest: unknown = event.error?.digest + void report({ + url: location.href.slice(0, limits.text), + message: (typeof message === 'string' ? message : event.message).slice(0, limits.text), + stack: typeof stack === 'string' ? stack.slice(0, limits.stack) : '', + digest: typeof digest === 'string' ? digest.slice(0, limits.text) : null, + templates: Array.from(document.querySelectorAll('template[data-dgst], template[data-msg]')) + .slice(0, limits.templates) + .map((template) => ({ + digest: template.getAttribute('data-dgst')?.slice(0, limits.text) ?? null, + message: template.getAttribute('data-msg')?.slice(0, limits.text) ?? null, + })), + }).catch(() => {}) + }) + }, DIAGNOSTIC_LIMITS) + observedContext.on('page', (observedPage) => { + observedPage.on('pageerror', (error) => { + browserErrorCount += 1 + const message = truncate(error.message, DIAGNOSTIC_LIMITS.text) + if (!browserErrors.includes(message) && browserErrors.length < DIAGNOSTIC_LIMITS.samples) { + browserErrors.push(message) + } + recordErrorSample(browserErrorSamples, { + stage: currentCheck, + url: truncate(observedPage.url(), DIAGNOSTIC_LIMITS.text), + message, + stack: truncate(error.stack ?? '', DIAGNOSTIC_LIMITS.stack), + digest: null, + templates: [], + }) + }) + observedPage.on('response', (response) => { + if ( + response.request().resourceType() !== 'document' || + response.frame() !== observedPage.mainFrame() || + documentResponses.length >= DIAGNOSTIC_LIMITS.samples + ) { + return + } + const headers: Record = {} + for (const name of [ + 'content-type', + 'cache-control', + 'x-nextjs-cache', + 'x-nextjs-prerender', + 'x-nextjs-stale-time', + 'x-nextjs-postponed', + 'x-matched-path', + ]) { + const value = response.headers()[name] + if (value) headers[name] = truncate(value, DIAGNOSTIC_LIMITS.text) + } + documentResponses.push({ + stage: currentCheck, + url: truncate(response.url(), DIAGNOSTIC_LIMITS.text), + status: response.status(), + headers, + }) + }) + }) +} + const browser = await chromium.launch() const context = await browser.newContext({ viewport: { width: 1440, height: 1000 } }) +await observeBrowserContext(context) const page = await context.newPage() -page.on('pageerror', (error) => browserErrors.push(error.message)) async function check(name: string, run: () => Promise) { + currentCheck = name const started = performance.now() try { await run() @@ -83,9 +219,9 @@ async function verifyLegacyDraft( existingDraft = false ) { const legacyContext = await browser.newContext() + await observeBrowserContext(legacyContext) await legacyContext.addCookies(await context.cookies()) const legacyPage = await legacyContext.newPage() - legacyPage.on('pageerror', (error) => browserErrors.push(error.message)) const prefix = `${userId}:organization:${organizationId}:` const draftKey = `${prefix}${destination === 'assistant' ? 'search-assistant' : 'home'}` const attachment = { @@ -261,8 +397,9 @@ try { assert.equal(body.organizationId, organizationId) assert.equal(body.query, 'Launch notes') await expect(page.getByText('Search found no results.', { exact: true })).toBeVisible() - assert.equal(new URL(page.url()).pathname, `${root}/search`) - assert.equal(new URL(page.url()).searchParams.get('q'), 'Launch notes') + await expect(page).toHaveURL( + (url) => url.pathname === `${root}/search` && url.searchParams.get('q') === 'Launch notes' + ) await capture('search-results-desktop') } ) @@ -350,6 +487,7 @@ try { await capture('failure').catch(() => {}) process.exitCode = 1 } finally { + currentCheck = 'Teardown' await browser.close() try { await sql.begin(async (tx) => { @@ -370,5 +508,21 @@ try { await sql.end() } await mkdir(dirname(reportPath), { recursive: true }) - await writeFile(reportPath, JSON.stringify({ checks, browserErrors, artifactDirectory }, null, 2)) + await writeFile( + reportPath, + JSON.stringify( + { + checks, + browserErrors, + browserErrorCount, + browserErrorSamples, + browserErrorEventCount, + browserErrorEvents, + documentResponses, + artifactDirectory, + }, + null, + 2 + ) + ) }